diff --git a/.github/workflows/terminal.yml b/.github/workflows/terminal.yml new file mode 100644 index 0000000..f379cda --- /dev/null +++ b/.github/workflows/terminal.yml @@ -0,0 +1,185 @@ +# 终端组件(docs/terminal)的 CI:原生库(libghostty-vt、libtcodepty)→ 三平台测试 → AOT 发布与自检。 +# 原生库在 Linux 上用 zig 交叉编译 linux / windows 目标;macOS 目标需要 Apple SDK,在 macOS runner 上构建。 +name: terminal + +on: + push: + branches: [main] + paths: [ "src/TCode.Terminal.**", "tests/TCode.Terminal.**", "native/**", "tools/terminal/**", ".github/workflows/terminal.yml", "Directory.*.props" ] + pull_request: + paths: [ "src/TCode.Terminal.**", "tests/TCode.Terminal.**", "native/**", "tools/terminal/**", ".github/workflows/terminal.yml", "Directory.*.props" ] + workflow_dispatch: + # 每晚:PTY 压力测试按文档规模(03 §14 T10–T14),差分测试扫 200 个种子,结构化 fuzz 跑 20 分钟 + schedule: + - cron: "17 19 * * *" + +permissions: + contents: read + +env: + DOTNET_NOLOGO: "1" + DOTNET_CLI_TELEMETRY_OPTOUT: "1" + +jobs: + native: + name: native (${{ matrix.host }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - host: linux + os: ubuntu-24.04 + rids: linux-x64 linux-arm64 win-x64 win-arm64 + pty_rids: linux-x64 linux-arm64 + - host: macos + os: macos-15 + rids: osx-arm64 osx-x64 + pty_rids: osx-arm64 osx-x64 + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache-global + steps: + - uses: actions/checkout@v4 + - name: Install llvm-strip + if: matrix.host == 'linux' + run: sudo apt-get update -q && sudo apt-get install -y -q llvm + - name: Cache Zig toolchain and packages + uses: actions/cache@v4 + with: + path: | + ~/.cache/tcode/zig + ${{ github.workspace }}/.zig-cache-global + key: zig-${{ matrix.host }}-${{ hashFiles('native/toolchain.json', 'native/ghostty/ghostty-upstream.json') }} + - name: Build libghostty-vt + run: for rid in ${{ matrix.rids }}; do tools/terminal/build-ghostty.sh "$rid"; done + - name: Build libtcodepty and spawn helper + run: for rid in ${{ matrix.pty_rids }}; do native/tcodepty/build.sh "$rid"; done + - name: Check Linux glibc floor (2.28) + if: matrix.host == 'linux' + run: | + for f in artifacts/native/linux-*/lib*.so; do + max=$(objdump -T "$f" | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1) + echo "$f requires $max" + [ "$(printf '%s\nGLIBC_2.28\n' "$max" | sort -V | tail -1)" = "GLIBC_2.28" ] + done + - uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.host }} + path: | + artifacts/native/*/*ghostty-vt.* + artifacts/native/*/*tcodepty.* + artifacts/native/*/tcode-pty-spawn + artifacts/native/*/ghostty-commit.txt + if-no-files-found: error + + test: + name: test (${{ matrix.rid }}) + needs: native + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + rid: linux-x64 + - os: macos-15 + rid: osx-arm64 + - os: windows-2022 + rid: win-x64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - uses: actions/download-artifact@v4 + with: + pattern: native-* + path: artifacts/native + merge-multiple: true + - name: Restore executable bits + if: runner.os != 'Windows' + run: chmod +x artifacts/native/*/tcode-pty-spawn + - name: Install vim (self-test exercises a full-screen program) + if: runner.os == 'Linux' + run: sudo apt-get install -y -q vim + - name: Test core + run: dotnet test tests/TCode.Terminal.Core.Tests + env: + TCODE_DIFF_SEEDS: ${{ github.event_name == 'schedule' && '200' || '' }} + TCODE_FUZZ_MINUTES: ${{ github.event_name == 'schedule' && '20' || '' }} + - name: Test PTY + run: dotnet test tests/TCode.Terminal.Pty.Tests --logger "console;verbosity=normal" + env: + TCODE_PTY_STRESS: ${{ github.event_name == 'schedule' && '1' || '' }} + - name: Test protocol + run: dotnet test tests/TCode.Terminal.Protocol.Tests + - name: Test pty host (end to end) + run: dotnet test tests/TCode.Terminal.Host.Tests + - name: Test view (headless snapshots) + run: dotnet test tests/TCode.Terminal.Avalonia.Tests + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results-${{ matrix.rid }} + path: artifacts/test-results/ + if-no-files-found: ignore + - uses: actions/upload-artifact@v4 + if: always() + with: + name: snapshots-${{ matrix.rid }} + path: tests/TCode.Terminal.Avalonia.Tests/bin/Debug/net10.0/snapshots/ + if-no-files-found: ignore + - name: AOT publish tcode-ptyhost (0 IL warnings) + shell: bash + run: | + dotnet publish src/TCode.Terminal.Host -c Release -r ${{ matrix.rid }} -o artifacts/publish/ptyhost 2>&1 | tee publish.log + if grep -E 'warning IL[0-9]{4}' publish.log; then echo "::error::AOT/trim warnings"; exit 1; fi + - name: Check ConPTY publish layout (03 §14 W-T6) + if: runner.os == 'Windows' + shell: bash + run: | + d=artifacts/publish/ptyhost + need="conpty.dll arm64/OpenConsole.exe" + [ "${{ matrix.rid }}" = win-x64 ] && need="$need x64/OpenConsole.exe" + for f in $need; do [ -f "$d/$f" ] || { echo "::error::missing $f"; exit 1; }; done + - name: Discovery tests against the AOT host (04 §15) + shell: bash + run: dotnet test tests/TCode.Terminal.Host.Tests --filter "FullyQualifiedName~DiscoveryTests" + env: + TCODE_PTYHOST_DIR: ${{ github.workspace }}/artifacts/publish/ptyhost + - name: Run AOT self-test + shell: bash + run: artifacts/publish/ptyhost/tcode-ptyhost --self-test + + # esctest2(11 §1.1.4):真实 PTY + 一致性测试模式的核心。新增失败(单独重跑仍失败)阻断;已知失败见 known-failures.txt + esctest: + name: esctest2 (linux-x64) + needs: native + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - uses: actions/download-artifact@v4 + with: + pattern: native-* + path: artifacts/native + merge-multiple: true + - name: Restore executable bits + run: chmod +x artifacts/native/*/tcode-pty-spawn + - name: Fetch esctest2 (pinned, sha256 checked) + id: fetch + run: echo "dir=$(tools/terminal/fetch-esctest.sh)" >> "$GITHUB_OUTPUT" + - name: Run esctest2 + run: | + dotnet run --project tools/terminal/EscTestRunner -c Release -- \ + --esctest "${{ steps.fetch.outputs.dir }}" \ + --known tests/TCode.Terminal.Core.Tests/EscTest/known-failures.txt \ + --log artifacts/esctest.log + - uses: actions/upload-artifact@v4 + if: always() + with: + name: esctest-log + path: artifacts/esctest.log* + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index b2e30e2..ed4ff10 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ obj/ .idea/ *.user .DS_Store + +# 终端原生构建与上游源码(tools/terminal/*.sh) +artifacts/ + +# StreamMark 本地包(tools/streammark/pack.sh 生成) +packages/streammark/*.nupkg diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d8089df --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/StreamMark"] + path = external/StreamMark + url = https://github.com/239573049/StreamMark.git diff --git a/Directory.Packages.props b/Directory.Packages.props index 0458289..0a164a8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,8 @@ + + diff --git a/TCode.slnx b/TCode.slnx index 07e32f5..9d8e7c6 100644 --- a/TCode.slnx +++ b/TCode.slnx @@ -8,6 +8,11 @@ + + + + + @@ -15,5 +20,10 @@ + + + + + diff --git a/docs/terminal/00-current-state.md b/docs/terminal/00-current-state.md new file mode 100644 index 0000000..eb26ac2 --- /dev/null +++ b/docs/terminal/00-current-state.md @@ -0,0 +1,56 @@ +# TCode 终端:现状分析 + +> 日期:2026-10-16。本文只描述仓库里已有的东西和缺口,不做选型;选型依据见 [research/](research/)。 + +## 1. 结论 + +TCode 目前**没有终端**: + +- 没有 PTY; +- 没有 VT 解析或屏幕模型; +- 没有渲染控件; +- 没有终端配置。 + +能看到的只有工作台"+"菜单里的一个"终端"占位标签。和终端相关的现有代码都属于 Agent 的**非交互**命令执行(`shell -c`、重定向管道),它和交互式终端是两条不同的路径,不能直接复用为终端。 + +## 2. 已有代码清单 + +| 位置 | 内容 | 与终端的关系 | +|---|---|---| +| [WorkbenchViewModel.cs](../../src/TCode.Desktop/ViewModels/WorkbenchViewModel.cs) | `WorkbenchTabKind.Terminal`;`AddTool` 用 `PlaceholderTabViewModel` 打开占位标签 | `AddTool` 按 Kind 查找已有标签,**同一种工具只能开一个标签**。多终端需要改成按实例管理 | +| [MainWindow.axaml:692](../../src/TCode.Desktop/Views/MainWindow.axaml#L692) | `PlaceholderTabViewModel` 的 DataTemplate:图标 + 标题 + 描述 + "即将推出" | 终端控件接入后,换成专门的 `TerminalTabViewModel` 模板 | +| [strings.tsv:78](../../src/TCode.Desktop/Localization/strings.tsv#L78) | `TerminalDescription`:"终端将基于 Iciclecreek.Avalonia.Terminal(XTerm.NET + Porta.Pty)实现,并与 Agent 的 bash 工具共享会话" | 早期设想,未经调研,需要按最终选型改写 | +| [architecture.md §8](../architecture.md) | 路线图:"终端(Iciclecreek.Avalonia.Terminal)" | 同上 | +| [MainWindowSnapshotTests.cs:123](../../tests/TCode.Desktop.Tests/MainWindowSnapshotTests.cs#L123) | 截图测试会打开终端占位标签 | 终端控件接入后需要专门的截图测试 | +| [ShellResolver.cs](../../src/TCode.Agent.Coding/Tools/Shell/ShellResolver.cs) | Agent 用的 shell:Unix 取 `$SHELL`,但**只接受 bash / zsh**,否则 `/bin/bash` → `/bin/sh`;Windows 依次是 Git Bash → pwsh → Windows PowerShell,并带 `-NoProfile -NonInteractive` | 这套选择是为"让模型稳定执行命令"设计的,和"与系统终端一致"的要求相反:fish / nushell 用户会被换成 bash,Windows 默认也不是 PowerShell。交互式终端需要另写一套 profile 探测 | +| [BashTool.cs](../../src/TCode.Agent.Coding/Tools/Shell/BashTool.cs) | `Process` + 重定向 stdin/stdout/stderr,设置 `PAGER=cat`、`GIT_TERMINAL_PROMPT=0`、`TCODE_AGENT=1` | 没有 TTY,程序会关闭颜色和交互。"与 bash 工具共享会话"如果要做,需要另行设计(见 §4) | +| [ProcessRunner.cs](../../src/TCode.Agent.Coding/Processes/ProcessRunner.cs) | rg、git 等辅助命令的运行与进程树终止 | 无关;但说明 UI 和 Host 两个进程里都在用 `System.Diagnostics.Process`。自己管理 PTY 子进程时,要考虑与 .NET 运行时的 SIGCHLD 处理互相影响(见 research/02) | +| [ToolPresentation.cs](../../src/TCode.Desktop/ViewModels/ToolPresentation.cs) | 把 `bash` / `exec_command` / `run_terminal_cmd` / `terminal` 等工具名归为 Command 类 | 将来"在终端中打开 / 重新运行"的入口 | +| [AcpSession.cs:94](../../src/TCode.Runtimes/Acp/AcpSession.cs#L94) | ACP `initialize` 声明 `terminal:false`、`auth.terminal:false`;`ToolCallContent` 中的 `terminal` 显示为"[terminal output unavailable]" | [acp-v1.md](../protocols/acp-v1.md) §4.3 写明:等内嵌终端上线后,把 `auth.terminal` 改为 `true`,让需要在终端中登录的 Agent 可以在 TCode 内完成登录 | +| [CodexSession.cs:830](../../src/TCode.Runtimes/Codex/CodexSession.cs#L830) | Codex 的 `writeStdin` 审批显示为"Write to terminal";只处理了 `item/commandExecution/outputDelta`([第 423 行](../../src/TCode.Runtimes/Codex/CodexSession.cs#L423)),`terminalInteraction` 没有处理 | Codex 的交互式命令输出(含 ANSI)目前按纯文本处理 | +| [JsonRpcConnection.cs:82](../../src/TCode.Rpc.Contracts/JsonRpcConnection.cs#L82) | UI ↔ Host 是 stdio 上按行分帧的 JSON-RPC,发送队列是**无界** `Channel` | 如果把 PTY 放到 Host 进程,终端字节流走这条通道会有两个问题:JSON 里的 base64 开销,以及没有背压(`cat` 大文件时队列会无限增长)。见 research/06 | +| [settings.md](../settings.md) | `~/.tcode/settings.json`(JSONC + Schema + 热重载) | 终端配置应作为其中的 `terminal` 节,沿用同样的宽容解析和热重载规则 | + +## 3. 缺口 + +| 层 | 需要的能力 | 现状 | +|---|---|---| +| 进程 / PTY | Unix:openpty + 控制终端 + 安全启动子进程;Windows:ConPTY;resize、退出码、杀进程树、前台进程与 cwd 查询 | 无 | +| 终端核心 | VT 解析状态机、主 / 备用屏幕、回滚、reflow、字符集、模式、应答(DA / CPR / DECRQM…)、Unicode 宽度与 grapheme | 无 | +| 渲染 | 等宽网格自绘、字体回退、宽字符 / emoji、box drawing、光标、选区、搜索高亮、HiDPI | 无。团队有 Avalonia 自绘的经验:[CodeView](../../src/TCode.Desktop/Controls/CodeView.cs),以及 StreamMark | +| 输入 | 键盘编码(legacy / Kitty)、IME 组合输入、鼠标上报、粘贴(bracketed paste)、与全局快捷键(⌘K 等)的冲突处理 | 无 | +| 系统一致性 | 按平台探测用户真正的 shell 与 profile(WSL / Git Bash / pwsh…)、login shell、环境变量、各平台的复制粘贴与 Option 键习惯、默认字体 | 只有 Agent 用的 `ShellResolver`,目标不同 | +| 配置 | profiles、字体、光标、回滚、键位、响铃、shell 集成… | 无 | +| 工作台 | 多个终端标签、分屏、标题跟随前台进程 / OSC、关闭确认 | 每种工具只能开一个占位标签 | +| 传输抽象 | 本地 PTY、SSH、WSL、`docker exec` 等共用一个连接接口 | 无 | +| 测试 | 字节流回放 → 屏幕快照断言;vttest / esctest;吞吐基准;截图测试 | 无 | + +## 4. 与终端相关的后续集成点 + +这些不属于终端组件本身,但会影响它的接口设计,要在设计阶段预留: + +1. **ACP 终端登录**:`auth.terminal:true` 之后,需要以"指定命令 + 参数 + 环境变量"启动一个终端,并拿到退出码([acp-v1.md](../protocols/acp-v1.md) §4)。终端 API 需要支持"运行一条命令并等待它退出",而不只是启动交互式 shell。 +2. **Agent 命令在可见终端中运行**:占位文案中"与 bash 工具共享会话"的设想。可以做到的程度取决于 PTY 放在哪个进程(UI / AgentHost / 独立 pty host),见 research/06。 +3. **路径与链接**:终端输出中的 `path:line:col` 需要在 TCode 工作台中打开。所以链接识别要有可插拔的 provider,不能写死成打开浏览器。 +4. **SSH**:远程终端以后要能与远程文件树(SFTP)共用一条连接。所以传输层不能假设"连接 = 本地进程"。 +5. **NativeAOT**:终端库的每个依赖都要通过 AOT 发布检查(0 个 IL 警告),并实际运行一次发布后的二进制。 diff --git a/docs/terminal/01-architecture.md b/docs/terminal/01-architecture.md new file mode 100644 index 0000000..b2f96ab --- /dev/null +++ b/docs/terminal/01-architecture.md @@ -0,0 +1,428 @@ +# TCode 终端:总体架构 + +> 状态:设计稿 v1(2026-10-16)。依据见 [README.md](README.md) 的决策表和 [research/](research/)。本文定义各层的职责、进程划分、线程模型和层间接口。各子系统的实现细节见 02–13 号文档。 + +## 1. 目标与约束 + +| 类别 | 要求 | +|---|---| +| 完整性 | 完整的终端模拟器:vim、tmux、htop、less、fzf、neovim、Claude Code、Codex 等 TUI 都能正常工作;Tier 0 和 Tier 1 协议全部支持([10-protocol-compat.md](10-protocol-compat.md)) | +| 系统一致 | 默认 shell、login 方式、环境变量、键鼠约定、字体、光标、响铃都跟随所在平台的系统终端;所有偏离系统的行为都可以配置([07-shell-profiles-config.md](07-shell-profiles-config.md)) | +| 性能 | 输入回显路径上不加任何延迟;ASCII 解析目标 ≥ 500 MB/s(libghostty-vt 的 SIMD 构建。这是目标值,调研中只有相对数据,没有绝对出处,M0 实测后修订;Windows 可能只能用标量构建,即 win-x64 的 AVX 兼容问题和 win-arm64 在 Zig 0.16 下开启 SIMD 无法编译,目标另按实测设定,见 [12](12-build-and-ci.md));渲染每帧 p99 ≤ 2 ms;终端洪流不能拖慢 Agent UI;空闲时 CPU ≈ 0([11-testing-benchmarks.md](11-testing-benchmarks.md)) | +| NativeAOT | 所有新增项目都要求 `IsAotCompatible=true`,发布时 0 个 IL2xxx/IL3xxx 警告;只用 `LibraryImport` + `DisableRuntimeMarshalling`,不用反射;JSON 只用源生成 | +| 平台 | win / linux / osx × x64 / arm64 共 6 个 RID;Windows 最低 10 1809(17763);macOS 最低 15.0(.NET 10 的下限);Linux 支持 RHEL 8(内核 4.18、glibc 2.28),因此 Linux 的 AOT 发布必须在 glibc 2.28 的容器中进行(这一点同样适用于现有的 Desktop 和 AgentHost) | +| 会话保活 | UI 崩溃、重载、升级后,终端会话仍在运行,可以重新连接,屏幕和回滚内容完整 | +| Agent | Agent 可以在用户看得见的终端里运行命令、读取命令输出;ACP 登录可以在内嵌终端中完成 | +| SSH | 第一阶段:交互终端在本地 PTY 里运行系统 `ssh`;第二阶段:用 Tmds.Ssh 支持 SFTP 和远程文件树 | + +## 2. 已确定的决策 + +| # | 决策 | 来源 | +|---|---|---| +| D1 | 终端核心使用 **libghostty-vt**(Ghostty 的 C API),自己维护 C# 绑定,pin 上游 commit,CI 构建 6 个 RID,**不做托管保底核心**。C# 门面接口与 libghostty 解耦,保留以后更换核心的可能 | 用户 | +| D2 | 组织形式:**TCode 仓库内的独立项目** `src/TCode.Terminal.*`。终端库不依赖 TCode 的其他项目,以后可以拆成独立仓库 | 用户 | +| D3 | **一开始就做独立的 pty host 进程**(`tcode-ptyhost`)。终端会话不放在 UI 进程,也不放在 AgentHost | 用户 | +| D4 | SSH 采用混合方案:交互终端用系统 `ssh`(默认不加 `-t`,只在有远程命令时加,以尊重用户的 `RequestTTY` 配置);以后 SFTP 用 Tmds.Ssh | 用户 | +| D5 | 渲染:先用 `Control.Render`(R3)做出可用版本,再迁到 `CompositionCustomVisualHandler` 在渲染线程绘制(R1) | 用户 | +| D6 | 终端获得焦点时按系统终端惯例分配快捷键:macOS 上 ⌘ 组合键归 TCode,⌘K 清屏;Windows / Linux 上 Ctrl+字母交给 shell,TCode 用 Ctrl+Shift。命令面板增加 ⌘⇧P / Ctrl+Shift+P | 用户 | +| D7 | shell 集成默认自动注入,可以关闭,注入失败时显示原因 | 用户 | +| D8 | v1 范围:多标签与分屏、会话保活与恢复、Agent 与终端打通、Kitty 图形协议。**不做** sixel | 用户 | +| D9 | macOS 的 login shell 用 argv0 `-zsh` 形式直接启动,**不经 `/usr/bin/login`**(login 父进程总是 `exit(0)`,会吞掉 shell 的退出码;2026-10-17 用户决定放弃 login 包装,代价是没有 utmp 记录和 "Last login");Unicode 宽度在 macOS / Linux 上默认兼容模式,程序开启 2027 后切换到字形簇模式;**Windows 上固定为字形簇模式**(ConPTY 的宽度在创建时就固定,不响应 2027);Windows 随应用分发 `conpty.dll` + `OpenConsole.exe`,v1 实现 win32-input-mode;`TERM=xterm-256color`,不发布自定义 terminfo | 采纳调研推荐(可推翻) | + +## 3. 进程划分 + +``` +┌──────────────────── TCode.Desktop(UI 进程) ────────────────────┐ +│ TerminalView × N(每个视图一个 UI 侧核心:libghostty-vt) │ +│ 标签 / 分屏 · 快捷键路由 · 设置 · 链接 → 工作台 │ +│ │ PtyHostClient(控制面 + 数据面,同一条 IPC 连接) │ +└────────┼─────────────────────────────────────────────────────────┘ + │ Unix domain socket / Windows named pipe(仅当前用户、令牌认证) +┌────────┴──────────── tcode-ptyhost(独立 AOT 进程) ───────────────┐ +│ SessionManager │ +│ └ Session × N │ +│ ├ ITerminalConnection(本地 PTY:Unix spawn helper / ConPTY) │ +│ ├ 镜像核心:无头 libghostty-vt(权威状态、查询应答、快照) │ +│ ├ 输出日志:带序号的字节流 + 订阅者(每个订阅者独立 ack 流控) │ +│ └ 命令跟踪:OSC 133 边界、退出码、cwd │ +│ Persistence:退出时写快照,下次启动恢复内容(revive) │ +└────────┬─────────────────────────────────────────────────────────┘ + │ 同一套 IPC 协议(第二个客户端) +┌────────┴──────────── TCode.AgentHost ─────────────────────────────┐ +│ bash 工具(可见终端模式)· ACP auth.terminal / terminal/* · 读取命令输出 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### 3.1 为什么核心状态有两份(UI 侧核心 + 镜像核心) + +pty host 里的每个会话都有一个**无头镜像核心**,它是该会话的**权威状态**。UI 进程里的每个 `TerminalView` 再有一个自己的核心,只用来渲染和处理交互。两者接收完全相同的字节流,按相同的顺序处理,所以状态相同。 + +| 职责 | 由谁负责 | 原因 | +|---|---|---| +| 查询应答(DA、CPR、DECRQM、XTVERSION、OSC 10/11 颜色查询、2048 尺寸报告…) | **镜像核心**;UI 侧核心的 `write_pty` 回调被丢弃 | 程序启动时就会查询(ConPTY 启动时会发 DA1 并等待应答),这时可能还没有任何 UI 连接;如果两份核心都应答,程序会收到重复的应答 | +| 重连时恢复屏幕和回滚 | 镜像核心的 `ghostty_snapshot_encode` | 快照包含解析到一半的输入,可以从字节流的任意位置精确续传 | +| Agent 读取屏幕、命令输出 | 镜像核心(formatter、OSC 133 语义区、`select_output`) | Agent 读取时 UI 不一定开着 | +| 退出前保存内容(revive) | 镜像核心 | 同上 | +| 渲染、选区、搜索、视口滚动、按键 / 鼠标 / 焦点编码、IME 列宽预测 | **UI 侧核心** | 这些都是高频交互,不能每次都跨进程往返 | + +代价:每个字节解析两次(分别在两个进程里并行进行),回滚内存也占两份。libghostty-vt 的 SIMD 解析很快(目标 500 MB/s 量级,待 M0 实测),镜像核心可以更积极地压缩回滚([02-core-ghostty.md](02-core-ghostty.md) §7)。VS Code 的 ptyHost 也采用同样的结构(headless xterm + 渲染端 xterm)。 + +**备选方案及不选的原因**:只在 pty host 里解析,把渲染增量(脏行)发给 UI(WezTerm mux 的做法)。这样只解析一遍,但选区、搜索、按键编码都要跨进程往返;而且 libghostty 的 render state 只能在持有终端的进程里使用,UI 侧就得自己实现一套行缓存。 + +### 3.2 状态一致性规则 + +两份核心要保持一致,以下规则必须遵守: + +1. **只能通过日志改变状态。** 会改变终端状态的操作只有以下几种:PTY 输出、resize、reset、`SetColors`、`Clear`(⌘K 清屏),以及 host 自己追加的输出(例如 revive 恢复的内容和分隔文字)。它们都由 pty host 写入**同一条带序号的输出日志**,客户端按日志顺序执行,不能在本地抢先执行(记录类型的完整定义见 [04](04-pty-host.md))。 + - 例:UI 请求 resize → host 执行 `TIOCSWINSZ` / `ResizePseudoConsole`,同时在日志里追加一条 `Resize` 记录 → 镜像核心和所有客户端都在这条记录处执行 resize。UI 发出请求到收到这条记录之间大约 1 ms,这段时间里视图按旧尺寸显示(Ghostty 本身也会把 resize 合并 25 ms)。 +2. **影响状态的配置两边一致**:初始尺寸、像素尺寸、回滚上限、宽度模式、调色板默认值、Kitty 图形开关和上限。这些都由 host 在 `session/attach` 的应答中下发,UI 侧核心按此创建。 +3. **两边使用同一个 libghostty-vt 构建**:pty host 和 Desktop 一起发布,加载同一个 RID 目录下的同一个原生库。`build_info` 中没有哈希,所以握手时校验 `ghosttyBuildId` = 版本字符串 + 库文件的 SHA-256。例外:升级后仍在运行的旧 host(keepAlive 会话)构建不同,这时改用 VT 格式 attach(由旧 host 的镜像核心用 formatter 输出 VT 序列重建屏幕),可能出现少量显示偏差([04](04-pty-host.md) §1.5)。 +4. 只影响 UI 的选项,例如视口位置、选区、搜索,只在 UI 侧核心修改,不需要同步。 +5. **调色板**:OSC 4/10/11 的查询应答依赖调色板,所以主题切换时 UI 先通知 host(`session/setColors`),host 在日志中追加 `SetColors` 记录,两边在同一位置更新默认颜色。 +6. **需要用户参与的应答由 host 转发给 UI**:OSC 52 读剪贴板由镜像核心触发,host 向该会话的主视图发 `clipboard/readRequest`(附请求 id),UI 询问用户后回 `clipboard/readResponse`,host 再写回 PTY。没有 UI 连接时一律拒绝。libghostty 在回调返回时如果没有应答,会自动回一个空剪贴板,所以"以后补发"依赖 overlay **O3**([02](02-core-ghostty.md));O3 打不上时退回到回调内同步等待(有超时)。OSC 52 写同样只由镜像核心触发。 +7. **依赖 UI 侧状态的协议在 v1 关闭**:Kitty 粘贴事件(mode 5522)的授权记录在 UI 侧核心,程序的读取请求却到达镜像核心,两者无法对应,所以 v1 不启用。 +8. **v1 需要四个小 overlay 补丁**([02](02-core-ghostty.md),打在 pin 住的上游 commit 上,升级时逐个检查能否去掉): + - O1 登记 win32-input-mode(9001),libghostty 的模式表里没有它,未知 CSI 也不进回调; + - O2 停用 Kitty 粘贴事件 5522(安装了剪贴板读取回调后,库会把 5522 报告为已识别); + - O3 OSC 52 读取允许延后应答; + - O4 导出 modifyOtherKeys 的状态,供 `InputModes` 使用。 + 任一补丁打不上时的退路见 02 §17。 +9. **已知限制**:快照不包含 Kitty 图像,重新连接后已显示的图像会丢失;Windows 的宽度"锁定"只是把 2027 的默认值设为置位,程序仍可以复位它。 + +### 3.3 进程生命周期 + +| 事件 | 行为 | +|---|---| +| 首次打开终端(或 Agent 首次需要终端) | Desktop 按需启动 `tcode-ptyhost`(脱离 Desktop 的 Job / 进程组,Desktop 崩溃时 host 不受影响),通过 IPC 握手,并在 `host/configure` 中发送 Desktop 启动时的原始环境(BaseEnvironment):host 是长期进程,它自己的环境会过期,新会话的环境以 Desktop 最近一次发送的为准。AgentHost 通过环境变量拿到连接地址和受限令牌;**这些变量必须从 AgentHost 启动的所有子进程(BashTool、MCP 服务器、外部 Agent CLI)的环境中剔除**,否则 Agent 执行的任何命令都能直接连上 pty host | +| UI 崩溃、重载、升级重启 | host 不受影响;新的 Desktop 找到正在运行的 host(运行时目录中的 `ptyhost-.json`),重新连接,用快照恢复每个标签。无客户端连接的宽限期默认 **30 分钟**,超时后的处理见下一行 | +| 宽限期到期 | 没有前台程序在运行的会话按"退出 TCode"处理;**有前台程序在运行的会话继续保留**,直到程序结束或达到 `keepAliveMaxHours`(2026-10-17 用户决定) | +| 用户正常退出 TCode | 有程序在运行时汇总确认一次(`keepAlive` 模式不结束进程,不需要确认);然后按 `terminal.persistence.onQuit` 处理:
**`revive`(默认)**:每个会话写一份快照(裁剪到尾部 N 行,默认 1000 行;libghostty 快照格式不保证跨版本兼容,所以同时保存一份 `tail.vt` 作为回退)→ SIGHUP / 关闭 → host 退出;下次启动时恢复标签、cwd 和内容,并启动新 shell(iTerm2、VS Code 的做法)
`keepAlive`:host 保留所有会话继续运行,下次启动时直接重新连接(类似 tmux)
`close`:全部关闭,不保存 | +| host 崩溃 | Desktop 检测到断开 → 所有终端标签显示"会话已丢失",并提供"在相同目录重新打开"(使用最近一次的 revive 快照,host 每 30 s 增量保存一次)。Desktop 不会自动重启正在运行的程序 | +| 系统关机、注销 | 与正常退出相同,但不弹确认 | + +pty host 按"用户 + `TCODE_HOME` + 构建"区分:正常情况下只有一个 host,多个 Desktop 窗口(以后会有)共用它;升级后新旧两个构建的 host 可能短暂并存,旧 host 在其会话全部结束后退出([04](04-pty-host.md) §1.5)。Agent 创建的会话默认不持久化。 + +## 4. 项目划分 + +``` +src/ + TCode.Terminal.Core/ 终端核心门面(无 UI):TerminalCore、RenderState 读取、键盘/鼠标/粘贴编码、 + 选区、搜索、快照、formatter;Interop/Ghostty(LibraryImport 原始声明); + 托管 PNG 解码器(供 Kitty 图形使用);runtimes//native/libghostty-vt.* + TCode.Terminal.Pty/ ITerminalConnection 与本地 PTY:Unix(libtcodepty + tcode-pty-spawn)、 + Windows(ConPTY,随包 conpty.dll + OpenConsole.exe);进程信息查询 + TCode.Terminal.Shell/ profile 探测(各平台)、启动命令构造(login、argv0)、环境变量策略、 + shell 集成脚本(嵌入资源)与注入、ssh 主机列表 + TCode.Terminal.Protocol/ pty host IPC 协议:帧格式、控制消息(源生成 JSON)、流控;PtyHostClient + TCode.Terminal.Host/ tcode-ptyhost 可执行项目:SessionManager、镜像核心、输出日志、命令跟踪、 + 持久化、Agent 命令执行器 + TCode.Terminal.Avalonia/ TerminalView 控件:Painter、行缓存、字体与 sprite、输入 / IME / 鼠标、 + 选区手势、搜索栏、可访问性;只依赖 Avalonia(不依赖 AtomUI); + 资源键 Terminal.*(颜色、字体) + TCode.Desktop/ 集成:TerminalTabViewModel、分屏、快捷键路由、settings 的 terminal 节、 + AtomUI 主题 → Terminal.* 映射、链接打开、Agent 终端展示 +native/ + ghostty/ 上游 pin(ghostty-upstream.json)、构建脚本、overlay 补丁(如有) + tcodepty/ libtcodepty(约 690 行 C)与 tcode-pty-spawn(约 85 行 C,PTY / --exec / --detach 三种模式) +tools/terminal/ 原生库构建脚本、EscTestRunner(esctest2 驱动)、夹具录制工具 +tests/ + TCode.Terminal.Core.Tests/ 回放 → 网格断言、增量 vs 全量差分、esctest2 驱动、fuzz 回归 + TCode.Terminal.Pty.Tests/ 真实 PTY 往返、resize、退出码、SIGPIPE、关闭语义、ConPTY 握手 + TCode.Terminal.Shell.Tests/ profile 探测(假文件系统 / 注册表)、启动命令构造、环境变量策略、shell 集成注入(CI 中真实启动各 shell) + TCode.Terminal.Host.Tests/ 协议、流控、重连快照、持久化、多客户端 + TCode.Terminal.Avalonia.Tests/ 视图单元测试:按键编码表、路由、选区手势、行缓存 + TCode.Desktop.Tests/ TerminalView 截图测试(Headless + Skia) + TCode.Terminal.Benchmarks/ BenchmarkDotNet:解析吞吐、快照成本、渲染帧时间 +``` + +依赖方向(单向): + +``` +TCode.Desktop ──▶ TCode.Terminal.Avalonia ──▶ TCode.Terminal.Core + │ ▲ + ├──▶ TCode.Terminal.Protocol ◀── TCode.Terminal.Host ──▶ TCode.Terminal.Pty + │ │ +TCode.AgentHost ──▶ TCode.Terminal.Protocol └──▶ TCode.Terminal.Shell ──▶ TCode.Terminal.Pty +``` + +- `TCode.Terminal.*` 不引用任何 `TCode.*` 非终端项目,也不引用 AtomUI。TCode 的设置、主题、本地化通过 Desktop 的适配层注入。 +- `TCode.Terminal.Protocol` 只依赖 BCL,Desktop 和 AgentHost 都引用它。 +- 只有 `TCode.Terminal.Avalonia` 依赖 Avalonia。 + +## 5. 线程模型 + +### 5.1 pty host 中的一个会话 + +| 线程 | 职责 | +|---|---| +| 读线程(专用 `Thread`) | 从 PTY 读取。Unix:非阻塞 fd + `poll`,循环读到 EAGAIN、凑满 64 KiB 或用完 3 ms(gather,弥补 macOS 每次只能读约 1 KiB 的限制)。Windows:阻塞 `ReadFile`。读到的数据块交给会话 actor | +| 会话 actor(专用 `Thread`,或每个会话一个串行队列) | 独占镜像核心:`vt_write` → 产生的应答直接排进写队列;把数据块追加到输出日志并唤醒订阅者;处理命令(resize、写入、kill、快照、Agent 查询) | +| 写线程(专用 `Thread`) | 从 channel 取数据写入 PTY;遇到 EAGAIN 就 poll `POLLOUT`;大段粘贴按 4 KiB 分块 | +| 退出监视(进程级一个线程) | Linux:pidfd + epoll(内核 < 5.3 时,每个会话一个阻塞的 `waitpid(pid)` 线程);macOS:kqueue `EVFILT_PROC`;Windows:`RegisterWaitForSingleObject` | +| IPC 写线程(每个客户端一个) | 控制帧优先,数据帧按订阅者 ack 流控发送 | + +**背压**:读线程使用 4 块 × 64 KiB 的缓冲池。镜像核心解析完一个数据块并把它追加到日志后,这块缓冲才归还。日志对每个订阅者都有未确认上限(§6.4),任何订阅者超过上限时,该会话暂停读取 PTY,从而反压子进程。例外:没有活跃 UI 订阅者时(UI 已断开),只受镜像核心的速度限制,日志只保留最近的环形窗口。 + +### 5.2 UI 进程中的一个 TerminalView + +| 线程 | 职责 | +|---|---| +| IPC 读线程(每个 host 连接一个) | 解帧,把数据帧分派到对应视图的核心 actor 队列 | +| 视图核心 actor(专用 `Thread`) | 独占 UI 侧核心:`vt_write`(`write_pty` 回调为空)→ 按需执行 `render_state_update` → 只复制脏行,生成 `FrameSnapshot` → 发布(`Volatile` 交换 + Generation 加一);处理 UI 命令(视口滚动、选区、搜索、按键编码),处理完每块数据后回 ack | +| UI 线程 | 布局、键盘 / IME / 鼠标、选区手势、AutomationPeer;R3 阶段每帧在 `Render` 中读取最新快照并绘制 | +| 渲染线程(R1 阶段,Avalonia 合成器,所有窗口共用) | `CompositionCustomVisualHandler` 读取最新快照并绘制;**从不等锁** | + +要点: + +- 按键路径:UI 线程 KeyDown → 用 actor 单独发布的 **`InputModes`** 编码(DECCKM、DECKPAM、Kitty flags、9001、鼠标模式等)→ 编码结果直接通过 IPC 写给 host。**输入路径上没有任何合并定时器。** `InputModes` 每处理完一个数据块就发布,不受 mode 2026 或视图不可见的影响;不能用 `FrameSnapshot.Modes`,因为 mode 2026 期间快照最多会被扣住 1 s,读到的模式可能已经过期。 +- 视图不可见(标签在后台)时,视图核心仍然解析(保持状态最新),但不生成快照,也不请求帧。 +- 光标闪烁由 UI 线程在相位边界唤醒一次渲染,不在渲染线程逐帧计时,以保证空闲 CPU ≈ 0。 +- mode 2026 期间不发布快照,超时 1 s。 + +## 6. 层间接口 + +以下是各层之间的约定。签名是设计稿,实现时可以调整名称,但职责划分不变。 + +### 6.1 核心门面(TCode.Terminal.Core) + +```csharp +namespace TCode.Terminal.Core; + +/// 一个终端状态机。非线程安全:只能由其所属的 actor 线程调用。 +public sealed class TerminalCore : IDisposable +{ + public static TerminalCore Create(in TerminalCoreOptions options, ITerminalEffects effects); + + // —— 改变状态(只能按输出日志的顺序调用)—— + public void Write(ReadOnlySpan data); + public void Resize(TerminalSize size); // 见下方 TerminalSize + public void SetDefaultColors(in TerminalPalette palette); + public void Reset(); + + // —— 查询 / 交互(只在 UI 侧使用)—— + public void ScrollViewport(ViewportScroll scroll); // 按行、按页、到顶 / 底、到上 / 下一个 prompt + public bool CaptureFrame(FrameSnapshotBuilder builder);// render state 更新 + 只复制脏行;无变化时返回 false + public SelectionHandle Select(SelectionRequest request); // 两点、词、行、全部、命令输出 + public string? GetSelectionText(SelectionHandle selection, TextFormat format); + public TerminalSearch CreateSearch(string query, SearchOptions options); + + // —— 输入编码(读取当前模式,不改变状态)—— + public int EncodeKey(in KeyInput key, Span destination); + public int EncodeMouse(in MouseInput mouse, Span destination); + public int EncodeFocus(bool focused, Span destination); + public PasteResult EncodePaste(ReadOnlySpan text, bool allowUnsafe, IBufferWriter destination); + + // —— 镜像侧 —— + public void EncodeSnapshot(IBufferWriter destination, SnapshotOptions options); + public static TerminalCore Restore(ReadOnlySequence snapshot, in TerminalCoreOptions options, ITerminalEffects effects); + public string Format(FormatRequest request); // 纯文本 / VT / HTML;整屏、回滚、某条命令的输出 + public IReadOnlyList Commands { get; } // OSC 133 解析出的命令边界(A/B/C/D 与退出码) + public void CompressIdle(TimeSpan budget); +} + +/// 副作用回调。在调用 Write 的线程上同步触发,实现方不能阻塞,也不能重入 TerminalCore。 +public interface ITerminalEffects +{ + void WriteToPty(ReadOnlySpan reply); // 镜像核心:写回 PTY。UI 侧核心:丢弃查询应答, + // 但 EncodePaste 期间开启捕获作用域,收集粘贴编码结果 + void Bell(); + void TitleChanged(string title); + void WorkingDirectoryChanged(string uri); // OSC 7 等,不可信输入 + void ClipboardWrite(ClipboardTarget target, ReadOnlySpan data); + ClipboardReadResult ClipboardRead(ClipboardTarget target); // 默认拒绝;需要询问时返回"待定",以后补发应答 + void Notification(string? title, string body); // OSC 9 / 777 + void Progress(ProgressState state, int? percent); // OSC 9;4 + void SemanticPrompt(in SemanticPromptEvent e); // OSC 133 + void UnknownOsc(int command, ReadOnlySpan payload); // libghostty 不解析的 OSC,例如 633(命令行与 nonce,用于 Agent 命令跟踪) + void RenderHold(bool hold); // mode 2026 +} +``` + +快照格式(UI 侧 actor 产出,渲染方只读): + +```csharp +public sealed class FrameSnapshot +{ + public long Generation; + public TerminalSize Size; + public RowSnapshot[] Rows; // 未变化的行与上一帧共享同一对象 + public CursorState Cursor; // 位置、形状、可见、闪烁、是否在宽字符上 + public TerminalPalette Palette; // 已处理 reverse video(DECSCNM) + public TerminalModes Modes; // DECCKM、DECKPAM、2004、1004、鼠标跟踪与编码、Kitty flags、9001…… + public SelectionSpans? Selection; + public ScrollbarState Scrollbar; // 总行数、视口偏移、视口行数 + public ImagePlacements? Images; // Kitty 图形的放置位置(像素矩形 + 图片 id + 版本) + public SearchHighlights? Search; // 搜索匹配与当前匹配 + public HyperlinkTable? Links; // 超链接 id → URI(OSC 8) + public RowSnapshot[]? Overscan; // 视口上下的额外行(平滑滚动) + public MouseShape PointerShape; // OSC 22 +} + +public sealed class RowSnapshot +{ + public ulong RowId; public int Version; // RowId 来自 libghostty 的行 ID,跨滚动不变 + public ulong[] Cells; // 我们自己的 8 字节 cell 格式(不直接暴露 GhosttyCell 位布局) + public ResolvedStyle[] Styles; // 本行去重后的样式:fg / bg / 下划线颜色与样式、属性、超链接 id + public GraphemeTable? Graphemes; // 稀疏:列 → 码点序列 + public RowFlags Flags; // 软换行、prompt 行、语义区 +} +``` + +渲染层只依赖 `FrameSnapshot`,不依赖 libghostty 的任何类型。 + +`TerminalSize` 在所有层(核心、连接、IPC)中含义统一: + +```csharp +/// 像素是**单个 cell** 的尺寸;整块区域像素(TIOCSWINSZ 的 ws_xpixel / ws_ypixel)= 列数 × CellWidth、行数 × CellHeight,由需要的一方计算。 +public readonly record struct TerminalSize(int Columns, int Rows, int CellWidth, int CellHeight); +``` + +### 6.2 连接(TCode.Terminal.Pty) + +采用 [research/06](research/06-ssh-transport.md) §6 的接口草案: + +```csharp +namespace TCode.Terminal.Pty; + +public interface ITerminalConnection : IAsyncDisposable +{ + string Kind { get; } // "local-pty" | "replay" | 以后的 "ssh" …… + ConnectionCapabilities Capabilities { get; } // Resize、PixelSize、Signals、ForegroundProcess、WorkingDirectory + PipeReader Output { get; } // 消费方 AdvanceTo 之前,读线程最多再读一个池的数据(背压) + ValueTask WriteAsync(ReadOnlyMemory input, CancellationToken ct = default); + ValueTask ResizeAsync(TerminalSize size, CancellationToken ct = default); + ValueTask SignalAsync(TerminalSignal signal, CancellationToken ct = default); // HUP / INT / TERM / KILL + Task Exited { get; } // 退出码或信号;未知时两者都为空 + ValueTask GetForegroundAsync(CancellationToken ct = default); // 前台进程名 / pid / 是否就是 shell + ValueTask GetWorkingDirectoryAsync(CancellationToken ct = default); +} + +public sealed record PtyLaunch( + string File, IReadOnlyList Arguments, string? Argv0, // Argv0 = "-zsh" 这类 login 形式 + string? WindowsCommandLine, // Windows:原样命令行(cmd.exe 等不按 CommandLineToArgvW 解析的程序) + string? WorkingDirectory, + IReadOnlyList> Environment, // 完整环境块(策略由 TCode.Terminal.Shell 决定) + TerminalSize InitialSize, + PtyWidthMode WidthMode, bool AmbiguousWide); // Windows:映射为 ConPTY 的 GLYPH_WIDTH_* / AMBIGUOUS_IS_WIDE + +// ITerminalConnection 另有:bool TryWriteReply(ReadOnlySpan)(镜像核心的应答,非阻塞,排在输入之后先进先出)、 +// ValueTask ClearBufferAsync()(Windows:ClearPseudoConsole)、ValueTask CloseAsync(TimeSpan grace)(SIGHUP → 超时 SIGKILL / Job), +// 以及启动警告(例如 conpty.dll 降级为系统 conhost)。完整定义见 03。 +// Exited 在"输出读到 EOF,或进程退出后输出静默超时"之后完成:后台进程仍持有 slave 时读不到 EOF。 +``` + +### 6.3 Shell 与 profile(TCode.Terminal.Shell) + +```csharp +public interface IProfileProvider { IReadOnlyList Detect(); } // 每个平台一个实现 + +public sealed record TerminalProfile( + string Id, string Name, string Command, IReadOnlyList Arguments, + bool LoginShell, ShellKind Kind, // Bash / Zsh / Fish / Pwsh / PowerShell / Cmd / Nu / Wsl / Ssh / Other + string? Icon, ProfileSource Source); // Detected / Settings +// 完整字段(PathStyle、RawCommandLine、Environment、RequiresTrust、WslDistribution、Ssh 等) +// 与异步探测接口(vswhere、wsl.exe 需要异步)见 07 §1.2。 + +public static class LaunchBuilder +{ + /// profile + 设置 + 项目上下文 → PtyLaunch(包括 login shell 的 argv0、环境变量增删、shell 集成注入) + public static LaunchPlan Build(TerminalProfile profile, LaunchContext context); +} +``` + +### 6.4 IPC 协议(TCode.Terminal.Protocol) + +完整规格见 [04-pty-host.md](04-pty-host.md)。要点: + +- **传输**: + - Unix:`$TCODE_HOME/run/ptyhost-.sock`,所在目录权限 0700(路径超出 `sun_path` 上限时回退,见 04); + - Windows:命名管道,ACL 只允许当前用户,并设置 `PIPE_REJECT_REMOTE_CLIENTS`; + - 先校验对端 uid / SID,再做 **HMAC 双向挑战应答**(令牌保存在权限 0600 的 `ptyhost-.json` 中,从不在连接上传输,防止管道抢注者拿到令牌),然后握手协议版本和 `ghosttyBuildId`。 +- **帧**:`[u32 长度][u8 类型][u8 标志][u16 保留][u32 会话 id][负载]`。类型分为三种: + - 控制请求 / 应答 / 通知:负载是源生成 JSON(与现有 RPC 风格一致); + - 数据输出 `Output`:负载是 `[u64 日志序号][原始字节]`; + - 数据输入 `Input`:原始字节。 +- **一条连接、两个优先级**:每个客户端的发送端有两个队列,控制帧优先;数据帧单帧不超过 64 KiB,所以控制消息最多被延迟一个数据帧。 +- **流控(每个会话、每个订阅者独立)**: + - host 记录"已发送未确认"的字节数,超过 **256 KiB** 就暂停这个订阅者; + - 客户端的核心每处理完 **32 KiB** 回一次 `Ack(序号)`; + - 所有订阅者都低于 **64 KiB** 时恢复。 + - 慢订阅者最多拖住会话 **10 s**;超时后这个订阅者降级,等它跟上后用快照重新同步(resync)。数据不会丢,因为镜像核心始终是完整的。Agent 订阅者用"按需读取"模式,不订阅原始字节,所以不参与流控(§9)。 +- **重连**:`session/attach` 返回 `{快照, 快照对应的日志序号}`,之后的 `Output` 帧从这个序号开始,不重不漏。快照分两段发送:先发 READY 段(可以立即渲染),再增量发送更早的回滚。 + +### 6.5 UI 控件(TCode.Terminal.Avalonia) + +```csharp +public sealed class TerminalView : Control // R3:重写 Render;R1:挂载 CompositionCustomVisual +{ + public ITerminalViewSession? Session { get; set; } // 由宿主提供:输入输出走 IPC,或者在测试里直接接回放 + public TerminalViewOptions Options { get; set; } // 字体、光标、选区、鼠标、粘贴、链接、可访问性…… + public event EventHandler? LinkActivated; // URL、文件路径(path:line:col)、OSC 8 + public event EventHandler? KeyRouting; // 宿主决定按键归 TCode 还是归 shell + public void Copy(); public void Paste(); public void Clear(); public void Find(); public void ScrollToPrompt(int delta); +} + +/// 视图与宿主之间的会话:Desktop 用 PtyHostClient 实现,测试用回放实现。 +public interface ITerminalViewSession +{ + TerminalCoreOptions CoreOptions { get; } + void Start(ITerminalViewSink sink); // 宿主把输出日志中的数据 / resize / 颜色记录推给 sink + void SendInput(ReadOnlySpan bytes); + void RequestResize(TerminalSize size); + void Ack(long sequence); + void RequestClear(); // ⌘K:由 host 追加 Clear 记录 + void SetColors(in TerminalPalette palette); // 主题切换:由 host 追加 SetColors 记录 + void AnswerClipboardRead(long requestId, ClipboardReadResult result); // 回应 host 转来的 OSC 52 读取请求 +} +``` + +`TerminalView` 不知道 pty host 的存在,也不知道 TCode 的设置格式。 + +## 7. 与 TCode 的集成点 + +| 位置 | 改动 | +|---|---| +| [WorkbenchViewModel.cs](../../src/TCode.Desktop/ViewModels/WorkbenchViewModel.cs) | 新增 `TerminalTabViewModel`。`AddTool(Terminal)` 改为每次都新建一个终端,不再按 Kind 去重;标签支持分屏(`TerminalGroupViewModel` 持有一棵分屏树) | +| [MainWindow.axaml.cs](../../src/TCode.Desktop/Views/MainWindow.axaml.cs) `OnWindowKeyDown` | 焦点在终端里时,先询问终端的按键路由(D6);命令面板新增 ⌘⇧P / Ctrl+Shift+P | +| `docs/settings.md` + `TCode.Config` | 新增 `terminal` 节([07](07-shell-profiles-config.md) §7),沿用 JSONC、宽容解析、热重载 | +| 主题 | `TCode.Desktop` 把 AtomUI token 和 TCode 调色板映射到 `Terminal.*` 资源键(与 StreamMark 的 `MarkdownResourceAlias` 做法相同) | +| 链接 | `LinkActivated` → 文件路径在工作台打开(含行号);URL 交给系统打开;OSC 8 打开前确认真实地址 | +| AgentHost | 新增 `TerminalBroker`,连接 pty host,为 bash 工具的可见终端模式、ACP `auth.terminal` / `terminal/*` 提供服务([09](09-agent-integration.md)) | +| ACP | `auth.terminal:true`;`terminal:true` 作为可配置能力(v2 草案删除了它,见 09) | +| 文案 | `TerminalDescription` 和 [architecture.md](../architecture.md) 的路线图改为本文的方案 | + +## 8. 安全边界 + +| 边界 | 规则 | +|---|---| +| IPC | 仅当前用户:Unix socket 所在目录 0700,Windows 管道 ACL 只允许当前用户,再加令牌认证;拒绝远程客户端 | +| 程序 → 终端 | OSC 52 读剪贴板默认询问;标题回报默认关闭;OSC 8 链接打开前显示真实地址;OSC 7 cwd 当作不可信输入(只用于继承目录,并校验目录存在);DECRQSS 应答不回显请求内容([10](10-protocol-compat.md) §6) | +| 用户 → 程序 | 粘贴前检查 `ESC[201~` 和控制字符;多行粘贴警告(可配置) | +| Agent → 终端 | Agent 只能操作它自己创建的终端,或者用户明确共享给它的终端;在终端里执行命令前走 TCode 的审批;Agent 读取用户终端的内容需要用户授权([09](09-agent-integration.md) §11) | +| 持久化 | revive 快照可能包含敏感输出:文件权限 0600,可以关闭,保存的行数有上限 | +| 原生库 | conpty.dll 只按绝对路径加载;libghostty-vt 和 libtcodepty 与应用一起签名(macOS 需要公证) | +| 许可证 | Ghostty、kitty 的 shell 集成脚本是 GPLv3,不能拷贝进 TCode(Apache-2.0);只能参考思路自写,或改编 VS Code 的 MIT 脚本(07 §5) | + +## 9. Agent 使用终端的方式(概要) + +完整设计见 [09-agent-integration.md](09-agent-integration.md)。 + +- AgentHost 是 pty host 的另一个客户端。它的 `TerminalBroker` 可以创建会话、写入、等待命令结束、读取命令输出、kill。 +- **命令边界**:依靠 shell 集成的 OSC 133(A/B/C/D + 退出码)。镜像核心的 `Commands` 列表记录每条命令的输出区间,可以用 formatter 取出纯文本。 +- **没有 shell 集成时**(注入失败、用户关闭、远程 shell):退回"进程模式",即现有的 `shell -c` 执行方式,或者用空闲检测 + 哨兵(VS Code 的 basic / none 策略)。 +- **用户可见**:Agent 创建的终端在工作台中显示为"Agent 终端"标签(带 Agent 标记)。用户可以接管输入,例如命令卡在交互提示时。 + +## 10. 实施里程碑(概要) + +详见 [13-roadmap.md](13-roadmap.md)。每个里程碑都要求:AOT 发布 0 个警告,并且运行过 AOT 二进制。 + +| 里程碑 | 内容 | 验收 | +|---|---|---| +| M0 Spike(1–2 周) | libghostty-vt 在 6 个 RID 上构建并加载;绑定最小子集;Unix spawn helper + ConPTY 往返;`Control.Render` 画出静态网格 | 6 个 RID 的 CI 全绿;AOT 二进制能运行 `vim` 并退出 | +| M1 核心与 PTY | 核心门面、差分测试、esctest2 进 CI;PTY 全功能(退出码、关闭语义、前台进程、cwd) | Tier 0 通过;PTY 压力测试通过 | +| M2 pty host | IPC 协议、流控、镜像核心、重连快照、持久化 | Desktop 被杀后重启,标签和内容完全恢复 | +| M3 视图 | R3 渲染、字体回退、sprite、光标、选区、搜索、IME、鼠标、可访问性 v1 | 截图测试;输入延迟与吞吐达到目标 | +| M4 集成 | 标签与分屏、快捷键路由、设置、主题、链接、shell 集成脚本、profile 探测 | 三个平台上完成"与系统一致"的检查清单 | +| M5 Agent | bash 工具可见终端模式、ACP `auth.terminal` / `terminal/*`、命令输出读取 | Agent 端到端测试(Fake 模型) | +| M6 R1 + 图形 | 渲染线程绘制、Kitty 图形协议 | 与 Agent UI 同时运行时,终端帧率不受影响 | +| M7 SSH 一期 | 主机列表、ssh profile、TERM 降级 | 通过 ProxyJump、FIDO 密钥连接成功 | diff --git a/docs/terminal/02-core-ghostty.md b/docs/terminal/02-core-ghostty.md new file mode 100644 index 0000000..4d4e057 --- /dev/null +++ b/docs/terminal/02-core-ghostty.md @@ -0,0 +1,1211 @@ +# TCode 终端核心:libghostty-vt 绑定与 TCode.Terminal.Core 设计 + +> 状态:设计稿 v1(2026-10-16)。上位文档:[01-architecture.md](01-architecture.md)(决策 D1–D9、§3 进程划分与一致性规则、§6.1 接口)。协议范围与应答字符串见 [10-protocol-compat.md](10-protocol-compat.md)。 +> 范围:`src/TCode.Terminal.Core`(门面 + Interop)、`native/ghostty`(上游 pin 与构建输入)、`tests/TCode.Terminal.Core.Tests`(差分测试)。渲染、PTY、IPC 只在接口处涉及。 +> 读者:实现 `TCode.Terminal.Core` 的工程师。本文所有 API 名、枚举名保留原文。 + +## 0. 标注约定与来源 + +| 标记 | 含义 | +|---|---| +| `[H:file:行]` | **已核实**:ghostty `include/ghostty/vt/` @ `b3dc3d7`(2026-10-15)。例:`[H:terminal.h:79-84]` | +| `[S:file:行]` | **已核实**:ghostty `src/terminal/` @ `b3dc3d7` 的实现代码 | +| `[R01 §x]` `[R03 §x]` `[R04 §x]` | 调研报告 [research/01](research/01-emulator-cores.md)、[03](research/03-vt-protocol.md)、[04](research/04-rendering-performance.md) | +| `[A01 §x]` | [01-architecture.md](01-architecture.md) | +| `[RT]` | RoyalTerminal `native/ghostty-vt-extensions/README.md` @ `01833b8`(12 个修正补丁的说明) | +| `[DN]` | deblasis/libghostty-vt-dotnet @ `c6f23d8`(`ghostty-upstream.json`、`build/generate-bindings.sh`) | +| `[MH-0428]` | Mitchell Hashimoto, *Ghostty Is Leaving GitHub*(2026-04-28),https://mitchellh.com/writing/ghostty-leaving-github | +| **[推断]** | 基于已核实事实的设计推理,未实测 | +| **[待 spike]** | 必须在 M0/M1 用代码验证后才能定稿 | + +--- + +## 1. 结论摘要 + +1. **绑定分三层**:`Interop`(从头文件生成的 `LibraryImport` 声明 + blittable 结构体)→ `Ghostty*` 安全句柄与小型包装 → `TerminalCore` 门面。渲染层、IPC 层只看见门面类型(`FrameSnapshot` 等),看不见任何 `Ghostty*` 类型 [A01 §6.1]。 +2. **v1 用到 151 个 C 函数**(头文件共 208 个,含 5 个 wasm 专用);清单见 §5。每帧 FFI 调用数约 `6 + 3×脏行 + 2×(每行不同样式数)`,200×60 全量重绘约 500 次调用,远低于调研时估计的 2.5 万次(因为整行用 `CELLS_RAW` 一次取出)[R01 §4.1.3][H:render.h:446-460]。**v1 不需要为了性能加 Zig shim**;功能缺口由 §2.4 的 overlay 和 §8.9 的可选 ext 导出解决。 +3. **libghostty-vt 的 VT 处理不依赖回调的返回值,也不读时钟**:标题、pwd、语义区、2026 等状态在调用回调**之前**就已写入终端 [S:stream_terminal.zig:1871-1886,1973-1984][H:terminal.h:1529];查询类回调只产生应答字节。所以"镜像核心挂全部 effects、UI 侧核心丢弃应答"不会让两份状态分叉 **[推断,依据代码阅读]**。这是 [A01 §3.1] 双核心方案成立的前提,差分测试要持续守住它(§15.4)。 +4. **v1 带一个小的 overlay 补丁集**(§2.4,共 4 项,每项几行到几十行),用来补 libghostty 与 01 设计之间的缺口: + - **O1 登记 mode 9001**(win32-input-mode):模式表里没有它 [S:modes.zig:362-416],未知 CSI 也不会进回调 [H:terminal.h:131-160]。追加到模式表末尾后,DECRQM、RIS、快照都自动正确。 + - **O2 停用 mode 5522**(Kitty 粘贴事件):[A01 §3.2 规则 7] 要求 v1 关闭;但镜像核心为 OSC 52 读安装了 `clipboard_read`,库就会把 5522 报告为"可识别"[S:stream_terminal.zig:1908-1920]。把它在模式表里标成 `disabled`。 + - **O3 OSC 52 读允许"延后应答"**:库要求在回调返回前应答,否则自动回一个空剪贴板 [H:terminal.h:969-981][S:stream_terminal.zig:1072-1101];这与 [A01 §3.2 规则 6] 的"host 转发给 UI、稍后写回"冲突。补丁让镜像核心可以不应答、由 host 稍后自己写 `OSC 52` 应答。 + - **O4 导出 modifyOtherKeys 状态**:[A01 §5.2] 的 `InputModes` 需要它,C API 没有 getter(只能经 `ghostty_key_encoder_setopt_from_terminal` 间接读取 [H:key/encoder.h:169-186])。 + - 补丁打不上时的退路逐项写在 §2.4。 +5. **其他与 01 / 04 需要对齐的点**(详见 §17): + - **Kitty 图像不在快照里** [S:snapshot/terminal.zig:238-242]。重连后 UI 侧核心没有图像,与镜像核心不一致(§11.5)。 + - **快照格式 v1 不承诺二进制兼容** [H:snapshot.h:112-113]:与 [04 §10] 一致,revive 保存裁剪后的快照 + `tail.vt` 回退(§9.4)。 + - **Windows 上 ConPTY 的字宽模式在创建时固定**,无法跟随程序运行时切换 2027;Windows 默认改为字形簇模式(§13.4,需用户确认,影响 D9)。 +6. **RoyalTerminal 修过的 12 个问题,在 HEAD 上至少 6 个仍然存在**(代码中仍无对应 dirty 标记或边界检查),其余 6 个需要移植回归用例才能判断(§15.5)。增量渲染 vs 全量重建的差分测试是 M1 的硬性验收项。 + +--- + +## 2. 上游 pin 与升级 + +### 2.1 `native/ghostty/ghostty-upstream.json` + +参考 [DN] 的同名文件(字段:repo、branch、commit、upstreamVersion、lastUpdated),扩展为: + +```jsonc +{ + "schema": 1, + "repo": "https://github.com/ghostty-org/ghostty.git", // 主源 + "mirrors": [ // 主源不可用时按序尝试(§2.5) + "https:///ghostty-org/ghostty.git" + ], + "commit": "b3dc3d794513c11633e7c3347935693957744e83", // 40 位,唯一权威 + "branch": "main", // 只用于"有多少新提交"的提示,构建不用 + "upstreamVersion": "1.3.2-dev", // build.zig.zon 的 .version + "commitDate": "2026-10-15T14:12:20-07:00", + "sourceSha256": " 的 SHA-256>", + "headersSha256": "", + "zig": { "version": "0.16.0", "sha256": { "macos-aarch64": "...", "linux-x86_64": "...", "windows-x86_64": "..." } }, + "build": { + "optimize": "ReleaseFast", + "args": ["-Demit-lib-vt"], + "perRid": { + "win-x64": ["-Dtarget=x86_64-windows-gnu", "-Dcpu=baseline", "-Dsimd=false"], // §3.2 + "win-arm64": ["-Dtarget=aarch64-windows-gnu"], + "osx-arm64": ["-Dtarget=aarch64-macos"], "osx-x64": ["-Dtarget=x86_64-macos"], + "linux-x64": ["-Dtarget=x86_64-linux-gnu.2.28"], "linux-arm64": ["-Dtarget=aarch64-linux-gnu.2.28"] + } + }, + "typeManifestSha256": { "osx-arm64": "", "...": "..." }, + "overlays": [ // 本地修正补丁(§2.3),没有就为空数组 + { "id": "dirty-zero-width-append", "file": "src/terminal/Terminal.zig", + "originalSha256": "<打补丁前整文件哈希>", "patch": "overlays/0001-dirty-zero-width.patch", + "upstreamIssue": null, "rtRef": "RT 第 1 项" } + ], + "abi": { "functionCount": 208, "boundFunctionCount": 151 }, + "reviewedBy": "", + "updatedAt": "2026-10-16T00:00:00Z" +} +``` + +- `commit` 之外的哈希都是"防止静默漂移"的校验项。构建脚本先校验 `sourceSha256`,再应用 overlay(校验 `originalSha256`,不匹配就失败,与 [RT] 的做法相同),然后构建。 +- `typeManifestSha256` 让 CI 能发现"同一 commit 在不同 Zig 补丁版本下类型布局变了"这类意外 **[推断]**。 +- glibc 目标后缀 `.2.28` 对应 [A01 §1] 的 RHEL 8 下限 **[推断:Zig 支持 `-gnu.2.28` 形式的 glibc 版本目标]**。 + +### 2.2 升级流程与检查单 + +节奏:每月一次(与 [R01 §6] 建议一致),或者上游修复了我们 overlay 覆盖的问题时提前升级。一次升级是一个独立 PR,标题 `ghostty: ..`。 + +| # | 步骤 | 工具 / 产物 | 通过条件 | +|---|---|---|---| +| 1 | 生成上游差异摘要 | `git log --oneline .. -- include/ghostty/vt src/terminal`;`git diff --stat` | PR 描述里附上摘要,标出 `include/` 变化 | +| 2 | **头文件 API 差异** | `tools/GhosttyBindgen diff-headers`:比较两版本的函数签名、枚举值、结构体字段 | 列出新增 / 删除 / 签名变化。签名变化必须逐条处理 | +| 3 | **类型清单差异** | 在新旧两个库上分别运行 `ghostty_type_json()`,用 `tools/GhosttyBindgen diff-manifest` 比较 | 列出所有 offset、size、枚举值、packed 位偏移的变化;`GhosttyCell` 位布局变化必须更新 §6.5 的转换测试 | +| 4 | 重新生成绑定 | `tools/GhosttyBindgen generate`(§4.1) | 生成文件与提交的文件逐字一致,或差异已被审阅 | +| 5 | 布局自检 | `GhosttyLayoutTests`(§4.2)在 6 个 RID 上运行 | 全部结构体、枚举、`GhosttyCell` 位布局与清单一致 | +| 6 | overlay 复核 | 每个 overlay:上游是否已修?`originalSha256` 是否仍匹配? | 已修复的删除;不匹配的重新审阅后更新 | +| 7 | 单元 + 回放测试 | `TCode.Terminal.Core.Tests` | 全绿 | +| 8 | **差分测试**(§15) | 增量 vs 全量、镜像 vs UI、快照往返 | 0 差异;新增差异需定位到上游提交,并决定 overlay 还是等待上游 | +| 9 | esctest2 / ucs-detect 回归 | CI job | 通过数不低于上一版本;新失败项进 known-bugs 并说明原因 | +| 10 | 吞吐基准 | `TCode.Terminal.Benchmarks` | 解析吞吐、帧捕获耗时回退不超过 10% | +| 11 | AOT 发布 + 运行二进制 | `dotnet publish -p:PublishAot=true` × 6 RID;运行 `tcode-ptyhost --selftest` | 0 个 IL 警告;自检通过 | +| 12 | 更新 `ghostty-upstream.json` | 新 commit、哈希、`reviewedBy` | — | + +快照兼容:升级后 pty host 和 Desktop 一起发布;`ghosttyBuildId` 不同的旧 host(keepAlive 会话)改用 VT 格式 attach([04 §1.5、§8.5])。revive 文件的 `snapshot.bin` 在构建不同时不用,改用 `tail.vt`(§9.4、[04 §10.5])。 + +### 2.3 overlay(本地补丁)政策 + +- 默认**不打补丁**,问题先报上游。 +- 只在满足以下全部条件时加 overlay:差分测试能稳定复现;影响用户可见的正确性或安全;上游 30 天内没有修复。 +- overlay 以 patch 文件形式放在 `native/ghostty/overlays/`,构建时应用到源码副本,不修改 submodule / 下载的源码目录([RT] 的做法)。 +- 新增**导出函数**(§8.9 的功能缺口)放在独立的 `native/ghostty/ext/` Zig 包里,以 `tcode_ghostty_*` 前缀导出,与上游符号隔离。构建方式沿用 [RT]:生成上游 Zig 根文件的副本,在末尾追加 `ext/*.zig` 的导出;不改上游目录。 + +### 2.4 v1 overlay 补丁计划(O1–O4) + +上面"默认不打补丁"的例外:这 4 项是 01 设计决策(D9 的 win32-input-mode、规则 6、规则 7、`InputModes`)直接要求的,从 M0 起就有。每项都有退路,补丁打不上(例如上游重构导致 `originalSha256` 不匹配且短期无法改写)时启用退路,功能降级但不阻塞发布。 + +| # | 文件 | 改动 | 验证 | 退路 | +|---|---|---|---|---| +| **O1** | `src/terminal/modes.zig` | 在 `entries` **末尾**追加 `.{ .name = "win32_input", .value = 9001, .default_configurable = false }`。`setMode` 的 `else => {}` 分支已覆盖,无副作用;RIS 复位为 false;DECRQM 自动应答 1/2 | ① `CSI ? 9001 h` 后 `DATA_MODE(9001)` 为 true;② `CSI ? 9001 $ p` → `CSI ? 9001 ; 1 $ y`;③ RIS 后为 false;④ 快照往返保持 | 视图 actor 与会话 actor 在 `vt_write` 前运行托管扫描器 `DecPrivateModeScanner`(§13.5);DECRQM 9001 只能答 0 | +| **O2** | `src/terminal/modes.zig` | `kitty_paste_events`(5522)条目加 `.disabled = true`(字段语义:"Ghostty will not allow it to be set or queried" [S:modes.zig:332-335])| DECRQM 5522 → 0;`CSI ? 5522 h` 后粘贴仍是 2004 包裹的文本 | 两份核心都不改:镜像核心对 DECRQM 5522 答"已识别",程序可能以为有粘贴事件。UI 侧核心不装 `clipboard_read`,粘贴仍按文本发出 —— 字节层面一致,只是声明不准确 | +| **O3** | `src/terminal/stream_terminal.zig` `clipboardRead` | 新增终端级开关 `clipboard_read_deferred`(由 ext 导出 `tcode_ghostty_set_clipboard_read_deferred(t, bool)` 设置)。开关打开时,回调返回而未调用 `reply` 的情况**不再**发送空剪贴板(跳过 [S:stream_terminal.zig:1096-1100] 的兜底应答);并在 `GhosttyClipboardRead` 末尾追加 `terminator` 字段(sized struct 允许追加),供 host 稍后用同一终止符应答| 镜像核心回调不应答 → PTY 无输出;host 稍后写入的 `ESC ] 52 ; c ; <原终止符>` 被程序正确接收 | 回调内**同步**阻塞:IPC 询问 UI,最多等 30 s,超时回 `DENIED`(库随之发空剪贴板)。阻塞期间该会话暂停解析 | +| **O4** | ext(追加导出)| `bool tcode_ghostty_modify_other_keys_2(GhosttyTerminal)`:读 `terminal.flags.modify_other_keys_2`([S:Terminal.zig:114];RoyalTerminal 有同名扩展 [RT])| `CSI > 4 ; 2 m` 后为 true,`CSI > 4 ; 0 m`、RIS 后为 false | 按键改在视图 actor 上用 `setopt_from_terminal` 编码(多一次线程跳转,约 10–50 µs [R04 §5.1])| + +O1 的快照影响:快照把三套模式按 `ModePacked` 的位序写入 8 字节槽,当前占 43 位、43–63 位保留且"解码器忽略保留位"[S:snapshot/terminal.zig:164-215,474-476]。9001 追加在末尾占第 43 位:同一构建的两个进程之间往返正确;被未打补丁的解码器读取时只是丢掉该位。上游注释说"新增模式需要提升快照版本",我们不改版本号,因为快照只在同一构建之间使用([04 §8.5] 的跨构建 attach 走 VT 格式)**[待 spike:确认没有 comptime 断言限定 43 位]**。 + +dirty 漏标(§15.5 的 1、2、6、7)若上游 30 天内未修,再按同样格式加 O5–O8。 + +### 2.5 Ghostty 迁出 GitHub 的应对 + +已知事实 [MH-0428]:项目已决定离开 GitHub,目的地未公布,迁移是渐进的;**GitHub 上会保留当前 URL 的只读副本**;原因是 Issues / PR / Actions 的可靠性,不是 Git 本身。截至本文日期,上游提交仍在 GitHub 上进行(`b3dc3d7`,2026-10-15)。 + +| 风险 | 对策 | +|---|---| +| 主仓库地址变更 | 构建只认 `commit`,不认 URL。`repo` + `mirrors` 按序尝试;任何一个源拿到该 commit 且 `sourceSha256` 匹配即可 | +| 只读副本停止同步 | 升级时脚本比较各源的 `main` 头;发现主源变化时更新 `repo` 字段(单独的 PR) | +| 源码不可获取(离线、全部源失效) | CI 缓存:每次升级时把 `git archive` 结果上传到 TCode 自己的 release 资产 `ghostty-src-.tar.gz`,作为最后一个 mirror | +| issue 跟踪迁移 | `overlays[].upstreamIssue` 存完整 URL,不存 `#1234` 这样的短号 | +| 依赖(Zig 包)托管变化 | `build.zig.zon` 的依赖由 Zig 按哈希获取;CI 设置 `ZIG_GLOBAL_CACHE_DIR` 并缓存,升级时预热 **[推断]** | +| 上游 CI 不再在 GitHub Actions 上运行 | 不影响我们:我们自己跑 6 RID 构建、测试和 esctest;上游 CI 只作参考 | + +--- + +## 3. 原生构建(要点) + +完整构建文档另写,这里只列影响核心设计的事实。 + +| 项 | 内容 | 来源 | +|---|---|---| +| 工具链 | Zig **0.16.0** 精确版本;`zig build -Demit-lib-vt` 同时产出动态库和静态库 | [R01 §4.1.4] | +| 产物 | `libghostty-vt.dylib` / `libghostty-vt.so` / `ghostty-vt.dll`;`[LibraryImport("ghostty-vt")]` 可以在三个平台上解析到 | [DN:NativeMethods.cs:7](已核实) | +| 优化级别 | 发布用 `ReleaseFast`(与上游 Ghostty 一致);差分测试和 fuzz 额外跑一套 `ReleaseSafe` 构建,让 Zig 的 `assert`、越界检查变成可见的 panic | **[推断]**;理由见 §15.5 第 4、8 项 | +| 动态链接 | v1 只用动态库,放在 `runtimes//native/`。静态链接进 NativeAOT 留到以后(compiler_rt 冲突风险)| [R01 §6] | +| Windows x64 | RoyalTerminal 发现默认构建可能在启动阶段执行 AVX 指令,发布版改用基线 CPU 并关闭 SIMD | [R01 §4.1.4] | +| 构建信息 | `ghostty_build_info` 只提供版本字符串和 commit(`VERSION_BUILD`),**没有构建哈希**[H:build_info.h:84-125];`ghostty_type_json()` 的清单含 `library_version`、`commit`、`dirty` 字段 [S:c/types.schema.json] | 已核实 | +| 握手标识 | `ghosttyBuildId` = `VERSION_STRING` + `"+sha256:"` + 原生库文件字节的 SHA-256([A01 §3.2 规则 3]、[04 §10.2] 的格式,例如 `1.3.2-dev+sha256:6b0e…`)。由 `GhosttyRuntime` 在启动时计算一次(约 2 MB,毫秒级)。overlay 改变了库字节,所以自动体现在哈希里 | 已对齐 01 / 04 | +| 禁用 Glyph Protocol | Ghostty 自有的 APC 协议,默认开启 [H:terminal.h:2181-2190];注册的字形不进快照,开启会导致重连后不一致。两份核心都设 `GLYPH_PROTOCOL=false` | 已核实 + 推断 | + +--- + +## 4. 绑定层(`TCode.Terminal.Core.Interop.Ghostty`) + +### 4.1 生成方法 + +候选方案: + +| 方案 | 做法 | 优点 | 缺点 | +|---|---|---|---| +| A 纯手写 | 手写约 151 个 `LibraryImport` 和约 60 个结构体 | 完全可控 | 升级时容易漏掉签名变化([DN] 记录过:`ghostty_terminal_new` 去掉了按值传入的选项结构体,名字没变,"name-existence check" 仍然全绿,ABI 却已损坏) | +| B ClangSharp 直接生成 C# | `ClangSharpPInvokeGenerator` | 现成 | 生成的是 `DllImport` 风格;[DN] 踩过的坑:`vt.h` 只有 include,不传 `--traverse` 就什么都不生成且退出码为 0;退出码是诊断条数而不是成败 [DN:build/generate-bindings.sh] | +| **C ClangSharp 解析 + 自写发射器(采用)** | ClangSharp 以 XML 模式输出 AST 摘要(`--output-mode Xml`),自写 `tools/GhosttyBindgen` 读取 XML,按我们的规则发射 C# | 签名变化一定会体现在生成文件的 diff 里;发射规则(`LibraryImport`、函数指针、`nint` 句柄)统一 | 需要维护一个小工具(约 800 行) | + +> `--output-mode Xml` 的确切参数名 **[待 spike]**;如果 XML 模式不可用,退路是 libclang 的 C API(`clang_visitChildren`)直接在工具里解析头文件,工作量相近。 + +`tools/GhosttyBindgen` 的规则: + +| C 形态 | C# 形态 | +|---|---| +| `GHOSTTY_API T fn(args)` | `[LibraryImport("ghostty-vt", EntryPoint = "fn")] internal static partial T Fn(args);`,`[SuppressGCTransition]` 只加在 §5 标记为"纯函数、极快"的少数调用上 **[待 spike:确认这些函数不会回调、不会长时间运行]** | +| 不透明句柄 `typedef struct XImpl* X` | `nint`(Interop 层);门面层用 SafeHandle 包装(§4.6) | +| `typedef enum GHOSTTY_ENUM_TYPED {...}` | `enum X : int`(头文件保证底层为 int [H:types.h:36-83]);`*_MAX_VALUE` 哨兵不生成 | +| `typedef uint16_t GhosttyMode` 等整型别名 | 同宽整型,或 `readonly struct` 包装(`GhosttyMode` 用包装,带 `New(value, ansi)`) | +| 结构体 | `[StructLayout(LayoutKind.Sequential)]` blittable struct;含 `bool` 的字段保持 `bool`(启用 `DisableRuntimeMarshalling` 后 `bool` 按 1 字节处理) | +| 联合体(带 `_padding[n]`) | `[StructLayout(LayoutKind.Explicit, Size = N)]` + `[FieldOffset(0)]` 各臂 | +| 回调函数指针类型 | `delegate* unmanaged[Cdecl]<...>`;返回 `bool` 的回调在签名里用 `byte`,避免平台差异 | +| `size_t` / `uintptr_t` / `intptr_t` | `nuint` / `nuint` / `nint` | +| `static inline` 帮助函数(`ghostty_mode_new` 等)和宏(`GHOSTTY_INIT_SIZED`、`GHOSTTY_COLOR_PALETTE_MASK_*`) | 不是导出符号,在 C# 手写等价实现 [H:modes.h:118-144][H:types.h:351-353] | +| `wasm.h` 的 5 个函数 | 不生成 | + +程序集级别:`[assembly: DisableRuntimeMarshalling]`;项目 `IsAotCompatible=true`、`AllowUnsafeBlocks=true`。 + +### 4.2 布局校验:`ghostty_type_json` + +上游明确要求绑定"从清单读取 offset、size、对齐、枚举值、packed 位布局,不要硬编码,并在初始化时校验所需的描述符";位布局"不承诺跨版本稳定" [H:types.h:355-410]。 + +| 时机 | 校验内容 | 失败时 | +|---|---|---| +| **单元测试**(每个 RID 的 CI) | 对 Interop 中每个结构体:`Unsafe.SizeOf()` == 清单 `size`;每个字段的 `Marshal.OffsetOf`(测试里允许反射)== 清单 `offset`;每个枚举:C# 值 == 清单 `values`;`schema == 1`;`abi.pointer_size`、`endian` 与进程一致 | 测试失败 | +| **启动时**(Release 也做,成本 < 1 ms) | 只校验热路径依赖的部分:`GhosttyCell` 的 packed 描述(content_tag、content 各臂、style_id、wide、hyperlink、semantic_content 的 `lsb` / `width`)和 6 个 sized struct 的 `size`;解析结果存进 `GhosttyCellLayout`(§6.5) | 抛 `GhosttyAbiMismatchException`,pty host 拒绝启动会话,Desktop 显示"终端组件与原生库不匹配" | + +启动时的 JSON 解析用 `Utf8JsonReader` 手写遍历(只取需要的节点),不用 `JsonSerializer`,保持 AOT 安全和零反射 **[推断]**。 + +`GhosttyCell` 在 HEAD 上的布局(只用于理解,代码一律从清单读取)[S:page.zig:2137-2160]: + +| 位 | 字段 | 说明 | +|---|---|---| +| 0–1 | `content_tag` | 0 codepoint、1 codepoint_grapheme、2 bg_color_palette、3 bg_color_rgb | +| 2–25 | `content` | codepoint:低 21 位;palette:低 8 位;rgb:r、g、b 各 8 位(r 在最低位)| +| 26–41 | `style_id` | 页内样式 id,0 = 默认样式;**只在同一页内有意义** | +| 42–43 | `wide` | 0 narrow、1 wide、2 spacer_tail、3 spacer_head | +| 44 | `protected` | DECSCA | +| 45 | `hyperlink` | 有超链接 | +| 46–47 | `semantic_content` | 0 output、1 input、2 prompt | +| 48–63 | padding | | + +### 4.3 sized struct + +头文件里约 20 个结构体首字段是 `size_t size`,调用方用 `GHOSTTY_INIT_SIZED` 初始化 [H:types.h:325-353]。规则: + +1. **我们传给库的**(如 `GhosttyRenderStateCursor`、`GhosttyFormatterTerminalOptions`、`GhosttyPaste`、`GhosttyTerminalMemoryUsage`):一律 `new T { size = (nuint)Unsafe.SizeOf() }`,由生成器为每个 sized struct 生成 `static T Create()`。 +2. **库传给我们的**(回调参数,如 `GhosttyTerminalSemanticPrompt*`、`GhosttyClipboardRead*`、`GhosttyTerminalProgramStatus*`):先读 `size`,**只访问 `offset + 字段大小 <= size` 的字段**。生成器为每个这样的结构体生成 `static bool HasField(T* p, int fieldOffset, int fieldSize)` 帮助方法。 +3. 我们回复给库的(`GhosttyClipboardReadReply`、`GhosttyClipboardWriteReply`):同规则 1。 +4. "frozen layout" 的结构体(`GhosttyClipboardContent`、`GhosttyTerminalModeConfig`)没有 size 字段 [H:terminal.h:710,1928]。 + +pin 机制保证库和绑定来自同一 commit,所以 size 不匹配只可能是生成器或布局 bug,测试会捕获。 + +### 4.4 按值传递的结构体 + +以下函数按值传结构体:`ghostty_terminal_scroll_viewport`(24 B)、`ghostty_terminal_grid_ref` / `point_from_grid_ref`(`GhosttyPoint` 24 B)、`ghostty_terminal_select_output`(`GhosttyGridRef`)、`ghostty_formatter_terminal_new`(`GhosttyFormatterTerminalOptions`)、`ghostty_terminal_selection_format_*`、`ghostty_mouse_event_set_position` / `get_position`(两个 float,按值返回)。 + +`LibraryImport` 对 blittable 结构体按值传递由运行时处理各平台 ABI(Windows x64 大于 8 字节隐式传引用、SysV 大于 16 字节走内存、Apple arm64 HFA 规则)。**[待 spike]**:在 6 个 RID 上为每个按值函数写往返测试(例如 `ghostty_terminal_grid_ref` → `ghostty_terminal_point_from_grid_ref` 坐标一致;`set_position` → `get_position` 一致)。 + +### 4.5 回调:`[UnmanagedCallersOnly]` + GCHandle + +```csharp +// 每个 TerminalCore 一个 GCHandle,作为 GHOSTTY_TERMINAL_OPT_USERDATA。 +_self = GCHandle.Alloc(this); // .NET 9+ 泛型 GCHandle +Native.TerminalSet(_t, GhosttyTerminalOption.Userdata, (void*)GCHandle.ToIntPtr(_self)); +Native.TerminalSet(_t, GhosttyTerminalOption.WritePty, + (void*)(delegate* unmanaged[Cdecl])&Callbacks.WritePty); + +static class Callbacks +{ + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + public static void WritePty(nint terminal, nint userdata, byte* data, nuint len) + { + var core = GCHandle.FromIntPtr(userdata).Target; + try { core.OnWritePty(new ReadOnlySpan(data, checked((int)len))); } + catch (Exception ex) { core.RecordCallbackFault(ex); } // 绝不让异常穿过原生帧 + } +} +``` + +规则: + +| 规则 | 说明 | +|---|---| +| 不抛异常 | `[UnmanagedCallersOnly]` 方法里的未处理异常会直接终止进程。所有回调体包 `try/catch`,异常记入 `core.CallbackFaults` 并写日志;`Write` 返回后如有故障,按严重程度决定是否把核心标为 `Faulted` | +| 借用数据立即复制 | 回调参数中的字符串、数组只在回调期间有效 [H:terminal.h:558-565,1885-1903] | +| 重入保护 | 进入回调时置 `_inCallback = true`;门面所有公开方法检查它,回调内调用公开方法抛 `InvalidOperationException`。门面内部的回调处理器可以调用**只读**的 `ghostty_terminal_get`、`grid_ref`、`grid_ref_track`、`render_state_update`(头文件只禁止重入 `vt_write` [H:terminal.h:79-84],render hold 文档明确要求在回调里调用 `render_state_update` [H:terminal.h:1777-1783]) | +| 进程级回调(sys) | `GHOSTTY_SYS_OPT_DECODE_PNG`、`GHOSTTY_SYS_OPT_LOG` 是进程全局的 [H:sys.h:16-30]。`GhosttyRuntime.EnsureInitialized()` 在第一个 `TerminalCore.Create` 之前调用一次,userdata 传 `null`,处理函数必须线程安全(多个核心 actor 可能同时调用) | +| GCHandle 生命周期 | `Dispose` 中先 `ghostty_terminal_free`,再 `GCHandle.Free`;顺序反了会让最后一次回调拿到已释放的句柄 | + +### 4.6 句柄与生命周期 + +| 原生对象 | 包装 | 释放函数 | 绑定终端? | +|---|---|---|---| +| `GhosttyTerminal` | `TerminalHandle : SafeHandle` | `ghostty_terminal_free` | — | +| `GhosttyRenderState` | `RenderStateHandle` | `ghostty_render_state_free` | 否(只在 update 时借用)| +| `GhosttyRenderStateRowIterator` / `RowCells` | 普通 `IDisposable` 字段,由 `FrameCapturer` 持有 | `*_free` | 否 | +| `GhosttyKeyEncoder` / `GhosttyKeyEvent` / `GhosttyMouseEncoder` / `GhosttyMouseEvent` | 同上,由 `InputEncoder` 持有,复用 | `*_free` | 否 | +| `GhosttySearch` | `SearchHandle` | `ghostty_search_free` | **是**:free 必须与终端访问串行 [H:search.h:91-96] | +| `GhosttyFormatter` | 用完即释放(`using`)| `ghostty_formatter_free` | 是 | +| `GhosttyTrackedGridRef` | `TrackedRef` | `ghostty_tracked_grid_ref_free` | 是(每个都给终端变更增加簿记开销,要节制使用 [H:grid_ref.h:74-76])| +| `GhosttySelectionGesture` | 由 `SelectionController` 持有 | `ghostty_selection_gesture_free(gesture, terminal)` | 是(free 需要传终端)| +| `GhosttySnapshotDecoder` | `RestoreSession` 持有 | `ghostty_snapshot_decoder_free` | 借用 READY 返回的终端 | +| `GhosttyKittyGraphicsPlacementIterator` | `FrameCapturer` 持有 | `*_free` | 数据只在终端未变更时有效 | + +**终结器线程问题**:SafeHandle 的 `ReleaseHandle` 可能在终结器线程运行,而 libghostty 的终端不是线程安全的。规则: + +1. "绑定终端"的子对象**不实现终结器释放**:它们由 `TerminalCore` 统一持有,`TerminalCore.Dispose()`(在 actor 线程)按"子对象 → 终端"的顺序释放。 +2. 只有 `TerminalHandle` 自身有终结器兜底。终结器运行时整个 `TerminalCore` 已不可达,不存在并发访问;此时先释放仍挂着的子对象(search、tracked ref 允许在终端之前或之后释放 [H:types.h:192-198][H:grid_ref.h:85-87]),再释放终端。 +3. Debug 构建中 `TerminalCore` 记录所属线程 id,每个公开方法断言 `Environment.CurrentManagedThreadId == _ownerThread`。 + +### 4.7 错误码映射 + +`GhosttyResult` 值见 [H:types.h:85-110]。 + +| 原生返回 | 门面行为 | +|---|---| +| `GHOSTTY_SUCCESS (0)` | 正常 | +| `GHOSTTY_OUT_OF_MEMORY (-1)` | 抛 `TerminalOutOfMemoryException`(派生自 `InsufficientMemoryException`);`Write` 路径不会出现(`vt_write` 永不失败 [H:terminal.h:3085-3089])| +| `GHOSTTY_INVALID_VALUE (-2)` | 视为**绑定或调用顺序错误**:抛 `GhosttyInvalidValueException(functionName)`。例外:`ghostty_snapshot_encode` 在"解析器未处于 ground 且未开启续传跟踪"时也返回它 [H:snapshot.h:309-312],门面把它映射为 `SnapshotDeferred`(§9.2)| +| `GHOSTTY_OUT_OF_SPACE (-3)` | 门面内部按返回的所需大小扩容重试,不对外暴露 | +| `GHOSTTY_NO_VALUE (-4)` | `TryXxx` 返回 `false` / 可空返回 `null` | +| `GHOSTTY_IO_ERROR (-5)` | 我们的 writer/reader 回调返回了 `false`:抛 `IOException`,内含回调里捕获的原始异常 | +| `GHOSTTY_LIMIT_EXCEEDED (-6)` | 抛 `TerminalLimitExceededException`(快照或续传超过上限)| +| `GHOSTTY_REJECTED (-7)` | 不是异常:`PasteResult.RequiresConfirmation` [H:types.h:103-108] | +| 其他负值 | `GhosttyException(code, functionName)`(未来版本新增的错误码)| + +辅助:`static void Check(GhosttyResult r, [CallerArgumentExpression] string expr = "")`,热路径用 `[MethodImpl(AggressiveInlining)]` 的 `if (r != 0) Throw(...)`,抛出部分放在不内联的方法里。 + +`GHOSTTY_TERMINAL_DATA_VT_PROCESSING_ERROR`(VT 处理中出现过非预期错误,不能清除)[H:terminal.h:2850-2864]:每次 `CaptureFrame` / 每 5 秒读一次,首次为 true 时写一条警告日志并上报遥测,不改变行为。 + +--- + +## 5. v1 函数清单 + +"用"列:M = 镜像核心(pty host)用,V = UI 侧核心(视图)用,T = 只在测试 / 工具中用。共 151 个。 + +### 5.1 terminal(18 个,全部使用) + +| 函数 | 用 | 用途 | +|---|---|---| +| `ghostty_terminal_new` / `_free` | MV | 创建(只有列、行参数 [H:terminal.h:2986-2989])、释放 | +| `ghostty_terminal_reset` | MV | 用户触发的"重置终端"(作为日志记录,两边同时执行)| +| `ghostty_terminal_resize` | MV | 列、行 + cell 像素;会 reflow、解除 2026、发 2048 报告 [H:terminal.h:3019-3048] | +| `ghostty_terminal_set` | MV | 全部选项与回调(§6.2、§7)| +| `ghostty_terminal_vt_write` | MV | 喂字节;永不失败 | +| `ghostty_terminal_vt_write_until_ground` | M | 快照前把流推进到 ground 的备用手段(§9.2)| +| `ghostty_terminal_continuation_write` / `_buf` / `_alloc` | MT | 续传字节;镜像核心编码快照时由库内部使用,测试中直接校验 | +| `ghostty_terminal_scroll_viewport` | V | 按行 / 页 / 顶 / 底 / 绝对行 / 按 prompt 滚动 | +| `ghostty_terminal_compression_activity` / `_compress` | MV | 回滚压缩调度(§7.3)| +| `ghostty_terminal_get` / `_get_multi` | MV | 读模式、光标、滚动条、标题、pwd、颜色、内存占用等 | +| `ghostty_terminal_grid_ref` / `_grid_ref_track` / `_point_from_grid_ref` | MV | 超链接解析、选区端点换算、命令边界锚点 | + +### 5.2 render state(22 个,全部使用) + +`ghostty_render_state_new/free/update/begin_update/end_update/clean/get/get_multi/set`,`ghostty_render_state_row_iterator_new/free/next/next_dirty`,`ghostty_render_state_row_get/get_multi/set`,`ghostty_render_state_row_cells_new/next/select/get/get_multi/free`。只在 V 用(镜像核心不渲染)。`begin_update`/`end_update` 在 v1 不用(actor 独占终端,没有锁可省),保留绑定供 R1 阶段评估。 + +### 5.3 screen / style / grid ref(15 个) + +| 函数 | 用 | 用途 | +|---|---|---| +| `ghostty_cell_get` / `_get_multi` | T | 测试中逐字段解码,作为 §6.5 位运算转换的对照 | +| `ghostty_row_get` / `_get_multi` | V | 解码 render state 给出的 `GhosttyRow`(wrap、wrap_continuation、semantic_prompt、hyperlink、kitty placeholder);行布局**不透明**,不能自己解位 [H:screen.h:44-53] | +| `ghostty_style_default` / `_is_default` | VT | 默认样式比较 | +| `ghostty_grid_ref_cell` / `_row` / `_graphemes` / `_hyperlink_uri` / `_style` | MVT | 超链接 URI(只有这个 API 能取到)、测试中的独立全量重建(§15.2)| +| `ghostty_tracked_grid_ref_free` / `_has_value` / `_point` / `_set` / `_snapshot` | MV | 命令边界锚点(§12)、选区锚点 | + +### 5.4 键盘 / 鼠标 / 焦点编码(34 个) + +| 函数 | 用 | 用途 | +|---|---|---| +| `ghostty_key_encoder_new/free/setopt/setopt_from_terminal/encode`(5)| V | 按键编码(UI 线程,选项来自 `InputModes` 逐项 `setopt`,§13.1);`setopt_from_terminal` 只在 O4 退路中使用(它还会把 `MACOS_OPTION_AS_ALT` 重置为 false [H:key/encoder.h:177-179])| +| `ghostty_key_event_new/free/set_action/set_key/set_mods/set_consumed_mods/set_composing/set_utf8/set_unshifted_codepoint`(9)| V | 构造按键事件(对象复用);对应的 7 个 `get_*` 只在 T 用 | +| `ghostty_key_event_get_*`(7)| T | 测试 | +| `ghostty_mouse_encoder_new/free/setopt/setopt_from_terminal/reset/encode`(6)| V | 鼠标上报编码(UI 线程);`EVENT`、`FORMAT`、`SIZE`、`ANY_BUTTON_PRESSED`、`TRACK_LAST_CELL` | +| `ghostty_mouse_event_new/free/set_action/set_button/clear_button/set_mods/set_position`(7)| V | 构造鼠标事件;`get_*` 3 个只在 T 用(不计入)| +| `ghostty_focus_encode` | V | `CSI I` / `CSI O`;调用前检查 1004 模式 | + +### 5.5 paste(3 个) + +`ghostty_terminal_paste`(V,§13.3)、`ghostty_paste_is_safe`(V,"总是确认多行粘贴"选项的预检)、`ghostty_paste_encode`(M:Agent 写入时按镜像核心的 2004 状态编码)。 + +### 5.6 selection(21 个,全部使用,V) + +`ghostty_terminal_select_word/word_between/line/all/output`、`ghostty_terminal_selection_format_buf/format_alloc/adjust/order/ordered/contains/equal`、`ghostty_selection_gesture_new/free/reset/get/get_multi`、`ghostty_selection_gesture_event_new/free/set`、`ghostty_selection_gesture_event`。`select_output` 和 `selection_format_*` 也在 M 用(Agent 读命令输出)。 + +### 5.7 search(8 个,全部使用,V) + +`ghostty_search_new/free/tick/feed/run/set/get/get_multi`。匹配规则:除 ASCII 字母不区分大小写外按字节精确匹配;覆盖主屏、alt 屏和回滚;`tick` 不碰终端,`feed` 需要独占终端 [H:search.h:20-96]。v1 在视图 actor 上按时间片驱动(§14.2)。 + +### 5.8 formatter(5 个,M + T) + +`ghostty_formatter_terminal_new/format/format_buf/format_alloc/free`:输出 PLAIN / VT / HTML,可限定选区 [H:types.h:243-258][H:formatter.h]。用途:Agent 读屏、revive 保存(§9.4)、差分测试的规范化输出(§15.3)。 + +### 5.9 snapshot(12 个) + +`ghostty_snapshot_encode`(M)、`_encode_buf` / `_encode_alloc`(T)、`ghostty_snapshot_decoder_new`(V)/ `_new_buf`(T)/ `_free` / `_set` / `_ready` / `_next` / `_decode`(T)/ `_get` / `_get_multi`。 + +### 5.10 kitty graphics(16 个,V;M 只用 `get(GENERATION)` 做内存统计) + +`ghostty_kitty_graphics_get`、`_image`、`_image_get`、`_image_get_multi`、`_placement_iterator_new/free/set`、`_placement_next`、`_placement_get`、`_placement_get_multi`、`_placement_render_info`(主路径)、`_placement_rect` / `_pixel_size` / `_grid_size` / `_viewport_pos` / `_source_rect`(这 5 个只在 T 中与 `render_info` 对照)。 + +### 5.11 其余 + +| 组 | 函数 | 用 | 用途 | +|---|---|---|---| +| unicode(2)| `ghostty_unicode_codepoint_width`、`_grapheme_width` | V(UI 线程可直接调用:纯函数、线程安全 [H:unicode.h:63-66,138-140])| IME 预编辑文本的列宽预测;可访问性文本 | +| sys(2)| `ghostty_sys_set` | MV | PNG 解码器、日志回调 | +| | `ghostty_sys_log_stderr` | T | 测试日志 | +| allocator(2)| `ghostty_alloc`、`ghostty_free` | MV | PNG 解码输出缓冲(必须用库给的 allocator 分配 [H:sys.h:126-135]);释放 `*_alloc` 系列返回的缓冲 | +| 编码帮助(3)| `ghostty_mode_report_encode`、`ghostty_size_report_encode`、`ghostty_color_scheme_report_encode` | M | 主动发送的报告:主题切换时的 2031 通知(`CSI ? 997 ; 1/2 n`);另外两个只在 T 中对照 | +| 颜色(5)| `ghostty_color_parse`、`_palette_default`、`_palette_generate`、`_perceived_luminance`、`_contrast` | V | 主题解析、生成 256 色、判断明暗、最小对比度 | +| 构建信息(1)| `ghostty_build_info` | MV | 日志、关于页、确认 `KITTY_GRAPHICS` 已编译进库 | +| 类型清单(1)| `ghostty_type_json` | MVT | §4.2 | + +**不绑定**(v1):`ghostty_osc_*`(8)、`ghostty_sgr_*`(9)—— 终端内部已解析,v1 无独立使用场景;`ghostty_color_parse_x11` / `_parse_palette_entry` / `_rgb_get` / `_luminance` / `_x11_names` / `_x11_name_count`(6);`wasm.h`(5);`ghostty_mouse_event_get_*`(3,测试也不需要)。合计 208 − 151 = 57。 + +--- + +## 6. 门面 API(`TCode.Terminal.Core`) + +### 6.1 类型总览 + +``` +TerminalCore 一个终端状态机(单线程) + ├─ TerminalCoreOptions 创建参数;其中 TerminalStateConfig 必须两边一致(§7.1) + ├─ ITerminalEffects 副作用回调(§6.3) + ├─ FrameCapturer render state → FrameSnapshot(只在 View 角色创建) + ├─ InputEncoder key / mouse / focus 编码(View) + ├─ SelectionController / TerminalSearch(View) + ├─ CommandTracker OSC 133 / 633 命令边界(Mirror) + └─ RestoreSession 快照恢复(READY + 增量历史) +GhosttyRuntime 进程级初始化:加载库、校验清单、安装 sys 回调 +``` + +### 6.2 `TerminalCore` + +在 [A01 §6.1] 的基础上细化(标 ★ 的是相对 01 的修改,理由见 §17): + +```csharp +namespace TCode.Terminal.Core; + +public enum TerminalCoreRole { Mirror, View } + +/// 与 A01 §6.1 一致:像素是**单个 cell** 的物理像素;整块区域 = 列数 × CellWidth、行数 × CellHeight。 +/// 门面在调用 ghostty_terminal_resize 时检查范围(列、行 1..65535;像素 ≥ 0)。 +public readonly record struct TerminalSize(int Columns, int Rows, int CellWidth, int CellHeight); + +/// 影响终端状态的配置:两份核心必须完全一致(A01 §3.2 规则 2)。pty host 在 session/attach 中下发, +/// 并把 StableHash() 放进握手;Desktop 侧不一致则拒绝 attach。 +public sealed record TerminalStateConfig( + TerminalSize InitialSize, + ScrollbackLimits Scrollback, // §10 + WidthPolicy Width, // Compat | Grapheme:设置 mode 2027 的复位默认值(§13.4) + CursorShape DefaultCursorShape, bool DefaultCursorBlink, + KittyGraphicsLimits KittyGraphics, // §11.3 + bool ResizePullScrollback, // Windows ConPTY = false [H:terminal.h:2416-2439] + string TerminfoName); // "xterm-256color",影响 XTGETTCAP TN 应答 + +public sealed record TerminalCoreOptions( + TerminalCoreRole Role, + TerminalStateConfig State, + TerminalPalette Palette, ColorScheme Scheme, // 初始默认颜色;之后只能通过 SetDefaultColors 改 + ReplyProfile Replies, // DA1/DA2/DA3、XTVERSION、OSC52 是否声明(只有 Mirror 用到) + int ContinuationMaxBytes, // Mirror 4 MiB;View 0(§9.2) + bool TestMode = false); // 打开 DECRQCRA、XTWINOPS 全集,仅 esctest 用 + +public sealed class TerminalCore : IDisposable +{ + public static TerminalCore Create(TerminalCoreOptions options, ITerminalEffects effects); + + // —— 改变状态:只能按输出日志顺序调用(A01 §3.2 规则 1)—— + public void Write(ReadOnlySpan data); + public void Resize(TerminalSize size); + public void SetDefaultColors(in TerminalPalette palette, ColorScheme scheme); // ★ 带上明暗,用于 996/2031 + public void Reset(); + public void Clear(ClearFlags flags); // ★ 04 §6.2 的 Clear 记录(⌘K);Screen / Scrollback,见 §6.6 + + // —— 只读信息(两种角色都可用)—— + public TerminalSize Size { get; } + public string Title { get; } // DATA_TITLE,已去除控制字符 + public string WorkingDirectoryRaw { get; } // DATA_PWD 原文(OSC 7 是 file:// URI,OSC 9;9 / 1337 是路径) + public TerminalModes Modes { get; } // 一次 get_multi 读全 + public bool CursorAtPrompt { get; } // DATA_CURSOR_AT_PROMPT + public bool Win32InputMode { get; } // ★ O1 后来自 DATA_MODE(9001);退路为托管扫描器(§13.5) + public InputModes ReadInputModes(); // ★ A01 §5.2:actor 每批 Write 后调用、变化才发布(§13.1) + public TerminalMemoryUsage GetMemoryUsage(); // DATA_MEMORY_USAGE(会遍历所有页,不要每帧调用) + + // —— View 角色 —— + public void ScrollViewport(ViewportScroll scroll); + public void SetOverscan(ushort above, ushort below); + public bool CaptureFrame(FrameSnapshotBuilder builder, in CaptureOptions options); // §8 + public SelectionController Selection { get; } + public TerminalSearch CreateSearch(); + public string? HyperlinkAt(int viewportRow, int column); // ★ + // 按键 / 鼠标 / 焦点编码不在 TerminalCore 上:UI 线程持有独立的 InputEncoder,选项来自 InputModes(§13.1) + public PasteResult EncodePaste(in PasteRequest request, IBufferWriter output); // UI 侧核心:开启 write_pty 捕获作用域(§13.3) + + // —— Mirror 角色 —— + public void CompleteClipboardRead(in PendingClipboardRead request, ReadOnlySpan data, IBufferWriter reply); // ★ O3 延后应答(§14.3) + public SnapshotResult EncodeSnapshot(IBufferWriter destination); // ★ 可能返回 Deferred(§9.2) + public static RestoreSession BeginRestore(ISnapshotSource source, TerminalCoreOptions options, ITerminalEffects effects); // ★ + public string Format(in FormatRequest request); + public CommandTracker Commands { get; } + public CompressionResult Compress(CompressionMode mode); // §7.3 + public ulong CompressionActivity { get; } + + public void Dispose(); +} +``` + +### 6.3 `ITerminalEffects` + +01 的签名保留;以下是逐项说明和新增项。所有回调都在调用 `Write`(或 `Resize`、`Reset`、`Clear`、`EncodePaste`)的线程上同步触发,实现方**不能阻塞(只有 O3 退路下的 ClipboardRead 例外)、不能调用 `TerminalCore` 的任何成员**。 + +```csharp +public interface ITerminalEffects +{ + void WriteToPty(ReadOnlySpan reply); // 应答字节(DA、CPR、DECRQM、颜色查询、2048……) + void Bell(); + void TitleChanged(string title); + void WorkingDirectoryChanged(string raw); // 未解析原文;只当不可信输入(10 §6.5) + ClipboardWriteResult ClipboardWrite(in ClipboardWriteRequest request); // ★ 同步返回结果 + ClipboardReadDecision ClipboardRead(in ClipboardReadRequest request); // ★ Mirror:返回 Deferred(O3)后由 host 转发 clipboard/readRequest;退路下同步阻塞(§14.3) + void Notification(string title, string body); // OSC 9 / 777 + void Progress(ProgressState state, int? percent); // OSC 9;4 + void ProgramStatus(in ProgramStatusReport report); // ★ OSC 7501(Claude Code 等会发送) + void SemanticPrompt(in SemanticPromptEvent e); // OSC 133(CommandTracker 已先更新) + void RenderHold(bool hold); // mode 2026;FrameCapturer 已先处理 + void FullReset(); // ★ RIS 之后(标题、pwd 已清空,回调不会再单独通知) + void UnknownOsc(int number, ReadOnlySpan payload, OscTerminator terminator); // ★ 例如 OSC 633 + void ResizeWindowRequested(ushort rows, ushort columns); // ★ CSI 8 t,默认忽略 + void Win32InputModeChanged(bool enabled); // ★ 9001 变化(O1 后由门面比较 DATA_MODE 触发) +} +``` + +DA、XTVERSION、尺寸查询、996 明暗查询**不走 effects**:它们的答案完全由核心自己的状态决定(`ReplyProfile`、最近一次 `Resize`、最近一次 `SetDefaultColors`),门面在内部回调里直接作答。这样两份核心对查询的"答案"天然相同,只是 UI 侧的应答被丢弃。 + +### 6.4 `FrameSnapshot` / `RowSnapshot` + +```csharp +public sealed class FrameSnapshot // 发布后不可变;渲染线程只读 +{ + public long Generation; + public TerminalSize Size; + public RowSnapshot[] Rows; // 视口行,长度 = Size.Rows,未变化的行与上一帧共享同一对象 + public RowSnapshot[]? Overscan; // 视口上下的额外行(DATA_OVERSCAN 实际捕获数),ViewportY 为负 / ≥ Rows + public SelectionSpans? Selection; // 每个视口行 + overscan 行一个 { StartX, EndX };不放进 RowSnapshot,便于行对象共享(§8.6) + public CursorState Cursor; + public TerminalPalette Palette; // 已处理 DECSCNM(mode 5):默认前景 / 背景已交换 + public TerminalModes Modes; // 只用于渲染与 UI 提示;按键编码用 InputModes(A01 §5.2) + public ScrollbarState Scrollbar; // total / offset / len(行),以及 ViewportActive + public ImagePlacements? Images; // §11.4 + public SearchHighlights? Search; // 视口内的匹配区间 + 当前匹配(§8.6) + public HyperlinkTable? Links; // 超链接 id → URI(§8.5);ResolvedStyle.HyperlinkId 指向它 + public MouseShape PointerShape; // OSC 22(DATA_MOUSE_SHAPE) + public bool RenderHeld; // ★ 2026 期间被冻结的帧(调试用) +} + +public sealed class RowSnapshot // 不可变,可跨帧共享 +{ + public ulong RowId; // ★ 我们自己的 64 位 id,由 128 位 GhosttyRenderStateRowId 映射而来(§8.3) + public int Version; // 同一 RowId 内容每变化一次加一 + public ulong ContentHash; // ★ Cells + Styles + Graphemes 的 XxHash3;渲染层的整形缓存用它做键 + public ulong[] Cells; // 8 字节 cell(§6.5),长度 = 列数 + public ResolvedStyle[] Styles; // [0] 永远是默认样式 + public GraphemeTable? Graphemes; // 稀疏:列 → 额外码点(不含基字符) + public RowFlags Flags; // SoftWrapped、WrapContinuation、PromptLine、PromptContinuation、HasHyperlink、KittyPlaceholder + public int ViewportY; // 负数 = 视口上方的 overscan 行 +} + +public readonly record struct ResolvedStyle( + TerminalColor Foreground, TerminalColor Background, TerminalColor UnderlineColor, + StyleFlags Flags, // Bold Italic Faint Blink Inverse Invisible Strikethrough Overline + UnderlineStyle Underline, // None Single Double Curly Dotted Dashed(值同 GHOSTTY_SGR_UNDERLINE_*) + int HyperlinkId); // 0 = 无;FrameSnapshot.Links 的键(§8.5) + +/// 32 位:高 8 位为种类,低 24 位为值。种类 0 = 默认(使用调色板的默认前景/背景), +/// 1 = 调色板索引(低 8 位),2 = RGB(0xRRGGBB)。调色板颜色**不在捕获时解析**, +/// 由渲染层用 FrameSnapshot.Palette 解析,这样粗体变亮色、最小对比度等策略都在渲染层统一处理。 +public readonly record struct TerminalColor(uint Raw); +``` + +### 6.5 8 字节 cell 格式(`TCell`) + +我们自己的格式只服务渲染,与 `GhosttyCell` 解耦(上游位布局不受 ABI 保护 [H:render.h:456-458])。 + +| 位 | 字段 | 说明 | +|---|---|---| +| 0–20 | `Codepoint` | 基字符码点;0 = 无文字(空格、只有背景色的格子、spacer)| +| 21–22 | `Width` | 0 Narrow、1 Wide、2 SpacerTail(不绘制)、3 SpacerHead(软换行行末的占位)| +| 23 | `HasGrapheme` | `Graphemes` 中有该列的额外码点 | +| 24–39 | `StyleIndex` | `Styles` 的下标;0 = 默认样式。行内最多 65 535 种,不可能溢出(列数 ≤ 65 535)| +| 40–41 | `Semantic` | 0 Output、1 Input、2 Prompt(OSC 133)| +| 42 | `Hyperlink` | 该格有超链接(`Styles[StyleIndex].HyperlinkId != 0` 的快速判断)| +| 43 | `KittyPlaceholder` | 码点为 U+10EEEE(Kitty 虚拟放置占位符,§11.4)| +| 44–63 | 保留 | 必须为 0;以后可用于搜索高亮等 | + +`TCell` 提供 `static` 访问器(`TCell.Codepoint(ulong)` 等),全部是 `[AggressiveInlining]` 的移位和掩码。 + +**从 `GhosttyCell` 转换**(`CellConverter.ConvertRow`,每个脏行调用一次): + +```csharp +// L 是启动时从 ghostty_type_json 解析出的 GhosttyCellLayout(各字段 shift / mask) +for (int x = 0; x < cols; x++) +{ + ulong g = raw[x]; + uint tag = (uint)(g >> L.TagShift) & L.TagMask; + uint sid = (uint)(g >> L.StyleShift) & 0xFFFF; + uint wide = (uint)(g >> L.WideShift) & 3; + bool link = ((g >> L.HyperlinkShift) & 1) != 0; + uint sem = (uint)(g >> L.SemanticShift) & 3; + + uint cp = 0; TerminalColor bgOverride = default; bool grapheme = false; + switch (tag) + { + case 0: cp = (uint)(g >> L.CodepointShift) & 0x1FFFFF; break; // codepoint + case 1: cp = (uint)(g >> L.CodepointShift) & 0x1FFFFF; grapheme = true; break; // codepoint_grapheme + case 2: bgOverride = TerminalColor.Palette((byte)(g >> L.PaletteShift)); break; // bg_color_palette + case 3: bgOverride = TerminalColor.Rgb((byte)(g >> L.RShift), (byte)(g >> L.GShift), (byte)(g >> L.BShift)); break; + } + + int hyperlinkId = link ? hyperlinks.Resolve(viewportY, x) : 0; // §8.5,同一 run 只解析一次 + int styleIndex = styles.Intern(sid, bgOverride, hyperlinkId); // §8.4,sid != 0 时首次遇到才发 FFI + if (grapheme) graphemes.Capture(x); // §8.4 + + outCells[x] = TCell.Pack(cp, wide, grapheme, styleIndex, sem, link, cp == 0x10EEEE); +} +``` + +- 只有背景色的格子(tag 2、3)在 Ghostty 里 `style_id` 为 0,背景色内联在 cell 中 [R04 §2.1.2]。我们把它合成为一个"默认样式 + 背景覆盖"的 `ResolvedStyle`,渲染层不需要区分。 +- 纯 ASCII、无样式的行,循环体只有移位和一次 `styles.Intern(0, default, 0)` 的快速分支;可以用 `Vector256` 先判断"整组 4 个 cell 的 tag、style_id、hyperlink 都为 0"再走快速路径(与 Ghostty `page.Mask` 的思路相同 [R04 §2.1.2])**[推断:优化项,先写标量版]**。 + +### 6.6 `Clear`(⌘K) + +[04 §6.2] 的 `Clear` 记录(`flags`:bit0 screen、bit1 scrollback)在两份核心的同一日志位置执行。C API 没有清屏 / 清回滚函数,门面用 `vt_write` 注入等价序列:scrollback → `CSI 3 J`;screen → `CSI H` + `CSI 2 J`。 + +- **只在解析器处于 ground 时注入**:注入前读 `DATA_VT_GROUND`,不在 ground 时注入会切断程序的半截序列。要求 host 只在镜像核心 `VT_GROUND == true` 的块边界追加 `Clear` 记录,否则推迟到下一个回到 ground 的块边界(⌘K 时 shell 通常处于 ground)。UI 侧核心在同一位置必然也处于 ground(字节相同),门面仍断言一次。 +- 注入的字节不经过 PTY,`CSI 2/3 J` 也没有应答。 +- 清屏后是否让 shell 重画提示符、ConPTY 是否需要 `ClearPseudoConsole`,由 04 / 06 决定 [R02 §3 W6]。 + +--- + +## 7. 镜像核心与 UI 侧核心的配置差异 + +### 7.1 必须一致的配置(`TerminalStateConfig`) + +| 配置 | 原生选项 | 为什么必须一致 | +|---|---|---| +| 初始尺寸、像素尺寸 | `ghostty_terminal_new` + `resize` | 换行、reflow、2048 报告 | +| 回滚上限(行、字节)| `SCROLLBACK_MAX_LINES` / `_BYTES` [H:terminal.h:2213-2257] | 回滚裁剪点决定 `ED 3`、搜索结果、快照内容 | +| 宽度策略 | `MODE_DEFAULT`(mode 2027)[H:terminal.h:2308-2321] | 决定每个字符占几格 | +| 默认光标形状 / 闪烁 | `DEFAULT_CURSOR_STYLE` / `_BLINK` | `CSI 0 q` 之后的状态 | +| Kitty 图像存储上限、允许的传输介质 | `KITTY_IMAGE_STORAGE_LIMIT`、`KITTY_IMAGE_MEDIUM_*` | 淘汰顺序、文件读取结果都会改变状态(§11.3)| +| resize 是否从回滚拉回行 | `RESIZE_PULL_SCROLLBACK` | resize 结果不同 | +| Glyph Protocol | `GLYPH_PROTOCOL=false`(两边都关)| §3 | +| APC 上限 | `APC_MAX_BYTES`、`APC_MAX_BYTES_KITTY`(两边都用默认值)| 超限截断会改变图像是否成功 | +| 默认颜色 | `COLOR_*`(只通过 `SetColors` 日志记录修改)| OSC 4/10/11 查询答案、渲染 | + +### 7.2 effects 的分工 + +原则:**会产生外部动作(写 PTY、写剪贴板、弹系统通知)的 effect 只在镜像核心生效**,因为它在没有 UI 时也必须工作,而且只应发生一次;**只影响画面的 effect 只在 UI 侧核心生效**,因为它和渲染时序绑定。 + +| 原生回调 | 镜像核心(pty host) | UI 侧核心(视图) | +|---|---|---| +| `WRITE_PTY` | 交给会话写队列,进入 PTY | **捕获作用域**:只在 `EncodePaste` 执行期间收集输出([A01 §6.1]),其余一律丢弃。必须安装:`ghostty_terminal_paste` 没有 write_pty 会返回 INVALID_VALUE [H:paste.h:179-182] | +| `DEVICE_ATTRIBUTES`、`XTVERSION`、`SIZE`、`COLOR_SCHEME` | 由门面按 `ReplyProfile` / 当前尺寸 / 当前明暗作答 | 同样安装(答案被丢弃),保持代码路径一致 | +| `ENQUIRY` | 不安装(ENQ 不应答 [R03 §1.4])| 不安装 | +| `BELL` | 不安装 | 视图:响铃动画 / 声音(按设置)| +| `TITLE_CHANGED` | 记录会话标题(持久化、Agent 列表、重连初值)| 更新标签页标题 | +| `PWD_CHANGED` | 记录会话 cwd("在相同目录重新打开"、Agent)| 显示 | +| `CLIPBOARD_WRITE` | 按策略经 host 发 `session/clipboardWrite` 给主视图([04 §11.2]),同步回复 SUCCESS(策略 deny 时 DENIED)| 不安装(Kitty 5522 写入会以 ENOSYS 拒绝,应答被丢弃)。[06 §6] 写成了「UI 侧核心触发、镜像核心忽略」,与 04 相反,本文按 04(§17)| +| `CLIPBOARD_READ` | O3 打开延后应答;回调登记请求后返回,host 发 `clipboard/readRequest` 给主视图([A01 §3.2 规则 6],§14.3)| 不安装(UI 侧核心的粘贴不会变成 5522 事件;O2 另外把 5522 整体停用)| +| `DESKTOP_NOTIFICATION` | 转发给 Desktop 进程(每个 Desktop 进程只显示一次,不按视图重复)| 不安装 | +| `PROGRESS_REPORT` | 记录(重连时下发初值)| 标签页进度条 | +| `PROGRAM_STATUS`(OSC 7501)| 按协议规则维护记录表(最多 256 条),转发给 Desktop / Agent | 不安装 | +| `SEMANTIC_PROMPT` | `CommandTracker`(§12)| 提示符装饰(命令块分隔、退出码徽标)| +| `RENDER_HOLD` | 不安装(不渲染)| `FrameCapturer`(§8.7)| +| `RESET` | 清空 CommandTracker、ProgramStatus 记录 | 清空视图侧缓存 | +| `UNKNOWN_SEQUENCE` + `UNKNOWN_MAX_BYTES=4096` | OSC 633(§12.3)| 不安装 | +| `RESIZE_WINDOW` | 不安装(v1 不允许程序改窗口尺寸)| 不安装 | +| `TITLE_REPORT` | `false`(默认关闭)| `false` | +| `XT_CHECKSUM_REPORT` | 仅 `TestMode` 时 `true`(esctest)| `false` | +| `TERMINFO_NAME` | `"xterm-256color"` | 同左 | +| `CONTINUATION_MAX_BYTES` | 4 MiB(快照需要,§9.2)| 0 | + +### 7.3 回滚压缩策略 + +压缩只改变存储表示,不改变逻辑内容 [H:terminal.h:3278-3282],所以两边可以不同。 + +| | 镜像核心 | UI 侧核心 | +|---|---|---| +| 触发 | `compression_activity` 令牌 1 s 不变 | 令牌 1 s 不变,**且**视口在底部、没有进行中的搜索 / 选区拖动 | +| 方式 | `INCREMENTAL`,每步之间让出 actor(每步后检查输入队列)| 同左 | +| 无订阅者时(UI 断开)| 再执行一次 `FULL` | — | +| 恢复快照 | — | 解码器 `COMPRESS_HISTORY=true` [H:snapshot.h:163-194] | +| 平台不支持 | `MEMORY_USAGE.compression_supported == false` 时跳过(`compress` 会返回 `UNSUPPORTED`)[H:terminal.h:466-473] | 同左 | + +Ghostty 应用的参数是空闲 250 ms、步间隔 1 ms [R04 §2.1.4]。我们取 1 s 是为了避开交互中的短暂停顿 **[推断,基准测试后调整]**。 + +--- + +## 8. render state → FrameSnapshot + +### 8.1 每帧流程(视图核心 actor) + +``` +CaptureFrame(builder): + if (renderHeld && !holdExpired) return false // §8.7 + render_state_update(rs, t) // 1 + render_state_get_multi(rs, [DIRTY, COLS, ROWS, CURSOR, OVERSCAN]) // 1 + terminal_get_multi(t, [SCROLLBAR, VIEWPORT_ACTIVE, MOUSE_SHAPE, + KITTY_KEYBOARD_FLAGS, ACTIVE_SCREEN, MODE×N]) // 1 + if dirty == FALSE && 选区/图像/光标都没变: return false + if dirty == FULL || 尺寸变 || 调色板代数变: + render_state_get(rs, COLORS) // 1(含 256 色) + render_state_get(rs, ROW_ITERATOR, it) // 1 + while row_iterator_next_dirty(it, &i): // 每脏行 1 + row_get_multi(it, [ID, VIEWPORT_Y, RAW, CELLS_RAW]) // 每脏行 1 + row_get_multi(rawRow, [WRAP, WRAP_CONTINUATION, SEMANTIC_PROMPT, + HYPERLINK, KITTY_VIRTUAL_PLACEHOLDER]) // 每脏行 1(ghostty_row_get_multi) + 需要样式或字形簇时: row_get(it, CELLS, cells) // 每行最多 1 + ConvertRow(...) // §6.5 + 选区:§8.6;图像:§11.4 + render_state_clean(rs) // 1 + builder.Publish() // Volatile 交换 + Generation++ +``` + +**只能有一个 render state 绑定到一个终端**:`update` 会"消费"终端的 dirty 状态 [H:render.h:616-620],第二个 render state 会漏掉变化。测试里的全量重建对照(§15.2)每次新建、只做一次 update,不受此影响。 + +### 8.2 脏行与"未变化的行" + +- 全局 `DIRTY`:`FALSE` 跳过;`PARTIAL` 只遍历脏行;`FULL` 全部重建 [H:render.h:228-243]。 +- 对 PARTIAL 中**未被访问的行**:沿用上一帧同一 `ViewportY` 位置的 `RowSnapshot` 对象。依据:render state 只在行脏、页脏或需要全量重建时才重建该位置的数据,否则保留上一次的内容 [S:render.zig:553-596,795-836];所以对 render state 来说"不脏"就等于"与上一帧同位置相同"。这个推论的正确性完全依赖终端正确地标记 dirty —— 正是 RoyalTerminal 补丁修的那类问题,由 §15 的差分测试兜底。 +- 当前上游的视口滚动会把所有行标为脏 [H:render.h:179-182],所以滚动时每行都会重建 `RowSnapshot`;重建本身很便宜(复制 + 转换),昂贵的是渲染层整形,靠 `ContentHash` 避免(§8.3)。 + +### 8.3 RowId 与 Version + +- Ghostty 的行 id 是 128 位(`bits[2]`,实现上是"页 serial + 页内行号"),永不复用,0 表示无 [H:render.h:283-302][S:render.zig:371-384]。 +- 01 的 `RowSnapshot.RowId` 是 `ulong`。映射:每个核心一个 `Dictionary<(ulong, ulong), RowIdEntry>`,`RowIdEntry { ulong Id; int Version; ulong Hash; }`;新出现的 Ghostty id 分配自增 64 位 id。每帧结束后删除本帧未出现的条目(id 消失即永久失效)。 +- 重建脏行时计算 `ContentHash`;与条目中的 `Hash` 相同时 `Version` 不变、并且**直接复用上一帧的 `RowSnapshot` 对象**("保守标脏"导致的无效重建在这里被吸收);不同则 `Version++`。 +- 渲染层的行缓存键:`(ContentHash, FontGeneration, CellMetricsGeneration)`。它不依赖 `RowId`,因此滚动、同内容的行重复出现时都能命中 **[推断:比按 RowId 缓存命中率更高,代价是哈希计算约 0.1 µs/行]**。 + +### 8.4 样式与字形簇 + +- 样式:`CELLS_RAW` 里的 `style_id` 只在本行所在页内有意义。行内按 `style_id` 去重:第一次遇到某个非 0 的 `style_id` 时,`row_cells_select(x)` + `row_cells_get(STYLE)` 取出 `GhosttyStyle`,转换为 `ResolvedStyle`(`fg_color` / `bg_color` / `underline_color` 的 tag `NONE / PALETTE / RGB` 映射到 `TerminalColor` 的三种种类;`underline` 是 `GHOSTTY_SGR_UNDERLINE_*` 整数 [H:style.h:94-108][H:sgr.h:102-107])。 +- 行内最终的 `Styles[]` 键是 `(style_id, bgOverride, hyperlinkId)`,用一个 16 项的线性探测小表,超过后退回 `Dictionary`。 +- 字形簇:`content_tag == codepoint_grapheme` 的格子,`row_cells_select(x)` + `row_cells_get_multi([GRAPHEMES_LEN, GRAPHEMES_BUF])`,缓冲区固定 64 个 `uint`(Ghostty 每格上限 64 个码点 [S:page.zig:92]),只存除基字符以外的码点。 +- 不使用 `ROW_CELLS_DATA_FG_COLOR / BG_COLOR`:它们会把调色板解析成 RGB,且不处理粗体 [H:render.h:960-974],与 §6.4 "颜色在渲染层解析"的约定冲突。 + +### 8.5 超链接 + +render state 只告诉我们"这格有超链接"(cell 的 hyperlink 位、行的 `HYPERLINK` 标记),**拿不到 URI**;URI 只能通过 `ghostty_terminal_grid_ref` + `ghostty_grid_ref_hyperlink_uri` 取得 [H:grid_ref.h:166-191],也拿不到 OSC 8 的显式 `id=`。 + +- 捕获时:对带 `HYPERLINK` 标记的脏行,按"连续的 hyperlink 格子"分 run,每个 run 只解析一次:`ghostty_terminal_grid_ref({VIEWPORT, x, y})` → `ghostty_grid_ref_hyperlink_uri`。这发生在同一次 actor 处理内、终端未变更,grid ref 有效。 +- 超链接表:核心内部维护 URI → `int HyperlinkId` 的驻留表(LRU 上限 4096 项,id 在核心生命周期内稳定)。每帧把本帧可见行用到的条目放进 `FrameSnapshot.Links`(id → URI,[A01 §6.1]),渲染层和悬停提示只读这张表。 +- overscan 行的 `ViewportY` 为负或超出视口,用 `SCREEN` 坐标(`scrollbar.offset + viewportY`)解析 **[推断:SCREEN 坐标与 scrollbar offset 同一行空间,[H:terminal.h:316-318] 只明确了 ROW 滚动与 scrollbar 同空间,待测试确认]**。 +- "悬停时高亮同一链接的所有格子"按 `HyperlinkId`(即 URI 相同)分组,跨行也成立;无法区分"URI 相同但 OSC 8 id 不同"的两个链接 —— 已知偏差,v1 接受。 +- 01 的 `HyperlinkAt(row, col)`:直接用同一对 API 解析,供点击时取真实地址。 + +### 8.6 选区、光标、overscan + +- **选区**:不逐行读 `ROW_DATA_SELECTION`。每帧读 `DATA_SELECTION`,用 `ghostty_terminal_point_from_grid_ref(…, SCREEN)` 把两个端点转成屏幕坐标,再按矩形 / 线性规则在托管代码里算出每行的 `SelectionSpan` —— 2–3 次调用代替每行 1 次,而且选区变化不需要让行对象失效。 +- **光标**:`GHOSTTY_RENDER_STATE_DATA_CURSOR`(sized struct:是否在视口内、x、y、是否在宽字符尾、可见、闪烁、密码输入、形状)[H:render.h:510-549]。`password_input` 为 true 时,UI 显示密码锁图标并让 IME 进入密码模式 **[推断:libghostty 如何判定密码输入未查]**。 +- **overscan**:平滑滚动(R1 阶段)才需要。`SetOverscan(above, below)` 设置后下一次 update 会全量重建 [H:render.h:411-417]。行迭代器先给出视口上方的行、再给视口行、再给下方的行 [H:render.h:108-114];按 `ROW_DATA_VIEWPORT_Y` 分拣:0..Rows-1 进 `FrameSnapshot.Rows`,其余进 `FrameSnapshot.Overscan`。实际行数以 `DATA_OVERSCAN` 为准 [H:render.h:387-392]。 +- **搜索高亮**:有活动的 `TerminalSearch` 时,每帧读 `GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES` 和 `SELECTED_MATCH`(都是 `GhosttySelection`),用与选区相同的端点换算方法生成 `FrameSnapshot.Search` 的每行区间 [H:search.h:42-60]。匹配只在下一次终端变更前有效,所以必须在本次捕获内换算完。 +- **鼠标指针形状**:`DATA_MOUSE_SHAPE`(OSC 22)→ `FrameSnapshot.PointerShape`。 + +### 8.7 mode 2026(render hold) + +- `RENDER_HOLD(held=true)` 回调里:立即执行一次 `CaptureFrame`(此时终端正是程序希望显示的那一帧 [H:terminal.h:1775-1783]),发布后记录 `holdStartedAt`,之后 `CaptureFrame` 直接返回 false。 +- `held=false`:恢复正常捕获并立即发布一帧。 +- 超时 1 s [R03 §2.2][A01 §5.2]:**不修改 mode 2026**,只是自己忽略 hold 继续发布(头文件允许"在显示实时内容更重要时忽略 hold"[H:terminal.h:1840-1843])。不改模式是为了不让 UI 侧核心与镜像核心的模式状态分叉(例程用 `OPT_MODE` 复位 2026,那会改变终端状态)。 +- 用户滚动、拖动选区时同样忽略 hold。 + +### 8.8 FFI 预算 + +| 场景 | 调用次数(每帧)| 估计耗时 **[推断:按每次 5–20 ns 的无封送调用]** | +|---|---|---| +| 无变化 | 3(update、两次 get_multi)| < 0.1 µs | +| 输入回显(1–2 个脏行,少量样式)| ≈ 12 | < 1 µs | +| 200×60 全量重绘,每行 3 种样式 | 6 + 60×3 + 60×3×2 ≈ 550 | 3–11 µs | +| 最坏:每格不同的 truecolor 样式(彩虹测试)| ≈ 6 + 180 + 12 000×2 ≈ 24 000 | 0.1–0.5 ms | +| 每格都是字形簇(emoji 墙)| ≈ 24 000 | 同上 | + +[A01 §1] 的目标是渲染每帧 p99 ≤ 2 ms,快照发布 ≤ 50 µs [R04 §0.7]。除最后两种病态场景外都满足。 + +### 8.9 什么时候需要 Zig shim + +**性能触发条件**(满足任一):基准 S4(全屏 truecolor 动画)上 `CaptureFrame` p99 > 0.5 ms;或者 Windows / Linux arm64 上实测的单次调用开销 > 50 ns。shim 做法:导出 `tcode_ghostty_row_export(iterator, out_cells, out_style_ids, out_styles, style_cap, out_graphemes, …)`,一次调用把整行(含去重样式表、字形簇)拷进调用方提供的平面数组(RoyalTerminal 的思路 [R01 §4.1.3])。 + +**功能缺口**(与性能无关)。前两项已经由 §2.4 的 O1、O4 在 v1 解决;其余是 v1 接受的限制,以后集中放进 `native/ghostty/ext`: + +| 缺口 | 影响 | shim 导出 | +|---|---|---| +| modifyOtherKeys 状态无 getter | `InputModes` 缺字段 | **v1:O4** `tcode_ghostty_modify_other_keys_2`(RoyalTerminal 有同名扩展 [RT])| +| mode 9001(win32-input-mode)未实现 | 无法跟踪 ConPTY 的 `CSI ? 9001 h` | **v1:O1** 登记进模式表 | +| Kitty 图像不进快照 | 重连后 UI 侧没有图像 | 图像导出 / 导入函数 | +| Kitty 动画无 tick API | 自动播放动画不动(`animationTick` 只被 Ghostty 自己的渲染器调用 [S:kitty/graphics_storage.zig:1057])| `tcode_ghostty_kitty_animation_tick(t, now_ms)` | +| 可见性(2033)无 setter | `CSI ? 998 n` 永远回答"可见"(`flags.visible` 默认 true [S:Terminal.zig:136])| `tcode_ghostty_visibility_set` | +| OSC 99 已解析但不交给 C API | kitty 通知协议不可用 | 回调桥(RoyalTerminal 做过 [RT])| + +v1 决策:M0 起带 O1–O4(§2.4);性能 shim 不做,等基准结果;上表其余项 v1 不做。 + +--- + +## 9. 快照:编码与恢复 + +### 9.1 格式要点(已核实) + +`"GHOSTSNP"` + u16 版本,后接带 CRC32C 的记录流:TERMINAL → SCREEN×n(含 PAGE)→ CONTINUATION → **READY** → HISTORY×n(PAGE 从新到旧)→ **FINISH** [H:snapshot.h:52-110]。READY 之后终端即可渲染和接收实时输入;`decoder_next` 每次前插一页历史,两次调用之间可以渲染、resize、喂实时数据 [H:snapshot.h:505-516]。 + +**快照包含**:尺寸、像素尺寸、滚动区、制表位、调色板(原值 + 覆盖)、pwd、标题、三套模式、光标默认值、鼠标状态、回滚上限、两个屏幕的页、光标、保存的光标、字符集、Kitty 键盘栈 [S:snapshot/terminal.zig:217-236]。 +**不包含**:Kitty 图像与放置、Glyph 注册、选区、视口位置、dirty 状态、所有回调和"调用方本地"的配置(Unicode 宽度策略被列为"版本级或调用方要求")[S:snapshot/terminal.zig:226-242]。 + +### 9.2 编码(镜像核心,`session/attach` 时) + +1. 会话 actor 在两个数据块之间处理 attach 请求(此时没有进行中的 `vt_write`)。记下当前日志序号 `S`。 +2. `ghostty_snapshot_encode(t, writer)`:writer 回调把字节追加到 `SegmentedBuffer`(64 KiB 段),**同时**按记录头(u16 tag、u32 len、u32 crc)切分;读到 READY 记录时,把此前的段标记为"READY 段可发送"。 +3. 返回 `GHOSTTY_INVALID_VALUE` 且 `DATA_VT_GROUND == false`:说明解析器在序列中间而续传不可用(超过 `CONTINUATION_MAX_BYTES` 或开启跟踪前就已在序列中)[H:terminal.h:2276-2294]。处理:`SnapshotResult.Deferred`,host 继续处理下一个数据块后重试;连续 2 s 不成功则对 attach 方返回"稍后重试"。 +4. IPC 按 [04 §8.2]:`Response{attachId, snapshotSeq=S, coreOptions, metadata}` 走控制队列;快照字节按 64 KiB 分成 `SnapshotChunk` 顺序发送(快照字节流本身就是 READY 在前,客户端解码器读到 READY 即可渲染,host 不需要自己切 READY 段 [04 §8.1]);之后从位置 S 开始发 `Output` / 记录。上面第 2 步的记录头解析只用于统计,可以省略。 + +成本:编码是 O(全部回滚),期间会话 actor 被占用、PTY 读取被反压。耗时 **[待 spike]**。注意 [04 §8.2] 的退路(编码超过 50 ms 就改用 `historyLines` 限制,按 §9.4 的方法裁剪)只能减小传输量和 UI 侧解码量,**减不了 actor 上的编码时间**:裁剪发生在完整编码之后。真正缩短 actor 占用需要上游提供"只编码最近 N 行"的接口,或者 ext 导出。 + +### 9.3 恢复(UI 侧,`RestoreSession`) + +```csharp +public sealed class RestoreSession : IDisposable +{ + public TerminalCore Core { get; } // READY 之后即可用 + public long HistoryRowsTotal { get; } // DATA_HISTORY_ROWS_PRIMARY:READY 时就能定滚动条长度 + public bool PumpHistory(TimeSpan budget); // 循环 decoder_next,返回是否还有剩余 +} +``` + +1. `ghostty_snapshot_decoder_new(null, &dec, reader)`:reader 回调从 IPC 的"快照通道"阻塞读取(视图 actor 线程上,允许阻塞)。 +2. `decoder_set(MAX_CONTINUATION_BYTES = 4 MiB)`、`decoder_set(COMPRESS_HISTORY = true)`;`RETAIN_CONTINUATION` 保持 false(UI 侧核心不需要再导出续传)。 +3. `decoder_ready(dec, &t)` → 得到终端句柄。**此时把 `TerminalCoreOptions` 中除 `TerminalStateConfig` 外的选项、全部回调和 userdata 重新应用到这个终端**(快照不含回调与本地配置)。`TerminalStateConfig` 中的回滚上限来自快照本身,门面断言它与下发的配置一致。 +4. 创建 render state,`CaptureFrame` 发布第一帧。 +5. actor 主循环:优先处理 `Output` 帧(序号 > S);空闲时 `PumpHistory(2 ms)`;`decoder_next` 返回 `NO_VALUE` 表示 FINISH,释放解码器。 +6. 任一步出错:丢弃该核心,向 host 请求重新 attach(最多 3 次),仍失败则视图显示"无法恢复内容"并从当前序号开始以空屏继续。 + +### 9.4 退出保存(revive):裁剪后的快照 + `tail.vt` + +与 [04 §10] 一致:每个会话保存 `snapshot.bin`(裁剪到尾部 N 行,默认 1000)和 `tail.vt`(formatter 输出的 VT 文本)。原因:快照格式 v1 "尚无二进制兼容保证"[H:snapshot.h:112-113],`ghosttyBuildId` 或快照信封版本不同时不能用 `snapshot.bin`,退回 `tail.vt`。 + +**裁剪**(快照 API 没有"只编码尾部 N 行"的选项 [H:snapshot.h:302-383])。核心层提供 `SnapshotTrimmer.Trim(ReadOnlySequence full, int lines) → (byte[] snapshot, byte[] tailVt)`,在持久化工作线程上运行,不占会话 actor: + +1. `ghostty_snapshot_decoder_new_buf` → `decoder_ready` 得到临时终端(不装任何回调,写入不会产生副作用)。`DATA_HISTORY_ROWS_PRIMARY` 给出完整历史行数。 +2. 循环 `decoder_next`,累加 `DATA_PROGRESS_ROWS`(每页实际前插的行数);累计 ≥ N(主屏)后停止。`PROGRESS_SCREEN` 为 alternate 的页不计数。 +3. **先 `ghostty_snapshot_decoder_free`,再对临时终端编码**:头文件写明"放弃增量解码后,终端仍可使用,保留已恢复的历史",并且解码器在 FINISH 之前借用该终端 [H:snapshot.h:434-445,485-487]。所以中途停止后,释放解码器即可正常 `ghostty_snapshot_encode`(这回答了 [04 §10.3] 的待验证项,仍需测试确认)**[待 spike]**。 +4. 页粒度:历史按页前插,最后一页可能多出几百行;需要精确 N 行时,用 `SCROLLBACK_MAX_LINES = N` 设置临时终端再编码(设置会立即裁剪完整的历史页 [H:terminal.h:2250-2253],仍是页粒度)。v1 接受页粒度的误差。 +5. `tail.vt`:对同一临时终端 `ghostty_formatter_terminal_new(emit = VT, unwrap = false, trim = true, extra = { palette, screen.style, screen.hyperlink })`,选区为主屏最后 N 行到屏幕末尾。 + +**恢复**(新会话的镜像核心,[04 §10.5]):`snapshot.bin` 可用 → `BeginRestore` 后一次 `PumpHistory` 到 FINISH,再按 §9.3 第 3 步重新应用选项与回调;否则把 `tail.vt` 作为 host 追加的 Output 写入。随后 resize、追加换行与分隔文字由 host 以 Output 记录完成。Kitty 图像不会恢复(§11.5)。 + +--- + +## 10. 回滚设置 + +libghostty 同时支持字节上限和行上限,"先到者生效",两者都按页(约 400 KB)粒度裁剪,实际保留的行数几乎总是多于配置值 [H:terminal.h:2213-2257]。**必须显式设置**,因为 libghostty 的默认字节上限只有 10 KB [R04 §2.1.4]。 + +| 用户配置 `terminal.scrollback` | `SCROLLBACK_MAX_LINES` | `SCROLLBACK_MAX_BYTES` | +|---|---|---| +| 正整数 L(默认 10 000 [R05 §7.5])| L | `max(4 MiB, L × 256 × 8 × 1.25)`,即按 256 列、样式开销 25% 估算的内存护栏。L=10 000 时约 25.6 MB | +| `0` | 0 | 0(字节上限 0 表示禁用回滚 [H:terminal.h:2228])| +| `-1`(不限行数)| NULL(不限)| 50 MB(与 Ghostty 应用默认值相同 [R04 §2.1.4])| + +- 字节护栏的作用:宽终端或样式很多的输出(每页能放的行变少)不至于让 10 000 行变成几百 MB。行数配置面向用户,字节配置面向内存 [R04 §2.10]。 +- 修改上限会立即裁剪 [H:terminal.h:2227-2229]。[04 §6.2] 的记录类型里没有"修改回滚上限",所以 v1 规定:**设置修改只对新会话生效**,运行中的会话保持创建时的上限(两份核心因此始终一致)。若以后要即时生效,需在 04 增加 `SetScrollback` 记录。 +- 进程级内存监控:pty host 每 30 s 读一次每个会话的 `MEMORY_USAGE`(会遍历所有页,不要更频繁)[H:terminal.h:2954-2967],汇总到诊断页。 + +--- + +## 11. Kitty 图形 + +### 11.1 PNG 解码器的注入 + +libghostty 收到 PNG 格式(`f=100`)的图像时调用进程级的 `GHOSTTY_SYS_OPT_DECODE_PNG` 回调;不安装则拒绝 PNG [H:sys.h:118-173,204-213]。RGB / RGBA / 灰度、zlib 压缩(`o=z`)由库内部处理(Wuffs)[S:kitty/graphics_image.zig:636-675]。 + +| 方案 | 优点 | 缺点 | 结论 | +|---|---|---|---| +| **A 托管自写解码器 + `System.IO.Compression.ZLibStream`** | 零新依赖;内存安全;AOT 安全(BCL);**镜像核心和 UI 侧核心用同一份代码,结果逐字节一致** | 约 600 行代码 + 测试;性能低于原生(但 PNG 在 kitty 中多是小图)| **采用** | +| B 借用 Wuffs(已编进 libghostty,`WUFFS_CONFIG__MODULE__PNG` 已定义 [S:pkg/wuffs/build.zig:5-16])| 原生、快、Wuffs 本身内存安全 | 需要 Zig shim 导出一个 `decode_png` 并在构建里引用 Wuffs 模块 | 如果 M2 引入 `ext` 包,可以切换到 B(接口不变)| +| C SkiaSharp(RoyalTerminal 的做法)| 现成 | pty host 不应依赖 Skia 原生库(体积、AOT);两个进程的解码器不同会破坏一致性 | 不采用 | +| D 第三方托管库(StbImageSharp、BigGustave 等)| 现成 | 引入依赖,需要逐个做 AOT 实测和安全审计 | 不采用 | + +方案 A 的实现规格(`TCode.Terminal.Core.Imaging.PngDecoder`,`static`、无状态、线程安全): + +1. 校验签名;逐块解析(长度 ≤ 剩余字节、CRC32 用 `System.IO.Hashing.Crc32` 校验)。支持 IHDR、PLTE、tRNS、IDAT、IEND;忽略其他辅助块(含 gAMA、iCCP,不做色彩管理,与终端常见实现一致 **[推断]**)。 +2. 尺寸检查:宽、高 ≥ 1 且 ≤ 10 000;`width × height × 4` ≤ 单图上限(= `KittyGraphicsLimits.MaxImageBytes`,默认 64 MiB)。**解压前**按 IHDR 算出期望的解压长度,`ZLibStream` 读取时一旦超过就失败(防压缩炸弹)。 +3. 去滤波:None / Sub / Up / Average / Paeth;位深 1/2/4/8/16;颜色类型 0/2/3/4/6;Adam7 隔行。16 位取高字节。 +4. 输出:`ghostty_alloc(allocator, w*h*4)` 分配,直接写 RGBA(非预乘);失败时 `ghostty_free` 已分配的部分并返回 false [H:sys.h:126-152]。 +5. 回调外层 `try/catch`,任何异常都返回 false。 +6. 测试:PngSuite 全套(含故意损坏的 x*.png);fuzz(SharpFuzz)1 小时无异常、内存有界;与 `System.Drawing`-无关的参考值(预先用 libpng 生成的 RGBA 文件)逐字节比较。 + +### 11.2 v1 支持范围 + +| 功能 | v1 | 说明 | +|---|---|---| +| 直接传输(`t=d`)、分块、RGB/RGBA/PNG、zlib | ✅ | 库原生 | +| 文件(`t=f`)、临时文件(`t=t`)、共享内存(`t=s`)| ❌ 两边都关闭 | 临时文件和共享内存**读完即删除**,两份核心先后读取,后读的一方必然失败 → 状态分叉;`t=f` 有本地文件探测风险且 SSH 远端应禁止 [R03 §8.5]。libghostty 默认也只允许直接传输 [S:Terminal.zig:318-325] | +| 放置(`a=p`)、删除(`a=d`)、z 层(下 / 中 / 上)| ✅ | 渲染层按 `GhosttyKittyPlacementLayer` 三层绘制 | +| Unicode 占位符(虚拟放置,U+10EEEE)| ✅(需要自己解码)| C API 只告诉我们"是虚拟放置",占位符格子的图像 id(前景色)、行列(变音符)解码由我们实现(移植 Ghostty `graphics_unicode.zig`);cell 格式里有 `KittyPlaceholder` 位(§6.5)| +| 动画:客户端驱动换帧(`a=a,c=N`)| ✅ | 当前帧随 `DATA_PTR` 变化,`GENERATION` 会变 [H:kitty_graphics.h:402-406] | +| 动画:自动播放(`a=a,s=3`)| ❌ | 无 tick API(§8.9)| +| 查询 `a=q` | ✅ | 镜像核心应答 | + +### 11.3 内存上限 + +| 项 | 默认 | 说明 | +|---|---|---| +| 每屏存储上限 `KITTY_IMAGE_STORAGE_LIMIT` | 64 MB(libghostty 默认 10 MB,Ghostty 应用 320 MB [S:Terminal.zig:307-316])| 主屏和 alt 屏各一份 [H:terminal.h:2084-2095];0 = 关闭协议。必须两边一致 | +| 单图上限 | 64 MiB(PNG 解码输出)| §11.1 | +| UI 进程的像素副本(§11.4)| 进程总计 256 MiB,LRU | 不影响终端状态,可以只在 UI 侧设置 | + +双核心的代价:同一张图在镜像核心和 UI 侧核心各存一份解码后的 RGBA,UI 进程还有一份给渲染器的副本(以及 GPU 纹理)。64 MB 上限下最坏约 3–4 倍 **[推断]**。 + +### 11.4 捕获到 `FrameSnapshot.Images` + +1. 每帧读 `KITTY_GRAPHICS` 句柄和 `GENERATION`;代数不变且帧不脏时沿用上一帧的 `ImagePlacements`。 +2. 否则遍历放置:`placement_iterator_new`(复用)→ `kitty_graphics_get(PLACEMENT_ITERATOR)` → `placement_next` → `placement_get_multi([IMAGE_ID, PLACEMENT_ID, IS_VIRTUAL, Z])` → `kitty_graphics_image(id)` → `placement_render_info`(像素尺寸、格数、视口行列(可为负)、是否可见、源矩形)[H:kitty_graphics.h:445-487]。 +3. 像素:`image_get_multi([WIDTH, HEIGHT, FORMAT, DATA_PTR, DATA_LEN, GENERATION])`。按 `(imageId, generation)` 查 `ImagePixelCache`;未命中时把数据复制成**预乘 BGRA**(Skia 原生格式)放进池化缓冲。`DATA_PTR` 返回 `NO_VALUE` 表示负载还在传输中,跳过 [H:kitty_graphics.h:391-394]。 +4. `ImagePlacements` 引用缓存条目(引用计数);渲染层按 `(imageId, generation)` 管理纹理。 +5. 借用指针只在下一次终端变更前有效,所以复制必须在本次捕获内完成。 + +### 11.5 两份核心的一致性 + +| 情况 | 是否一致 | 处理 | +|---|---|---| +| 正常运行(两边收到同样字节)| 一致:同一解码器、同一上限、只允许直接传输 | — | +| UI 重连(快照恢复)| **不一致**:快照不含图像 [S:snapshot/terminal.zig:238-242],镜像核心仍有图像 | v1:重连后已有图像不显示(占位符格子保留但无图);之后新传输的图像正常。程序引用旧图像 id 的放置命令,镜像核心应答成功、UI 侧无图。在视图中显示一次"重连后部分图像不可用"的提示。根治需要 shim(§8.9)或上游支持 | +| 存储上限不同 | 会分叉(淘汰顺序不同)| `TerminalStateConfig` 强制一致 | +| PNG 解码失败 | 两边同样失败 | 同一解码器 | + +--- + +## 12. OSC 133 命令跟踪(`CommandTracker`,镜像核心) + +### 12.1 数据模型 + +```csharp +public sealed class CommandRecord +{ + public long Id; // 自增 + public CommandState State; // Prompt → Input → Running → Finished | Abandoned + public DateTimeOffset PromptAt, StartedAt, EndedAt; + public int? ExitCode; // has_exit_code 为 false 时为 null(Windows 退出码可为负)[H:terminal.h:1492-1500] + public string? CommandLine; // OUTPUT_START 的 command(shell 发送并已解码);633 E 需 nonce 匹配(§12.3) + // 退出码来自 OSC 133 D,与 shell 进程本身的退出码无关:macOS 上 login 包装会吞掉 shell 的 + // 退出码(login 总是 exit(0),计划用 kqueue NOTE_EXITSTATUS 取 shell pid 的退出状态,待 spike,见 03) + public string? ErrorText; // COMMAND_END 的 error + public string? WorkingDirectory; // 开始时的 cwd 原文 + public TrackedAnchor PromptStart, InputStart, OutputStart, OutputEnd; // 跟踪型 grid ref + public long OutputStartSeq, OutputEndSeq; // 输出日志序号(数据块粒度) +} +``` + +### 12.2 事件处理 + +`SEMANTIC_PROMPT` 回调在终端已更新屏幕之后调用 [H:terminal.h:1529-1531],此时光标位置就是事件发生的位置: + +| 事件 | 动作 | +|---|---| +| `PROMPT_START`(`prompt_kind` = PRIMARY)| 若上一条仍为 Running:标记 Abandoned。新建记录,`PromptStart = track(cursor)`。重复的 PROMPT_START(resize 后重画)只更新锚点,不新建 [H:terminal.h:1522-1527] | +| `PROMPT_START`(RIGHT / CONTINUATION / SECONDARY)| 忽略(多行输入)| +| `INPUT_START` | `InputStart = track(cursor)` | +| `OUTPUT_START` | `OutputStart = track(cursor)`;`StartedAt`;`CommandLine = e.command`;`OutputStartSeq = 当前块序号` | +| `COMMAND_END` | `OutputEnd = track(cursor)`;`ExitCode`、`ErrorText`、`EndedAt`;`State = Finished`。若此前没有 OUTPUT_START(命令被取消)→ Abandoned [R03 §2.4] | +| `RESET` 回调 | 清空全部记录、释放锚点 | + +- `track(cursor)`:`ghostty_terminal_grid_ref_track(t, {ACTIVE, CURSOR_X, CURSOR_Y})`(回调内只读访问,允许)。 +- 上限:保留最近 1000 条记录;淘汰时释放 4 个跟踪引用(每个跟踪引用都会增加终端变更的簿记开销 [H:grid_ref.h:74-76])。 +- 锚点失效(被回滚裁剪):`has_value == false`,该记录的输出标为"已不在回滚中"。 +- 序号是数据块粒度:回调发生在 `vt_write` 中间,拿不到块内字节偏移。Agent 需要字节级精确输出时,用 `OutputStartSeq` 前一块到 `OutputEndSeq` 的原始字节,交给一个临时终端重放后再 format **[推断:备用路径]**。 + +### 12.3 OSC 633(VS Code) + +libghostty 不实现 633,会通过 `UNKNOWN_SEQUENCE` 回调以 OSC 原文交给我们(需同时设置 `UNKNOWN_MAX_BYTES`)[H:terminal.h:131-197]。 + +- 镜像核心解析 `633;A/B/C/D[;exit]` 映射为同样的 CommandTracker 事件;`633;E;;` 只在 nonce 等于本会话注入脚本时生成的 nonce 时采用 [R03 §2.4];`633;P;Cwd=…` 作为 cwd 提示。 +- **限制**:633 不会在网格上打语义标记,所以 `select_output`、按 prompt 滚动对只发 633 的 shell 无效。TCode 自己的 shell 集成脚本([A01] D7)**只发 OSC 133**(含 kitty 的 `cmdline=` 扩展),633 只用于兼容别人的脚本。 + +### 12.4 读取命令输出(Agent) + +1. 首选:`ghostty_terminal_select_output(t, snapshot(OutputStart))` → `GhosttySelection` → `ghostty_terminal_selection_format_alloc(PLAIN, unwrap=true, trim=true)`。 +2. 退路:用 `OutputStart`、`OutputEnd` 两个锚点构造线性选区再 format。 +3. 输出上限:默认 256 KiB 文本,超出时保留头尾并在中间注明省略 **[推断:与 bash 工具现有截断策略保持一致,详见 09 号文档]**。 + +--- + +## 13. 输入编码 + +### 13.1 `InputModes` 与按键编码(UI 线程) + +按 [A01 §5.2] 与 [06 §3.4]:视图核心 actor 每处理完一批 `Write` 调用一次 `ReadInputModes()`,与上一份比较,有变化才用 `Volatile` 交换发布。它**不**随 `FrameSnapshot` 发布(2026 期间快照最多被扣 1 s,视图不可见时不生成)。UI 线程持有自己的 `GhosttyKeyEncoder` / `GhosttyMouseEncoder`(不绑定终端),`InputModes` 变化时逐项 `setopt`,**不调用 `setopt_from_terminal`**(那需要终端句柄,只能在 actor 线程)。 + +`ReadInputModes()` 的实现(一次 `ghostty_terminal_get_multi` + 一次 O4 调用): + +| `InputModes` 字段 | 来源 | 对应编码器选项 | +|---|---|---| +| CursorKeyApplication(DECCKM ?1)| `DATA_MODE` | `KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION` | +| KeypadApplication(?66)| `DATA_MODE` | `KEYPAD_KEY_APPLICATION` | +| IgnoreKeypadWithNumLock(?1035)| `DATA_MODE` | `IGNORE_KEYPAD_WITH_NUMLOCK` | +| AltEscPrefix(?1036)| `DATA_MODE` | `ALT_ESC_PREFIX` | +| BackarrowKeyMode(DECBKM ?67)| `DATA_MODE` | `BACKARROW_KEY_MODE` | +| ModifyOtherKeys2 | **O4** `tcode_ghostty_modify_other_keys_2` | `MODIFY_OTHER_KEYS_STATE_2` | +| KittyFlags | `DATA_KITTY_KEYBOARD_FLAGS` | `KITTY_FLAGS` | +| MouseTracking(9/1000/1002/1003)、MouseFormat(1005/1006/1015/1016)| `DATA_MODE` × 8 | 鼠标编码器 `EVENT` / `FORMAT` | +| AlternateScroll(?1007)、AltScreen(`DATA_ACTIVE_SCREEN`)、BracketedPaste(?2004)、FocusEvent(?1004)、GraphemeCluster(?2027)| `DATA_MODE` / `DATA_ACTIVE_SCREEN` | UI 侧逻辑(滚轮、IME 列宽、焦点上报)| +| Win32Input(?9001)| **O1** 后 `DATA_MODE(9001)` | 平台层自己编码 `CSI Vk;Sc;Uc;Kd;Cs;Rc _` [R03 §4.5] | +| CursorAtPrompt | `DATA_CURSOR_AT_PROMPT` [H:terminal.h:2925-2934] | 06 的按键路由 | + +`MACOS_OPTION_AS_ALT` 来自用户设置,不来自终端。`DATA_MODE` 是输入 / 输出型参数,`get_multi` 中可重复出现,每项指向不同的 `GhosttyTerminalModeConfig` [H:terminal.h:2899-2908] **[待 spike:确认 get_multi 允许重复键]**。 + +O4 不可用时的退路:按键改为在 actor 上编码(`setopt_from_terminal` 后编码,多一次线程跳转,约 10–50 µs [R04 §5.1]),`InputModes.ModifyOtherKeys2` 恒为 false 仅供显示。 + +### 13.2 鼠标与焦点 + +- 鼠标:UI 线程的编码器,`EVENT` / `FORMAT` 来自 `InputModes`,另设 `SIZE`(屏幕、cell、padding 的物理像素)、`ANY_BUTTON_PRESSED`、`TRACK_LAST_CELL`(1002 模式按格去重)[H:mouse/encoder.h]。 +- 焦点:`InputModes.FocusEvent` 为 true 时 `ghostty_focus_encode`(纯函数,可在 UI 线程调用)。 + +### 13.3 粘贴 + +与 [A01 §6.1]、[06 §7] 一致:粘贴在 **UI 侧核心**上执行,`EncodePaste` 期间打开 `write_pty` 捕获作用域收集输出。 + +1. `EncodePaste` 是视图 actor 上的命令(需要终端句柄):构造 `GhosttyPaste{ mimes = ["text/plain"], reader, source, allow_unsafe }`,调用 `ghostty_terminal_paste` [H:paste.h:170-187];输出经 `write_pty` 分块进入捕获缓冲。 +2. 返回 `GHOSTTY_REJECTED`:`PasteResult.RequiresConfirmation`,UI 弹确认框(预览前几行),确认后带 `allowUnsafe = true` 再调一次。 +3. 一致性:UI 侧核心不装 `clipboard_read`,所以粘贴**不会**变成 5522 粘贴事件(库只在"5522 开启且安装了 clipboard_read"时才发事件 [H:paste.h:20-28]),也就不会在 UI 侧核心留下授权记录;O2 另外把 5522 停用,DECRQM 如实回答。 +4. Agent 写入终端时没有 UI 侧核心:host 用 `ghostty_paste_encode`(不依赖终端的编码函数)按镜像核心当前的 2004 状态编码,**永远不**放行不安全内容 [R03 §8.4][H:paste.h:213-257]。 + +IME 提交的文本不是粘贴,作为普通输入字节发送 [H:paste.h:84-91]。 + +### 13.4 宽度模式(mode 2027) + +libghostty 的 2027 是运行时模式:关闭时零宽字符直接附着到前一格、VS16 不改宽度、ZWJ 不合并(每个 emoji 各自计宽);开启时按 UAX #29 分簇,VS16 → 2、VS15 → 1 [S:Terminal.zig:1286-1300,1475-1530][H:unicode.h:63-140]。`MODE_DEFAULT` 可设置复位默认值(2027 是"可配置默认"的模式)[S:modes.zig:406,494]。 + +| 平台 | `WidthPolicy` | 2027 默认 | ConPTY 标志 | 说明 | +|---|---|---|---|---| +| macOS / Linux | `Compat` | 复位 | — | D9:默认兼容,程序(neovim 等)开启后切换 | +| Windows(本地 ConPTY)| `Grapheme`(**锁定**)| **置位** | `PSEUDOCONSOLE_GLYPH_WIDTH_GRAPHEMES`(0x08)| 已写入 [A01](D9 更新):ConPTY 的字宽在创建时固定、忽略 2027,模拟器必须与之一致 [R02 §3 W5]。程序发 `CSI ? 2027 l` 时,libghostty 会照常复位该模式(两份核心一致),但 ConPTY 仍按字形簇布局 → 可能错位;v1 接受,见下 | +| Windows(SSH / WSL 走 ConPTY)| 同上 | 同上 | 同上 | 同上 | + +- 歧义宽度:libghostty **没有**"歧义宽度按宽字符处理"的选项(`ghostty_unicode_codepoint_width` 的规则里没有 EAW=A 分支 [H:unicode.h:40-58])。v1 不提供该设置;ConPTY 也不设 `AMBIGUOUS_IS_WIDE`。 +- Windows 的"锁定"只在核心层面是默认值:libghostty 没有"永久置位"的开关,DECRQM 2027 会回答 1/2(可切换)而不是 3(永久置位,WT 的回答 [R03 §2.2])。如果要严格锁定,需要再加一个 overlay(2027 在 Windows 构建中忽略复位)——不推荐,因为同一原生库服务所有会话;v1 接受上述偏差,在 10 号文档记录。 +- IME 预编辑列宽:UI 线程直接调用 `ghostty_unicode_codepoint_width`(2027 关)或 `ghostty_unicode_grapheme_width`(2027 开),模式取 `InputModes.GraphemeCluster`。 + +### 13.5 win32-input-mode(9001) + +**首选:O1**(§2.4)。登记后 `CSI ? 9001 h/l` 由 libghostty 正常处理,两份核心同步,DECRQM 如实应答,`InputModes.Win32Input` 从 `DATA_MODE` 读取,`ITerminalEffects.Win32InputModeChanged` 由门面在每批 `Write` 后比较状态触发。ConPTY 启动时发 `ESC[?9001h`、退出时 `?9001l` [R03 §4.5]。 + +**退路(O1 打不上时)**:`DecPrivateModeScanner`(约 120 行,托管)。在 `Write` 把字节交给 `vt_write` **之前**扫描,只识别 `ESC [ ? Pm h/l`(`Pm` 为分号分隔的参数列表)中的 9001,跨块保存部分状态;遇到其他序列立即回到空闲态;CAN / SUB / ESC 中断时丢弃。两份核心都运行它(相同字节 → 相同结论)。RIS 时复位。代价:DECRQM 9001 回答 0;快照不含该状态,attach 元数据需带上 `win32InputMode`。只在 Windows 会话启用。 + +--- + +## 14. 线程与重入规则 + +### 14.1 总则 + +| 规则 | 来源 | +|---|---| +| 一个 `TerminalCore` 只被一个线程(所属 actor)访问;libghostty 本身不建线程,单个终端不是线程安全的 | [R01 §3.3][A01 §5] | +| 回调在调用 `vt_write` / `resize` / `reset` / `paste` 的线程上同步执行;回调内禁止重入写入 | [H:terminal.h:79-84,3060-3063] | +| 借用指针(`DATA_TITLE` 字符串、Kitty 句柄、grid ref、render state 行数据)只在下一次终端变更前有效;门面绝不把它们存过一次调用 | [H:terminal.h:2649-2651][H:kitty_graphics.h:109-115] | +| UI 线程 / 渲染线程只读 `FrameSnapshot`(不可变)| [A01 §5.2] | +| 进程级 API(`ghostty_sys_set`、`ghostty_unicode_*`、`ghostty_type_json`、`ghostty_build_info`)可在任意线程调用;`sys_set` 只在启动时调用一次 | [H:sys.h:255-258][H:unicode.h:63-66] | + +### 14.2 actor 内的调度 + +视图 actor 的一次循环:处理所有排队的 `Output` 块(每块后检查是否有高优先级命令:按键编码、视口滚动)→ 若渲染端设置了"想要帧"则 `CaptureFrame` → 空闲时:搜索 `feed` + `tick`(2 ms 时间片)、历史恢复 `PumpHistory`、压缩(§7.3)。搜索的 `tick` 理论上可以放到工作线程 [H:search.h:84-96],v1 为简单起见留在 actor 上。 + +### 14.3 OSC 52 读:延后应答(O3) + +[A01 §3.2 规则 6]:镜像核心触发,host 向主视图发 `clipboard/readRequest`,UI 询问用户后回 `clipboard/readResponse`,host 写回 PTY [04 §11.2]。libghostty 的原始语义是"回调返回前必须应答,否则自动回空剪贴板"[H:terminal.h:969-981][S:stream_terminal.zig:1096-1100],所以需要 O3。 + +O3 打开后镜像核心的流程: + +1. 回调里检查 `location`(标准 / primary / selection)和 OSC 52 的终止符(BEL 或 ST;库在 `ClipboardReadState` 里保存了它,但回调参数里没有 → **O3 补丁同时把 terminator 放进 `GhosttyClipboardRead` 末尾的新字段**,sized struct 允许追加 [H:terminal.h:969-975])。 +2. 策略 `deny` 或没有 UI 连接:不调用 `reply`、也不登记请求(O3 打开时等于不应答)。 +3. 否则不调用 `reply`,返回;门面生成 `PendingClipboardRead{ id, location, terminator, seqAtRequest }` 交给 host。 +4. UI 允许后,host 在会话 actor 上调用 `core.CompleteClipboardRead(pending, data, writer)`:编码 `ESC ] 52 ; ; <终止符>` 放进 PTY 写队列。拒绝或 60 s 超时:不写任何东西。 + +拒绝时不应答与 [R03 §8.1]、[04 §11.2] 一致;库的默认(回空剪贴板)让程序不必等超时,但会和 host 稍后的真实应答重复,所以 O3 打开时一律不用。OSC 5522(kitty 剪贴板协议)的读取也会进同一个回调:v1 对它同步回 `UNSUPPORTED`(库按协议回 ENOSYS [H:terminal.h:913-916]),不转发给 UI。 + +O3 打不上时的退路:回调内同步阻塞——IPC 询问 UI,最多等 30 s,期间该会话暂停解析;超时回 `DENIED`(空应答)。这是 libghostty 文档认可的方式("an embedder that needs to ask the user must block"[H:terminal.h:1044-1048])。 + +### 14.4 回调中能做什么 + +| 允许 | 禁止 | +|---|---| +| `ghostty_terminal_get` / `get_multi`、`grid_ref`、`grid_ref_track`、`point_from_grid_ref` | `vt_write`、`vt_write_until_ground` | +| `ghostty_render_state_update`(仅 render hold 回调)| `resize`、`reset`、`set`(选项)、`paste`、`snapshot_encode` | +| 复制借用数据、入队消息 | 调用 `ITerminalEffects` 之外的门面公开方法(`_inCallback` 检查)| +| (仅 ClipboardRead)有超时的阻塞 IPC | 无超时的阻塞;访问其他会话的核心 | + +--- + +## 15. 差分测试 + +### 15.1 目标 + +捕获三类问题:(a) 终端没有正确标记 dirty,导致增量渲染结果与真实状态不同;(b) render state 自身的复制错误;(c) 两份核心、快照往返之间的状态分叉。 + +### 15.2 增量 vs 全量(每帧) + +``` +DiffHarness(input bytes, chunker): + t = new terminal(配置 = View 角色) + inc = FrameCapturer(t) // 正常路径:一个长期存在的 render state + for chunk in chunker(input): // 随机切块,含 1 字节切块、序列中间切块 + t.Write(chunk) + a = inc.Capture() // 增量 + b = FullRebuild(t) // 新建 render state → update 一次(必然 FULL)→ 转换 → 释放 + AssertEqual(Normalize(a), Normalize(b), chunkIndex, byteOffset) + 每 N 块: c = GridRefRebuild(t) // 不经过 render state:逐格 grid_ref_cell/style/graphemes + AssertEqual(Normalize(b), Normalize(c)) +``` + +- `Normalize`:把 `FrameSnapshot` 展开为"每格(码点 + 额外码点、宽度、ResolvedStyle 全字段、语义、超链接 URI)+ 行标记 + 光标 + 调色板";`RowId`、`Version`、`Generation` 不比较(它们本来就随路径不同)。 +- 比较 a 与 b 抓的是 (a) 类问题(漏标 dirty);比较 b 与 c 抓的是 (b) 类问题。 +- 新建的 render state 不会干扰增量那一个:顺序是"写入 → 增量 update(消费 dirty)→ 新建 update(全量)",两次 update 之间没有写入。 +- 切块策略:固定 4 KiB;随机 1–64 字节;**逐字节**(小语料);在每个 ESC、每个多字节 UTF-8 中间各切一次。RoyalTerminal 第 1 项问题(`K` 与 U+0301 之间有一次干净的帧)只有在两个字符之间做一次 capture 才能复现。 +- 失败时自动最小化:对块序列做 ddmin(删块、合并块),输出最小复现的 `.in` 文件 + 首个差异格。 + +### 15.3 其他一致性对照 + +| 对照 | 做法 | 抓什么 | +|---|---|---| +| 镜像 vs UI | 两个终端(Mirror / View 配置)喂同样字节;每 K 块比较 `Format(VT, 全部 extra)` 输出与全量捕获 | effects 配置差异导致的分叉(§1 第 3 点的不变量)| +| 快照往返 | 任意时刻 encode → decode(含逐页 `next` 期间继续喂字节)→ 与原终端比较全量捕获和 formatter 输出 | 快照遗漏的状态、续传错误(RoyalTerminal 第 5 项)| +| 两种切块 | 同一输入用两种切块方式喂两个终端,结果必须相同 | 跨块解析状态错误 | +| 宽度 | ucs-detect 语料在两种宽度策略下各跑一次 | 2027 切换 | + +### 15.4 语料 + +1. RoyalTerminal 12 项问题的最小复现(§15.5,逐项写成用例)。 +2. xterm.js `escape_sequence_files`(约 80 对)、Alacritty ref tests(45 个,含 tmux_htop、vim)[R03 §9.1]。 +3. 自录的真实程序:vim、htop、tmux 分屏、less、fzf、Claude Code / Codex 一轮对话(离线 / 回放)。 +4. fuzz:结构化变异(在合法序列中插入 ESC / CAN / C1 / 超长参数 / 超长 OSC / 半截 UTF-8),以及专门的"dirty 压力"生成器:随机光标移动 + 宽字符 + 零宽字符 + OSC 133 + 软换行边界写入 + 滚动区 + DECSTBM。 +5. CI:每个 PR 跑 1–3(约 2 分钟);夜间跑 fuzz 30 分钟,使用 `ReleaseSafe` 原生库。 + +### 15.5 RoyalTerminal 12 项修正在 HEAD(`b3dc3d7`)上的状态 + +RoyalTerminal pin 在 `b40acce5`,以 overlay 方式修正了 12 处 [RT]。逐项检查当前代码: + +| # | 问题([RT] 原文概括)| HEAD 上的代码 | 状态 | +|---|---|---|---| +| 1 | 2027 关闭时追加零宽字符,没有标记光标行 dirty,render state 只显示基字符 | `print` 的 width==0 分支直接 `appendGrapheme(prev, c)`,前面没有 `cursorMarkDirty`(2027 开启的分支有)[S:Terminal.zig:1475-1530];`Page.appendGrapheme` 只设 `row.grapheme`,不设 dirty [S:page.zig:1562-1591] | **仍存在**(代码阅读)| +| 2 | 覆盖软换行宽字符的任一半时,清除了上一行的 spacer_head,但上一行没标 dirty | `writeCell` 的 `.wide` / `.spacer_tail` 分支把 `cursorCellEndOfPrev()` 改成 narrow,没有标记上一行 [S:Terminal.zig:1681-1712] | **仍存在**(代码阅读)| +| 3 | Kitty 动画删除帧时 `current_index` 下标差一 | `removed_idx = number - 2` 与包含根帧的 `current_index`(0 = 根帧 [S:kitty/graphics_animation.zig:95-98])比较 [S:kitty/graphics_storage.zig:1546-1560] | **仍存在**(代码阅读)| +| 4 | 淘汰请求应按实际占用而不是准入上限计算(RGB→RGBA 提升后可超限)| `evictImageExcept` 仍有 `assert(req <= self.total_limit)`;`reserveAnimationBytes` 加了保护,`addImage` 路径没有 [S:kitty/graphics_storage.zig:298-304,1003-1025] | **疑似仍存在**;ReleaseFast 下 assert 变为未定义行为 | +| 5 | APC 之后切换到 C1 引导的 DCS/CSI/OSC 时,续传导出未规范化 | `stream_continuation.zig` 未见对应规范化逻辑 | **未能确定**,用 §15.3 快照往返 + APC/C1 语料验证 | +| 6 | OSC 133 和隐式换行续行改了行的语义标记,没有标 dirty | `cursorSetSemanticContent` 改 `page_row.semantic_prompt` 无 dirty [S:Screen.zig:2948-2980];换行续行处同样 [S:Terminal.zig:1822,2229,2384][S:Screen.zig:3842,3878] | **仍存在**(代码阅读)| +| 7 | `cursorResetWrap` 清除下一行的 wrap_continuation,没有标 dirty | [S:Screen.zig:1639-1664] 无 dirty | **仍存在**(代码阅读)| +| 8 | bitmap 分配器大块分配越界读 | 大块路径在 `while (rem > 64)` 之后直接读 `bitmaps[i]`,`i` 可能等于 `bitmaps.len`(例如单字中请求 65 块)[S:bitmap_allocator.zig:286-322] | **仍存在**(代码阅读);超链接、字形簇分配可触发 | +| 9 | PageList 非 reflow 克隆失败后的行重置与 pin 重映射 | 涉及多段错误路径 | **未核实**,需移植 [RT] 的回归用例 | +| 10 | 光标页样式扩容时恢复的超链接引用泄漏 / 空指针 | 同上 | **未核实** | +| 11 | scroll-clear 裁剪后光标 pin 已迁移,旧页内 id 失效 | 同上 | **未核实** | +| 12 | 非 reflow 加宽时新页连一行都放不下导致无限分配 | 同上 | **未核实**([RT] 自述该补丁"已编写但未构建运行")| + +处理: + +- 1、2、6、7 是"漏标 dirty",用户可见(显示旧内容)。M1 必须有对应的失败用例;若上游 30 天内未修,按 §2.3 加 overlay(每处都只是加一行 `markDirty`)。同时向上游报告。 +- 3、4、8 是 Kitty / 分配器的健壮性问题:3 只影响动画显示;4、8 在 ReleaseFast 下是未定义行为,属于**安全问题**,优先报上游;在上游修复前,fuzz 和差分测试使用 ReleaseSafe 构建以便发现。 +- 5、9–12:移植 [RT] 的回归用例(MIT 许可,注明来源)后再判断。 + +--- + +## 16. 风险与 spike 清单 + +| # | 风险 / 待验证项 | 验证方法 | 阶段 | +|---|---|---|---| +| S1 | 6 RID 构建与加载;Windows x64 无 AVX 指令 | CI 矩阵 + 反汇编检查 | M0 | +| S2 | 按值传递结构体在 6 RID 上的 ABI | §4.4 往返测试 | M0 | +| S3 | ClangSharp XML 输出能否覆盖全部头文件 | 生成器原型 | M0 | +| S4 | `[SuppressGCTransition]` 用在哪些函数上安全且有收益 | BenchmarkDotNet,对比有无 | M1 | +| S5 | 每帧 FFI 成本(§8.8)在 Windows arm64、Linux arm64 上的实测 | Benchmarks S1/S4 | M1 | +| S6 | 50 MB 回滚的快照编码耗时、解码耗时 | 基准 | M2 | +| S7 | ~~ReleaseSafe 与 ReleaseFast 的吞吐差距~~(已决定 ReleaseFast,不再需要) | — | — | +| S8 | Windows:ConPTY `GLYPH_WIDTH_GRAPHEMES` + 2027 默认置位下,pwsh / cmd / WSL 中 emoji、CJK 光标是否对齐;程序复位 2027 后的错位程度 | 手工 + 回放 | M1 | +| S13 | O1:追加 9001 后 `ModePacked` 变为 44 位,确认 snapshot 编解码与上游测试没有限定 43 位的 comptime 断言 | 构建 + 快照往返 | M0 | +| S14 | O3:延后应答 + `GhosttyClipboardRead.terminator` 追加字段;host 稍后写入的应答被 neovim / tmux 正确接收 | 回放 + 手工 | M2 | +| S15 | `terminal_get_multi` 是否允许重复的 `DATA_MODE` 键(§13.1)| 单元测试 | M0 | +| S16 | revive 裁剪:中途释放解码器后再编码(§9.4 第 3 步)| 单元测试 | M2 | +| S9 | OSC 52 读阻塞期间的用户体验(30 s 上限是否合适)| 手工 | M4 | +| S10 | 托管 PNG 解码器的性能(1920×1080 PNG)与 fuzz | 基准 + fuzz | M6 | +| S11 | SCREEN 坐标与 scrollbar offset 是否同一行空间(§8.5、§8.6)| 单元测试 | M1 | +| S12 | 差分测试本身的开销(每块全量重建)能否在 CI 时间预算内 | 计时 | M1 | +| R1 | 上游 API 变动频繁(近 90 天 `include/ghostty/vt` 82 次提交 [R01 §4.1.1])| 每月升级 + §2.2 检查单 | 持续 | +| R2 | 上游迁出 GitHub 期间源不可用 | §2.5 | 持续 | +| R3 | dirty 漏标类问题(§15.5)| 差分测试 + overlay | M1 起 | +| R4 | ReleaseFast 下 Zig 断言变为未定义行为,被恶意输出触发 | 报上游;fuzz 与差分测试用 ReleaseSafe 构建尽早发现 | 持续 | +| R5 | 快照格式在升级间不兼容 | 只用于同构建进程间;revive 带 `tail.vt` 回退(§9.4)| — | +| R6 | overlay O1–O4 在上游升级时失效 | §2.2 第 6 步;每项都有退路(§2.4)| 持续 | + +--- + +## 17. 与 01 / 04 / 06 的差异及待决问题 + +本文没有修改其他文档。按协调方更新后的 01(D9、规则 1–8、`InputModes`、`TerminalSize`、`ghosttyBuildId`)和 04 §8、§10 对齐后,仍需处理的差异: + +| # | 位置 | 现状 | 本文结论 | 依据 | +|---|---|---|---|---| +| 1 | 01 §6.1 `ClipboardRead` 注释"需要询问时返回待定";04 §11.2"返回待定 → 稍后写回" | libghostty 原生不支持"待定":回调返回而不应答,库会自动回空剪贴板,之后 host 再写会产生两次应答 | 需要 overlay **O3**;退路是回调内同步阻塞。01 规则 6 的流程不变 | [S:stream_terminal.zig:1072-1101]、§14.3 | +| 2 | 01 规则 7 "5522 在 v1 不启用" | 镜像核心为 OSC 52 读安装了 `clipboard_read`,库会把 5522 报告为已识别、并允许程序打开它 | overlay **O2** 把 5522 标为 disabled;退路下只是 DECRQM 声明不准确,字节一致 | [S:stream_terminal.zig:1908-1920]、§2.4 | +| 3 | 01 §5.2 / 06 §3.4 的 `InputModes` 含 modifyOtherKeys | C API 没有读取 modifyOtherKeys 的接口 | overlay **O4**;退路:按键回到 actor 上编码 | §13.1 | +| 4 | 01 规则 8(9001)| — | 采用 overlay **O1**(具体计划见 §2.4),退路为托管扫描器 | §13.5 | +| 5 | 06 §6 "OSC 52 写由 UI 侧核心触发、镜像核心忽略" | 与 04 §11 "效果由镜像核心产生、UI 侧核心忽略"相反 | 按 04:镜像核心产生,host 发 `session/clipboardWrite` 给主视图 | §7.2 | +| 6 | 04 §8.2 "编码超过 50 ms 就改用 `historyLines` 裁剪" | 裁剪在完整编码之后,不减少会话 actor 的占用时间 | 该退路只减小传输与 UI 解码;actor 时间需上游接口或 ext | §9.2 | +| 7 | 04 §10.3 "中途停止解码的终端能否再编码"待验证 | 头文件说明放弃增量解码后终端可继续使用 | 先 `decoder_free` 再编码,列为 S16 | [H:snapshot.h:434-445]、§9.4 | +| 8 | 04 §6.2 记录类型 | 没有"修改回滚上限" | v1 回滚设置只对新会话生效 | §10 | +| 9 | 04 §6.2 `Clear` | C API 没有清屏函数 | 门面注入 `CSI 3 J` / `CSI H CSI 2 J`,要求 host 只在镜像核心处于 ground 时追加该记录 | §6.6 | +| 10 | 01 §6.1 `Restore(ReadOnlySequence)` | 一次性恢复 | 增量的 `BeginRestore(ISnapshotSource) → RestoreSession`(与 04 §8.2 的 `decoder_next` 循环一致)| §9.3 | +| 11 | 01 §3.1 "重连时恢复屏幕和回滚" | Kitty 图像不在快照里 | 重连后已有图像在 UI 侧丢失,v1 接受并提示 | §11.5 | +| 12 | 01 §6.1 `RowId` 注释"来自 libghostty 的行 ID" | libghostty 行 id 为 128 位 | 门面映射为 64 位自增 id,另加 `ContentHash` | §8.3 | +| 13 | 01 §1、04 §7.1 "libghostty 解析约 500 MB/s" | 调研只有相对数据(SIMD 比标量快 7.3 倍),没有 MB/s 实测 [R01 §3.4] | 改为"待基准测试确定",04 的流控参数推算以此为前提 | — | +| 14 | D9 Windows 宽度锁定为字形簇 | libghostty 没有"永久置位",程序仍能复位 2027,DECRQM 回答 1/2 而不是 3 | v1 接受;严格锁定需要额外 overlay,不推荐 | §13.4 | + +待决问题: + +1. ~~DA2 应答~~:已决定 `>1;10;0c`(2026-10-17,见 [10](10-protocol-compat.md) §2)。 +2. ~~pty host 的原生库用 ReleaseFast 还是 ReleaseSafe~~:已决定发布用 **ReleaseFast**(2026-10-17,与 Ghostty 一致);fuzz 和差分测试继续用 ReleaseSafe 构建。 +3. **是否以后引入 `ext` 解决 §8.9 余下的缺口**(重连丢图、动画自动播放、2033、OSC 99)。 + +--- + +## 18. 来源 + +| 键 | 来源 | +|---|---| +| H | ghostty-org/ghostty @ `b3dc3d794513c11633e7c3347935693957744e83`(2026-10-15)`include/ghostty/vt/*.h`、`include/ghostty/vt.h` | +| S | 同一 commit 的 `src/terminal/`:`Terminal.zig`、`Screen.zig`、`page.zig`、`render.zig`、`stream_terminal.zig`、`stream_continuation.zig`、`modes.zig`、`bitmap_allocator.zig`、`kitty/graphics_storage.zig`、`kitty/graphics_animation.zig`、`kitty/graphics_image.zig`、`snapshot/{main,terminal,screen}.zig`、`c/types.schema.json`;`pkg/wuffs/build.zig` | +| RT | royalapplications/RoyalTerminal @ `01833b8`:`native/ghostty-vt-extensions/README.md`、`src/RoyalTerminal.GhosttySharp/GhosttySys.cs` | +| DN | deblasis/libghostty-vt-dotnet @ `c6f23d8`:`ghostty-upstream.json`、`build/generate-bindings.sh`、`build/generate-bindings.rsp`、`build/check-binding-symbols.sh`、`src/Ghostty.Vt/Native/NativeMethods.cs` | +| MH-0428 | https://mitchellh.com/writing/ghostty-leaving-github(2026-04-28)| +| R01/R03/R04/R05 | 本目录 `research/` 下的调研报告 | diff --git a/docs/terminal/03-pty.md b/docs/terminal/03-pty.md new file mode 100644 index 0000000..52ad753 --- /dev/null +++ b/docs/terminal/03-pty.md @@ -0,0 +1,1204 @@ +# TCode 终端 03:PTY 层(TCode.Terminal.Pty + native/tcodepty) + +> 状态:详细设计 v1(2026-10-16)。上游:[01-architecture.md](01-architecture.md)(D1–D9、§5 线程模型、§6.2 接口)、[research/02-pty.md](research/02-pty.md)、[research/05](research/05-platform-behavior-config.md) §1/§2/§8、[research/06](research/06-ssh-transport.md) §6。 +> 本文写到"照着就能实现"的粒度:C 头文件、helper 源码、C# 互操作声明、线程与关闭状态机、构建命令、测试与 spike 清单。 + +**标注约定**(沿用 research/02) + +- **[实]**:已在源码、官方文档或本机实测中核实;源码引用写成 `前缀 路径:行号`,前缀与提交号见 research/02 §0,本文新增的见下表。 +- **[推]**:推断,实现前需要实测,并已列入 §15 spike 清单。 + +| 前缀 | 对象 | 版本 | +|---|---|---| +| LOGIN | apple-oss-distributions/system_cmds `login/login.c` | `408bba7` | +| XNU | apple-oss-distributions/xnu `bsd/kern/kern_event.c` | 本地快照 xnu-12377.1.9 | +| GH | ghostty `src/termio/Exec.zig`、`src/terminal/modes.zig` | `b3dc3d7` | +| WT | microsoft/terminal `src/winconpty/winconpty.cpp`、`src/inc/conpty-static.h`、`src/host/*`;`src/terminal/adapter/adaptDispatch.cpp`(main,2026-10-16 通过 GitHub 读取) | `44b440b` | +| MP | MiniPty `src/MiniPty/Native/minipty_unix_exit.c`、`scripts/build-native.sh` | `7460059` | +| PP | Porta.Pty `src/Porta.Pty/build/Porta.Pty.targets`、`ConPtyImportResolver.cs` | `0186b60` | +| RT | dotnet/runtime `src/native/libs/System.Native/pal_process.c`、`pal_signal.c` | `bc110ba` | +| local | 本机 macOS 26.6.2,bash 3.2.57,zsh | 2026-10-16 | + +--- + +## 0. 结论摘要 + +1. **Unix**:C# 一次 `LibraryImport` 调用进入 `tcpty_spawn`。它在 C 里完成 `/dev/ptmx` → termios(IUTF8) → winsize → `posix_spawn(helper)`,参数为 `SETSID | SETSIGDEF(全集) | SETSIGMASK(空)`,macOS 另加 `CLOEXEC_DEFAULT`。helper 依次执行 `TIOCSCTTY`、close_range、chdir、`execvp`,exec 之前出错就通过 fd 3 的状态管道报告。**Linux 也走 helper**:我们的 file action 是 `adddup2`(dup 不会自动取得控制终端),而且 RHEL 8 的 glibc 2.28 没有 `addchdir_np`。 +2. **读**:每个会话一个专用读线程,调用 `tcpty_read_gather`(Ghostty 的算法:第一次 EAGAIN 就交付;只有凑满 ≥1 KiB 时才自旋 16 次、poll 1 ms,单批总预算 3 ms,上限 64 KiB)→ `PipeWriter`,`pauseWriterThreshold = 256 KiB` 就是 01 §5.1 说的"4 块 × 64 KiB"。**写**:每个会话一个专用写线程,按 4 KiB 分块;遇到 EAGAIN 就 poll `POLLOUT` 和 wake fd。 +3. **退出**:整个进程一个退出监视线程(Linux 用 pidfd + epoll,macOS 用 kqueue),只调用 `waitpid(pid)`;pidfd 不可用时,每个会话一个阻塞 `waitpid` 的线程。Windows 用 `ThreadPool.RegisterWaitForSingleObject`。**`Exited` 要等输出排空(EOF,或静默 100 ms,最长 1 s)之后才完成。** +4. **关闭**:Unix 依次 SIGHUP 前台进程组和 shell 进程组 → 关闭 master(内核挂断)→ 2 s 后 SIGKILL。Windows 在专用线程上调用 `ClosePseudoConsole`,读线程同时切到"丢弃模式"继续读,读到 `ERROR_BROKEN_PIPE` 为止,超时后 `TerminateProcess`。 +5. **Windows**:按绝对路径加载随包的 `conpty.dll`,`OpenConsole.exe` 放在 `x64\`、`arm64\` 子目录;加载失败时回退 kernel32 并记录"降级"。函数指针用 `delegate* unmanaged`。读线程在 `CreatePseudoConsole` 之后、`CreateProcessW` 之前启动,DA1 由镜像核心应答。每个会话一个 Job,标志为 `KILL_ON_JOB_CLOSE | BREAKAWAY_OK`;正常关闭时先清除 `KILL_ON_JOB_CLOSE` 再关闭 Job 句柄,所以 Job 只在崩溃时兜底。 +6. **新发现**(详见 §16): + - macOS 的 `login` 父进程等到 shell 结束后**总是 `exit(0)`** [实 LOGIN login.c:854-883],shell 的退出码丢失;因此 2026-10-17 决定不经 login 包装,改用 argv0 `-zsh`(§8.3); + - ConPTY 把 mode 2027 当作"永久"模式,设置无效 [实 WT adaptDispatch.cpp:1891-1892、2032-2033],宽度模式在创建时就固定了; + - libghostty-vt 的模式表里没有 9001 [实 GH modes.zig]; + - research/02 建议的 `trap - PIPE` **对 bash 无效** [实 local]; + - `PR_SET_PDEATHSIG` 跟随的是"父线程",所以不采用。 + +--- + +## 1. 项目结构与文件清单 + +``` +native/tcodepty/ + include/tcodepty.h C API(§2),C# 侧逐字镜像 + src/tcpty_spawn.c init、spawn、termios、winsize、状态管道协议 ~220 行 + src/tcpty_io.c read_gather、read、write、wait、wake、set_nonblock、close、QoS ~150 行 + src/tcpty_exit.c exitwatch(epoll+pidfd / kqueue)、reap、信号映射 ~170 行 + src/tcpty_proc.c 前台进程组、进程名/路径、cwd、子进程、termios 状态 ~150 行 + src/tcpty_internal.h 错误码映射、fd 提升(F_DUPFD_CLOEXEC ≥ 10) + helper/tcode-pty-spawn.c helper 可执行文件(§3):PTY / --exec / --detach 三种模式 ~85 行 + build.sh ./build.sh [--static](§11) + check-glibc.sh 检查 .so 依赖的最高 GLIBC_ 符号版本 ≤ 2.28 +src/TCode.Terminal.Pty/ + TCode.Terminal.Pty.csproj IsAotCompatible、AllowUnsafeBlocks、DisableRuntimeMarshalling + ITerminalConnection.cs 接口与值类型(§4.1) + PtyLaunch.cs PtyLaunch、PtyWidthMode、PtyLaunchHints + LocalPty.cs 工厂:LocalPty.Start(PtyLaunch, LocalPtyOptions) + LocalPtyConnection.cs 与平台无关的部分:Pipe、读写线程、Exited、关闭状态机(§5) + LocalPtyOptions.cs、PtyException.cs、PtyDiagnostics.cs + Unix/TcPtyNative.cs LibraryImport 声明(§4.2) + Unix/TcPtyStructs.cs blittable 结构体 + Unix/PtyFdHandle.cs SafeHandle(master fd、wake fd) + Unix/UnixPtyBackend.cs spawn 编组、gather 读、写、resize、信号、关闭 + Unix/UnixExitWatcher.cs 进程级退出监视 + waitpid 回退线程 + Unix/UnixProcessInfo.cs 前台进程、cwd(§8) + Unix/NativeStringBlock.cs argv / envp 编码为 UTF-8 的 char** + Unix/HelperLocator.cs + Windows/Win32.cs kernel32 LibraryImport(§7.6) + Windows/ConPtyApi.cs conpty.dll 加载与函数指针(§7.1) + Windows/PseudoConsoleHandle.cs + Windows/ConPtyBackend.cs 调用序列(§7.3) + Windows/WindowsJob.cs、Windows/EnvironmentBlock.cs、Windows/CommandLine.cs + Windows/ProcessTree.cs Toolhelp32(§8.2) +tests/TCode.Terminal.Pty.Tests/ §14 +``` + +- libtcodepty 实际约 690 行,比 01 估计的约 300 行多。多出来的是 exitwatch、proc 查询和错误映射,所有平台差异都集中在这里。 +- Windows 没有我们自己的原生代码 [实 research/02 §5.4]。 + +--- + +## 2. libtcodepty C API(头文件草案) + +约定(对所有函数都成立): + +| 项 | 约定 | +|---|---| +| 返回值 | `int32_t`:`>= 0` 表示成功(含义见各函数说明);`< 0` 是 **可移植错误码** `TCPTY_E_*`。errno 在 Linux 和 macOS 上取值不同(例如 EAGAIN 分别是 11 和 35),所以 C# 只根据可移植码分支。原始 errno 用 `tcpty_last_errno()`(线程局部)取,仅用于日志 | +| EINTR | 全部在 C 里重试,绝不返回给调用方 | +| fd 所有权 | 返回给调用方的 fd 都带 `FD_CLOEXEC`,并已提升到 ≥ 10(防止与 0–3 冲突,见 §2.2),由调用方负责关闭(`tcpty_close`) | +| 输入字符串 / 数组 | 借用,只在调用期间有效;编码为 UTF-8,以 NUL 结尾 | +| 输出字符串 | 写入调用方提供的缓冲区,**返回完整长度**(不含 NUL),语义同 `snprintf`:返回值 ≥ cap 表示被截断 | +| 结构体 | 首字段 `uint32_t size` = `sizeof`,用于以后扩展;C# 填 `sizeof(T)` | +| 线程安全 | 全部可重入;`tcpty_spawn` 内部在 fd 创建到 posix_spawn 这一段持有全局互斥锁(macOS 并发 openpt 会以 -ENXIO 失败 [实 research/02 §2.3、U7]) | +| ABI 版本 | `tcpty_version()` 返回 `major << 16 \| minor`。动态加载时 C# 检查 major 是否相等;静态链接(DirectPInvoke)时不会不一致 | + +```c +/* tcodepty.h — libtcodepty C API v1.0 + * 只在 linux-{x64,arm64}、osx-{x64,arm64} 上构建;Windows 不使用。 */ +#ifndef TCODEPTY_H +#define TCODEPTY_H +#include +#ifdef __cplusplus +extern "C" { +#endif +#define TCPTY_API __attribute__((visibility("default"))) +#define TCPTY_ABI_MAJOR 1 +#define TCPTY_ABI_MINOR 0 + +/* ───────── 错误码(可移植,负值) ───────── */ +enum { + TCPTY_OK = 0, + TCPTY_E_AGAIN = -1, /* EAGAIN / EWOULDBLOCK */ + TCPTY_E_IO = -2, /* EIO / EPIPE / ECONNRESET:对端已消失 */ + TCPTY_E_PERM = -3, /* EPERM / EACCES */ + TCPTY_E_NOENT = -4, + TCPTY_E_SRCH = -5, /* 进程不存在 */ + TCPTY_E_CHILD = -6, /* 没有这个子进程(已被别人回收)→ 退出码未知 */ + TCPTY_E_NOSYS = -7, /* ENOSYS / ENOTSUP:内核或 seccomp 不支持 */ + TCPTY_E_INVAL = -8, + TCPTY_E_NOMEM = -9, + TCPTY_E_BADF = -10, + TCPTY_E_NXIO = -11, /* pty 用尽或 macOS 并发竞争(已重试过) */ + TCPTY_E_TIMEDOUT = -12, + TCPTY_E_MFILE = -13, /* EMFILE / ENFILE */ + TCPTY_E_NOTTY = -14, + TCPTY_E_OTHER = -99 +}; +TCPTY_API int32_t tcpty_version(void); +TCPTY_API int32_t tcpty_last_errno(void); /* 线程局部,最近一次失败的原始 errno */ +TCPTY_API int32_t tcpty_strerror(int32_t raw_errno, char *buf, int32_t cap); + +/* ───────── 进程级初始化(幂等;pty host 启动时调用一次) ───────── */ +typedef struct tcpty_init_info { + uint32_t size; + int32_t pidfd_supported; /* Linux:pidfd_open(getpid()) 探测成功;macOS 恒为 0 */ + int32_t sigchld_was_ignored; /* 进入时 SIGCHLD 为 SIG_IGN 或带 SA_NOCLDWAIT;已复位为 SIG_DFL(见 §10) */ + int32_t stdio_repaired; /* fd 0–2 中有空位,已用 /dev/null 补上 */ +} tcpty_init_info; +TCPTY_API int32_t tcpty_init(tcpty_init_info *info); + +/* ───────── 启动 ───────── */ +enum { /* tcpty_spawn_options.flags */ + TCPTY_SPAWN_IUTF8 = 1u << 0 /* 在系统默认 termios 上加 IUTF8(默认应当置位) */ +}; +enum { /* 失败阶段:result.stage */ + TCPTY_STAGE_NONE = 0, + TCPTY_STAGE_OPENPT = 1, TCPTY_STAGE_GRANT = 2, TCPTY_STAGE_SLAVE = 3, + TCPTY_STAGE_TERMIOS = 4, TCPTY_STAGE_WINSIZE = 5, TCPTY_STAGE_PIPE = 6, + TCPTY_STAGE_ATTR = 7, TCPTY_STAGE_SPAWN = 8, /* posix_spawn(helper) 本身失败,例如 helper 缺失 */ + TCPTY_STAGE_HELPER_ARGS = 101, TCPTY_STAGE_HELPER_CTTY = 102, + TCPTY_STAGE_HELPER_EXEC = 103, /* 目标程序 exec 失败(ENOENT / EACCES …) */ + TCPTY_STAGE_HELPER_PROTO = 104 /* 状态管道协议错误或超时 */ +}; +enum { TCPTY_WARN_CWD_FALLBACK = 1u << 0 }; /* cwd 不可用,已回退到 $HOME 或 / */ + +typedef struct tcpty_spawn_options { + uint32_t size; + uint32_t flags; + const char *helper_path; /* 必填,tcode-pty-spawn 的绝对路径 */ + const char *file; /* 目标程序;含 '/' 时直接 exec,否则按 envp 中的 PATH 查找(execvp 规则) */ + const char *const *argv; /* 以 NULL 结尾;argv[0] 就是 argv0(例如 "-zsh") */ + const char *const *envp; /* 以 NULL 结尾的 "K=V" 数组,作为子进程的完整环境 */ + const char *cwd; /* NULL 或 "" 表示继承 host 的 cwd(不推荐) */ + uint16_t cols, rows; /* 字符尺寸,必须 > 0 */ + uint16_t xpixel, ypixel; /* 整个窗口的像素尺寸,未知时为 0 */ + uint32_t status_timeout_ms; /* 等待 helper 报告的上限,0 表示默认 5000 */ +} tcpty_spawn_options; + +typedef struct tcpty_spawn_result { + uint32_t size; + int32_t master_fd; /* 成功时 ≥ 10,带 CLOEXEC,处于阻塞模式(由调用方 set_nonblock) */ + int32_t pid; /* 直接子进程,即 shell */ + int32_t stage; /* TCPTY_STAGE_*,成功时为 0 */ + int32_t error; /* 失败时的原始 errno(helper 报告的值也放在这里) */ + uint32_t warnings; /* TCPTY_WARN_* */ + int32_t warn_error; /* 产生警告时的原始 errno */ +} tcpty_spawn_result; + +/* 返回 0 或 TCPTY_E_*。失败时 master 和 pid 都已经回收,调用方不用清理。 */ +TCPTY_API int32_t tcpty_spawn(const tcpty_spawn_options *opt, tcpty_spawn_result *res); + +/* ───────── I/O ───────── */ +TCPTY_API int32_t tcpty_set_nonblock(int32_t fd, int32_t enable); +TCPTY_API int32_t tcpty_close(int32_t fd); +/* wake:每个会话一对 pipe。read 端交给 gather/wait;被唤醒后不排空("粘性"),之后的等待都会立即返回 */ +TCPTY_API int32_t tcpty_wake_create(int32_t *read_fd, int32_t *write_fd); +TCPTY_API int32_t tcpty_wake_signal(int32_t write_fd); + +typedef struct tcpty_gather_config { + uint32_t size; + int32_t bridge_threshold; /* 1024:攒够这么多才认为流已"饱和" */ + int32_t spin_max; /* 16:饱和后遇到 EAGAIN 立即重读的次数 */ + int32_t bridge_poll_ms; /* 1 */ + int32_t budget_us; /* 3000:单批总时间预算 */ +} tcpty_gather_config; +enum { TCPTY_GATHER_EOF = 1u << 0, TCPTY_GATHER_WOKEN = 1u << 1 }; +/* 阻塞直到有数据、EOF 或被唤醒。返回读到的字节数(≥ 0);*flags_out 是 EOF 和 WOKEN 的组合。 + * EIO(Linux:最后一个 slave 已关闭)与 read()==0(macOS)都报告为 EOF。fd 必须是非阻塞的。 */ +TCPTY_API int32_t tcpty_read_gather(int32_t fd, int32_t wake_fd, uint8_t *buf, int32_t cap, + const tcpty_gather_config *cfg, uint32_t *flags_out); +TCPTY_API int32_t tcpty_read(int32_t fd, uint8_t *buf, int32_t cap); /* 单次读:>0、0=EOF、E_AGAIN */ +TCPTY_API int32_t tcpty_write(int32_t fd, const uint8_t *buf, int32_t len); /* >=0 已写字节数、E_AGAIN、E_IO */ +enum { TCPTY_POLL_IN = 1, TCPTY_POLL_OUT = 2, TCPTY_POLL_HUP = 4, TCPTY_POLL_WOKEN = 8 }; +/* 返回 1 表示就绪,0 表示超时;*revents 是上面各位的组合 */ +TCPTY_API int32_t tcpty_wait(int32_t fd, int32_t wake_fd, int32_t events, int32_t timeout_ms, uint32_t *revents); +TCPTY_API int32_t tcpty_resize(int32_t fd, uint16_t cols, uint16_t rows, uint16_t xpixel, uint16_t ypixel); +TCPTY_API int32_t tcpty_get_winsize(int32_t fd, uint16_t out[4]); /* 测试与诊断用 */ +TCPTY_API void tcpty_set_thread_qos_interactive(void); /* macOS:QOS_CLASS_USER_INITIATED;其他平台为空操作 */ + +/* ───────── 退出 ───────── */ +enum { TCPTY_EXIT_UNKNOWN = 0, TCPTY_EXIT_CODE = 1, TCPTY_EXIT_SIGNAL = 2 }; +typedef struct tcpty_exit_status { + uint32_t size; + int32_t kind; /* TCPTY_EXIT_* */ + int32_t value; /* 退出码,或信号编号(平台原值) */ + int32_t core_dumped; + int32_t raw; /* 原始 wait status */ +} tcpty_exit_status; +/* 只调用 waitpid(pid)。block=0 时加 WNOHANG。返回 1 表示已回收,0 表示尚未退出; + * 返回 TCPTY_E_CHILD 时 out->kind = UNKNOWN */ +TCPTY_API int32_t tcpty_reap(int32_t pid, int32_t block, tcpty_exit_status *out); + +/* 进程级退出监视:Linux 用 epoll + pidfd(EPOLLONESHOT),macOS 用 kqueue EVFILT_PROC */ +TCPTY_API int32_t tcpty_exitwatch_init(void); +/* 已删除:TCPTY_WATCH_EXITSTATUS(原用于 login 包装下的孙进程;2026-10-17 决定不经 login,见 §8.3) */ +/* 返回 0;TCPTY_E_SRCH 表示进程已经退出(调用方立即 reap);TCPTY_E_NOSYS 或 TCPTY_E_PERM 表示 + * 不支持(调用方对这个 pid 改用阻塞 waitpid 线程) */ +TCPTY_API int32_t tcpty_exitwatch_add(int32_t pid, uint32_t flags); +typedef struct tcpty_exit_event { int32_t pid; int32_t has_status; int32_t status_raw; } tcpty_exit_event; +/* 返回事件个数(≥ 0)。被 wake 唤醒时返回 0;timeout_ms 为 -1 表示无限等待 */ +TCPTY_API int32_t tcpty_exitwatch_wait(tcpty_exit_event *events, int32_t cap, int32_t timeout_ms); +TCPTY_API int32_t tcpty_exitwatch_wake(void); +TCPTY_API int32_t tcpty_decode_wait_status(int32_t raw, tcpty_exit_status *out); + +/* ───────── 信号 ───────── */ +enum { TCPTY_SIG_HUP = 1, TCPTY_SIG_INT = 2, TCPTY_SIG_QUIT = 3, TCPTY_SIG_KILL = 4, TCPTY_SIG_TERM = 5, + TCPTY_SIG_CONT = 6, TCPTY_SIG_STOP = 7, TCPTY_SIG_TSTP = 8, TCPTY_SIG_WINCH = 9, + TCPTY_SIG_USR1 = 10, TCPTY_SIG_USR2 = 11 }; +TCPTY_API int32_t tcpty_signal_number(int32_t portable); /* → 平台信号编号 */ +TCPTY_API int32_t tcpty_signal_name(int32_t signo, char *buf, int32_t cap); /* "SIGHUP"…;未知时为 "SIG" */ +TCPTY_API int32_t tcpty_kill(int32_t pid, int32_t signo); /* kill(pid, s) */ +TCPTY_API int32_t tcpty_killpg(int32_t pgid, int32_t signo); /* kill(-pgid, s);pgid ≤ 1 时返回 E_INVAL */ + +/* ───────── 进程信息 ───────── */ +TCPTY_API int32_t tcpty_get_fg_pgrp(int32_t master_fd); /* tcgetpgrp(master),返回 > 0 或错误码 */ +TCPTY_API int32_t tcpty_get_pgid(int32_t pid); +TCPTY_API int32_t tcpty_get_sid(int32_t pid); +/* Linux:/proc//comm(15 字符);macOS:proc_pidpath 的 basename,失败时用 proc_name */ +TCPTY_API int32_t tcpty_get_process_name(int32_t pid, char *buf, int32_t cap); +TCPTY_API int32_t tcpty_get_process_path(int32_t pid, char *buf, int32_t cap); +/* Linux:readlink /proc//cwd;macOS:proc_pidinfo(PROC_PIDVNODEPATHINFO).pvi_cdir */ +TCPTY_API int32_t tcpty_get_cwd(int32_t pid, char *buf, int32_t cap); +/* macOS:proc_listchildpids;Linux:/proc//task//children,没有时扫描 /proc 下的 stat。返回个数 */ +TCPTY_API int32_t tcpty_list_children(int32_t pid, int32_t *pids, int32_t cap); +enum { TCPTY_TERMIOS_ECHO = 1, TCPTY_TERMIOS_ICANON = 2, TCPTY_TERMIOS_ISIG = 4 }; +TCPTY_API int32_t tcpty_get_termios_state(int32_t fd, uint32_t *flags); /* 密码提示判断(09 Agent 用) */ + +#ifdef __cplusplus +} +#endif +#endif +``` + +### 2.1 为什么 Unix C API 没有"宽度模式"参数 + +内核 PTY 不测量字宽,Unicode 宽度只影响模拟器和 ConPTY 自己的缓冲区。Unix 上唯一相关的设置是 IUTF8:canonical 模式下按 UTF-8 字符退格。所以 `PtyLaunch.WidthMode` 只在 Windows 上映射为 ConPTY flags(§7.4),在 Unix 上忽略。 + +### 2.2 `tcpty_spawn` 实现要点(父进程侧) + +```c +int32_t tcpty_spawn(const tcpty_spawn_options *o, tcpty_spawn_result *r) { + lock(&g_spawn_mutex); + /* 1. master:直接 open /dev/ptmx(等价于 posix_openpt),一开始就带 O_CLOEXEC,避免与其他线程的 fork 竞争 + * macOS 上 errno<=0 或 ENXIO 时最多重试 8 次,每次间隔 1 ms [实 research/02 §2.3] */ + m = open("/dev/ptmx", O_RDWR | O_NOCTTY | O_CLOEXEC); grantpt(m); unlockpt(m); + /* slave 路径:macOS 用 ioctl(m, TIOCPTYGNAME, buf)(线程安全,NP pty.cc:788),Linux 用 ptsname_r */ + s = open(path, O_RDWR | O_NOCTTY | O_CLOEXEC); + m = fd_high(m); s = fd_high(s); /* fcntl(F_DUPFD_CLOEXEC, 10) 后关闭原 fd,见下文 */ + /* 2. termios:tcgetattr(s) 取得系统默认值,只加 IUTF8 [实 research/02 §2.6];TIOCSWINSZ(s, …) */ + /* 3. 状态管道:Linux 用 pipe2(O_CLOEXEC),macOS 用 pipe + FD_CLOEXEC(仍在锁内),再提升到 ≥ 10 */ + /* 4. attr:SETSID | SETSIGDEF | SETSIGMASK (+ macOS 的 CLOEXEC_DEFAULT);sigdefault = sigfillset,sigmask = 空 */ + /* 5. file actions:adddup2(s,0) adddup2(s,1) adddup2(s,2) adddup2(pw,3) */ + /* 6. argv_h = [helper, cwd ?: "", file, argv[0..]] ;posix_spawn(&pid, helper, &fa, &attr, argv_h, envp),EINTR 时重试 */ + unlock(&g_spawn_mutex); + close(pw); close(s); /* 父进程不关 slave 就永远读不到 EOF [实 U5] */ + /* 7. 读状态管道,直到 EOF(helper exec 成功后 fd 3 因 CLOEXEC 被关闭),用 poll 限时: + * 每条记录 {int32 stage, int32 err}:stage < 0 是警告,记下后继续读;stage > 0 是致命错误 → waitpid(pid,0),返回错误 + * 超时 → kill(pid, SIGKILL) + waitpid → 返回 STAGE_HELPER_PROTO / E_TIMEDOUT */ + r->master_fd = m; r->pid = pid; return TCPTY_OK; +} +``` + +- **fd 提升到 ≥ 10**:如果 status 管道的写端恰好就是 fd 3,`adddup2(3, 3)` 在某些 libc 上不会清除 CLOEXEC,helper 一 exec 就失去 fd 3 [推,POSIX 2017 才要求清除;未逐一核对 glibc 2.28 和 XNU]。pty host 以分离方式启动、stdio 可能被关闭时,`/dev/ptmx` 也可能拿到 0–2。统一提升到 ≥ 10,就不必关心这些差异。 +- **CLOEXEC 竞争**:macOS 没有 `pipe2`。从 `pipe` 到 `fcntl` 之间,其他线程如果 fork,会继承这对 fd。pty host 内基本不用 `Process`,所以只记作风险 [推]。 +- `ptsname_r` 在 glibc 2.28 和 musl 中都有。posix_spawn 的 `SETSID` 需要 glibc ≥ 2.26、musl ≥ 1.1.17、macOS 的 0x0400 [实 research/02 §2.2]。 + +--- + +### 2.3 管道模式启动:`tcpty_spawn_pipe`(Unix) + +pty host 在 Unix 上不能用 `Process`(§10),但 profile 探测和 SSH 需要运行一些短命令(`ssh -G `、读取 shell 版本等),并收集它们的输出。为此 libtcodepty 提供一个不分配 PTY 的启动接口,子进程的退出同样由 §6 的退出监视处理(只 `waitpid(pid)`): + +```c +typedef struct tcpty_pipe_options { + uint32_t size; /* sized-struct */ + const char *helper_path; /* tcode-pty-spawn,以 --exec 模式启动:复位全部信号后 execvp */ + const char *file; /* 按 PATH 查找规则解析 */ + const char *const *argv; /* argv[0] 是 argv0 */ + const char *const *envp; + const char *cwd; /* 可为 NULL */ +} tcpty_pipe_options; + +typedef struct tcpty_pipe_result { + int32_t pid; + int32_t stdin_fd; /* 均为 O_CLOEXEC;调用方负责关闭 */ + int32_t stdout_fd; + int32_t stderr_fd; +} tcpty_pipe_result; + +TCPTY_API int32_t tcpty_spawn_pipe(const tcpty_pipe_options *opt, tcpty_pipe_result *res); +``` + +C# 侧包装为 `PipeProcess.RunAsync(file, args, env, cwd, timeout, maxOutput)`,超时后 `killpg`。Windows 没有这个限制,直接使用 `System.Diagnostics.Process`。 + +## 3. tcode-pty-spawn helper(完整草案) + +三种模式: + +| 模式 | 调用方 | 动作 | +|---|---|---| +| **PTY 模式** | `tcpty_spawn`(pty host 内) | TIOCSCTTY → 关闭 fd ≥ 4 → chdir → execvp,通过 fd 3 报告错误 | +| **`--exec`** | BashTool(AgentHost,经 `Process.Start`) | 把全部信号恢复为默认、清空掩码 → execvp(§12) | +| **`--detach`** | Desktop 启动 pty host([04-pty-host.md](04-pty-host.md) §1) | `setsid` → 恢复信号、清空掩码 → 可选地把就绪管道 dup 到 fd 1 → 关闭 fd ≥ 3 → `execv(host)` | + +`--detach` 的参数格式按 04 §1:`tcode-pty-spawn --detach [--stdout-fd N] -- [args...]`。 + +- `--stdout-fd N`:就绪管道所在的 fd。N ≠ 1 时先 `dup2(N, 1)`,这样它才能在"关闭 fd ≥ 3"之后保留下来。Desktop 用 `Process.Start` 重定向 stdout 时传 1 即可。 +- 用 `execv`,不做 PATH 查找,host 路径必须是绝对路径。 +- 失败时没有状态管道,helper 向 stderr 写一行 `tcode-pty-spawn: : `,并以 **126**(setsid / 参数错误)或 **127**(exec 失败)退出。这两个码与 04 §1 中 host 自身的退出码 0/2/3/4 不冲突,Desktop 据此区分"helper 失败"与"host 失败"。 +- 由 `Process.Start` 启动的 helper 不是进程组组长(.NET 不会 setpgid),所以 `setsid()` 必定成功。失败(EPERM)也按 126 退出,不重试 fork [推]。 + +```c +/* tcode-pty-spawn:posix_spawn / Process.Start 之后、目标程序之前的一跳,exec 之后不运行任何托管代码。 + * PTY 模式:argv = [self, cwd, file, argv0, args...];fd 0-2 = slave;fd 3 = 状态管道写端。 + * exec 模式:argv = [self, "--exec", file, argv0, args...];复位全部信号后 execvp。 + * detach 模式:argv = [self, "--detach", ["--stdout-fd", N], "--", host, args...];setsid、复位信号、关闭 fd≥3 后 execv。 + * 状态记录 {int32 stage, int32 err}:stage > 0 致命(随后 _exit(127));stage < 0 是警告,继续执行。 + * stage 编号与 tcodepty.h 的 TCPTY_STAGE_HELPER_* 一致:101 参数,102 控制终端,103 exec;-1 表示 cwd 回退。 */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(__linux__) +#include +#endif + +static void report(int32_t stage, int32_t err) { + int32_t rec[2] = { stage, err }; + while (write(3, rec, sizeof rec) < 0 && errno == EINTR) {} + if (stage > 0) _exit(127); +} + +static void close_from(int low) { /* 兜底:防止宿主中有不带 CLOEXEC 的 fd 泄漏给子进程 */ +#if defined(__linux__) && defined(SYS_close_range) + if (syscall(SYS_close_range, low, ~0U, 0) == 0) return; /* Linux ≥ 5.9 */ +#endif +#if defined(__linux__) + DIR *d = opendir("/proc/self/fd"); +#else + DIR *d = opendir("/dev/fd"); /* macOS:PTY 模式已有 CLOEXEC_DEFAULT,detach 模式需要遍历 */ +#endif + if (d == NULL) return; + for (struct dirent *e; (e = readdir(d)) != NULL;) { + int fd = atoi(e->d_name); + if (fd >= low && fd != dirfd(d)) close(fd); + } + closedir(d); +} + +static void reset_signals(void) { + struct sigaction dfl; memset(&dfl, 0, sizeof dfl); dfl.sa_handler = SIG_DFL; + for (int s = 1; s < NSIG; s++) sigaction(s, &dfl, NULL); /* SIGKILL/SIGSTOP 失败可忽略 */ + sigset_t none; sigemptyset(&none); sigprocmask(SIG_SETMASK, &none, NULL); +} + +static void die(int code, const char *stage) { /* exec / detach 模式:没有状态管道 */ + dprintf(2, "tcode-pty-spawn: %s: %s\n", stage, strerror(errno)); + _exit(code); +} + +int main(int argc, char **argv) { + if (argc >= 4 && strcmp(argv[1], "--exec") == 0) { + reset_signals(); + execvp(argv[2], &argv[3]); + die(127, "exec"); + } + if (argc >= 2 && strcmp(argv[1], "--detach") == 0) { + int i = 2; + if (i + 1 < argc && strcmp(argv[i], "--stdout-fd") == 0) { + int n = atoi(argv[i + 1]); + if (n != 1 && dup2(n, 1) == -1) die(126, "dup2"); + i += 2; + } + if (i >= argc || strcmp(argv[i], "--") != 0 || i + 1 >= argc) { errno = EINVAL; die(126, "args"); } + if (setsid() == -1) die(126, "setsid"); /* 新会话、无控制终端 */ + reset_signals(); + close_from(3); + execv(argv[i + 1], &argv[i + 1]); /* host 绝对路径;argv0 = 路径本身 */ + die(127, "exec"); + } + if (fcntl(3, F_SETFD, FD_CLOEXEC) == -1) _exit(127); /* 没有状态管道就无法报告 */ + if (argc < 5) report(101, EINVAL); + if (ioctl(STDIN_FILENO, TIOCSCTTY, 0) == -1) report(102, errno); /* Linux 与 macOS 都需要(adddup2 不会取得控制终端) */ + close_from(4); + const char *cwd = argv[1]; + if (cwd[0] != '\0' && chdir(cwd) == -1) { + report(-1, errno); /* 与 Ghostty 一致:回退而不是失败 */ + const char *home = getenv("HOME"); + if (home == NULL || chdir(home) == -1) (void)chdir("/"); + } + execvp(argv[2], &argv[3]); /* 按子进程环境中的 PATH 查找;ENOEXEC 时回退 /bin/sh */ + report(103, errno); + return 127; +} +``` + +| 设计点 | 说明 | 来源 | +|---|---|---| +| 为什么 Linux 也要 `TIOCSCTTY` | research/02 §2.2 的结论"Linux 不需要 helper"前提是 `addopen(0, slave_path)`;我们用的是 `adddup2(已打开的 O_NOCTTY slave)`,dup 不会让会话首进程取得控制终端,所以必须显式 TIOCSCTTY。posix_spawn 已经执行了 setsid,helper 是会话首进程,调用会成功 | [实 LX tty_jobctrl.c:365-395、research/02 §2.2];[推] Linux 上会成功,见 S2 | +| fd 3 在 exec 前设为 CLOEXEC | exec 成功即关闭 fd 3,父进程读到 EOF 就表示成功 | [实 RTM spawn-helper.c:14] | +| `execvp` 而不是 `execve` + 自己找路径 | helper 的 environ 就是子进程的完整环境;PATH 查找和 ENOEXEC 回退由 libc 完成 | [实 RTM spawn-helper.c:19-21] | +| **不用 `PR_SET_PDEATHSIG`** | 它在**父线程**退出时就触发,而 spawn 可能在任意线程上执行;pty host 退出时 master 被关闭,内核会挂断会话,已经能兜底 | man 2 PR_SET_PDEATHSIG [实];与 research/02 §2.4 的可选建议相反 | +| 信号 | PTY 模式由父进程的 `SETSIGDEF(全集)` 和空 mask 处理;`--exec`、`--detach` 自己复位(它们是被 .NET `Process.Start` 启动的,SIGPIPE 继承为 SIG_IGN) | §10、§12 | +| `--detach` 的 fd 清理 | Linux 用 `close_range`,没有时遍历 `/proc/self/fd`;macOS 遍历 `/dev/fd`(没有 CLOEXEC_DEFAULT 可用)。fd 0–2 保留,由 host 在就绪后自己重定向到 /dev/null(04 §1) | 04 §1 | +| `--detach` 不 fork | `Process.Start` 的子进程不是组长,`setsid` 一定成功;不 fork 的话,Desktop 拿到的 pid 就是 host 的 pid,并且 Desktop 必须回收它(`Process` 会自动回收),否则会留下僵尸 | [推];host 退出后 Desktop 的 `Process` 对象负责回收 | +| 静态链接 | Linux 版 helper 用 musl 完全静态链接(§11),不依赖目标机的 glibc 版本 | §11 | + +--- + +## 4. C# 公共接口与互操作 + +### 4.1 接口(对齐 01 §6.2,加粗的是本文提议的补充) + +```csharp +namespace TCode.Terminal.Pty; + +public readonly record struct TerminalSize(int Columns, int Rows, int CellWidth, int CellHeight); +// 与 01 §6.1 一致:CellWidth/CellHeight 是单个 cell 的像素;PTY 层计算 ws_xpixel = Columns × CellWidth、 +// ws_ypixel = Rows × CellHeight(截断到 65535)。cell 尺寸未知时为 0,像素也写 0 + +public sealed record TerminalExit(int? ExitCode, string? Signal, bool CoreDumped = false, string? Message = null); +public enum TerminalSignal { Hup, Int, Quit, Term, Kill } +public sealed record ForegroundInfo(int? Pid, string? Name, bool IsShell); + +[Flags] public enum ConnectionCapabilities +{ None = 0, Resize = 1, PixelSize = 2, Signals = 4, ForegroundProcess = 8, WorkingDirectory = 16, ClearBuffer = 32 /* 新增 */ } + +public enum PtyWidthMode { Compat, Grapheme } +// 原 PtyLaunchHints.MacLoginWrapper 已删除:2026-10-17 决定不经 login(§8.3) + +public sealed record PtyLaunch( + string File, IReadOnlyList Arguments, string? Argv0, string? WorkingDirectory, + IReadOnlyList> Environment, TerminalSize InitialSize, PtyWidthMode WidthMode) +{ + public PtyLaunchHints Hints { get; init; } // 新增 + public string? WindowsCommandLine { get; init; } // 新增:原样传给 CreateProcessW(cmd.exe 和用户写的命令行字符串) + public bool AmbiguousWide { get; init; } // 新增:ConPTY 0x20,只在 1.25 中有效 +} + +public sealed class LocalPtyConnection : ITerminalConnection +{ + public int ProcessId { get; } // 直接子进程 + public PtyStartWarnings Warnings { get; } // 例如 CwdFallback + public ConPtyImplementation? ConPty { get; } // Bundled / Inbox / InboxNoRelease(仅 Windows) + public bool TryWriteReply(ReadOnlySpan reply); // 新增:镜像核心的应答通道,不阻塞,见 §5.3 + public ValueTask ClearBufferAsync(bool keepCursorRow); // 新增:Windows 调用 ClearPseudoConsole;其他平台为空操作 + public ValueTask CloseAsync(TimeSpan grace); // DisposeAsync 等于 CloseAsync(options.CloseGrace) + // 以及 ITerminalConnection 的全部成员 +} +``` + +能力: + +| 平台 | Capabilities | +|---|---| +| Unix | Resize \| PixelSize \| Signals \| ForegroundProcess \| WorkingDirectory | +| Windows | Resize \| Signals(受限)\| ForegroundProcess(启发式)\| ClearBuffer(仅随包 conpty.dll) | + +ConPTY 的 resize 只接受 COORD,**没有像素尺寸**;`CSI 14 t` 这类查询由镜像核心应答(S4 要核实 ConPTY 是否透传)。 + +### 4.2 LibraryImport 声明(摘录,其余函数按相同模式声明) + +```csharp +[assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling] +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory)] + +namespace TCode.Terminal.Pty.Unix; + +internal static unsafe partial class TcPtyNative +{ + private const string Lib = "libtcodepty"; // 与 同名 + + [LibraryImport(Lib, EntryPoint = "tcpty_version")] internal static partial int Version(); + [LibraryImport(Lib, EntryPoint = "tcpty_last_errno")] internal static partial int LastErrno(); + [LibraryImport(Lib, EntryPoint = "tcpty_init")] internal static partial int Init(TcPtyInitInfo* info); + [LibraryImport(Lib, EntryPoint = "tcpty_spawn")] internal static partial int Spawn(TcPtySpawnOptions* o, TcPtySpawnResult* r); + [LibraryImport(Lib, EntryPoint = "tcpty_set_nonblock")] internal static partial int SetNonblock(PtyFdHandle fd, int enable); + [LibraryImport(Lib, EntryPoint = "tcpty_close")] internal static partial int Close(int fd); + [LibraryImport(Lib, EntryPoint = "tcpty_wake_create")] internal static partial int WakeCreate(int* r, int* w); + [LibraryImport(Lib, EntryPoint = "tcpty_wake_signal")] internal static partial int WakeSignal(PtyFdHandle w); + [LibraryImport(Lib, EntryPoint = "tcpty_read_gather")] + internal static partial int ReadGather(PtyFdHandle fd, PtyFdHandle wake, byte* buf, int cap, TcPtyGatherConfig* cfg, uint* flags); + [LibraryImport(Lib, EntryPoint = "tcpty_write")] internal static partial int Write(PtyFdHandle fd, byte* buf, int len); + [LibraryImport(Lib, EntryPoint = "tcpty_wait")] + internal static partial int Wait(PtyFdHandle fd, PtyFdHandle wake, int events, int timeoutMs, uint* revents); + [LibraryImport(Lib, EntryPoint = "tcpty_resize")] + internal static partial int Resize(PtyFdHandle fd, ushort cols, ushort rows, ushort xpx, ushort ypx); + [LibraryImport(Lib, EntryPoint = "tcpty_reap")] internal static partial int Reap(int pid, int block, TcPtyExitStatus* s); + [LibraryImport(Lib, EntryPoint = "tcpty_exitwatch_init")] internal static partial int ExitWatchInit(); + [LibraryImport(Lib, EntryPoint = "tcpty_exitwatch_add")] internal static partial int ExitWatchAdd(int pid, uint flags); + [LibraryImport(Lib, EntryPoint = "tcpty_exitwatch_wait")] internal static partial int ExitWatchWait(TcPtyExitEvent* ev, int cap, int timeoutMs); + [LibraryImport(Lib, EntryPoint = "tcpty_exitwatch_wake")] internal static partial int ExitWatchWake(); + [LibraryImport(Lib, EntryPoint = "tcpty_killpg")] internal static partial int KillPg(int pgid, int signo); + [LibraryImport(Lib, EntryPoint = "tcpty_kill")] internal static partial int Kill(int pid, int signo); + [LibraryImport(Lib, EntryPoint = "tcpty_signal_number")] internal static partial int SignalNumber(int portable); + [LibraryImport(Lib, EntryPoint = "tcpty_get_fg_pgrp")] internal static partial int GetFgPgrp(PtyFdHandle fd); + [LibraryImport(Lib, EntryPoint = "tcpty_get_cwd")] internal static partial int GetCwd(int pid, byte* buf, int cap); + [LibraryImport(Lib, EntryPoint = "tcpty_list_children")] internal static partial int ListChildren(int pid, int* pids, int cap); + [LibraryImport(Lib, EntryPoint = "tcpty_set_thread_qos_interactive")] internal static partial void SetThreadQosInteractive(); +} + +[StructLayout(LayoutKind.Sequential)] +internal unsafe struct TcPtySpawnOptions +{ + public uint Size, Flags; + public byte* HelperPath, File; + public byte** Argv, Envp; + public byte* Cwd; + public ushort Cols, Rows, XPixel, YPixel; + public uint StatusTimeoutMs; +} +// TcPtySpawnResult / TcPtyInitInfo / TcPtyGatherConfig / TcPtyExitStatus / TcPtyExitEvent 与头文件逐字段对应,全部是 blittable 类型 + +internal sealed class PtyFdHandle : SafeHandle +{ + public PtyFdHandle() : base(-1, ownsHandle: true) { } + public PtyFdHandle(int fd) : base(-1, true) => SetHandle(fd); + public override bool IsInvalid => handle == -1; + protected override bool ReleaseHandle() => TcPtyNative.Close((int)handle) == 0; +} +``` + +- **SafeHandle 的引用计数**保证 `tcpty_read_gather` 在 poll 中阻塞时,fd 不会被关闭后又被复用。关闭顺序见 §6,先唤醒读线程,再 Dispose fd。 +- `NativeStringBlock`:把 argv 和 envp 用 UTF-8 编码进一块 `NativeMemory.Alloc` 分配的内存(字符串区 + `byte*[]` 指针数组,以 NULL 结尾),调用结束后 Free。不使用任何运行时封送。 +- 静态链接(DirectPInvoke)与动态加载使用同一组声明,见 §11.5。 + +--- + +## 5. LocalPtyConnection:线程、背压、写入 + +### 5.1 线程(与 01 §5.1 对应) + +| 线程 | 数量 | 实现 | +|---|---|---| +| 读线程 | 每个会话 1 个,`new Thread { IsBackground = true, Name = "pty-read-" }` | Unix:`tcpty_set_thread_qos_interactive()`,然后循环调用 `tcpty_read_gather`。Windows:阻塞 `ReadFile` 64 KiB,再用 `PeekNamedPipe` 把已有数据续读满(便宜的 gather) | +| 写线程 | 每个会话 1 个 | 从 channel 取数据;Unix 用 `tcpty_write` + `tcpty_wait(POLLOUT)`,Windows 用阻塞 `WriteFile` | +| 退出监视 | Unix:进程级 1 个(pidfd 不可用时,每个会话再加 1 个 256 KiB 栈的 waitpid 线程);Windows:线程池等待 | `UnixExitWatcher` / `ThreadPool.RegisterWaitForSingleObject` | +| 关闭线程 | Windows:每次关闭临时创建 1 个 | 专门调用 `ClosePseudoConsole`(W1) | + +### 5.2 读循环与背压 + +```csharp +private static readonly PipeOptions OutputPipe = new( + pool: MemoryPool.Shared, readerScheduler: PipeScheduler.Inline, writerScheduler: PipeScheduler.Inline, + pauseWriterThreshold: 256 * 1024, resumeWriterThreshold: 128 * 1024, // 相当于 01 §5.1 的"4 块 × 64 KiB" + minimumSegmentSize: 64 * 1024, useSynchronizationContext: false); + +private void ReadLoop() +{ + _backend.OnReadThreadStart(); + var writer = _output.Writer; + byte[]? scratch = null; + try + { + while (true) + { + if (Volatile.Read(ref _discarding)) // 正在关闭或消费方已离开:只读不交付 + { + scratch ??= new byte[64 * 1024]; + if (_backend.Read(scratch, out var st) == 0 && (st & ReadStatus.Eof) != 0) break; + if ((st & ReadStatus.Woken) != 0 && _backend.StopOnWake) break; + continue; + } + Memory mem = writer.GetMemory(64 * 1024); + int n; ReadStatus status; + using (MemoryHandle pin = mem.Pin()) n = _backend.Read(pin, mem.Length, out status); + if (n > 0) + { + writer.Advance(n); + Volatile.Write(ref _lastOutputTick, Environment.TickCount64); + var vt = writer.FlushAsync(); + var r = vt.IsCompleted ? vt.Result : vt.AsTask().GetAwaiter().GetResult(); // 阻塞在这里就是背压 + if (r.IsCompleted) Volatile.Write(ref _discarding, true); // 消费方已 Complete + // r.IsCanceled:关闭路径调用了 CancelPendingFlush,下一轮进入丢弃模式 + } + if ((status & ReadStatus.Eof) != 0) break; + if ((status & ReadStatus.Woken) != 0 && _backend.StopOnWake) break; + } + _eofSeen = true; + writer.Complete(); + } + catch (Exception ex) { writer.Complete(ex); } + finally { _readerDone.Set(); } +} +``` + +- **背压链**:消费方(会话 actor)不 `AdvanceTo` → Pipe 超过 256 KiB → `FlushAsync` 不完成 → 读线程停止读取 → 内核 tty 队列或 ConPTY 输出管道写满 → 子进程的 `write` 阻塞。这就是 01 §5.1 要求的行为。 +- **丢弃模式**是防死锁的关键:关闭时读线程可能正阻塞在 `FlushAsync` 上(消费方已不再读)。Windows 上这会让 conhost 卡在写输出 → 旧版 `ClosePseudoConsole` 永远等待(W1)。所以关闭路径要先 `_discarding = true`,再 `writer.CancelPendingFlush()`。Unix 上也采用同一机制,代码统一。 +- `StopOnWake`:Unix 为 true(wake 就是关闭信号);Windows 为 false(ReadFile 没有 wake,用 BROKEN_PIPE 结束)。 +- gather 参数沿用 Ghostty 的实测值:1 KiB / 16 次自旋 / 1 ms / 3 ms / 64 KiB [实 GH Exec.zig:1290-1352、1561-1690]。未饱和的流在第一次 EAGAIN 时立即交付,所以**回显路径上没有额外延迟**。 + +### 5.3 写入 + +| 来源 | 接口 | 阻塞语义 | +|---|---|---| +| 用户按键、粘贴(经 IPC) | `WriteAsync(input)` | 先复制到 ArrayPool 缓冲区再入队,**立即完成**。连接层不设上限,只公开 `PendingInputBytes`。积压上限和溢出策略由 host 决定(04 §7.5:8 MiB,超出部分丢弃并发 `session/inputOverflow`),这样 IPC 读线程永远不会被阻塞 | +| 镜像核心的应答(DA、CPR、DECRQM…),在会话 actor 上同步产生 | `TryWriteReply(span)` | **永不阻塞**,与用户输入进同一个 FIFO,不受 host 的上限约束。actor 如果阻塞在写入上 → 不再解析输出 → 子进程写输出阻塞 → 子进程读不到输入,形成死锁。(04 §5.3 说"不保证相对顺序";这里按 FIFO 实现,属于更强的保证) | + +写线程:每次 `WriteFile` / `tcpty_write` 最多 4 KiB。遇到 EAGAIN 就 `tcpty_wait(POLLOUT | wake)`;遇到 `E_IO` 表示进程已不在,丢弃剩余数据并停止。 + +**Unix canonical 模式的输入队列有上限**:Linux 是 4095 字节;macOS 是 MAX_INPUT 1024 [推]。往 `cat` 这类读整行的程序粘贴超长单行时会被截断。系统终端也有这个限制,不处理,只写进文档(S12)。 + +### 5.4 Resize + +`ResizeAsync` 在调用线程(会话 actor)上同步执行: + +- Unix:`tcpty_resize(master, cols, rows, cols×cw, rows×ch)`; +- Windows:`ResizePseudoConsole(hpc, {cols, rows})`。 + +调用很便宜。去抖由 UI 负责(16–33 ms)。内核只在尺寸确实变化时发送 SIGWINCH,所以不需要手动补发 [实 research/02 §2.8]。 + +--- + +## 6. 退出与关闭 + +### 6.1 自然退出流程 + +``` +Unix: 退出监视线程收到 pid 事件 → tcpty_reap(pid, block=1) + → TerminalExit(kind=CODE → ExitCode;SIGNAL → Signal="SIGxxx", CoreDumped;E_CHILD → 两者都为空) + → connection.OnChildExited(exit) +Windows:wait 回调 → GetExitCodeProcess → TerminalExit(ExitCode = unchecked((int)code)) + (0xC000013A 这类 NTSTATUS 由 UI 显示成十六进制,与 WT 一致) + +OnChildExited: + 1. _reaped = true(与信号路径持同一把锁:回收之后不再按 pid/pgid 发信号,防止 pid 被复用后误伤) + 2. 收尾排空:等到读线程读到 EOF,或输出静默 ≥ 100 ms,最长 1 s + 3. Windows 且 ConPty == InboxNoRelease(没有 Release,EOF 不会自己出现):在关闭线程上调用 ClosePseudoConsole,以产生 EOF + 4. _exited.TrySetResult(exit) + 5. Output:读到 EOF 时 Complete;如果后台进程仍持有 slave / 控制台,就继续读,直到 CloseAsync +``` + +- **为什么 `Exited` 要晚于排空**:pty host 在 `Exited` 完成时往输出日志追加一条"退出"记录。排空保证 shell 最后的输出(例如 `logout`)出现在这条记录之前。research/06 §6 要求"Output 在 Exited 之前读到 EOF",但后台进程持有 slave 时(`sleep 100 &` 之后 exit)做不到 [推,research/02 §2.7],所以改为"EOF 或静默超时",见 §16-6。 +- Linux 上最后一个 slave 关闭时,`read` 返回 EIO;macOS 上返回 0。`tcpty_read_gather` 把两者都报告为 EOF [实 research/02 §2.7]。 + +### 6.2 关闭(`CloseAsync(grace)`,默认 grace = 2 s) + +**Unix** + +| 步 | 动作 | 说明 | +|---|---|---| +| 1 | `_writes.Complete()`,丢弃未写出的输入 | — | +| 2 | 如果尚未回收:`fg = tcpty_get_fg_pgrp(master)`;`killpg(fg, SIGHUP)`(fg > 0 且 ≠ shellPgid 时);`killpg(shellPgid, SIGHUP)`;忽略 EPERM 和 ESRCH | Darwin 上 killpg 偶尔返回 EPERM,Ghostty 也忽略 [实 GH Exec.zig:1186-1191];login 包装时 pgid 的来源见 §8.3 | +| 3 | `_discarding = true`、`CancelPendingFlush()`、`tcpty_wake_signal(wake_w)`;Join 读写线程(各最多 500 ms) | 唤醒是"粘性"的,gather 和 wait 都会立即返回 | +| 4 | Dispose master fd | 内核挂断会话:向前台进程组和会话首进程发 SIGHUP,之后 tty 读写都返回 EIO [实 research/02 §2.4] | +| 5 | 在 grace 时间内等 `Exited`;超时后对 shellPgid 和最后一次看到的 fg 执行 `killpg(SIGKILL)`,再 `kill(pid, SIGKILL)`;之后再等 1 s | `nohup`、`disown`、自行 setsid 的程序会保留,与 Terminal.app 和 Ghostty 一致 [实 research/02 §2.4] | +| 6 | 仍然没有退出:记录日志,让退出监视继续挂着,等它最终回收 | 不阻塞关闭流程 | + +**Windows** + +| 步 | 动作 | 说明 | +|---|---|---| +| 1 | `_writes.Complete()` | — | +| 2 | **W2 保护**:距 `CreateProcessW` 不足 500 ms 时,先等到 500 ms 或进程退出 | 防止 `0xc0000142` 错误框 [实 Learn;时长为推,见 S6] | +| 3 | `_discarding = true`、`CancelPendingFlush()`。读线程**继续**读,直到 `ERROR_BROKEN_PIPE` | W1 | +| 4 | 新建关闭线程,调用 `ClosePseudoConsole(hpc)`:conhost 向所有客户端发送 `CTRL_CLOSE_EVENT` | 绝不在读线程、终结器或 UI 线程上调用 | +| 5 | 在 grace 时间内等 `Exited`;超时后 `TerminateProcess(hProcess, 1)`(只杀 shell,与 Unix 的 SIGKILL 对等) | — | +| 6 | 等读线程最多 1 s;仍阻塞时用 `CancelSynchronousIo(读线程句柄)` | 兜底 | +| 7 | 关闭 inWrite、outRead、hProcess;Job:先清除 `KILL_ON_JOB_CLOSE`,再关闭 Job 句柄(§7.5) | 正常关闭后,`start` 出去的 GUI 程序保留 | + +`PseudoConsoleHandle.ReleaseHandle()`(终结器路径):随包 conpty.dll 或 26100 以上的 inbox 版本,Close 不会阻塞,直接调用;其他情况新建一个线程去调用,绝不阻塞终结器 [实 research/02 §5.6]。 + +### 6.3 SignalAsync + +| 信号 | Unix | Windows | +|---|---|---| +| Int | `killpg(fg 或 shellPgid, SIGINT)` | 写入 `0x03`(ConPTY 把它转换为 CTRL_C_EVENT [推,S4]) | +| Hup / Term / Quit | 先 fg,再 shellPgid | Hup/Term → `ClosePseudoConsole` 路径;Quit 返回 false | +| Kill | fg、shellPgid 和 pid 都发 SIGKILL | `TerminateProcess` | + +回收之后的 SignalAsync 一律返回 false。 + +--- + +## 7. Windows:ConPTY + +### 7.1 conpty.dll 加载策略 + +```csharp +internal static unsafe class ConPtyApi +{ + public static ConPtyImplementation Implementation { get; private set; } // Bundled | Inbox | InboxNoRelease + static delegate* unmanaged s_create; // 返回 HRESULT + static delegate* unmanaged s_resize; + static delegate* unmanaged s_close; + static delegate* unmanaged s_release; // 可能为 null + static delegate* unmanaged s_clear; // 只有 conpty.dll 才有(BOOL keepCursorRow) + + internal static void EnsureLoaded(string baseDir, Action log) + { + string dll = Path.Combine(baseDir, "conpty.dll"); + string host = Path.Combine(baseDir, NativeArchFolder(), "OpenConsole.exe"); // IsWow64Process2 → x64 / arm64 + nint lib = 0; + if (File.Exists(dll) && File.Exists(host)) + lib = Win32.LoadLibraryExW(dll, 0, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32); + if (lib != 0 + && NativeLibrary.TryGetExport(lib, "ConptyCreatePseudoConsole", out var c) + && NativeLibrary.TryGetExport(lib, "ConptyResizePseudoConsole", out var r) + && NativeLibrary.TryGetExport(lib, "ConptyClosePseudoConsole", out var cl)) + { + s_create = (delegate* unmanaged)c; /* … */ + s_release = TryGet(lib, "ConptyReleasePseudoConsole"); + s_clear = TryGet(lib, "ConptyClearPseudoConsole"); + Implementation = ConPtyImplementation.Bundled; + return; + } + log(PtyDiagnostic.ConPtyDegraded(dll, host, Marshal.GetLastPInvokeError())); // 明确记录降级 + nint k32 = NativeLibrary.Load("kernel32.dll"); // KnownDLL,裸名安全 + s_create = /* CreatePseudoConsole */; s_resize = /* … */; s_close = /* … */; + s_release = TryGet(k32, "ReleasePseudoConsole"); // 26100+ 才有 + s_clear = null; + Implementation = s_release != null ? ConPtyImplementation.Inbox : ConPtyImplementation.InboxNoRelease; + } +} +``` + +| 规则 | 原因 | 来源 | +|---|---|---| +| 只按绝对路径加载;不使用 `[LibraryImport("conpty.dll")]` | **Windows 11 的 System32 下自带 `conpty.dll`**,裸名会悄悄绑定到 inbox 实现 | [实 PP ConPtyImportResolver.cs 注释];research/02 W12 | +| 加载前确认 `\OpenConsole.exe` 存在 | conpty.dll 先找同目录,再找 `<本机架构>\`,**都找不到时静默回退 System32\conhost.exe** | [实 WT winconpty.cpp:57-95] | +| 架构子目录按**本机**架构选择(IsWow64Process2),同目录**不放** OpenConsole | x64 进程在 arm64 Windows 上模拟运行时,conpty.dll 会选 arm64 的 OpenConsole(本机运行);同目录放一个会抢先被选中 | [实 WT winconpty.cpp:57-95;PP Porta.Pty.targets:64-66] | +| 用 `delegate* unmanaged`,不用 `Marshal.GetDelegateForFunctionPointer` | AOT 安全;x64 和 arm64 只有一种调用约定,不必写 `[Stdcall]` | [实 research/02 §5.2] | + +### 7.2 Win32 句柄与 SafeHandle + +- `PseudoConsoleHandle : SafeHandle`(HPCON) +- `SafeFileHandle`(管道两端) +- `SafeProcessHandle`(BCL 类型) +- `JobHandle : SafeHandle` + +### 7.3 完整调用序列 + +``` +进程级(第一次 Start 时):ConPtyApi.EnsureLoaded + +每个会话: + 1. CreatePipe(out inRead, out inWrite, null, 0); CreatePipe(out outRead, out outWrite, null, 0) // 不可继承、同步 + 2. flags = MapFlags(launch)(§7.4) + hr = s_create({cols, rows}, inRead, outWrite, flags, &hpc);失败时抛 PtyException(hr) + 立即关闭 inRead 和 outWrite(ConPTY 内部已经 DuplicateHandle)[实 research/02 §3.3] + 3. **启动读线程和写线程**(读 outRead,写 inWrite) + OpenConsole 一启动就输出 ESC[c、ESC[?1004h、ESC[?9001h [实 WT VtIo.cpp:197-204]; + 会话 actor 必须在 Start 返回后立即开始消费 Output,由镜像核心的 write_pty 应答 DA1(§7.7) + 4. job = WindowsJob.Create() // 每个会话一个:KILL_ON_JOB_CLOSE | BREAKAWAY_OK(§7.5) + 5. InitializeProcThreadAttributeList(null, 2, 0, &size) → NativeMemory.Alloc(size) → Initialize(…) + UpdateProcThreadAttribute(list, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x20016, (void*)hpc, sizeof(nint)) // 传值,不是 &hpc + UpdateProcThreadAttribute(list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x2000D, &job, sizeof(nint)) + 6. STARTUPINFOEXW si = { cb = sizeof, dwFlags = STARTF_USESTDHANDLES, hStd* = 0, lpAttributeList = list } + cmd = launch.WindowsCommandLine ?? CommandLine.Build(File, Arguments) // 可写的 char 缓冲区 + env = EnvironmentBlock.Build(launch.Environment) // §9 + cwd = Directory.Exists(wd) ? wd : %USERPROFILE%(发生回退时设置 Warnings.CwdFallback) + CreateProcessW(null, cmd, null, null, bInheritHandles: FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, env, cwd, &si, &pi) + 失败:读线程收尾(关闭 hpc → BROKEN_PIPE),抛出异常 + DeleteProcThreadAttributeList; Free; CloseHandle(pi.hThread) + 7. if (s_release != null) s_release(hpc) + // 之后 outRead 上的 BROKEN_PIPE 就是"所有客户端都已断开"的可靠 EOF [实 research/02 §3.3;NP conpty.cc:450-460] + 8. ThreadPool.RegisterWaitForSingleObject(new ProcessWaitHandle(pi.hProcess), OnExit, null, -1, executeOnlyOnce: true) +``` + +`ClearBufferAsync(keepCursorRow)`:只在 Bundled 实现下调用 `s_clear(hpc, keepCursorRow ? 1 : 0)`,在用户执行"清除缓冲区"(⌘K / Ctrl+Shift+K)时与核心的 clear 一起调用。否则 conhost 下一次重绘会把旧内容画回来 [实 research/02 W6]。 + +### 7.4 flags 与宽度模式 + +| 输入 | Bundled(1.25.x) | Inbox(kernel32) | +|---|---|---| +| `PtyWidthMode.Compat` | `GLYPH_WIDTH_WCSWIDTH` 0x10 | **0** | +| `PtyWidthMode.Grapheme` | `GLYPH_WIDTH_GRAPHEMES` 0x08 | **0** | +| `AmbiguousWide` | 再加 `0x20`(要求 conpty.dll 文件版本 ≥ 1.25) | 忽略 | +| `INHERIT_CURSOR` 0x1 | 永不使用 | 永不使用 | + +- Inbox 必须传 0:旧版 inbox 中 0x8 是已删除的 `PASSTHROUGH_MODE` [实 research/02 §3.2]。不传时,conhost 的默认测量方式是 Graphemes [实 WT settings.hpp:236]。 +- flags 位定义见 [实 WT conpty-static.h:26-38、winconpty.cpp:169-185]。 +- **宽度在创建时就固定了。** ConPTY 对 DECSET 2027 不做任何事,DECRQM 报告"永久"状态 [实 WT adaptDispatch.cpp:1891-1892、2032-2033]。因此 Windows 上的两份核心必须**锁定宽度模式**,忽略程序发来的 2027 切换。建议 Windows 默认 Grapheme(与 WT 和 conhost 默认一致),这与 D9 的"默认兼容模式"不同,见 §16-4。 + +### 7.5 Job Object 策略(允许 breakaway,只兜底 shell 树) + +| 项 | 取值 | 理由 | +|---|---|---| +| 粒度 | 每个会话一个 Job | 便于按会话收尾 | +| LimitFlags | `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE \| JOB_OBJECT_LIMIT_BREAKAWAY_OK`,**不设** `SILENT_BREAKAWAY_OK` | shell 树默认都在 Job 里(崩溃时兜底);显式使用 `CREATE_BREAKAWAY_FROM_JOB` 的程序可以脱离 | +| 加入方式 | `PROC_THREAD_ATTRIBUTE_JOB_LIST` | 不存在"创建之后、Assign 之前"的竞争窗口 [推:1809 是否支持需要实测,S7] | +| 正常关闭 | `SetInformationJobObject` 清除 `KILL_ON_JOB_CLOSE` → `CloseHandle(job)` | 关闭标签时,用户 `start` 出去的程序保留,与 WT 一致 [推,S7] | +| pty host 崩溃 | 系统关闭 Job 句柄 → 整棵树被杀 | 兜底 | +| OpenConsole.exe | 不在会话 Job 中(它由 conpty.dll 在 CreatePseudoConsole 时启动) | host 消失 → signal 管道断开 → OpenConsole 自行退出,并向客户端发送 CTRL_CLOSE | + +### 7.6 kernel32 互操作清单 + +全部使用 `[LibraryImport("kernel32.dll", SetLastError = true)]`,参数只有 blittable 类型、指针和 SafeHandle: + +- 管道:`CreatePipe`、`ReadFile`、`WriteFile`、`PeekNamedPipe`、`CancelSynchronousIo` +- 进程:`InitializeProcThreadAttributeList`、`UpdateProcThreadAttribute`、`DeleteProcThreadAttributeList`、`CreateProcessW`、`GetExitCodeProcess`、`TerminateProcess`、`GetProcessTimes`、`IsWow64Process2` +- Job:`CreateJobObjectW`、`SetInformationJobObject` +- 进程快照:`CreateToolhelp32Snapshot`、`Process32FirstW`、`Process32NextW` +- 加载:`LoadLibraryExW` + +结构体: + +- `STARTUPINFOEXW`、`PROCESS_INFORMATION`、`COORD`、`JOBOBJECT_EXTENDED_LIMIT_INFORMATION`(含 `IO_COUNTERS`); +- `PROCESSENTRY32W`,其中 `fixed char szExeFile[260]`。 + +也可以改用 CsWin32 源生成(Porta.Pty 的做法);手写声明约 25 个,差别不大。 + +### 7.7 DA1 / 9001 握手 + +| 序列 | 谁处理 | 要求 | +|---|---|---| +| `ESC[c`(DA1) | 镜像核心(libghostty-vt)的 write_pty → `TryWriteReply` | 1.24 及更早版本、inbox:应答之前 conhost 最多阻塞 3 s;1.25:只在使用 INHERIT_CURSOR 时等待 [实 research/02 W3]。读线程在 CreateProcess 之前启动,并且 actor 立即开始消费 | +| `ESC[?1004h` | 两份核心都记录焦点模式 | UI 发送焦点事件 | +| `ESC[?9001h` | **libghostty-vt 的模式表里没有 9001** [实 GH modes.zig:有 1004 和 2027,没有 9001] | 需要在 TCode 一侧额外跟踪:两份核心前面各加一个轻量的 9001 序列识别器,写进 `FrameSnapshot.Modes`,并负责 DECRQM 9001 的应答。属于 02-core / 06-input 的工作,见 §16-5 | +| resize 之后的 `ESC[6n` | 镜像核心应答 CPR | [实 research/02 W7] | + +### 7.8 W1–W12 对策 + +| # | 陷阱 | 本设计的对策 | 位置 | +|---|---|---|---| +| W1 | Close 死锁 | 在关闭线程上调用 Close;读线程进入丢弃模式继续读到 BROKEN_PIPE;`CancelPendingFlush`;优先使用随包 dll(其 Close 不等待) | §5.2、§6.2 | +| W2 | 启动中 Close 弹出 0xc0000142 | 距启动不足 500 ms 的关闭请求先等待 | §6.2 步骤 2 | +| W3 | DA1 阻塞 | 读线程在 CreateProcess 之前启动;镜像核心应答;**不做** DA1 过滤 hack | §7.3、§7.7 | +| W4 | 9001 | 在 TCode 一侧跟踪 9001,06-input 负责编码 | §7.7 | +| W5 | 字宽不一致 | flags 与核心一致;Windows 锁定宽度模式 | §7.4 | +| W6 | 旧版"重新渲染"与清屏 | 随包 dll;`ClearBufferAsync` | §7.3 | +| W7 | resize 后的 CPR | 镜像核心任何时候都应答 | §7.7 | +| W8 | 编码 | ConPTY 的输入输出都是 UTF-8;字节原样交给核心,PTY 层不解码 | — | +| W9 | 句柄泄漏 | `bInheritHandles = FALSE`;hStd* 显式为 0 | §7.3 步骤 6 | +| W10 | 进程树 | 每个会话一个 Job,正常关闭时清除 KILL 标志 | §7.5 | +| W11 | 前台进程和 cwd | Toolhelp32;cwd 只来自 OSC 7 / 9;9 | §8.2 | +| W12 | DLL 误绑定 | 绝对路径 + 确认 OpenConsole 在位;回退时明确记录日志 | §7.1 | + +--- + +## 8. 进程信息 + +### 8.1 Unix:前台进程 + +``` +GetForegroundAsync: + fg = tcpty_get_fg_pgrp(master) // tcgetpgrp + shellPgid = ResolveShellPgid() // §8.3 + if fg <= 0 → null + name = tcpty_get_process_name(fg) // 进程组组长;不存在时(管道中的组长已退出)name = null + return new ForegroundInfo(fg, name, IsShell: fg == shellPgid) +``` + +- "是否有程序在运行"由 host 结合忽略列表判断(research/05 §8.1:shell 和 login 不算,tmux 算)。PTY 层只提供上面三项。 +- 查询按需进行,标题刷新节流到 ≤ 1 Hz,不轮询。 + +### 8.2 Windows:Toolhelp32 进程树 + +1. `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS)`,建立 pid → ppid 映射; +2. 从 shell pid 出发,求后代集合。用 `GetProcessTimes` 校验"子进程的创建时间 ≥ 父进程的创建时间",防止 pid 复用造成误判; +3. 排除 `conhost.exe` 和 `OpenConsole.exe`; +4. 剩余后代为空 → `IsShell = true`;否则取创建时间最晚的后代作为 Name 和 Pid。 + +单次快照约 1–5 ms [推],按需调用。WorkingDirectory 能力为 false。 + +### 8.3 macOS:不经 login 包装(2026-10-17 决定) + +用户决定放弃 `/usr/bin/login` 包装(原因:login 父进程 `waitpid` 到 shell 后调用 `bail(NO_SLEEP_EXIT, 0)` → `exit(0)`,完全不看 shell 的退出状态 [实 LOGIN login.c:854-883、1480-1493])。macOS 改为由 helper 直接 exec shell,argv0 为 `-zsh` 形式([07](07-shell-profiles-config.md) §3.2)。因此在所有 Unix 平台上: + +- spawn 返回的 pid 就是 shell,`shellPgid = pid`(POSIX_SPAWN_SETSID); +- 退出码直接来自 `waitpid(pid)`,不需要对孙进程注册 `NOTE_EXITSTATUS`;`TCPTY_WATCH_EXITSTATUS` 与 `PtyLaunchHints.MacLoginWrapper` 不再需要,实现时删除; +- cwd 兜底直接查询 shell 的 pid。 + +### 8.4 cwd 回退链 + +| 顺序 | 来源 | 平台 | +|---|---|---| +| 1 | 镜像核心记录的 OSC 7(Windows 还有 OSC 9;9),只作为不可信输入,需要校验目录存在 | 全部(host 层) | +| 2 | `connection.GetWorkingDirectoryAsync()` → `tcpty_get_cwd(shellPid)`:Linux 读 `/proc//cwd`,macOS 用 `PROC_PIDVNODEPATHINFO` [实 SDK proc_info.h:741] | Unix | +| 3 | 这个会话启动时的 cwd | 全部 | +| 4 | 项目根目录 → 用户主目录 | 全部 | + +- 取的是 **shell** 的 cwd,不是前台程序的 cwd(与 Konsole 一致 [实 research/05 §1.2])。 + +--- + +## 9. 环境变量的传递格式 + +| 平台 | 格式 | 规则 | +|---|---|---| +| Unix | `char *const envp[]`,元素为 UTF-8 的 `"KEY=VALUE"`,以 NULL 结尾,通过 `posix_spawn` 原样传给 helper,helper 用它 `execvp`(PATH 查找也使用这份环境) | 由 C# 构造:key 去重(后者覆盖前者);key 为空或含 `=`、值含 NUL 时直接拒绝(抛 `ArgumentException`)。PTY 层**不增删任何变量**,策略在 TCode.Terminal.Shell(research/05 §2.3)。不在子进程里 setenv | +| Windows | UTF-16 环境块:`K=V\0K=V\0\0`(为空时是 `\0\0`),配合 `CREATE_UNICODE_ENVIRONMENT` | 按名称做大小写不敏感的排序(Win32 文档要求按 Unicode 序,与区域无关;用 `OrdinalIgnoreCase` 近似);以 `=` 开头的驱动器 cwd 项(`=C:=C:\…`)保留在最前面;key 去重时不区分大小写 | + +`.NET` 读取环境时按 UTF-8 解码,原来不是 UTF-8 的字节会丢失 [推]。v1 接受这个限制,S11 实测其影响。 + +argv 与命令行: + +- Unix 用 `argv0` 加参数数组,不经过 shell; +- Windows 优先使用 `WindowsCommandLine` 原样传入,否则按 MSVCRT 规则拼接(`"File" args`,处理反斜杠和引号); +- cmd.exe 和用户手写的命令行必须走原样传入,这是在 `PtyLaunch` 中新增该字段的原因(§16-1)。 + +--- + +## 10. 与 .NET 运行时共存的规则 + +| # | 规则 | 原因 | 来源 | +|---|---|---|---| +| R1 | 只 `waitpid(pid)`,**绝不** `waitpid(-1)` 或 `wait()` | 否则会抢走 `Process` 的退出状态 | [实 research/02 §2.5;RT ProcessWaitState.Unix.cs:609-700] | +| R2 | 收到退出通知后**立即**回收 | 僵尸进程会让 .NET 的每次 SIGCHLD 都走 checkAll 全量扫描 | [实 同上:622-640] | +| R3 | 回收时遇到 `ECHILD` → 退出码"未知"(ExitCode 和 Signal 都为空) | .NET reapAll 的场景(pid 1,或启动时 SIGCHLD = SIG_IGN)| [实 RT pal_signal.c:380-388] | +| R4 | 不把 SIGCHLD 设为 SIG_IGN 或 SA_NOCLDWAIT;`tcpty_init` 发现进入时已经是 SIG_IGN 时,复位为 SIG_DFL 并上报 `sigchld_was_ignored` | .NET 在初始化时就记录了"原始处置"并据此决定 reapAll,我们事后复位改变不了它的决定,只能保证内核不再自动回收 | [实 RT pal_signal.c:384];影响为 [推] | +| R5 | pty host 尽量不用 `System.Diagnostics.Process`、`Console`、`PosixSignalRegistration`;必须用时,确保启动时 SIGCHLD 不是 SIG_IGN(Desktop 用 `Process.Start` 启动 host 时,自定义处理器会被复位为 SIG_DFL) | 不用就不会安装 .NET 的 SIGCHLD 处理器,彼此完全不干扰 | [实 RT pal_process.c:584-604(只复位自定义处理器)] | +| R6 | posix_spawn 的 `SETSIGDEF` 用**全集**,`SETSIGMASK` 用空集 | .NET 的 posix_spawn 路径只复位"自定义处理器",SIG_IGN 被保留,所以 SIGPIPE 会以忽略状态传下去 | [实 RT pal_process.c:584-604;node-pty pty.cc:830-845] | +| R7 | SIGPIPE 在 host 内保持 .NET 的 SIG_IGN | 写入已关闭的 pty 或管道时得到 EPIPE(映射为 `E_IO`),而不是让 host 崩溃 | [实 research/02 §1.4] | +| R8 | fd 0–2 在 host 启动时补齐(`tcpty_init`) | host 以分离方式启动,stdio 可能被关闭,之后 `/dev/ptmx` 会拿到 0–2 | [推];node-pty 的 low_fds 也是在防这个 [实 NP pty.cc:752-758] | +| R9 | 读、写、退出监视都用专用 `Thread`,不在线程池中阻塞 | — | [实 research/02 §5.5] | +| R10 | .NET 11 GA 之后评估 `UnixProcessStartArguments` 回调 | 把 pid 交给 `Process` 跟踪,从根本上消除 SIGCHLD 冲突。`IPtyExitWatcher` 抽象保留替换点 | [实 research/02 §2.5] | + +--- + +## 11. 原生构建与打包 + +### 11.1 编译命令(4 个 Unix RID) + +推荐方案:**Linux 用 zig 交叉编译**(libghostty-vt 已经需要 pin 一个 zig 版本,复用即可),macOS 用 Apple clang。 + +```bash +# 公共选项 +CFLAGS="-O2 -fPIC -fvisibility=hidden -D_GNU_SOURCE -Wall -Wextra -Werror -Inative/tcodepty/include" +SRC="native/tcodepty/src/tcpty_spawn.c native/tcodepty/src/tcpty_io.c native/tcodepty/src/tcpty_exit.c native/tcodepty/src/tcpty_proc.c" + +# linux-x64 / linux-arm64:glibc 基线 2.28(RHEL 8),任何 Linux 主机都能交叉编译 +for T in x86_64 aarch64; do + zig cc -target $T-linux-gnu.2.28 $CFLAGS -shared -Wl,-soname,libtcodepty.so -o out/$T/libtcodepty.so $SRC + for f in $SRC; do zig cc -target $T-linux-gnu.2.28 $CFLAGS -c $f -o out/$T/$(basename $f .c).o; done + zig ar rcs out/$T/libtcodepty.a out/$T/*.o # 供 DirectPInvoke 使用 + zig cc -target $T-linux-musl -O2 -static -o out/$T/tcode-pty-spawn native/tcodepty/helper/tcode-pty-spawn.c # 完全静态 +done +native/tcodepty/check-glibc.sh out/*/libtcodepty.so # objdump -T | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1 ≤ 2.28 + +# osx-arm64 / osx-x64:Apple clang,在 arm64 runner 上可以同时产出两个架构 +for A in arm64 x86_64; do + clang -arch $A -mmacosx-version-min=11.0 $CFLAGS -dynamiclib -install_name @rpath/libtcodepty.dylib -o out/$A/libtcodepty.dylib $SRC + clang -arch $A -mmacosx-version-min=11.0 $CFLAGS -c $SRC # 生成 .o,然后: + libtool -static -o out/$A/libtcodepty.a *.o + clang -arch $A -mmacosx-version-min=11.0 -O2 -o out/$A/tcode-pty-spawn native/tcodepty/helper/tcode-pty-spawn.c +done +``` + +| 项 | 决定 | 说明 | +|---|---|---| +| 备选(不用 zig) | Linux 用 `quay.io/pypa/manylinux_2_28_{x86_64,aarch64}` 容器中的 gcc-toolset,分别在 `ubuntu-24.04` 和 `ubuntu-24.04-arm` runner 上原生编译 | 与 MiniPty 的 runner 布局相同 [实 MP build.yaml:20-40];helper 也可以用 `gcc -static`(glibc 静态链接时 `opendir` 没有问题) | +| glibc 基线 | 2.28;用到的符号:posix_spawn\*、ptsname_r、epoll_\*、pipe2、syscall。`close_range` 和 `pidfd_open` **只通过 `syscall()` 调用**,不依赖 2.34 / 2.36 的封装 | .NET 10 的 glibc 基线是 2.27 [实 dotnet/core supported-os.md] | +| musl | **v1 不发布 musl RID**(01 定的是 6 个 RID)。代码与 musl 兼容(SETSID 需要 1.1.17+,ptsname_r 存在),以后加 `zig cc -target *-linux-musl` 即可。helper 本来就是 musl 静态链接,与发行版无关 | libghostty-vt 也要同步加 musl 构建,所以放到以后 | +| macOS 最低版本 | `-mmacosx-version-min=11.0`,与 `tools/package_macos_app.sh` 的 `LSMinimumSystemVersion` 一致;使用的 API 最低要求 10.7(proc_listchildpids),不使用 26.0 才有的 `addchdir` | .NET 10 官方只支持 macOS 15、26、27 [实 supported-os.md:23],建议 12-build 文档把 LSMinimumSystemVersion 提高到 15.0 | +| 编译警告 | `-Werror`;另跑一遍 `-fsanitize=address,undefined` 的测试构建(只在 Linux CI 上) | — | + +### 11.2 签名与公证(macOS) + +```bash +# 由内向外签名:先签 helper 和 dylib,再签 tcode-ptyhost,最后签 .app +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" TCode.app/Contents/MacOS/ptyhost/tcode-pty-spawn +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" TCode.app/Contents/MacOS/ptyhost/libtcodepty.dylib # 仅动态模式 +codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID" TCode.app/Contents/MacOS/ptyhost/tcode-ptyhost +# …最后签 TCode.app,然后 notarytool submit --wait,stapler staple +``` + +- helper **不需要任何 entitlement**。hardened runtime 只约束 helper 自己的映像,exec 之后由新映像(shell)自己的签名决定 [推,S13]。 +- 动态模式下 dylib 必须使用相同的 Team ID(library validation)。 +- 可执行位:构建脚本中执行 `chmod 755`。打包时用 ``,MSBuild 的 Copy 会保留权限 [推]。运行时 `HelperLocator` 检查 `File.GetUnixFileMode` 是否有 UserExecute,没有就报出明确错误(不在已签名的 bundle 里改权限)[实 research/02 U13]。 + +### 11.3 发布目录布局 + +沿用 AgentHost 发布到 `agenthost/` 子目录的做法 [实 src/TCode.Desktop/TCode.Desktop.csproj:50-53]。pty host 发布到 `ptyhost/`。 + +``` +/ (macOS:TCode.app/Contents/MacOS/) + TCode[.exe] + libghostty-vt.{dylib,so,dll} UI 侧核心使用 + agenthost/… + ptyhost/ + tcode-ptyhost[.exe] AOT + libghostty-vt.* 镜像核心使用(与 UI 侧是同一个构建,01 §3.2 规则 3) + tcode-pty-spawn Unix + libtcodepty.{so,dylib} Unix,只在动态模式下存在 + conpty.dll Windows(来自 runtimes/win-/native) + x64/OpenConsole.exe Windows x64 包同时带 x64 和 arm64 两份;arm64 包只带 arm64 + arm64/OpenConsole.exe +``` + +`HelperLocator` 默认使用 `Path.Combine(AppContext.BaseDirectory, "tcode-pty-spawn")`;可以用 `LocalPtyOptions.HelperPath` 覆盖(测试用)。 + +### 11.4 ConPTY NuGet 引用 + +```xml + + + true + true + + + + +``` + +- 包里的 `build/` targets **只对直接引用生效**,通过 ProjectReference 传递时只会带上 conpty.dll,OpenConsole 会丢失,而 conpty.dll 找不到它时会静默回退到 conhost [实 PP Porta.Pty.targets:3-23]。所以要在 Host 项目里直接引用,`TCode.Terminal.Pty` 本身不引用这个包。 +- 属性名 `ConptyRequires*Host` 取自 Porta.Pty 的用法 [实 PP Porta.Pty.csproj:35-36]。它在 AOT 发布中是否把文件放进 `x64\` 和 `arm64\`,需要 S8 实测;如果不行,就用 `GeneratePathProperty` 加显式的 `` 兜底。 +- **CI 断言**:Windows 发布目录必须包含 `conpty.dll`、`x64\OpenConsole.exe` 和 `arm64\OpenConsole.exe`(x64 包)。启动日志必须报告 `ConPty=Bundled`。 +- 升级规则:conpty.dll 与 OpenConsole.exe **必须成对升级**(wezterm#7774 [实 research/02 §3.4])。 + +### 11.5 AOT 下静态链接 libtcodepty(DirectPInvoke) + +| 方案 | 优点 | 缺点 | 决定 | +|---|---|---|---| +| 动态 `.so` / `.dylib` | Debug/JIT、单元测试直接可用;替换方便 | 多一个需要签名的文件;需要处理加载路径(已用 `AssemblyDirectory` 限定) | **测试和 Debug 构建使用** | +| 静态 `.a` + `` + `` | 少一个文件;符号缺失时在链接期报错;调用没有懒绑定开销 [实 research/02 §5.3] | 每个 RID 都要有 `.a`;它与宿主的 libc 一起链接(这正是我们想要的) | **tcode-ptyhost 的 Release AOT 发布使用** | + +```xml + + + + + +``` + +注意:AOT 二进制本身链接的是**构建机**的 glibc。要支持 RHEL 8,tcode-ptyhost 必须在 glibc ≤ 2.28 的 sysroot 上发布,由 12-build 文档负责。 + +--- + +## 12. 独立修复:BashTool 的 SIGPIPE 问题(只给出改法,本次不改代码) + +**问题**:`BashTool.Start` 通过 `Process.Start` 启动 `$SHELL -c`(src/TCode.Agent.Coding/Tools/Shell/BashTool.cs:184-211)。.NET 把 SIGPIPE 设为 SIG_IGN,它的 spawn 路径又只复位自定义处理器(RT pal_process.c:584-604),所以 bash 进程继承了 SIGPIPE 忽略状态。结果 `yes | head -1` 中的 `yes` 不会被 SIGPIPE 结束,而是报 `yes: stdout: Broken pipe` 并以 1 退出;Python 会打印 BrokenPipeError 栈。 + +**本机实测**(父进程 SIGPIPE=IGNORE)[实 local]: + +| 命令 | 结果 | +|---|---| +| `bash -c 'trap - PIPE; yes \| head -1'` | `yes: stdout: Broken pipe`,PIPESTATUS = `1 0` → **`trap - PIPE` 对 bash 无效**(bash 规定:进入时就被忽略的信号不能被 trap 或复位) | +| `zsh -c 'trap - PIPE; yes \| head -1'` | pipestatus = `141 0` → zsh 有效 | +| `sh -c 'trap - PIPE; perl -e "print \$SIG{PIPE}"'` | `IGNORE` → /bin/sh 也无效 | + +所以 research/02 §9.3 的建议"在 `-c` 前加 `trap - PIPE;`"不成立。 + +**改法**:借用 helper 的 `--exec` 模式,在 exec shell 之前把全部信号复位。 + +```csharp +// BashTool.Start(只在 Unix 上,并且找到 helper 时) +var shell = ShellResolver.Current; +var helper = PtyHelperPath.TryResolve(); // 1) 环境变量 TCODE_PTY_HELPER(Desktop 启动 AgentHost 时注入) + // 2) Path.Combine(AppContext.BaseDirectory, "..", "ptyhost", "tcode-pty-spawn") +var info = helper is null || OperatingSystem.IsWindows() + ? new ProcessStartInfo(shell.Executable) + : new ProcessStartInfo(helper) { ArgumentList = { "--exec", shell.Executable, shell.Executable } }; // file, argv0 +foreach (var arg in shell.PrefixArguments) info.ArgumentList.Add(arg); +info.ArgumentList.Add(command); +// 其余(重定向、环境变量)不变 +``` + +| 项 | 说明 | +|---|---| +| 进程树与 Kill | helper 是 exec 进 shell 的,pid 不变,`Process.Kill(entireProcessTree)` 的行为不变 | +| 找不到 helper 时 | 保持现状并记录一次警告;zsh 可以在命令前加 `trap - PIPE;` 作为局部缓解(已实测有效),bash 没有纯 shell 层面的修复 | +| 依赖方向 | AgentHost 不需要引用 TCode.Terminal.\*,只需要知道 helper 的路径 | +| 测试 | 新增 `BashToolTests.SigpipeIsDefault`:`yes \| head -1; echo ${PIPESTATUS[0]}` 期望输出 141 | +| `ProcessRunner`(rg、git) | 这些程序自己处理 EPIPE,可以不改;需要时用同样的方式包装 | + +--- + +## 13. 退出码语义汇总 + +| 情形 | TerminalExit | UI 文案(research/05 §8.3) | +|---|---|---| +| Unix WIFEXITED | `ExitCode = n` | 0 → 按 closeOnCleanExit 处理 | +| Unix WIFSIGNALED | `Signal = "SIGTERM"…`,`CoreDumped` | "被信号终止" | +| shell 自己报告 128+N(子进程被信号杀死后 shell 正常退出) | `ExitCode = 128+N` | 原样显示 | +| `ECHILD` | 两者都为空 | "进程已退出",不显示退出码 | +| Windows | `GetExitCodeProcess`;`STILL_ACTIVE`(259)不会出现,因为只在进程句柄被触发后才读取 | ≥ 0x80000000 时显示十六进制 | +| 启动失败(stage ≠ 0) | 不产生连接,`Start` 抛出 `PtyException(Stage, Errno, Message)` | 05 §8.3 "启动失败"界面:显示命令和 errno 文本 | + +--- + +## 14. 测试计划(tests/TCode.Terminal.Pty.Tests) + +所有测试都通过真实的 PTY 进行,不使用模拟。断言针对输出字节(带超时的 `ReadUntil(pattern, 5s)`)、`Exited` 和 fd/线程计数。 + +| # | 用例 | 平台 | 断言 | +|---|---|---|---| +| T1 | 往返:`sh -c 'printf ready; read x; printf "got:%s" "$x"'`,写入 `abc\r` | 全部(Windows 用 `cmd /c set /p`) | 输出包含 `got:abc`;echo 可见 | +| T2 | tty 正确性:`tty; test -t 0; ps -o sid=,pgid=,tpgid= -p $$; stty -a` | Unix | 会话首进程 = pid;tpgid = pgid;`iutf8` 已打开;`bash -i` 不报 "no job control" | +| T3 | SIGPIPE:`bash -c 'yes \| head -1; echo ${PIPESTATUS[0]}'` | Unix | `141` | +| T4 | resize:`trap 'stty size' WINCH` 循环;ResizeAsync(100×30, cell 9×18) | Unix;Windows 用 `mode con` 查询 | 输出 `30 100`;`tcpty_get_winsize` 等于 `{30,100,900,540}` | +| T5 | 退出码:`exit 0` / `exit 3` / `kill -TERM $$` / `exec false` | 全部 | 0 / 3 / Signal=SIGTERM / 1 | +| T6 | 启动错误:文件不存在、不可执行、cwd 不存在 | Unix | `PtyException` 的 Stage=HELPER_EXEC,ENOENT / EACCES;cwd 不存在时 `Warnings.CwdFallback` 且实际在 $HOME | +| T7 | 关闭语义:`trap '' HUP; sleep 100` | Unix | 关闭耗时 ≈ grace + ε,最后被 SIGKILL;普通 shell 在 200 ms 内退出;`nohup sleep 30 &` 在关闭后仍存活(`kill -0`) | +| T8 | 退出排空:`printf 'last-line'; exit 0` | 全部 | Output 中 `last-line` 出现在 `Exited` 完成之前 | +| T9 | 后台进程持有 slave:`sleep 5 & exit` | Unix | `Exited` 在 ≤ 1.2 s 内完成(不等 5 s) | +| T10 | `yes \| head -c 100M` 与慢消费方 | 全部 | 消费方暂停 2 s 期间,Pipe 积压 ≤ 256 KiB + 64 KiB;子进程被阻塞(随后吞吐恢复) | +| T11 | `cat` 1 GB(`head -c 1G /dev/zero \| tr '\0' a`) | 全部 | 记录 MB/s;host 的 RSS 增量 < 50 MB。实测(M1,macOS arm64):约 23–34 MiB/s,与不经本库的 Python `pty.fork` 读者相同,瓶颈在内核 PTY + `tr`,原先"≥ 300 MB/s"的推测不成立 | +| T12 | 50 个并发会话:同时启动,各自往返,再全部关闭 | 全部 | 全部成功;之后 fd 数量回到基线(`/proc/self/fd` 或 `lsof -p`),线程数回到基线,没有僵尸进程(`ps -o stat`) | +| T13 | 快速开关:500 次 Start → 立即 Close | 全部 | 没有异常、没有泄漏;macOS 不出现 ENXIO(锁 + 重试);Windows 不出现 0xC0000142 | +| T14 | TUI resize 风暴:测试程序在 SIGWINCH 时打印尺寸;1 s 内 resize 1000 次(随机尺寸),最后一次固定 | 全部 | 最后一次打印的尺寸等于最终尺寸;不崩溃、不挂起;`vim -u NONE` 能正常退出 | +| T15 | 与 SIGCHLD 共存:后台循环 `Process.Start("true")` 1000 次,同时 20 个 PTY 会话各自退出 | Unix | 每个 `Process.WaitForExit` 都返回;每个 PTY 的退出码正确 | +| T16 | pidfd 回退:设置 `TCODE_PTY_FORCE_WAITPID_THREAD=1` 后重跑 T5、T12 | Linux | 结果相同 | +| T17 | macOS argv0 login shell:argv0 `-zsh` 启动 `/bin/zsh`,执行 `exit 3` | macOS | `ps -o args= -p $$` 为 `-zsh`;PATH 含 `/etc/paths.d` 中的路径;`TerminalExit.ExitCode == 3` | +| T18 | helper `--detach`:从 .NET `Process.Start` 启动 `--detach --stdout-fd 1 -- /bin/sh -c 'ps -o pgid= -p $$; tty; ls /dev/fd; echo READY'`(Linux 另查 `ps -o sid=`,macOS 用 `ps -o sess=`),并额外打开一个不带 CLOEXEC 的 fd | Unix | sid = pgid = pid(新会话),没有控制终端(`tty` 输出 "not a tty");`/dev/fd` 只有 0–2(以及 ls 自己打开的 fd);SIGPIPE 为默认;参数错误时退出码为 126,exec 失败时为 127 | +| T19 | helper `--exec`:`--exec bash bash -c 'yes \| head -1; echo ${PIPESTATUS[0]}'` | Unix | `141` | +| W-T1 | DA1:Inbox 模式(强制)下,从启动到第一个提示符的时间 < 500 ms;把应答路径关掉时约 3 s(证明应答确实生效) | Windows | — | +| W-T2 | 9001/1004:输出中包含 `ESC[?9001h`、`ESC[?1004h`;以 9001 编码发送 `a` 的按下和抬起,`cmd` 收到 `a` | Windows | — | +| W-T3 | 关闭不死锁:`cmd /c type 大文件` 持续输出,消费方停止读取后调用 Close | Windows(Bundled 和强制 InboxNoRelease 各跑一遍) | Close 在 grace + 1 s 内完成 | +| W-T4 | Release 模式的 EOF:`cmd /c exit 5` | Windows | ExitCode = 5;Output 完成 | +| W-T5 | Unicode 环境变量:`TCODE_T=中文🙂`,执行 `cmd /c echo %TCODE_T%` | Windows | UTF-8 字节一致 | +| W-T6 | 发布布局:conpty.dll + `x64\` + `arm64\` 下的 OpenConsole.exe,`Implementation == Bundled` | Windows | — | +| AOT | `tests/aot-smoke`:AOT 发布一个控制台程序,跑 T1 + T5 + 关闭 | 6 个 RID | 0 个 IL 警告;退出码 0 | + +**CI**(`.github/workflows/terminal-pty.yml`;runner 名称参考 MiniPty [实 MP build.yaml:26-40]): + +| Job | runner | 内容 | +|---|---|---| +| native-linux | ubuntu-24.04 | zig 交叉编译两个 Linux RID;check-glibc;产物上传为 artifact | +| native-macos | macos-26(arm64) | 产出两个 macOS RID 的 dylib、.a 和 helper | +| test-linux-x64 / arm64 | ubuntu-24.04 / ubuntu-24.04-arm | 下载 native 产物 → `dotnet test`(全部 Unix 用例,含 T16)+ ASan 构建 + AOT smoke | +| test-rhel8 | ubuntu-24.04 + `almalinux:8` 容器 | AOT smoke(在 glibc 2.28 sysroot 上发布的二进制)。容器内的内核仍是宿主内核,pidfd 回退路径靠 T16 的强制开关覆盖 | +| test-macos-arm64 / x64 | macos-26 / macos-26-intel(或 macos-15-intel) | 全部 Unix 用例 + T17 + AOT smoke | +| test-windows-x64 / arm64 | windows-2025 / windows-11-arm | W-T1..6;用 `LocalPtyOptions.ForceConPty = Inbox / InboxNoRelease` 覆盖降级路径(windows-2025 是 26100 构建,inbox 自带 Release) | +| 手动矩阵(发版前) | Windows 10 1809 / 21H2 虚拟机、RHEL 8 实机、macOS 15 | W-T3 的真实 inbox 死锁场景;内核 4.18 上的 pidfd 回退 | + +GitHub runner 提供 `/dev/ptmx`;Docker 默认挂载 `/dev/pts`。 + +--- + +## 15. 风险与 spike 清单 + +| # | 待实测项 | 怎么测 | 失败时的后备方案 | +|---|---|---|---| +| S1 | ~~macOS login 包装~~(已取消:2026-10-17 决定不经 login) | — | — | +| S2 | Linux:helper 在 `adddup2` 了 O_NOCTTY slave 之后执行 TIOCSCTTY 成功;glibc 2.28 和 musl 在 sigdefault 含 SIGKILL 时不报错 | T2,在 RHEL 8 和 Alpine 容器中运行 | sigdefault 去掉 SIGKILL 和 SIGSTOP | +| S3 | macOS gather 参数的收益(1 KiB / 16 / 1 ms / 3 ms);用户交互型 QoS | T11 对比实验 | 调整参数 | +| S4 | ConPTY 1.25:DECSET 2027、DECRQM 2027/9001、`CSI 14/16/18 t` 是由 conhost 自己应答还是透传;`0x03` 是否产生 CTRL_C_EVENT | 用 Windows 测试程序写入查询序列,比较输出 | 修改 10-protocol-compat 的应答表 | +| S5 | inbox 1809 / 21H2 上丢弃模式能否避免 Close 死锁 | W-T3 手动矩阵 | 在 Close 之前先 `CancelSynchronousIo` 并关闭 outRead | +| S6 | W2 的 0xc0000142 窗口实际有多长 | T13 在 Windows 上统计 | 调整 500 ms | +| S7 | 1809 是否支持 `PROC_THREAD_ATTRIBUTE_JOB_LIST`;运行时能否清除 `KILL_ON_JOB_CLOSE` | 单元测试 + 1809 虚拟机 | CREATE_SUSPENDED → AssignProcessToJobObject → ResumeThread | +| S8 | `ConptyRequires*Host` 在 net10.0 AOT 发布中是否把 OpenConsole 放进两个架构子目录 | W-T6 | `GeneratePathProperty` + 显式 `` | +| S9 | 用 DirectPInvoke 把 libtcodepty.a 静态链接进 4 个 RID 的 AOT 二进制,与 libghostty-vt 没有符号冲突 | AOT smoke | 改用动态库 | +| S10 | pidfd 在 Docker 默认 seccomp 和 Flatpak 中是否可用 | T16 + `tcpty_init_info.pidfd_supported` | waitpid 线程回退(已实现) | +| S11 | 环境变量中有非 UTF-8 字节时的影响 | 构造 `LANG=` 和 GBK 值进行测试 | C shim 直接读取 host 原始的 `environ` | +| S12 | canonical 模式下长行粘贴被截断(macOS 1024、Linux 4095) | 往 `cat` 粘贴 8 KiB 单行 | 写进文档;多行粘贴警告已经覆盖大部分场景 | +| S13 | hardened runtime 和公证对 helper exec 用户 shell 的影响;TCC 权限提示归属(Desktop → host → shell 的"责任进程") | 公证构建 + 访问 ~/Documents | 由 04-pty-host 决定 host 的启动方式(spawnattr 的 disclaim 或不 disclaim) | +| S14 | Release 模式下后台控制台客户端(`start /b`)对 EOF 的影响 | Windows 实测 | 与 Unix T9 采用同样的排空超时 | + +其他风险: + +| 风险 | 影响 | 缓解 | +|---|---|---| +| 两份核心的 9001 跟踪逻辑不在 libghostty 中 | Windows 上按键编码错误 | §7.7,在 02-core / 06-input 中落实 | +| macOS 上 openpt 到 fcntl 之间的 CLOEXEC 竞争 | 极少数情况下 fd 泄漏给并发 fork 的子进程 | 直接用 `open(O_CLOEXEC)`;host 内不用 Process | +| OpenConsole 缺失时静默回退 | 功能悄悄降级 | 加载前检查 + CI 断言 + 诊断日志 | +| libtcodepty 体积超过 01 的估计 | 维护成本 | 按功能拆成四个源文件,每个都有单元测试 | + +--- + +## 16. 与 01 及其他文档的不一致、待决问题 + +| # | 问题 | 建议 | +|---|---|---| +| 1 | 01 §6.2 的 `PtyLaunch` 缺少:Windows 原样命令行、macOS login 包装提示、AmbiguousWide;`ITerminalConnection` 缺少应答专用的非阻塞写入、`ClearBuffer`、带 grace 参数的关闭、启动警告 | 采用 §4.1 的补充,并同步修改 01 §6.2 | +| 2 | ~~`TerminalSize` 的像素语义~~ | **已解决**:01 已统一为 cell 像素;本文 §4.1 已对齐。research/06 §6 的"总像素"写法已经过时 | +| 2a | 04 §1 的 `--detach` 模式由本文 §3 的 helper 实现。04 §4.4 `session/signal` 在 Windows 上把 KILL 写成"终止进程树",本文 §6.3 只 `TerminateProcess(shell)`(与 Unix 只杀进程组对等) | 二选一后统一。建议用 `TerminateJobObject(会话 Job)`:只限于这个会话的树,符合"终止进程树"的说法 | +| 3 | ~~macOS login 包装吞掉退出码~~ | 已解决:不经 login(§8.3) | +| 4 | D9 "默认兼容宽度,收到 2027 再切换"与 ConPTY 不兼容:宽度在创建时固定,2027 是永久模式 [实] | Windows 默认 Grapheme 并锁定;Unix 保持 D9 不变 | +| 5 | 01 §6.1 的 `FrameSnapshot.Modes` 包含 9001,但 libghostty-vt 没有这个模式 [实] | 由 02-core 设计 TCode 侧的模式跟踪器 | +| 6 | research/06 §6 "Output 在 Exited 之前读到 EOF"在后台进程持有 slave 时做不到 | 采用 §6.1 的"EOF 或静默超时"语义 | +| 7 | research/02 §9.3 / U3 的 `trap - PIPE` 修复对 bash 无效 [实 local] | 采用 §12 的 helper `--exec` 方案 | +| 8 | research/02 §2.4 建议的 `PR_SET_PDEATHSIG` 跟随的是父**线程** | 不采用;由 master 关闭后的内核挂断兜底 | +| 9 | 01 估计 libtcodepty 约 300 行,本设计约 690 行 | 修改 01 §4 中的估计 | +| 10 | `tools/package_macos_app.sh` 的最低系统版本是 11.0,而 .NET 10 只支持 macOS 15 及以上 | 由 12-build 决定是否提高 | +| 11 | pty host 不应使用 `Process` / `Console`(R5),stdio 必须补齐(R8) | 写进 04-pty-host 的启动规范 | diff --git a/docs/terminal/04-pty-host.md b/docs/terminal/04-pty-host.md new file mode 100644 index 0000000..f1944c3 --- /dev/null +++ b/docs/terminal/04-pty-host.md @@ -0,0 +1,1167 @@ +# TCode 终端:pty host 进程与 IPC 协议 + +> 状态:设计稿 v1(2026-10-16);M2 已按本文实现(2026-10-17),实现中的调整与未做项见 [13](13-roadmap.md) "M2 实施状态"。范围:`TCode.Terminal.Protocol`(帧格式、控制消息、流控、`PtyHostClient`、启动与发现)和 `TCode.Terminal.Host`(`tcode-ptyhost` 可执行文件:会话管理、镜像核心、输出日志、持久化)。 +> 上位文档:[01-architecture.md](01-architecture.md) §3(进程划分、一致性规则、生命周期)、§5(线程模型)、§6.4(IPC 要点)、§8(安全)、决策 D3。本文细化这些约定;与 01 不一致的地方列在 §19。 +> 相关:[02-core-ghostty.md](02-core-ghostty.md)(快照、formatter、回滚压缩)、[07-shell-profiles-config.md](07-shell-profiles-config.md)(profile、环境变量、`terminal` 配置节)、[09-agent-integration.md](09-agent-integration.md)(`command/*` 细节、审批)、[10-protocol-compat.md](10-protocol-compat.md)(OSC 52、查询应答)、[11-testing-benchmarks.md](11-testing-benchmarks.md)。 + +来源标注:**[实]** = 本次读过源码、头文件或文档并核对过;**[推]** = 推断或经验值,需要实测;**[定]** = 本文做出的设计决定。外部源码版本:VS Code `4765bd3`(2026-10-16),Ghostty `b3dc3d7`(2026-10-15),dotnet/runtime `main`(2026-10-16 读取)。 + +--- + +## 0. 结论摘要 + +| 主题 | 结论 | +|---|---| +| 进程 | `ptyhost/tcode-ptyhost[.exe]`,与 `agenthost/` 并列发布。Desktop 按需启动。Unix 下经 `tcode-pty-spawn --detach` 执行 `setsid` 并关闭多余 fd;Windows 用 `CreateProcessW` + `CREATE_BREAKAWAY_FROM_JOB \| DETACHED_PROCESS \| CREATE_NEW_PROCESS_GROUP` | +| 单例与发现 | `$TCODE_HOME/run/`(0700)下,每个**构建**一个 host:`ptyhost-.json`(0600)+ `ptyhost-.lock`。host 在整个生命周期内持有 `.lock`,所以"锁拿得到"就说明 host 已死,不依赖 pid 判断 | +| 升级 | 新旧构建的 host 可以并存。新会话只建在当前构建的 host 里;旧 host 只"排空":协议主版本相同时用 VT 兼容模式 attach,主版本不同时只能用冻结的引导子集(列出 / 保存 / 退出) | +| 认证 | 文件权限 + 对端凭据(`SO_PEERCRED` / `getpeereid` / 管道 SID)+ 基于 32 字节令牌的 **HMAC 双向挑战应答**(令牌本身不上线)。AgentHost 使用 Desktop 签发的**受限令牌** | +| 帧 | 12 字节头 `[u32 len][u8 type][u8 flags][u16 rsv][u32 session][payload]`,小端。控制帧载荷为源生成 JSON(≤ 1 MiB),数据帧(Output / Record / Snapshot / Input)载荷 ≤ 64 KiB | +| 日志 | 每个会话一条带**位置序号**的日志:Output 记录按字节数推进位置,Resize / SetColors / Reset / Clear 记录各占 1。默认环形保留 1 MiB | +| 流控 | 按订阅者独立计算:`backlog = head − acked`。backlog > 256 KiB 时暂停读 PTY;客户端每处理 32 KiB 回一次 Ack;所有 UI 订阅者都 < 64 KiB 时恢复。订阅者连续 10 s 不消化就降级,之后重新 attach(resync),不会无限期卡住 shell | +| 重连 | `session/attach` 在会话 actor 上原子地完成三件事:取 `S = head`、编码快照、登记游标 = S。快照字节流(READY 段在前)和 `Output(seq ≥ S)` 在同一个数据队列里按顺序发送,所以不重不漏 | +| 持久化 | `$TCODE_HOME/terminal/revive//`:`snapshot.bin`(libghostty 快照,裁剪到 N 行)+ `tail.vt`(formatter 产出的 VT 文本,供跨版本恢复)+ `meta.json`;每 30 s 保存有变化的会话;目录 0700,文件 0600 | +| 心跳 | 双向 Ping,空闲 5 s 发一次。客户端 6 s + 5 s 收不到任何帧时判定 host 无响应(与 VS Code 相同);host 30 s 收不到客户端的帧就断开该连接 | +| 宽限期 | 没有 UI 连接后开始计时,默认 30 min(只计清醒时间),到期按 `revive` 方式退出。用户主动选择 `keepAlive` 退出时不计时 | + +--- + +## 1. 进程 + +### 1.1 可执行文件与发布布局 + +| 项 | 值 | +|---|---| +| 项目 | `src/TCode.Terminal.Host/TCode.Terminal.Host.csproj`:`OutputType=Exe`、`AssemblyName=tcode-ptyhost`、`PublishAot=true`、`InvariantGlobalization=true`(host 不做本地化,所有面向用户的文字都由 Desktop 传入) | +| 发布位置 | 与 AgentHost 并列:`/ptyhost/`。macOS 包内为 `TCode.app/Contents/MacOS/ptyhost/` | +| 目录内容 | `tcode-ptyhost[.exe]`、`tcode-pty-spawn`(Unix)、`libghostty-vt.{dylib,so,dll}`、`libtcodepty.{dylib,so}`(Unix)、`conpty.dll` + `OpenConsole.exe`(Windows,D9)、`ghostty-build.txt`(构建 ID,见 §2.4) | +| MSBuild | 仿照 `CopyAgentHost` / `PublishAgentHost`([TCode.Desktop.csproj:36-54](../../src/TCode.Desktop/TCode.Desktop.csproj) [实]),新增 `CopyPtyHost` / `PublishPtyHost`,用相同的 RID 和 `PublishAot` 发布到 `ptyhost/` | +| 测试覆盖 | 环境变量 `TCODE_PTYHOST_DIR` 指向 host 的构建输出(与 `TCODE_AGENTHOST_DIR` 相同的做法,见 [AgentHostConnection.cs:54-58](../../src/TCode.Desktop/Services/AgentHostConnection.cs) [实]) | +| 自检 | `tcode-ptyhost --self-test`:启动 `/bin/sh`(或 `cmd.exe`),完成一次往返、resize、快照编码与解码后退出,返回码 0。CI 中的 AOT 冒烟测试运行它 | + +libghostty-vt 在 Desktop 根目录和 `ptyhost/` 中各有一份,**字节完全相同**(来自同一次构建)。握手时比较构建 ID(§2.4),满足 01 §3.2 规则 3。 + +### 1.2 命令行 + +``` +tcode-ptyhost --home --build --ready-fd 1 [--log-level info] [--parent-pid ] +tcode-ptyhost --self-test +``` + +| 参数 | 说明 | +|---|---| +| `--home` | 显式传入,不依赖继承的环境变量(Desktop 可能用 `TCODE_HOME` 覆盖过数据目录) | +| `--build` | Desktop 计算出的构建 ID。host 启动后自己再算一遍,不一致就以退出码 3 退出(防止 Desktop 和 host 的文件来自不同的安装) | +| `--ready-fd` | 就绪通知写到哪个 fd(Unix)。Windows 上固定使用继承进来的 stdout 句柄 | +| `--parent-pid` | 仅用于日志 | + +### 1.3 启动方式:脱离 Desktop + +目标:Desktop 崩溃、被杀、从终端启动后终端被关闭时,host 都不受影响(01 §3.3)。 + +| 平台 | 做法 | 理由 | +|---|---|---| +| macOS / Linux | Desktop 用 `Process.Start` 执行 `ptyhost/tcode-pty-spawn --detach --stdout-fd 1 -- ptyhost/tcode-ptyhost …`,stdin 为 `/dev/null`,stdout 为管道(用于读就绪行),stderr 为管道。helper 在 `--detach` 模式下:① `setsid()`;② 把信号处理恢复为默认、清空信号掩码;③ `close_range(3, ~0U, 0)`(Linux ≥ 5.9),旧内核逐个关闭 `/proc/self/fd` 中的 fd,macOS 遍历 `/dev/fd`;④ `execv(host)` | `setsid` 让 host 成为新会话的首进程,没有控制终端:Desktop 所在终端关闭时的 SIGHUP、Ctrl+C 产生的 SIGINT 都到不了 host [实,POSIX 语义]。.NET 只对自己打开的 fd 设置 `O_CLOEXEC`,Avalonia 等原生库打开的 fd 可能被继承下来。如果 host 持有 Desktop 某个 socket 的副本,对端就永远收不到 EOF,所以必须在 exec 前关掉 [推] | +| Windows | Desktop 用 `LibraryImport` 调 `CreateProcessW`。标志:`CREATE_BREAKAWAY_FROM_JOB \| CREATE_NEW_PROCESS_GROUP \| DETACHED_PROCESS \| CREATE_UNICODE_ENVIRONMENT \| EXTENDED_STARTUPINFO_PRESENT`;用 `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` 只让子进程继承就绪管道的写端 | Desktop 可能运行在某个 Job 中(调试器、某些启动器、CI),子进程默认会继承这个 Job,Job 关闭时一起被杀。`CREATE_BREAKAWAY_FROM_JOB` 只有在 Job 设置了 `JOB_OBJECT_LIMIT_BREAKAWAY_OK` 时才有效,否则 `CreateProcess` 返回 `ERROR_ACCESS_DENIED` [推,依据 Win32 文档语义]。`.NET Process.Start` 无法传这些标志 | +| Windows 回退 | 收到 `ERROR_ACCESS_DENIED` 时去掉 BREAKAWAY 再启动,并在 `hello` 应答中带 `jobBound: true`。Desktop 据此把 `keepAlive` 选项置灰,并说明"当前环境下终端会随 TCode 一起退出" | 备选方案是用 WMI `Win32_Process.Create` 在 Job 之外创建进程。它依赖 COM,AOT 下成本高,v1 不做 | + +**就绪握手(启动时序)**: + +```mermaid +sequenceDiagram + participant D as Desktop + participant S as tcode-pty-spawn + participant H as tcode-ptyhost + D->>D: 获取 run/ptyhost-.start.lock(短时锁,串行化多个启动者) + D->>D: 再试一次连接(可能已被别的窗口启动) + D->>S: Process.Start(--detach … host) + S->>S: setsid · 恢复信号 · 关闭 fd≥3 + S->>H: execv + H->>H: 获取 ptyhost-.lock(生命周期锁;拿不到就以退出码 2 退出) + H->>H: 生成 token、instanceId,监听 socket / pipe + H->>H: 原子写入 ptyhost-.json(先写 tmp 文件 0600,再 rename) + H-->>D: stdout 写一行:"READY \n" + H->>H: 把 /dev/null 重定向到 fd 0/1/2(Windows:关闭 std 句柄) + D->>D: 读到 READY(超时 20 s)→ 释放 start.lock → 读 json → 连接 → hello/auth +``` + +- 超时取 20 s,对应 VS Code 的 `ConnectingBeatInterval = 20000`,给慢机器留出余量(`common/terminal.ts:476` [实])。 +- 退出码:0 正常;2 已有同构建的 host 在运行(Desktop 收到后直接连接它);3 构建 ID 不一致;4 无法创建运行时目录或 socket;其他值为崩溃。 +- 读到 READY 后,Desktop 立即释放 `Process` 对象,**不 Kill**。host 已经是 init / launchd / systemd 子收割者的孙进程(Unix),或者是 Job 之外的独立进程(Windows)。 + +### 1.4 单例发现 + +**目录布局** [定]: + +``` +$TCODE_HOME/run/ 0700(Windows:继承用户配置目录的 ACL) + ptyhost-.json 0600,每个运行中的 host 一份 + ptyhost-.lock host 生命周期锁(独占) + ptyhost-.start.lock 启动者之间的短时锁 + ptyhost-.sock Unix socket(路径过长时放到别处,见 §2.2) +``` + +`` = `hex(SHA-256(protocolMajor ‖ appVersion ‖ ghosttyBuildId))[0..12]`。之所以按构建区分而不是"每个用户只有一个",原因见 §1.5。 + +**`ptyhost-.json`**(源生成,`PtyHostRegistration`): + +```jsonc +{ + "formatVersion": 1, + "pid": 41237, + "processStartTime": "2026-10-16T14:03:11.512Z", // Linux /proc//stat 第 22 列;macOS proc_pidinfo;Windows GetProcessTimes + "instanceId": "01JAB3…", // 每次启动随机生成(ULID) + "endpoint": "/Users/u/.tcode/run/ptyhost-3f9a1c0d2b7e.sock", // Windows:\\.\pipe\tcode-ptyhost- + "token": "base64url(32 字节)", // 完整权限令牌 + "protocol": { "major": 1, "minor": 0 }, + "hostVersion": "0.9.0", + "build": "3f9a1c0d2b7e", + "ghosttyBuildId": "1.3.1+sha256:6b0e…", + "startedAt": "2026-10-16T14:03:11.600Z", + "jobBound": false +} +``` + +**陈旧文件与 pid 复用的检测**(按顺序判断,前一层就能得出结论时不再看后面): + +| 层 | 检查 | 结论 | +|---|---|---| +| 1 | 尝试以非阻塞方式获取 `ptyhost-.lock`(Unix:`FileStream` + `FileShare.None`,.NET 在 Unix 上用 `flock(LOCK_EX\|LOCK_NB)` 实现 [推];Windows:共享模式为 0 的打开方式是强制锁) | **能拿到锁说明 host 已死**:删除 json 和 sock,然后释放锁。host 进程退出(包括崩溃、被 SIGKILL)时内核自动释放锁,所以 pid 被复用也不会误判 | +| 2 | 拿不到锁 → 读 json,连接 endpoint | 连接被拒绝或超时(2 s):host 可能卡住了,按"无响应"处理(§1.6),**不删文件** | +| 3 | 握手应答中的 `instanceId` 和 `pid` 必须与 json 一致,`serverProof` 必须验证通过 | 不一致:文件被篡改或抢注,拒绝连接并记录安全日志 | + +`processStartTime` 只用于诊断日志(把"pid 还在,但已经不是那个进程"的情况写清楚)。存活判断只看锁。 + +### 1.5 升级时新旧 host 并存 + +01 §3.3 要求"UI 升级重启后会话仍在运行"。升级后 Desktop 是新构建,正在运行的 host 是旧构建,01 §3.2 规则 3 又要求两侧使用同一个 libghostty 构建。处理方式 [定]: + +| 情形 | 判定 | 行为 | +|---|---|---| +| 同构建 | `build` 相同 | 正常 attach(libghostty 二进制快照) | +| 协议主版本相同,构建不同(通常是 libghostty 或应用版本变了) | `protocol.major` 相同 | 旧 host 成为**遗留 host**:Desktop 可以 attach 它的会话,但 `snapshotFormat = "vt"`(formatter 输出带回滚的 VT 文本,见 §8.5),标签上显示"旧版本会话"标记。**新会话只在当前构建的 host 中创建**。遗留 host 的最后一个会话关闭后,host 立即退出 | +| 协议主版本不同 | `protocol.major` 不同 | 只能使用**冻结的引导子集**(§4.9):`hello`、`auth`、`session/list`、`persistence/save`、`host/quit`。Desktop 弹出提示:"N 个旧版本终端仍在运行(其中 M 个有程序在运行:…)"。选项:**关闭并恢复内容**(默认:先 `persistence/save`,再 `host/quit{mode:"revive"}`,然后由新 host 按 revive 流程恢复)/ **保留**(旧 host 继续运行,直到程序结束或系统重启;"终端"菜单中保留一个入口,可以稍后关闭) | + +补充规则: + +- 遗留 host 不接受 `session/create`(返回 `HostDraining`),避免它从已被替换的安装目录启动 `tcode-pty-spawn`。macOS 升级会替换 `.app`,旧 helper 文件已经不存在了 [推]。 +- 协议版本只增不减:`minor` 的变化必须向后兼容,只能新增可选字段、新方法、带 `IGNORABLE` 标志的新帧类型;`major` 的变化允许不兼容,但**引导子集的帧头、方法名和字段永远不变**。 +- 构建 ID 不同意味着两个核心不是同一个构建。VT 兼容模式下 UI 侧核心可能在极少数边界情况与镜像核心不一致。查询应答始终来自镜像核心,所以程序看到的行为不受影响,影响只限于显示([推],列入 §18)。 + +### 1.6 心跳 + +| 方向 | 规则 | 依据 | +|---|---|---| +| 双向 | 一端在 5 s 内没有发出任何帧时,发送 `Ping`;对端收到后立即回 `Pong`(控制优先级) | VS Code `BeatInterval = 5000` [实 `common/terminal.ts:470`] | +| 客户端判定 host 无响应 | 6 s(5 s × 1.2)内没收到任何帧 → 记警告,开始第二次计时;再过 5 s 仍没有 → 触发 `Unresponsive`。UI 显示"终端服务无响应"横幅,但**不断开、不杀进程**。之后收到任何帧就恢复 | VS Code 两段计时 `FirstWaitMultiplier = 1.2`、`SecondWaitMultiplier = 1`,第二段用来避免电脑刚从睡眠唤醒时误报 [实 `common/terminal.ts:481-487`;`ptyHostService.ts:380-400`] | +| `session/create` 超时 | 5 s 没有应答 → 立即提示无响应(不等心跳) | VS Code `CreateProcessTimeout = 5000` [实 `common/terminal.ts:493`] | +| host 判定客户端已死 | 30 s 没有收到该连接的任何帧 → 关闭连接,等同于断开 | [定],比客户端的判定宽松,因为 UI 进程可能被调试器暂停 | +| 时钟 | 全部使用单调时钟(`Stopwatch.GetTimestamp`) | — | + +### 1.7 宽限期 + +| 项 | 规则 | +|---|---| +| 开始 | 最后一个 `ui` 类型的连接断开,而且没有收到 `host/quit`(即异常断开,例如 Desktop 崩溃或被杀)。只剩 `agent` 连接时也开始计时(AgentHost 是 Desktop 的子进程,通常会随之退出) | +| 时长 | `terminal.persistence.orphanGraceMinutes`,默认 **30**(01 §3.3)。由 Desktop 通过 `host/configure` 下发,host 记在内存里 | +| 计时 | 只计清醒时间:Unix 用 `CLOCK_MONOTONIC`(macOS 的 `mach_absolute_time` 和 Linux 的 `CLOCK_MONOTONIC` 在睡眠期间都不前进 [推]);Windows 用 `QueryUnbiasedInterruptTime`(不含睡眠和休眠时间 [推])。VS Code 的重连宽限期使用 `ProcessTimeRunOnceScheduler`,用意相同 [实 `ptyService.ts:803-811`] | +| 取消 | 任何 `ui` 连接完成 `auth` | +| 到期 | 执行 `host/quit{mode:"revive", reason:"orphanTimeout"}`:保存快照 → 关闭全部会话 → 退出 | +| 例外 | 用户选择 `keepAlive` 退出时(`host/quit{mode:"keepAlive"}`)**不计时**,与 tmux 相同;可以用 `terminal.persistence.keepAliveMaxHours` 设置上限,默认不限制 | +| 无会话 | 没有任何会话、也没有任何连接时,60 s 后退出(避免空 host 常驻) | + +### 1.8 host 崩溃后 Desktop 的行为 + +1. 检测:读到 EOF / `ECONNRESET`,或者 `Process` 退出事件(只有本 Desktop 启动的 host 才有这个事件)。 +2. 所有已 attach 的视图冻结在最后一帧,叠加提示"终端会话已丢失(终端服务意外退出)",按钮为 **在相同目录重新打开**。点击后用最近一次 revive 快照(host 每 30 s 保存一次)新建会话:`session/create{reviveFrom: persistentId}`(§10.5)。 +3. **不自动重启正在运行的程序**(01 §3.3)。host 本身在下一次需要终端时才会重新启动,不会立即拉起。 +4. 防止崩溃循环:5 min 内崩溃 ≥ 3 次后停止自动启动,显示错误和日志路径,提供"重试"按钮。VS Code 的上限是 `MaxRestarts = 5`([实 `ptyHostService.ts:26,164`]),但 VS Code 会立即重启 host,我们是按需启动,所以阈值取得更小。 +5. host 退出时 PTY master 被关闭,内核会向各会话的前台进程组发送 SIGHUP(Unix),Windows 上 ConPTY 也随之结束。所以 host 崩溃就等于会话丢失,这与 01 一致。 + +### 1.9 日志 + +| 项 | 值 | +|---|---| +| 位置 | `$TCODE_HOME/logs/ptyhost/ptyhost--.log` | +| 实现 | 自写的极简 `ILoggerProvider`:后台线程批量写入文件,AOT 安全,不引入第三方库。host 没有 stdout / stderr(§1.3 已重定向到 `/dev/null`) | +| 轮转 | 单个文件 ≤ 5 MiB,最多保留 10 个文件 | +| 级别 | `--log-level` 或环境变量 `TCODE_PTYHOST_LOG`(`trace\|debug\|info\|warn`),默认 `info` | +| 隐私 | **从不记录终端内容、输入字节、剪贴板内容和环境变量的值。** 只有同时设置 `TCODE_PTYHOST_LOG=trace` 和 `TCODE_PTYHOST_LOG_DATA=1` 时,才记录数据的前 64 字节(以十六进制) | +| 崩溃 | 托管异常:`AppDomain.UnhandledException` 写入日志后退出。原生崩溃(libghostty、libtcodepty)由系统崩溃报告记录:macOS `~/Library/Logs/DiagnosticReports`,Windows WER(可以配置 LocalDumps),Linux 为 core / systemd-coredump。NativeAOT 没有 `createdump` [推] | +| Desktop 侧 | `PtyHostClient` 的日志写入 Desktop 自己的日志;断开原因和 host 退出码都要记录 | + +--- + +## 2. 传输与认证 + +### 2.1 传输 + +| 平台 | 传输 | 服务端 | 客户端 | +|---|---|---|---| +| macOS / Linux | `AF_UNIX` + `SOCK_STREAM` | `Socket.Bind(new UnixDomainSocketEndPoint(path))`,`Listen(32)`。bind 之前先 `umask(0077)`,bind 之后再 `chmod 0600`(双保险) | `Socket.ConnectAsync`,之后包装成 `NetworkStream` | +| Windows | 命名管道,字节模式 | **自己调 `CreateNamedPipeW`**(`LibraryImport`),见下方说明,再用 `new NamedPipeServerStream(PipeDirection.InOut, isAsync: true, isConnected: false, safeHandle)` 包装 | `NamedPipeClientStream(".", name, InOut, Asynchronous)`,连接后校验服务端身份(§2.3) | + +为什么 Windows 上要自己创建管道:.NET 的 `NamedPipeServerStream` 构造管道时**不设置 `PIPE_REJECT_REMOTE_CLIENTS`**;`CurrentUserOnly` 只是给当前用户加一条 FullControl ACL;只有在 `maxNumberOfServerInstances == 1` 时才设置 `FILE_FLAG_FIRST_PIPE_INSTANCE` [实 dotnet/runtime `NamedPipeServerStream.Windows.cs` `Create()`]。我们需要: + +| 参数 | 值 | +|---|---| +| `dwOpenMode` | `PIPE_ACCESS_DUPLEX \| FILE_FLAG_OVERLAPPED`;第一个实例另加 `FILE_FLAG_FIRST_PIPE_INSTANCE`(防止抢注:如果别人先创建了同名管道,我们的创建会失败,host 以退出码 4 退出并记录安全日志) | +| `dwPipeMode` | `PIPE_TYPE_BYTE \| PIPE_READMODE_BYTE \| PIPE_WAIT \| PIPE_REJECT_REMOTE_CLIENTS` | +| 安全描述符 | SDDL `D:P(A;;GA;;;<当前用户 SID>)(A;;GA;;;SY)`,`P` 表示阻止继承。命名管道的默认 DACL 会给 Everyone 和匿名账户读权限,所以必须显式指定 [推,依据 CreateNamedPipe 文档] | +| 实例 | 始终保持一个空闲实例在等待连接:一个实例被连上后,立即创建下一个 | + +### 2.2 路径与长度限制 + +| 项 | 规则 | +|---|---| +| `sun_path` 上限 | macOS 104 字节,Linux 108 字节,都包含结尾的 NUL。.NET 的 `UnixDomainSocketEndPoint` 遇到超长路径会抛异常 [推] | +| 首选路径 | `$TCODE_HOME/run/ptyhost-.sock`。UTF-8 字节数 **< 100** 时使用(与现有 `PeerDirectory.SocketPathFor` 的阈值相同 [实 `src/TCode.AgentHost/Peers/PeerDirectory.cs:101-109`]) | +| 回退(Linux) | `$XDG_RUNTIME_DIR/tcode-/ptyhost-.sock`,其中 ` = hex(SHA-256(TCODE_HOME))[0..12]`。`XDG_RUNTIME_DIR` 是用户私有目录(0700,tmpfs) | +| 回退(macOS) | `$TMPDIR/tcode-/ptyhost-.sock`。`$TMPDIR` 是每个用户独立的 `/var/folders/…/T/`,长度约 50 字节 [推] | +| 最后回退 | `/tmp/tcode--/ptyhost-.sock` | +| 回退目录检查 | 创建时 mode 0700。已存在时用 `lstat` 检查(**不跟随符号链接**):必须是目录、owner 等于自己的 euid、mode 恰好为 0700。任何一项不满足就换一个后缀重试,3 次都失败则以退出码 4 退出。这样可以防止其他用户在 `/tmp` 预先创建同名目录或符号链接 | +| 记录 | 实际使用的 endpoint 写入 json,客户端**不自己推算路径** | +| Windows 管道名 | `\\.\pipe\tcode-ptyhost--`,不会超过 256 字符的限制 | +| 不使用 | Linux 抽象命名空间 socket:它没有文件系统权限,同一网络命名空间内的任何用户都能连接 | + +### 2.3 认证与对端校验 + +三层校验,按顺序: + +| 层 | 服务端(host) | 客户端 | +|---|---|---| +| 1 访问控制 | socket 所在目录为 0700;管道 DACL 只允许当前用户和 SYSTEM | — | +| 2 对端身份 | Linux:`socket.GetRawSocketOption(SOL_SOCKET=1, SO_PEERCRED=17)` 读出 `struct ucred{pid,uid,gid}`,要求 `uid == geteuid()`。macOS:`GetRawSocketOption(SOL_LOCAL=0, LOCAL_PEERCRED=1)` 读出 `xucred`,`LOCAL_PEERPID=2` 读出 pid(用于日志)。Windows:`GetNamedPipeClientProcessId` → `OpenProcessToken` → 用户 SID 必须与自己相同 | Windows:`GetNamedPipeServerProcessId` → 校验服务端进程的用户 SID,防止被抢注的管道冒充 host。Unix 上目录权限已经足够,再加上 HMAC | +| 3 令牌 | HMAC 挑战应答(§2.4)。**令牌本身从不出现在线上**,所以即使客户端连到冒充者,令牌也不会泄露 | 校验 `serverProof` | + +01 §6.4 写的是"连接后先发 32 字节随机令牌"。本文改为挑战应答:在 Windows 管道抢注、`/tmp` 回退目录等边界情况下,直接发送令牌会把它交给冒充者(§19 第 2 条)。 + +### 2.4 握手 + +连接建立后,只允许 `hello` → `auth` 两个请求。任一步失败,或者 **5 s** 内没有完成,host 就关闭连接。认证完成前,帧载荷上限为 16 KiB。 + +```mermaid +sequenceDiagram + participant C as 客户端(Desktop / AgentHost) + participant H as host + C->>H: Request hello{protocol, clientKind, clientVersion, ghosttyBuildId, clientNonce, pid} + H->>H: 生成 serverNonce(32 B)
serverProof = HMAC-SHA256(K, "tcode-ptyhost/1/server" ‖ clientNonce ‖ serverNonce) + H-->>C: Response {protocol, hostVersion, build, ghosttyBuildId, instanceId, pid, serverNonce, serverProof, jobBound, capabilities} + C->>C: 校验 serverProof(FixedTimeEquals)、instanceId、协议主版本 + C->>H: Request auth{clientProof = HMAC-SHA256(K, "tcode-ptyhost/1/client" ‖ serverNonce ‖ clientNonce)} + H->>H: 依次用各个已知令牌(完整令牌 + 已签发的受限令牌)验证 → 确定该连接的 scope + H-->>C: Response {scope: "full" | "agent", sessionLimit, connectionId} +``` + +- `K` 是令牌的原始 32 字节。签发的受限令牌(§13.2)各自也是 32 字节。 +- **M2 调整**:使用受限令牌的客户端在 `hello` 中带上 `tokenId`(`auth/mintToken` 返回的非秘密标识),host 用该令牌计算 `serverProof`,`auth` 时也只验证这一个令牌;不带 `tokenId` 时按完整令牌处理。原方案"host 逐个尝试"无法让持受限令牌的客户端验证 host。未知 `tokenId` 用随机密钥作答(认证必然失败),不暴露哪些 id 存在。 +- **`ghosttyBuildId`** = `ghostty_build_info(VERSION_STRING)` + `"+sha256:"` + 原生库文件 SHA-256 的前 16 位十六进制。libghostty 的 `build_info` 只提供版本、SIMD、Kitty、优化级别等字段,**没有构建哈希** [实 `include/ghostty/vt/build_info.h:53-126`]。所以由 CI 在打包时计算文件哈希,写入 `ghostty-build.txt`,Desktop 和 host 运行时都读这个文件;`--self-test` 会核对文件哈希与它是否一致。 +- 协议协商:主版本必须相同,否则返回 `VersionMismatch`(附带双方版本),客户端转入 §1.5 的流程。次版本取两者中较小的那个,按它启用功能。 +- `ghosttyBuildId` 不同不算错误:`hello` 应答返回双方的值,客户端由此决定 attach 时使用的快照格式(§1.5)。 + +--- + +## 3. 帧格式 + +### 3.1 帧头(12 字节,全部为小端) + +``` +offset size 字段 说明 +0 4 length u32:其后的字节数(= 8 + 载荷长度),≥ 8 +4 1 type u8:帧类型(§3.2) +5 1 flags u8:bit0 IGNORABLE(接收方不认识该类型时可以丢弃);bit1..7 保留,必须为 0 +6 2 reserved u16:必须为 0(以后用于扩展) +8 4 sessionId u32:0 = 连接级;非 0 = host 分配的会话 id +12 n payload +``` + +- 长度上限:控制帧载荷 ≤ **1 MiB**;数据帧(0x10–0x2F)载荷 ≤ **64 KiB + 16**(数据加上类型自己的小头);认证完成前 ≤ 16 KiB。超过上限就以 `ProtocolError` 关闭连接,**不尝试恢复同步**。 +- 选择小端:6 个目标 RID 全部是小端;libghostty 快照格式也是小端 [实 `snapshot.h` "Every integer is unsigned and little-endian"]。 +- 不认识的 `type`:带 `IGNORABLE` 标志的丢弃;否则以 `ProtocolError` 关闭连接。 +- `sessionId`:会话类帧(数据帧、会话事件通知、会话请求)必须填写;JSON 参数里也有 `sessionId` 字段时,两者必须一致,否则返回 `InvalidParams`。读线程只看帧头就能把帧分派到对应会话,不需要解析 JSON。 + +### 3.2 类型码表 + +| 码 | 名称 | 方向 | 优先级 | 载荷 | +|---|---|---|---|---| +| 0x01 | `Request` | 双向 | 控制 | JSON `{"id":u32,"method":"…","params":{…}}` | +| 0x02 | `Response` | 双向 | 控制 | JSON `{"id":u32,"result":…}` 或 `{"id":u32,"error":{"code":int,"message":"…","data":{…}}}` | +| 0x03 | `Notification` | 双向 | 控制 | JSON `{"method":"…","params":{…}}` | +| 0x10 | `Output` | H→C | 数据 | `[u64 seq][原始字节]` | +| 0x11 | `Record` | H→C | 数据 | `[u64 seq][u8 kind][3 B 保留][按 kind 定义的二进制]`(§6.2) | +| 0x12 | `SnapshotChunk` | H→C | 数据 | `[u32 attachId][u8 flags: bit0 LAST][u8 format: 1=ghostty, 2=vt][u16 保留][u64 offset][字节]` | +| 0x20 | `Input` | C→H | 数据 | 原始字节(已编码的按键、粘贴、鼠标报告),单帧 ≤ 64 KiB | +| 0x21 | `Ack` | C→H | 控制 | `[u64 seq]`:该订阅者已处理到的位置(不含 seq 本身) | +| 0x30 | `Ping` | 双向 | 控制 | `[u64 nonce][i64 发送方单调时间(µs)]` | +| 0x31 | `Pong` | 双向 | 控制 | 原样回显 Ping 的载荷 | + +`Ack`、`Ping` 和 `Pong` 是二进制帧,但走控制优先级:它们很小,又直接影响流控和存活判断。 + +### 3.3 JSON 部分 + +- 信封沿用现有 JSON-RPC 的形状(`id`、`method`、`params`、`result`、`error{code,message,data}`),去掉 `"jsonrpc":"2.0"` 字段。错误码复用 `JsonRpcConnection` 中的标准值([实 `src/TCode.Rpc.Contracts/JsonRpcConnection.cs:69-75`]),再加上 §4.10 的专用码。 +- **源生成**:`TCode.Terminal.Protocol` 定义 `PtyHostJsonContext`,选项与 `RpcJsonContext` 一致([实 `src/TCode.Rpc.Contracts/RpcJsonContext.cs:7-11`]): + +```csharp +[JsonSourceGenerationOptions( + JsonSerializerDefaults.Web, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + AllowOutOfOrderMetadataProperties = true, + UseStringEnumConverter = true, + MaxDepth = 16)] +[JsonSerializable(typeof(HelloParams))] [JsonSerializable(typeof(HelloResult))] +// … §4 中每个 Params / Result / 事件 DTO 都在这里登记 +public sealed partial class PtyHostJsonContext : JsonSerializerContext; +``` + +- **解析信封时不建 `JsonDocument`**:用 `Utf8JsonReader` 扫一遍,取出 `id`、`method`,记下 `params` / `result` 所在的字节区间(`TokenStartIndex` 加上 `Skip()`),再按方法名找到对应的 `JsonTypeInfo`,用 `JsonSerializer.Deserialize(slice, typeInfo)` 反序列化。 +- 分派器是手写的 `switch (method)`,与 `AgentHostServerDispatcher` 的风格相同,不用反射。 +- 方法名常量放在 `PtyMethods`(类似 `RpcMethods`)。 +- 取消:`$/cancel` 通知 `{id}`,被取消的请求回复 -32800。 +- 字节类字段(`session/input` 的 JSON 形式、剪贴板数据)用 `byte[]`,源生成序列化为 base64。只有低频的地方这样用,高频数据都走二进制帧。 + +### 3.4 发送队列与优先级 + +每个连接有一个**写线程**(01 §5.1),按以下顺序取帧: + +1. 控制队列(`Request`、`Response`、`Notification`、`Ack`、`Ping`、`Pong`),先进先出,全部取完; +2. 数据:采用**拉取**方式。写线程维护一个"有数据可发的订阅"就绪环,轮流处理,每次从一个订阅的日志游标处最多取 64 KiB(受该订阅者的信用额度限制,§7)。每发完一帧,回到第 1 步。 + +由此可以得到: +- 控制消息最多被延迟一个数据帧(≤ 64 KiB,在 UDS 上为微秒级); +- 多个会话之间按轮转分配带宽,一个会话的洪流不会饿死其他会话; +- 没有无界的数据队列:数据只存在于会话日志里(§6),写线程直接从日志发送,不复制给每个订阅者(发送时用 `Socket.Send(IList>)` 把帧头和日志段合并成一次系统调用)。 +- **合并方式**:不设定时器。写线程被唤醒时把当前所有可发的数据一次发出,洪流时自然合并,单次回显则立即发送。VS Code 在 host 侧固定合并 5 ms([实 `common/terminalDataBuffering.ts:28`]),我们不这样做,因为 01 §1 要求输入回显路径上不加任何延迟。 + +客户端侧:`Input` 和控制请求共用一个先进先出队列,保证顺序;`Ack` 插到队首。 + +--- + +## 4. 控制消息 + +约定:C = 客户端(`ui` 指 Desktop,`agent` 指 AgentHost),H = host。"权限"一列中,`full` 表示只有完整令牌可以调用,`agent` 表示受限令牌也可以调用,但只能操作自己的会话或被共享给它的会话(§13.2)。下文的类型名都是 `PtyHostJsonContext` 中的 DTO。 + +### 4.1 连接与 host + +| 方法 | 方向 | 权限 | 参数 | 返回 | 错误 | +|---|---|---|---|---|---| +| `hello` | C→H | 未认证 | `protocol{major,minor}`、`clientKind: ui\|agent\|test`、`clientVersion`、`ghosttyBuildId`、`clientNonce`(byte[32])、`pid` | `protocol`、`hostVersion`、`build`、`ghosttyBuildId`、`instanceId`、`pid`、`serverNonce`、`serverProof`、`jobBound`、`capabilities: string[]`(如 `"revive"`、`"kittyGraphics"`、`"commandRun"`) | `VersionMismatch` | +| `auth` | C→H | 未认证 | `clientProof` | `scope: full\|agent`、`connectionId`、`limits{maxSessions, maxAttach}` | `Unauthenticated`(返回后关闭连接) | +| `host/configure` | C→H | full | `HostSettingsDto`:剪贴板策略 `oscWrite/oscRead: allow\|ask\|deny`、`titleReport`、`notifications{rateLimitPerSec}`、`xtversion`、`confirmIgnoreProcesses[]`、`persistence{enabled, scrollbackLines, onQuit, orphanGraceMinutes, keepAliveMaxHours, saveIntervalSec}`、`outputRingBytes`、`settingsProfiles[]`(来自 settings.json 的 profile 定义)。整份替换,幂等 | `{applied: true}` | `InvalidParams` | +| `host/info` | C→H | full | — | 版本、运行时长、会话数、连接列表(kind、pid、scope)、内存用量、遗留标记 `draining` | — | +| `host/quitCheck` | C→H | full | `{includeAgent?: bool}` | `QuitCheckResult{running: RunningSessionDto[]}`,其中每项为 `{sessionId, persistentId, label, title, kind, foreground{pid,name,commandLine}, atPrompt}`,只列出"有程序在运行"的会话(§9.1) | — | +| `host/quit` | C→H | full | `{mode: revive\|keepAlive\|close, reason: user\|shutdown\|logoff\|update\|orphanTimeout, deadlineMs?: int}` | `{saved: int, closed: int}`;返回前完成保存。`keepAlive` 模式下返回后 host 继续运行 | `HostShuttingDown` | +| `host/sessionAdded` | H→C(通知,发给 full 连接) | — | `SessionInfoDto`(例如 Agent 新建的终端,Desktop 收到后为它开标签) | — | — | +| `host/sessionRemoved` | H→C(通知) | — | `{sessionId, persistentId, reason: closed\|killed\|draining}` | — | — | +| `host/goodbye` | H→C(通知) | — | `{reason: quit\|orphanTimeout\|signal\|draining}`,host 退出前发出,客户端据此区分"正常退出"和"崩溃" | — | — | +| `auth/mintToken` | C→H | full | `{scope: "agent", label, ttlSeconds?}` | `{token: byte[32], tokenId}` | `Forbidden` | +| `auth/revoke` | C→H | full | `{tokenId}`;同时断开所有使用该令牌的连接 | — | — | +| `$/cancel` | 双向(通知) | 任意 | `{id}` | — | — | + +### 4.2 Profile + +| 方法 | 方向 | 权限 | 参数 | 返回 | 说明 | +|---|---|---|---|---|---| +| `profiles/list` | C→H | agent | `{refresh?: bool}` | `ProfileDto[]`:`id, name, command, args, loginShell, kind, icon, source: detected\|settings, isDefault, available, unavailableReason?` | 探测由 host 中的 `TCode.Terminal.Shell` 完成:真正启动进程的是 host,而 Desktop 不引用 Shell 项目(01 §4)。探测结果缓存 60 s,`refresh` 强制重新探测 | + +### 4.3 会话生命周期 + +**`session/create`**(C→H,权限 agent;agent 创建的会话 owner 为 `agent:`) + +| 参数 | 类型 | 说明 | +|---|---|---| +| `profileId` | string? | 为空时使用默认 profile | +| `launch` | `{command, args[]}`? | 覆盖 profile 的启动命令(Agent 进程模式、ssh profile 的变体);与 `profileId` 至少填一个 | +| `cwd` | string? | 必须是存在的目录,否则返回 `CwdNotFound`(附带回退建议:项目根目录、home) | +| `env` | `Dictionary`? | 覆盖项;值为 null 表示删除。合并规则见 07:host 自身的启动环境(去掉 `TCODE_PTYHOST_*` 等内部变量)→ profile → 这里的覆盖项 → TERM 等固定项 | +| `size` | `{cols, rows, cellWidth, cellHeight}` | 初始尺寸,cell 像素可以为 0 | +| `widthMode` | `compat\|grapheme` | D9:默认 `compat`,程序开启 2027 时在流内切换 | +| `palette` | `PaletteDto` | 初始默认颜色(fg、bg、cursor、256 色),镜像核心按它应答 OSC 4/10/11 | +| `kind` | `user\|agent` | agent 会话在 UI 上显示 Agent 标记(01 §9) | +| `agentSessionId` | string? | `kind = agent` 时必填,用于 09 的权限关联 | +| `label` | string? | 用户给标签起的名字 | +| `tags` | `Dictionary`? | host 不解释的元数据(工作区、项目路径、窗口 id),revive 时原样保存 | +| `shellIntegration` | `auto\|off` | D7 | +| `persistentId` | string? | 为空时 host 新生成一个;revive 时沿用旧值,保持标签与恢复数据的对应关系 | +| `reviveFrom` | string? | 用某个 revive 条目的内容预先填充镜像核心(§10.5) | +| `reviveBanner` | string? | 恢复内容之后插入的分隔文字(Desktop 负责本地化,例如"── 已恢复 10/16 23:10 的内容 · 新会话 ──") | +| `attach` | `AttachParams`? | 创建后立即 attach,结果放在返回值里,省去一次往返,也避免漏掉 shell 最初的输出 | +| `persist` | bool? | 默认 `kind == user`。agent 会话默认不保存 revive 数据 | + +返回 `SessionCreatedDto`:`sessionId (u32)`、`persistentId`、`pid`、`launch{file, args, argv0, login}`(实际执行的命令,用于启动失败时的说明)、`shellIntegration{status: injected\|disabled\|unsupported\|failed, reason?}`、`revived{fromSavedAt, lines, usedFallback}?`、`attach?: AttachResultDto`。 + +错误:`ProfileNotFound`、`CwdNotFound`、`SpawnFailed{stage: resolve\|spawn\|exec, errno?, message}`、`ResourceLimit`、`HostDraining`、`ReviveNotFound`。 + +> 启动后约 250 ms 内就退出的情况(05 §8.3)不在 `create` 中报错:`create` 返回成功,随后以 `session/exited{early: true}` 报告,UI 显示命令行与错误,并提供"关闭 shell 集成后重试"。 + +**其他会话方法**: + +| 方法 | 方向 | 权限 | 参数 | 返回 | 错误 / 说明 | +|---|---|---|---|---|---| +| `session/list` | C→H | agent(只能看到自己有权限的会话) | `{}` | `SessionInfoDto[]`:`sessionId, persistentId, kind, owner, agentSessionId?, label, title, cwd, pid, state: running\|exited, exit?, size, createdAt, lastOutputAt, subscribers{ui,agent}, sizeOwner?, tags, revived?` | **引导子集**(§4.9),字段只增不减 | +| `session/attach` | C→H | agent | `AttachParams`:`sessionId, mode: view\|agent, snapshot: ghostty\|vt\|none, resumeFrom?: u64, claimSize?: bool, historyLines?: int`(vt 格式时最多附带多少行回滚) | `AttachResultDto`(§8.2):`attachId, snapshotSeq, snapshotFormat, snapshotLength, coreOptions, metadata, pendingEvents[], isSizeOwner` | `SessionNotFound`、`AlreadyAttached`(同一连接重复 attach 同一会话)、`Forbidden`、`SnapshotFailed` | +| `session/detach` | C→H | agent | `{sessionId}` | — | `NotAttached`。连接断开时,该连接的所有订阅自动 detach | +| `session/input` | C→H | agent(必须有写权限) | `{sessionId, text?: string, bytes?: byte[]}` | — | 供 Agent 和测试使用的 JSON 形式;UI 一律使用 `Input` 帧。`SessionExited`、`InputOverflow` | +| `session/resize` | C→H | ui 订阅者 | `{sessionId, cols, rows, cellWidth, cellHeight}` | `{applied: bool, size, owner}` | 不是尺寸所有者时返回 `applied: false`(不算错误,§9.2) | +| `session/focus` | C→H | ui 订阅者 | `{sessionId}` | `{isSizeOwner: true}` | 声明本视图为**主视图**:成为尺寸所有者,并接收剪贴板读取请求(§11) | +| `session/setColors` | C→H | ui 订阅者(full) | `{sessionId, palette: PaletteDto}` | `{seq}`:`SetColors` 记录的位置 | 01 §3.2 规则 5 | +| `session/reset` | C→H | ui(full) | `{sessionId, hard: bool}` | `{seq}` | 在日志中追加 `Reset` 记录 | +| `session/clear` | C→H | ui(full) | `{sessionId, scrollback: bool, screen: bool, redrawPrompt: bool}` | `{seq}` | ⌘K 清屏(D6)。追加 `Clear` 记录;`redrawPrompt` 为真、且 shell 集成表明光标在提示符处时,再向 PTY 写一个 `\x0c` 让 shell 重画提示符 | +| `session/signal` | C→H | agent(必须有写权限) | `{sessionId, signal: INT\|TERM\|HUP\|KILL\|QUIT\|TSTP\|CONT, target: foreground\|shell}` | `{delivered: bool}` | Unix:`killpg(tcgetpgrp(master))` 或 `killpg(shellPgid)`;Windows 只支持 INT(向 ConPTY 写 `\x03`)和 KILL(`TerminateJobObject` 终止该会话的 Job,与 03 一致),其余返回 `delivered: false` | +| `session/kill` | C→H | agent(必须有写权限) | `{sessionId}` | `{exit}` | 立即执行关闭序列(02 §2.4:SIGHUP → 等 2 s → SIGKILL)。**会话保留**,状态变为 exited,以便 UI 显示退出信息 | +| `session/close` | C→H | agent(必须是 owner),ui(full) | `{sessionId, force: bool}` | `{closed: bool, confirm?: RunningSessionDto}` | `force = false` 且有程序在运行时,**不关闭**,返回 `closed: false` 和确认信息。UI 弹窗确认后再以 `force: true` 调用。关闭后删除该会话的 revive 条目(用户明确关闭的会话不恢复),并广播 `host/sessionRemoved` | +| `session/foreground` | C→H | agent | `{sessionId}` | `ForegroundDto{running: bool, atPrompt: bool?, shellPid, foreground{pid, name, commandLine?}?, reason: idle\|atPrompt\|foregroundProcess\|childProcess\|unknown}` | 判定规则见 §9.1 | +| `session/share` | C→H | full | `{sessionId, agentSessionId, access: none\|read\|write}` | — | 用户把自己的终端共享给某个 Agent(01 §8,09 §5) | +| `session/setLabel` | C→H | full | `{sessionId, label?}` | — | 保存到 meta,供 revive 使用 | +| `session/read` | C→H | agent(必须有读权限) | `{sessionId, range: screen\|scrollback\|all\|command, commandId?, format: plain\|vt\|html, maxBytes, fromLine?}` | `{text, truncated, lines}` | 镜像核心的 formatter(01 §3.1);细节见 09 | + +### 4.4 会话事件(H→C,`Notification`,帧头带 sessionId) + +所有事件都带 **`seq`**:事件发生时日志的位置。客户端应当在自己的核心处理到 `seq` 之后再呈现事件,这样"退出"提示一定出现在最后一段输出之后,`commandFinished` 也一定在命令输出之后。 + +| 事件 | 参数 | 发送给 | 说明 | +|---|---|---|---| +| `session/exited` | `seq, exitCode?, signal?, coreDumped, early: bool, message?` | 所有订阅者 | 退出码未知时两者都为 null(02 §2.5 ECHILD)。发送前先把 PTY 中剩余的输出读完并追加到日志 | +| `session/title` | `seq, title, source: osc\|process` | 所有订阅者 | 有长度上限(4 KiB,10 §6.7)。标题回报默认关闭 | +| `session/cwd` | `seq, uri, path?, verified: bool` | 所有订阅者 | OSC 7 / OSC 1337 CurrentDir。来自不可信输入:只有 host 名是本机且目录存在时,`verified` 才为 true | +| `session/bell` | `seq` | 所有 ui 订阅者 | 同一会话 100 ms 内只发一次 | +| `session/notification` | `seq, title?, body, source: osc9\|osc777\|osc99` | 所有 ui 订阅者 | 每个会话限速(默认 1 条/s,超出部分丢弃并计数);title ≤ 256 字节,body ≤ 4 KiB | +| `session/progress` | `seq, state: none\|normal\|error\|indeterminate\|paused, percent?` | 所有 ui 订阅者 | OSC 9;4 | +| `session/commandStarted` | `seq, commandId, commandLine?, cwd?` | 所有订阅者 | OSC 133 C(以及可选的 633 E 命令行)。`commandId` 由 host 分配,在会话内单调递增 | +| `session/commandFinished` | `seq, commandId, exitCode?, durationMs` | 所有订阅者 | OSC 133 D | +| `session/shellIntegration` | `status, reason?` | 所有 ui 订阅者 | D7:注入失败的原因 | +| `session/foregroundChanged` | `running, name?` | 所有 ui 订阅者 | 标签上的"忙"标记;最多 1 次/s;只在有 ui 订阅者时才轮询 | +| `session/clipboardWrite` | `seq, target: clipboard\|primary, data: byte[]`(UTF-8,≤ 8 MiB) | **主视图**所在连接(§11) | OSC 52 写入。策略为 `deny` 时 host 不发送 | +| `session/resync` | `reason: lagging\|ringOverflow` | 被降级的订阅者 | 客户端应当 detach 后重新 attach(§7.4) | +| `session/inputOverflow` | `droppedBytes` | 发送该输入的连接 | 程序不读 stdin、积压超过上限(§7.5) | + +**host 向客户端发的请求**(H→C `Request`): + +| 方法 | 参数 | 返回 | 说明 | +|---|---|---|---| +| `ui/clipboardRead` | `{sessionId, target, programHint?}` | `{allow: bool, data?: byte[]}` | OSC 52 读取。只发给主视图所在的连接,超时 60 s 视为拒绝(§11) | + +### 4.5 Agent 命令接口(只定义接口,语义见 09) + +| 方法 | 方向 | 权限 | 参数 | 返回 | +|---|---|---|---|---| +| `command/run` | C→H | agent | `{sessionId?, create?: SessionCreateParams, commandLine, strategy: auto\|shellIntegration\|idle\|process, timeoutMs?, outputLimitBytes?}` | `{commandId, sessionId, strategy}`;错误:`ShellIntegrationUnavailable`、`CommandBusy`(会话中已有命令在运行) | +| `command/output` | C→H | agent | `{sessionId, commandId, cursor?: string, maxBytes, format: plain\|vt}` | `{text, cursor, truncated, done}`。`cursor` 是不透明的续读位置;截断方式与 ACP `terminal/output` 一致,从头部截断,并且在字符边界上截断 | +| `command/wait` | C→H | agent | `{sessionId, commandId, timeoutMs}` | `{exitCode?, signal?, timedOut, durationMs}` | + +数据来源是镜像核心的 OSC 133 命令记录和 formatter(01 §9),**不订阅原始字节**,因此不参与流控(§7.3)。 + +### 4.6 持久化 + +| 方法 | 方向 | 权限 | 参数 | 返回 | 说明 | +|---|---|---|---|---|---| +| `persistence/list` | C→H | full | `{}` | `ReviveEntryDto[]`:`persistentId, savedAt, reason, label, title, cwd, profileId, kind, lines, compatible: bool, tags` | `compatible` 表示 `snapshot.bin` 可以被当前的 libghostty 解码;为 false 时会用 `tail.vt` | +| `persistence/save` | C→H | full | `{sessionIds?: u32[], reason: periodic\|quit\|shutdown\|manual}` | `{saved: int, failed: [{sessionId, error}]}` | 立即保存。**引导子集** | +| `persistence/delete` | C→H | full | `{persistentIds: string[]}` | `{deleted: int}` | Desktop 恢复完成后,清理 state.json 中已经不再引用的条目 | +| `persistence/clear` | C→H | full | `{}` | — | 用户关闭持久化,或者选择"清除终端历史"时调用 | + +### 4.7 方法与 01 的对照 + +01 §6.4 列出的要点在本文中的位置:传输 → §2;帧 → §3;优先级 → §3.4;流控 → §7;重连 → §8。 + +### 4.8 并发与顺序 + +- 同一连接上的请求,在 host 侧**按会话串行**处理:所有会话请求都投递到会话 actor 的命令队列,按到达顺序执行。不同会话之间并行。 +- 连接级请求(`host/*`、`profiles/list`)在线程池上执行。 +- `Input` 帧不经过 actor:读线程直接放进会话的写入通道(§5.2),这样输入路径少一次线程切换。 + +### 4.9 引导子集(跨主版本冻结) + +以下内容在任何主版本中都**不改变**:帧头格式、类型码 0x01–0x03、`hello`(请求和应答中的 `protocol`、`instanceId`、`pid`、`serverNonce`、`serverProof` 字段)、`auth`、`session/list`(`sessionId`、`persistentId`、`label`、`title`、`cwd`、`state` 字段)、`host/quitCheck`、`persistence/save`、`host/quit`。主版本不同的 Desktop 用它们来排空遗留 host(§1.5)。 + +### 4.10 错误码 + +| 码 | 名称 | 含义 | +|---|---|---| +| -32700 / -32600 / -32601 / -32602 / -32603 / -32800 | 标准 | 与 `JsonRpcConnection` 相同 | +| -33001 | `Unauthenticated` | 认证失败或尚未认证 | +| -33002 | `Forbidden` | 令牌的 scope 不允许,或无权访问该会话 | +| -33003 | `VersionMismatch` | 协议主版本不同;`data: {host, client}` | +| -33004 | `ProtocolError` | 帧不合法(发送后关闭连接) | +| -33010 | `SessionNotFound` | | +| -33011 | `SessionExited` | 对已退出的会话执行 input / resize / signal | +| -33012 | `AlreadyAttached` | | +| -33013 | `NotAttached` | | +| -33020 | `SpawnFailed` | `data: {stage, errno, message, command}` | +| -33021 | `ProfileNotFound` | | +| -33022 | `CwdNotFound` | `data: {fallback}` | +| -33030 | `SnapshotFailed` | libghostty 返回错误;`data: {ghosttyResult}` | +| -33040 | `PersistenceDisabled` | | +| -33041 | `ReviveNotFound` | | +| -33050 | `ShellIntegrationUnavailable` | | +| -33051 | `CommandBusy` | | +| -33060 | `HostShuttingDown` | | +| -33061 | `HostDraining` | 遗留 host 拒绝创建会话 | +| -33062 | `ResourceLimit` | `data: {limit}`;默认会话上限 256、连接上限 32、每个连接的未完成请求上限 256 | +| -33063 | `InputOverflow` | | + +--- + +## 5. host 内部结构 + +### 5.1 组件 + +``` +PtyHostServer + ├ Listener(socket / pipe)→ 每个连接一个 Connection(读线程 + 写线程) + ├ Auth(令牌表、对端校验) + ├ SessionManager(u32 → Session;persistentId → Session) + │ └ Session + │ ├ ITerminalConnection(TCode.Terminal.Pty) + │ ├ SessionActor(专用 Thread):镜像核心 TerminalCore、OutputLog、CommandTracker、FlowGate + │ ├ WriterChannel(Input + 查询应答 → 写线程) + │ └ Subscribers:List(连接、模式、游标、acked、状态) + ├ ExitWatcher(全进程一个,01 §5.1) + ├ PersistenceService(定时器 + 写盘工作线程) + └ HostLifetime(宽限期、quit、信号) +``` + +### 5.2 会话 actor 的命令 + +actor 是镜像核心**唯一**的使用者(01 §5.1)。命令队列为 `Channel`(无界,但命令来源只有控制请求,数量有界)。actor 循环优先处理命令,没有命令时处理输出块: + +| 命令 | 来源 | 动作 | +|---|---|---| +| `OutputChunk(buf)` | 读线程 | `core.Write` → 效果回调(应答排进 WriterChannel;标题、cwd 等事件排进事件队列)→ `log.AppendOutput` → 把缓冲还给池 → 唤醒订阅者 → `FlowGate.Evaluate` | +| `Resize(size)` | `session/resize` | `connection.ResizeAsync`(已退出的会话跳过)→ `core.Resize` → `log.AppendRecord(Resize)` | +| `SetColors(p)` / `Reset(hard)` / `Clear(flags)` | 对应请求 | 修改核心 → 追加记录 | +| `Attach(params, conn)` | `session/attach` | §8.2 | +| `Detach(sub)` | 请求或连接断开 | 移除订阅者 → `FlowGate.Evaluate` | +| `Snapshot(purpose)` | 持久化 | 编码到池化缓冲 → 交给 PersistenceService | +| `Read(req)` / `CommandQuery` | Agent | formatter / 命令记录 | +| `Exited(exit)` | ExitWatcher | 读完剩余输出 → 状态改为 exited → 事件 | +| `Compress` | 空闲 5 s | `core.CompressIdle(budget: 2 ms)` | + +镜像核心的创建选项:`GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES` 必须**非零**。只有在输入写入之前就开启了"续接跟踪",快照才能在解析器处于序列中间时编码,否则返回 `GHOSTTY_INVALID_VALUE` [实 `snapshot.h` `ghostty_snapshot_encode` 注释]。上限取 1 MiB [推]。 + +### 5.3 写入通道 + +`WriterChannel` 是一个有界的字节通道,上限 8 MiB(§7.5)。来源有两个:IPC 读线程送来的 `Input` 帧,以及 actor 产生的查询应答。两者在通道中交错,**不保证**相对顺序(真实终端也不保证)。写线程按 02 的规则写 PTY(EAGAIN 时 poll `POLLOUT`,大段数据按 4 KiB 分块)。 + +--- + +## 6. 输出日志 + +### 6.1 序号(位置)定义 + +- 每个会话有一个 `u64` 位置计数器 `head`,从 0 开始。 +- `Output` 记录:位置 = 第一个字节的位置;追加 n 个字节后 `head += n`。 +- `Resize`、`SetColors`、`Reset`、`Clear` 记录:各占 **1** 个位置。这样每条记录的位置都是唯一的,"快照位于 S"的含义也没有歧义:**所有位置 < S 的记录都已经包含在快照中,位置 ≥ S 的都没有**。 +- `Ack(seq)`:该订阅者已经处理完所有位置 < seq 的记录。 +- 事件的 `seq`:事件产生时的 `head`(产生该事件的 Output 记录处理完之后)。 +- 序号只在一个 host 的生命周期内有效。revive 出来的新会话从 0 重新开始。 + +### 6.2 记录类型 + +| kind | 名称 | 二进制载荷(接在 `[u64 seq][u8 kind][3 B 保留]` 之后) | 镜像核心 / UI 核心的动作 | +|---|---|---|---| +| — | `Output` | 单独的帧类型 0x10,载荷为原始字节 | `Write(bytes)` | +| 1 | `Resize` | `u16 cols, u16 rows, u16 cellWidth, u16 cellHeight` | `Resize(size)` | +| 2 | `SetColors` | `u32 mask`(bit0 fg、bit1 bg、bit2 cursor、bit3 palette、bit4 "全部恢复默认")+ `fg[4] bg[4] cursor[4]`(RGBX)+ `palette[256][4]` | `SetDefaultColors(p)` | +| 3 | `Reset` | `u8 hard` | `Reset()` | +| 4 | `Clear` | `u8 flags`(bit0 screen、bit1 scrollback) | 清屏和 / 或清回滚(01 §3.2 只列出了 Output / Resize / Reset / SetColors,Clear 是本文新增的,见 §19) | + +### 6.3 环形保留 + +- 存储:由 64 KiB 段组成的环(段来自 `ArrayPool.Shared`),记录可以跨段。另有一个小索引记录每一段起始处的位置。 +- 保留下限:`min(活跃订阅者中最小的 acked)`。还有未确认数据的段不能回收。 +- 保留上限:`terminal.ptyHost.outputRingBytes`,默认 **1 MiB**。在上限之内,即使所有订阅者都已确认,也保留最近的数据,用于:① `resumeFrom` 快速续传(§8.4);② Agent 读取原始尾部内容(09 可以选择使用)。 +- 活跃订阅者被流控暂停时,日志不会无限增长:读 PTY 已经暂停了(§7.2)。只有被降级的订阅者(§7.4)的游标会落到环外,这种订阅者已经转为 resync,不再阻止回收。 +- 内存估算:每个会话 1 MiB 环 + 镜像核心(回滚压缩后)。256 个会话时环的上限是 256 MiB,所以环按需增长,空闲 60 s 后缩回 64 KiB [定]。 + +### 6.4 订阅者游标 + +```csharp +sealed class Subscription +{ + public Connection Connection; + public SubscriptionMode Mode; // View(接收 Output)| Agent(只接收事件) + public ulong SendCursor; // 下一个要发送的位置 + public ulong Acked; // 已确认的位置 + public SubscriptionState State; // Snapshotting | Live | Lagging | Resync + public long LaggingSinceTicks; +} +``` + +--- + +## 7. 流控 + +### 7.1 取值 + +| 参数 | TCode | VS Code | 依据 | +|---|---|---|---| +| 高水位(暂停) | **256 KiB**(`head − acked`) | `HighWatermarkChars = 100000` | [实 VS Code `common/terminal.ts:886`]。VS Code 计数的是 UTF-16 字符;我们计数的是字节(CJK 字符 1 个对应 3 字节),而且一帧最多 64 KiB,至少要能容纳 4 帧在途才不会断流。libghostty 的解析速度约 500 MB/s(01 §1),UI 侧核心即使只有 100 MB/s,256 KiB 也只需约 2.5 ms 就能处理完 [推] | +| 低水位(恢复) | **64 KiB** | `LowWatermarkChars = 5000` | [实 `:896`]。必须 ≥ Ack 粒度,否则可能永远无法恢复(VS Code 注释:"This must be less than or equal to LowWatermarkChars or the terminal max never unpause" [实 `:899`]) | +| Ack 粒度 | **32 KiB** | `CharCountAckSize = 5000` | [实 `:901`]。取低水位的一半,留出余量。按 500 MB/s 计约 1.6 万次 Ack/s,每次 Ack 是 20 字节的帧,开销可以忽略 [推] | +| 读缓冲池 | 4 × 64 KiB | node-pty 的 `pause()` | 01 §5.1 | + +**Ctrl+C 的延迟上限**:用户在洪流中按下 Ctrl+C 后,UI 最多还要处理"高水位 256 KiB + 读池 256 KiB + 内核 PTY 缓冲"的数据才能看到提示符。按 100 MB/s 计约 5 ms [推]。这个数字作为调参基线,由 11 号文档的基准测试验证。 + +### 7.2 规则 + +1. 写线程只在 `SendCursor < head` 且 `SendCursor − Acked < 256 KiB` 时发送该订阅的数据。 +2. actor 每追加一块数据后计算 `max(head − Acked)`(只算 `Live` 状态的 View 订阅者)。超过 256 KiB 时,`FlowGate` 关闭:actor 不再从 `ITerminalConnection.Output` 取数据,读线程用完 4 块缓冲后阻塞,内核 PTY 缓冲填满后,子进程的 `write` 阻塞。这就是背压。 +3. 收到 `Ack`(读线程处理,无锁更新 `Acked`)后,如果门是关闭的,并且所有 `Live` View 订阅者都满足 `head − Acked < 64 KiB`,就向 actor 发 `Resume`。 +4. 客户端:视图核心 actor 每处理完 32 KiB(累计)就发一次 `Ack(当前位置)`。另外,snapshot 处理完后立即发一次 Ack。 +5. 快照字节(`SnapshotChunk`)不计入 backlog:它们在 Output 之前发送,由写线程的轮转调度控制节奏。 + +### 7.3 没有 UI 订阅者时 + +- 只受镜像核心的解析速度限制,`FlowGate` 始终打开(01 §5.1)。 +- Agent 订阅者(`mode: agent`)不接收 Output,**不参与流控**(01 §6.4)。 +- 环形日志只保留最近的 1 MiB。 + +### 7.4 慢订阅者 + +01 §6.4 写的是"慢订阅者会拖住整个会话(保证不丢数据)"。本文保留这条原则,但加上时间上限,避免出问题的客户端让 shell 永久卡住 [定]: + +| 情况 | 处理 | +|---|---| +| 短时间慢(< 10 s) | 正常背压,等待 | +| 某个 View 订阅者的 backlog 连续 **10 s** 都 ≥ 高水位,但它所在的连接仍然有心跳 | 订阅者降为 `Lagging`:不再参与 `FlowGate` 的计算,会话恢复读取;host 发 `session/resync{reason: lagging}`,然后停止向它发送 Output | +| 客户端收到 `resync` | 丢弃该会话的 UI 侧核心,`session/detach`,再 `session/attach`(使用新快照)。屏幕内容由镜像核心保证完整,所以**状态不会丢失**,只是被跳过的那段原始字节不会再逐帧播放 | +| 连接无心跳 30 s | 断开连接(§1.6) | + +### 7.5 输入方向 + +- `WriterChannel` 上限 8 MiB。程序长时间不读 stdin(例如被 `SIGTSTP` 停住)时,超出部分丢弃,并向发送方发 `session/inputOverflow`,UI 提示"程序没有读取输入,粘贴内容被截断"。 +- 不阻塞 IPC 读线程,否则一个会话的输入积压会卡住同一连接上的所有会话(06 §7.3 指出的队头阻塞问题)。 + +--- + +## 8. 重连与快照 + +### 8.1 快照格式(libghostty) + +[实 Ghostty `include/ghostty/vt/snapshot.h`]: + +- 格式为 10 字节的信封 `"GHOSTSNP" + u16 version`,后面是若干条各自带 CRC32C 的记录,顺序为 `TERMINAL → SCREEN/PAGE… → CONTINUATION → READY → HISTORY/PAGE…(从新到旧)→ FINISH`。 +- `ghostty_snapshot_decoder_ready()` 读到 READY 就返回一个可以渲染的终端;之后每调用一次 `next()` 就把一页更早的回滚**插到前面**。 +- 编码是同步的,编码期间调用方必须保证核心不被修改。actor 模型正好满足这一点。 +- "Snapshot format version 1 is a work in progress and does not yet carry a binary-compatibility guarantee" → 影响跨版本 revive(§10.2)。 + +所以"先发 READY 段再增量发历史"不需要 host 自己分段:快照字节流天然就是 READY 在前。host 只要按顺序分块发送,客户端解码器读到 READY 就可以开始渲染。 + +### 8.2 attach 时序 + +```mermaid +sequenceDiagram + participant UI as Desktop(视图核心 actor) + participant R as host 连接读线程 + participant A as 会话 actor + participant W as host 连接写线程 + UI->>R: Request session/attach{mode:view, snapshot:ghostty, claimSize} + R->>A: Attach 命令(排在当前输出块之后) + Note over A: 原子步骤(期间不处理任何输出块)
1. S = log.head
2. ghostty_snapshot_encode → 池化缓冲 B(长度 L)
3. 登记 Subscription{SendCursor=S, Acked=S, State=Snapshotting}
4. 读取元数据(标题、cwd、命令记录、退出状态、待补发事件) + A->>W: 控制队列:Response{attachId, snapshotSeq=S, format, L, coreOptions, metadata} + A->>W: 数据:快照 B(按 64 KiB 分成 SnapshotChunk,最后一块带 LAST) + A->>A: State=Live(Output(seq≥S) 在快照之后进入同一订阅的发送序列) + W-->>UI: Response(控制优先,先到) + UI->>UI: 用 coreOptions 准备解码器 + W-->>UI: SnapshotChunk × k + UI->>UI: decoder_ready() → 得到可渲染的核心 → 立即发布第一帧 + UI->>UI: decoder_next() × n:插入更早的回滚 + W-->>UI: Output(seq=S…)、Record… + UI->>UI: 按顺序 Write / Resize …(处理到 FINISH 之后,见 §18 待验证项) + UI->>R: Ack(S + …) +``` + +要点: + +- **对齐**:快照与 `S` 是在同一次 actor 处理中取得的,所以快照恰好等于"所有位置 < S 的记录都已应用"的状态。之后发给该订阅者的第一条记录的位置就是 S。**不重不漏**。 +- **顺序**:快照块和 Output 属于同一个订阅、同一个数据序列,写线程保证快照块全部发完之后才发 Output。客户端仍应能处理"快照块先于 Response 到达"的情况(按 `attachId` 暂存),这样 host 的实现可以更自由。 +- **attach 结果中的 `coreOptions`**(01 §3.2 规则 2):`cols, rows, cellWidth, cellHeight, scrollbackMaxLines, scrollbackMaxBytes, widthMode, palette, kittyGraphics{enabled, storageLimitBytes}, continuationMaxBytes: 0`(UI 侧核心不需要续接跟踪)。UI 侧核心按这些选项配置解码出来的终端。 +- **元数据**:`title, cwd, pid, state, exit?, shellIntegration, commands{count, lastExitCode}, sizeOwner, isSizeOwner, foreground?, bellPending, pendingNotifications[]`。 +- **快照很大时**:10,000 行 × 200 列的回滚,快照估计在 MB 级 [推]。编码期间会话暂停处理输出,编码耗时列为基准项(§18)。如果编码超过 50 ms,就改为 `historyLines` 限制:只发送最近 N 行的历史(实现方法同 §10.3 的裁剪)。 + +### 8.3 多个客户端同时 attach + +- 每个订阅者有自己的 `S`、自己的快照、自己的游标,彼此独立。 +- Agent(`mode: agent`)attach 时不需要快照(`snapshot: none`),只接收事件;读取内容用 `session/read` / `command/output`。 +- 同一连接对同一会话重复 attach,返回 `AlreadyAttached`。同一个 Desktop 的两个视图要显示同一会话时,由 Desktop 在本进程内复用同一个订阅(§9.3)。 + +### 8.4 `resumeFrom`(快速续传) + +客户端仍然持有 UI 侧核心、而且核心已经处理到位置 P(例如被 `resync` 降级,或者连接短暂断开但 host 还在),attach 时可以带上 `resumeFrom: P`。host 判断 `P ≥ log 中最早保留的位置`,并且 `P ≤ head` 时,跳过快照,直接从 P 开始发送;否则退回完整快照。`resumeFrom` 只在同一个 `instanceId` 内有效。 + +### 8.5 VT 兼容格式(`snapshot: vt`) + +- 用于构建 ID 不同的遗留 host(§1.5),也可以用于调试。 +- 内容:formatter 以 VT 格式输出的"回滚尾部(`historyLines`,默认 10,000)+ 屏幕",后面跟上恢复光标位置、模式(DECSET)、SGR、标题的序列。 +- 精度低于二进制快照:解析到一半的序列、Kitty 图片、部分模式无法还原 [推]。 + +--- + +## 9. resize 与一致性 + +### 9.1 "有程序在运行"的判定(供 `session/foreground`、`session/close`、`host/quitCheck` 使用) + +按 05 §8.1 的建议: + +| 平台 | 规则 | +|---|---| +| macOS / Linux | `tcgetpgrp(master)` 不等于 shell 的 pgid,并且前台进程名不在忽略列表中(忽略列表由 `host/configure` 下发)→ running。有 shell 集成、而且最近一个 OSC 133 状态是"在提示符处"时 → `atPrompt`,视为空闲(Ghostty 的做法) | +| Windows | Toolhelp32 取 shell 的子进程树,排除忽略列表中的进程,剩下的不为空 → running;同样参考 OSC 133 | +| 已退出 | `running: false` | + +### 9.2 尺寸所有者 + +规则 [定]:**每个会话在任一时刻最多有一个尺寸所有者**,它是一个 View 订阅者。 + +| 事件 | 所有者变化 | +|---|---| +| 第一个 View 订阅者 attach | 成为所有者 | +| `session/attach{claimSize: true}` 或 `session/focus` | 调用者成为所有者("最近获得焦点者"规则,与 tmux 的 `window-size latest` 相同 [推]) | +| 所有者 detach | 所有权交给最近一次 focus 的其余 View 订阅者;没有的话,所有者为空,尺寸保持不变 | +| 只有 Agent 订阅者 | 尺寸保持 `create` 时的值;Agent **不能** resize | + +**resize 时序**: + +```mermaid +sequenceDiagram + participant V as 视图(UI 线程) + participant VA as 视图核心 actor + participant H as host(会话 actor) + participant P as PTY + V->>V: 布局变化 → 计算 cols×rows(同一帧内只发最后一次) + V->>H: Request session/resize{cols, rows, cellW, cellH} + alt 调用者是尺寸所有者 + H->>P: TIOCSWINSZ / ResizePseudoConsole + H->>H: 镜像核心 Resize · 追加 Record(Resize) @ seq=k + H-->>V: Response {applied:true, size} + H-->>VA: Record(Resize, seq=k)(数据序列中按顺序) + VA->>VA: 核心 Resize(在 seq=k 处)· 发布新帧 + P-->>H: 程序收到 SIGWINCH 后重绘 → Output(seq>k) + else 不是所有者 + H-->>V: Response {applied:false, size: 当前尺寸, owner} + end +``` + +- 在收到 Record 之前,视图按旧尺寸显示,多出来的区域用背景色填充(01 §3.2 规则 1:约 1 ms)。**UI 侧核心绝不能抢先 resize**。 +- 客户端节流:拖动窗口时,每个渲染帧最多发一次 resize,不加额外的定时器。host 不做合并(Ghostty 合并 25 ms 的做法留给 UI 侧,是否需要以测试结果为准)。 +- 会话已退出时,仍然执行镜像核心的 resize 并追加记录(用于显示退出后的内容),但跳过 PTY 调用。 + +### 9.3 多个视图显示同一会话 + +| 场景 | v1 | +|---|---| +| 同一窗口中分屏,两个视图显示同一会话 | **v1 不提供**:分屏总是新建会话(与 01 §7 中"`AddTool(Terminal)` 每次都新建一个"一致)。协议层已经支持,以后开放时,Desktop 内部的两个视图共用一个订阅和一个 UI 侧核心,各自只是视口不同 | +| 多个窗口(以后)或多个 Desktop 进程显示同一会话 | 协议支持:每个连接一个订阅。尺寸按"最近获得焦点者"决定;非所有者的视图按会话尺寸渲染,超出部分裁剪、不足部分留白,并在角落提示"尺寸由另一个视图控制" | + +--- + +## 10. 持久化(revive) + +### 10.1 位置与权限 + +``` +$TCODE_HOME/terminal/revive/ 0700 + / 0700 + meta.json 0600 + snapshot.bin 0600(libghostty 快照,已裁剪) + tail.vt 0600(formatter 输出的 VT 文本,供跨版本恢复) +``` + +- 05 §8.4 建议放在 `state.json` 旁边的单独目录中,这里与它一致。 +- Windows:目录继承用户配置目录的 ACL(用户本人 + SYSTEM + Administrators),不另外设置。 +- 原子写入:先写到 `*.tmp`(0600),`fsync` 后 `rename`;三个文件都写完后,最后写 `meta.json`。没有 `meta.json` 的目录视为不完整,启动时清理。 + +### 10.2 `meta.json` + +```jsonc +{ + "formatVersion": 1, + "persistentId": "01JAB3…", + "savedAt": "2026-10-16T15:10:02Z", + "reason": "periodic", // periodic | quit | shutdown | orphanTimeout | manual + "hostVersion": "0.9.0", + "ghosttyBuildId": "1.3.1+sha256:6b0e…", + "snapshotFormatVersion": 1, // 快照信封中的 u16 + "snapshotSha256": "…", + "profileId": "zsh", + "launch": { "command": "/bin/zsh", "args": ["-l"], "login": true }, + "initialCwd": "/Users/u/proj", + "cwd": "/Users/u/proj/src", // 最近一次通过校验的 OSC 7,或从 /proc 等读到的 cwd + "label": null, "title": "vim main.c", + "kind": "user", + "size": { "cols": 120, "rows": 34, "cellWidth": 9, "cellHeight": 18 }, + "widthMode": "compat", + "lines": 1000, + "exit": null, + "hadRunningProgram": true, + "tags": { "workspace": "…" } +} +``` + +### 10.3 行数上限与裁剪 + +- `terminal.persistence.scrollbackLines`,默认 **1000**(01 §3.3),上限 10,000,0 表示只保存屏幕内容。 +- libghostty 的编码函数**没有"只编码尾部 N 行"的选项**([实 `snapshot.h` 中只有 `encode`、`encode_buf`、`encode_alloc`])。裁剪方法 [定,需要验证]: + 1. actor 编码完整快照,交给持久化工作线程(之后不再占用 actor); + 2. 工作线程用 `decoder_ready()` 解码出一个临时终端,然后反复调用 `next()`,直到累计插入的行数 ≥ N 时停止(不必读到 FINISH,ready 返回的终端本身就可以使用); + 3. 对临时终端再编码一次,得到 `snapshot.bin`; + 4. 用同一个临时终端的 formatter 生成 `tail.vt`。 +- 如果第 2 步中途停止解码的终端不能再编码(§18 待验证),就改为:用 `SCROLLBACK_MAX_LINES = N` 创建临时终端,再把完整快照解码进去。 + +### 10.4 保存时机 + +| 时机 | 行为 | +|---|---| +| 定时 | 每 **30 s**(`saveIntervalSec`),只保存自上次保存后 `head` 有变化的会话("增量"的含义是只处理有变化的会话,每份快照本身是完整的)。各会话错开执行,每轮最多占用 actor 总时间 20 ms,超出的部分顺延到下一轮 | +| 退避 | 某个会话单次编码超过 50 ms 时,它的保存间隔改为 120 s | +| `host/quit{revive\|keepAlive}` | 对所有 `persist` 的会话同步保存,总期限 `deadlineMs`(默认 2000);超时的会话保留上一次的保存结果 | +| SIGTERM / 注销 | 同上,期限 1500 ms | +| 关闭会话 | 删除该会话的条目 | +| `persist = false`(Agent 会话的默认值)或持久化被关闭 | 不保存 | + +### 10.5 启动恢复流程 + +```mermaid +sequenceDiagram + participant D as Desktop + participant H as host(当前构建) + D->>D: 读 state.json:终端标签 → persistentId 列表 + D->>H: 发现 / 启动(§1.3–1.4)→ hello/auth → host/configure + D->>H: session/list + H-->>D: 仍在运行的会话(keepAlive,或者上次是 Desktop 崩溃) + loop 每个仍在运行、且 persistentId 与某个标签对应的会话 + D->>H: session/attach{claimSize} + end + D->>D: 检查遗留 host(其他 build 的 json)→ §1.5 + D->>H: persistence/list + loop 每个没有对应运行会话、但有 revive 条目的标签 + D->>H: session/create{reviveFrom: id, persistentId: id, cwd: meta.cwd(已校验), profileId, size: 当前视图尺寸, reviveBanner, attach} + H->>H: 新建镜像核心 ← snapshot.bin(ghosttyBuildId 或格式版本不兼容、解码失败时改用 tail.vt;也失败则留空) + H->>H: Resize 到请求的尺寸;追加 Output:换行把恢复的内容推入回滚 + 分隔文字 + 清屏并把光标移到左上角 + H->>H: 启动新 shell + H-->>D: SessionCreated{revived{…}, attach{快照…}} + end + D->>H: persistence/delete(state.json 中已不再引用的条目) +``` + +- **为什么要把恢复的内容推入回滚,并从干净的屏幕开始**:Windows 上 ConPTY 启动时假定屏幕是空的、光标在左上角。如果恢复的内容占着屏幕,ConPTY 的重绘会覆盖它。VS Code 在 Windows 上遇到过 conpty 的 inherit cursor 选项导致 conhost 挂起的问题([实 `ptyService.ts:869-876`,microsoft/terminal#11213])。统一推入回滚,三个平台的行为一致。 +- 恢复内容和分隔文字是 host 自己追加的 Output 记录,同样经过日志,所以 01 §3.2 规则 1 仍然成立。 +- 恢复后的 cwd:先用 `meta.cwd`(目录必须存在),否则用 `initialCwd`,再否则用 home。 +- VS Code 恢复 100 行,并显示 "History restored"([实 `ptyService.ts:265,291`]);我们默认 1000 行(05 §8.4)。 + +### 10.6 `terminal.persistence.onQuit` + +| 模式 | Desktop 的动作 | host 的动作 | 下次启动 | +|---|---|---|---| +| **`revive`(默认)** | 确认(§12)→ `host/quit{mode:"revive"}` | 保存所有可持久化的会话 → 关闭序列(SIGHUP → 2 s → SIGKILL)→ `host/goodbye` → 退出 | 按 §10.5 恢复内容,启动新 shell | +| `keepAlive` | 确认(提示"这些程序会在后台继续运行")→ `host/quit{mode:"keepAlive"}` → 断开 | 也保存一次(防止之后重启或崩溃);不计宽限期(§1.7);会话继续运行 | `session/list` 后直接 attach(与 tmux 类似) | +| `close` | 确认 → `host/quit{mode:"close"}` | 不保存,删除所有 revive 条目 → 关闭全部会话 → 退出 | 只恢复标签布局(以及 profile 和 cwd,取决于 07 的设定),不恢复内容 | + +`jobBound: true`(§1.3)时,`keepAlive` 不可用,按 `revive` 处理。 + +--- + +## 11. 剪贴板、通知与铃声 + +host 没有 UI。这些效果由镜像核心产生(01 §6.1 的 `ITerminalEffects`),UI 侧核心的同类回调一律**忽略**,否则会触发两次。 + +### 11.1 主视图 + +- 每个会话的**主视图**(primary UI)= 最近一次调用 `session/focus` 的 View 订阅者所在的连接;没有的话,取最近一次 attach 的 View 订阅者。 +- 需要用户回应的请求(OSC 52 读取)只发给主视图。单向通知(bell、notification、progress)发给所有 UI 连接,由 Desktop 去重(多窗口时只在拥有该标签的窗口显示)。 + +### 11.2 OSC 52 + +| 动作 | 有 UI | 没有 UI(无 View 订阅者) | +|---|---|---| +| 写入(`oscWrite`) | `allow`:发 `session/clipboardWrite` 给主视图,Desktop 写入剪贴板并显示一个短暂提示(10 §6.1);`ask`:Desktop 弹确认;`deny`:host 不发送。单次上限 8 MiB;数据不是合法 base64 时**忽略**(10 §6.1 的建议) | **丢弃**并记录计数。不缓存起来稍后写入,因为延后写剪贴板会让用户困惑 | +| 读取(`oscRead`,默认 `ask`) | 镜像核心的 `ClipboardRead` 返回"待定"→ host 向主视图发 `ui/clipboardRead`(H→C Request)→ Desktop 按策略处理(`ask` 时弹窗并显示请求来源)→ 应答 `{allow, data}` → host 在 actor 上编码 `OSC 52 ; c ; ST`,放进 WriterChannel | **拒绝** | +| 拒绝或超时(60 s) | 不应答(与 Ghostty 一致 [推];10 号文档最终决定) | — | +| DA1 中的 `52` | 只有 `oscWrite ≠ deny` 时才在 DA1 应答中带上(10 §2);由 `host/configure` 下发到镜像核心 | 同左 | + +### 11.3 通知、铃声与进度 + +| 效果 | 有 UI | 没有 UI | +|---|---|---| +| bell | 事件 → Desktop 按 `bell.*` 设置(声音、视觉、窗口注意) | 设置 `bellPending`,attach 时放在元数据里 → 标签显示角标 | +| notification(OSC 9 / 777 / 99) | 事件 → 窗口未聚焦时显示系统通知,否则在应用内提示 | 保存最近 **20** 条到 `pendingNotifications`(在 attach 结果中补发,Desktop 可以合并成一条"离开期间 3 条通知") | +| progress | 事件 → 标签进度条 / Dock、任务栏进度 | 只保留最新状态,attach 时补发 | +| title / cwd | 事件 | 保存最新值,attach 元数据中带上 | + +--- + +## 12. 关闭与退出 TCode + +### 12.1 关闭标签 + +1. `session/close{force:false}` +2. 返回 `closed:false` 时,Desktop 按 `terminal.confirmClose`(默认 `whenRunning`)弹窗,内容为前台程序名和命令行。 +3. 用户确认后,`session/close{force:true}`。host 执行关闭序列:Unix 为 02 §2.4 的 `killpg(SIGHUP)` → 关闭 master → 等 2 s → `killpg(SIGKILL)`;Windows 为 02 §3.5 的做法,在**单独线程**上调用 `ClosePseudoConsole`,同时继续读输出管道,避免在旧系统上死锁。 + +### 12.2 退出 TCode + +```mermaid +sequenceDiagram + participant U as 用户 + participant D as Desktop + participant H as host + U->>D: ⌘Q / 关闭最后一个窗口 + D->>H: host/quitCheck{} + H-->>D: running: [{label, foreground: "npm run dev"}, …] + alt 有程序在运行,且 onQuit ≠ keepAlive 或者 confirmClose = always + D->>U: 汇总确认一次(iTerm2 PromptOnQuit 的做法,05 §8.1) + U-->>D: 确认 / 取消 + end + D->>H: host/quit{mode: onQuit, reason: user} + H-->>D: {saved, closed} + H-->>D: host/goodbye(revive / close 模式) + D->>D: 写 state.json(标签 → persistentId)→ 退出 +``` + +### 12.3 系统关机与注销 + +| 平台 | Desktop 收到的信号 | host 自身的处理 | +|---|---|---| +| macOS | `applicationShouldTerminate` 中能区分关机 / 注销(与正常退出流程相同,但**不弹确认**),调用 `host/quit{mode: keepAlive 时改为 revive, reason: shutdown}` | launchd 向用户的所有进程发 SIGTERM,超时后发 SIGKILL [推]。host 用 `PosixSignalRegistration(SIGTERM)` 捕获,在 1.5 s 内保存后退出 | +| Linux | SIGTERM(会话管理器 / systemd 停止用户会话);如果能连上 logind,也可以监听 `PrepareForShutdown` | 同上:SIGTERM / SIGHUP → 保存 → 退出。systemd 的默认停止超时为 90 s [推] | +| Windows | `WM_QUERYENDSESSION` / `WM_ENDSESSION`(Avalonia 窗口) | host 没有控制台(`DETACHED_PROCESS`),也没有窗口,收不到 `CTRL_SHUTDOWN_EVENT`。所以 host 在一个专用线程上创建一个**隐藏的顶层窗口**(message-only 窗口收不到这类广播),处理 `WM_ENDSESSION` 并在其中同步保存 [推,需验证] | +| 都失败时 | — | 最多丢失 30 s 内的内容(定时保存是兜底) | + +关机时 keepAlive 没有意义(进程总会被杀),所以统一按 revive 处理。 + +--- + +## 13. 客户端 API + +### 13.1 Desktop:`PtyHostClient`(`TCode.Terminal.Protocol`,只依赖 BCL) + +```csharp +namespace TCode.Terminal.Protocol; + +/// 发现或启动 host(只有 Desktop 使用)。 +public static class PtyHostLocator +{ + /// 先找当前构建的 host;找不到就启动(§1.3),等待 READY,然后连接并完成握手。 + public static ValueTask ConnectOrStartAsync(PtyHostLaunchOptions options, CancellationToken ct); + + /// 列出其他构建(遗留)的 host(§1.5)。 + public static IReadOnlyList FindLegacyHosts(string home, string currentBuild); +} + +public sealed record PtyHostLaunchOptions( + string Home, string HostDirectory, string BuildId, string GhosttyBuildId, + string ClientVersion, TimeSpan ReadyTimeout /* 20 s */); + +public sealed record PtyHostEndpoint(string Address, ReadOnlyMemory Token, string InstanceId, string Build); + +public sealed class PtyHostClient : IAsyncDisposable +{ + public static ValueTask ConnectAsync(PtyHostEndpoint endpoint, PtyHostClientOptions options, CancellationToken ct); + + public HelloResult Host { get; } // 版本、构建、jobBound、capabilities + public string Scope { get; } // "full" | "agent" + public bool IsResponsive { get; } // 心跳状态(§1.6) + public Task Completion { get; } // 断开原因:Goodbye(reason) | Lost(exception) + public event Action? ResponsivenessChanged; + public event Action? SessionAdded; // host/sessionAdded + public event Action? SessionRemoved; + + // host + public ValueTask ConfigureAsync(HostSettingsDto settings, CancellationToken ct = default); + public ValueTask QuitCheckAsync(CancellationToken ct = default); + public ValueTask QuitAsync(QuitParams p, CancellationToken ct = default); + public ValueTask MintAgentTokenAsync(string label, CancellationToken ct = default); + + // profile 与会话 + public ValueTask> ListProfilesAsync(bool refresh = false, CancellationToken ct = default); + public ValueTask CreateSessionAsync(SessionCreateParams p, IPtySessionSink? sinkForAttach, CancellationToken ct = default); + public ValueTask> ListSessionsAsync(CancellationToken ct = default); + public ValueTask AttachAsync(AttachParams p, IPtySessionSink sink, CancellationToken ct = default); + public ValueTask GetForegroundAsync(uint sessionId, CancellationToken ct = default); + public ValueTask CloseSessionAsync(uint sessionId, bool force, CancellationToken ct = default); + public ValueTask ShareAsync(uint sessionId, string agentSessionId, ShareAccess access, CancellationToken ct = default); + + // 持久化 + public ValueTask> ListReviveAsync(CancellationToken ct = default); + public ValueTask DeleteReviveAsync(IReadOnlyList persistentIds, CancellationToken ct = default); + + // host → UI 请求的处理器(不设置时默认拒绝) + public Func>? ClipboardReadHandler { get; set; } +} + +/// 一个已 attach 的订阅。所有方法线程安全;SendInput 从不阻塞(写入发送队列)。 +public sealed class PtySessionHandle : IAsyncDisposable +{ + public uint SessionId { get; } + public AttachResultDto Attach { get; } + public void SendInput(ReadOnlySpan bytes); // 按 64 KiB 切分成 Input 帧 + public void Ack(ulong position); // 由视图核心 actor 调用(§7.2 第 4 条) + public ValueTask ResizeAsync(TerminalSizeDto size, CancellationToken ct = default); + public ValueTask FocusAsync(CancellationToken ct = default); + public ValueTask SetColorsAsync(PaletteDto palette, CancellationToken ct = default); + public ValueTask ClearAsync(ClearParams p, CancellationToken ct = default); + public ValueTask SignalAsync(PtySignal signal, SignalTarget target, CancellationToken ct = default); + public ValueTask KillAsync(CancellationToken ct = default); + public ValueTask DetachAsync(CancellationToken ct = default); +} + +/// 在 IPC 读线程上调用:不能阻塞,不能回调 PtyHostClient 的同步方法;数据需要复制后再转交给视图核心 actor。 +public interface IPtySessionSink +{ + void OnSnapshotChunk(ReadOnlySpan chunk, ulong offset, bool last); + void OnOutput(ulong position, ReadOnlySpan data); + void OnRecord(ulong position, in LogRecord record); // Resize / SetColors / Reset / Clear + void OnEvent(SessionEvent e); // §4.4 中的各种事件(带 seq) + void OnResync(string reason); + void OnDetached(DetachReason reason); // 连接断开、会话关闭、被踢出 +} +``` + +- Desktop 中的 `PtyTerminalViewSession : ITerminalViewSession`(01 §6.5)用 `PtySessionHandle` 实现:`Start(sink)` 发起 attach,`SendInput`、`RequestResize`、`Ack` 直接转发给 handle。 +- 线程:每个连接一个读线程(同步 `Socket.Receive` 读入池化缓冲)和一个写线程(01 §5.2)。控制请求的 continuation 不在读线程上执行(`TaskCreationOptions.RunContinuationsAsynchronously`)。 +- 实现**不复用** `JsonRpcConnection`:它使用 Content-Length 分帧和无界 `Channel`,每帧都会 `JsonDocument.Parse` + `Clone()`([实 06 §7.2;`JsonRpcConnection.cs`])。新实现只沿用它的信封形状、错误码和"手写分派器 + 源生成"的风格。 + +### 13.2 AgentHost:获取连接参数 + +| 步骤 | 说明 | +|---|---| +| 1 签发 | Desktop 启动 AgentHost 之前,如果 host 已经在运行,调用 `auth/mintToken{scope:"agent"}` 拿到受限令牌 | +| 2 环境变量 | Desktop 在 `AgentHostConnection.StartProcess` 的 `ProcessStartInfo.Environment` 中设置:`TCODE_PTYHOST_ENDPOINT`(socket 路径或管道名)、`TCODE_PTYHOST_TOKEN`(base64url)、`TCODE_PTYHOST_INSTANCE`(instanceId,用于核对)、`TCODE_PTYHOST_PROTOCOL`(`1.0`) | +| 3 延迟获取 | AgentHost 启动时 host 还没运行(通常如此,因为 host 是按需启动的),或者连接认证失败(host 已经重启,令牌失效)时,AgentHost 通过现有的 JSON-RPC 向 UI 发请求 `terminal/ptyHostEndpoint`(新增 Host→UI 方法,在 `RpcMethods` 中登记)。Desktop 收到后按需启动 host,签发新令牌,返回相同的四个字段 | +| 4 连接 | AgentHost 的 `TerminalBroker` 调用 `PtyHostClient.ConnectAsync(endpoint)`,`clientKind = agent` | +| 5 环境清理 | **AgentHost 必须从它启动的所有子进程**(BashTool、外部 Agent CLI、MCP server)**的环境中删除 `TCODE_PTYHOST_*`**;host 构造 shell 环境时也删除这些变量(07)。否则终端里运行的任何程序都能拿到令牌 | +| 6 吊销 | AgentHost 退出或重启时,Desktop 调用 `auth/revoke` 吊销旧令牌 | + +--- + +## 14. 安全 + +### 14.1 威胁模型与对策 + +| 威胁 | 场景 | 对策 | 残余风险 | +|---|---|---|---| +| **同机的其他用户** | 连接 socket 或管道,读写别人的终端 | ① socket 所在目录 0700,socket 文件 0600;管道 DACL 只允许当前用户和 SYSTEM,并设置 `PIPE_REJECT_REMOTE_CLIENTS`(§2.1)② 对端 uid / SID 校验(§2.3)③ HMAC 令牌 ④ `/tmp` 回退目录做 `lstat` 和 owner 检查 | 管理员 / root 不在防护范围内 | +| 其他用户抢注 | 抢先创建同名管道或目录,冒充 host 骗取输入 | `FILE_FLAG_FIRST_PIPE_INSTANCE`(host 创建失败时报错);客户端校验服务端 SID 和 `serverProof`;令牌不上线 | — | +| **同一用户的其他进程** | 读取 `ptyhost-*.json` 中的令牌,控制所有终端 | 这**不是**安全边界(tmux、VS Code、iTerm2 都是如此)。做的事情:令牌不进入终端和子进程的环境变量(§13.2 第 5 条);Linux 上可选 `prctl(PR_SET_DUMPABLE, 0)`,阻止同用户进程读取 `/proc//environ` 和内存(代价是没有 core dump,默认关闭) | 恶意程序以同一用户身份运行时,可以完全控制终端 | +| **Agent 越权** | Agent 操作用户的终端,读取敏感输出,或者调用 `host/quit` | ① 受限令牌:只能访问 owner 为自己的会话,以及经 `session/share` 明确共享的会话;不能调用 `host/*`、`persistence/*`、`session/setColors` 等方法(§4 的权限列)② 在终端中执行命令之前,必须先经过 AgentHost 的审批(09)③ 建议 AgentHost 的文件工具把 `$TCODE_HOME/run/` 和 `$TCODE_HOME/terminal/revive/` 加入拒绝列表,并且读取它们**总是**需要审批(否则 Agent 可以通过 bash 读出完整令牌) | Agent 的 bash 工具以用户身份运行,理论上仍然可以读取令牌文件。所以受限令牌主要用来防止 Agent 侧逻辑出错,不能防住恶意的 Agent | +| 恶意程序输出 | OSC 52 读取剪贴板、通知轰炸、伪造 cwd | §11 的策略;通知限速、长度上限;OSC 7 只作为提示使用(`verified`);标题回报关闭 | — | +| 有缺陷或恶意的客户端 | 超长帧、深层嵌套的 JSON、海量请求 | 帧长度上限(§3.1)、`MaxDepth = 16`、未完成请求上限、会话和连接数上限、认证超时 5 s | — | +| 持久化泄露 | revive 文件中有敏感输出 | 0700 / 0600;可以关闭;行数上限;用户关闭会话时删除;`close` 模式全部删除;`persistence/clear` | 磁盘未加密时,有物理接触的人可以读取 | +| 日志泄露 | 日志中出现输入或输出内容 | 默认不记录(§1.9) | — | + +--- + +## 15. AOT 注意事项 + +- **Unix 上 host 进程内不使用 `System.Diagnostics.Process` 和 `System.Console`**:使用它们会让 .NET 安装 SIGCHLD 处理器并改动终端设置,与我们自己的子进程回收冲突([03](03-pty.md))。需要执行外部命令时(例如 `ssh -G`、`vswhere` 之外的探测)用 libtcodepty 的管道模式启动接口 `tcpty_spawn_pipe`([03](03-pty.md) §2.3)。**Windows 上没有 SIGCHLD 问题**,探测类外部命令(`wsl.exe -l -q`、vswhere)可以直接用 `Process`,但 `Console` 仍然不用。 +- **启动时补齐 fd 0–2**:`--detach` 之后,如果 0/1/2 中有未打开的,用 `/dev/null` 补上,防止后来打开的 PTY master 恰好占用 0–2 号 fd。 + +| 项 | 做法 | +|---|---| +| JSON | 只用 `PtyHostJsonContext`;信封用 `Utf8JsonReader` 扫描;不用 `JsonNode` 的泛型 `Add`(docs/architecture.md §5.1) | +| socket | `Socket` + `UnixDomainSocketEndPoint`、`GetRawSocketOption`(读对端凭据,不需要 P/Invoke) | +| 命名管道 | `CreateNamedPipeW`、`GetNamedPipeClientProcessId`、`GetNamedPipeServerProcessId`、`OpenProcessToken`、`GetTokenInformation`、`ConvertStringSecurityDescriptorToSecurityDescriptorW` 都用 `LibraryImport` 声明,项目启用 `DisableRuntimeMarshalling`,只使用 blittable 结构体,字符串手动转成 `char*` | +| 进程启动 | Unix 用 `Process.Start` 执行 helper;Windows 的 `CreateProcessW` + `STARTUPINFOEXW` + `InitializeProcThreadAttributeList`、`UpdateProcThreadAttribute`、`IsProcessInJob`、`QueryInformationJobObject` 都用 `LibraryImport` | +| 信号 | `PosixSignalRegistration.Create(PosixSignal.SIGTERM / SIGHUP)`;`setsid` 在 helper 中执行,不在托管代码里调用 | +| 加密 | `HMACSHA256.HashData`、`RandomNumberGenerator.Fill`、`CryptographicOperations.FixedTimeEquals`、`SHA256.HashData` | +| 锁文件 | `FileStream(FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)` | +| 时钟 | `Stopwatch.GetTimestamp`;Windows 的宽限期计时调用 `QueryUnbiasedInterruptTime`(`LibraryImport`) | +| 验收 | `TCode.Terminal.Protocol` 和 `TCode.Terminal.Host` 都设置 `IsAotCompatible=true`;host AOT 发布 0 个 IL2xxx / IL3xxx 警告;CI 中运行 AOT 二进制的 `--self-test`,并用 AOT host 跑 `TCode.Terminal.Host.Tests` 中的端到端测试(与 `TCODE_TEST_HOST_EXE` 的做法相同) | + +--- + +## 16. 测试计划 + +测试项目为 `tests/TCode.Terminal.Host.Tests`。除平台专属的用例外,每个用例都在三个平台上运行。端到端用例同时用 JIT 和 AOT 的 host 各跑一次。 + +| 类别 | 用例 | +|---|---| +| 帧编解码 | 帧头的编码 / 解码往返;`length < 8`、超过上限、保留位非零、未知类型(带和不带 IGNORABLE);半帧(数据逐字节到达);单次读取中包含多帧 | +| JSON | 每个 DTO 序列化往返(源生成);未知字段被忽略;`MaxDepth` 超限;帧头与参数中的 sessionId 不一致 | +| 握手 | 正确流程;错误的 clientProof / serverProof;超时 5 s;认证前发其他方法;主版本不同;受限令牌越权调用每个 full 方法时都返回 `Forbidden` | +| 对端校验 | Linux / macOS 读取对端凭据;Windows 用另一个本地账户连接被拒绝(CI 中需要建测试账户,可以手动执行);`/tmp` 回退目录被他人预先创建或是符号链接 | +| 发现 | 陈旧的 json(kill -9 host 后,锁可以拿到);pid 被复用(伪造 json 指向一个存活的无关 pid);两个 Desktop 同时启动时只产生一个 host;socket 路径超过 100 字节时回退 | +| 流控 | 回放工具产生 100 MB 输出 + 一个慢 sink:backlog 永远不超过 256 KiB 加一帧;暂停和恢复的阈值;关闭门后子进程 `write` 被阻塞(用 `yes` 测试);没有 UI 订阅者时全速运行;Agent 订阅者不影响流控;慢订阅者 10 s 后收到 `resync`,会话恢复读取 | +| 序号对齐 | 在洪流中的任意时刻 attach:UI 侧核心(快照 + 后续记录)与镜像核心的网格逐 cell 一致(差分);穿插 resize、setColors、clear;`resumeFrom` 在环内和环外两种情况 | +| 多客户端 | 两个 View 订阅者 + 一个 Agent 订阅者同时连接:内容一致;尺寸所有者的切换;非所有者的 resize 返回 `applied:false`;主视图收到 `ui/clipboardRead`,其他视图收不到 | +| 断线重连 | 关闭客户端 socket 后重新连接 → attach → 一致;在 attach 过程中(快照还没发完)断开 | +| Desktop 被杀 | 测试进程启动 host → 建 3 个会话(其中一个正在运行 `top` 类程序)→ 杀掉客户端进程(SIGKILL / TerminateProcess)→ 新客户端发现 host 并 attach → 网格一致,程序仍在运行;宽限期计时开始和取消(用可注入的时钟) | +| host 崩溃 | 向 host 发 SIGKILL → 客户端在 `Completion` 中得到 `Lost` → 用最近的 revive 条目 `create{reviveFrom}` 得到恢复的内容;5 min 内崩溃 3 次后停止自动启动 | +| 升级并存 | 用两个不同 `--build` 的 host(同一份二进制,用测试开关伪造 ghosttyBuildId 和协议版本):同主版本的遗留 host 可以用 VT 格式 attach,`create` 返回 `HostDraining`;主版本不同时只有引导子集可用,`persistence/save` + `host/quit` 后在新 host 中恢复 | +| 持久化 | 30 s 定时保存(注入时钟)只写有变化的会话;文件权限为 0600 / 0700;行数裁剪(5000 行裁到 1000 行);`snapshot.bin` 损坏时回退到 `tail.vt`;`close` 模式删除所有条目;SIGTERM 时 1.5 s 内保存完成 | +| 退出 | `quitCheck` 的判定(shell 空闲、`sleep 100`、vim、tmux、忽略列表中的进程、OSC 133 在提示符处);三种 `onQuit` 模式;Windows 关闭时不死锁(02 §3.5) | +| 心跳 | 客户端暂停(SIGSTOP)35 s → host 断开该连接;host 暂停 → 客户端在 11 s 后报告无响应,恢复后自动恢复正常 | +| 安全回归 | shell 环境中没有 `TCODE_PTYHOST_*`;日志中不出现输入的字节(grep 一个哨兵字符串) | +| 性能(11 号文档) | IPC 吞吐(目标是 UDS 上 ≥ 1 GB/s,参照 06 §7.3 匿名管道上原始帧 64 KiB 时 2.87 GB/s 的实测);回显延迟 p99(UDS 往返 < 100 µs [推]);10k 行回滚的快照编码耗时;256 个空闲会话的内存占用 | + +--- + +## 17. 实施顺序(M2 的拆分) + +1. `TCode.Terminal.Protocol`:帧编解码、DTO 和 `PtyHostJsonContext`、`PtyHostClient`(握手和控制请求)、单元测试。 +2. `TCode.Terminal.Host`:监听、认证、`SessionManager`、actor、输出日志、流控;先用回放连接测试,再接真实 PTY。 +3. attach 快照、`resumeFrom`、多客户端。 +4. 发现与启动(`tcode-pty-spawn --detach`、Windows `CreateProcessW`)、心跳、宽限期、日志。 +5. 持久化与 revive、`onQuit`、关机处理。 +6. 受限令牌、AgentHost 环境变量、`command/*` 接口(实现由 09 负责)。 +7. 升级并存(引导子集、VT 兼容格式)。 + +验收(01 §10 M2):Desktop 被杀后重启,标签和内容完全恢复;AOT 发布 0 个警告,并且运行过 AOT 二进制。 + +--- + +## 18. 风险与待验证项 + +| # | 项 | 类型 | 验证方法 | +|---|---|---|---| +| 1 | 快照编码耗时(10k / 100k 行回滚)以及编码期间会话停顿多久 | 性能 | BenchmarkDotNet + libghostty;目标是 10k 行 < 20 ms | +| 2 | 客户端在 `decoder_ready()` 之后、`next()` 还没执行完时,能否 `Write` 新的输出(决定是否必须等到 FINISH 才应用 Output) | API | 阅读 `example/c-vt-snapshot` 并做实验 | +| 3 | 在 `ready()` 后只执行了部分 `next()` 的终端能否再次编码(§10.3 的裁剪) | API | 实验;不行就采用备选方案 | +| 4 | libghostty 快照格式 v1 **没有二进制兼容保证** → 跨版本 revive 依赖 `tail.vt` 回退 | 已知 | [实 `snapshot.h`] | +| 5 | Windows:Desktop 处于禁止 breakaway 的 Job 中的比例;隐藏窗口能否收到 `WM_ENDSESSION`;`QueryUnbiasedInterruptTime` 在 Modern Standby 下的行为 | 平台 | 实机测试 | +| 6 | Linux:桌面环境把应用放在 `app-*.scope` 中,Desktop 退出时 host 是否会被 systemd 一起停止;`KillUserProcesses=yes` 时注销会杀死 keepAlive 会话;Flatpak / Snap 不在 v1 范围内 | 平台 | GNOME、KDE 实测 [推] | +| 7 | macOS:脱离 Desktop 的 host 启动的 shell,在 TCC(访问"文稿"等目录时的授权提示)中归属哪个应用;Desktop 退出后是否还归属 TCode.app | 平台 | 实测 | +| 8 | VT 兼容 attach 时两个核心的偏差 | 一致性 | 差分测试:同一字节流分别用两个 libghostty 版本处理 | +| 9 | host 中一个镜像核心崩溃(原生代码)会带走所有会话 | 架构 | fuzz(03 §9);以后可以考虑每个会话一个进程,代价很高,v1 不做 | +| 10 | 流控参数(256 / 64 / 32 KiB)在 UI 侧核心真实解析速度下是否合适:Ctrl+C 延迟、洪流时的帧率 | 性能 | 11 号文档的基准 | +| 11 | .NET 在 Unix 上的 `FileShare.None` 是否确实使用 `flock`,以及 NFS 上的家目录(`TCODE_HOME` 在 NFS 上时锁的语义不可靠) | 平台 | 读 dotnet/runtime 源码并实测;NFS 上把锁和 socket 放到 `XDG_RUNTIME_DIR` | +| 12 | ~~宽限期到期会杀掉正在运行的程序~~ | 产品 | 已决定(2026-10-17):有前台程序在运行的会话保留,直到程序结束或达到 `keepAliveMaxHours` | + +--- + +## 18.1 后续文档审阅中采纳的补充(2026-10-17) + +以下各项来自 07、09、12 的审阅,已经采纳。实现时以本节为准,前文的对应章节按此理解。 + +| # | 补充 | 来源 | +|---|---|---| +| 1 | `host/configure` 增加 `baseEnvironment`:Desktop 启动时的原始环境。新会话的环境基于最近一次由 **Desktop(full 连接)** 发送的这一份;由 agent 连接创建的会话同样使用它 | 07 §2、09 | +| 2 | `session/create` 和 `session/attach` 的应答增加 `integration: rich \| basic \| none`(shell 集成等级) | 09 | +| 3 | `command/wait` 的结果增加 `state`:`finished`、`waitingForInput`、`alternateScreen`、`timeout`;`command/output` 增加 `evicted`(输出已被裁出日志窗口);新增通知 `command/inputNeeded` 和 `session/userInput`(用户在 Agent 终端里输入时通知 Agent);新增 `command/interrupt`(按 ^C → SIGTERM → SIGKILL 逐级升级) | 09 | +| 4 | Agent 会话按 **`agentSessionId`** 判断归属,不按连接令牌判断:AgentHost 重启后令牌会变,按令牌判断会让旧终端变成无主会话 | 09 | +| 5 | 新增 `session/setPersist`:运行中修改 `persist`(用于"转为普通终端") | 09 | +| 6 | `ghostty-build.txt` 放到 macOS 应用包的 `Contents/Resources/`,不放在 `Contents/MacOS/`(避免影响签名和公证) | 12 | +| 7 | SFTP 连接池(08 第二期)放在 host 中,届时增加 `remote/*` 消息、主机密钥确认和口令请求;v1 不实现 | 07、08 | +| 8 | `Clear` 记录在 C API 中没有对应函数,由向两个核心注入 `CSI H CSI 2 J CSI 3 J` 实现;host **只在镜像核心的解析器处于 ground 状态时**追加 `Clear`(否则推迟到下一个数据块边界),保证注入的序列不会插进一个未完成的序列中 | 02 | +| 9 | 回滚上限的修改只对新会话生效(日志中没有"修改回滚上限"记录) | 02 | +| 10 | 快照编码超时的退路(§8.2"超过 50 ms 就裁剪历史")无法缩短 actor 的占用时间,因为裁剪发生在完整编码之后。改为:按回滚大小预估编码耗时,预估超过阈值时直接走"先释放增量解码器、再编码裁剪后的终端"的路径(02 spike S16) | 02 | +| 11 | OSC 52 读取依赖 overlay O3(回调中返回"待定"、以后补发应答)。O3 打不上时,退回到在回调内同步等待 UI 应答(有超时,超时则拒绝),期间该会话的 actor 阻塞 | 02 §17 | +| 12 | 快照不包含 Kitty 图像:重新连接后,已经显示的图像会丢失(新图像不受影响)。v1 接受这一限制,并在重连横幅中说明 | 02 | + +## 19. 与 01-architecture.md 的差异与待决问题 + +| # | 01 的原文 | 本文的处理 | 是否需要修改 01 | +|---|---|---|---| +| 1 | §6.4:"`$TCODE_HOME/run/ptyhost-.sock`";§3.3:"每个用户、每个 `TCODE_HOME` 只有一个 host" | 按**构建**区分:`ptyhost-.{json,lock,sock}`,用于升级时并存(§1.5);路径超长时回退到其他目录 | 是 | +| 2 | §6.4:"连接后先发 32 字节随机令牌" | 改为 HMAC 双向挑战应答,令牌不上线(§2.4) | 是 | +| 3 | §6.4:"慢订阅者会拖住整个会话(保证不丢数据)" | 拖住的时间上限为 10 s,之后降级并 resync;数据由镜像核心保证不丢 | 是 | +| 4 | §3.2 规则 1:改变状态的操作只有 PTY 输出、resize、reset 三种(规则 5 又加了 SetColors) | 增加 `Clear` 记录(D6 的 ⌘K),以及 host 自己追加的 Output(revive 的恢复内容和分隔文字) | 是 | +| 5 | §3.2 规则 3:两侧必须是同一个 libghostty 构建 | 升级后 keepAlive 的会话不满足这一条 → 遗留 host 改用 VT 兼容格式 attach,接受可能的显示偏差 | 是,需要补充说明 | +| 6 | §3.3:"无客户端连接的宽限期默认 30 分钟,超时后按'退出 TCode'处理" | keepAlive 退出后不计时;异常断开时,宽限期到期后**没有**前台程序的会话按退出处理,**有**前台程序在运行的会话继续保留,直到程序结束或达到 `keepAliveMaxHours`(2026-10-17 用户决定) | 01 已更新 | +| 7 | §3.3:revive 快照(尾部 N 行) | libghostty 没有尾部编码选项,快照格式 v1 也不保证兼容 → 需要裁剪流程(§10.3)和 `tail.vt` 回退 | 是,需要补充说明 | +| 8 | §6.4:"握手时校验 ghostty build_info 的哈希" | `build_info` 中没有哈希 → `ghosttyBuildId` = 版本字符串 + 库文件的 SHA-256 | 是 | +| 9 | 配置键:01 使用 `terminal.persistence.onQuit`;research/05 §10 使用 `restore.sessions` / `restore.scrollbackLines` | 本文使用 `terminal.persistence.{onQuit, scrollbackLines, orphanGraceMinutes, keepAliveMaxHours, saveIntervalSec}`,以 07 为准 | 由 07 统一 | +| 10 | §6.1 `Resize(TerminalSize)` 注释为"列、行、cell 像素宽高";06 §6 中的 `TerminalSize` 是**整个区域**的像素 | 协议使用 cell 像素(`TIOCSWINSZ` 的 `ws_xpixel` = cols × cellWidth) | 统一 `TerminalSize` 的定义 | +| 11 | 01 没有说明 Agent 会话是否持久化 | 默认不持久化(`persist = false`) | 由 09 确认 | +| 12 | 01 没有说明 AgentHost 在 host 尚未启动时如何拿到连接参数 | 新增 Host→UI 的 RPC `terminal/ptyHostEndpoint`(§13.2) | 需要在 `RpcMethods` 中登记(属于 09 和集成范围) | diff --git a/docs/terminal/05-rendering.md b/docs/terminal/05-rendering.md new file mode 100644 index 0000000..e13f1d9 --- /dev/null +++ b/docs/terminal/05-rendering.md @@ -0,0 +1,773 @@ +# TCode 终端:渲染(TCode.Terminal.Avalonia) + +> 状态:设计稿 v1(2026-10-16)。上游约束见 [01-architecture.md](01-architecture.md)(D5、§5.2 线程、§6.1 FrameSnapshot、§6.5 TerminalView);依据见 [research/04](research/04-rendering-performance.md)、[research/05](research/05-platform-behavior-config.md) §7、[research/03](research/03-vt-protocol.md) §6。输入侧见 [06-input.md](06-input.md)。 +> +> **来源标注**:**[已核实]** = 在只读源码中读到(Avalonia tag `12.1.3`/`8eeda4f`,libghostty-vt `b3dc3d7`,TCode `dfd3657`,StreamMark `074f7ff`),给出 `文件:行`;**[推断]** = 设计决定或由源码推导,未经运行验证;**[spike]** = 必须先做实验确认的点(§13)。 + +## 0. 结论摘要 + +| 主题 | 决定 | +|---|---| +| 绘制入口 | 一个与宿主无关的 `TerminalPainter`,产出"Skia 批次 + GlyphRun"交替的绘制序列;R3 由 `Control.Render` 录制,R1 由 `CompositionCustomVisualHandler.OnRender` 在渲染线程执行。两者共用 Painter、行缓存、sprite、颜色解析,只换 `ITerminalDrawTarget` | +| 文字 | `GlyphRun`,`GlyphInfo.GlyphAdvance` 强制为 cell 宽,按"行 × run"缓存。ASCII 且关闭连字时走 cmap 快路径,不做 shaping | +| 背景 / sprite / 装饰 / 光标 / 图片 | Skia lease(`ISkiaSharpApiLeaseFeature`),与 StreamMark.Mermaid 同一用法;拿不到 lease 时退化为 `FillRectangle` | +| 度量 | 照搬 Ghostty `Metrics.calc`:cell 宽高 round 到**整数设备像素**,基线、下划线位置 round,粗细 ceil 且 ≥1 | +| 行缓存 | 键 = `RowId + Version + FontGeneration`(连字且"光标处断开"时再加光标列);颜色、选区、搜索、光标**不进键**,绘制时用裁剪覆盖着色 | +| 字体 | 默认按平台(`.SF NS Mono`→Menlo / Cascadia Mono→Consolas / portal monospace);逐码点回退 + 缓存;系统查找放后台线程,先画占位 | +| 帧调度 | 输入路径无定时器;空闲时渲染循环休眠;光标闪烁由 UI 线程按相位边界唤醒(不逐帧轮询);mode 2026 由核心 actor 扣住快照 | +| 主题 | `Terminal.*` 资源键 + `TerminalResourceAlias`(同 `MarkdownResourceAlias`);Desktop 映射 AtomUI token / TCode 调色板;明暗两套 ANSI 16 色(§11.3) | + +## 1. 模块与文件 + +`src/TCode.Terminal.Avalonia/Rendering/`: + +| 类型 | 职责 | 所在线程(R3 / R1) | +|---|---|---| +| `TerminalView` | 控件本体;布局、度量、选择宿主、转发 UI 状态(§4) | UI | +| `UiRenderHost`(R3) | 重写 `Render(DrawingContext)`,录制 Painter 输出 | UI 录制 → 渲染线程回放 | +| `CompositorRenderHost` + `TerminalVisualHandler`(R1) | 自定义合成视觉,渲染线程绘制 | UI 创建;渲染线程绘制 | +| `TerminalPainter` | 快照 + 行缓存 → 绘制序列(§3) | 渲染所有者线程 | +| `CellMetrics` / `MetricsCalculator` | cell 度量(§5) | UI 计算,作为不可变值下发 | +| `FontSet` / `FontResolver` / `FallbackWorker` | 字面、回退、emoji(§6) | 解析在后台线程;结果不可变 | +| `RowCache` / `RowLayout` / `RunBuilder` | 行布局与 GlyphRun 缓存(§7) | 渲染所有者线程独占 | +| `SpriteRasterizer` / `SpriteAtlas` | 程序化字形(§8) | 渲染所有者线程 | +| `ColorResolver` / `ContrastCache` | 调色板、属性、最小对比度(§9.1、§9.7) | 渲染所有者线程 | +| `OverlayState` | 光标闪烁相位、预编辑、悬停链接、焦点(UI 产出的不可变对象) | UI 产出,渲染方只读 | +| `ImageStore` | Kitty 图形解码后位图(§9.9) | 写:视图核心 actor;读:渲染所有者 | +| `FrameSlot` | 最新快照槽 + Generation + WantFrame 标志(§10) | actor 写,渲染方读 | +| `TerminalTheme` / `TerminalResourceKeys` | 资源键解析为 `RenderTheme`(§11) | UI | +| `RenderStats`(EventSource) | 计数器与耗时(§12) | 任意 | + +"渲染所有者线程":R3 为 UI 线程,R1 为 Avalonia 渲染线程。`RowCache`、`SpriteAtlas`、`ContrastCache` 只由所有者访问,不加锁;跨线程输入一律是不可变对象或 `Volatile` 发布(满足 01 §5.2"渲染线程从不等锁")。 + +## 2. 数据流 + +``` +视图核心 actor ──CaptureFrame──▶ FrameSlot.Publish(frame) (Volatile 交换 + Generation++) + │ │ 若宿主空闲:Wake(UI.Post,合并) + │ ImageStore.Put(图片) ▼ + │ ┌──── R3:UI 线程 InvalidateVisual → Render(DrawingContext) +UI 线程 ── OverlayState ─────┤ Painter → DrawingContext(Custom 批次 + DrawGlyphRun) + (光标相位/预编辑/悬停/焦点) └──── R1:SendHandlerMessage → 渲染线程 OnAnimationFrameUpdate/OnRender + Painter → ImmediateDrawingContext(lease 批次 + DrawGlyphRun) +``` + +渲染方需要但 01 §6.1 `FrameSnapshot` 没有列出的字段(建议补进快照,见文末"架构问题"): + +| 字段 | 用途 | 来源 | +|---|---|---| +| `SearchHighlights`(视口内 `(row, colStart, colEnd, isActive)`) | 搜索高亮 §9.5 | actor 读 `GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES` 并换算到视口 [已核实 `search.h` DATA_VIEWPORT_MATCHES] | +| `Hyperlinks`(id → URI) | 悬停提示、OSC 8 下划线 | actor | +| `OverscanAbove/Below`(行) | 平滑滚动 §10.6 | `GHOSTTY_RENDER_STATE_OPTION_OVERSCAN` [已核实 `render.h:392-418`] | +| `Cursor.PasswordInput` | 可选的密码输入提示 | `GHOSTTY_RENDER_STATE_DATA_CURSOR_PASSWORD_INPUT` [已核实 `render.h:361`] | +| `MouseShape`(OSC 22) | 指针形状 | `GHOSTTY_TERMINAL_DATA_MOUSE_SHAPE` [已核实 `terminal.h:2952`] | + +## 3. `TerminalPainter` + +### 3.1 接口 + +```csharp +namespace TCode.Terminal.Avalonia.Rendering; + +/// 一帧的输入:全部是不可变值或由渲染所有者独占的对象。 +public readonly struct PaintInput +{ + public FrameSnapshot Frame { get; init; } + public OverlayState Overlay { get; init; } // 光标相位、预编辑、悬停链接、焦点、选区预览 + public CellMetrics Metrics { get; init; } // 设备像素整数度量 + 缩放 + public RenderTheme Theme { get; init; } // 已解析的颜色、最小对比度、选项 + public PixelRect DirtyRows { get; init; } // 需要重画的行范围(R1 局部失效;R3 全部) +} + +public interface ITerminalDrawTarget +{ + bool HasSkia { get; } // 拿得到 lease + void Skia(SkiaBatch batch); // 执行一个 Skia 批次(R3:Custom 操作;R1:lease) + void GlyphRun(IImmutableBrush brush, RowRun run); // 画一个文字 run + void Fallback(SkiaBatch batch); // 无 Skia 时:只回放 FillRect +} + +public sealed class TerminalPainter +{ + public TerminalPainter(RowCache rows, SpriteAtlas sprites, ColorResolver colors, ImageStore images); + public void Paint(in PaintInput input, ITerminalDrawTarget target); +} +``` + +`SkiaBatch` 是一个池化的命令数组(`struct` 元素:`FillRect`、`Sprite`、`DecorationPath`、`Image`、`StrokeRect`),只引用不可变数据(颜色值、atlas 页句柄、图片句柄),所以 R3 可以把它交给渲染线程延后执行。批次在 `ICustomDrawOperation.Dispose` 时归还对象池 [推断:需确认 Avalonia 在 render data 替换时 Dispose 自定义操作,§13 S-8]。 + +### 3.2 绘制顺序(每帧) + +顺序取自 Ghostty(bg → 下划线 → 字形 → 删除线)[已核实 research/04 §2.1.9],按"能否与 GlyphRun 合批"切成 5 段。lease 期间不能调用其他 `ImmediateDrawingContext` 方法 [已核实 `DrawingContextImpl.cs:119-131`,见 research/04 §3.3],所以段与段之间 Dispose lease。 + +| 段 | 类型 | 内容(从下到上) | +|---|---|---| +| P0 Under | Skia | ① 用默认背景清空网格区域与内边距;② Kitty 图片 `z < INT32_MIN/2`(below-bg);③ 每行背景 RLE(§9.2,已叠加选区 / 搜索 / inverse);④ Kitty 图片 `z < 0`(below-text);⑤ 下划线、上划线、悬停链接下划线(§8.5);⑥ sprite 字形(box、block、braille…,§8);⑦ block 光标的填充矩形 | +| P1 Text | GlyphRun | 所有文字 run;被选区、搜索、block 光标覆盖的区间按裁剪分段换色(§7.5) | +| P2 Over | Skia | ① 删除线;② bar / underline / hollow 光标;③ Kitty 图片 `z ≥ 0`(above-text);④ 预编辑背景 | +| P3 PreeditText | GlyphRun | 预编辑文字(没有预编辑时跳过) | +| P4 Top | Skia | 预编辑下划线与插入符;调试叠加层(§12) | + +没有预编辑、没有 above-text 图片、没有删除线时,P2–P4 为空,整帧只有一次 lease + 一组 GlyphRun。 + +### 3.3 两种宿主的 `ITerminalDrawTarget` + +| 方法 | R3 `DrawingContextTarget` | R1 `ImmediateTarget` | +|---|---|---| +| `Skia(batch)` | `context.Custom(new SkiaBatchOperation(batch, bounds))`;操作的 `Render(ImmediateDrawingContext)` 在渲染线程取 lease 执行 [已核实:`DrawingContext.Custom` 抽象方法 `DrawingContext.cs:243`;渲染线程回放 `RenderDataStream.Replay.cs:57-58`] | `using var lease = feature.Lease(); SkiaBatchExecutor.Run(lease.SkCanvas, batch)` | +| `GlyphRun(brush, run)` | `context.DrawGlyphRun(brush, run.GlyphRun)`(录制时 clone `PlatformImpl` [已核实 `RenderDataDrawingContext.cs:140-148`]) | `ctx.DrawGlyphRun(brush, run.ImmutableRef)`,`ImmutableRef` 由 `GlyphRun.TryCreateImmutableGlyphRunReference()` 创建后缓存在行布局里 [已核实 `GlyphRun.cs:926`、`ImmediateDrawingContext.cs:188`] | +| 拿 lease | 在 `SkiaBatchOperation.Render` 里 `context.TryGetFeature(typeof(ISkiaSharpApiLeaseFeature))` | `OnRender` 里同样调用;`CompositorDrawingContextProxy.GetFeature` 先 Flush 再转交 Skia [已核实 research/04 §3.4] | +| 无 Skia | `Fallback`:逐个 `FillRectangle`;sprite 退化为字体字形(行布局里保留字体 run 备份) | 同左(`ImmediateDrawingContext.FillRectangle` [已核实 `:201`]) | + +坐标:Painter 内部全部用**设备像素整数**计算,交给目标前除以 `Metrics.Scaling` 换成 DIP(§5.3)。lease 内直接在 `SkCanvas` 上 `Scale(1/scaling)` 前置,然后用整数像素画,保证与网格对齐。 + +`SkiaBatchExecutor` 规则: + +- `FillRect`:同色合并为一次 `SKCanvas.DrawRect`;`SKPaint` 按颜色缓存,`IsAntialias=false`。 +- `Sprite`:Alpha8 atlas 页 + 颜色;优先 `SKCanvas.DrawAtlas(image, sprites, transforms, colors, SKBlendMode.Modulate)` 一次画完一个页的所有 sprite;[spike S-5] 验证 A8 图像 + colors 的着色结果,不行就退化为逐个 `DrawImage` + `SKPaint.Color`(Skia 对 alpha-only 图像使用画笔颜色 [推断])。 +- `DecorationPath`:curly / dotted / dashed 下划线按整段路径绘制(§8.5),开 AA。 +- `Image`:`SKImage` + 源 / 目标矩形,1:1 时 `SKSamplingOptions(SKFilterMode.Nearest)`,缩放时 Linear。 +- 透明度:乘以 `lease.CurrentOpacity`(StreamMark 同样处理 [已核实 `SkiaPictureDiagram.cs:122-131`])。 + +## 4. 渲染宿主 + +### 4.1 R3:`Control.Render` + +```csharp +public override void Render(DrawingContext context) +{ + var frame = _slot.Latest; // Volatile 读 + if (frame is null) { context.FillRectangle(_theme.BackgroundBrush, Bounds); return; } + _slot.MarkConsumed(frame.Generation); // 置 WantFrame,§10.3 + _painter.Paint(new PaintInput { Frame = frame, Overlay = _overlay, Metrics = _metrics, + Theme = _renderTheme, DirtyRows = AllRows }, _drawingTarget); +} +``` + +- 失效:`FrameSlot` 发布后 actor 调 `Wake`(§10.1)→ UI 线程 `InvalidateVisual()`;整控件重录(Avalonia 收到新 render data 就整体失效 [已核实 research/04 §3.2 `ServerCompositionDrawListVisual.cs:36-39`])。 +- 行缓存命中时每帧 UI 线程只做"遍历可见行 + 录制已有 GlyphRun",目标 ≤0.5 ms(200×60)。 +- 光标闪烁、预编辑、悬停都是 `OverlayState` 变化 → `InvalidateVisual()`。 + +### 4.2 R1:`CompositionCustomVisualHandler` + +已核实的 API(`CompositionCustomVisualHandler.cs:10-111`):`OnMessage`、`OnAnimationFrameUpdate`、`OnRender(ImmediateDrawingContext)`、`EffectiveSize`、`CompositionNow`、`Invalidate()`、`Invalidate(Rect)`、`RegisterForNextAnimationFrameUpdate()`、`RenderClipIntersectes(Rect)`;`Compositor.CreateCustomVisual(handler)`、`CompositionCustomVisual.SendHandlerMessage`、`ElementComposition.SetElementChildVisual`(`ElementCompositionPreview.cs:21`)。 + +```csharp +// UI 线程 +protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) +{ + base.OnAttachedToVisualTree(e); + var compositor = ElementComposition.GetElementVisual(this)!.Compositor; + _handler = new TerminalVisualHandler(_slot, _painterFactory); // _slot 是普通托管对象,跨线程只做 Volatile 读 + _visual = compositor.CreateCustomVisual(_handler); + _visual.Size = new Vector(Bounds.Width, Bounds.Height); + ElementComposition.SetElementChildVisual(this, _visual); + _visual.SendHandlerMessage(new HostMessage.Configure(_metrics, _renderTheme, _overlay)); +} +// 尺寸、缩放、主题、OverlayState 变化:_visual.Size = …; _visual.SendHandlerMessage(new HostMessage.…(…)) +// 卸载:SetElementChildVisual(this, null); 发送 Detach,handler 释放 atlas 与图片 +``` + +```csharp +// 渲染线程 +sealed class TerminalVisualHandler : CompositionCustomVisualHandler +{ + long _drawnGeneration; int _idleTicks; bool _active; + public override void OnMessage(object m) + { + switch (m) + { + case HostMessage.Wake: Activate(); break; + case HostMessage.Overlay o: _overlay = o.State; Invalidate(o.DirtyRect); break; + case HostMessage.Configure c: Apply(c); Invalidate(); break; // 度量/主题变:清行缓存 + } + } + public override void OnAnimationFrameUpdate() + { + var frame = _slot.Latest; + if (frame is not null && frame.Generation != _drawnGeneration) + { + Invalidate(DirtyRect(frame)); // 只失效变化行(Rows 引用不等的行) + _idleTicks = 0; + } + else if (++_idleTicks >= 30) { _active = false; return; } // 约 250 ms 无变化:休眠 + RegisterForNextAnimationFrameUpdate(); + } + public override void OnRender(ImmediateDrawingContext ctx) { /* Painter.Paint(…, ImmediateTarget(ctx)) */ } + void Activate() { if (!_active) { _active = true; _idleTicks = 0; RegisterForNextAnimationFrameUpdate(); } } +} +``` + +- 脏矩形:默认 `SingleDirtyRectTracker` 会把多个脏区合并成外接矩形 [已核实 research/04 §3.0]。v1 接受;是否开 `CompositionOptions.UseRegionDirtyRectClipping`([已核实 `CompositionOptions.cs:11`])由 spike S-3 决定,它是应用级开关,Desktop 决定。 +- `OnRender` 里用 `RenderClipIntersectes(rowRect)` 跳过不在裁剪区内的行。 +- handler 拿不到 DPI,缩放随 `Configure` 消息下发。 + +### 4.3 R2(调试开关) + +`Control.Render` 里只录一个覆盖整个控件的 `ICustomDrawOperation`,`Render(ImmediateDrawingContext)` 里跑与 R1 完全相同的代码。用途:在不动合成器的前提下单独验证"渲染线程上创建 GlyphRun / 字体解析"(spike S-1),以及作为 R1 出问题时的回退。 + +### 4.4 宿主开关 + +| 层 | 名称 | 值 | +|---|---|---| +| `TerminalViewOptions.RenderHost` | `Auto` / `UiThread`(R3) / `CustomOperation`(R2) / `Compositor`(R1) | `Auto`:M3–M5 = R3;M6 起 = R1 | +| settings(高级,`terminal.rendering.host`) | `auto` / `ui` / `customOp` / `compositor` | `auto` | +| 环境变量 | `TCODE_TERMINAL_RENDER_HOST` | 覆盖 settings,用于排查 | + +`Auto` 在以下情况自动降为 R3:`ElementComposition.GetElementVisual(this)` 为 null;Headless 且 spike S-7 结论是自定义视觉不进 `CaptureRenderedFrame`。切换宿主时重建 `RowCache`(R3 缓存 `GlyphRun`,R1 缓存 `ImmutableRef`)。 + +### 4.5 从 R3 迁移到 R1 的步骤(M6) + +| # | 步骤 | 完成标准 | +|---|---|---| +| 1 | Painter、RowCache、SpriteAtlas、ColorResolver 从第一天起就不碰 `Control`/`AvaloniaObject`,只吃 `PaintInput` | R3 阶段代码审查项 | +| 2 | 跑 spike S-1:渲染线程上 `GlyphRun` 构造、`TextShaper.ShapeText`、`FontManager.TryGetGlyphTypeface` 的线程安全 | 不安全 → 行布局构建移到"布局工作线程",渲染线程只消费已建好的 `RowLayout`(多一帧延迟) | +| 3 | 开 R2 开关跑全部截图测试与 S4/S12 基准 | 像素一致;R2 帧时间达标 | +| 4 | 实现 `TerminalVisualHandler` 与消息;光标闪烁改为 `HostMessage.Overlay` | 截图测试在 R1 下通过(S-7 通过时) | +| 5 | 唤醒路径:actor → `Dispatcher.UIThread.Post(Wake)` → `SendHandlerMessage`;按键时 UI 线程预先发 `Wake`(§10.1) | 输入延迟分段(§12)p50 不劣于 R3 | +| 6 | 跑 S12(StreamMark 流式 + 终端洪流) | 终端帧率不随 UI 线程负载下降 | +| 7 | `Auto` 切到 R1;R3 保留一个版本作回退 | 发布说明 | + +## 5. cell 度量与像素对齐 + +### 5.1 字号单位 + +字号按点(pt)配置(research/05 §7.1):macOS `1pt = 1 DIP`,Windows / Linux `1pt = 96/72 DIP`。`emDip = sizePt × unit`;`emPx = emDip × scaling`(`TopLevel.RenderScaling` [已核实 `TopLevel.cs:519`])。 + +### 5.2 计算(设备像素) + +输入:主字体 regular 字面的 `GlyphTypeface.Metrics`(设计单位,`Ascent` 为负 [已核实 `FontMetrics.cs:6-56`])。`s = emPx / DesignEmHeight`。 + +| 量 | 公式 | 取整 | 来源 | +|---|---|---|---| +| `faceW` | ASCII 32..126 的最大 advance × s(`TryGetHorizontalGlyphAdvances` [已核实 `GlyphTypeface.cs:694`]) | — | Ghostty `Metrics.zig:243-327` | +| `cellW` | `faceW + letterSpacing × scaling` | **round**,≥1 | Ghostty 用 round 不用 ceil | +| `faceH` | `(Descent − Ascent + LineGap) × s` | — | | +| `cellH` | `faceH × lineHeight` | **round** | | +| `baselineFromBottom` | `LineGap·s/2 + Descent·s − (cellH − faceH)/2` | round | 行高调整差值上下各半 | +| `baselineY` | `cellH − baselineFromBottom` | 整数 | 字形基线距 cell 顶 | +| `underlineThickness` | `UnderlineThickness·s`;缺失时 `0.15 × exHeight` | **ceil**,≥1 | | +| `underlineY` | `baselineY − UnderlinePosition·s`(Avalonia 符号约定下 UnderlinePosition 为负 [推断:需按字体实测确认符号]) | round | | +| `strikeThickness` / `strikeY` | 同上;缺失时位于 `(exHeight + 粗细)/2` | ceil / round | | +| `overlineY` | 0 | — | | +| `boxThickness` | = `underlineThickness` | — | Ghostty sprite light 线宽 | +| `cursorBarWidth` | = `boxThickness` | — | | +| `cursorUnderlineHeight` | = `underlineThickness` | — | | + +缺失度量时的估算:capHeight = 0.75 × ascent,exHeight = 0.75 × capHeight(Ghostty `Metrics.zig:120-230`)。 + +### 5.3 网格原点与布局 + +- 内边距 `Options.Padding`(默认 6 DIP)先换成设备像素 round。网格列数 `cols = floor((widthPx − padL − padR) / cellW)`,行数同理;剩余像素放在右侧和底部,用默认背景填充(可选 `padding.extendBackground`:用最近一列 / 行的背景延伸,Ghostty `window-padding-color=extend`)。 +- 原点对齐:控件在窗口中的位置可能落在分数设备像素上。R3:`this.TranslatePoint(default, topLevel)` × scaling 取小数部分 `frac`,Painter 整体平移 `−frac`;R1:在 lease 中读 `SkCanvas.TotalMatrix` 的平移分量,同样消除小数。 +- `GlyphRun.BaselineOrigin` 用 `(col × cellW, row × cellH + baselineY) / scaling`,换成 DIP 后可能是分数,但对应的设备像素是整数。 +- 尺寸变化 → `RequestResize(new TerminalSize(Columns: cols, Rows: rows, CellWidth: cellW, CellHeight: cellH))`。按 01 §6.1,`CellWidth/CellHeight` 是**单个 cell** 的像素尺寸,这里取设备像素整数(供 CSI 14/16 t、2048、SGR-pixels 使用,与 Ghostty 一致 [推断]);整块区域像素由需要的一方乘出来。网格之外的内边距不计入。 + +### 5.4 重建触发 + +| 事件 | 动作 | +|---|---| +| 字体族、字号、行高、字距、粗体字重变化 | 重算度量;`FontGeneration++`;清空行缓存、sprite atlas;重新 `RequestResize` | +| `TopLevel.ScalingChanged`([已核实 `TopLevel.cs:348`]),窗口移到不同 DPI 的屏幕 | 同上 | +| 回退字体解析完成(§6.3) | `FallbackEpoch++`;只重建带 `PendingFallback` 标记的行 | +| 主题 / 调色板变化 | `ThemeGeneration++`;不清行布局(颜色不进键),清 `ContrastCache` | + +## 6. 字体 + +### 6.1 默认字体("跟随系统"的解析结果) + +来源 research/05 §7.1。解析在 Desktop(需要读系统设置),结果以字体族列表传给 `TerminalViewOptions.Font`。 + +| 平台 | 首选 | 回退 | 字号 | +|---|---|---|---| +| macOS | `.SF NS Mono`(隐藏系统字体,`/System/Library/Fonts/SFNSMono.ttf`) | Menlo | 12 pt | +| Windows | Cascadia Mono | Consolas | 12 pt | +| Linux | portal `org.freedesktop.appearance` / gsettings `monospace-font-name`(GNOME,默认 Adwaita Mono 11)→ kdeglobals `[General] fixed`(KDE)→ fontconfig `monospace` | DejaVu Sans Mono | 取系统值,缺省 11 pt | + +验证:`FontManager.TryGetGlyphTypeface(new Typeface(family))`([已核实 `FontManager.cs:129`])成功、`Metrics.IsFixedPitch` 为真、`M` 与 `i` 的 advance 相等(防止误配到比例字体)。`.SF NS Mono` 能否被 Skia 按族名解析是 spike S-9(research/05 标为 [推])。 + +### 6.2 字面:真实字面优先,缺失时合成 + +- `FontSet` 持有 4 个字面:regular / bold / italic / bold-italic,用 `new Typeface(family, style, weight)` + `TryGetGlyphTypeface` 取得。 +- Avalonia 在字体族没有对应字面时**自动合成**:Skia 的 `TryCreateGlyphTypeface` 在 `weight ≥ 600 && !IsBold` 时加 `FontSimulations.Bold`,斜体缺失时加 `Oblique` [已核实 `FontManagerImpl.cs:113-121`;`FontCollectionBase.cs:495-503`];合成实现是 `SKFont.Embolden` + `skewX = -0.3`(约 16.7°)[已核实 `SkiaTypeface.cs:33-40`]。 +- 判断是否合成:`glyphTypeface.FontSimulations != None`。设置 `terminal.font.syntheticStyle`(默认 `true`,同 Ghostty `font-synthetic-style`);为 `false` 时,合成出来的字面改用 regular(只保留颜色等其他属性)。 +- 粗体字重 `font.weightBold`(默认 `bold`=700),普通字重 `font.weight`(默认 400)。 +- `colors.boldIsBright`(默认 false):粗体且前景是调色板 0–7 时换成 8–15(§9.1)。 + +### 6.3 逐码点回退 + +解析顺序(Ghostty `CodepointResolver.getIndex` [已核实 research/04 §2.1.12]): + +| # | 步骤 | 实现 | +|---|---|---| +| 1 | 程序化字形 | 码点在 §8.1 范围且 `rendering.customGlyphs=true` → sprite,不进字体 | +| 2 | 码点映射覆盖 | `font.codepointMap`(范围 → 字体族),可选 | +| 3 | 主字体同样式 | `CharacterToGlyphMap` 查表([已核实 `GlyphTypeface.cs:324`]);emoji 呈现(§6.5)时跳过文本字体 | +| 4 | 主字体 regular | 非 regular 样式找不到时 | +| 5 | 已加载的回退字面 | 按加载顺序 | +| 6 | 配置的回退列表 | `font.fallback`,缺省为 §6.4 的 CJK 列表 | +| 7 | 系统匹配 | `FontManager.TryMatchCharacter(cp, style, weight, stretch, family, culture, out typeface)` [已核实 `FontManager.cs:291`],**在 `FallbackWorker` 后台线程执行** | +| 8 | 全部失败 | 画"缺字方框" sprite(`U+FFFD` 也找不到时) | + +缓存:`Dictionary`,键 = `cp(21) | style(2)<<21 | presentation(2)<<23`;值是 `FaceId`(`FontSet` 内 `GlyphTypeface` 表下标)。第 7 步未完成时缓存 `Pending`,该行布局标记 `PendingFallback` 并先画占位(空白,不画方框,避免闪烁);后台完成后 `FallbackEpoch++` 并唤醒渲染,带标记的行重建(WezTerm 做法 [已核实 research/04 §2.3])。 + +回退字面尺寸调整(Ghostty `size_adjustment = ic_width` [已核实 research/04 §2.1.12]):CJK 回退字面测 `水`(U+6C34)的 advance,若 `> 2 × cellW`,按比例缩小 emSize;再保证 `ascent + descent ≤ cellH`。基线与主字体共用 `baselineY`。 + +### 6.4 CJK 回退列表(默认) + +按 UI 语言调整顺序(`zh-Hans` / `zh-Hant` / `ja` / `ko` 各自排第一)[推断:列表为常见系统自带字体]: + +| 平台 | 列表 | +|---|---| +| macOS | PingFang SC, PingFang TC, Hiragino Sans, Apple SD Gothic Neo | +| Windows | Microsoft YaHei UI, Microsoft JhengHei UI, Yu Gothic UI, Malgun Gothic, SimSun | +| Linux | Noto Sans Mono CJK SC, Noto Sans CJK SC, Source Han Sans SC, WenQuanYi Micro Hei | + +### 6.5 彩色 emoji 与宽字形 + +- 呈现判定:grapheme 带 VS16,或首码点默认 emoji 呈现(`Codepoint.HasEmojiPresentation` [已核实 research/04 §4.3])→ emoji 字体;带 VS15 → 文本字体。 +- emoji 字体:Apple Color Emoji / Segoe UI Emoji / Noto Color Emoji。Skia 自动绘制 COLR / sbix / CBDT 彩色字形 [推断],画笔颜色不影响彩色字形。 +- 尺寸(Ghostty `SharedGrid.zig:416-432`):`cover` 方式放进 2 格,居中,左右各留 2.5%:用 `TryGetGlyphMetrics` 取字形包围盒,按 `min((2·cellW·0.95)/w, cellH/h)` 求 emSize。emoji 单独成 run(§7.3)。 +- 宽度永远由核心决定(快照里的 wide 标志),渲染器只决定字形放进 1 格还是 2 格(research/03 §6.3 第 6 条)。 +- 宽于 1 格的窄字形(Nerd Font 图标等,Ghostty `constraintWidth` [已核实 research/04 §4.3]):右邻格为空白时允许溢出到 2 格;否则按 1 格等比缩小。溢出只影响字形,背景仍按格画。 + +## 7. 行缓存 + +### 7.1 键与生命周期 + +```csharp +readonly record struct RowKey(ulong RowId, int Version, int FontGeneration, int CursorBreakCol); +// CursorBreakCol:仅当 ligatures=true 且 breakAtCursor=true 且光标在本行时为光标列,否则 -1 +``` + +- `RowId` 跨滚动稳定(01 §6.1),普通滚动全部命中。 +- 存储:`Dictionary`(按 RowId),条目里存 `Version / FontGeneration / CursorBreakCol`,不匹配则重建并复用旧对象的数组(零分配)。 +- 淘汰:每个条目记录最后使用的帧号;超过 120 帧未用且缓存条目数 > `3 × 可见行数` 时淘汰;字体或宿主变化时整体清空。 +- `FallbackEpoch` 不进键:带 `PendingFallback` 的条目在 epoch 变化时视为失效。 + +### 7.2 `RowLayout` 内容 + +| 字段 | 内容 | +|---|---| +| `BgSpans` | `(colStart, colEnd, ColorRef bg, flags)` RLE,已去掉默认背景段 | +| `Runs` | `RowRun[]`:`FaceId`、`emSize`、`colStart/colEnd`、`StyleIndex`、`GlyphRun`(R3)或 `IImmutableGlyphRunReference`(R1)、`IsEmoji`、`HasBlink` | +| `Sprites` | `(col, SpriteKey, StyleIndex)`;宽 sprite 记 2 格 | +| `Decorations` | 下划线(样式 / 颜色引用)、上划线、删除线、超链接 id 的列区间 | +| `Flags` | `PendingFallback`、`HasBlink`、`HasWideOverflow`、`AllDefaultBg` | + +颜色在布局里只存引用(`StyleIndex` 指向 `RowSnapshot.Styles`),绘制时由 `ColorResolver` 解析(§9.1)。 + +### 7.3 run 切分规则 + +| 断开条件 | 原因 | +|---|---| +| 样式变化(不含 bg) | fg、粗体 / 斜体、下划线不同 → 字面或颜色不同 | +| bg 变化,**仅当** `minimumContrastRatio > 1` | 最小对比度按 (fg, bg) 调整前景 | +| 字面(`FaceId`)变化 | 回退字体、emoji | +| sprite 格 | 从 run 中移出,进 `Sprites` | +| 宽字符尾格(spacer) | 跳过,不产生字形 | +| 光标列 | 仅 `ligatures=true && breakAtCursor=true`:光标前断开、光标格单独成 run(Ghostty [已核实 research/04 §2.1.10]) | +| 不可见(SGR 8) | 不产生字形 | +| 选区、搜索、block 光标 | **不断开**,绘制时裁剪换色(§7.5) | +| run 长度 | 不限 | + +### 7.4 GlyphRun 构建 + +1. **ASCII 快路径**(全部码点 0x20–0x7E、主字面、`ligatures=false`):每个字面预先建 `ushort[95]` 字形表;`GlyphInfo(glyph, cluster: i, advance: cellWDip)`。不经过 HarfBuzz。 +2. **cmap 路径**(关闭连字、每格一个码点或 grapheme 不需要 shaping):逐格 `CharacterToGlyphMap` 取字形,同样固定 advance。 +3. **shaping 路径**(开启连字,或 grapheme 含组合符 / ZWJ / 复杂文字):`TextShaper.Current.ShapeText(chars, new TextShaperOptions(face, emSize, bidiLevel: 0, culture, features))`([已核实 `TextShaper.cs:43`;TCode 已 `UseHarfBuzz()`,research/04 §4.5])→ 网格对齐:每个 cluster 的起点强制为 `col × cellW`,cluster 内多个字形保留 HarfBuzz 的相对偏移,最后一个字形的 advance 补齐到下一个 cluster 的列(WT `AtlasEngine.cpp:1173` 同样做法)。 + - `ligatures=false` 时 features 传 `-calt,-liga,-dlig`(Ghostty 建议值)。 + - shaping 结果按 `hash(码点序列, 相对 cluster, FaceId, featuresId)` 缓存(颜色不进键,Ghostty `run.zig:262-287`),容量 4096。 +4. `new GlyphRun(face, emSizeDip, chars, glyphInfos, baselineOrigin)`([已核实 `GlyphRun.cs:63-68`]);R1 再 `TryCreateImmutableGlyphRunReference()` 并缓存,旧引用在条目重建时 Dispose。 + +### 7.5 绘制时的覆盖着色 + +选区、搜索匹配、block 光标会让一个 run 的一部分换前景色。做法(WezTerm 按像素区间切条 [已核实 research/04 §2.3]):对与覆盖区间相交的 run,先 `PushClip` 非覆盖部分画一次原色,再 `PushClip` 覆盖部分画一次覆盖色。R1 用 `ImmediateDrawingContext.PushClip(Rect)` [已核实 `:277`],R3 用 `DrawingContext.PushClip` [已核实 `:354`]。一行最多 3 种覆盖(选区、搜索、光标),每个 run 最多 7 段。 + +## 8. 程序化字形(sprite)与装饰 + +### 8.1 范围 + +取 Ghostty / WT / xterm.js / kitty 的并集(research/04 §4.6)。`rendering.customGlyphs=false` 时全部改用字体。 + +| 优先级 | 范围 | 内容 | +|---|---|---| +| P1(M3) | U+2500–257F | Box Drawing(含圆角 ╭╮╰╯、虚线 ┄┆、对角 ╱╲╳) | +| P1 | U+2580–259F | Block Elements(含 ░▒▓) | +| P1 | U+2800–28FF | Braille | +| P1 | U+E0B0–E0BF、E0D2、E0D4 | Powerline 几何类 | +| P2(M4) | U+1FB00–1FBEF | Legacy Computing(sextant、wedge、对角块等) | +| P2 | U+1CD00–1CDE5 | Octants | +| P2 | U+25E2–25E5、25F8–25FA、25FF | 几何三角 | +| P3(M6) | U+EE00–EE0B | Fira Code 进度条与 spinner | +| P3 | U+F5D0–F60D | git 分支图(kitty 扩展) | +| 内置 | — | 缺字方框(§6.3 第 8 步) | + +### 8.2 光栅化 + +- 每个 sprite:`SKPath` 画到 **A8** `SKBitmap`(`cellW × cellH`,宽 sprite 为 `2·cellW`)。 +- 键:`(cp, cellW, cellH, boxThickness)`;打包进 `SpriteAtlas`(1024×1024 A8 页,shelf 打包,满了开新页,最多 8 页,超出时整体清空重建——终端用到的 sprite 种类有限)。 +- 栅格化时机:首次在行布局中出现时同步完成(单个 ≤50 µs 目标);atlas 页从 `SKBitmap` 生成 `SKImage` 的操作在帧内集中做一次。 +- 着色:A8 + 画笔颜色(§3.3 `Sprite`)。最小对比度对 box / block / powerline / legacy 豁免(Ghostty [已核实 research/04 §2.1.14])。 + +### 8.3 对齐规则(Ghostty,[已核实 research/04 §2.1.13、§4.6]) + +| 项 | 规则 | +|---|---| +| 线宽 | light = `boxThickness`,heavy = `2 × boxThickness` | +| 中线位置 | 水平线 y = `(cellH − t) / 2` 向下取整;竖线同理;保证相邻格的线首尾相接 | +| 分数坐标 | `min = s − round((1 − f)·s)`,`max = round(f·s)`:两个半块在奇数尺寸下无缝 | +| 1/8 块(▁▂…) | 用上面的分数公式求像素边界 | +| 阴影 ░▒▓ | 整格填充,alpha 分别为 25% / 50% / 75%(不用点阵,HiDPI 下不出摩尔纹) | +| 盲文 | 点宽 = `min(w/4, h/8)`;剩余像素按"点宽 > 边距 > 间距"分配 | +| 圆角 | 四分之一圆弧,半径 = `min(w, h)/2`,开 AA | +| 虚线 ┄┅┆┇┈┉┊┋ | 2/3/4 段,段长与间隔整数像素,余数分给间隔 | +| 对角线 | 开 AA,从格角到格角(跨格连续) | +| Powerline | 三角形 / 半圆实心;细线版本用 `boxThickness` 描边;高度覆盖整个 cellH | +| Sextant / Octant | 2×3 / 2×4 子块网格,用分数公式求边界,按码点查位表 | + +### 8.4 光标形状(作为装饰绘制,不进 atlas) + +| 形状 | 矩形 | +|---|---| +| block | 整格(宽字符为 2 格)填充 | +| bar | x = 格左边,宽 `cursorBarWidth`;与 Ghostty 一样贴在格的左侧 | +| underline | y = `underlineY`,高 `cursorUnderlineHeight` | +| hollow | 整格描边,线宽 `boxThickness`(失焦时) | + +### 8.5 下划线、删除线、上划线、超链接 + +画在 P0(下划线、上划线)与 P2(删除线),按**整段列区间**画一条路径,不按格拼 sprite,保证曲线连续。 + +| 样式(SGR 4:n,[已核实 `sgr.h:102-107`]) | 画法 | +|---|---| +| single | 矩形 `[x0, x1) × [underlineY, +t)` | +| double | 两条,间隔 `t`:第二条在 `underlineY + 2t`,超出 cell 底时整体上移 | +| curly | 正弦路径,周期 = `cellW`,振幅 = `max(1, round(cellW/π/2))`,线宽 `t`,AA;从 `x0` 对齐到格边,跨 run 相位连续(相位按绝对列计算) | +| dotted | 圆点直径 `t`(至少 1 px),间距 `2t`,按绝对 x 对齐 | +| dashed | 段长 `cellW/3 + 1`,间隔同长度,按格重复 | +| 删除线 | 矩形 `[strikeY, +strikeThickness)` | +| 上划线 | 矩形 `[0, t)` | +| 颜色 | SGR 58 下划线颜色,缺省用前景色(已经过 faint / 最小对比度) | + +超链接(OSC 8 与检测到的 URL / 路径):常态不加装饰;悬停时(`OverlayState.HoverLink` 的列区间),没有下划线的加 single,已有 single 的改 double(Ghostty 规则 [已核实 research/04 §4.7]),颜色用 `Terminal.LinkForeground`(未设置时用前景)。 + +## 9. 颜色、覆盖层与特殊属性 + +### 9.1 每格颜色解析顺序 + +``` +fg/bg ← ResolvedStyle(ColorRef:Default | Palette(i) | Rgb)对 FrameSnapshot.Palette 解析 +bold && boldIsBright && fg 是 Palette(0..7) → Palette(i+8) +inverse → 交换 fg/bg(Default 也参与交换) +faint → fg = mix(fg, bg, 0.5)(等价于 alpha 0.5,Ghostty / xterm.js) +invisible → 不画字形与装饰(背景照画) +选区 → bg = SelectionBackground;fg = SelectionForeground 或自动黑/白(§9.4) +搜索匹配 → bg = SearchMatch[Active]Background;fg 自动黑/白 +block 光标所在格 → fg = CursorText(缺省为默认背景色) +最小对比度(§9.7)→ 调整 fg;豁免 sprite 几何范围、emoji、图片 +blink 熄灭相位 → 不画字形(§9.8) +``` + +`FrameSnapshot.Palette` 已处理 DECSCNM 反显(01 §6.1)。颜色值以 `uint`(ARGB)在 Painter 内流转,`IImmutableBrush` 按颜色缓存(`ImmutableSolidColorBrush`)。 + +### 9.2 背景:按行 RLE + +- 每帧先用默认背景清空整个网格 + 内边距(一次 `DrawRect`),所以默认背景段不画。 +- 每行 `BgSpans` 合并相邻同色格;选区、搜索、inverse 在 Painter 里与 `BgSpans` 求交后输出,最终每行若干 `FillRect`。 +- 宽字符的背景覆盖 2 格。 +- 先用 RLE(research/04 §8.2 建议);如果 S4 基准显示背景是瓶颈,再换成"每格 1 像素位图 + 最近邻放大"一次 `DrawImage`(WT / Ghostty 做法)。 + +### 9.3 光标 + +| 项 | 规则 | 来源 | +|---|---|---| +| 形状 | 快照 `CursorState`(DECSCUSR)优先;未设置时用 `cursor.style`:macOS / Linux block,Windows bar | research/05 §7.2 | +| 颜色 | OSC 12 设置的颜色 → `Terminal.CursorColor` → 前景色 | | +| 光标下文字 | block:`Terminal.CursorTextColor`,缺省默认背景色 | Ghostty `cell_text.v.glsl:134-141` | +| 低对比度兜底 | 光标色与所在格背景对比度 < 1.5 → 改用该格前景色 / 背景色反色 | Alacritty `display/content.rs:22,124-136` | +| 失焦 | `cursor.unfocusedStyle`:`outline`(默认,hollow)/ `none` / `same` | VS Code | +| 宽字符 | 光标在宽字符上(`CURSOR_VIEWPORT_WIDE_TAIL` [已核实 `render.h:377`])→ 覆盖 2 格 | | +| 不可见 | DECTCEM 关闭、视口滚离光标行 → 不画 | | +| 预编辑中 | 不画终端光标,由预编辑插入符代替(§9.6) | | + +**闪烁** + +| 条件 | 闪烁? | +|---|---| +| `cursor.blink = false` | 否 | +| `cursor.blink = true` | 由程序决定:`CursorState.Blinking`(DECSCUSR 奇数值 / mode 12,`GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING` [已核实 `render.h:358`]) | +| `cursor.blink = system`(默认) | 系统允许闪烁时同上;macOS 默认不闪(Terminal.app / iTerm2) | +| 失焦、窗口最小化、`reduceMotion` | 否(常亮) | +| Linux `cursor-blink-timeout` 到期(无输入 N 秒) | 停在"亮" | + +系统闪烁参数(Avalonia 不提供 [已核实 `IPlatformSettings.cs:13-63` 无此项],由 Desktop 读取后传入 `TerminalViewOptions.CursorBlinkInterval`): + +| 平台 | 来源 | 换算 | +|---|---|---| +| Windows | `GetCaretBlinkTime()`(user32);返回 `INFINITE` 或 > 10 s 视为不闪 | 返回值即半周期 | +| Linux GNOME | portal / gsettings `org.gnome.desktop.interface cursor-blink`、`cursor-blink-time`(整周期 ms,默认 1200)、`cursor-blink-timeout`(s,默认 10) | 半周期 = time/2 | +| Linux KDE | kdeglobals `CursorBlinkRate` [推断] | ms | +| macOS | `NSTextInsertionPointBlinkPeriodOn/Off`(未公开键,通常不存在)→ 560 ms(iTerm2 的回退值) | 只在 `cursor.blink=true` 时使用 | + +相位:有输入或输出时重置为"亮"(节流 500 ms,Ghostty `Termio.zig:714-725`)。计时器在 UI 线程(§10.2)。 + +### 9.4 选区高亮 + +- 来自快照 `Selection`(UI 侧核心的当前选区,线性或矩形)。 +- `Terminal.SelectionBackground` 必有;`Terminal.SelectionForeground` 可选。未设置前景时按选区背景亮度自动取黑或白(WT / Ghostty)。 +- 直接改格子颜色,不用半透明叠加(WT `AtlasEngine.cpp:410-484`),文字清晰度不受影响。 + +### 9.5 搜索高亮 + +- 来自快照 `SearchHighlights`(§2,需补进快照)。 +- 普通匹配 `Terminal.SearchMatchBackground`,当前匹配 `Terminal.SearchMatchActiveBackground`;前景自动黑 / 白。优先级:当前匹配 > 选区 > 普通匹配。 +- 搜索 UI 与线程见 [06-input.md](06-input.md) §9。 + +### 9.6 预编辑(IME)叠加层 + +- 数据:`OverlayState.Preedit { Text, CursorPos, CellWidths[] }`,列宽由 UI 线程用 `ghostty_unicode_grapheme_width`(2027 开)或逐码点 `ghostty_unicode_codepoint_width`(2027 关)预测([已核实 `unicode.h:69,144`,纯函数、线程安全];06 §4)。 +- 位置:从光标格开始。总宽超出行尾时整体左移,使右端对齐行尾;仍放不下(预编辑宽于整行)时从左端裁剪,保证插入符可见。 +- 绘制:P2 用默认背景填充覆盖区域 → P3 用默认前景画文字(独立的预编辑行布局,不进行缓存)→ P4 画 1 条 single 下划线(`underlineThickness`)和插入符(bar,`cursorBarWidth`,位置为 `CursorPos` 对应的格边)。 +- 预编辑不写入核心,不影响快照(01 §6.1 的快照只含终端状态)。 + +### 9.7 最小对比度 + +- 默认 `colors.minimumContrastRatio = 4.5`(research/05 §7.4 建议,VS Code 同值);系统高对比度时 7;设 1 关闭。 +- 算法:WCAG 相对亮度 `(L1+0.05)/(L2+0.05)`。不达标时采用 xterm.js 的逐步调整([已核实 research/05 §7.4:`Color.ts:296-361`]):前景比背景暗就每次把 RGB 各乘 0.9(变暗),否则向白色逼近 10%;最多 10 次;仍不达标则取黑或白中对比度高者(Ghostty 的兜底)。这样保留色相,比 Ghostty 直接变黑白更柔和。 +- 缓存:`Dictionary<(uint fg, uint bg), uint>`,容量 4096,`ThemeGeneration` 变化时清空。 +- 豁免:§8.1 中 box / block / powerline / legacy / octant 范围(几何字符常故意 fg≈bg)、emoji、图片、`invisible`。 + +### 9.8 特殊属性 + +| 属性 | 渲染 | +|---|---| +| bold | bold 字面(或合成);可选 bold-is-bright | +| italic | italic 字面(或合成) | +| faint(SGR 2) | 前景向背景混合 50% | +| inverse(SGR 7) | 交换前景与背景 | +| invisible(SGR 8) | 只画背景 | +| blink(SGR 5/6) | `rendering.textBlink`(默认 true):可见区域有 blink 格时启动 500 ms 计时器,熄灭相位不画字形;`reduceMotion` 时常亮。Ghostty 不渲染,foot 渲染(research/04 §4.8) | +| strikethrough / overline / underline | §8.5 | + +### 9.9 Kitty 图形 + +数据路径: + +1. **视图核心 actor**:每次 `CaptureFrame` 时检查 `GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION`([已核实 `kitty_graphics.h:166`]),有变化就遍历放置(placement iterator),对每个放置调用 `ghostty_kitty_graphics_placement_render_info` / `placement_rect`([已核实 `:714-859`])得到像素矩形、源矩形、z 值。图片像素指针只在下一次修改终端前有效([已核实 `kitty_graphics.h:30-34`]),所以对"`(imageId, imageGeneration)` 新出现"的图片,在 actor 上把像素复制进 `ImageStore`(RGBA,必要时从 RGB / Gray 扩展)。 +2. **快照**:`FrameSnapshot.Images` 只带放置列表(`imageId, imageGeneration, destRectPx, srcRectPx, z`)。 +3. **渲染所有者**:按 `(imageId, generation)` 从 `ImageStore` 取像素,首次使用时 `SKImage.FromPixelCopy` 并缓存;R3 / R1 都在 Skia 批次里画。 + +| 项 | 规则 | +|---|---| +| z 序 | `z < INT32_MIN/2` → P0 ②(在格背景之下,只透过默认背景格显示);`z < 0` → P0 ④;`z ≥ 0` → P2 ③([已核实 `kitty_graphics.h:278-281` 三层枚举]) | +| 裁剪 | 先裁到网格矩形(不含内边距),再按源矩形取子图 | +| 采样 | 目标尺寸等于源尺寸时 Nearest,否则 Linear | +| 内存 | `ImageStore` 按字节计 LRU,上限 = 会话的 Kitty 存储上限(host 在 attach 时下发,01 §3.2 规则 2);不再被任何快照引用的图片先释放 `SKImage` | +| 虚拟放置(Unicode 占位符 U+10EEEE) | v1 支持:`render_info` 已给出格位;P2 优先级(M6 后段) | +| 无 Skia | 不画图片 | + +## 10. 帧调度 + +### 10.1 唤醒 + +| 来源 | 路径 | +|---|---| +| actor 发布新快照 | `FrameSlot.Publish`;若 `HostIdle` 标志为真(Interlocked 交换为假,保证只投递一次)→ `Dispatcher.UIThread.Post(Wake, DispatcherPriority.Render)`。R3:`InvalidateVisual()`;R1:`SendHandlerMessage(Wake)`(非 UI 线程没有公开的唤醒渲染循环的方法 [已核实 research/04 §3.0 `IRenderLoop.Wakeup` 为 internal]) | +| 按键、IME、粘贴 | UI 线程在把输入交给 IPC 的同时**预先**发 `Wake`(R1),回显帧不必再绕 UI 线程 | +| OverlayState 变化 | R3 `InvalidateVisual()`;R1 `SendHandlerMessage(Overlay)` 并局部 `Invalidate(rect)` | + +### 10.2 活跃与空闲 + +- R1:handler 活跃时每 tick `RegisterForNextAnimationFrameUpdate`;连续 30 tick 无新 Generation 就停止注册,渲染循环可以休眠(research/04 §5.4)。 +- 光标闪烁**不**靠逐帧轮询(那样空闲时也会 60–120 Hz 唤醒),改为 UI 线程 `DispatcherTimer` 在相位边界触发一次 `Overlay` 消息,handler 只失效光标所在的矩形。这与 research/04 §5.4"由 `CompositionNow` 驱动"不同,理由是空闲 CPU 目标(S10)[推断]。文字闪烁(SGR 5)同理。 +- `RenderHold`(mode 2026)期间 actor 不发布(01 §5.2),渲染方无事可做,自然空闲;超时 1 s 由 actor 处理。 + +### 10.3 洪流时的节流 + +渲染方每消费一个 Generation 就置 `FrameSlot.WantFrame = true`;actor 在批次之间看到 `WantFrame` 才 `CaptureFrame`,并清掉标志;解析完所有待处理输入、准备等待前,若状态是脏的也发布一次(research/04 §5.2)。效果:洪流时快照频率 ≤ 显示刷新率,交互时零延迟。 + +### 10.4 失焦、不可见 + +| 状态 | 行为 | +|---|---| +| 视图不可见(标签在后台、`IsEffectivelyVisible=false`) | 通知 actor `SetVisible(false)`:继续解析,不生成快照(01 §5.2);宿主停止计时器 | +| 窗口最小化 / 遮挡 | 同上(`TopLevel` 状态) | +| 失焦 | 光标改 `unfocusedStyle`,停止闪烁;文字闪烁继续(低频) | +| 重新可见 | actor 立即发布一次全量快照(Rows 全部新对象,行缓存按 RowId 仍可命中) | + +### 10.5 光标闪烁计时 + +`DispatcherTimer`(UI 线程),间隔 = 系统半周期;重置条件见 §9.3。`reduceMotion`、失焦、不闪烁时计时器停止。Linux 超时:最后一次输入后 `cursor-blink-timeout` 秒停止。 + +### 10.6 平滑滚动 + +- 设置 `terminal.rendering.smoothScrolling`,默认 `false`;全局 `reduceMotion`(TCode 已有 `MotionSettings.SystemReduceMotion` / `UserReduceMotion`,[已核实 `App.axaml.cs:55-58`])为真时强制关闭。 +- 实现:actor 请求 `OVERSCAN = {above: 1, below: 1}`([已核实 `render.h:409-418`]),快照带上下各 1 行。触控板精确滚动时,UI 线程把亚行位移 `subRowPx ∈ (−cellH, cellH)` 放进 `OverlayState`,Painter 整体平移网格并多画一行 overscan;累积超过一行时向 actor 发 `ScrollViewport(±1)` 并把 `subRowPx` 减去 `cellH`。 +- 鼠标滚轮按"行"跳动时不做动画(v1);需要时在 M6 后加 100 ms ease-out 的插值,overscan 扩到每次跳动的行数。 +- 关闭时 overscan 为 0(避免额外的快照复制)。 + +### 10.7 滚动条 + +不在 Painter 里画:`TerminalView` 模板里叠一个 Avalonia `ScrollBar`,绑定快照 `ScrollbarState`,自动隐藏;拖动时发 `ScrollViewport(absolute)`。样式由宿主主题提供(Desktop 用 AtomUI 的滚动条样式)。 + +## 11. 主题 + +### 11.1 `Terminal.*` 资源键 + +`TerminalResourceKeys`(`TCode.Terminal.Avalonia`,不依赖 AtomUI)。值可以是 `Color`、`ISolidColorBrush`,或 `TerminalResourceAlias(object Key)`(指向宿主的另一个资源键;目标是 `Color` 时自动转画刷),解析方式与 StreamMark `MarkdownResourceAlias` 相同:从 `TerminalView` 自身开始 `TryFindResource(key, ActualThemeVariant, out value)`,因此能找到窗口 / 主题作用域内的 token,并随明暗切换([已核实 StreamMark `MarkdownResourceAlias.cs`、`AtomUIMarkdownTheme.cs`])。 + +| 键 | 类型 | 说明 | +|---|---|---| +| `Terminal.Background` / `Terminal.Foreground` | 颜色 | 默认背景 / 前景(OSC 10/11 可改) | +| `Terminal.CursorColor` / `Terminal.CursorTextColor` | 颜色,可空 | 缺省:前景 / 背景 | +| `Terminal.SelectionBackground` / `Terminal.SelectionForeground` | 颜色 / 可空 | | +| `Terminal.SearchMatchBackground` / `Terminal.SearchMatchActiveBackground` | 颜色 | | +| `Terminal.LinkForeground` | 颜色,可空 | 悬停链接下划线颜色 | +| `Terminal.Ansi.Black` … `Terminal.Ansi.White`、`Terminal.Ansi.BrightBlack` … `Terminal.Ansi.BrightWhite` | 颜色 | 16 色(索引 0–15) | +| `Terminal.FontFamily` / `Terminal.FontSize` | `FontFamily` / `double`(pt) | 未设置时用 `TerminalViewOptions.Font` | +| `Terminal.Padding` | `Thickness` | 默认 6 | +| `Terminal.ScrollBarThumb` | 画刷 | 交给模板 | +| `Terminal.PreeditBackground` | 颜色,可空 | 缺省默认背景 | +| `Terminal.FocusBorder` | 画刷,可空 | 键盘焦点环(可访问性) | + +256 色中 16–255 按 xterm 公式生成(6×6×6 立方 + 24 级灰),不做资源键。 + +`ActualThemeVariantChanged` → 重建 `RenderTheme`、`ThemeGeneration++`、发给宿主;**同时**必须把新的默认调色板交给 pty host(01 §3.2:`SetColors` 是日志记录,两份核心在同一位置更新,OSC 4/10/11 查询应答才一致)。UI 侧核心**不**在本地抢先改调色板;`SetColors` 记录回来之前(约 1 ms)画面仍用旧调色板,可以接受。`ITerminalViewSession` 目前没有发起这个请求的方法(见文末架构问题)。 + +### 11.2 Desktop 的映射(`TCodeTerminalTheme : Styles`) + +与 `AtomUIMarkdownTheme` 同样写法:`Resources[key] = new TerminalResourceAlias(token)`。 + +| `Terminal.*` | 映射到 | +|---|---| +| `Background` | `TCode.TerminalBg`(新增于 `Palette.axaml`;暗 `#141414` = `TCode.WindowBg`,亮 `#FFFFFF`) | +| `Foreground` | `TCode.TerminalFg`(新增;暗 `#E6E6E6`,亮 `#1F1F1F`) | +| `CursorColor` | `TCode.TextPrimary` | +| `SelectionBackground` | `TCode.AccentSoftBg`(暗 `#2E2A4A` / 亮 `#ECE9FE`) | +| `SearchMatchBackground` | AtomUI `SharedTokenKind.ColorWarningBg` | +| `SearchMatchActiveBackground` | AtomUI `SharedTokenKind.ColorWarningBorder` [推断:token 名按 AtomUI 现有 `ColorWarning*` 系列,实现时核对] | +| `LinkForeground` | AtomUI `SharedTokenKind.ColorLink` | +| `FocusBorder` | `TCode.Accent` | +| `Ansi.*` | `Palette.axaml` 的 ThemeDictionaries 中新增 `Terminal.Ansi.*`(§11.3) | + +用户配置 `terminal.colors.dark` / `colors.light`(research/05 §10.3)覆盖上述映射,由 Desktop 直接写进 `TerminalView.Resources`。 + +### 11.3 默认 ANSI 16 色 + +设计取向:暗色沿用 TCode 已有的文件图标色系(`TCode.File*`),亮色把"白"映射为深灰(VS Code Light 的做法),保证在白底上可读。表中对比度为与默认背景的 WCAG 对比度(脚本计算)。 + +| 索引 | 名称 | 暗(bg `#141414`) | 对比度 | 亮(bg `#FFFFFF`) | 对比度 | +|---|---|---|---|---|---| +| fg | 前景 | `#E6E6E6` | 14.8 | `#1F1F1F` | 16.5 | +| 0 | Black | `#2C2C2E` | 1.3 | `#1F1F1F` | 16.5 | +| 1 | Red | `#E8645A` | 5.6 | `#B3261E` | 6.5 | +| 2 | Green | `#5EBE84` | 8.1 | `#1A7A40` | 5.4 | +| 3 | Yellow | `#E2C05C` | 10.5 | `#8A6100` | 5.5 | +| 4 | Blue | `#5AA2F0` | 6.9 | `#1F66C8` | 5.5 | +| 5 | Magenta | `#A88BF0` | 6.7 | `#7A4FD8` | 5.3 | +| 6 | Cyan | `#4FC1C9` | 8.6 | `#0E7A84` | 5.1 | +| 7 | White | `#C8C8CA` | 11.0 | `#6B6B70` | 5.3 | +| 8 | BrightBlack | `#808086` | 4.7 | `#76767B` | 4.5 | +| 9 | BrightRed | `#FF7B72` | 7.3 | `#D0392E` | 4.9 | +| 10 | BrightGreen | `#7EE0A1` | 11.5 | `#24874C` | 4.5 | +| 11 | BrightYellow | `#F5D97A` | 13.2 | `#956A00` | 4.8 | +| 12 | BrightBlue | `#82BEFF` | 9.4 | `#2A6FD6` | 4.8 | +| 13 | BrightMagenta | `#C4B3FF` | 9.9 | `#8455E6` | 4.8 | +| 14 | BrightCyan | `#7FDDE3` | 11.7 | `#10828D` | 4.6 | +| 15 | BrightWhite | `#F5F5F5` | 16.9 | `#3A3A3C` | 11.4 | + +- 暗色 Black(0)有意接近背景:它主要被用作背景色(状态栏、选中行);作前景时由最小对比度兜底。 +- 亮色所有前景色 ≥ 4.5,所以亮色主题下最小对比度几乎不会触发,颜色保真。 +- 光标:暗 `#F0F0F0` / 亮 `#1A1A1A`(`TCode.TextPrimary`);光标下文字取默认背景。 + +## 12. 性能预算与测量点 + +### 12.1 预算(200×60,缩放 2×) + +| 项 | 预算 | 依据 | +|---|---|---| +| Painter 全量一帧(R1 渲染线程 / R3 渲染线程回放) | p99 ≤ 2 ms | 01 §1、research/04 §7.3 | +| 1–3 脏行(R1 局部失效) | ≤ 0.3 ms | research/04 §7.3 | +| R3 UI 线程录制(缓存全命中) | ≤ 0.5 ms | [推断] | +| R1 UI 线程每帧 | ≤ 0.1 ms(只做输入与唤醒) | research/04 §0.7 | +| 行布局重建:ASCII 快路径 / shaping | ≤ 20 µs / ≤ 150 µs 每行 | [推断] | +| sprite 首次栅格化 | ≤ 50 µs 每个 | [推断] | +| 系统回退查找 | 不在渲染线程;首帧不阻塞 | WezTerm | +| GC | 稳态零分配(行布局复用数组、批次池化) | research/04 §7.3 | +| 空闲 | 10 个终端空闲 + 光标闪烁 < 0.5% CPU | S10 | + +### 12.2 测量点 + +`RenderStats : EventSource`(名称 `TCode-Terminal-Render`,AOT 安全的 EventCounters): + +| 计数器 | 含义 | +|---|---| +| `paint-us`(p50/p95/p99,滑动窗口) | Painter.Paint 耗时 | +| `record-us` | R3 的 `Render` 录制耗时 | +| `rows-rebuilt` / `rows-hit` | 行缓存重建 / 命中 | +| `glyphruns-built`、`shape-cache-hit` | | +| `sprites-rasterized`、`atlas-pages` | | +| `fallback-pending`、`fallback-resolved` | 后台字体查找 | +| `frames-painted`、`generations-skipped` | 渲染方跳过的 Generation(洪流时正常) | +| `host-idle` | 宿主是否在休眠 | +| `contrast-cache-size`、`image-bytes` | | + +输入延迟分段(与 06 §12 共用时间戳,`Stopwatch.GetTimestamp`):`KeyDown` → `SendInput` 返回 → 核心处理到回显所在的输出块 → 包含回显的快照发布 → `OnRender`/`Render` 结束。调试叠加层(`TerminalViewOptions.ShowRenderStats`)在 P4 画这些数字。 + +### 12.3 research/04 §11 spike 落地 + +| # | spike | 实施 | 通过标准 | 里程碑 | +|---|---|---|---|---| +| S-1(04 #1) | 渲染线程上 `FontManager` / `TextShaper` / `GlyphRun` 的线程安全 | `TCode.Terminal.Benchmarks` 里 8 个线程并发构建同一组行布局 10 万次,比对字形与 advance;再在 R2 开关下跑截图测试 | 无异常、结果一致;否则采用"布局工作线程"方案(§4.5 步骤 2) | M3 末 | +| S-2(04 #2) | handler 自轮询开销、休眠与唤醒 | S10(10 终端空闲 10 min)+ S8(Typometer) | 空闲 < 0.5% CPU;唤醒后首帧 ≤ 1 vsync | M6 | +| S-3(04 #3) | 单脏矩形合并的实际收益、`UseRegionDirtyRectClipping` 副作用 | S4(vim 滚动、htop)开关对比,同时测 TCode 其他界面的帧时间 | 打开后终端 p99 降 ≥ 30% 且其他界面无回退才建议 Desktop 打开 | M6 | +| S-4(04 #4) | GlyphRun 在 Metal / ANGLE / GLX / Software 下的成本 | 4 个后端 × 200×60 全量重画,AOT 二进制 | 全部 p99 ≤ 2 ms;Software 后端允许 ≤ 6 ms | M3 | +| S-5 | A8 atlas + `DrawAtlas` colors 着色 | 截图测试比较 `DrawAtlas` 与逐个 `DrawImage` 的像素 | 一致则用 DrawAtlas | M3 | +| S-6(04 #6) | libghostty-vt 每帧 FFI 成本 | 属于 02-core-ghostty;渲染侧只验证"每帧 FFI 次数 ∝ 脏行数" | `CaptureFrame` 全脏 ≤ 50 µs | M1 | +| S-7(04 #7) | Headless 下 `CompositionCustomVisual` 是否进入 `CaptureRenderedFrame` | 最小测试:自定义视觉画一个红色方块后截图 | 能截到 → R1 也跑截图测试;否则 R1 只跑 R2 等价测试 | M6 开始前 | +| S-8 | R3 下 `ICustomDrawOperation` 的 Dispose 时机 | 计数测试:连续 1000 帧后池中对象数稳定 | 无泄漏 | M3 | +| S-9 | `.SF NS Mono` 能否被 Skia 解析 | Headless 截图 + `TryGetGlyphTypeface` 返回的 FamilyName | 失败则默认改为 Menlo | M3 | +| (04 #5) | AOT 下向量路径 | 属于核心,不在本文 | — | — | + +## 13. 截图测试清单 + +基础设施:沿用 `tests/TCode.Desktop.Tests/HeadlessApp`(`UseSkia()` + `UseHeadlessDrawing=false` [已核实 `HeadlessApp.cs:16-19`]),新增 `TerminalTestHost`:用回放实现的 `ITerminalViewSession` 直接喂字节(01 §6.5),`Step` 后 `CaptureRenderedFrame`。每个用例 = **像素探针断言**(`PixelProbe`:取指定设备像素的颜色,断言等于 / 接近期望)+ 保存 PNG 供人工查看。只有 sprite 类用例额外保留金样图(`tests/TCode.Desktop.Tests/Terminal/golden/`,容差 0)。R3 必跑;S-7 通过后 R1 再跑一遍并比较两者像素完全一致。 + +| # | 用例 | 关键断言 | +|---|---|---| +| T1 | ASCII 网格对齐,缩放 1.0 / 1.25 / 1.5 / 2.0 | 每列字形墨迹落在 `[col·cellW, (col+1)·cellW)`;cellW/H 为整数像素 | +| T2 | 16 / 256 / truecolor 前景与背景 | 背景探针颜色精确;RLE 相邻同色无缝 | +| T3 | bold / italic / faint / inverse / invisible / bold-is-bright | faint 颜色 = 50% 混合;invisible 处等于背景 | +| T4 | 5 种下划线 + SGR 58 颜色 + 删除线 + 上划线 | 下划线行像素在 `underlineY` 处;curly 跨格相位连续 | +| T5 | Box drawing 拼接(`┼─│╋━┃╭╮╰╯` 组成网格) | 相邻格连线像素无断点、无重叠加深 | +| T6 | Block elements(▀▄█▌▐ 与 1/8 块、░▒▓) | 半块在奇数 cellH 下无缝;阴影 alpha | +| T7 | Braille 全集抽样 | 点位置与点宽 | +| T8 | Powerline E0B0–E0B3 与背景拼接 | 三角形边缘与相邻背景无缝隙 | +| T9 | Sextant / octant / wedge(M4) | 子块边界 | +| T10 | 宽字符 CJK + 回退字体 | 字形在 2 格内;回退前先画占位、`FallbackEpoch` 后出现字形 | +| T11 | emoji:单个、VS16、ZWJ 家庭、旗帜、肤色(兼容模式与 2027) | 宽度来自核心;字形居中于 2 格 | +| T12 | 组合字符(é = e + U+0301、天城文) | 附着在前一格 | +| T13 | 光标 block / bar / underline / hollow、失焦、宽字符上 | 形状矩形;block 下文字色为 CursorText | +| T14 | 光标闪烁相位(手动时钟) | 亮 / 灭两帧 | +| T15 | 选区:线性、矩形、跨软换行 | 覆盖段换色,未覆盖段原色(裁剪着色正确) | +| T16 | 搜索高亮:普通与当前匹配 | 颜色优先级 | +| T17 | 预编辑:中间、靠右溢出、宽于整行 | 位置、下划线、插入符 | +| T18 | 最小对比度:亮色主题下黄色字 | 调整后对比度 ≥ 4.5;box drawing 不被调整 | +| T19 | 明暗主题切换 | 背景与 16 色随主题;行缓存未重建(计数器) | +| T20 | Kitty 图片三个 z 层 | 遮挡顺序;网格外被裁掉 | +| T21 | OSC 8 / URL 悬停下划线(single → double) | | +| T22 | 连字开关(含 `->`、`=>`)与光标处断开 | 关闭时逐字符;开启时光标格单独 | +| T23 | 缩放变化(1.0 → 2.0)重建 | 度量与 atlas 重建,无旧尺寸残留 | +| T24 | 平滑滚动 `subRowPx` | 网格整体平移,overscan 行可见 | +| T25 | 无 Skia(`UseHeadlessDrawing=true`)退化 | 背景与文字仍正确,不抛异常 | +| T26 | 文字闪烁(SGR 5)与 `reduceMotion` | | +| T27 | 1000 帧压力:行缓存与批次池不增长 | 计数器稳定(S-8) | + +--- + +## 发现的架构问题 / 待决问题(供修订 01) + +1. `FrameSnapshot` 缺少渲染必需字段:搜索高亮、超链接 id → URI 表、overscan 行、OSC 22 指针形状(§2)。 +2. 01 §3.2 规则 1 已把 `SetColors`、`Clear` 列为日志记录,但 `ITerminalViewSession` 仍没有发起这两种请求的方法(例如 `RequestSetColors(TerminalPalette)`、`RequestClear(ClearKind)`);主题切换(§11.1)和 ⌘K(06 §2)都需要。 +3. research/04 建议光标闪烁由渲染线程 `CompositionNow` 驱动;本文改为 UI 线程按相位唤醒,以满足空闲 CPU 目标(§10.2),需确认。 diff --git a/docs/terminal/06-input.md b/docs/terminal/06-input.md new file mode 100644 index 0000000..0328943 --- /dev/null +++ b/docs/terminal/06-input.md @@ -0,0 +1,669 @@ +# TCode 终端:输入(键盘、IME、鼠标、选区、剪贴板、粘贴、链接、搜索、可访问性) + +> 状态:设计稿 v1(2026-10-16)。上游约束见 [01-architecture.md](01-architecture.md)(D6 快捷键、§3.2 状态一致性、§5.2 线程、§6.5 TerminalView);依据见 [research/05](research/05-platform-behavior-config.md) §4–§6、§9,[research/03](research/03-vt-protocol.md) §4–§6,[research/04](research/04-rendering-performance.md) §6。渲染侧见 [05-rendering.md](05-rendering.md)。 +> +> **来源标注**:**[已核实]** = 在只读源码中读到(Avalonia tag `12.1.3`/`8eeda4f`,libghostty-vt `b3dc3d7`,Windows Terminal `44b440b`,TCode `dfd3657`),给出 `文件:行`;**[推断]** = 设计决定或推导,未经运行验证。 + +## 0. 结论摘要 + +| 主题 | 决定 | +|---|---| +| 按键路由 | `MainWindow` 的 Tunnel 处理器在焦点位于终端时**不处理**按键;`TerminalView` 在自己的 KeyDown 里抛出 `KeyRouting`,由 Desktop 的 `TerminalKeyRouter` 按平台键表决定"归 TCode 执行"还是"归 shell 编码" | +| 键表 | macOS:⌘ 组合全部归 TCode,⌘K 清屏;Win/Linux:Ctrl+字母交给 shell,TCode 用 Ctrl+Shift;命令面板全平台增加 ⌘⇧P / Ctrl+Shift+P(D6) | +| KeyDown / TextInput | 可打印文本(含 AltGr、死键、Option 字符、IME 提交)走 TextInput;其余在 KeyDown 编码并设 `Handled`。KeyDown 一旦 `Handled`,Win32 与 macOS 都不会再产生文本 [已核实] | +| 编码 | UI 线程持有 `GhosttyKeyEncoder` / `GhosttyMouseEncoder`,选项来自 actor 单独发布的 `InputModes`(不受 mode 2026 扣快照影响),输入路径零线程跳转 | +| win32-input-mode 9001 | libghostty-vt **不支持**(模式表里没有 9001 [已核实])→ 需要 overlay 补丁登记该模式;编码由 TCode 自己实现,Win32 原始键信息用 `Win32Properties.AddWndProcHookCallback` 取得 | +| IME | `TextInputMethodClient`(`SupportsPreedit=true`),预编辑自绘,列宽用 `ghostty_unicode_grapheme_width` 预测,`CursorRectangle` 跟随预编辑插入符 | +| 鼠标 / 选区 | 上报编码在 UI 线程;选区手势、自动滚动交给 actor 上的 libghostty selection gesture 状态机(15 ms tick,与 Ghostty 相同) | +| 剪贴板 / 粘贴 | 复制默认纯文本;粘贴走 `ghostty_terminal_paste`,`GHOSTTY_REJECTED` → 确认框 → `allow_unsafe` 重试 | +| Linux PRIMARY | Avalonia 12.1.3 没有 [已核实],先做进程内 PRIMARY,接口留给 12.2 替换 | +| 可访问性 v1 | `TerminalAutomationPeer`(`IValueProvider` + `IScrollProvider`)、LiveSetting 播报、可访问缓冲视图(⌥F2 / Alt+F2) | + +## 1. 模块 + +| 类型 | 项目 | 职责 | +|---|---|---| +| `TerminalKeyRouter` / `TerminalKeymap` | TCode.Desktop | 平台键表、`skipShell`、命令执行(§2) | +| `TerminalInputController` | TCode.Terminal.Avalonia | KeyDown / KeyUp / TextInput 决策、修饰键左右跟踪、吞掉被拦截按键的释放事件(§3) | +| `KeyInputMapper` | 同上 | Avalonia `KeyEventArgs` → `KeyInput`(§3.4) | +| `PlatformKeyboard`(Win32 / macOS / X11 三个实现) | 同上 | 取"去掉 Option / Alt 后的字符"、Win32 VK / 扫描码 / 控制键状态(§3.5、§3.6) | +| `Win32InputModeEncoder` | TCode.Terminal.Core | `CSI Vk;Sc;Uc;Kd;Cs;Rc _` 编码(§3.7) | +| `TerminalImeClient` | TCode.Terminal.Avalonia | IME 客户端(§4) | +| `MouseController` / `SelectionController` | 同上 | 上报、滚轮、手势、右键 / 中键(§5) | +| `ITerminalClipboard` / `PrimarySelection` | 同上 | 剪贴板抽象、进程内 PRIMARY(§6) | +| `PasteController` / `PathQuoter` | 同上 | 粘贴流程、拖放路径转义(§7) | +| `LinkDetector` | 同上 | URL、文件路径、OSC 8 识别与悬停(§8) | +| `SearchController` + `SearchBar` | 同上 | 搜索 UI 与工作线程 tick(§9) | +| `TerminalAutomationPeer` / `OutputAnnouncer` / `AccessibleBufferView` | 同上 | 可访问性(§10) | + +## 2. 按键路由 + +### 2.1 Avalonia 中的事件顺序(已核实) + +1. Window 上的 Tunnel 处理器先收到 KeyDown:TCode 的 `MainWindow` 在这里处理全局快捷键 [已核实 `MainWindow.axaml.cs:159` `AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel)`;`:463-541`]。 +2. 焦点元素(`TerminalView`)的 KeyDown。 +3. 若 KeyDown 未 `Handled`,平台再产生 TextInput: + - Win32:`WM_KEYDOWN` 被 Handled 后 `_ignoreWmChar = true`,随后的 `WM_CHAR` 被丢弃;`< 0x20` 的 `WM_CHAR` 一律丢弃;IME 组合中不产生 TextInput [已核实 `WindowImpl.AppWndProc.cs:261-275, 975-988`]。`WM_SYSCHAR`(Alt+字母)**没有**处理分支,所以 Alt+字母永远没有 TextInput [已核实:该文件无 `WM_SYSCHAR` 分支]。 + - macOS:KeyDown 先于输入法上下文触发;`handled` 为真时直接返回,不交给 `NSTextInputContext`,也不产生文本 [已核实 `AvnView.mm:798-814`]。组合进行中或"可能开始组合"的按键,`Key` 被替换为 `ImeProcessed`,但 `PhysicalKey` / `KeySymbol` 保留真实值 [已核实 `AvnView.mm:772-796`]。 + +结论:**谁在 KeyDown 里设 `Handled`,谁就要负责这个键的全部输出**;不设就会收到 TextInput。 + +### 2.2 方案 + +``` +Window Tunnel:MainWindow.OnWindowKeyDown + ├─ 命令面板 / 设置页打开时的既有逻辑(焦点不在终端,不受影响) + └─ 焦点在 TerminalView 内(e.Source 是 TerminalView 或其后代以外的终端元素)→ 直接 return,不处理任何键 +TerminalView(自身的 Tunnel 处理器,保证先于模板内子元素) + ├─ Key == ImeProcessed 或预编辑非空 → 不处理(交给 IME) + ├─ 修饰键本身 → 记录左右;kitty flag 8 时编码;return + ├─ 抛出 KeyRouting(TerminalKeyRoutingEventArgs) + │ Desktop.TerminalKeyRouter:Chord → 命令 id → 是否在 skipShell 集合 → 命令是否"可执行"(performable) + │ → 执行,并设 args.Route = TCode + ├─ Route == TCode → e.Handled = true;记住 PhysicalKey 以吞掉其 KeyUp(§2.6) + └─ 否则 → TerminalInputController 编码(§3) +``` + +为什么不在 Window 的 Tunnel 里直接查表:Window 不知道终端当前能否执行某个命令(例如"有选区才复制"、"备用屏下 ⌘K 不清屏"),而 `TerminalView` 又不应该知道 TCode 的命令。`KeyRouting` 事件让两者各管一半,和 Ghostty 的 `performable:`(只有动作能执行时才吞键)同义 [已核实 research/05 §4.6]。 + +```csharp +public sealed class TerminalKeyRoutingEventArgs : EventArgs +{ + public KeyEventArgs Key { get; } + public KeyChord Chord { get; } // 规范化后的组合:物理修饰键 + Key/PhysicalKey + public ITerminalCommandContext Terminal { get; } // HasSelection、IsAlternateScreen、HasShellIntegration、IsSearchOpen…… + public TerminalKeyRoute Route { get; set; } = TerminalKeyRoute.Shell; // Shell | TCode +} +``` + +命令面板打开时焦点在面板的搜索框里,不在终端,所以面板的 ↑↓↵ 处理不受影响。 + +### 2.3 macOS 键表(终端获得焦点时) + +规则:所有 ⌘ 组合归 TCode;Ctrl、Option、Fn、无修饰交给 shell(research/05 §4.6)。`⌘Q / ⌘H / ⌘M` 由系统菜单处理,不经过视图 [推断]。 + +| 按键 | 命令 id | 行为 | +|---|---|---| +| ⌘C | `terminal.copy` | 有选区时复制;无选区时吞掉(不发给 shell,与 Terminal.app 一致) | +| ⌘V | `terminal.paste` | 粘贴剪贴板(§7) | +| ⌘A | `terminal.selectAll` | | +| ⌘F | `terminal.find` | 打开搜索栏,选区为单行时预填 | +| ⌘G / ⇧⌘G | `terminal.findNext` / `terminal.findPrevious` | 搜索栏打开时 | +| **⌘K** | `terminal.clear` | 清屏与回滚(§2.5);备用屏中不可执行 → 交给 shell | +| ⌘⇧P | `workbench.commandPalette` | 新增;终端外 ⌘K 仍打开面板 | +| ⌘T | `terminal.new` | 在当前终端组新开标签 | +| ⌘W | `terminal.closePane` | 关闭当前分屏,最后一个分屏时关闭标签(运行中程序按 `confirmClose` 确认) | +| ⌘D / ⇧⌘D | `terminal.splitRight` / `terminal.splitDown` | iTerm2 惯例 | +| ⌘[ / ⌘] | `terminal.focusPreviousPane` / `focusNextPane` | iTerm2 | +| ⌥⌘←↑→↓ | `terminal.focusPane.{left,up,right,down}` | iTerm2 | +| ⌘1…⌘9 | `terminal.selectTab.N` | 终端组内第 N 个标签(⌘9 = 最后一个),Terminal.app 惯例 | +| ⇧⌘[ / ⇧⌘] | `terminal.previousTab` / `nextTab` | | +| ⌘+ / ⌘- / ⌘0 | `terminal.fontSize.{increase,decrease,reset}` | | +| ⌘↑ / ⌘↓ | `terminal.scrollToPreviousPrompt` / `NextPrompt` | 需要 OSC 133;没有时按"上 / 下一页" | +| ⌘Home / ⌘End、⌘PgUp / ⌘PgDn | `terminal.scrollToTop/Bottom`、`scrollPageUp/Down` | | +| ⌘← / ⌘→ / ⌘⌫ | `terminal.sendText`:`^A` / `^E` / `^U` | Ghostty / VS Code 同样映射(research/05 §4.2);kitty 键盘标志非 0 时不映射,原样编码 | +| ⌥← / ⌥→ | 发送 `ESC b` / `ESC f` | 仅 `macOptionAsMeta=none` 且 kitty 标志为 0;Terminal.app / Ghostty 同 | +| ⌥F2 | `terminal.accessibleView` | §10.3 | +| ⌘N / ⌘B / ⌘, / ⇧⌘N / ⇧⌘E | TCode 全局(新会话、侧栏、设置、新项目、资源管理器) | 与现有一致 | +| Ctrl+任意、⌥+任意(上面未列)、F1–F12、无修饰 | shell | | + +### 2.4 Windows / Linux 键表 + +规则:Ctrl+Shift 组合与登记的 F 键归 TCode;单独的 Ctrl+字母、Alt+字母交给 shell;AltGr 产生的字符永远是文本(research/05 §4.6)。 + +| 按键 | 命令 id | Windows | Linux | +|---|---|---|---| +| Ctrl+Shift+C、Ctrl+Insert | `terminal.copy` | ✓ | ✓ | +| **Ctrl+C** | `terminal.copyIfSelection` | `keyboard.ctrlCCopies=system` 默认开:有选区时复制并清除选区,否则发 `^C`(WT 新装默认 [已核实 research/05 §4.3]) | 默认关(发 `^C`) | +| Ctrl+Shift+V | `terminal.paste` | ✓ | ✓ | +| Ctrl+V | `terminal.paste` | ✓(WT 新装默认) | 交给 shell(`^V` literal-next) | +| Shift+Insert | 粘贴 | 剪贴板 | PRIMARY(VTE、VS Code) | +| Ctrl+Shift+Insert | 粘贴剪贴板 | — | ✓ | +| Ctrl+Shift+A | `terminal.selectAll` | ✓ | ✓ | +| Ctrl+Shift+F | `terminal.find` | ✓ | ✓ | +| F3 / Shift+F3、Enter / Shift+Enter(搜索栏内) | 下一个 / 上一个 | ✓ | ✓ | +| Ctrl+Shift+K | `terminal.clear` | ✓(WT) | ✓ | +| Ctrl+Shift+P | `workbench.commandPalette` | 新增 | 新增 | +| Ctrl+Shift+T / Ctrl+Shift+W | 新标签 / 关闭分屏 | ✓ | ✓ | +| Ctrl+Shift+D / Ctrl+Shift+Alt+D | 向右 / 向下分屏 | ✓ | ✓ | +| Alt+←↑→↓ | 切换分屏焦点 | ✗(交给 shell:readline 的 Alt+← 等) | ✗ | +| Ctrl+Shift+Alt+←↑→↓ | 切换分屏焦点 | ✓ | ✓ | +| Ctrl+Tab / Ctrl+Shift+Tab | 下一个 / 上一个标签 | ✓(WT) | ✓ | +| Ctrl+Alt+1…9 | 选择标签 | ✓(WT);AltGr 字符优先(德语布局 AltGr+7 = `{`,不会触发) | — | +| Alt+1…9 | 选择标签 | — | ✓(GNOME Terminal / Ghostty) | +| Ctrl+= / Ctrl+- / Ctrl+0 | 字号 | ✓(WT / VTE) | ✓ | +| Shift+PgUp / Shift+PgDn | 回滚翻页 | ✓ | ✓ | +| Ctrl+Shift+↑ / ↓ | 回滚一行 | ✓(WT) | ✓ | +| Ctrl+Shift+Home / End | 回滚到顶 / 底 | ✓(WT) | ✓ | +| Ctrl+Shift+PgUp / PgDn | 上 / 下一个 prompt | ✓(Ghostty) | ✓ | +| Alt+F2 | `terminal.accessibleView` | ✓ | ✓ | +| Ctrl+, / Ctrl+Shift+N / Ctrl+Shift+E | TCode 全局 | ✓ | ✓ | +| **Ctrl+K / Ctrl+N / Ctrl+B** | TCode 全局键 | **交给 shell**(readline kill-line、下一条历史、光标左移 / tmux 前缀) | 同左 | + +### 2.5 ⌘K / Ctrl+Shift+K 清屏 + +按 01 §3.2,清屏是日志记录 `Clear`,不能在 UI 侧核心本地执行: + +1. `terminal.clear` 的可执行条件:当前不在备用屏(快照 `Modes.AlternateScreen`);否则返回"不可执行",按键交给 shell(Ghostty 同样规则:备用屏下清屏会破坏程序对光标的认知 [已核实 Ghostty `Termio.zig:597-606`、`Surface.zig:5322-5336`])。 +2. 视图调用会话的"请求清屏"(`ITerminalViewSession` 需补方法,见文末),host 在日志中追加 `Clear{history:true}`,镜像核心与 UI 侧核心在同一位置执行。 +3. 语义(Ghostty `clearScreen`):清除回滚;光标不在 prompt 时只擦除光标行以上;在 prompt 时整屏清除并由 host 向 PTY 写一个 FF(`0x0C`)让 shell 重画 prompt;同时删除 Kitty 图片。具体实现属于 [04-pty-host.md](04-pty-host.md)。 +4. 视图清除本地选区与搜索结果(这两者只在 UI 侧,01 §3.2 规则 4)。 + +### 2.6 被拦截按键的释放事件 + +kitty 键盘协议开启 flag 2(报告事件类型)时,程序会收到释放事件。TCode 消费的按键(如 ⌘C),其释放事件也必须拦截,否则程序会收到"只有释放没有按下"的事件(research/03 §4.4 末尾的教训,源自 Codex 注释)。`TerminalInputController` 维护 `HashSet _swallowed`:路由为 TCode 时加入;对应 KeyUp 到达时移除并 `Handled`,不编码。焦点离开时清空。 + +### 2.7 设置项语义 + +| 设置 | 语义 | +|---|---| +| `terminal.keyboard.skipShell: string[]` | 修改"终端获得焦点时仍归 TCode 的命令"集合。默认集合 = §2.3 / §2.4 表中归 TCode 的全部命令。`"-terminal.clear"` 表示从集合移除(⌘K 交给 shell);`"workbench.toggleSidebar"` 表示加入(Win/Linux 上 Ctrl+B 也归 TCode)。语义同 VS Code `commandsToSkipShell`([已核实 research/05 §4.6]) | +| `terminal.keyboard.sendKeybindingsToShell: bool`(默认 false) | 为 true 时,默认集合缩小为"最小集":命令面板、复制、粘贴、⌥F2/Alt+F2;其余全部交给 shell。用户在 `skipShell` 里**显式加入**的命令仍归 TCode | +| 优先级 | 用户 `skipShell` 的显式加入 / 移除 > `sendKeybindingsToShell` > 平台默认 | +| 不支持 | 多键序列(chord prefix),避免 VS Code `allowChords` 的前缀键吞键问题 | + +`TerminalKeymap` 是 Desktop 的数据表(平台 × 命令 → `KeyChord[]`),同一张表也用于命令面板里显示快捷键。 + +## 3. KeyDown 与 TextInput 的分工 + +### 3.1 判定算法 + +```text +OnKeyDown(e): + 1. e.Key == ImeProcessed 或 preedit 非空 → 返回(不 Handled) + 2. 更新修饰键左右状态;若按的是修饰键本身: + kitty flags & 8 → 编码按下事件,Handled;否则返回 + 3. KeyRouting → Route == TCode → Handled,加入 _swallowed,返回 + 4. 计算 printable = KeySymbol 非空 且 首字符 ≥ 0x20 且 ≠ 0x7F + 5. 以下情况走"文本路径"(不 Handled,等 TextInput): + a. Windows:Ctrl 与 Alt 同时按下 且 printable 且 altGrAliasing (AltGr) + b. macOS:Option 按下、无 Ctrl/⌘、printable、且该侧 Option 不作 Meta (⌥ 字符、死键) + c. 无 Ctrl / Alt(Meta) / Super、printable、kitty flags 不含 8 (普通字符、Shift 字符、死键) + d. 9001 模式下的可打印键:见 §3.7(先记下 VK/扫描码,等 TextInput) + 6. 其余:KeyInput ← 映射(§3.4);n = encoder.Encode(KeyInput); + n > 0 → SendInput,Handled;n == 0(例如无修饰的修饰键)→ 不 Handled + 7. 发出输入前:清除选区(可配置),视口滚到底(有输入即回到底部,同所有终端) + +OnKeyUp(e): + _swallowed 中 → 移除,Handled,返回 + kitty flags & 2 → 编码释放事件(Enter/Tab/Backspace 只在 flag 8 时报释放,由编码器处理) + 9001 → 编码释放事件(§3.7) + +OnTextInput(e): + 9001 → 每个 UTF-16 码元编码为按下事件(§3.7) + 否则 → UTF-8 直接 SendInput(IME 提交不加 bracketed paste,research/03 §4.1) +``` + +关键点: + +- 第 5 步保证死键、AltGr、Option 字符、IME 都由平台合成字符,TCode 不自己合成(research/05 §4.4)。 +- **Backspace、Enter、Tab、Esc 一律在 KeyDown 编码并 Handled**:Win32 上 Ctrl+Backspace 会产生 `WM_CHAR 0x7F`(≥0x20 不被过滤),不 Handled 会重复发送;macOS 上这些键的 KeySymbol 是控制字符 [已核实 `AvnView.mm:692-698` 注释],第 4 步已排除。 +- Windows Alt+字母:`WM_SYSKEYDOWN` 的 KeySymbol 来自 `MapVirtualKey(MAPVK_VK_TO_CHAR)`([已核实 `WindowImpl.AppWndProc.cs:1506-1511`]),字母为大写、不含 Shift 效果,并且没有 TextInput。所以 Alt 组合必须在 KeyDown 编码,基础字符由 §3.5 求出。 + +### 3.2 Backspace / Delete 与常用键 + +| 键 | 无修饰 | 说明(编码由 libghostty 编码器完成) | +|---|---|---| +| Backspace | `0x7F` | DECBKM(mode 67)置位时发 `0x08`(编码器选项 `BACKARROW_KEY_MODE` [已核实 `encoder.h:114`]);`keyboard.backspace=ctrlH` 时反转 | +| Ctrl+Backspace | `0x08` | | +| Alt+Backspace | `ESC 0x7F` | macOS 需 Option 作 Meta,或 ⌥⌫ 映射(§2.3 未列,按 Option 字符路径:⌥⌫ 在 US 布局不产生字符 → 编码为 Alt+Backspace) | +| Delete(含 macOS fn+⌫) | `CSI 3~` | | +| Enter | `CR` | Alt+Enter `ESC CR`;kitty disambiguate 下 Shift+Enter `CSI 13;2u`(Claude Code / Codex 靠它,research/03 §4.4) | +| Tab / Shift+Tab | `HT` / `CSI Z` | | +| Esc | `ESC` | kitty flag 1:`CSI 27u` | +| 方向键 / Home / End | `CSI A..D/H/F`;DECCKM 下 `SS3` | 带修饰一律 CSI(research/03 §4.1) | + +### 3.3 macOS Option 作为 Meta + +- 设置 `keyboard.macOptionAsMeta: none | left | right | both`(默认 `none`,research/05 §10.3)。 +- 左右区分:Avalonia 的 `KeyModifiers` 不分左右,但修饰键本身会产生 KeyDown / KeyUp(macOS 由 `flagsChanged` 合成 [已核实 `AvnView.mm:605-659`]),`PhysicalKey` 为 `AltLeft` / `AltRight`。`TerminalInputController` 自己维护四个修饰键的左右按下状态,填入 `GhosttyMods` 的 `*_SIDE` 位 [已核实 `event.h:46-76`],并设置编码器 `MACOS_OPTION_AS_ALT`(`FALSE/TRUE/LEFT/RIGHT` [已核实 `encoder.h:62-76`])。 +- Option 作 Meta 时,需要"不带 Option 的字符"作为 `utf8`(编码器要求传入未经 Ctrl/Meta 变换的字符 [已核实 `event.h:430-447`])。Avalonia 的 `KeySymbol` 已经包含 Option 效果(`UCKeyTranslate` 带 `optionKey` [已核实 `KeyTransform.mm:421-439`]),所以由 `PlatformKeyboard.Mac` 用 `PhysicalKey → kVK 键码`反查表 + 自己的 `UCKeyTranslate`(`LibraryImport` Carbon:`TISCopyCurrentKeyboardLayoutInputSource`、`TISGetInputSourceProperty`、`UCKeyTranslate`、`LMGetKbdType`)去掉 Option 重新求字符 [推断:AOT 安全的 P/Invoke]。 + +### 3.4 `KeyInput` → libghostty 键编码器 + +`KeyInput`(TCode.Terminal.Core,01 §6.1 `EncodeKey(in KeyInput)` 的参数): + +| 字段 | 类型 | 来源 | 写入 libghostty | +|---|---|---|---| +| `Action` | Press / Repeat / Release | KeyDown / KeyUp;Repeat = 该 `PhysicalKey` 已在按下集合中(Avalonia `KeyEventArgs` 没有重复标志 [已核实 `KeyEventArgs.cs:8-76`]);Win32 用 lParam bit 30(§3.6) | `ghostty_key_event_set_action` | +| `Key` | `TerminalKey`(W3C code 枚举) | `KeyEventArgs.PhysicalKey`。Avalonia 的 `PhysicalKey` 与 `GhosttyKey` 都基于 W3C UI Events code,按名一一映射 [已核实 `PhysicalKey.cs`、`event.h:78-273`] | `set_key` | +| `Mods` | Shift/Ctrl/Alt/Super/CapsLock/NumLock + 左右位 | `KeyModifiers` + 自己跟踪的左右状态;`Meta`(⌘/Win) → Super | `set_mods` | +| `ConsumedMods` | 同上 | 产生 `Utf8` 时被布局"用掉"的修饰键:Shift(字符与不带 Shift 的字符不同)、Option(未作 Meta 且产生了字符) | `set_consumed_mods` | +| `Utf8` | 字符串 | §3.5 的"基础字符":当前布局下含 Shift、不含 Ctrl/Alt/Meta 的字符;控制字符与 macOS 功能键 PUA 一律传空 [已核实 `event.h:430-447`] | `set_utf8` | +| `UnshiftedCodepoint` | uint | 同一物理键不按 Shift 的字符(kitty key code 用未 Shift 的码点,research/03 §4.4) | `set_unshifted_codepoint` | +| `Composing` | bool | 预编辑非空 | `set_composing` | +| Win32 附加:`VirtualKey`、`ScanCode`、`Extended`、`ControlKeyState`、`RepeatCount` | | §3.6 | 只给 `Win32InputModeEncoder` | + +编码器选项(UI 线程持有一个 `GhosttyKeyEncoder`,每次 `InputModes` 变化时 `setopt`,不调用 `setopt_from_terminal`——那需要终端句柄,只能在 actor 线程)[已核实 `encoder.h:89-114, 167-186`]: + +| 选项 | 来自 `InputModes` | +|---|---| +| `CURSOR_KEY_APPLICATION` | DECCKM(mode 1) | +| `KEYPAD_KEY_APPLICATION` | DECKPAM(mode 66 / `ESC =`) | +| `IGNORE_KEYPAD_WITH_NUMLOCK` | mode 1035(默认置位) | +| `ALT_ESC_PREFIX` | mode 1036(默认置位) | +| `MODIFY_OTHER_KEYS_STATE_2` | `CSI > 4 ; 2 m` | +| `KITTY_FLAGS` | 当前屏幕 kitty 标志栈顶 | +| `MACOS_OPTION_AS_ALT` | 设置项(不来自终端) | +| `BACKARROW_KEY_MODE` | mode 67 | + +`InputModes` 由视图核心 actor 在每批 `vt_write` 后比较、变化才发布(`Volatile` 交换),**不随 `FrameSnapshot` 发布**:快照在 mode 2026 期间最多被扣 1 s、视图不可见时不生成(01 §5.2),用快照里的 `Modes` 编码会读到过期模式。字段:DECCKM、DECKPAM、1035、1036、modifyOtherKeys、kitty 标志、67、鼠标跟踪模式与编码、1007、备用屏、2004、1004、2027、9001、`CursorAtPrompt`(`GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT` [已核实 `terminal.h:2934`])。 + +### 3.5 求"基础字符" + +| 平台 | `KeySymbol` 的性质 | 取 `Utf8` / `UnshiftedCodepoint` | +|---|---|---| +| macOS | 含 Shift / CapsLock / Option,不含 Ctrl / ⌘ [已核实 `KeyTransform.mm:421-427`] | 无 Option:直接用;Option 作 Meta:`UCKeyTranslate` 去掉 Option(§3.3);Unshifted:去掉 Shift 再译一次 | +| Windows `WM_KEYDOWN` | `ToUnicodeEx` + 当前键盘状态;Ctrl 组合产生控制字符时被过滤为 null [已核实 `KeyInterop.cs:520-548`] | 自己调用 `ToUnicodeEx(vk, sc, 只含 Shift/CapsLock 的状态, flags = 0x4 不改变键盘状态)` [推断:Avalonia 同样用 0x4,`KeyInterop.cs:523`] | +| Windows `WM_SYSKEYDOWN` | `MapVirtualKey` 结果:无 Shift、字母大写 [已核实 `KeyInterop.cs:503-512`] | 同上(用 `ToUnicodeEx` 而不是 `MapVirtualKey`) | +| Linux X11 | 来自 keysym;Ctrl 下的行为未核实 [推断] | `KeySymbol` 可打印则用;否则按 `Key`(布局相关虚拟键)取字母 / 数字;再不行用 US 布局表 | + +### 3.6 Win32 原始按键信息 + +Avalonia 不在 `KeyEventArgs` 中暴露 VK、扫描码和重复次数。TerminalView 附加到窗口时调用 `Win32Properties.AddWndProcHookCallback(topLevel, hook)`([已核实 `Win32Properties.cs:41,83`;钩子在 Avalonia 的 `WndProc` 之前同步调用 `WindowImpl.cs:1043-1054`]),在钩子里记录最近一条 `WM_KEYDOWN / WM_SYSKEYDOWN / WM_KEYUP / WM_SYSKEYUP` 的 `wParam`(VK)与 `lParam`(扫描码 bits 16–23、扩展位 24、重复次数 bits 0–15、之前状态 bit 30),不设 `handled`。随后同一线程上触发的 KeyDown 读取这条记录。`ControlKeyState` 用 `GetKeyState` 求(消息处理期间反映该消息时刻的状态): + +| 位 | 值 | 来源 | +|---|---|---| +| RIGHT_ALT_PRESSED | 0x0001 | `GetKeyState(VK_RMENU)` | +| LEFT_ALT_PRESSED | 0x0002 | `VK_LMENU` | +| RIGHT_CTRL_PRESSED | 0x0004 | `VK_RCONTROL` | +| LEFT_CTRL_PRESSED | 0x0008 | `VK_LCONTROL` | +| SHIFT_PRESSED | 0x0010 | `VK_SHIFT` | +| NUMLOCK_ON | 0x0020 | `VK_NUMLOCK` 切换位 | +| SCROLLLOCK_ON | 0x0040 | `VK_SCROLL` 切换位 | +| CAPSLOCK_ON | 0x0080 | `VK_CAPITAL` 切换位 | +| ENHANCED_KEY | 0x0100 | lParam bit 24 | + +钩子只在 Windows 注册;其他平台此路径为空。 + +### 3.7 win32-input-mode(9001) + +**libghostty-vt 现状**:模式表里没有 9001 [已核实 `src/terminal/modes.zig:362-416`],未知 CSI 也不会交给 `UNKNOWN_SEQUENCE` 回调(只报 APC / OSC [已核实 `terminal.h:616-636`])。所以 TCode 无法从核心得知 ConPTY 发来的 `CSI ? 9001 h`。方案:在 `native/ghostty` 的 overlay 补丁里给模式表加一项 `win32_input = 9001`(默认关,两份核心用同一构建,DECRQM 应答随之正确)。补丁打不上时的退路:UI 侧 actor 在 `vt_write` 前对字节流做一个只识别 `CSI ? 9001 h/l` 的小状态机 [推断]。这是对 01 / 02 的补充(见文末)。 + +**优先级**:WT 在 kitty 标志非 0 时优先 kitty,否则 9001 优先 [已核实 WT `terminalInput.cpp:224-234`]。ConPTY 当前强制关闭 KKP 转换(research/03 §4.5),所以 Windows 本地会话里实际总是 9001。TCode 采用 WT 的顺序。 + +**编码**:`CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`,省略值取默认(Vk/Sc/Uc/Kd/Cs = 0,Rc = 1)(research/03 §4.5)。流程照搬 WT([已核实 WT `Terminal.cpp:604-760`]): + +| 事件 | 处理 | +|---|---| +| 修饰键按下 / 释放 | 立即编码(Kd=1/0),Uc=0 | +| 非字符键(方向、F 键、Enter、Tab、Esc、Backspace,或 Alt 单独按下的组合) | 立即编码按下,Uc = `ToUnicodeEx` 结果(可能为 0),`Handled` | +| 产生字符的键按下(含死键、AltGr) | **不**编码、不 Handled;记下 `(Vk, Sc, Cs)` 为"待配对" | +| TextInput(平台已合成的字符,含死键组合结果) | 每个 UTF-16 码元编码一个按下事件:Vk / Sc 取"待配对"记录(没有时 Vk 取 `VkKeyScanEx(ch)` 的低字节,Sc 由 `MapVirtualKey` 求;IME 提交没有对应按键时 Vk=`VK_PACKET`(0xE7),Sc=0),Uc=码元 | +| 任意键释放 | 编码释放(Kd=0),Uc 用 `ToUnicodeEx` 求 | +| 被 TCode 拦截的键 | 按下与释放都不发 | + +Rc 取 lParam 的重复次数(一般为 1)。焦点事件(1004)照常发。非 Windows 平台不注册钩子,9001 置位时也按普通路径编码 [推断:非 Windows 上没有程序会请求 9001]。 + +## 4. IME + +### 4.1 客户端实现 + +```csharp +sealed class TerminalImeClient(TerminalView view) : TextInputMethodClient +{ + public override Visual TextViewVisual => view; + public override bool SupportsPreedit => true; // 预编辑自己画(05 §9.6) + public override bool SupportsSurroundingText => false; // 终端没有可编辑的上下文 + public override string SurroundingText => string.Empty; + public override TextSelection Selection { get => default; set { } } + public override Rect CursorRectangle => view.ImeCaretRect(); // DIP,相对 TextViewVisual + public override void SetPreeditText(string? text, int? cursorPos) => view.SetPreedit(text, cursorPos); + public void NotifyCaretMoved() => RaiseCursorRectangleChanged(); +} +// TerminalView:AddHandler(TextInputMethodClientRequestedEvent, (_, e) => e.Client = _ime); +``` + +API 与签名 [已核实 `TextInputMethodClient.cs:5-117`;`InputElement.cs:134-137`]。只有 `SupportsPreedit=true` 时平台才把组合串交给客户端(research/04 §6.2)。 + +| 项 | 规则 | +|---|---| +| 预编辑期间 | 不写 PTY;KeyDown 的 `ImeProcessed` 一律放行;`KeyInput.Composing=true` | +| 提交 | 经 TextInput 到达 → 普通文本路径(不加 bracketed paste);清空预编辑 | +| 取消 | `SetPreeditText(null)` 或空串 → 清空叠加层 | +| 焦点离开 | 清空预编辑(平台会取消组合) | +| `TextInputOptions` | `ContentType=Normal`、`Multiline=false`、`ShowSuggestions=false`;快照 `Cursor.PasswordInput` 为真时 `IsSensitive=true`([已核实 `TextInputOptions.cs:30-264`],桌面平台是否有效果未验证) | + +### 4.2 预编辑列宽预测 + +预编辑叠加层要按"程序提交后终端实际占用的列数"排版,否则提交瞬间会跳动: + +- 把预编辑字符串转成码点数组,按当前 `InputModes.GraphemeClusterMode`(2027): + - 开:循环调用 `ghostty_unicode_grapheme_width(cps + i, len - i, &w)`,累加;每个 grapheme 记录 1 或 2 列; + - 关:逐码点 `ghostty_unicode_codepoint_width`,宽度 0 的码点并入前一个 grapheme。 +- 这两个函数是纯函数、无分配、线程安全 [已核实 `unicode.h:69, 70-144`],UI 线程直接调用(通过 `TCode.Terminal.Core.TerminalUnicode` 静态包装)。 +- 结果 `CellWidths[]` 放进 `OverlayState.Preedit`,渲染见 05 §9.6。 +- 歧义宽度设置(`unicode.ambiguousWidth`)由核心决定;若核心配置为 wide,预测时对 East Asian Ambiguous 码点改为 2(与核心同一张表)[推断:需与 02 的宽度配置一致]。 + +### 4.3 候选框定位 + +- `CursorRectangle`:有预编辑时为预编辑插入符所在格的矩形;否则为光标格矩形(DIP,相对 `TerminalView`)。高度用整格高,Windows IMM 和 macOS 都把候选框放在矩形下方。 +- 光标格变化(快照 `Cursor` 位置变化、视口滚动、字号变化)以及预编辑插入符移动时调用 `NotifyCaretMoved`,每帧最多一次。 +- 预编辑叠加层左移(05 §9.6)时,矩形跟随叠加层的实际位置。 + +## 5. 鼠标 + +### 5.1 上报 + +UI 线程持有 `GhosttyMouseEncoder`,选项来自 `InputModes`:`EVENT`(跟踪模式 none / X10 / normal / button / any)、`FORMAT`(X10 / UTF-8 / SGR / urxvt / SGR-pixels)、`SIZE`(屏幕像素、cell 像素、内边距)、`ANY_BUTTON_PRESSED`、`TRACK_LAST_CELL`(按格去重)[已核实 `mouse/encoder.h:35-125`]。坐标用设备像素(与 `TerminalSize.CellWidth/CellHeight` 一致),encoder 负责换算成格与 SGR-pixels。 + +| 规则 | 说明 | +|---|---| +| 绕过上报 | 按住 `mouse.bypassReportingModifier`(默认 Shift,WT / VTE / Ghostty)时,鼠标事件交给 TCode(选区、链接),不上报 | +| 上报内容 | 按下、释放、拖动(button / any 模式)、移动(any 模式)、滚轮(按钮 4–7) | +| 捕获 | 按下时 `e.Pointer.Capture(this)`,拖出控件仍能收到移动与释放;坐标由 encoder 钳制在网格内 | +| 激活窗口的点击 | 窗口在按下前 50 ms 内刚获得激活时,该次按下与对应释放都不上报 [推断:各终端常见做法,research/03 §5] | +| 链接点击 | ⌘/Ctrl+单击链接优先于上报(WT 规则,§8.3) | +| OSC 22 指针形状 | 快照 `MouseShape` 映射到 Avalonia `Cursor`;默认 `Ibeam`;悬停链接时 `Hand` | + +### 5.2 滚轮 + +```text +OnPointerWheelChanged(e): + rows = 累积(e.Delta.Y × linesPerNotch),取整数部分,余数留到下次(触控板高精度滚动) + if 跟踪模式 ≠ none 且未按绕过修饰键 → 每行上报一个按钮 4/5 事件(水平 6/7) + elif 备用屏 且 mode 1007 置位 → 每行编码一次 ↑/↓(按 DECCKM 选 CSI / SS3) + else → actor.ScrollViewport(-rows)(回滚) +``` + +- mode 1007(alternate scroll)在 libghostty 中默认置位 [已核实 `modes.zig:394` `.default = true`],所以 less、man 等程序在备用屏下滚轮可用。 +- `linesPerNotch`:Windows 读 `SPI_GETWHEELSCROLLLINES`,其他平台 3 [推断]。Avalonia `PointerWheelEventArgs.Delta` 的单位在各平台的精确定义需实测([已核实只有 `Delta` 属性 `PointerWheelEventArgs.cs:5`])。 +- 平滑滚动开启时,回滚分支改为把亚行位移交给渲染(05 §10.6)。 + +### 5.3 选区手势(libghostty selection gesture) + +手势状态机在 actor 上(`ghostty_selection_gesture_event` 需要终端句柄,且必须与终端修改串行 [已核实 `selection.h:54-60, 632-636`])。UI 线程只发命令: + +```csharp +readonly record struct SelectionGestureCommand( + GestureEventType Type, // Press / Drag / Release / AutoscrollTick + int ViewportCol, int ViewportRow, // actor 用 ghostty_terminal_grid_ref 转成 GridRef + PixelPoint SurfacePosition, // 设备像素 + ulong TimeNs, bool Rectangle); +``` + +actor 每次调用前设置的事件选项 [已核实 `selection.h:480-536`]: + +| 选项 | 值 | +|---|---| +| `TIME_NS` | `Stopwatch` 纳秒;不设则只有单击行为 | +| `REPEAT_INTERVAL_NS` | `IPlatformSettings.GetDoubleTapTime(PointerType.Mouse)`([已核实 `IPlatformSettings.cs:34`]);libghostty 默认为 0,必须设置 [已核实 `selection_gesture.zig:81`] | +| `REPEAT_DISTANCE` | `GetDoubleTapSize(PointerType.Mouse)` 换成设备像素 | +| `WORD_BOUNDARY_CODEPOINTS` | `selection.wordSeparators` 的平台默认(research/05 §5.3) | +| `BEHAVIORS` | 单击 = CELL,双击 = WORD,三击 = LINE(换行前的整条逻辑行,iTerm2 / research/05 §5.3) | +| `RECTANGLE` | 矩形选择修饰键按下:macOS Option、Windows Alt、Linux Ctrl(research/05 §5.3) | +| `GEOMETRY` | 列数、cell 宽、左内边距、表面高度(设备像素) | +| `VIEWPORT` | 自动滚动 tick 时的指针视口坐标 | + +- 结果选区用 `GHOSTTY_TERMINAL_OPT_SELECTION` 安装为终端当前选区([已核实 `terminal.h:2148-2160`]),下一帧快照带出 `Selection`。 +- **自动滚动**:拖动事件后读 `GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL`(none / up / down);非 none 时 actor 启动 15 ms 定时器,每次发 `AUTOSCROLL_TICK`,直到变回 none 或释放(Ghostty `selection_scroll_ms = 15` [已核实 `termio/Thread.zig:41`])。 +- 程序开启鼠标跟踪时,只有按住绕过修饰键才做选区。 +- Option/Alt+单击移动光标(`mouse.altClickMovesCursor`,默认开):仅当 `CursorAtPrompt` 为真且点击位置在光标所在的逻辑行内;计算列差,用键编码器发送对应次数的 ←/→ [推断:VS Code / Terminal.app 的行为;Ghostty 的 `cursor-click-to-move` 同理]。macOS 上 Option+拖动是矩形选择,只有"单击无拖动"才移动光标。 +- macOS 深按(Force Touch):Avalonia 不提供 → 不支持 `DEEP_PRESS`。 + +### 5.4 右键与中键 + +| 平台 | 右键(`mouse.rightClick=system`) | 中键(`mouse.middleClick=system`) | +|---|---|---| +| macOS | 指针不在现有选区内时先选中单词,然后弹出上下文菜单 | 无 | +| Windows | 有选区:复制并清除选区;无选区:粘贴 | 无 | +| Linux | 上下文菜单 | 粘贴 PRIMARY(§6.3) | + +(research/05 §5.3,VS Code 的分平台默认值。)程序开启鼠标跟踪且未按绕过修饰键时,右键 / 中键上报给程序。 + +上下文菜单项(由 Desktop 通过 `TerminalView.ContextRequested` 提供 AtomUI 菜单,视图只给出上下文):复制、复制为 HTML、粘贴、粘贴选区(Linux)、全选、清屏、查找、向右 / 向下分屏、打开链接 / 复制链接地址(指针在链接上)、复制命令输出(有 OSC 133)、可访问缓冲视图。 + +## 6. 剪贴板 + +### 6.1 复制 + +| 项 | 规则 | +|---|---| +| 文本 | actor `GetSelectionText(selection, TextFormat.Plain)`:软换行的行拼接(`unwrap=true`),`selection.trimTrailingWhitespace`(默认 true)去掉行尾空白(`trim` [已核实 `selection.h:235-246` 格式化选项]);矩形选区每行一段 | +| 换行符 | Windows `\r\n`,其他 `\n` | +| 写入 | `TopLevel.Clipboard.SetTextAsync`([已核实 `TopLevel.cs:544`、`ClipboardExtensions.cs:165`]) | +| HTML | 默认不写;`terminal.copyAsHtml` 命令同时写纯文本 + HTML:`DataTransferItem` 加 `DataFormat.Text` 与平台格式([已核实 `DataFormat.cs:185` `CreateStringPlatformFormat`])——Windows `"HTML Format"`(CF_HTML,需拼接带偏移的头)、macOS `"public.html"`、Linux `"text/html"` [推断:平台格式名];HTML 由 formatter 生成(01 §6.1 `Format`) | +| 复制后 | Windows Ctrl+C / 右键复制:清除选区(WT);⌘C / Ctrl+Shift+C:保留选区 | + +### 6.2 copyOnSelect + +`selection.copyOnSelect: system | off | primary | clipboard | both`,默认 Linux `primary`,其他 `off`(research/05 §10.3)。在选区手势**释放**时写入(拖动中不写),选区为空不写。 + +### 6.3 Linux PRIMARY(进程内) + +- Avalonia 12.1.3 没有 `ClipboardType` / `PrimarySelection`,`TopLevel` 只有 `Clipboard` [已核实:源码中无 `PrimarySelection`;`TopLevel.cs:544`];X11 PRIMARY 在 master 上才有,Wayland 后端没有(research/05 §5.2)。 +- `ITerminalClipboard` 抽象:`SetTextAsync(ClipboardTarget, string)`、`GetTextAsync(ClipboardTarget)`。`ClipboardTarget.Primary` 的 12.1.x 实现是 `InProcessPrimarySelection`(静态单例,保存最后一次选中的文本),只在 TCode 自己的控件之间生效。Desktop 可以让 `CodeView` 等控件也写入它。 +- 升级到带 PRIMARY 的 Avalonia 后,只替换这一个实现(`TopLevel.TryGetClipboard(ClipboardType.PrimarySelection)`)。 +- OSC 52 的 `p` / `s` 目标映射到同一个 PRIMARY(`GHOSTTY_CLIPBOARD_LOCATION_PRIMARY/SELECTION` [已核实 `terminal.h:690-700`])。 + +### 6.4 OSC 52 + +- 写:由**镜像核心**的 `ClipboardWrite` 触发,host 发 `session/clipboardWrite` 给该会话的主视图([04](04-pty-host.md) §11);Desktop 按 `clipboard.oscWrite`(默认 `allow`)处理,`ask` 时弹出确认,显示前 200 字符。UI 侧核心的同一回调忽略,避免多个视图重复写入。 +- 读:同样由镜像核心触发,经 `clipboard/readRequest` / `clipboard/readResponse` 往返(01 §3.2 规则 6)。默认 `ask`。库原生不支持"以后补发应答",依赖 overlay O3([02](02-core-ghostty.md))。 + +## 7. 粘贴 + +### 7.1 流程 + +```text +Paste(source = Clipboard | Primary | Drop | Command): + text ← 剪贴板 / PRIMARY / 拖放结果 + if text 为空 → 返回 + 预处理: + trimTrailingNewline(默认 true):bracketed paste 未开启 且 去掉末尾 \r?\n 后是单行 → 去掉末尾换行 + largePasteWarning(字节,0 = 关):超过阈值 → 确认 + multiLineWarning = always:含换行就确认(即使 bracketed) + 含 "\x1b[201~" → 一律确认(Ghostty 规则) + r ← actor.EncodePaste(text, allowUnsafe: multiLineWarning == never) + if r == Rejected → 确认框(显示行数与前 5 行): + [粘贴] → EncodePaste(text, allowUnsafe: true) + [粘贴为单行] → 把换行替换为空格后 EncodePaste(…, allowUnsafe: true) + [取消] + SendInput(r.Bytes)(host 写线程按 4 KiB 分块,01 §5.1);视口滚到底 +``` + +- `EncodePaste` 在 actor 上调用 `ghostty_terminal_paste`([已核实 `paste.h:187-189`]):开启 bracketed paste(2004)时包裹,否则 `\n → \r`;不安全控制字节替换为空格;可能注入命令的内容(未包裹时的换行 / 回车,包裹时的结束标记)返回 `GHOSTTY_REJECTED`,不写任何东西 [已核实 `paste.h:42-50, 165-183`]。`multiLineWarning=auto`(默认)正好等于这个规则(research/05 §6.3)。 +- **输出通道**:`ghostty_terminal_paste` 的结果通过 `write_pty` 回调分块输出 [已核实 `paste.h:37-41`],而 01 §3.1 规定 UI 侧核心的 `write_pty` 是空实现。actor 必须在调用期间打开"捕获作用域":回调把字节写进 `PasteResult` 的缓冲,调用结束后关闭;其他时间仍丢弃。 +- **mode 5522(kitty 粘贴事件)**:需要安装 `clipboard_read` 回调才会触发 [已核实 `paste.h:16-28, 87-93`]。程序随后用 OSC 5522 读取剪贴板时,请求会到达**镜像核心**,而一次性密码授权记录在 UI 侧核心——两份核心状态不一致。v1 **不**在 UI 侧核心安装 `clipboard_read`,粘贴事件关闭(见文末)。 +- `paste.ignoreBracketedPasteMode=true`:改用不读终端状态的 `ghostty_paste_encode`(不包裹)[已核实 `paste.h:243`]。 +- 来源:键盘 / 菜单 / 中键粘贴用 `GHOSTTY_PASTE_SOURCE_CLIPBOARD`;拖放用 `GHOSTTY_PASTE_SOURCE_TEXT` [已核实 `paste.h:76-85`]。IME 提交不走粘贴。 + +### 7.2 拖放 + +- `DragDrop.SetAllowDrop(view, true)`;`DragOver`:数据含 `DataFormat.File` 或 `DataFormat.Text` 时 `DragEffects=Copy`([已核实 `DragDrop.cs`、`DragEventArgs.cs`、`DataFormat.cs:34-46`])。 +- 文件:`IStorageItem.TryGetLocalPath()`,按 shell 种类转义,多个路径以空格连接,**末尾不加换行**,然后走 §7.1(安全检查一样适用)。 +- shell 种类来自 `TerminalViewOptions.ShellKind`(Desktop 按 profile 填,01 §6.3 `ShellKind`)。 + +| shell | 规则(research/05 §6.2–6.3,VS Code `preparePathForShell`) | +|---|---| +| bash / zsh / sh(macOS) | 反斜杠转义 `` \ ()"&'!$<>;\|*?[]#`{}^+=@~ ``、空格、制表符(Terminal.app / iTerm2 观感) | +| bash / zsh / sh(Linux) | 单引号包裹,内部 `'` 写成 `'\''`(`g_shell_quote`) | +| fish | 单引号包裹,内部 `\` → `\\`、`'` → `\'` | +| pwsh / powershell | 含空格或 `` '`$&(){}[];,@# `` 等时单引号包裹,`'` 写成 `''`;**不加** `& ` 前缀 | +| cmd | 含空格或 `&()[]{}^=;!'+,`~` 时双引号包裹 | +| Git Bash | `C:\a\b` → `/c/a/b`,再按 bash(Linux 规则) | +| WSL | `C:\a\b` → `/mnt/c/a/b`,再按 bash(Linux 规则) | +| nu | 不含反引号时用反引号包裹,否则 `r#'…'#` | +| 其他 / 未知 | 按平台默认 posix 规则;Windows 按 cmd | +| 所有 | 先去掉 C0 / C1 控制字符 | + +- 拖入纯文本:按 §7.1 粘贴(`SOURCE_TEXT`)。 + +## 8. 链接 + +### 8.1 来源 + +| 来源 | 识别 | +|---|---| +| OSC 8 | 快照 cell 的超链接 id → `Hyperlinks` 表的 URI(需补进快照,见 05 文末) | +| URL | `[GeneratedRegex]`(AOT 安全)识别 `https?`、`ftp`、`file`、`mailto`、`ssh`、`git` 等 scheme;去掉末尾 `.,;:!?'"`,括号按配对保留(`(…)` 在 URL 内部成对时保留结尾的 `)`) | +| 文件路径 | 绝对路径(`/a/b`、`~/a`、`C:\a`、`\\server\share`)、相对路径(`./a`、`../a`、`a/b.ext`)加位置后缀 | + +位置后缀(取 VS Code `terminalLinkParsing.ts` 的常用子集 [已核实 research/05 §5.1 来源]): + +| 形式 | 示例 | +|---|---| +| `:line` / `:line:col` / `:line.col` | `src/a.cs:12:5` | +| `(line)` / `(line,col)` / `(line, col)` | `a.cs(12,5)`(MSBuild) | +| `[line]` / `[line, col]` | | +| `", line N"` | `File "a.py", line 3`(Python) | +| ` line N` / `:line N` | `a.txt line 3` | +| `#Lline` | `a.cs#L12` | + +### 8.2 检测与验证 + +- 时机:指针悬停时只检测指针所在的**逻辑行**(拼接软换行行),结果按 `(RowId, Version)` 缓存;不在每帧全屏扫描。 +- 相对路径的基准目录:该行所属命令的 cwd(OSC 133 命令记录里的 cwd),否则当前 OSC 7 cwd,否则会话初始 cwd。 +- 存在性验证在线程池执行,结果缓存 5 s;路径不存在则不显示为链接(避免把普通词当路径)。 +- OSC 7 的主机名不是本机(ssh 会话)时,不做文件路径识别;URL 照常。 + +### 8.3 悬停与激活 + +| 项 | 规则 | +|---|---| +| 悬停 | 下划线(05 §8.5:无下划线加 single,已有 single 改 double)+ 指针 `Hand` + 工具提示:OSC 8 显示真实地址;检测到的链接显示"⌘/Ctrl+单击打开" | +| 激活修饰键 | macOS ⌘,Windows / Linux Ctrl(research/05 §5.3) | +| 与鼠标上报 | 修饰键 + 单击优先于程序的鼠标上报(WT) | +| 键盘 | v1 不提供键盘遍历链接(v2) | + +```csharp +public sealed class TerminalLinkActivatedEventArgs : EventArgs +{ + public TerminalLinkKind Kind { get; } // Url | File | Hyperlink(OSC 8) + public string Target { get; } // 原始目标(URI 或路径文本) + public string? DisplayText { get; } // 屏幕上的文字(OSC 8 时可能与 Target 不同) + public string? ResolvedPath { get; } // File:已按 cwd 解析的绝对路径 + public int? Line { get; } public int? Column { get; } + public KeyModifiers Modifiers { get; } + public bool Handled { get; set; } +} +``` + +Desktop 处理:URL 按 `links.allowedSchemes` 交给 `TopLevel.Launcher`;OSC 8 先确认真实地址(显示文字与地址不一致时必须确认);`file://` 无主机在 TCode 工作台打开,有主机拒绝;文件路径在工作台打开到行列(01 §7)。 + +## 9. 搜索 + +### 9.1 UI + +- `SearchBar`:`TerminalView` 模板中的浮层(右上角),含输入框、"k / n"、上一个 / 下一个、关闭。样式由宿主主题提供。 +- 打开:`terminal.find`;选区为单行时预填并选中。 +- 匹配规则:libghostty 搜索按字节精确匹配,只有 ASCII 字母不区分大小写 [已核实 `search.h:31-34`]。v1 不提供正则、全词、区分大小写开关。 +- 键:Enter / ⌘G / F3 = 向更旧的内容(`GHOSTTY_SEARCH_OPT_SELECT_NEXT`,从屏幕底部往回滚方向 [已核实 `search.h` OPT_SELECT_NEXT 注释]);Shift+Enter / ⇧⌘G / Shift+F3 = 向更新的内容(`SELECT_PREV`);Esc 关闭并把焦点还给终端。 +- 计数显示 `SELECTED_INDEX + 1` / `TOTAL_MATCHES`(index 0 是最新的匹配 [已核实 `search.h` DATA_SELECTED_INDEX 注释])。 +- 关闭时若有选中的匹配,把它安装为终端选区,⌘C 可以直接复制。 + +### 9.2 线程 + +libghostty 搜索把工作分成两类 [已核实 `search.h:36-52, 104-117`]:`feed` / `set(NEEDLE)` / `select` 需要独占终端(只能在 actor);`tick` / `get` 只碰搜索自己的内存,可以在其他线程。同一个 `GhosttySearch` 的调用必须串行。所以采用"所有权交接": + +```text +UI:输入变化(50 ms 去抖)→ actor.SetNeedle(text) +actor:ghostty_search_set(NEEDLE) → feed → status + RUNNING → 把 search 句柄交给 SearchWorker(标记 owner = worker),actor 之后不再碰它 +SearchWorker(线程池):循环 tick,每轮预算 2 ms,直到 FEED_REQUIRED 或 COMPLETE → 交回 actor +actor(收回后):get(TOTAL_MATCHES, SELECTED_INDEX, VIEWPORT_MATCHES) + → 换算视口坐标(ghostty_terminal_point_from_grid_ref)→ 写进下一帧 FrameSnapshot.SearchHighlights + → 向 UI 发 SearchStatus(k, n, running) + FEED_REQUIRED → 再 feed,再交给 worker +终端有新输出(search 处于 actor 手中时):每 100 ms 最多 feed 一次,保持视口匹配最新 +select next/prev:actor 调 set(SELECT_NEXT/PREV)(会先追上终端,并按 SCROLL_IF_NEEDED 滚动视口) +``` + +- 匹配跨主屏 / 备用屏切换保留([已核实 `search.h:20-26`]),vim 进出不会重启回滚搜索。 +- 匹配结果只在下一次修改终端前有效,所以只在 actor 上、feed 之后读取并立即换算([已核实 `search.h:63-71`])。 +- 关闭搜索:`set(NEEDLE, null)` 释放终端内的跟踪状态,然后 `ghostty_search_free`。 + +## 10. 可访问性 v1 + +### 10.1 `TerminalAutomationPeer` + +Avalonia 12.1.3 公开的 provider 只有 Value、RangeValue、Toggle、Invoke、Scroll、Selection、ExpandCollapse 等,没有 `ITextProvider` [已核实 `Avalonia.Controls/Automation/Provider/` 目录]。 + +| 成员 | 实现 | +|---|---| +| ControlType | `Document`;Name = 终端标题(OSC 0/2 或 profile 名) | +| `IValueProvider` | `IsReadOnly = true`;`Value` = 视口纯文本(UI 线程从最新快照的行生成,上限 64 KiB);`SetValue` 抛 `InvalidOperationException`([已核实接口 `IValueProvider.cs`])。Linux AT-SPI 的 Text 接口由 `IValueProvider.Value` 驱动(research/05 §9) | +| `IScrollProvider` | 只支持纵向:`VerticallyScrollable`、`VerticalScrollPercent`(由 `ScrollbarState`)、`VerticalViewSize`、`Scroll(ScrollAmount)` → `ScrollViewport` 行 / 页、`SetScrollPercent` → 绝对滚动 [已核实接口 `IScrollProvider.cs`] | +| 变化通知 | 视口内容变化且已检测到辅助技术时,节流 500 ms `RaisePropertyChangedEvent(Value)`([已核实 `AutomationPeer.cs:590`]) | + +### 10.2 输出播报 + +- 隐藏的播报元素:`TerminalView` 模板里一个 1×1、`Opacity=0`、不可命中测试、`AutomationProperties.LiveSetting=Polite` 的元素([已核实 `AutomationProperties.cs:239`])。Win32 上 peer 的 Name 变化且 LiveSetting 非 Off 时触发 UIA `LiveRegionChanged`;macOS 透传 LiveSetting(research/04 §6.3)。`IsVisible=false` 的元素可能不进自动化树,所以用透明而不是隐藏 [推断:需在三个平台验证]。 +- 内容:actor 在每批输出后报告"新完成的行"(光标离开的行,以及滚出视口的行)的绝对行号区间;UI 线程每 500 ms 汇总,向 actor 请求这些行的纯文本(`Format`)。 +- 去掉回显:维护最近 1 s 内用户键入字符的队列,新输出以这些字符开头时去掉(xterm.js `_charsToConsume` 思路,research/04 §6.3)。 +- 超过 20 行时只播报"输出了 N 行"(xterm.js 上限 20 行)。 +- 开关:`accessibility.announceOutput: auto | on | off`;`auto` = 检测到屏幕阅读器时开。Avalonia 没有屏幕阅读器检测 API [已核实:源码中无相关 API],由 Desktop 分平台检测:Windows `SystemParametersInfo(SPI_GETSCREENREADER)`;macOS `NSWorkspace.isVoiceOverEnabled`;Linux D-Bus `org.a11y.Status.ScreenReaderEnabled` [推断]。 + +### 10.3 可访问缓冲视图(⌥F2 / Alt+F2) + +- 与 VS Code Accessible View 相同(research/05 §9):打开一个只读 `TextBox`(自带平台文本无障碍,屏幕阅读器可逐字、逐行浏览),内容是回滚 + 屏幕的纯文本(actor `Format`,上限最近 10,000 行),插入符放在末尾。 +- 有 OSC 133 时:Ctrl+↑ / Ctrl+↓(macOS ⌥↑ / ⌥↓)在命令之间跳转;提供"跳到命令"列表(命令行文本 + 退出码)。 +- Esc 关闭并把焦点还给终端;打开时不实时刷新,重新打开才更新(避免屏幕阅读器位置跳动)。 + +### 10.4 其他 + +| 项 | 规则 | +|---|---| +| 键盘陷阱 | 终端会吃掉 Tab;离开终端的方式:⌘⇧P / Ctrl+Shift+P 命令面板、⌘B 等全局键(macOS),以及 Ctrl+Shift+E(Win/Linux)。这些都在默认 skipShell 中,`sendKeybindingsToShell=true` 时命令面板仍保留 | +| 焦点可见 | `Terminal.FocusBorder` 焦点环(05 §11.1) | +| 高对比度 | 最小对比度自动提高到 7(05 §9.7) | +| 屏幕阅读器开启时 | 关闭光标与文字闪烁 | +| v2 | UIA TextPattern / NSAccessibility 文本(需要给 Avalonia 贡献 `ITextProvider` 或写平台专用 peer,research/04 §10 决策 3) | + +## 11. 焦点上报 + +GotFocus / LostFocus,以及窗口 `Activated` / `Deactivated`:模式 1004 置位时 `ghostty_focus_encode`(纯函数 [已核实 `focus.h:64`])编码后 `SendInput`,只在状态真正变化时发送。ConPTY 依赖它(research/03 §5)。失焦同时清空修饰键左右状态与 `_swallowed`。 + +## 12. 测试计划 + +### 12.1 自动化 + +| 套件 | 内容 | 方式 | +|---|---|---| +| `KeyEncodingTests`(Core.Tests) | 表驱动:`(KeyInput, InputModes) → 期望字节`。覆盖 legacy 全表(research/03 §4.1)、DECCKM / DECKPAM、modifyOtherKeys 2、kitty 标志 1/2/4/8/16 组合(含释放、重复、修饰键自身)、Backspace 各变体与 DECBKM、Option 作 Meta 左 / 右 | 直接调用编码器,不经 UI | +| `Win32InputModeTests` | 修饰键按下 / 释放、非字符键、"待配对 + TextInput"、死键组合、IME 提交(VK_PACKET)、`ControlKeyState` 各位、被拦截键不发送 | 纯函数 + 伪造的钩子记录 | +| `KeyDecisionTests`(Avalonia.Tests) | §3.1 判定表:AltGr、Option 字符、Alt+字母(Win32 SYSKEY)、ImeProcessed、预编辑中、kitty flag 8 | 构造 `KeyEventArgs` 调用 `TerminalInputController` | +| `KeyRouterTests`(Desktop.Tests) | 三平台键表、`skipShell` 增删、`sendKeybindingsToShell`、performable(无选区 Ctrl+C → shell;备用屏 ⌘K → shell)、释放事件吞掉 | 纯逻辑 | +| `MouseEncodingTests` | 5 种跟踪 × 5 种编码 × 按钮 / 修饰键、绕过修饰键、1007、滚轮累积、坐标钳制、SGR-pixels 像素坐标 | 编码器 + 控制器 | +| `SelectionReplayTests` | 回放固定屏幕内容 + 手势序列 → 期望选中文本:三平台单词分隔符、三击逻辑行、矩形、自动滚动 tick、软换行拼接、行尾空白 | Core 回放 | +| `PasteTests` | 拒绝矩阵(bracketed 开 / 关 × 换行 / 结束标记 / 控制字符)、单行去尾换行、捕获作用域、拖放转义表(每种 shell 的空格、引号、`$`、中文、`'` 与 `"` 并存) | Core + 纯函数 | +| `LinkDetectorTests` | URL 末尾标点、括号配对、各位置后缀形式、相对路径解析、远程 cwd 关闭路径识别、OSC 8 | 纯函数 | +| `SearchTests` | 所有权交接、计数与 k/n、上一个 / 下一个环绕、备用屏切换保留、输出期间增量 feed | Core | +| `PreeditWidthTests` | 语料(CJK、emoji ZWJ、组合符、韩文字母组合)在 2027 开 / 关下:预测列宽 = 写入核心后光标位移 | Core | +| `AutomationTests` | `Value` 内容与长度上限、`IScrollProvider` 百分比、播报节流与回显去除、超过 20 行摘要 | Headless | +| 截图测试 | 预编辑、选区、搜索高亮、链接悬停(05 §13 T15–T17、T21) | Headless | + +UI 级测试只在进程内调用控制器或使用 Avalonia Headless 的输入 API,不使用操作系统级的合成输入或屏幕区域截图。 + +### 12.2 必须手工验收的部分 + +| 区域 | 清单 | +|---|---| +| IME(macOS) | 简体拼音、繁体注音、日文罗马字、韩文 2-Set:预编辑位置随光标;候选框跟随;提交后列宽不跳;Esc 取消;⌘K 等快捷键在组合中不触发;预编辑在行尾时左移 | +| IME(Windows) | 微软拼音、微软日文 IME、韩文 IME:同上;`ImeProcessed` 不产生多余字符 | +| IME(Linux) | fcitx5 + 拼音、ibus + 拼音:同上 | +| 死键 | Windows US-International、macOS ABC Extended、French:`'`+`e` → `é`;死键 + 空格 → 原字符 | +| AltGr | Windows 德语(AltGr+Q = `@`、AltGr+7 = `{`)、波兰语程序员布局;`altGrAliasing=false` 后 Ctrl+Alt+Q 发给程序 | +| Option | `macOptionAsMeta` 四个取值;⌥B 在 none 时输入 `∫`、left 时左 ⌥ 发 `ESC b` | +| kitty 键盘 | nvim(`CSI >3u`)、Claude Code 的 Shift+Enter、Codex;⌘C 拦截后程序不收到释放事件 | +| win32-input-mode | PowerShell 7 + PSReadLine:Ctrl+Space 补全、Shift+Enter、Ctrl+Backspace;Far Manager;cmd 下中文输入 | +| 鼠标 | htop / mc / vim `mouse=a` 的点击、拖动、滚轮;Shift 绕过;SGR-pixels(`kitten show-key` / 自写探针);HiDPI 坐标 | +| 选区与剪贴板 | 三平台双击 / 三击 / 矩形;Windows 右键复制 / 粘贴;Linux 中键粘贴 PRIMARY(进程内) | +| 粘贴 | 多行确认、粘贴为单行、含 `ESC[201~` 的内容;大段粘贴(10 MB)不卡 UI | +| 拖放 | Finder / Explorer / Nautilus 拖入含空格、引号、中文的路径到 zsh、bash、fish、pwsh、cmd、Git Bash、WSL | +| 链接 | URL、`file:line:col` 在工作台打开到行;OSC 8 真实地址确认 | +| 屏幕阅读器 | Windows NVDA 与讲述人、macOS VoiceOver、Linux Orca:新输出播报(不重复读回显)、可访问缓冲视图逐行浏览与命令跳转、`IScrollProvider` 滚动 | +| 键盘陷阱 | 只用键盘能离开终端(命令面板、全局键) | + +--- + +## 发现的架构问题 / 待决问题(供修订 01) + +> 2026-10-17:以下各项均已并入 01-architecture.md(第 6 项的通道见 01 §3.2 规则 6 与 04 §11)。 + +1. **9001 不在 libghostty-vt 的模式表里**(§3.7):D9 要求 v1 实现 win32-input-mode,而核心无法跟踪 `CSI ? 9001 h`;需要在 `native/ghostty` overlay 补丁里登记该模式(或在 actor 里另做字节流嗅探)。 +2. **输入编码不能用 `FrameSnapshot.Modes`**(§3.4):01 §5.2 允许"UI 线程直接用快照里的模式编码",但快照在 mode 2026 下最多扣 1 s、视图不可见时不生成。建议 actor 单独发布 `InputModes`。 +3. **`ITerminalViewSession` 缺少请求方法**:发起 `Clear`(§2.5)与 `SetColors`(05 §11.1)的日志记录请求。 +4. **UI 侧核心的 `write_pty` 不能完全为空**(§7.1):`ghostty_terminal_paste` 通过它输出,actor 需要"粘贴捕获作用域"。 +5. **kitty 粘贴事件(mode 5522)与双核心冲突**(§7.1):授权记录在 UI 侧核心,程序的读取请求到达镜像核心。v1 关闭。 +6. **OSC 52 读取的应答通道未定义**(§6.4):镜像核心需要向 UI 请求剪贴板内容并等待用户确认,01 / 04 需要定义 host → UI 的请求与回传。 diff --git a/docs/terminal/07-shell-profiles-config.md b/docs/terminal/07-shell-profiles-config.md new file mode 100644 index 0000000..1883623 --- /dev/null +++ b/docs/terminal/07-shell-profiles-config.md @@ -0,0 +1,1396 @@ +# 07 · Shell、Profile 与配置(TCode.Terminal.Shell + settings 的 `terminal` 节) + +> 状态:设计稿 v1(2026-10-16)。上游:[01-architecture.md](01-architecture.md)(D4、D7、D9、§3.3、§6.3)、[research/05](research/05-platform-behavior-config.md)、[research/03](research/03-vt-protocol.md) §2.4/§7、[research/02](research/02-pty.md) §2.4/§2.9、[docs/settings.md](../settings.md)、`src/TCode.Config`。SSH 部分见 [08-ssh.md](08-ssh.md),Agent 使用终端见 09。 +> +> 标注:**[实]** = 已在源码、官方文档或本机核实;**[推]** = 推断,实现前要验证;**[定]** = 本文做出的设计决定(可在评审中推翻)。 +> +> 源码前缀沿用 research/05 §0:VSC = microsoft/vscode `4765bd3`,GH = ghostty `b3dc3d7`,WT = microsoft/terminal `44b440b`,IT = iTerm2 `5794785`,WZ = wezterm `e2a98e1`,OSSH = openssh-portable `6545e6a`。路径相对各仓库根目录。 + +## 0. 结论摘要 + +1. **所有探测与启动构造都在 pty host 里完成**(01 §6.3)。Desktop 只负责把 `settings.json` 的 `terminal` 节解析成一个与 TCode.Config 无关的 DTO(放在 `TCode.Terminal.Protocol`),通过 `host/configure` 推给 host;host 里的 `ProfileResolver` 把"探测结果 + 设置覆盖 + 自定义 profile"合并,`LaunchBuilder` 产出 `PtyLaunch`。 +2. **探测**:Unix 以 `getpwuid(getuid())->pw_shell` 为准、`/etc/shells` 列出其余 shell、`$SHELL` 只作回退;Windows 采用 WT 的 pwsh 排名规则,另探测 Windows PowerShell、cmd、Git Bash、WSL(读注册表,失败才跑 `wsl.exe -l -q`)、MSYS2、Cygwin、VS 开发者命令行。冷探测预算:Unix < 5 ms,Windows < 150 ms(不含 vswhere,后者异步)。 +3. **macOS 启动**(2026-10-17 用户决定,不经 `/usr/bin/login`):spawn helper 直接 exec shell,argv0 为 `-zsh` 这样的 login 形式,PATH 由系统的 `/etc/zprofile`(path_helper)补齐;shell 是 PTY 的直接子进程,退出码准确(§3.2)。bash 例外:为了注入 shell 集成,用非 login 的 `--init-file` 并在脚本里模拟 login 启动文件。 +4. **环境变量**:以 Desktop 启动时的原始环境为基础(不是 host 自己的环境,原因见 §4.1),剔除 `TCODE_*`(包括 pty host 的连接地址和令牌)、其他终端遗留的变量、启动器变量、`SHLVL`;设置 `TERM=xterm-256color`、`COLORTERM=truecolor`、`TERM_PROGRAM=TCode`、`TERM_PROGRAM_VERSION`、`TCODE_TERMINAL_ID`;macOS 按**系统区域**补 `LANG`。合并顺序见 §4.6。 +5. **Shell 集成**(D7,默认开):bash 用 `--init-file`;zsh 用 ZDOTDIR,但只放一个 `.zshenv`,进来后立刻还原用户的 ZDOTDIR(Ghostty 的思路,HISTFILE 不受影响);fish 用 `XDG_DATA_DIRS` 的 `vendor_conf.d`;pwsh 用 `-NoExit -Command`;nu 依赖 nu 自带的 OSC 133;cmd 用 `PROMPT` 做部分集成。发出 OSC 133 A/B/C/D(含退出码)、OSC 7、带 nonce 的 OSC 633;E,以及一条握手属性 `OSC 633;P;TCodeShellIntegration=…`。10 秒内没有握手判为失败,并显示原因。 +6. **脚本许可**:Ghostty 和 kitty 的 bash/zsh/fish 脚本是 **GPLv3**(文件头写明)[实],TCode 是 Apache-2.0,**不能拷贝**;VS Code 的脚本是 MIT,可以改编并保留版权声明。我们自己写脚本,只借鉴机制。 +7. **`terminal` 节**:沿用 settings.md 的风格(省略即跟随系统、同 id 覆盖内置项、JSONC、宽容解析、500 ms 防抖热重载)。新增三点: + - 平台覆盖块 `terminal.macos` / `windows` / `linux`,可以覆盖任意字段,不再使用"字符串或 `{macos,linux,windows}`"这种联合类型; + - **字段级容错**:`terminal` 节中的错误只让该字段回到默认值、或只停用该 profile,不会让整个 settings.json 失效(厂商配置不能因为一个字号写错而失效); + - 新增 `persistence.*`(键名以 04-pty-host.md 为准:enabled、onQuit、scrollbackLines、orphanGraceMinutes、keepAliveMaxHours、saveIntervalSec,取代 research/05 的 `restore.*`)、`graphics.*`(Kitty 图形开关与内存上限)、`ssh.*`(由 08 定稿)。Agent 相关设置不放在 `terminal` 下,而是 09 §2.2 定义的顶层 `agent.shell.*`。 + +--- + +## 1. 范围、进程归属与接口 + +### 1.1 数据流 + +``` +~/.tcode/settings.json + │ UserSettingsService(已有:FileSystemWatcher + 500 ms 防抖) + ▼ +TCode.Desktop: TerminalConfig.Parse(JsonObject? terminal, Platform) ← TCode.Config(新增) + │ → TerminalSettingsAdapter:映射为 Protocol DTO(不含 TCode.Config 类型) + ▼ host/configure { config, baseEnvironment, clientVersion } ← TCode.Terminal.Protocol +tcode-ptyhost + ├ ProfileResolver:ProfileProviders.Detect() ⊕ config.Profiles → ResolvedProfile[] + ├ profiles/list → Desktop("+" 菜单、设置页);同时写磁盘缓存 + └ session/create { profileId, cwdRequest, purpose, extraEnv, size } + → LaunchBuilder.Build(profile, LaunchContext) → LaunchPlan + → ITerminalConnection(TCode.Terminal.Pty) +``` + +- **依赖方向**(D2、01 §4):`TCode.Terminal.Shell` 只依赖 BCL 和 `TCode.Terminal.Pty`(用其中的 `PtyLaunch`、`TerminalSize`)。`{env:VAR}` 展开、平台判断等都在 Shell 内实现,**不引用 TCode.Config**。语法与 `McpVariables` 保持一致,两边共用一套测试向量(§9.1)[定]。 +- **Desktop 不做探测**:Desktop 打开"+"菜单时,如果 host 还没启动,先显示磁盘缓存 `$TCODE_HOME/cache/terminal-profiles.json`(host 写入),同时异步拉起 host 并刷新列表(§2.6)。 + +### 1.2 对 01 §6.3 接口的细化 + +01 §6.3 的签名保持不变,下面只做**向后兼容的扩充**(新增字段都有默认值): + +```csharp +namespace TCode.Terminal.Shell; + +public interface IProfileProvider +{ + /// 同步探测:只做文件、注册表这类廉价检查(预算见 §2.6)。 + IReadOnlyList Detect(); +} + +/// 可选:需要运行外部进程的探测器(vswhere、wsl.exe 回退)。ProfileResolver 先用同步结果出菜单,再合并异步结果。 +public interface IAsyncProfileProvider : IProfileProvider +{ + ValueTask> DetectSlowAsync(CancellationToken ct); +} + +public sealed record TerminalProfile( + string Id, string Name, string Command, IReadOnlyList Arguments, + bool LoginShell, ShellKind Kind, string? Icon, ProfileSource Source) +{ + // —— 以下为本文新增 —— + public PathStyle PathStyle { get; init; } = PathStyle.Native; // Native | Msys | Cygwin | Wsl:拖放路径转义、OSC 7 路径换算 + public string? RawCommandLine { get; init; } // 仅 Windows:映射为 03 §4 的 PtyLaunch.WindowsCommandLine,原样作为 lpCommandLine(cmd /k "…" 等,§3.4) + public IReadOnlyDictionary Environment { get; init; } = Empty; // profile 自带的环境(MSYS2 的 MSYSTEM 等) + public string? WorkingDirectory { get; init; } // profile 固定的起始目录;null = 用全局规则 + public bool? ShellIntegration { get; init; } // null = 跟随全局 + public bool RequiresTrust { get; init; } // VS Code 的 unsafe:首次使用前确认(MSYS2、Cygwin) + public string? Version { get; init; } // 菜单副标题:"7.6.1"、"Store" + public bool Hidden { get; init; } // 探测到但默认收在"更多"里(次要 pwsh 安装、VS 开发者命令行) + public string? WslDistribution { get; init; } // Kind == Wsl 时 + public SshTarget? Ssh { get; init; } // Kind == Ssh 时(08 §2.4) + public IntegrationHint Integration { get; init; } = IntegrationHint.Auto; // Auto | Skip | AppendToPwshCommand(VS DevShell,§5.3.4) +} + +public enum ProfileSource { Detected, Settings, DetectedOverridden } +public enum PathStyle { Native, Msys, Cygwin, Wsl } + +public static class LaunchBuilder +{ + public static LaunchPlan Build(TerminalProfile profile, LaunchContext context); +} + +public sealed record LaunchContext( + LaunchPurpose Purpose, // Interactive | Agent | AcpAuth | Revive(09 使用后两者) + TerminalSize InitialSize, + PtyWidthMode WidthMode, + IReadOnlyDictionary BaseEnvironment, // §4.1 + ShellConfig Config, // host/configure 下发的 terminal 配置(已平台合并) + string? ProjectRoot, + CwdRequest Cwd, // Default | Inherit(sessionId) | Explicit(path) + IReadOnlyDictionary ExtraEnvironment, // 调用方(Agent/ACP)追加;null = 删除 + bool? ShellIntegrationOverride, // "关闭 shell 集成后重试"时为 false + IShellPlatform Platform); // 可替换的系统访问:文件、注册表、账户、时钟(§9.1) + +public sealed record LaunchPlan( + PtyLaunch Launch, + IntegrationPlan Integration, // Mode:Injected | NativeExpected | Partial | None;Reason;Nonce;ScriptVersion + string DisplayCommand, // 错误界面显示的等价命令行(已脱敏:不含 nonce) + IReadOnlyList Warnings); // 例如"PATH 前缀会被 path_helper 重排" +``` + +`Detect()` 保持同步是为了满足 01 的签名;Windows 上需要启动进程的部分放进 `IAsyncProfileProvider`(§2.6)。这是对 01 的补充,列在 §10。 + +--- + +## 2. Profile 探测 + +### 2.1 通用规则 + +**profile id**(同时是 `terminal.profiles` 的键,也写入 `state.json` 的标签恢复数据)[定]: + +| 来源 | id 格式 | 例子 | 说明 | +|---|---|---|---| +| 虚拟 id | `system` | `system` | 不是独立条目,表示"账户登录 shell"(Unix)或"最佳 PowerShell"(Windows)。`defaultProfile` 的默认值 | +| Unix shell | shell 文件名 | `zsh`、`bash`、`fish`、`nu`、`pwsh` | 同名不同路径时,账户 shell(或 `/etc/shells` 中靠前的那个)用短名,其余用 `<名>@<绝对路径>`,例如 `bash@/opt/homebrew/bin/bash` | +| Windows PowerShell 7 | `pwsh`、`pwsh-preview`、`pwsh@<变体>` | `pwsh@store`、`pwsh@scoop`、`pwsh@dotnet`、`pwsh@x86`、`pwsh@path` | `pwsh` 始终指"排名第一的稳定版",安装新版本后会指向新的那个(与 WT 的 "PowerShell" 语义一致),用户对 `pwsh` 的覆盖随之生效 | +| 其他 Windows 内置 | `powershell`、`cmd`、`git-bash`、`msys2`、`cygwin` | | | +| WSL | `wsl:<发行版名>` | `wsl:Ubuntu-24.04` | 名字原样保留;查找时不区分大小写 | +| VS 开发者命令行 | `vs-devcmd:`、`vs-devshell:` | `vs-devshell:1a2b3c4d` | instanceId 来自 vswhere | +| SSH | `ssh:` | `ssh:prod-1` | 见 08 §2 | +| 自定义 | 用户任意写 | `node-dev` | 正则 `^[A-Za-z0-9][A-Za-z0-9._@:+-]{0,127}$`;不能以 `wsl:`、`ssh:`、`vs-` 开头,除非 `type`/`command` 明确给出(这时视为自定义 profile,只是借用了名字) | + +**合并顺序**(`ProfileResolver`): + +1. 各平台探测器按 §2.3/§2.4 的顺序产出 `Detected` 列表; +2. `terminal.profiles.` 的 id 与探测结果相同时,逐字段覆盖,`Source = DetectedOverridden`;`"enabled": false` 表示隐藏; +3. id 不同的是自定义 profile,必须有 `command`(或 `type: "ssh"` + `host`),否则该 profile 报错、停用,不影响其他(§7.5); +4. `platforms` 字段不含当前平台的条目丢弃; +5. 按"探测顺序 → 自定义(按设置文件中的顺序)"排序;`Hidden` 的条目在菜单里收进"更多"。 + +**默认 profile 的解析**:`defaultProfile`(平台覆盖块优先)→ 找不到或已停用时记一条警告,回到 `system` → `system` 的解析见各平台小节 → 仍然失败时走 §2.7 的最终回退。 + +**`ShellKind` 的判定**:按可执行文件名(去掉 `.exe`,小写)映射:`bash`→Bash,`zsh`→Zsh,`fish`→Fish,`pwsh`/`pwsh-preview`→Pwsh,`powershell`→PowerShell,`cmd`→Cmd,`nu`→Nu,`wsl`→Wsl,`ssh`→Ssh,其他→Other。Kind 决定 argv0 形式(§3)和注入方式(§5)。用户可以在 profile 里写 `"kind"` 强制指定(例如 `/usr/local/bin/mybash` 实际是 bash)。 + +### 2.2 macOS 与 Linux + +``` +DetectUnix(): + account = tcode_get_account() // libtcodepty:getpwuid_r(getuid()) → { name, home, shell } + shells = ParseEtcShells("/etc/shells") + result = [] + + accountShell = account.shell + if !IsUsable(accountShell): // 空、不存在、不可执行、/usr/bin/false、*/nologin + accountShell = env(SHELL) if IsUsable(env(SHELL)) else null // 只作回退 + if accountShell == null: + accountShell = macOS ? "/bin/zsh" : FirstUsable("/bin/bash", "/bin/sh") + + for path in [accountShell] ++ shells: // 账户 shell 放第一个,即使它不在 /etc/shells 里 + if !IsUsable(path) or IsExcluded(path): continue + real = realpath(path) + if seen(real): continue // Linux merged-usr:/bin/bash 与 /usr/bin/bash 指向同一个文件 + result += MakeProfile(path, isAccountShell: path == accountShell) + return result + +ParseEtcShells(file): + 逐行;去掉首尾空白;跳过空行与 '#' 开头的行;只接受以 '/' 开头的绝对路径;文件不存在时返回 [] + +IsExcluded(path): basename ∈ { rbash, git-shell, nologin, false, true, tmux, screen, scponly, rssh } +``` + +- **为什么以 passwd 为准**:login(1)、VTE、Ptyxis、WezTerm、从桌面启动的 Ghostty 都这样做;`chsh` 之后 `$SHELL` 会过期,直到用户重新登录桌面 [实 research/05 §1.1–1.2]。`$SHELL` 只在 passwd 不可用时使用(例如容器里 passwd 的 shell 字段是 `/sbin/nologin`)。 +- **getpwuid 放在 C shim 里**:`struct passwd` 在 macOS 上多出 `pw_change`、`pw_class`、`pw_expire` 字段,布局与 glibc 不同,不适合在 C# 中镜像。`libtcodepty` 增加 `tcode_get_account(buf, len)`,返回以 NUL 分隔的 `name\0home\0shell\0` [定]。macOS 上 getpwuid 会经过 opendirectoryd,结果与 `dscl . read /Users/$USER UserShell` 相同 [推]。 +- **`sh` 和 `dash` 也列出**,但排在后面(VS Code 也列出 `/etc/shells` 的每一项 [实 VSC src/vs/platform/terminal/node/terminalProfiles.ts:401-440])。`tmux`、`screen` 在 Debian 的 `/etc/shells` 里,但它们不是 shell,排除;需要时用户可以写自定义 profile。 +- **Linux Flatpak**(以后):`/.flatpak-info` 存在时,账户 shell 要在宿主机上用 `flatpak-spawn --host getent passwd $USER` 查询,启动也要经 `flatpak-spawn --host`(Ptyxis、Ghostty 的做法 [实 research/05 §1.2])。v1 不发布 Flatpak,`IsFlatpak()` 时只给出一条"不支持"的警告 [定]。 +- **macOS 上命令的解析**:host 是从 Dock 启动的 Desktop 拉起的,`PATH` 只有 `/usr/bin:/bin:/usr/sbin:/sbin`。自定义 profile 写 `"command": "nu"` 这样的短名时,在 host 的 PATH 里找不到 Homebrew 的程序。解析顺序:绝对路径 → 基础环境的 PATH → `path_helper -s` 给出的 PATH(`/etc/paths` + `/etc/paths.d/*`)→ 常见目录 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.cargo/bin`、`~/.local/bin`、`~/.nix-profile/bin`、`/nix/var/nix/profiles/default/bin`、`/run/current-system/sw/bin`。找到后以**绝对路径**写入 `PtyLaunch.File` [定]。原因:login 包装里的 bash 用 `--noprofile` 启动,此时 PATH 还没有被 login 文件补全,`exec` 找不到短名。 + +### 2.3 Windows + +探测器按下表顺序运行。路径中的环境变量用 host 进程的值展开(`%ProgramFiles%` 等不会因用户而异)。 + +| # | 探测器 | 候选路径 / 方法 | 产出 | 来源 | +|---|---|---|---|---| +| 1 | PowerShell 7 | ① `%ProgramFiles%\PowerShell\\pwsh.exe`(枚举子目录:纯数字为稳定版,`-preview` 为预览版);② `%ProgramFiles(x86)%\PowerShell\\pwsh.exe`(WOW);ARM64 系统上再查 `%ProgramFiles(Arm)%`;③ Store/MSIX 应用执行别名 `%LOCALAPPDATA%\Microsoft\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe`、`…\Microsoft.PowerShellPreview_8wekyb3d8bbwe\pwsh.exe`;④ `%USERPROFILE%\.dotnet\tools\pwsh.exe`;⑤ Scoop:`%SCOOP%`(缺省 `%USERPROFILE%\scoop`)`\apps\pwsh\current\pwsh.exe`,其次 `\shims\pwsh.exe`;⑥ 最后在 PATH 中找 `pwsh.exe`(Chocolatey 等),按 realpath 去重 | `pwsh`、`pwsh-preview`、其余为 `pwsh@<变体>`(`Hidden`) | WT TSM/PowershellCoreProfileGenerator.cpp:52-135;VSC src/vs/base/node/powershell.ts:250-305 [实] | +| 2 | Windows PowerShell | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` | `powershell` | WT TSM/defaults.json:38-56 [实] | +| 3 | cmd | `%SystemRoot%\System32\cmd.exe`(不用 `%ComSpec%`,防止被改写;只有找不到时才回退到 `%ComSpec%`) | `cmd` | WT defaults.json:57-75 [实] | +| 4 | Git Bash | ① 注册表 `HKLM\SOFTWARE\GitForWindows` 的 `InstallPath`([推]:Git for Windows 安装程序写入);② PATH 中 `git.exe` 上两级目录;③ `%ProgramW6432%`、`%ProgramFiles%`、`%ProgramFiles(x86)%`、`%LOCALAPPDATA%\Programs` 下的 `Git`;④ Scoop 的 `apps\git\current`、`apps\git-with-openssh\current`。每个根目录依次试 `bin\bash.exe`、`usr\bin\bash.exe`。**不要用 `git-bash.exe`**:它会启动 mintty 窗口 | `git-bash`,参数 `--login -i`,`PathStyle=Msys` | VSC terminalProfiles.ts:286-336 [实] | +| 5 | WSL | 见下方专门小节 | `wsl:<名>`,Kind=Wsl,`PathStyle=Wsl` | WT TSM/WslDistroGenerator.cpp:19-101 [实] | +| 6 | MSYS2 | `%HOMEDRIVE%\msys64\usr\bin\bash.exe`;参数 `--login -i`;环境 `MSYSTEM=UCRT64`([推]:MSYS2 目前推荐的默认环境)、`CHERE_INVOKING=1`(保持起始目录) | `msys2`,`RequiresTrust=true`,`PathStyle=Msys` | VSC terminalProfiles.ts:125-134 [实] | +| 7 | Cygwin | `%HOMEDRIVE%\cygwin64\bin\bash.exe`、`%HOMEDRIVE%\cygwin\bin\bash.exe`;参数 `--login -i` | `cygwin`,`RequiresTrust=true`,`PathStyle=Cygwin` | VSC terminalProfiles.ts:117-124 [实] | +| 8 | VS 开发者命令行(异步) | `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe -all -prerelease -format json -utf8 -nologo`(超时 3 s),取 `instanceId`、`installationPath`、`displayName`、`installationVersion` | `vs-devcmd:`、`vs-devshell:`,`Hidden=true` | WT TSM/VsDevCmdGenerator.cpp:39-67、VsDevShellGenerator.cpp:11-89 [实];用 vswhere 而不是 COM `ISetupConfiguration2`,避免在 AOT 里做 COM 互操作 [定] | + +**pwsh 的排名**(照搬 WT)[实 WT PowershellCoreProfileGenerator.cpp:150-346]: + +1. 主版本号高者优先; +2. 同版本时按安装形态:Store > Scoop > dotnet tool > 传统安装(Program Files)> WOW(x86); +3. 预览版单独排名,第一名为 `pwsh-preview`。 + +版本号取目录名(`7`、`7-preview`);Store 和 dotnet tool 无法从路径得到版本时,读文件版本资源(`FileVersionInfo.GetVersionInfo`,AOT 可用 [推]);应用执行别名是 0 字节的 reparse point,读不到版本时按"Store 最新"处理 [推]。 + +**应用执行别名的检测**:`File.Exists` 对 `IO_REPARSE_TAG_APPEXECLINK` 返回 true [推],`CreateProcessW` 也能直接启动它(WT 就是这样启动 Store 版 pwsh 的)[推]。实现时加一条集成测试(Windows CI 上从 Store 安装 pwsh 不可行,用 mock 的属性测试 + 人工验收)。 + +**cmd 与 VS 开发者命令行用 `RawCommandLine`**: + +- VS DevCmd:`cmd.exe /k "\Common7\Tools\VsDevCmd.bat" -startdir=none -arch= -host_arch=`; +- VS DevShell:` -NoExit -Command "&{Import-Module '\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'; Enter-VsDevShell -SkipAutomaticLocation -DevCmdArguments '-arch= -host_arch='}"`,`Integration = AppendToPwshCommand`(§5.3.4)。 + +`` 取 host 进程的架构(`RuntimeInformation.OSArchitecture`:`x64`、`arm64`)[实 WT VsDevCmdGenerator.cpp]。 + +#### WSL + +WT 的做法是读注册表,不运行 `wsl.exe` [实 WT WslDistroGenerator.cpp:19-23、195-215]。VS Code 运行 `wsl.exe -l -q` [实 VSC terminalProfiles.ts:347-389]。我们以注册表为主,命令为回退 [定]: + +``` +DetectWsl(): + if OSBuild < 19041: 不探测(与 VS Code 一致;19041 起 wsl.exe 才支持 --cd 与 Linux 路径) + wslExe = GetSystemDirectory() + "\wsl.exe" // 绝对路径,防止 PATH 劫持(WT GH#11096) + if !exists(wslExe): return [] + + key = HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss + if key 存在: + for sub in key.SubKeyNames(形如 {guid}): + name = sub["DistributionName"] as string + if name 为空: continue + if name 以 "docker-desktop" 或 "rancher-desktop" 开头: continue + // WT 还跳过 Modern=1 的发行版:那是给 WT fragment 机制让路 [推];TCode 不读 fragment,所以不跳过 + yield name(保持注册表枚举顺序;DefaultDistribution 的 guid 对应的发行版排第一) + else: + out = platform.Processes.RunAsync(wslExe, ["-l", "-q"], env: { WSL_UTF8: "0" }, timeout: 2s) // 放进 DetectSlowAsync;经 §2.8 的外部命令执行器 + if out.ExitCode != 0: return [] // 没有发行版时 wsl.exe 打印提示并返回非 0 + text = UTF16LE.Decode(out.Stdout) // 强制 WSL_UTF8=0,否则用户设置了 WSL_UTF8=1 时会变成 UTF-8(VS Code #276253) + text = text.TrimStart('\uFEFF').Replace("\0", "") + names = text.Split('\n').Select(l => l.TrimEnd('\r').Trim()).Where(l => l.Length > 0) + 同上过滤 docker-desktop / rancher-desktop +``` + +- **命令行**:`\wsl.exe -d <名>`;起始目录用 `--cd`(§3.4)。 +- **为什么注册表优先**:`wsl.exe -l` 在某些版本上会唤醒 WSL 服务,冷启动可达数百毫秒;注册表读取不到 1 ms [推]。 +- **图标**:按名字含 Ubuntu / Debian / openSUSE / Alpine / Fedora / Arch / kali 选 lobe 或内置图标,否则用通用 Linux 图标(VS Code `getWslIcon` 的思路)[实 VSC terminalProfiles.ts:391+]。 + +### 2.4 profile 的"信任" + +`RequiresTrust`(MSYS2、Cygwin):这些路径位于 `C:\` 根目录下,普通用户就能创建,可能被植入恶意程序 [实 VSC terminalProfiles.ts:117-134 的 unsafe 标记]。首次从这类 profile 启动时弹窗确认,显示完整路径;确认结果写入 `state.json` 的 `terminal.trustedProfiles`(按 `id + 路径` 记录,路径变了要重新确认)[定]。设置文件中显式写出的自定义 profile 视为已信任(用户自己写的)。 + +### 2.5 探测结果的展示字段 + +| 字段 | macOS / Linux | Windows | +|---|---|---| +| `Name` | `zsh`、`bash (Homebrew)`(路径在 `/opt/homebrew`、`/usr/local/Cellar` 下时) | `PowerShell`、`PowerShell 7 (Store)`、`Windows PowerShell`、`命令提示符`、`Git Bash`、`Ubuntu-24.04 (WSL)`、`MSYS2`、`Cygwin`、`Developer Command Prompt for VS 2026`、`Developer PowerShell for VS 2026` | +| `Icon` | 按 Kind 选 lobe-icons / 内置图标 | 同左 | +| `Version` | 不探测(避免运行 `--version`) | pwsh 的版本、`Store` / `Scoop` 等形态 | + +名字走本地化资源(`strings.tsv`),由 Desktop 根据 id 前缀和 Kind 生成;host 只返回结构化字段 [定]。 + +### 2.6 耗时与缓存 + +| 步骤 | 预算 | 说明 | +|---|---|---| +| Unix 冷探测 | < 5 ms | 一次 getpwuid + 读 `/etc/shells` + 十几次 `stat`/`access` | +| Windows 同步部分 | < 150 ms | 约 40 次文件检查 + 2 次注册表读取 + PATH 查找;`FileVersionInfo` 只对 pwsh 候选读取 | +| Windows 异步部分 | vswhere ≤ 3 s 超时;`wsl.exe -l -q` ≤ 2 s 超时 | 后台运行,完成后推送 `profiles/changed`;外部命令一律经 §2.8 的执行器 | + +**缓存策略** [定]: + +1. host 内存中缓存 `ResolvedProfile[]` 和每个探测器的输入指纹: + - Unix:`/etc/shells` 的 mtime + 账户 shell 字符串; + - Windows:Lxss 键的最后写入时间、各候选路径存在与否的位图、PATH 字符串的哈希。 +2. **刷新时机**: + - host 启动; + - 收到 `profiles/refresh`(设置页的"重新探测"按钮); + - `host/configure` 带来新的 profiles 覆盖时(只重新合并,不重新探测); + - Desktop 打开"+"菜单时,若缓存超过 30 s,就先返回缓存,同时后台重新探测;结果有变化时推送 `profiles/changed`(stale-while-revalidate)。 +3. **磁盘缓存**:每次结果变化时,host 把它写到 `$TCODE_HOME/cache/terminal-profiles.json`(0600,带 `hostVersion` 字段)。Desktop 在 host 尚未运行时读它来显示菜单,菜单项可以立即点击:点击后再启动 host,host 重新解析 profile id。 +4. **AgentHost** 通过 `profiles/list` 读取,不单独缓存。 + +### 2.7 探测失败时的回退 + +| 情况 | 行为 | +|---|---| +| 某个探测器抛异常(注册表权限、路径非法) | 记日志,跳过该探测器,其他照常 | +| 列表为空 | Unix:合成一个 `sh` profile(`/bin/sh`);Windows:合成 `cmd`(`%SystemRoot%\System32\cmd.exe`,没有时用 `%ComSpec%`)。**保证至少有一个 profile** | +| `defaultProfile` 指向不存在或已停用的 id | 设置页显示警告;新终端用 `system` | +| profile 的 `command` 在启动时已经不存在(例如卸载了 pwsh) | 启动失败横幅:"找不到 `<路径>`",提供"改用默认 profile"和"重新探测" | + + +### 2.8 外部命令的执行:不用 `System.Diagnostics.Process` + +pty host 进程内**不使用 `System.Diagnostics.Process` 和 `System.Console`**:使用它们会让 .NET 安装 SIGCHLD 处理器并改动终端设置,与 host 自己的子进程回收冲突(03 §10 R5、04 的实现约束)。探测和启动构造中需要运行的外部命令有: + +| 命令 | 平台 | 用途 | +|---|---|---| +| `wsl.exe -l -q` | Windows | WSL 回退探测(§2.3) | +| `vswhere.exe …` | Windows | VS 开发者命令行(§2.3) | +| `/usr/libexec/path_helper -s` | macOS | 非 login profile 的 PATH 修补、短名解析(§2.2、§4.5) | +| `ssh -V`、`ssh -G` | 全部 | 08 §2.1、§2.3 | + +这些命令都经 `IShellPlatform.Processes`(§9.1)运行,host 中的实现是 `HelperProcessRunner` [定]: + +- **Unix**:用 libtcodepty 以**管道模式**(stdin 接 `/dev/null`,stdout/stderr 接管道,不分配 PTY)启动 `tcode-pty-spawn --exec `。helper 复位全部信号后 execvp(03 §3、§12),子进程的回收走 host 自己的退出监视(`waitpid(pid)`,03 §6),不经过 .NET; +- **Windows**:按 04 的约束同样经 `tcode-pty-spawn --exec`。如果 03 最终不为 Windows 提供 helper(03 §3 的 helper 是 Unix C 程序),则改为在 `TCode.Terminal.Pty` 中直接用 `CreateProcessW` + 匿名管道 + `STARTUPINFOEX` 的 `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`(只继承这两个管道句柄)+ `CREATE_NO_WINDOW` 实现,同样不使用 `Process`; +- 统一的约束: + - 超时后杀死子进程(Unix `kill(SIGKILL)`;Windows `TerminateProcess`),并读完管道,避免僵尸进程和句柄泄漏; + - stdout 上限 1 MiB,超出时截断并视为失败; + - 环境使用 07 §4 的剔除规则后的 BaseEnvironment(不能把 pty host 的令牌带给外部命令); + - 输出按字节返回,由调用方解码(`wsl.exe` 是 UTF-16LE,其他是 UTF-8)。 +- 03 目前的 C API 中只有 PTY 模式的 `tcpty_spawn`,**管道模式的启动接口需要 03 补充**(列入 §10)。 + +--- + +## 3. 启动命令的构造(LaunchBuilder) + +### 3.1 流水线 + +``` +Build(profile, ctx): + 1. 生效配置 = ctx.Config 中该 profile 的覆盖 ⊕ profile 自带字段 + 2. 解析可执行文件为绝对路径(§2.2 的搜索顺序;Windows 用 SearchPathW 规则 + PATHEXT) + 3. 计算工作目录(§6.4),校验存在;不存在时回退到项目根目录 → home,并加一条 Warning + 4. 计算环境变量(§4.6 的第 1–7 步) + 5. 决定 shell 集成:IntegrationPlanner(profile.Kind, args, cfg, platform)(§5.3、§5.5)→ 修改 args 与 env(§4.6 第 8 步) + 6. 平台包装:macOS login(§3.2)| Linux argv0(§3.3)| Windows 命令行拼接(§3.4) + 7. 产出 PtyLaunch(File, Arguments, Argv0, WorkingDirectory, Environment, InitialSize, WidthMode) 和 IntegrationPlan +``` + +`LaunchBuilder` 是纯函数:所有系统访问都经过 `IShellPlatform`,测试时用假实现(§9.1)。 + +### 3.2 macOS:argv0 形式的 login shell(不经 login(1)) + +> 2026-10-17 用户决定:**放弃 `/usr/bin/login` 包装**(原 D9)。原因是 login 父进程总是 `exit(0)`,shell 的退出码会丢失(03 §8.3 已核实 login.c:854-883),"shell 以非 0 退出则保留标签"的行为就无法实现。 + +**命令形态** [定]:spawn helper 直接 exec 目标 shell,argv0 设为 `-`(例如 `-zsh`),shell 因此作为 login shell 启动。这是 WezTerm 的做法,也就是 bash 的 `exec -l` 的效果。 + +| 项 | 说明 | +|---|---| +| argv0 | `"-" + Path.GetFileName(shell)`,例如 `-zsh`、`-fish`、`-bash`(与 Terminal.app 的 argv0 相同) | +| PATH | zsh 和 bash 作为 login shell 会读 `/etc/zprofile`、`/etc/profile`,其中调用 `path_helper`,补齐 `/etc/paths` 与 `/etc/paths.d`(包括 Homebrew 写进去的路径)[实 research/05 §1.1];fish 的 login 启动同样会处理 `/etc/paths`;其余 shell 按 §4.5 修补 | +| 工作目录 | 由 spawn helper 在 exec 前 chdir(03 §3) | +| 与 Terminal.app 的差异 | 没有 utmp/wtmp 记录(`who`、`last` 里看不到这个终端),`getlogin()` 返回的可能不是该终端的会话用户,也不显示 "Last login"。这三点对日常使用基本没有影响,换来的是**准确的退出码**和更简单的进程树 | +| 退出码 | PTY 的直接子进程就是 shell,`waitpid` 得到的就是 shell 的真实状态;不需要 03 的 `NOTE_EXITSTATUS` 方案 | +| 前台进程 / cwd | spawn 返回的 pid 就是 shell,其 pgid 等于 pid;cwd 兜底查询直接用这个 pid | + +**按 Kind 区分**: + +| Kind | File | argv | 说明 | +|---|---|---|---| +| Zsh、Fish、Nu、Other(sh、ksh、tcsh…) | shell 路径 | `[-, args…]` | 真正的 login shell | +| Bash(注入 shell 集成时) | bash 路径 | `[bash, --init-file, <脚本>, args…]`,并设置 `TCODE_SI_BASH_LOGIN=1` | argv0 以 `-` 开头时,bash 作为 login shell 会忽略 `--init-file`。所以改为非 login,由脚本模拟 login 的启动文件(VS Code 的方式,兼容 bash 3.2)[实 VSC shellIntegration-bash.sh:30-58] | +| Bash(不注入时) | bash 路径 | `[-bash, args…]` | | +| Pwsh | pwsh 路径 | `[pwsh, -Login, args…]` | pwsh 的 `-Login` **必须是第一个参数**,否则被忽略 [实 learn.microsoft.com about_Pwsh] | +| 非 shell 的自定义命令(`kind: "other"`,例如 `htop`) | 命令路径 | `[name, args…]` | 不加 `-`;PATH 按 §4.5 修补 | + +**`loginShell: false`(用户关闭)**:argv0 为普通名字(`zsh`),PATH 按 §4.5 修补。 + +### 3.3 Linux + +- **默认非 login 的交互式 shell**:直接 exec,参数不加 `-l`(GNOME Terminal、Ptyxis、Konsole、VS Code、Ghostty、Alacritty 一致 [实 research/05 §1.2])。 +- **`loginShell: true`**:`PtyLaunch.Argv0 = "-" + basename`(GNOME Terminal 的做法)。例外:bash 注入时改用 `TCODE_SI_BASH_LOGIN=1`(同 §3.2 的原因);pwsh 用 `-Login` 作为第一个参数。 +- 不经 `/bin/sh -c` 包装(Ghostty 的 `.shell` 形式会包一层 sh [实 GH Exec.zig:2007-2019]):我们的 profile 本来就是 argv 数组,不需要 shell 解析。 + +### 3.4 Windows + +#### 命令行拼接(CommandLineToArgvW / MSVCRT 规则) + +ConPTY 的 `CreateProcessW` 只接受一个字符串。参数数组按下列算法拼接 [实 learn.microsoft.com/cpp/c-language/parsing-c-command-line-arguments;Daniel Colascione 的 "Everyone quotes command line arguments the wrong way"]: + +``` +Join(file, args): + // 第一个 token(程序名)的解析规则不同:不处理反斜杠转义,只认引号 + s = file 含空格或制表符 ? "\"" + file + "\"" : file // file 不得含 '"'(Windows 文件名本来就不允许) + for a in args: s += " " + QuoteArg(a) + return s + +QuoteArg(a): + if a != "" and a 不含 { ' ', '\t', '\n', '\v', '"' }: return a + r = "\"" + i = 0 + while i < len(a): + n = 0 + while i < len(a) and a[i] == '\\': n++; i++ + if i == len(a): r += '\\' × (2n); break // 结尾的反斜杠要加倍,否则会转义收尾的引号 + if a[i] == '"': r += '\\' × (2n + 1) + '"' + else: r += '\\' × n + a[i] + i++ + return r + "\"" +``` + +- `lpApplicationName` 传绝对路径,避免 `CreateProcess` 按空格逐段猜测可执行文件 [定]。 +- **cmd.exe 与 `.bat`/`.cmd` 不按这套规则解析**:cmd 自己处理 `/c`、`/k` 后面的引号,并解释 `& | < > ^ %` 等元字符。CreateProcess 启动 `.bat` 时也会隐式经过 cmd(BatBadBut,CVE-2024-24576)[实]。因此: + - 内置的 cmd 类 profile 使用 `RawCommandLine`,原样传入,不再拼接; + - 用户自定义的 profile 可以写 `"commandLine": "…"`(仅 Windows)来提供原始命令行; + - **来自 Agent 或 ACP 的参数**(09)如果目标是 cmd/.bat/.cmd,参数中出现 `& | < > ^ % ! " \r \n` 时拒绝启动,不尝试转义 [定]。 +- **pwsh 的 `-Command` 字符串**:PowerShell 单引号字符串中,`'` 写成 `''`。我们构造的是 `-Command`,其中的路径用 `''` 包裹,再整体用 `QuoteArg` 拼接。 + +#### WSL 的 `--cd` + +| 起始目录来源 | 参数 | 说明 | +|---|---|---| +| 默认(没有项目) | `--cd ~` | WT 在 build ≥ 19041 时用 `~` [实 WT WslDistroGenerator.cpp:35-46、70-77] | +| 项目根目录(Windows 路径) | `--cd "C:\work\proj"` | wsl.exe 会转换为 `/mnt/c/work/proj` [推] | +| 从 WSL 标签继承(OSC 7 给出 Linux 路径,§6.4) | `--cd /home/u/src` | 要求 build ≥ 19041 | +| 用户在 profile 中写了 `cwd` | 原样传给 `--cd` | | + +`lpCurrentDirectory` 同时设为一个存在的 Windows 目录(项目根目录或 `%USERPROFILE%`)。UNC 路径(`\\wsl.localhost\…`)不能作为 `lpCurrentDirectory`,否则 CreateProcess 会失败 [推]。 + +#### Git Bash / MSYS2 / Cygwin + +- 注入脚本的路径用正斜杠形式:`C:/Users/u/.tcode/shell-integration/…/tcode.bash`。MSYS 运行时能识别这种形式 [推;VS Code 直接传 Windows 路径,同样可用]。 +- `MSYSTEM`、`CHERE_INVOKING` 来自 profile 的 `Environment`,用户可以覆盖。 +- Git Bash 的 `bin\bash.exe` 是一个包装程序,它会设置 `MSYSTEM=MINGW64` 和 PATH,然后启动 `usr\bin\bash.exe` [推]。所以不要直接用 `usr\bin\bash.exe`(只有 `bin\` 不存在时才用)。 + +### 3.5 用户参数与注入的兼容性 + +用户在 profile 里写了参数时,只有能确认注入安全,才会注入(VS Code 的做法,否则返回 `UnsupportedArgs`)[实 VSC terminalEnvironment.ts:330-380]: + +| Shell | 允许注入的参数(不区分大小写) | 这些参数会阻止注入 | +|---|---|---| +| bash | 空;`-l`/`--login`(转为模拟 login);`-i`/`--interactive`;`--noprofile`(模拟 login 时跳过 profile 文件) | `-c`、`--rcfile`/`--init-file`、`--norc`、`--posix`、`-s`、脚本文件 | +| zsh | 空;`-l`/`--login`;`-i` | `-c`、`-f`/`--no-rcs`、`-d`/`--no-globalrcs`、`-o …`、脚本文件 | +| fish | 空;`-l`/`--login`;`-i`;`--private`/`-P` | `-c`、`--no-config`/`-N`、`-C`/`--init-command` 已存在时仍可注入(fish 允许多个 `-C`)[推] | +| pwsh / powershell | 空;`-NoLogo`/`-nol`;`-Login`/`-l`;`-NoProfileLoadTime`;`-WorkingDirectory

` | `-Command`、`-c`、`-File`、`-f`、`-EncodedCommand`、`-NonInteractive`、`-NoExit`(已有)、`-ConfigurationName` | +| nu | 不注入(§5.3.5) | — | + +被阻止时 `IntegrationPlan.Mode = None`,`Reason = UnsupportedArgs`,并在 UI 中说明(§5.5)。 + +--- + +## 4. 环境变量策略 + +### 4.1 基础环境从哪里来 + +- **问题**:pty host 是一个长期运行的进程,可以跨越 Desktop 的多次重启(01 §3.3 的 keepAlive、UI 崩溃重连)。如果用 host 自己的环境,那么用户换了一个环境启动 TCode(例如从带 `nvm` 的终端里 `open -a TCode`),新终端仍然会用第一次启动 host 时的环境。 +- **决定** [定]: + - Desktop 在 `Program.Main` 一开始(任何代码修改环境之前)抓取 `Environment.GetEnvironmentVariables()` 作为 **BaseEnvironment**,在 `host/configure` 中发给 host; + - host 按客户端保存一份,`session/create` 时使用发起请求的那个 Desktop 的 BaseEnvironment; + - AgentHost 发起的会话使用最近一次连接的 Desktop 的 BaseEnvironment(AgentHost 本来就是 Desktop 启动的,它自己的环境里还有 TCode 内部变量)。 +- **Windows 的环境刷新**(`reloadEnvironment`,默认 true):在 BaseEnvironment 的基础上,用注册表中的值替换同名变量: + - 读取 `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`、`HKCU\Environment`、`HKCU\Volatile Environment`; + - `REG_EXPAND_SZ` 用 `ExpandEnvironmentStringsW` 展开; + - `Path` 按"系统;用户"拼接,其他变量用户值覆盖系统值; + - 注册表中没有的变量(父进程临时设置的)保留。 + + 效果:用户安装软件修改 PATH 之后,新终端不需要重启 TCode 就能看到。来源:WT `compatibility.reloadEnvironmentVariables` [实 WT ConptyConnection.cpp:296-316;src/inc/til/env.h:237-270、446-476]。WT 是否保留"注册表中没有的变量"[推]。 + +### 4.2 设置的变量 + +| 变量 | 值 | 平台 | 依据 | +|---|---|---|---| +| `TERM` | `xterm-256color`(`terminal.term` 可改) | macOS、Linux;Windows 上只对 Git Bash / MSYS2 / Cygwin 设置(MSYS 程序依赖 TERM)[推] | D9;research/03 §7.3 | +| `COLORTERM` | `truecolor` | 全部 | research/05 §2.3 | +| `TERM_PROGRAM` | `TCode` | 全部。**不要**设为 `Apple_Terminal`,否则会触发 `/etc/zshrc_Apple_Terminal` 的会话保存逻辑 | research/05 §2.3;与 XTVERSION 的名称一致(research/03 §7.3) | +| `TERM_PROGRAM_VERSION` | Desktop 的语义化版本号,例如 `0.9.0` | 全部 | 同上 | +| `TCODE_TERMINAL_ID` | 会话 uuid(与 pty host 的会话 id 一一对应,但不是 IPC 凭据) | 全部 | [定] 类似 `WT_SESSION`、`WEZTERM_PANE`。供以后的 `tcode` CLI 或 Agent 定位"当前终端";不含任何秘密。09 可以决定不要它 | +| `SHELL` | Unix:passwd 中的账户 shell(不是当前 profile 的 shell) | macOS 上 login(1) 本来就会这样设置;Linux 上顺便修正 chsh 之后的过期值 | LOGIN:954-955 [实];WZ env-bootstrap/src/lib.rs:236-240 [实] | +| `LANG`(或 `LC_CTYPE`) | §4.4 | macOS | | +| `WSLENV` | §4.7 | Windows,Kind=Wsl | | +| 注入用的临时变量 `TCODE_SI_*` | §5.3 | 注入时 | 脚本读取后立即 unset | + +**不设置**: + +- `TCODE_AGENT`:这是 `BashTool` 给 Agent 命令设置的标记(src/TCode.Agent.Coding/Tools/Shell/BashTool.cs)[实 research/05 §2.3],交互终端不能带。Agent 创建的可见终端是否设置,由 09 通过 `LaunchContext.ExtraEnvironment` 决定。 +- `COLUMNS`、`LINES`:程序应当通过 `TIOCGWINSZ` 取尺寸。 + +### 4.3 剔除的变量 + +按以下顺序检查,命中即剔除(名称比较:Unix 区分大小写,Windows 不区分)[定,汇总自 research/05 §2.2–2.3]: + +| 类别 | 按名称 | 按前缀 | +|---|---|---| +| TCode 内部 | — | `TCODE_`(保留 `TCODE_HOME`,因为它是面向用户的配置)。**特别注意**:01 §3.3 规定 AgentHost 通过环境变量拿到 pty host 的连接地址和令牌(例如 `TCODE_PTYHOST_*`),这些变量**必须**不能进入用户 shell,否则 shell 里的任何程序都能连上 pty host 并操作所有终端 | +| 其他终端遗留 | `COLORFGBG`、`COLORTERM`、`TERM`、`TERMCAP`、`TERMINFO`、`DEFAULT_COLORS`、`LC_TERMINAL`、`LC_TERMINAL_VERSION`、`MLTERM`、`STY`、`TMUX`、`TMUX_PANE`、`VTE_VERSION`、`WINDOWID`、`WT_SESSION`、`WT_PROFILE_ID`、`WCWIDTH_CJK_LEGACY`、`TERM_SESSION_ID`、`TERM_FEATURES`、`KITTY_WINDOW_ID`、`KITTY_PID`、`KITTY_PUBLIC_KEY`、`KITTY_INSTALLATION_DIR` | `ALACRITTY_`、`CONTOUR_`、`FOOT_`、`GHOSTTY_`、`GNOME_TERMINAL_`、`ITERM_`、`ITERM2_`、`KITTY_`、`KONSOLE_`、`MC_`、`MINTTY_`、`MOSH_`、`PUTTY_`、`RXVT_`、`TERM_PROGRAM`(含 `_VERSION`,随后重新设置)、`TERMINAL_`、`URXVT_`、`VSCODE_`、`WEZTERM_`、`XTERM_`、`ZUTTY_`、`ELECTRON_` | +| 会话层级 | `SHLVL`、`PWD`、`OLDPWD`、`_`(bash 的上一条命令) | — | +| Linux 桌面与 systemd | `DESKTOP_STARTUP_ID`、`XDG_ACTIVATION_TOKEN`、`GIO_LAUNCHED_DESKTOP_FILE`、`GIO_LAUNCHED_DESKTOP_FILE_PID`、`INVOCATION_ID`、`JOURNAL_STREAM`、`NOTIFY_SOCKET`、`MANAGERPID`、`MAINPID`、`PIDFILE`、`EXIT_CODE`、`EXIT_STATUS`、`SERVICE_RESULT` | `LISTEN_`、`WATCHDOG_`、`DBUS_STARTER_`、`GDK_DEBUG`、`GSK_` | +| Snap / AppImage | `APPIMAGE`、`APPDIR`、`OWD`、`ARGV0` | `SNAP` | +| macOS 应用身份 | `__CFBundleIdentifier`、`XPC_SERVICE_NAME`、`XPC_FLAGS` | `__XPC_` | +| 开发期启动器 | `DOTNET_LAUNCH_PROFILE`、`DOTNET_MODIFIABLE_ASSEMBLIES`、`DOTNET_STARTUP_HOOKS`、`COR_ENABLE_PROFILING` | `DOTNET_WATCH`、`MSBuild`、`MSBUILD`、`CORECLR_`、`COR_PROFILER` | + +说明: + +- **`DOTNET_*` 不一刀切**:NativeAOT 程序没有宿主,不会自己写入 `DOTNET_*`;出现这些变量,只能是用户或启动工具设置的。所以只剔除开发期注入的那几个,`DOTNET_ROOT`、`DOTNET_CLI_TELEMETRY_OPTOUT` 等保留 [实 research/05 §2.3]。 +- **`TERMINFO_DIRS` 保留**(NixOS、Homebrew ncurses 用户可能有意设置),只有当它指向其他终端的资源目录(路径中含 `Ghostty.app`、`kitty.app`、`/kitty/terminfo`、`WezTerm`)时才去掉那一段 [定]。 +- **用户在 `terminal.env` 里显式写出的变量不受剔除影响**:合并时剔除在前,用户设置在后(§4.6)。 + +### 4.4 macOS 的 LANG + +- **为什么要补**:从 Dock 启动的程序没有 `LANG` [实 research/05 §1.1]。macOS 26 的 `/etc/zprofile` 会在 `LANG` 为空时设为 `C.UTF-8` [实 local],但只有 zsh login shell 会读它,bash 和 fish 不会;而且 `C.UTF-8` 会让程序的消息、日期格式都退回英文和 C 习惯。 +- **规则**(`setLocaleVariables`,默认 `auto`): + +``` +FixLocale(env): + if setLocaleVariables == off: return + if setLocaleVariables == auto and env.LANG 存在 and IsUtf8(env.LANG) and LocaleExists(env.LANG): return + // 按系统区域,不按 TCode 界面语言(research/05 决策 8) + CFPreferencesAppSynchronize(kCFPreferencesAnyApplication) // host 是长期进程,每次重新读取,避免缓存过期 + langs = CFPreferencesCopyAppValue("AppleLanguages") // ["zh-Hans-CN", "en-CN"] + locale = CFPreferencesCopyAppValue("AppleLocale") // "zh_CN" 或 "en_US@rg=cnzzzz" + country = 先取 "@rg=" 中的地区(rg=cnzzzz → CN),否则取 locale 中 "_" 后面的部分 + candidates = [] + for l in langs, 直到并包括第一个两字母代码: // iTerm2 的取法 + lang = l 去掉第一个 "-" 之后的部分(zh-Hans-CN → zh) + candidates += lang + "_" + country + ".UTF-8" + candidates += locale 去掉 "@…" 部分 + ".UTF-8" + for c in candidates: if LocaleExists(c): env.LANG = c; return + env.LC_CTYPE = "UTF-8" // macOS 上 "UTF-8" 是有效的 LC_CTYPE 值 +LocaleExists(name) = Directory.Exists("/usr/share/locale/" + name) +IsUtf8(v) = v 以 ".UTF-8" 或 ".utf8"(不区分大小写)结尾 +``` + + 来源:iTerm2 `iTermLocaleGuesser`(候选列表与验证)[实 IT sources/Locale/iTermLocaleGuesser.swift:87-230];Terminal.app 的 "Set locale environment variables on startup" [实 research/05 §2.1]。CoreFoundation 的调用用 `LibraryImport` 声明在 `TCode.Terminal.Shell` 的 macOS 部分,不依赖 AppKit [定]。 +- `on`:总是按上述规则覆盖 `LANG`。Linux 和 Windows 不处理 locale。 + +### 4.5 GUI 启动时 PATH 缺失 + +| 场景 | 处理 | +|---|---| +| macOS + login(默认) | login shell 的 `/etc/zprofile`(path_helper)和 `~/.zprofile` 会补全 PATH,**不做任何处理**。这与 Terminal.app 一致,也避免了 VS Code 遇到的 path_helper 重排问题 [实 research/05 §1.5] | +| macOS + `loginShell: false` | 启动前把 `path_helper -s` 的结果(`/etc/paths` + `/etc/paths.d/*`)与 BaseEnvironment 的 PATH 合并去重,前者在前 [定]。`~/.zprofile` 中的 Homebrew `shellenv` 不会执行,设置页在该开关旁注明 | +| macOS + 用户在 `terminal.env` 中设置了 PATH(例如前置 `~/bin`) | path_helper 会把原有 PATH 条目挪到后面,用户的前置项因此失效(VS Code issue #99878 的问题 [实 VSC terminalEnvironment.ts:279-313])。如果用户值以 `{env:PATH}` 结尾,就把前缀部分放进 `TCODE_SI_PATH_PREFIX`,由注入脚本在 login 文件执行完后重新前置;没有注入时给出 `LaunchWarning` | +| Linux | 桌面会话的 PATH 一般已经完整(显示管理器会 source `~/.profile`、systemd user 环境)。不处理 | +| Windows | `reloadEnvironment`(§4.1) | + +### 4.6 合并顺序 + +``` +env = BaseEnvironment // 1. §4.1(Windows:再叠加注册表刷新) +env.RemoveWhere(StripRules) // 2. §4.3 +FixLocale(env) // 3. §4.4(macOS) +env.Set(TERM, COLORTERM, TERM_PROGRAM, TERM_PROGRAM_VERSION, TCODE_TERMINAL_ID, SHELL) // 4. §4.2 +ApplyUserEnv(env, config.env) // 5. 全局 terminal.env(已经过平台覆盖块合并) +ApplyUserEnv(env, profile.Environment ⊕ profile 覆盖的 env) // 6. profile 级 +ApplyUserEnv(env, ctx.ExtraEnvironment) // 7. 调用方(Agent / ACP / 恢复) +integration.Apply(env, args) // 8. 注入变量:读取第 7 步之后的 ZDOTDIR / XDG_DATA_DIRS 原值 +WslEnv.Apply(env, profile) // 9. §4.7 +PathFixups(env) // 10. §4.5(macOS 非 login) + +ApplyUserEnv(env, map): + for (k, v) in map: + if v == null: env.Remove(k) // null = 删除(WT 做不到删除,VS Code 可以) + else: env[k] = Expand(v, env: BaseEnvironment, projectRoot: ctx.ProjectRoot) +``` + +- **展开语法**与 `McpVariables` 一致(`{env:VAR}`、`${env:VAR}`、`${VAR}`、`${VAR:-default}`、整串 `$VAR`、`${workspaceFolder}`、`${userHome}`),实现位于 `TCode.Terminal.Shell.EnvExpander` [定]。 + - 展开时**对照 BaseEnvironment**,而不是对照前几步修改后的环境,避免出现"用户值引用了被剔除的变量"这类不直观的行为; + - 唯一的例外:写 `"PATH": "~/bin:{env:PATH}"` 时,`{env:PATH}` 取第 1 步的值(Windows 上是刷新后的值)。 +- **大小写**:Windows 上键不区分大小写(`Path` 与 `PATH` 是同一个变量),合并时保留第一次出现的写法;Unix 区分大小写。 +- **保护**:用户设置不能覆盖第 8 步写入的注入变量(第 8 步在后);但用户可以通过 `env` 改写 TERM 等第 4 步的值。用户改写 TERM 后,设置页提示"非 xterm-256color 的 TERM 需要对应的 terminfo"。 + +### 4.7 WSLENV(Windows,Kind=Wsl) + +WSL 只把 `WSLENV` 中列出的变量从 Windows 传进 Linux。在原值前面追加 `TERM_PROGRAM:TERM_PROGRAM_VERSION:COLORTERM:TCODE_TERMINAL_ID`,再加上用户 `env` 中出现的键(不带标志位,按字符串原样传递)[实 WT ConptyConnection.cpp:58-151、WZ config.rs:1606-1622 的做法]。用户需要路径转换时,可以在 `WSLENV` 中自己写 `/p` 等标志。 + +--- + +## 5. Shell 集成 + +### 5.1 发出的序列 + +| 时机 | 序列 | 用途 | 来源 | +|---|---|---|---| +| 脚本加载完成(一次) | `OSC 633 ; P ; TCodeShellIntegration=<脚本版本>,, ST` | **握手**:表明是 TCode 自己的脚本在工作,用来区分"注入成功"和"用户另外装了别的集成"(§5.5)。633;P 的未知属性会被其他终端忽略 | [定];633;P 的格式 [实 research/03 §2.4] | +| 每次提示符之前 | `OSC 133 ; D ; <退出码> ST` | 上一条命令结束。只有之前发过 C 才发 D(第一个提示符不发) | research/03 §2.4 | +| 同上 | `OSC 7 ; file://<百分号编码的路径> ST` | 当前目录。Windows 上路径写成 `/C:/Users/…` | | +| 同上 | `OSC 133 ; A ; aid= ; cl=line ST` | 提示符开始。`aid` 用于区分嵌套 shell;`cl=line` 声明单行编辑器支持左右方向键(Alt+单击移动光标时使用,Tier 2) | Per Bothner 的 semantic prompt 规范 [实 research/03 §2.4] | +| 提示符结束、输入开始 | `OSC 133 ; B ST` | | | +| 命令执行前 | `OSC 633 ; E ; <转义后的命令行> ; ST` | 精确的命令行文本。转义规则同 VS Code:`\` → `\\`,`;` 和 0x00–0x1F 写成 `\xHH` | VSC shellIntegration-bash.sh:151-190、shellIntegration.ps1:95-104 [实] | +| 同上,紧随其后 | `OSC 133 ; C ST` | 命令输出开始 | | +| 续行提示符(PS2,可选) | `OSC 133 ; P ; k=c ST` | 续行提示符,命令块高亮时排除 | | + +终止符统一用 `ESC \`(ST),pwsh 5.1 和 cmd 用 BEL(`\a`),解析两种都接受 [定]。 + +**为什么既要 133 又要 633;E**: + +- OSC 133 由镜像核心(libghostty-vt)直接解析,生成 `TerminalCore.Commands`(01 §6.1),用于命令边界、退出码、命令导航、选择命令输出; +- 133 只给出边界,**拿不到可信的命令行文本**。从屏幕上抠 B 到 C 之间的文本并不可靠: + - 提示符可能有多行、右侧提示符; + - 长命令会软换行; + - zsh-autosuggestions 和 fish 的灰色建议文本也在这一区域里,会被当成命令; + - 语法高亮插件会重绘输入行。 +- **Agent 需要准确的命令行**(09): + - 读取"用户刚才在终端里运行了什么"作为上下文; + - 在共享终端里执行命令时,核对 shell 实际执行的是不是 Agent 发出的那条(中间可能被用户编辑,或者被别名展开); + - 命令历史 UI、"重新运行"。 +- **nonce 防伪造**:程序输出可以伪造 `633;E`,诱导用户或 Agent "重新运行"一条恶意命令。所以 host 只在 nonce 与本会话一致时,才把 E 的内容记为可信命令行;不一致时只当作"未知命令"(VS Code `VSCODE_NONCE` 的做法)[实 VSC terminalEnvironment.ts:86-88;research/03 §2.4]。 + - nonce 是每个会话 128 位的随机数,经 `TCODE_SI_NONCE` 传给脚本; + - 脚本存进非导出的 shell 变量后立即 unset,子进程看不到; + - 运行在同一个 shell 进程里的代码(用户的 rc、插件)仍然可以读到它,这一点无法避免(VS Code 的 ps1 脚本也这样说明 [实 VSC shellIntegration.ps1:26-31])。 +- 命令行文本**只用于显示和 Agent 上下文**,不会自动执行,也不会写入 shell 历史(research/03 §2.4)。 + +### 5.2 解析侧:633 由 host 自己处理 + +libghostty 的 OSC 解析器有 `semantic_prompt`(133,含 kitty 的 `cmdline=`/`cmdline_url=` 选项),但**没有 633** [实 GH src/terminal/osc/parsers/ 目录中没有 633 的解析器]。方案 [定]: + +- libghostty-vt 的 C API 会把不认识的序列经 `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` 回调交出原文,133 则经 `GHOSTTY_TERMINAL_OPT_SEMANTIC_PROMPT` 回调给出 [实,09 §3 引用 ghostty `include/ghostty/vt/terminal.h:104,144-157,1405-1500`]。因此镜像核心的命令跟踪器 = 133 回调 + 在未知序列回调中解析 633(E、P),两者在同一次 `vt_write` 中按字节流顺序到达,天然有序; +- 本节只规定 **shell 侧发出什么**;解析与命令记录的结构以 09 为准; +- 备选(回调不可用时):会话 actor 在 `vt_write` 之前用一个小状态机扫描 `ESC ] 633 ;`(向量化 `IndexOf(0x1B)` 跳过普通字节)。 + +### 5.3 各 shell 的注入机制 + +| Shell | 机制 | 改了什么 | 依据 | +|---|---|---|---| +| bash(含 macOS `/bin/bash` 3.2、Git Bash、MSYS2、Cygwin) | `--init-file /bash/tcode.bash`;login 时设 `TCODE_SI_BASH_LOGIN=1`,由脚本模拟 login 启动文件 | 只改参数 | VS Code [实 VSC terminalEnvironment.ts:335];**不用** Ghostty 的 `--posix` + `ENV`:Apple 的 bash 3.2 禁用了这条路径,Ghostty 和 iTerm2 都直接跳过 `/bin/bash` [实 GH shell_integration.zig:144-158];POSIX 模式下 HISTFILE 默认是 `~/.sh_history`,还要额外修正 [实 GH shell_integration.zig:391-406] | +| zsh | `ZDOTDIR=/zsh`(目录中只有 `.zshenv`);用户原来的 ZDOTDIR 存入 `TCODE_SI_ZDOTDIR` | 只改环境 | Ghostty 的单 `.zshenv` 方案 [实 GH src/shell-integration/zsh/.zshenv]。VS Code 复制 4 个文件到 `/tmp/--zsh`(1700 权限)[实 VSC terminalEnvironment.ts:206-273],需要处理临时目录被篡改的问题,不采用 | +| fish | 在 `XDG_DATA_DIRS` 前面加上 `/fish-data`,fish 启动时加载其中的 `fish/vendor_conf.d/tcode.fish`;`TCODE_SI_XDG_DIR` 记录要去掉的那一项,`TCODE_SI_XDG_UNSET=1` 表示原来没有设置 | 只改环境 | Ghostty [实 GH shell_integration.zig:620-690;fish/vendor_conf.d];VS Code 用 `--init-command`(改参数)[实 VSC terminalEnvironment.ts:336] | +| pwsh / Windows PowerShell | `-NoExit -Command "<加载脚本>"` | 只改参数 | VS Code [实 VSC terminalEnvironment.ts:330-332] | +| nu | 不注入,依赖 nu 自带的 OSC 133 / OSC 7 | — | §5.3.5 | +| cmd | 包装 `PROMPT` 环境变量(部分集成:A、B、无退出码的 D、OSC 9;9 cwd,没有 C 和 E) | 只改环境 | WT 的 shell 集成教程 [实 learn.microsoft.com/windows/terminal/tutorials/shell-integration] | +| WSL | v1 不注入 | — | §5.3.7 | +| ssh 等其他 | 不注入 | — | §5.8 | + +#### 5.3.1 bash:`tcode.bash` 大纲 + +```bash +# tcode.bash — TCode shell integration for bash (≥ 3.2). Auto-loaded via --init-file or sourced manually. +[[ $- == *i* ]] || return 0 # 只处理交互式 shell +[[ -n ${__tcode_si_loaded-} ]] && return 0 # 防止重复加载(手动 source + 自动注入) +__tcode_si_loaded=1 + +# 1. 取出注入变量并清除(不导出给子进程) +__tcode_nonce=${TCODE_SI_NONCE-}; builtin unset TCODE_SI_NONCE +if [[ ${TCODE_SI_INJECTED-} == 1 ]]; then # 由 --init-file 自动注入:我们负责读取启动文件 + builtin unset TCODE_SI_INJECTED + if [[ -n ${TCODE_SI_BASH_LOGIN-} ]]; then # 模拟 login:/etc/profile + 第一个存在的个人 profile + builtin unset TCODE_SI_BASH_LOGIN + [[ -r /etc/profile ]] && builtin source /etc/profile + for __tcode_f in ~/.bash_profile ~/.bash_login ~/.profile; do + [[ -r $__tcode_f ]] && { builtin source "$__tcode_f"; break; } + done + if [[ -n ${TCODE_SI_PATH_PREFIX-} ]]; then PATH=$TCODE_SI_PATH_PREFIX$PATH; fi + builtin unset TCODE_SI_PATH_PREFIX __tcode_f + else + [[ -r ~/.bashrc ]] && builtin source ~/.bashrc + fi +fi + +# 2. 工具函数 +__tcode_osc() { builtin printf '\e]%s\e\\' "$1"; } +__tcode_escape() { …逐字节:\ → \\,; 和 < 0x20 → \xHH;长度 ≥ 2000 时用 sed 的快速路径(VS Code 的做法)… } +__tcode_osc7() { …把 $PWD 百分号编码,主机名取 ${HOSTNAME}… } + +# 3. preexec:DEBUG trap(bash 3.2 没有 PS0;PS0 的 $(…) 在子 shell 中执行,无法记录状态) +__tcode_executing=0 +__tcode_in_prompt=0 +__tcode_preexec() { + (( __tcode_in_prompt )) && return # PROMPT_COMMAND 自身触发的 DEBUG 不算 + (( __tcode_executing )) && return # 一行中的多条命令只报告一次 + __tcode_executing=1 + local cmd + cmd=$(builtin history 1 2>/dev/null) # 优先用历史:得到完整的一行 + if [[ $cmd == "$__tcode_last_hist" || -z $cmd ]]; then cmd=$BASH_COMMAND # ignorespace/ignoredups 时历史不变,退回当前简单命令 + else __tcode_last_hist=$cmd; cmd=${cmd#*[0-9] }; fi + __tcode_osc "633;E;$(__tcode_escape "$cmd");$__tcode_nonce" + __tcode_osc "133;C" +} +# 与用户已有的 DEBUG trap 串联:先读出原 trap(VS Code 的 __vsc_get_trap 思路),在我们的函数之后执行它 + +# 4. precmd:放在 PROMPT_COMMAND 的最前面,先保存 $? +__tcode_precmd() { + local s=$? + __tcode_in_prompt=1 + if (( __tcode_executing )); then __tcode_osc "133;D;$s"; __tcode_executing=0; fi + __tcode_osc7 + return $s # 把退出码原样留给用户的 PROMPT_COMMAND +} +__tcode_precmd_end() { # 放在 PROMPT_COMMAND 的最后:此时 PS1 已被 starship 等改写完毕 + __tcode_wrap_ps1 # 若 PS1 不以我们的标记开头,就包上 \[\e]133;A;aid=$$;cl=line\e\\\] … \[\e]133;B\e\\\] + __tcode_in_prompt=0 +} +# PROMPT_COMMAND:bash ≥ 5.1 支持数组形式,按数组插入首尾;更早的版本按字符串拼接 "__tcode_precmd; <原值>; __tcode_precmd_end" + +# 5. 握手 +__tcode_osc "633;P;TCodeShellIntegration=,bash,${BASH_VERSION}" +``` + +#### 5.3.2 zsh:`.zshenv` + `tcode-integration.zsh` + +```zsh +# /zsh/.zshenv —— 由于 ZDOTDIR 指向这里,zsh 在读取 /etc/zshenv 之后读取本文件 +# 1. 立即还原 ZDOTDIR,之后的 .zprofile/.zshrc/.zlogin 都从用户自己的目录读取(HISTFILE 默认位置也就不受影响) +if [[ -n ${TCODE_SI_ZDOTDIR+X} ]]; then 'builtin' 'export' ZDOTDIR=$TCODE_SI_ZDOTDIR; 'builtin' 'unset' TCODE_SI_ZDOTDIR +else 'builtin' 'unset' ZDOTDIR; fi +# 2. 加载用户自己的 .zshenv(它可能设置 fpath 等) +'builtin' 'typeset' __tcode_f=${ZDOTDIR-$HOME}/.zshenv +[[ -r $__tcode_f ]] && 'builtin' 'source' -- "$__tcode_f" +# 3. 交互式 shell 才加载集成;要求 zsh ≥ 5.1,否则发出 633;P;TCodeShellIntegrationError=ZshTooOld +if [[ -o interactive ]]; then 'builtin' 'source' -- "${${(%):-%x}:A:h}/tcode-integration.zsh"; fi +'builtin' 'unset' __tcode_f +``` + +- 所有内建命令都加引号,防止用户把 `source`、`unset` 等定义成别名(Ghostty 的 `.zshenv` 也这样做 [实])。这里只借鉴机制,代码自己写,因为 Ghostty 的文件是 GPLv3(§5.9)。 +- `tcode-integration.zsh`: + - 用 `add-zsh-hook precmd/preexec`; + - `preexec` 的 `$1` 就是完整的命令行,所以 E 是精确的; + - 第一次 precmd 时,把我们的 precmd 挪到 `precmd_functions` 末尾、preexec 挪到 `preexec_functions` 开头,保证 starship、p10k 等先设置好提示符; + - A、B 用 `PS1=%{\e]133;A…\e\\%}$PS1%{\e]133;B\e\\%}` 包裹,每次 precmd 检查后重新包裹; + - 第一次 precmd 时,如果有 `TCODE_SI_PATH_PREFIX` 就前置到 PATH 并 unset(此时 `/etc/zprofile` 的 path_helper 已经执行过)。 +- **已知限制**:如果 `/etc/zshenv` 自己设置了 ZDOTDIR,我们的 `.zshenv` 根本不会被读取 [实 GH src/shell-integration/README.md 的 Zsh 一节]。表现为注入超时;§5.5 对 zsh 超时给出这个提示。 + +#### 5.3.3 fish:`vendor_conf.d/tcode.fish` + +```fish +# 1. 无论是否交互,先还原 XDG_DATA_DIRS(只去掉我们加的那一项;原来没有设置就整体删除) +# 2. 不是交互式就结束 +# 3. 用 --on-event fish_preexec($argv[1] 是完整命令行)发 E 和 C;--on-event fish_postexec 记下 $status +# 4. 第一次 fish_prompt 事件时(此时用户 config.fish 已经执行完):把原 fish_prompt 改名为 __tcode_orig_fish_prompt, +# 定义新的 fish_prompt:先发 D、OSC 7、A,再调用原提示符,最后发 B +# 5. fish ≥ 4.0 自己会发 OSC 133 [推,需实测]:检测到 $version ≥ 4 时跳过 A/B/C/D,只发 E、OSC 7 和握手,避免重复 +``` + +fish 的 `vendor_conf.d` 在用户 `config.fish` **之前**加载,所以还原 `XDG_DATA_DIRS` 发生在用户配置之前,用户配置看到的是原始值 [实 GH fish/vendor_conf.d 脚本的注释与结构]。fish 从哪个版本开始从 `XDG_DATA_DIRS` 读取 `vendor_conf.d` 需要确认 [推:3.x],太旧时表现为注入超时。 + +#### 5.3.4 pwsh 与 Windows PowerShell:`tcode.ps1` + +**加载命令**(`-Command` 的内容): + +```powershell +# pwsh 7(Windows / macOS / Linux) +. '/pwsh/tcode.ps1' +# Windows PowerShell 5.1:执行策略可能阻止加载,失败时报告原因,不能让 shell 启动失败 +try { . '\pwsh\tcode.ps1' } catch { [Console]::Write([char]27 + ']633;P;TCodeShellIntegrationError=' + $_.FullyQualifiedErrorId + [char]7) } +``` + +- 参数:`[-Login(仅 macOS/Linux 的 login,必须第一个)] [用户的 -NoLogo 等] -NoExit -Command <上面的字符串>`。`-Command` 必须是最后一个参数 [实 about_Pwsh]。 +- **profile 照常加载**:PowerShell 先加载 `$PROFILE`,再执行 `-Command`,然后 `-NoExit` 进入交互 [实 about_Pwsh:-NoProfile 才会跳过 profile]。所以我们的脚本在 oh-my-posh 等设置好 `prompt` 函数之后才包装它,正好是我们需要的顺序。 +- **执行策略**: + - Windows 客户端版本上,Windows PowerShell 5.1 的默认执行策略是 `Restricted`,dot-source 脚本文件会失败 [推:Windows 客户端版默认值];pwsh 7 在 Windows 上默认是 `RemoteSigned`,本地文件可以运行。 + - **不绕过执行策略**:例如把脚本内容读出来用 `Invoke-Expression` 执行,在技术上可行,但企业可能用组策略设置了 AllSigned,绕过它等于违反管理员的意图 [定]。 + - 失败时通过上面的 `catch` 报告 `TCodeShellIntegrationError=…`,UI 显示"执行策略阻止了 shell 集成脚本",并给出说明链接。 + - 以后可以给脚本做 Authenticode 签名,让 AllSigned 环境也能加载(Restricted 仍然不行)。 +- **脚本内容**(参考 VS Code 的 MIT 脚本改编): + - 受限语言模式(`$ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage'`)下直接返回并报告原因; + - nonce 存进 `$Global:__TCodeState` 后清除 `$env:TCODE_SI_NONCE`; + - 包装 `prompt` 函数:先发 D(`$?` 与 `$LASTEXITCODE` 合成退出码,同 VS Code 的 `FakeCode` 逻辑),再发 OSC 7(只在 `$pwd.Provider.Name -eq 'FileSystem'` 时),然后 A + 原提示符 + B; + - 包装 `PSConsoleHostReadLine`:拿到用户输入的命令行后发 E 和 C,再把命令行返回给宿主(VS Code 的做法),不改用户的 Enter 键绑定; + - 5.1 不支持 `` `e ``,统一用 `[char]27`。 +- **VS DevShell**(`Integration = AppendToPwshCommand`):这类 profile 的 `-Command` 是我们自己构造的,所以把加载脚本的语句追加到 `Enter-VsDevShell …;` 之后,而不是因为"参数无法识别"而放弃注入 [定]。 + +#### 5.3.5 nu + +nushell 自带 OSC 133 和 OSC 7 的支持(`$env.config.shell_integration`),在较新版本中默认开启 [推,需按版本实测]。Ghostty 对 nu 的注入只用来提供 sudo/ssh 等附加功能,不用来发 133 [实 GH src/shell-integration/README.md 的 Nushell 一节]。因此 [定]: + +- 不注入;`IntegrationPlan.Mode = NativeExpected`; +- 收到 A 但没有握手时,状态显示为"Shell 自带集成"(§5.5),Agent 能用命令边界,但没有可信的命令行(E); +- 以后可以按 Ghostty 的方式加 `XDG_DATA_DIRS` + `--execute 'use tcode *'` 补上 E。 + +#### 5.3.6 cmd + +在用户原有 `PROMPT` 的外面包一层(没有设置时,原值取 cmd 的默认 `$P$G`): + +``` +PROMPT=$e]133;D$e\$e]9;9;$P$e\$e]133;A$e\<原 PROMPT>$e]133;B$e\ +``` + +- cmd 在显示提示符时展开 `$e`(ESC)和 `$P`(当前路径)[实 WT 的教程]; +- cwd 用 OSC 9;9 而不是 OSC 7,因为 `$P` 是未经编码的 Windows 路径,放进 file URI 不合法;OSC 9;9 是 ConEmu/WT 的约定,research/05 §3.4 要求兼容解析; +- 能力有限:D 没有退出码,没有 C 和 E。命令跟踪器把"B 之后用户按下的回车"推断为 C(WT 的 `autoMarkPrompts` 启发式); +- 状态显示为"部分集成"。Agent 在 cmd 终端里退回进程模式(01 §9)。 +- Clink 用户:Clink 有自己的 OSC 133 支持 [推]。检测到 `CLINK_*` 环境变量或 AutoRun 中包含 clink 时,不改 PROMPT [定]。 + +#### 5.3.7 WSL + +v1 不注入 [定]。原因: +- 我们启动的是 `wsl.exe`,不知道发行版里的默认 shell 是什么; +- 也无法把 Windows 上的脚本路径可靠地传进去。 + +用户可以在发行版的 `~/.bashrc` 里手动 source(§5.6;脚本在 `/mnt/c/Users//.tcode/shell-integration/current/`)。以后的做法:通过 `WSLENV` 传入 `TCODE_SI_*`,并用 `wsl.exe -d X -e bash --init-file /mnt/c/…`。缺点是这样会绕过用户在 WSL 里设置的默认 shell,所以要先查询默认 shell。 + +### 5.4 脚本的安装位置 + +``` +$TCODE_HOME/shell-integration/ + / ← 自动注入使用;内容哈希(全部脚本的 SHA-256 前 12 位)作目录名 + VERSION ← { "scriptVersion": 3, "tcodeVersion": "0.9.0" } + bash/tcode.bash + zsh/.zshenv + zsh/tcode-integration.zsh + fish-data/fish/vendor_conf.d/tcode.fish + pwsh/tcode.ps1 + current/ ← 同样内容的副本,供手动 source(§5.6);升级时整体替换 +``` + +- 脚本作为**嵌入资源**编进 `TCode.Terminal.Shell`(01 §4)。host 启动时检查 `/VERSION` 是否存在: + - 不存在:写到临时目录 `.tmp-`,再原子改名; + - 已存在:不重写。 +- **权限**:目录 0755、文件 0644,所有者为当前用户。`$TCODE_HOME` 在用户 home 下,不像 VS Code 那样用 `/tmp`,所以不需要 sticky 位这类防护。 +- **为什么用内容哈希而不是 TCode 版本号**:脚本没有变化的版本升级不产生新目录。旧会话(keepAlive 的 host 跨升级继续运行)引用的目录不能删除。 +- **清理**:host 启动时删除既不是当前哈希、也没有被任何运行中的 host(`run/ptyhost*.json` 里记录了各自的哈希)引用、并且超过 7 天的目录。 +- **为什么不从应用包里直接引用**(Ghostty 用 `/Contents/Resources`): + - 开发运行、便携版、Linux tarball 的路径各不相同; + - macOS 应用升级时,旧路径会在 shell 运行期间被替换,导致 zsh 的 autoload 失败。 + + 嵌入资源加释放到用户目录,在所有形态下行为一致 [定]。 + +### 5.5 注入状态与失败检测 + +**状态机**(每个会话一个,由 host 的命令跟踪器维护,`session/integrationChanged` 通知客户端): + +``` +Planned(mode) + ├─ mode == None → Inactive(reason) // 配置关闭、不支持的 shell/参数、远程 + ├─ mode == Partial(cmd)→ 收到 A → Partial + ├─ mode == NativeExpected(nu)→ 收到 A → Native ;10 s 超时 → Inactive(NoNativeIntegration) + └─ mode == Injected → Pending + ├─ 收到握手 633;P;TCodeShellIntegration → Active(version) + ├─ 收到 633;P;TCodeShellIntegrationError=X → Failed(X) // ExecutionPolicy、RestrictedLanguage、ZshTooOld… + ├─ 收到 A 但没有握手 → Native(例如用户自己 source 了 iTerm2/WezTerm 的脚本,或者 /etc/zshenv 改写了 ZDOTDIR 但 vte.sh 在工作) + ├─ shell 在握手前退出 → Failed(ShellExited) // 交给 §6.3 的"启动失败"处理 + └─ 10 s 内都没有 → Failed(Timeout);之后再收到握手 → Active(晚到,例如 rc 很慢) +``` + +- **超时为什么取 10 s**:rc 文件里加载 nvm、conda、p10k 的用户,启动可能需要好几秒。计时从第一个输出字节开始,而不是从 spawn 开始,因为 macOS 的 login 需要先打印 "Last login" [定]。 +- **失败原因与提示**: + +| 原因 | 显示文案(中文界面) | 可执行的操作 | +|---|---|---| +| `Disabled` | 已在设置中关闭 | — | +| `UnsupportedShell` | 不支持为 `` 自动注入 | 查看手动方式 | +| `UnsupportedArgs` | profile 的参数 `` 与注入方式冲突 | 编辑 profile | +| `Timeout`(zsh) | 10 秒内没有收到 shell 集成信号。可能是 `/etc/zshenv` 改写了 ZDOTDIR,或 zsh 版本低于 5.1 | 查看说明;对此 profile 关闭 | +| `Timeout`(其他) | 10 秒内没有收到 shell 集成信号,启动文件可能覆盖了提示符钩子 | 同上 | +| `ExecutionPolicy` | PowerShell 执行策略阻止了脚本 | 查看说明(含 `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`,由用户自己决定是否执行) | +| `RestrictedLanguage` | PowerShell 处于受限语言模式 | — | +| `ScriptInstallFailed` | 无法写入 `$TCODE_HOME/shell-integration` | 查看日志 | + +- **UI**: + - 标签悬停提示中有一行"Shell 集成:已启用(TCode v3)/ Shell 自带 / 部分 / 未启用:<原因>"; + - `Failed` 时,在终端顶部显示一条可关闭的信息条,**每个 profile 每个脚本版本只显示一次**,关闭记录写入 `state.json` 的 `terminal.integrationNoticeDismissed`; + - 信息条按钮:"查看原因"、"对此 profile 关闭 shell 集成"(写回 `profiles..shellIntegration: false`)、"不再提示"; + - Agent 侧(09)在 `Failed` / `Inactive` 时显示"此终端将以进程模式执行命令"。 +- **启动失败的重试**:shell 在 250 ms 内非 0 退出且注入过(§6.3)时,提供"关闭 shell 集成后重试",以 `ShellIntegrationOverride=false` 重新启动(VS Code 的做法 [实 VSC terminalInstance.ts:2905-2950])。 + +### 5.6 手动 source + +自动注入关闭或失败时,用户可以在自己的 rc 文件里加载。所有片段都先检查 `TERM_PROGRAM`,因为 `TCODE_*` 会被剔除,不能用来判断: + +```bash +# ~/.bashrc(放在文件末尾,在 starship / oh-my-bash 之后) +[[ $TERM_PROGRAM == TCode ]] && . "${TCODE_HOME:-$HOME/.tcode}/shell-integration/current/bash/tcode.bash" +``` +```zsh +# ~/.zshrc(末尾) +[[ $TERM_PROGRAM == TCode ]] && . "${TCODE_HOME:-$HOME/.tcode}/shell-integration/current/zsh/tcode-integration.zsh" +``` +```fish +# ~/.config/fish/config.fish +test "$TERM_PROGRAM" = TCode; and source (set -q TCODE_HOME; and echo $TCODE_HOME; or echo ~/.tcode)/shell-integration/current/fish-data/fish/vendor_conf.d/tcode.fish +``` +```powershell +# $PROFILE +if ($env:TERM_PROGRAM -eq 'TCode') { . "$(if ($env:TCODE_HOME) { $env:TCODE_HOME } else { "$HOME/.tcode" })/shell-integration/current/pwsh/tcode.ps1" } +``` + +- 手动加载时没有 nonce,E 被标记为"不可信命令行":显示时带标记,Agent 只能当作参考 [定]。 +- 脚本检测到 `TCODE_SI_INJECTED` 未设置时,**不会**再去 source 启动文件(避免重复加载)。 + +### 5.7 对用户 rc 文件的影响与规避 + +| Shell | 影响 | 规避 | +|---|---|---| +| bash | `--init-file` 取代了 `~/.bashrc` 的自动读取;login 被模拟:`shopt -q login_shell` 为假,`$0` 不以 `-` 开头 | 脚本按 bash 的规则 source `/etc/profile`、`~/.bash_profile`/`~/.bash_login`/`~/.profile` 或 `~/.bashrc`。设置页说明"login 由 TCode 模拟"。依赖 `login_shell` 判断的 rc 很少见 [推] | +| bash | DEBUG trap、`PROMPT_COMMAND` 被串联 | 保留用户原有的 trap 和 PROMPT_COMMAND,我们的函数放首尾,`$?` 原样传递 | +| bash | `HISTCONTROL=ignorespace/ignoredups` 时 `history 1` 拿不到当前命令 | 比较历史内容;没有变化时退回 `BASH_COMMAND`(只有第一条简单命令,E 标记为"可能不完整")。**不修改用户的 HISTCONTROL** | +| bash / zsh / fish / pwsh | Agent 在共享终端中执行的命令进入用户历史 | 只对 `Purpose = Agent` 的终端或 Agent 注入的命令处理(09):bash 在用户原值后追加 `ignorespace`,zsh `setopt HIST_IGNORE_SPACE`,fish 本来就不保存以空格开头的命令 [推],pwsh 设置 PSReadLine 的 `AddToHistoryHandler`。Agent 发出的命令前加一个空格。用户自己的交互终端**不改动** | +| zsh | ZDOTDIR 被临时改写;HISTFILE 默认取 `$ZDOTDIR/.zsh_history` | `.zshenv` 第一行就还原 ZDOTDIR,后续文件和 HISTFILE 的默认值都与未注入时相同(VS Code 是事后改 HISTFILE [实 VSC shellIntegration-rc.zsh:18-23],我们的方式不需要) | +| zsh | p10k 的 instant prompt 在 `.zshrc` 执行完之前就画出提示符 | 第一次 precmd 时才包装 PS1,第一个提示符可能缺少 A。命令跟踪器容忍这种情况 | +| fish | `XDG_DATA_DIRS` 被临时改写 | `vendor_conf.d` 一开始就还原,并精确区分"原来没有设置"和"原来是空" | +| pwsh | `prompt` 和 `PSConsoleHostReadLine` 被包装 | 只包装,不替换;用户之后重新定义 `prompt`(例如切换 oh-my-posh 主题)时,下一次 `PSConsoleHostReadLine` 会检测并重新包装 | +| 全部 | 注入变量泄漏给子进程 | 脚本读取后立即 unset;`TCODE_SI_*` 也在 §4.3 的剔除规则内(嵌套的 TCode 终端不会继承) | + +### 5.8 远程与嵌套 shell + +- **SSH profile 默认不注入**(08 §2.6):远端没有我们的脚本,在远端写文件属于侵入行为。 +- **本地 shell 中手动运行 `ssh`**:命令跟踪器看到的是一条正在执行的命令(C 之后),直到 ssh 退出才收到 D。远端如果自己装了 OSC 133 集成(例如 vte.sh),会产生**嵌套**的 A/B/C/D,其 `aid` 与本地不同。命令跟踪器按 `aid` 区分层级,Agent 只信任本会话 nonce 对应的那一层 [定,细节在 09]。 +- `exec zsh`、tmux 等会导致集成丢失:没有握手的 A 视为 Native;完全没有标记时,退回 WT 的回车启发式(research/05 §3.4)。 + +### 5.9 脚本的许可 + +| 来源 | 许可 | 能否使用 | +|---|---|---| +| Ghostty `src/shell-integration/{bash,zsh,fish}` | GPLv3(文件头:基于 kitty,"this file is also distributed under GPLv3")[实] | **不能拷贝**,只能参考机制(思想不受著作权保护) | +| kitty shell-integration | GPLv3 [实,同上] | 同上 | +| VS Code `shellIntegration-*.sh/.zsh/.fish/.ps1` | MIT [实 文件头] | 可以改编,保留版权声明,并在 THIRD-PARTY-NOTICES 中登记 | +| bash-preexec(Ghostty 引用) | MIT [推] | 可用;但我们的 DEBUG trap 方案用不到它 | + +TCode 仓库是 Apache-2.0(根目录 LICENSE)[实]。 + +--- + +## 6. 生命周期 + +### 6.1 关闭确认:"有程序在运行"的判定 + +``` +IsBusy(session): + if session.Exited: return false + if config.confirmClose == never: return false + if config.confirmClose == always: return true + t = session.Tracker // shell 集成的状态 + if t.Active or t.Native: + if t.State == Executing: return !IgnoredProcess(Foreground(session)) // C 之后、D 之前 + if t.State in (Prompt, Input): return false // 光标在提示符处(Ghostty 的做法) + return session.Platform == Windows ? BusyWindows(session) : BusyUnix(session) + +BusyUnix(session): + fg = tcgetpgrp(master) + if fg == session.ShellPgid: return false // ShellPgid:第一次收到 A 时记录的前台进程组; + // 没有集成时:spawn 后 500 ms 取 tcgetpgrp 作为近似(macOS 下是 login 之后的 shell) + name = ProcessName(fg) // 进程组首进程的 comm(Linux /proc//comm;macOS proc_name),失败时取该组任一进程 + return !IgnoredProcess(name) + +BusyWindows(session): + tree = Toolhelp32Snapshot 中以 shell pid 为根的子树(校验子进程的创建时间晚于父进程,排除 pid 复用) + tree -= { conhost.exe, OpenConsole.exe } + return tree.Any(p => !IgnoredProcess(p.Name)) + // Kind == Wsl:Linux 侧的进程在 Windows 上不可见;没有集成时视为空闲(与 WT 一致:WT 根本不检查进程) + +IgnoredProcess(name) = Normalize(name) ∈ (内置忽略列表 ∪ 用户追加) − 用户移除("-name") +Normalize:去掉 ".exe",去掉开头的 "-"(login shell 的 argv0),小写;以 gitstatusd 开头的统一视为 gitstatusd +``` + +- **为什么集成状态优先**:`bash script.sh` 运行时,前台进程名是 `bash`,在忽略列表中,只看进程名会误判为空闲。shell 集成知道当前是"执行中"[定]。 +- **内置忽略列表** [定,汇总 Ptyxis、VS Code、WezTerm、iTerm2:research/05 §8.1]: + - shell:`sh`、`bash`、`zsh`、`fish`、`nu`、`dash`、`ksh`、`mksh`、`tcsh`、`csh`、`elvish`、`xonsh`、`pwsh`、`powershell`、`cmd`,以及 `/etc/shells` 中各项的文件名; + - 包装进程:`login`、`wsl`、`wslhost`; + - 提示符辅助程序:`starship`、`oh-my-posh`、`gitstatusd`。 +- **不在列表中、因而算作"在运行"的**:`tmux`、`screen`、`ssh`、`mosh`、`vim` 等。 + - 关闭含 tmux 的标签会断开 tmux 客户端,tmux 会话本身不会丢;但 Terminal.app 默认列表里有 tmux 的做法我们不采用:用户可能运行的是 `tmux attach` 之外的东西,统一确认更安全; + - 用户可以写 `"ignoreProcesses": ["tmux"]` 把它加进来。 +- **SSH profile**:根进程是 `ssh`,远端的前台程序看不到。默认认为在运行(需要确认),因为关闭会结束远端 shell。用户可以把 `ssh` 加进忽略列表。 +- **汇总确认**: + - 关闭窗口或退出 TCode 时,对所有 `IsBusy` 的会话汇总确认一次(iTerm2 `PromptOnQuit`); + - `persistence.onQuit == keepAlive` 时,退出不会结束进程,**不需要确认**(与 01 §3.3 的表述有出入,列入 §10)。 +- `IsBusy` 是按需查询(关闭时调用),不轮询。 + +### 6.2 标题的优先级 + +``` +TabTitle(session): + 1. 用户手动重命名的标题(标签右键 → 重命名;保存在会话状态中,revive 时恢复) + 2. 程序通过 OSC 0/2 设置的标题(title.allowApplicationTitle 为 true,且最近一次设置的不是空串;设置空串表示恢复默认) + 3. 有 shell 集成时: + Executing → 命令名(E 的第一个词,去掉路径和常见前缀 sudo/env/time/nice) + Prompt → cwd 的目录名(home 显示为 "~") + 4. 没有 shell 集成时: + 前台进程不是 shell(§6.1 的 fg 名不在 shell 列表中)→ 前台进程名 + 否则 → cwd 目录名(OSC 7 → 进程 cwd 查询,节流到 ≤ 1 Hz) + 5. profile 的显示名 +副标题(标签悬停 / 第二行):cwd 的完整路径(home 缩写为 ~);SSH 会话显示 user@host +``` + +- 我们的脚本**不发** OSC 2:标题由 TCode 根据结构化信息计算。用户的提示符框架(oh-my-zsh 等)发出的 OSC 2 按第 2 级处理。 +- 标题的查询应答(title report)默认关闭(01 §8,research/03 §8.2)。 +- 远端主机(SSH)发出的 OSC 0/2 同样按第 2 级处理;控制字符和超长标题(> 256 字符)截断。 + +### 6.3 shell 退出之后(`onExit`) + +| 取值 | 退出码 0 | 非 0 或被信号终止 | +|---|---|---| +| `closeOnCleanExit`(默认) | 关闭标签 | 保留标签,在末尾显示 `[进程已退出,退出码 N]` 或 `[进程被 SIGTERM 终止]`;Enter 重新启动(同一 profile、同一 cwd,新会话接在旧内容后面,中间插入分隔线);Ctrl+D 或 Esc 关闭 | +| `close` | 关闭 | 关闭 | +| `hold` | 保留并显示 `[进程已完成]` | 同 `closeOnCleanExit` | + +- 依据:WT `closeOnExit: automatic`、Terminal.app 的 "Close if the shell exited cleanly" [实 research/05 §8.3]。 +- **启动即失败**:退出发生在启动后 **250 ms** 内(Ghostty 的阈值),或者在收到任何输出之前,且退出码非 0: + - 不论 `onExit` 取什么值都保留标签,显示错误面板:等价命令行(`LaunchPlan.DisplayCommand`)、退出码、最后几行输出; + - 按钮:"重试"、"关闭 shell 集成后重试"(仅在注入过时)、"编辑 profile"、"改用默认 profile"。 +- **SSH profile 的 255**:见 08 §2.8。 +- **Agent 和 ACP 创建的终端**(09):由调用方决定,通常是 `hold`,因为调用方要读取退出码和最终输出。 + +### 6.4 新标签与分屏的 cwd 继承 + +``` +ResolveCwd(ctx): + match ctx.Cwd: + Explicit(p) → p + Default → config.cwd: project → ctx.ProjectRoot ?? Home ; home → Home ; → 展开 ~ 与变量后的路径 + Inherit(src) → if config.newTabCwd != inherit: 同 Default + c = InheritedCwd(src) + return c ?? Default + 最后:目录不存在 → Warning + 回退到 ProjectRoot → Home + +InheritedCwd(src): + if src.Kind == Ssh: return null // 远端路径不能用作本地目录 + u = src.Tracker.LastOsc7 // 最近一次 OSC 7 + if u != null and IsLocalHost(u.Host, src) and Directory.Exists(LocalPath(u, src)): return LocalPath(u, src) + if src 的前台是 shell(§6.1): // 只在 shell 处于前台时才信任进程 cwd(GNOME Terminal 的 "safe" 规则) + p = Linux ? readlink(/proc//cwd) : macOS ? proc_pidinfo(shellPid, PROC_PIDVNODEPATHINFO) : null + if p != null and Directory.Exists(p): return p + return null + +IsLocalHost(h, src) = h ∈ { "", "localhost", 本机 hostname(大小写不敏感,去掉 .local)} + 或 (src.Kind == Wsl 且 h == 该发行版的 hostname) // WSL 内部的 hostname 与 Windows 不同 +LocalPath(u, src):百分号解码;Windows 上 "/C:/x" → "C:\x"; + src.PathStyle == Msys/Cygwin 时 "/c/x"、"/cygdrive/c/x" → "C:\x"; + src.Kind == Wsl 时保留 Linux 路径(只用于 WSL → WSL 的继承,见下) +``` + +- **跨类型继承**: + - WSL → 同一发行版的 WSL:`--cd `; + - WSL → 非 WSL:转换为 `\\wsl.localhost\<发行版>\<路径>`(Windows 11;Windows 10 是 `\\wsl$\`)[推],只有目标 shell 是 pwsh 时才使用(cmd 不能把 UNC 路径作为当前目录);其他情况回退到 Default; + - 非 WSL → WSL:Windows 路径直接交给 `--cd`。 +- **分屏**:使用源标签的 profile + 继承的 cwd(VS Code 的 `splitCwd: inherited`)。**新标签**使用默认 profile + 继承的 cwd。 +- **OSC 7 是不可信输入**(01 §8):只用于选择起始目录,并且必须校验目录存在;不用来打开文件,也不传给其他命令。 +- **revive**(01 §3.3):恢复时使用快照中记录的最后 cwd,规则同 `Explicit`(不存在时回退)。 + +--- + +## 7. settings.json 的 `terminal` 节 + +### 7.1 设计原则 + +沿用 [settings.md](../settings.md) §1: + +1. **省略即跟随系统**:字段省略或写 `"system"` 时使用"跟随系统"的解析结果;写出具体值才固定。设置页显示解析后的实际值,例如"跟随系统(Cascadia Mono 12)"。 +2. **profile 与 providers 写法相同**:键与探测到的 id 相同时是覆盖,否则是自定义;`"enabled": false` 表示隐藏。 +3. **平台覆盖块** [定,新增]:`terminal.macos` / `terminal.windows` / `terminal.linux` 可以包含 `terminal` 的任意字段(平台块自身除外),在当前平台上**深合并**到基础值之上: + - 对象逐键合并; + - 数组整体替换; + - `env` 中的 `null` 表示删除; + - 这样就不再需要 research/05 草案里"字符串或 `{macos,linux,windows}`"那种联合类型(源生成 JSON 不好处理)。 +4. **宽容解析,字段级容错** [定,新增]: + - 未知字段:警告(同 settings.md); + - 某个字段类型或取值非法:警告,**该字段回到默认值**; + - 某个 profile 缺少必填字段:只停用该 profile(相当于 settings.md 的"厂商错误"级别); + - `terminal` 不是对象:警告,整个节按默认处理。 + - **`terminal` 节永远不会造成"文件错误"**,除非 JSON 本身有语法错误。理由:字号写错不应该让模型和厂商配置也失效。 +5. **热重载**:Desktop 的 `UserSettingsService` 已经在监听(500 ms 防抖),`Changed` 事件触发后重新解析并 `host/configure`。字段表的"热重载"列: + - **立即**:作用于已经打开的终端; + - **新终端**:只影响此后新建的终端。设置页在这类字段旁提示"对新终端生效",并提供"重启此终端"的按钮(相当于 VS Code 的 `environmentChangesRelaunch`)。核心状态类字段(回滚上限、宽度模式、Kitty 图形上限等)必须两份核心一致(01 §3.2 规则 2),所以只能对新终端生效。 +6. **只管交互终端**:Agent 的 `bash` 工具不受 `terminal.*` 影响(VS Code `agentHostProfile` 与 `defaultProfile` 分开的做法);Agent 的设置在顶层 `agent.shell.*`(09 §2.2)。 + +### 7.2 示例 + +```jsonc +{ + "terminal": { + // 新终端的 profile:"system"(默认)= macOS/Linux 的账户登录 shell;Windows 的 PowerShell 7(没有时用 Windows PowerShell) + "defaultProfile": "system", + + "profiles": { + "pwsh": { "args": ["-NoLogo"] }, // ① 覆盖探测到的 profile:只写要改的字段 + "cmd": { "enabled": false }, // ② 隐藏 + "node-dev": { // ③ 自定义 + "name": "Node 开发", + "command": "/bin/zsh", // 绝对路径或 PATH 中的名字 + "args": [], + "loginShell": true, // 默认:macOS true,其他 false + "cwd": "~/work/web", + "env": { "NODE_ENV": "development", "DEBUG": null }, + "shellIntegration": true, + "icon": "nodejs", + "platforms": ["macos", "linux"] + }, + "prod": { "type": "ssh", "host": "prod-1" } // ④ SSH(字段见 08 §2.4) + }, + + "cwd": "project", // project | home | 绝对路径 + "newTabCwd": "inherit", // inherit | project | home + "env": { "EDITOR": "code --wait", "HTTPS_PROXY": "{env:CORP_PROXY}" }, + "setLocaleVariables": "auto", // auto | on | off(只在 macOS 上生效) + + "font": { "family": "system", "size": "system", "lineHeight": 1.0, "ligatures": false }, + "cursor": { "style": "system", "blink": "system", "unfocusedStyle": "outline" }, + "colors": { "minimumContrastRatio": 4.5, "boldIsBright": false }, + "scrollback": 10000, + "unicode": { "ambiguousWidth": "narrow", "widthMode": "compat" }, + + "keyboard": { "macOptionAsMeta": "none", "ctrlCCopies": "system", "skipShell": [], "kittyKeyboardProtocol": true }, + "selection": { "wordSeparators": "system", "copyOnSelect": "system" }, + "mouse": { "rightClick": "system", "middleClick": "system", "altClickMovesCursor": true }, + "links": { "detectFilePaths": true, "allowedSchemes": ["http", "https", "mailto", "file"] }, + "paste": { "multiLineWarning": "auto", "trimTrailingNewline": true, "largePasteWarning": 0 }, + "bell": { "sound": "system", "visual": true, "attention": true }, + "clipboard": { "oscWrite": "allow", "oscRead": "ask" }, + + "shellIntegration": { "enabled": true, "decorations": true, "notifyOnFinish": "unfocused" }, + "confirmClose": "whenRunning", // whenRunning | always | never + "ignoreProcesses": ["-tmux"], // 追加到内置列表;"-" 前缀表示从内置列表中移除 + "onExit": "closeOnCleanExit", // closeOnCleanExit | close | hold + "title": { "allowApplicationTitle": true }, + + "persistence": { + "onQuit": "revive", // revive | keepAlive | close(01 §3.3) + "scrollbackLines": 1000, // revive 快照保存的尾部行数;0 = 只恢复标签与 cwd + "orphanGraceMinutes": 30, // 没有任何 UI 连接时,host 等待多久后按"退出 TCode"处理(只计清醒时间) + "keepAliveMaxHours": null, // keepAlive 退出后会话最多保留多久;null = 不限制 + "saveIntervalSec": 30 // 增量保存快照的间隔(防 host 崩溃) + }, + "graphics": { "kitty": true, "kittyStorageLimitMB": 128 }, + "rendering": { "gpu": "auto", "customGlyphs": true, "smoothScrolling": false }, + "accessibility": { "announceOutput": "auto" }, + + "ssh": { "enabled": true, "sendEnv": true }, // 见 08 §5 + + // 平台覆盖块:只在对应平台生效,深合并到上面的值 + "windows": { "defaultProfile": "git-bash", "env": { "MSYS": "winsymlinks:nativestrict" } }, + "macos": { "font": { "size": 13 } } + } +} +``` + +### 7.3 字段表 + +"系统/固定"列:"跟随系统"表示默认值取决于平台或系统设置,"固定"表示所有平台默认值相同。 + +**启动** + +| 字段 | 类型 | 默认 | 系统/固定 | 依据 | 热重载 | +|---|---|---|---|---|---| +| `defaultProfile` | string | `system` | 跟随系统 | §2.1;research/05 §1.5 | 新终端 | +| `profiles.` | object | 探测结果 | 跟随系统 | §2、§7.4 | 新终端(`enabled`、`name`、`icon` 立即反映到菜单) | +| `cwd` | `project` \| `home` \| string | `project` | 固定 | VS Code 用工作区根目录 | 新终端 | +| `newTabCwd` | `inherit` \| `project` \| `home` | `inherit` | 固定 | VS Code `splitCwd: inherited`、GNOME Terminal、Ghostty(research/05 决策 10) | 立即(下次新建标签时生效) | +| `env` | object(值为 string \| null) | `{}` | — | VS Code `env.*`、WT `environment`;`null` = 删除 | 新终端 | +| `term` | string | `xterm-256color` | 固定 | D9 | 新终端 | +| `setLocaleVariables` | `auto` \| `on` \| `off` | `auto` | 跟随系统(只在 macOS 上生效) | §4.4 | 新终端 | +| `reloadEnvironment` | bool | `true` | 只在 Windows 上生效 | WT | 新终端 | + +**外观** + +| 字段 | 类型 | 默认 | 系统/固定 | 依据 | 热重载 | +|---|---|---|---|---|---| +| `font.family` | string \| string[] | `system` | 跟随系统:`.SF NS Mono` → Menlo / Cascadia Mono → Consolas / portal `monospace-font-name` | research/05 §7.1 | 立即 | +| `font.size` | number(pt)\| `system` | `system` | 跟随系统:macOS 12、Windows 12、Linux 取系统字号 | 同上 | 立即 | +| `font.lineHeight` / `letterSpacing` / `weight` / `weightBold` / `ligatures` | number / number / string / string / bool | 1.0 / 0 / normal / bold / false | 固定 | VS Code | 立即 | +| `font.fallback` | string[] | 按平台的 CJK 字体 | 跟随系统 | research/05 §7.1 | 立即 | +| `cursor.style` | `system` \| `block` \| `bar` \| `underline` | `system` | 跟随系统:macOS/Linux block,Windows bar | Terminal.app、VTE、WT | 立即 | +| `cursor.blink` | `system` \| bool | `system` | 跟随系统 | research/05 §7.2;`reduceMotion` 为真时不闪 | 立即 | +| `cursor.unfocusedStyle` | `outline` \| `none` \| `same` | `outline` | 固定 | VS Code | 立即 | +| `colors.dark` / `colors.light` | 调色板对象(`foreground`、`background`、`cursor`、`selectionBackground`、`ansi`[16]) | TCode 内置 | 跟随 `theme` | Ghostty、WT、Ptyxis | 立即(经 `session/setColors`,01 §3.2 规则 5) | +| `colors.minimumContrastRatio` | number(1–21) | 4.5(系统高对比度时 7) | 固定 | VS Code(research/05 决策 7) | 立即 | +| `colors.boldIsBright` | bool | false | 固定 | GNOME Terminal | 立即 | +| `scrollback` | int(行,0–1000000) | 10000 | 固定 | GNOME Terminal、Konsole、Alacritty | 新终端(两份核心必须一致) | +| `unicode.ambiguousWidth` | `narrow` \| `wide` | `narrow` | 固定 | 所有系统终端 | 新终端(核心选项) | +| `unicode.widthMode` | `compat` \| `grapheme` | macOS/Linux:`compat`(程序开启 2027 后自动切换到字形簇模式);**Windows:固定为 `grapheme`,设置值被忽略** | 跟随系统 | D9;Windows:ConPTY 的宽度在创建时就固定,对 DECSET 2027 不做任何处理,两份核心必须锁定为与 ConPTY 一致的 grapheme 模式,并忽略 2027 切换(03 §7.4) | 新终端(核心选项) | +| `rendering.gpu` / `customGlyphs` / `smoothScrolling` | `auto`\|`on`\|`off` / bool / bool | auto / true / false | 固定 | VS Code;`reduceMotion` 时强制关闭平滑滚动 | 立即 | + +**键盘、鼠标、链接、粘贴、响铃、剪贴板**(取值与依据沿用 research/05 §10.3,热重载均为"立即",以下几项除外) + +| 字段 | 类型 | 默认 | 热重载 | 说明 | +|---|---|---|---|---| +| `keyboard.kittyKeyboardProtocol` | bool | true | 新终端 | 影响核心对 `CSI ? u` 的应答,两份核心必须一致 | +| `keyboard.win32InputMode` | bool | true(只在 Windows 上生效) | 新终端 | D9;ConPTY 启动标志 | +| `keyboard.macOptionAsMeta` / `ctrlCCopies` / `skipShell` / `sendKeybindingsToShell` / `altGrAliasing` / `backspace` | — | research/05 §10.3 | 立即 | | +| `selection.*`、`mouse.*`、`links.*`、`paste.*`、`bell.*`、`clipboard.*` | — | research/05 §10.3 | 立即 | | + +**Shell 集成与生命周期** + +| 字段 | 类型 | 默认 | 系统/固定 | 依据 | 热重载 | +|---|---|---|---|---|---| +| `shellIntegration.enabled` | bool | true | 固定 | D7 | 新终端 | +| `shellIntegration.decorations` | bool | true | 固定 | VS Code `decorationsEnabled` | 立即 | +| `shellIntegration.failureNotice` | bool | true | 固定 | §5.5 | 立即 | +| `shellIntegration.notifyOnFinish` | `never` \| `unfocused` | `unfocused` | 固定 | Ptyxis、Ghostty、iTerm2 | 立即 | +| `shellIntegration.notifyOnFinishSeconds` | int | 10 | 固定 | [推] | 立即 | +| `confirmClose` | `whenRunning` \| `always` \| `never` | `whenRunning` | 跟随系统(macOS/Linux);Windows 比 WT 保守 | §6.1 | 立即 | +| `ignoreProcesses` | string[] | `[]`(叠加在内置列表上;`-name` 表示移除) | — | VS Code、WezTerm、Terminal.app | 立即 | +| `onExit` | `closeOnCleanExit` \| `close` \| `hold` | `closeOnCleanExit` | 固定 | §6.3 | 立即(作用于此后退出的会话) | +| `title.allowApplicationTitle` | bool | true | 固定 | WT | 立即 | + +**会话保活**(01 §3.3) + +| 字段 | 类型 | 默认 | 系统/固定 | 依据 | 热重载 | +|---|---|---|---|---|---| +| `persistence.onQuit` | `revive` \| `keepAlive` \| `close` | `revive` | 固定 | 01 §3.3;iTerm2、VS Code | 立即(下次退出时生效) | +| `persistence.scrollbackLines` | int(0–100000) | 1000 | 固定 | 01 §3.3;research/05 决策 9 | 立即(下次写快照时生效,包括 30 s 的增量保存) | +| `persistence.enabled` | bool | true | 固定 | 04 `host/configure` 的 `persistence.enabled`;false 时不写任何快照,`onQuit` 的 revive 退化为只恢复标签布局 | 立即 | +| `persistence.orphanGraceMinutes` | int(1–10080) | 30 | 固定 | 01 §3.3;04 §(宽限期):只计清醒时间 | 立即(推送给 host) | +| `persistence.keepAliveMaxHours` | int \| null | null(不限制) | 固定 | 04:keepAlive 退出时不计宽限期,可选上限 | 立即 | +| `persistence.saveIntervalSec` | int(5–3600) | 30 | 固定 | 01 §3.3、04:增量保存间隔 | 立即 | + +键名与语义以 [04-pty-host.md](04-pty-host.md) 为准(`host/configure` 的 `persistence{…}` 一一对应),本表只登记 settings.json 中的写法。**Agent 创建的会话默认不持久化**(01 §3.3、04),不受这些字段影响;是否允许持久化由 09 决定。快照文件权限 0600(01 §8)。`scrollbackLines: 0` 时只恢复标签、profile、cwd 和标题,不保存任何输出内容,适合在意敏感输出的用户。 + +**图形**(D8) + +| 字段 | 类型 | 默认 | 依据 | 热重载 | +|---|---|---|---|---| +| `graphics.kitty` | bool | true | D8 | 新终端(核心选项) | +| `graphics.kittyStorageLimitMB` | int(0–4096) | 128 | Ghostty `image-storage-limit` 默认 320 MB,而且主屏和备用屏各一份 [实 GH src/config/Config.zig:2520-2522]。我们的镜像核心和 UI 侧核心各持有一份,最坏情况是 4 倍(2 个屏幕 × 2 份核心),所以取得更保守 [定]。镜像核心能否只保留元数据、不保留像素,由 02 决定 | 新终端(核心选项) | + +**其他** + +| 字段 | 说明 | +|---|---| +| `accessibility.announceOutput` | `auto` \| `on` \| `off`,默认 `auto`(检测到屏幕阅读器时开启);立即生效 | +| `ssh.*` | 见 [08-ssh.md](08-ssh.md) §5 | +| Agent 相关 | **不在 `terminal` 节内**。由 [09](09-agent-integration.md) §2.2 定义为顶层的 `agent.shell.*`(`mode`、`profile`、`onTimeout`、`reveal`、`idleInputSeconds`、`preventHistory`)和 `agents.acp.clientTerminal`。其中 `agent.shell.profile` 可以引用 `terminal.profiles` 中的 id,此时 LaunchBuilder 按 `Purpose = Agent` 构造(§4.6 第 7 步追加调用方环境);`preventHistory` 的实现见 §5.7 | +| `macos` / `windows` / `linux` | 平台覆盖块(§7.1 第 3 条)。不能嵌套 | + +### 7.4 profile 字段(`terminal.profiles.`) + +| 字段 | 类型 | 探测到的 profile | 自定义 profile | 说明 | +|---|---|---|---|---| +| `name` | string | 可选 | 可选,默认为 id | 显示名称 | +| `command` | string | 可选(替换可执行文件) | **必填**(`type: ssh` 时不需要) | 绝对路径或 PATH 中的名字(§2.2 的搜索顺序) | +| `args` | string[] | 可选(**替换**探测到的参数) | 可选 | | +| `commandLine` | string | 可选 | 可选 | 只在 Windows 上生效:原始命令行,设置后忽略 `args`(§3.4) | +| `kind` | `bash` \| `zsh` \| `fish` \| `pwsh` \| `powershell` \| `cmd` \| `nu` \| `other` | 可选 | 可选,默认按文件名推断 | 决定 argv0 形式和注入方式 | +| `loginShell` | bool | 可选 | 可选 | 默认:macOS true,其他平台 false | +| `cwd` | string | 可选 | 可选 | 覆盖全局 `cwd`;支持 `~` 和变量 | +| `env` | object | 可选(与探测值逐键合并) | 可选 | 同全局 `env` | +| `shellIntegration` | bool | 可选 | 可选 | 覆盖全局开关 | +| `enabled` | bool | 可选 | 可选 | 默认 true | +| `icon` | string | 可选 | 可选 | lobe-icons 名或内置图标名 | +| `platforms` | (`macos`\|`linux`\|`windows`)[] | 不适用 | 可选 | 省略 = 所有平台 | +| `type` | `local` \| `ssh` | 不适用 | 可选,默认 `local` | `ssh` 的其余字段见 08 §2.4 | + +### 7.5 校验与错误级别 + +| 级别 | 示例 | 影响 | +|---|---|---| +| 文件错误(沿用 settings.md §2.5) | JSON 语法错误 | 沿用上一次正确的配置 | +| profile 错误 [新增] | 自定义 profile 没有 `command`;`type` 非法;`platforms` 中有未知值 | 只停用该 profile;设置页的 profile 列表中显示红色标记和原因 | +| 字段警告 [新增] | `font.size: "big"`;`onExit: "never"`;`scrollback: -1` | 该字段回到默认值,在设置页"终端"顶部显示黄色提示,并写入日志 | +| 警告 | 未知字段 | 忽略 | + +### 7.6 与 TCode.Config 的集成方案(只写方案,本文不改代码) + +注意:另一个会话正在给 TCode.Config 加 MCP 配置,下面的改动要在它合入之后进行,并沿用它的模式(`McpServers` 以 `JsonObject` 原样保留,再逐项解析)。 + +| 文件 | 改动 | +|---|---| +| `src/TCode.Config/Settings.cs` | `TCodeSettings` 增加 `public JsonObject? Terminal { get; init; }`(原样保留,同 `McpServers`),注释指向 `TerminalConfig.Parse`。`SettingsJsonContext` 增加 `[JsonSerializable(typeof(TerminalSettings))]` | +| `src/TCode.Config/TerminalSettings.cs`(新) | 反序列化用的记录类型,全部可空,与 settings.md 的写法一一对应:`TerminalSettings`、`TerminalProfileSettings`、`TerminalFontSettings`、`TerminalCursorSettings`、`TerminalColorsSettings`、`TerminalPaletteSettings`、`TerminalKeyboardSettings`、`TerminalSelectionSettings`、`TerminalMouseSettings`、`TerminalLinksSettings`、`TerminalPasteSettings`、`TerminalBellSettings`、`TerminalClipboardSettings`、`TerminalShellIntegrationSettings`、`TerminalPersistenceSettings`、`TerminalGraphicsSettings`、`TerminalSshSettings`、`JsonObject? Agent`(09 定稿前原样保留)。`font.size`、`cursor.blink` 这类"`system` 或具体值"的字段用 `JsonNode?` 接收,由 `TerminalConfig` 解释,避免自定义转换器 | +| `src/TCode.Config/TerminalConfig.cs`(新) | `public static TerminalConfig Parse(JsonObject? terminal, TerminalPlatform platform, List warnings)`:① 平台覆盖块的 JSON 级深合并;② 逐字段校验(枚举、范围),非法值记警告并回到默认;③ 逐个 profile 解析,错误只停用该项(同 `McpServerConfig.ParseServers`);④ 产出规整后的 `TerminalConfig`(不含 `system` 以外的平台判断:`system` 由 host 或 Desktop 的平台层解析)。另提供 `static void Validate(JsonObject terminal, List warnings)`,供 `SettingsFile.Parse` 调用 | +| `src/TCode.Config/SettingsFile.cs` | `TopLevelKeys` 增加 `"terminal"`;在 `Parse` 中,当 `obj["terminal"]` 是对象时调用 `TerminalConfig.Validate(…, warnings)`,**只产生警告,不产生 errors**(§7.5);不是对象时记警告。增加写回辅助方法:`JsonObject Terminal(JsonObject root)`、`JsonObject TerminalProfile(JsonObject root, string id)`、`void SetTerminal(JsonObject root, string dottedPath, JsonNode? value)`(例如 `"font.size"`,值为 null 时删除,空对象顺带删除) | +| `src/TCode.Config/settings.schema.json` | 顶层 `properties` 增加 `"terminal": { "$ref": "#/definitions/terminal" }`(顶层 `additionalProperties: false`,**必须**加)。新增定义:`terminal`(含 `macos`/`windows`/`linux` → `#/definitions/terminalPlatformOverride`)、`terminalPlatformOverride`(与 `terminal` 相同但不含平台键,用 `allOf` 复用公共部分)、`terminalProfile`、`terminalPalette`、`terminalEnv`(`additionalProperties: { "type": ["string", "null"] }`)。所有枚举字段同时接受 `"system"`。`description` 用英文,与现有 schema 一致 | +| `src/TCode.Config/SettingsSchema.cs` | 不变(继续写出嵌入的 schema) | +| `src/TCode.Terminal.Protocol/TerminalHostConfig.cs`(新,不属于 TCode.Config) | host 端使用的 DTO(源生成 JSON)。即 04 §(`host/configure`)中的 `HostSettingsDto`:本文的 profiles 覆盖对应其 `settingsProfiles[]`,`ignoreProcesses` 对应 `confirmIgnoreProcesses[]`,`persistence.*` 一一对应;另需增加 §4.1 的 `baseEnvironment` 和启动类字段(env、cwd、term、setLocaleVariables、shellIntegration、reloadEnvironment)。`TCode.Desktop` 的 `TerminalSettingsAdapter` 负责 `TerminalConfig → TerminalHostConfig` 的映射;终端库因此不依赖 TCode.Config(D2) | +| `src/TCode.Desktop/Services/UserSettingsService.cs` | 增加 `TerminalConfig Terminal`(`Changed` 时重新解析);增加 `SetTerminal(path, value)` 等写回方法,供设置页使用 | +| 测试 | 新建 `tests/TCode.Config.Tests`(或沿用 `TCode.AgentHost.Tests` 中 `ModelCatalogTests` 的位置):`TerminalConfigTests`(平台合并、字段级容错、profile 级容错、JSONC);`SchemaParityTests`:断言 `SettingsFile` 中的已知键集合与 schema 的 `properties` 一致,避免两边不同步(目前 schema 是手写的) | + +### 7.7 docs/settings.md 需要补充的章节(大纲) + +``` +§1 设计原则 + + 第 6 条:terminal 节采用字段级容错(字段非法只回到默认值,profile 非法只停用该 profile) + + 第 7 条:平台覆盖块(macos / windows / linux) +§2 格式 + 示例中加一段最小的 "terminal"(defaultProfile + 一个 profile 覆盖 + font.size) +§2.1 顶层字段 + + terminal | object | 无 | 见 §2.7 +§2.5 错误的级别 + + "profile 错误"与"字段警告"两行 +§2.7 终端(terminal)——新增 + 2.7.1 概览与原则(跟随系统、热重载的两类、只管交互终端) + 2.7.2 profiles:探测到的 id 一览(各平台)、覆盖与自定义、字段表、信任(MSYS2/Cygwin) + 2.7.3 启动:defaultProfile、cwd、newTabCwd、env(null 删除、变量展开、合并顺序摘要)、term、setLocaleVariables、reloadEnvironment + 2.7.4 外观:font、cursor、colors、scrollback、unicode、rendering + 2.7.5 键盘、鼠标与选择、链接、粘贴、响铃、剪贴板 + 2.7.6 Shell 集成:开关、支持的 shell、失败原因、手动 source 的片段、对 rc 文件的影响 + 2.7.7 生命周期:confirmClose、ignoreProcesses、onExit、title + 2.7.8 会话保活:persistence.*(含快照中可能有敏感输出的提示) + 2.7.9 图形:graphics.* + 2.7.10 SSH:ssh.*(链接到 docs/terminal/08-ssh.md 的用户部分) + 2.7.11 与 Agent 的关系:说明 agent.shell.* 是独立的顶层节(链接到 09 §2.2),以及 agent.shell.profile 引用 terminal.profiles 的规则 + 2.7.12 平台覆盖块示例 +§5 界面与写回 + + 左侧导航增加"终端",子页见本文 §8 +``` + +--- + +## 8. 设置页 UI 分组建议 + +左侧导航"通用 / 模型与厂商 / **终端** / 关于"。"终端"下分子页,每个字段旁显示"跟随系统(解析值)"或"已固定",以及"对新终端生效"的标记。 + +| 子页 | 内容 | +|---|---| +| 常规 | 默认 profile(下拉,带图标和版本);起始目录(项目 / home / 自定义);新标签目录(继承 / 项目 / home);关闭确认;shell 退出后;标题允许程序设置 | +| Profiles | 主从双栏(与"模型与厂商"一致):左栏分"探测到的 / 自定义 / SSH 主机"三组,每项有启用开关和 shell 集成状态点;右栏为表单(命令、参数、login、cwd、env 表格、shell 集成、图标),"重新探测"按钮,"用此 profile 打开终端"按钮;探测到的 profile 显示"已覆盖的字段"并可逐项"恢复默认" | +| 外观 | 字体(族、字号、行高、连字、回退);光标;配色(亮/暗两套,带预览);最小对比度;粗体高亮;回滚行数;Unicode 宽度 | +| 键盘与鼠标 | Option 作为 Meta;Ctrl+C 复制;不交给 shell 的命令列表;Kitty 键盘协议;win32-input-mode;右键 / 中键行为;Alt 单击移动光标;选词分隔符;选中即复制 | +| 粘贴与链接 | 多行警告、大段粘贴警告、去掉末尾换行;链接识别、允许的 scheme;OSC 52 读 / 写 | +| Shell 集成 | 总开关;命令装饰;命令完成通知(阈值);每种 shell 的支持情况表;手动 source 的片段(带"复制"按钮);失败提示开关 | +| 会话与恢复 | 退出时(恢复内容 / 保持运行 / 全部关闭);保存的行数;无连接宽限期;恢复标签;"清除已保存的终端内容"按钮 | +| 响铃与无障碍 | 声音、视觉、请求注意;朗读输出 | +| SSH | 见 08 §5 | +| Agent | 不在终端页内:放在"Agent"相关设置页,编辑顶层 `agent.shell.*`(09 §2.2);终端页只放一个跳转链接 | +| 高级 | 环境变量(全局 env 表格,`null` 用"删除"开关表示);TERM;locale;Windows 环境刷新;渲染(GPU、内置绘制字形、平滑滚动);Kitty 图形与内存上限;"在 settings.json 中编辑"链接 | + +写回规则沿用 settings.md §5:按 JSON 节点写回,只改动涉及的字段,注释会丢失(保存前提示)。平台相关的字段(例如在 Windows 上修改默认 profile)默认写入基础值;字段旁有"仅对此平台"复选框,勾选后写入平台覆盖块 [定]。 + +--- + +## 9. 测试计划 + +### 9.1 单元测试(`tests/TCode.Terminal.Shell.Tests`,三平台都跑) + +**可替换的系统访问**(AOT 友好,手写假实现,不用 Moq): + +```csharp +public interface IShellPlatform +{ + OSPlatformKind Os { get; } // MacOS | Linux | Windows + int WindowsBuild { get; } + Architecture OsArchitecture { get; } + IFileProbe Files { get; } // Exists、IsExecutable、RealPath、ReadAllText、EnumerateDirectories、GetFileVersion、IsAppExecLink + IRegistryReader? Registry { get; } // Windows:OpenSubKey、GetValue、GetSubKeyNames、LastWriteTime + IAccountInfo Account { get; } // Name、Home、Shell(替代 getpwuid) + IProcessRunner Processes { get; } // RunAsync(file, args, env, timeout) → (exit, stdout bytes, stderr) + IMacLocale? Locale { get; } // AppleLanguages、AppleLocale + IReadOnlyDictionary HostEnvironment { get; } +} +``` + +| 测试组 | 用例 | +|---|---| +| Unix 探测 | passwd 为 zsh、`$SHELL` 为 bash(chsh 后过期)→ 默认 zsh;passwd 为 `/usr/bin/false` → 回退 `$SHELL`;`/etc/shells` 含注释、空行、相对路径、tmux、rbash;merged-usr 下 `/bin/bash` 与 `/usr/bin/bash` 去重;同名不同路径的 id(`bash@/opt/homebrew/bin/bash`);`/etc/shells` 不存在;全部不可用 → 合成 `sh` | +| Windows 探测 | pwsh 排名(7.4 传统安装 vs 7.6 Store vs 7.6 scoop vs 7-preview)的各种组合与 id 分配;只有 Windows PowerShell;Git Bash 的四种来源与 `git-bash.exe` 排除;Lxss 注册表(含 docker-desktop、Modern=1、DefaultDistribution 排序);没有 Lxss 键时的 `wsl.exe -l -q` 回退:UTF-16LE + BOM + `\0` + `\r\n`、退出码非 0、超时;build < 19041 不探测 WSL;MSYS2 与 Cygwin 标记 RequiresTrust;vswhere JSON 解析与超时 | +| 合并 | 覆盖探测字段、`enabled: false`、自定义缺 `command` 只停用该项、`platforms` 过滤、平台覆盖块深合并(对象合并、数组替换、env 中的 null) | +| LaunchBuilder(macOS) | 各 Kind 的 argv 快照(argv0 为 `-`);bash 注入时 argv0 不加 `-`、设置 `TCODE_SI_BASH_LOGIN`;pwsh 的 `-Login` 在第一位;`loginShell: false` 时 PATH 合并 path_helper | +| LaunchBuilder(Windows) | `QuoteArg` 的向量(空串、空格、尾部反斜杠、内嵌引号、连续反斜杠后接引号,对照 CommandLineToArgvW 的往返结果——在 Windows CI 上用真实的 `CommandLineToArgvW` 解析验证);cmd/VS DevCmd 使用 RawCommandLine;Agent 参数含 `&` 时拒绝;WSL 的 `--cd` 各种来源 | +| 环境 | 剔除规则(每个类别至少一例,包括 `TCODE_PTYHOST_*`);`TCODE_HOME` 保留;用户 env 的 null 删除与覆盖顺序;`{env:VAR}` 展开对照 BaseEnvironment;Windows 键不区分大小写;WSLENV 追加;macOS LANG:已有 UTF-8 不改、`zh-Hans-CN` + `zh_CN` → `zh_CN.UTF-8`、locale 目录不存在 → `LC_CTYPE=UTF-8`、`@rg=` 地区 | +| 变量展开一致性 | 与 `McpVariables` 共用一份 JSON 测试向量文件(`tests/shared/variable-expansion.json`),两边的测试都读取它 | +| 注入计划 | §3.5 参数表中的每一行;zsh 已有 ZDOTDIR 时记入 `TCODE_SI_ZDOTDIR`;fish 的 XDG_DATA_DIRS 原来未设置 / 为空 / 有值三种情况;nu → NativeExpected;cmd 已有 PROMPT 时的包裹 | +| 关闭确认 | 用假的进程信息源:Executing 状态下前台为 bash → 忙;Prompt 状态 → 空闲;没有集成时前台 vim → 忙;tmux 默认算忙,`ignoreProcesses: ["tmux"]` 后空闲;`-zsh` 归一化;Windows 子树中只有 starship → 空闲 | +| 标题、cwd 继承 | 优先级表的每一级;OSC 7 主机名校验(localhost、本机名、`.local`、WSL 发行版名、远程主机拒绝);MSYS 路径换算;目录不存在时回退 | +| 设置解析(TCode.Config.Tests) | §7.5 的三个级别;schema 与已知键一致(`SchemaParityTests`) | + +### 9.2 注入脚本的集成测试(真实 shell + 真实 PTY,CI) + +**测试方式**:`ShellIntegrationE2ETests` 通过 `LaunchBuilder` 加 `TCode.Terminal.Pty` 真实启动 shell,用一个只解析 OSC 的小型扫描器(与 host 的 633 扫描器同一份代码)收集事件,**不依赖 UI**。每个 shell 跑同一段脚本: + +``` +等待握手 → 断言 A、B +输入 "echo hi\r" → 断言 E("echo hi", nonce 正确)、C、输出 "hi"、D;0、A、B +输入 "false\r" → 断言 D;1 +输入 "cd /tmp\r" → 断言 OSC 7 的路径为 /tmp(macOS 上为 /private/tmp 的 realpath,按目录身份比较) +输入 "echo 'a;b\\c'\r" → 断言 E 的转义与还原 +输入 "env | grep TCODE_SI_\r" → 断言没有输出(注入变量不泄漏) +输入 "exit 3\r" → Linux/Windows 断言退出码 3;macOS 断言 03 的 NOTE_EXITSTATUS 拿到 3(spike S1 成功时),否则断言 code 与 signal 都为空(未知),并且 login 的退出码为 0 +``` + +**rc 文件保真**(每个 shell 用一个临时 HOME): + +| 断言 | bash | zsh | fish | pwsh | +|---|---|---|---|---| +| 用户 rc 中定义的别名或函数可用 | `~/.bashrc` | `$ZDOTDIR/.zshrc`(自定义 ZDOTDIR)和 `~/.zshrc` | `config.fish` | `$PROFILE` | +| login 文件被读取(login 模式) | `~/.bash_profile` 设置的变量 | `.zprofile` | `status is-login` | — | +| 历史文件位置不变 | `HISTFILE` 未被改写 | 未设置 HISTFILE 时 `$HISTFILE == $ZDOTDIR/.zsh_history`(用户的 ZDOTDIR) | — | — | +| 环境还原 | — | `echo ${ZDOTDIR-unset}` 与注入前一致 | `XDG_DATA_DIRS` 与注入前完全一致(含"未设置") | — | +| 用户已有钩子保留 | 已有 DEBUG trap 和 PROMPT_COMMAND 仍然执行 | 已有 precmd 仍然执行 | 已有 `fish_prompt` 内容出现 | oh-my-posh 风格的自定义 `prompt` 仍然输出 | + +**CI 矩阵**(GitHub Actions): + +| Runner | Shell 与版本 | +|---|---| +| ubuntu-latest | bash 5.x、zsh 5.9、fish 3.x(apt)、fish 4.x(PPA 或官方 tarball)、pwsh 7(官方包)、nu(release 二进制,只断言 Native 状态) | +| ubuntu(RHEL 8 容器:`rockylinux:8`) | bash 4.4、zsh 5.5(验证老版本;zsh < 5.1 的路径用单元测试覆盖) | +| macos-latest | `/bin/bash` 3.2(`--init-file`)、Homebrew bash 5、`/bin/zsh`、fish(brew)、pwsh(brew);端到端断言 shell 的非 0 退出码能传回 | +| windows-latest | pwsh 7、Windows PowerShell 5.1(先在测试进程作用域设置 `-ExecutionPolicy RemoteSigned`,另有一个用例保持 Restricted,断言收到 `TCodeShellIntegrationError=…` 和 `Failed(ExecutionPolicy)`)、Git Bash(runner 自带 Git)、cmd(断言 PROMPT 部分集成:A、B、OSC 9;9) | + +- **超时检测**:用一个故意在 `.zshenv` 之前覆盖 ZDOTDIR 的环境(模拟 `/etc/zshenv`:通过 `zsh -d` 以外的方式无法改系统文件,所以在容器测试中写 `/etc/zshenv`),断言 10 s 后进入 `Failed(Timeout)`。测试中把超时缩短为 2 s(`IShellPlatform` 提供时钟)。 +- **AOT**:E2E 测试同时以 AOT 发布的 host 运行一次(01 的要求)。 + +### 9.3 人工验收清单(M4 "与系统一致") + +- macOS:新终端的 `ps -o args= -p $$` 显示 `-zsh`;`exit 3` 后标签保留并显示退出码 3;`echo $LANG` 与 Terminal.app 一致;Homebrew 程序在 PATH 中; +- Linux(GNOME、KDE 各一):`echo $0` 为 `bash`(非 login);`$SHELL` 与 passwd 一致; +- Windows:默认 pwsh 与 WT 的默认 profile 相同;WSL 发行版列表与 `wsl -l` 一致;安装软件修改 PATH 后新终端立即可见。 + +--- + +## 10. 待决问题与对 01 的补充 + +| # | 问题 | 本文的处理 | 需要谁决定 | +|---|---|---|---| +| 1 | 01 §6.3 的 `TerminalProfile` 缺少 `PathStyle`、`RawCommandLine`、`Environment`、`RequiresTrust`、`WslDistribution`、`Ssh` 等字段;`IProfileProvider.Detect()` 是同步的,但 vswhere 和 `wsl.exe` 回退需要异步 | §1.2 以向后兼容的方式扩充,并增加 `IAsyncProfileProvider` | 01 维护者确认 | +| 2 | 01 §6.2 的 `PtyLaunch` 只有参数数组;Windows 的 cmd / .bat 需要原始命令行 | 03 §4 已增加 `PtyLaunch.WindowsCommandLine`,本文的 `RawCommandLine` 映射到它 | 已解决 | +| 3 | 01 §3.3 规定 AgentHost 通过环境变量拿 pty host 的地址和令牌:这些变量会被 AgentHost 的 `BashTool` 子进程继承,Agent 执行的任何命令都能连上 pty host | 交互终端已经按 `TCODE_*` 剔除;`BashTool` 也必须剔除,或者改用文件句柄或参数传递 | 04 / 09 | +| 4 | 01 §3.3 "用户正常退出 TCode:有程序在运行时汇总确认一次":在 `keepAlive` 模式下进程不会结束,不需要确认 | §6.1 按 keepAlive 跳过确认 | 01 维护者确认 | +| 5 | libghostty-vt 不解析 OSC 633 | 09 已核实未知序列回调可用,§5.2 改为在回调中解析;字节流扫描只作备选 | 已由 09 解决 | +| 6 | pty host 的环境是第一个 Desktop 的环境,跨 Desktop 重启会过期 | §4.1:Desktop 在 `host/configure` 中发送 BaseEnvironment | 04(IPC 协议需增加该字段) | +| 7 | 01 §4 的测试项目列表里没有 `TCode.Terminal.Shell.Tests` | §9 建议新增 | 01 / 11 | +| 8 | ~~macOS login 包装会吞掉退出码~~ | 已解决:2026-10-17 用户决定不经 login,改用 argv0 `-zsh`(§3.2) | — | +| 13 | pty host 不能用 `System.Diagnostics.Process`,但 03 的 C API 只有 PTY 模式的启动;`tcode-pty-spawn` 是 Unix 程序,04 却要求 Windows 上的 `wsl.exe -l` 也经它执行 | §2.8:需要 03 增加管道模式的启动接口,并明确 Windows 上的执行方式 | 03 / 04 | +| 14 | Windows 的宽度模式锁定为 grapheme(03 §7.4),与 D9 "默认兼容模式"不同 | §7.3 的 `unicode.widthMode` 在 Windows 上忽略设置值 | 01 维护者确认 | +| 9 | `exec -la` 的 `-a` 在 `/bin/bash` 3.2 上是否可用 | Terminal.app 的字符串说明它可用;CI 实测 | 实测 | +| 10 | fish ≥ 4 是否自带 OSC 133;nu 默认是否开启 OSC 133 | §5.3.3、§5.3.5 按"自带"设计,并在运行时检测 | 实测 | +| 11 | `TCODE_TERMINAL_ID` 是否暴露 | 建议暴露(§4.2) | 09 | +| 12 | `terminal` 节采用字段级容错,与 settings.md 中"类型错误 = 文件错误"的规则不同 | §7.1 说明了理由;需要在 settings.md 中写明 | 用户 | + +## 11. 来源 + +**源码**(只读克隆,commit 见文首): + +- VS Code:`src/vs/platform/terminal/node/terminalEnvironment.ts`(:52-280 注入逻辑,:330-380 参数表)、`src/vs/platform/terminal/node/terminalProfiles.ts`(:96-142、:286-336、:347-389、:401-440)、`src/vs/base/node/powershell.ts`(:240-310)、`src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh`(:1-60、:151-190、:250-360)、`shellIntegration-env.zsh`、`shellIntegration-profile.zsh`、`shellIntegration-login.zsh`、`shellIntegration-rc.zsh`(:1-40)、`shellIntegration.ps1`(:1-40、:95-140) +- Ghostty:`src/termio/Exec.zig`(:1870-2020)、`src/termio/shell_integration.zig`(:43-160、:299-410、:620-700、:779-830、:919-948)、`src/shell-integration/README.md`、`src/shell-integration/zsh/.zshenv`、`src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish`、`src/shell-integration/bash/ghostty.bash`(文件头许可)、`src/terminal/osc/parsers/semantic_prompt.zig`、`src/config/Config.zig`(:2520-2522) +- Windows Terminal:`src/cascadia/TerminalSettingsModel/WslDistroGenerator.cpp`(:15-110、:195-215)、`PowershellCoreProfileGenerator.cpp`、`VsDevCmdGenerator.cpp`、`VsDevShellGenerator.cpp`、`defaults.json`、`src/cascadia/TerminalConnection/ConptyConnection.cpp`、`src/inc/til/env.h` +- iTerm2:`sources/Locale/iTermLocaleGuesser.swift`(:80-230) +- 本仓库:`src/TCode.Config/{Settings,SettingsFile,SettingsSchema,McpServerConfig,McpVariables,ConfigPaths}.cs`、`settings.schema.json`、`src/TCode.Desktop/Services/UserSettingsService.cs`、`src/TCode.AgentHost/SettingsWatcher.cs`、`LICENSE`(Apache-2.0) + +**文档**: + +- about_Pwsh(`-Login` 必须是第一个参数;`-Command` 必须是最后一个参数;`-NoExit`):https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_pwsh +- 命令行参数解析规则:https://learn.microsoft.com/cpp/c-language/parsing-c-command-line-arguments +- WT shell 集成教程(cmd 的 PROMPT 写法):https://learn.microsoft.com/windows/terminal/tutorials/shell-integration +- `man 1 login`、`man 8 path_helper`(macOS 26.6.2 本机) +- 其余结论引用 research/02、03、05 中已经标注的来源 diff --git a/docs/terminal/08-ssh.md b/docs/terminal/08-ssh.md new file mode 100644 index 0000000..4353cde --- /dev/null +++ b/docs/terminal/08-ssh.md @@ -0,0 +1,710 @@ +# 08 · SSH + +> 状态:设计稿 v1(2026-10-17)。上游:[01-architecture.md](01-architecture.md) D4、§6.2、§10 M7;[research/06](research/06-ssh-transport.md);[research/03](research/03-vt-protocol.md) §7;[07-shell-profiles-config.md](07-shell-profiles-config.md)(profile、环境变量、shell 集成、`terminal` 节);[04-pty-host.md](04-pty-host.md)(IPC、会话)。 +> +> 标注:**[实]** = 已在源码、官方文档或本机核实;**[推]** = 推断,实现前要验证;**[定]** = 本文的设计决定。 +> +> 源码版本:OSSH = openssh-portable `6545e6a`(2026-10-16);TMDS = Tmds.Ssh `e186178`(2026-10-14,发布版 0.26.0);GH = ghostty `b3dc3d7`;ZED = zed `6b218b8`;WT = microsoft/terminal `44b440b`。 + +## 0. 结论摘要 + +1. **第一期 = 本地 PTY 里运行系统 `ssh`**(D4)。TCode 不写任何 SSH 协议代码,只做三件事: + - 从 `~/.ssh/config` 生成主机列表(`ssh:<别名>` profile); + - 构造 `ssh` 命令行; + - 处理连接失败(退出码 255)的界面。 + + 认证、主机密钥确认、FIDO 触摸、ControlMaster 复用全部由 OpenSSH 在终端里自己完成,行为与系统终端完全一致。 +2. **TERM 不需要 `SetEnv`**:本地 TERM 已经是 `xterm-256color`,ssh 会把它放进 `pty-req` 发给服务端 [实 ssh_config(5)]。另外: + - **不要**像 Ghostty 那样传 `-o SetEnv=TERM=…`。`SetEnv` 是"第一次出现的那组生效",命令行上的 `-o SetEnv` 会**整组替换**用户 config 里的 `SetEnv` [实 OSSH readconf.c:1962-1989]; + - 用 `-o SendEnv=COLORTERM` 等传递 `COLORTERM`、`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`。`SendEnv` 是追加语义 [实 OSSH readconf.c:1936-1960],服务端不接受时被静默丢弃(env 请求的 want_reply=0)[实 OSSH clientloop.c:2706]。上游 sshd 默认不接受任何变量;Debian/Ubuntu 默认接受 `COLORTERM`。 +3. **askpass 第一期不接**:ssh 运行在 PTY 里,所有提示都直接出现在终端中。Zed 需要 askpass,是因为它的 master 连接没有 TTY [实 ZED crates/askpass];我们没有这种场景。 +4. **第二期**:引入 Tmds.Ssh,只用于 SFTP 文件树和 Agent 的远程只读操作。 + - 配置**不交给 Tmds.Ssh 的解析器**(它遇到 `ProxyCommand`、`SetEnv` 等关键字会直接抛异常 [实 research/06 §2.3]),而是先运行 `ssh -G <别名>` 拿到 OpenSSH 的最终结果,再自己映射到 `SshClientSettings`; + - 无法映射的情况(ProxyCommand、HostKeyAlias、keyboard-interactive、FIDO 密钥文件、PKCS#11)整体判为"不兼容",文件树显示原因,终端不受影响; + - Tmds.Ssh 的几个公开 API 缺口已经核实:`Proxy` 只有 internal 抽象成员,外部无法实现 ProxyCommand;`SshAgentCredentials(address)` 是 internal,无法指定 `IdentityAgent`;没有基于 Stream 的 `SftpClient`。这些计划通过上游 PR 解决。 +5. **第二期的交互终端仍然用系统 ssh**,不换成 Tmds.Ssh 的通道:一致性的价值远大于"少一次认证"。只提供一个可选的 `managed` 模式,给 Windows 上只能用密码登录的主机用(Windows 没有 ControlMaster,否则要输两次密码)。 + +--- + +## 1. 范围 + +| 期 | 交付 | 里程碑 | +|---|---|---| +| 一期 | 主机列表;`ssh` profile(探测得到的 + settings 中自定义的);`ssh` 可执行文件探测与缺失提示;`SendEnv`;255 退出码的界面;与 ControlMaster 共存;安全规则;CI 容器测试 | M7(01 §10) | +| 二期 | Tmds.Ssh 引入与 AOT 验证;`ssh -G` → `SshClientSettings` 的映射;known_hosts 界面;SFTP 连接池;远程文件树;可选的 `managed` 终端模式;Tmds.Ssh 上游 PR | 文件树功能上线时 | +| 以后 | 远程 shell 集成(§2.7);ControlMaster 预热;Windows 上的 askpass | — | + +不做:内置 SSH 客户端替代系统 ssh(research/06 的路线 B);mosh;串口。 + +--- + +## 2. 第一期:系统 ssh + +### 2.1 ssh 可执行文件的探测 + +| 平台 | 候选(按顺序) | 依据 | +|---|---|---| +| macOS | `/usr/bin/ssh` | 系统自带 | +| Linux | 基础环境 PATH 中的 `ssh`;然后 `/usr/bin/ssh`、`/usr/local/bin/ssh` | 发行版包 `openssh-client(s)` | +| Windows | ① `%SystemRoot%\System32\OpenSSH\ssh.exe`("可选功能"安装);② `%ProgramFiles%\OpenSSH\ssh.exe`(MSI 安装);③ `%ProgramFiles(x86)%\OpenSSH\ssh.exe`;④ PATH 中的 `ssh.exe`;⑤ Git for Windows 自带的 `\usr\bin\ssh.exe` | ①–③ [实 WT SshHostGenerator.cpp:18-24、44-58];④⑤ [定] | +| 全部 | `terminal.ssh.path` 设置了就只用它 | [定] | + +- **Git for Windows 的 ssh 排在最后**,并在界面上标注"MSYS 版本": + - 它是 MSYS 程序,`~` 指向 MSYS 的 home; + - 它不使用 Windows 的 `\\.\pipe\openssh-ssh-agent`,所以 Windows 的 ssh-agent 服务、1Password 的 agent 都对它无效 [实 research/06 §3]; + - 与 Win32-OpenSSH 混用时,用户会遇到"终端里能连上,文件树连不上"这类不一致。 +- **外部命令的执行**:`ssh -V`、`ssh -G` 都在 pty host 中运行,必须经 07 §2.8 的执行器(`tcode-pty-spawn --exec` + 管道),不能用 `System.Diagnostics.Process`(03 §10 R5、04)。 +- **版本检查**:探测到后运行一次 `ssh -V`(版本信息输出到 stderr,例如 `OpenSSH_10.3p1, LibreSSL 3.3.6`、`OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2`),结果按路径和 mtime 缓存。 + - 版本低于 7.3 时,`Include` 和 `ProxyJump` 不可用,主机列表仍然生成,但在设置页提示 [推:7.3 引入 Include/ProxyJump]; + - `ssh -G` 需要 6.8 以上 [推]。 +- **找不到 ssh 时**:主机列表为空,设置页的 SSH 子页和"+"菜单的 SSH 分组显示一条提示: + - Windows:"未找到 OpenSSH 客户端。可以在 设置 → 系统 → 可选功能 中添加"OpenSSH 客户端",或在管理员 PowerShell 中运行 `Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0`。" 附带"重新检测"按钮。命令只显示给用户,**TCode 不自动执行**(需要管理员权限)。 + - Linux:"未找到 ssh。请通过发行版的包管理器安装 openssh-client(Debian/Ubuntu)或 openssh-clients(Fedora/RHEL)。" + - macOS 不会出现这种情况。 +- 探测在 pty host 中完成(`SshProfileProvider : IProfileProvider`,07 §1.2),与本地 shell 的探测共用缓存和刷新机制(07 §2.6)。 + +### 2.2 主机列表:解析 `~/.ssh/config` + +**读哪些文件**: + +| 平台 | 用户配置 | 系统配置 | +|---|---|---| +| macOS / Linux | `~/.ssh/config` | `/etc/ssh/ssh_config` | +| Windows(Win32-OpenSSH) | `%USERPROFILE%\.ssh\config` | `%ProgramData%\ssh\ssh_config` [实 WT SshHostGenerator.cpp:26-27] | + +`terminal.ssh.configFile` 设置了时只读这个文件(对应 `ssh -F`),并且所有 ssh 命令都加 `-F `。 + +**解析器**(`SshConfigScanner`,只为了列出主机名,不解释其他关键字)[定]: + +``` +Scan(file, isUserConfig, depth): + if depth > 16: 警告并停止 // 与 OpenSSH 的 READCONF_MAX_DEPTH 相同 [实 OSSH readconf.c:2629] + for line in ReadLines(file)(去掉 UTF-8 BOM,兼容 CRLF): + line = 去掉首尾空白;空行和 '#' 开头的行跳过 + (keyword, args) = 拆分:关键字后面可以是空白或 '='("Host=foo" 也合法);参数支持双引号 + switch lower(keyword): + "host": + for pattern in args: + if pattern 含 '*' 或 '?',或以 '!' 开头: continue // 通配符与否定不生成条目 + yield HostEntry(alias: pattern, file, line, block: 当前块) + "match": 进入 Match 块(不生成条目;块内的 HostName 等不归属任何别名) + "include": + for spec in args: + path = ExpandTilde(ExpandEnv(spec)) // ssh_config(5):支持 ~、环境变量;%token 不展开 + if !IsAbsolute(path): path = (isUserConfig ? "~/.ssh/" : "/etc/ssh/") + path + for f in Glob(path) 按字典序: Scan(f, isUserConfig, depth + 1) + "hostname", "user", "port", "proxyjump", "remotecommand", "requesttty": + 记录到当前 Host 块(只用于展示和 §2.4 的提示;连接时以 ssh 自己的解析为准) +``` + +- 依据:`Include` 的相对路径规则、glob、按字典序处理、可以出现在 Host/Match 块内 [实 OSSH ssh_config.5:288-310]。 +- **块内的 Include 无条件展开**:列主机时我们不知道它所在的块会不会匹配。这样可能多列出一些主机,但不会漏掉。 +- **去重**:ssh 会把命令行上的主机名转成小写再匹配 [推],所以别名不区分大小写去重,保留第一次出现的写法。 +- **与 WT 的差异**:WT 只列出带有 `HostName` 的 Host 块 [实 WT SshHostGenerator.cpp:94-131]。我们列出所有非通配的别名,因为 `Host build-01` 加上 DNS 就能直接连接,不一定写 HostName。 +- **隐藏的主机** [定]:常见 git 托管站点的 Host 块只用来配置密钥,连上去也拿不到 shell。默认隐藏下列别名(可以在 `terminal.ssh.hiddenHosts` 中修改): + - `github.com`、`gist.github.com`、`ssh.github.com`; + - `gitlab.com`、`altssh.gitlab.com`; + - `bitbucket.org`、`altssh.bitbucket.org`; + - `ssh.dev.azure.com`、`vs-ssh.visualstudio.com`; + - `codeberg.org`、`git.sr.ht`。 +- **排序**:先按 `state.json` 中记录的最近使用时间,没有记录的按文件中出现的顺序。 +- **缓存**:记录所有读过的文件(包括 Include 进来的)的路径和 mtime;"+"菜单打开时检查 mtime,变了就重新扫描。一般一次扫描不到 5 ms。 +- **known_hosts 不用来生成列表**:其中的主机名通常是哈希过的(`|1|salt|hash`),而且大量是 git 托管站点。 + +### 2.3 `ssh -G`:什么时候用,怎么用 + +`ssh -G <别名>` 会在完成 Host/Match 解析后打印**最终配置**并退出,不发起连接 [实 OSSH ssh.1:276-283]。 + +**注意**:它会**执行** `Match exec` 里的命令(解析 Match 块就要执行),`CanonicalizeHostname` 也可能触发 DNS 查询 [推]。因此 [定]: + +- **不在生成列表时批量运行**:100 个主机就会执行 100 次用户的 `Match exec` 命令; +- 只在以下时机按需运行(单个主机,结果缓存到 config 文件的 mtime 改变为止): + 1. 设置页打开某个 SSH 主机的详情(显示解析后的 HostName、User、Port、ProxyJump、IdentityFile); + 2. 第二期建立 Tmds.Ssh 连接之前(§3.2); + 3. 一期连接前的"预检"——默认关闭,见 §2.4; +- **运行方式**: + - `argv = [ssh, (-F file)?, -G, --, <别名>]`; + - 环境同 07 §4(剔除 `TCODE_*` 等); + - 经 07 §2.8 的执行器运行;stdin 接 `/dev/null`,超时 5 s; + - stdout 按行解析为 `key value`:关键字是小写的;`identityfile`、`certificatefile`、`sendenv`、`setenv`、`localforward` 等可以出现多次,收集成列表; +- **退出码非 0**(通常是 255,config 有语法错误):把 stderr 显示在主机详情中,例如 "`/Users/u/.ssh/config line 12: Bad configuration option: foo`"。 + +### 2.4 ssh profile 的形态 + +**探测得到的**(`SshProfileProvider`): + +```csharp +new TerminalProfile( + Id: "ssh:" + alias, Name: alias, Command: sshPath, + Arguments: BuildSshArgs(target, config), // 见下 + LoginShell: false, Kind: ShellKind.Ssh, Icon: "ssh", Source: ProfileSource.Detected) +{ + Ssh = new SshTarget(Destination: alias, User: null, Port: null, ConfigFile: cfg.ConfigFile), + ShellIntegration = false, // §2.6 + PathStyle = PathStyle.Native, +} +``` + +**settings 中自定义的**(`terminal.profiles.`,`"type": "ssh"`): + +| 字段 | 类型 | 映射为 | 说明 | +|---|---|---|---| +| `host` | string,**必填** | 目标 | `~/.ssh/config` 中的别名或主机名 | +| `user` | string | `-l ` | | +| `port` | int | `-p ` | | +| `identityFile` | string | `-i ` | 展开 `~` | +| `jump` | string | `-J ` | ProxyJump | +| `args` | string[] | 插在目标之前 | 其他 ssh 选项,例如 `["-A"]` | +| `remoteCommand` | string | 目标之后的命令;同时加 `-t` | 例如 `"tmux new -A -s main"` | +| `sendEnv` | bool | 覆盖全局 `terminal.ssh.sendEnv` | | +| `name`、`icon`、`enabled`、`env`、`platforms` | — | 同 07 §7.4 | `env` 作用于本地的 ssh 进程(例如 `SSH_AUTH_SOCK`) | + +探测得到的 `ssh:<别名>` 也可以用同样的字段覆盖,例如 `"ssh:prod-1": { "remoteCommand": "tmux new -A -s main" }`。 + +**命令行构造**: + +``` +BuildSshArgs(t, cfg): + args = [] + if cfg.ConfigFile: args += ["-F", cfg.ConfigFile] + if sendEnv: args += ["-o", "SendEnv=COLORTERM", "-o", "SendEnv=TERM_PROGRAM", "-o", "SendEnv=TERM_PROGRAM_VERSION"] + if t.User: args += ["-l", t.User] + if t.Port: args += ["-p", t.Port] + if t.IdentityFile: args += ["-i", t.IdentityFile] + if t.Jump: args += ["-J", t.Jump] + args += t.ExtraArgs + if t.RemoteCommand: args += ["-t"] + args += ["--", t.Destination] // "--" 之后不再解析选项:防止以 "-" 开头的主机名被当作选项 + if t.RemoteCommand: args += [t.RemoteCommand] // 作为一个参数;远端由用户的登录 shell 解析 + return args +``` + +- **默认不加 `-t`**(与 01 D4 "`ssh -t`"的字面写法不同,列入 §8): + - 没有远程命令时,只要本地 stdin 是终端(PTY 正是),ssh 就会自动请求 PTY(`RequestTTY auto`); + - 强制 `-t` 会覆盖用户 config 中的 `RequestTTY no`,这不符合"与系统一致"。 + - 有 `remoteCommand` 时才加 `-t`,否则远端不会分配 PTY。 +- **校验目标** [定]:`host`、`user`、`jump` 不能包含空白和控制字符,`host`、`user` 不能以 `-` 开头。不合法时该 profile 报错、停用(07 §7.5)。虽然 argv 不经过 shell,`--` 也已经挡住了选项注入,但用户名或主机名以 `-` 开头基本都是配置错误。 +- **本地进程的环境**:同 07 §4 的规则(TERM、COLORTERM、TERM_PROGRAM 等都设置好)。 + - `SSH_AUTH_SOCK` 来自 BaseEnvironment。macOS 上它由 launchd 提供;Linux 上由桌面会话提供;Windows 的 Win32-OpenSSH 默认使用命名管道,不需要这个变量 [实 research/06 §3]。 + - **不设置 `SSH_ASKPASS*`**(§2.8)。 +- **macOS 不经 login 包装**:ssh 不是 shell(07 §3.2 的"非 shell 命令"),直接由 spawn helper 启动。`loginShell` 对 ssh profile 无意义,设置页不显示这个开关。这样还有一个好处:login 包装会吞掉退出码(login 恒 `exit(0)`,03 §8.3),而 ssh profile 不经 login,所以 §2.8 依赖的 255 退出码在 macOS 上也能可靠拿到 [定]。 +- **起始目录**:本地 cwd 对 ssh 没有意义,使用 home(避免 ssh 在不存在的目录中启动报错)。新标签从 ssh 标签继承 cwd 时不继承远端路径(07 §6.4)。 +- **标签标题**(07 §6.2):没有远端 OSC 标题时显示别名;副标题显示 `user@host:port`(来自 `ssh -G` 的缓存,没有缓存时只显示别名)。 + +### 2.5 远端的 TERM、COLORTERM 与 TERM_PROGRAM + +| 变量 | 怎么到达远端 | 结论 | 依据 | +|---|---|---|---| +| `TERM` | ssh 在 `pty-req` 中携带本地的 `$TERM`。"TERM 在请求 PTY 时总会发送" | 本地已经是 `xterm-256color`(07 §4.2),远端无需任何处理。**不加** `SetEnv TERM=…` | [实 OSSH ssh_config.5:1905-1908;research/06 §4] | +| `COLORTERM` | `SendEnv`,要求服务端 `AcceptEnv` | Debian/Ubuntu 的默认 sshd_config 含 `AcceptEnv LANG LC_* COLORTERM NO_COLOR` [实 research/06 §4];RHEL、macOS 和上游默认都不接受 → 远端程序回退到 256 色或主动查询 | [实 sshd_config(5):"The default is not to accept any environment variables"] | +| `TERM_PROGRAM`、`TERM_PROGRAM_VERSION` | `SendEnv` | 绝大多数服务器都不接受,被静默丢弃 | 同上 | +| `LANG`、`LC_*` | macOS 的默认 ssh_config 已经写了 `SendEnv LANG LC_*` [实 research/06 §4] | 与系统 ssh 相同,TCode 不额外处理 | | + +**为什么 `SendEnv` 安全而 `SetEnv` 不安全**: + +| 关键字 | 合并语义 | 在命令行上用 `-o` 传入的后果 | +|---|---|---| +| `SendEnv` | **追加**(源码注释:"appends to list; doesn't respect first-match-wins");用 `-VAR` 可以从列表中删除 | 在用户配置的基础上追加,无副作用 [实 OSSH readconf.c:1936-1960] | +| `SetEnv` | **第一次出现的那组整体生效**(`found = options->num_setenv == 0`) | 命令行参数先于配置文件处理,所以用户 config 里的所有 `SetEnv` 都会失效 [实 OSSH readconf.c:1962-1989]。Ghostty 的 `ssh-env` 正是这样做的 [实 GH src/cli/ssh.zig:290-303],这是一个副作用 | + +**服务端拒绝时**:env 请求的 want_reply 是 0 [实 OSSH clientloop.c:2706],客户端不会收到错误,也不会打印任何内容。用户看不到任何报错,正是我们想要的效果。 + +**没有 COLORTERM 时的真彩色**:程序仍然可以用 DECRQSS 或 XTGETTCAP 主动查询,这些查询经过 ssh 原样往返,由本地的镜像核心应答(research/03 §7.2–7.3)。 + +**`terminal.ssh.sendEnv` 默认 true** [定]。关闭它的理由只有一个:某些网络设备的 SSH 服务端遇到 env 请求会断开连接 [推]。 + +### 2.6 远端的 shell 集成 + +- **默认不注入**(07 §5.8): + - 远端没有我们的脚本,自动写入远端文件属于侵入行为; + - 远端可能根本不是 POSIX shell(网络设备、Windows OpenSSH 服务端的 cmd)。 +- **一期的效果**: + - 本地命令跟踪器看到的是一条"正在执行"的 ssh 命令(对 ssh profile 来说,整个会话就是这一条); + - 远端如果自己配置了 OSC 133(vte.sh、远端安装的 iTerm2/WezTerm 脚本、fish 4、nushell),镜像核心照样解析,状态为 `Native`(07 §5.5)。这时命令导航可用,但没有 nonce,也就没有可信的命令行,Agent 只能把它当作参考; + - 远端发来的 OSC 7 带有远端主机名,**不能**当作本地路径(07 §6.4;research/03 §7.3); + - 用户可以在远端手动 source 我们的脚本:设置页提供"复制安装命令",内容是把 `current//…` 的脚本内容通过 heredoc 写到远端 `~/.tcode/shell-integration/`,再加一行 rc 片段。这个命令由用户自己粘贴执行。 +- **以后的自动方案**(v2,按主机选择开启:`"ssh:prod-1": { "shellIntegration": true }`): + +| 方案 | 做法 | 优点 | 缺点 | +|---|---|---|---| +| A. kitty 式 tty 引导 | `ssh -t host '<一段 POSIX sh 引导脚本>'`:脚本关闭回显,用一次性口令向终端请求数据;TCode 在本地识别这个请求(一种私有 OSC/DCS),把 base64 的 tar(集成脚本 + 版本号)写进 PTY;脚本解包到 `~/.cache/tcode-si//` 后,`exec` 用户的登录 shell,并按 07 §5.3 的方式注入(ZDOTDIR 等通过 `env` 设置,不依赖 AcceptEnv) | 只用一条连接、一次认证;Windows 本地也能用(不需要 ControlMaster);不依赖 AcceptEnv | 只适用于 POSIX 远端;会占用 RemoteCommand(与用户自己的 RemoteCommand 冲突);需要在 TCode 的 PTY 输出路径上加一个握手识别器 [实 research/06 §5 引用 kitty 文档] | +| B. Ghostty 式 ControlMaster 复制 | 首次连接时经临时 ControlMaster 运行 `mkdir -p … && cat > …` 写入脚本,本地缓存"已安装";之后用 `ssh -t host 'exec env … $SHELL -l'` | 实现简单 | Windows 上没有 ControlMaster,会认证两次;需要远端可写 [实 GH src/cli/ssh.zig:480-560 的 terminfo 安装流程] | + + **建议 A** [定]:Windows 是 TCode 的一等平台,B 在 Windows 上体验差。A 的识别器需要 01 的镜像核心或会话 actor 支持"私有握手序列",到时在 04 中增加。 + +### 2.7 askpass:要不要接到 UI + +**Zed 的做法** [实 ZED crates/askpass/src/askpass.rs:34-60、225-405;research/06 §1]: + +- Zed 为每次连接建立一个临时目录,里面有 Unix socket 和 askpass 脚本(Windows 上直接用 Zed 的 cli.exe); +- 设置 `SSH_ASKPASS=<脚本>` 和 `SSH_ASKPASS_REQUIRE=force`。ssh 需要密码、口令或确认时会运行这个脚本,脚本通过 socket 把提示发给 Zed,Zed 用对话框收集输入; +- **前提**:Zed 的 master 连接是 `ssh -N`,**没有 TTY**。没有 askpass,ssh 就无法提示用户。 + +**TCode 一期不需要它** [定]: + +| 场景 | 有没有 TTY | 提示出现在哪里 | +|---|---|---| +| 交互终端(ssh profile、用户手动运行 ssh) | 有(我们的 PTY) | 终端里,与 Terminal.app 等完全一致:密码、密钥口令、OTP、`Are you sure you want to continue connecting (yes/no/[fingerprint])?`、FIDO 的 "Confirm user presence for key …" | +| `ssh -V`、`ssh -G` | 不需要 | 不会认证,没有提示 | + +- 设置 askpass 反而会改变行为:`SSH_ASKPASS_REQUIRE=force` 会让交互终端也弹出对话框。所以交互终端**不设置** askpass,用户自己在环境中设置的 `SSH_ASKPASS*` 原样保留(这是用户的选择)。 +- **什么时候需要**:TCode 在后台运行没有 TTY 的 ssh 时(以后的 ControlMaster 预热、§3.4 中二期回退路径里的后台 master),要按 Zed 的方式实现: + - 每次连接一个 0700 临时目录,放 Unix socket 和一个 askpass 入口; + - 入口是 TCode 的 AOT 可执行文件,以 `--askpass ` 模式运行; + - 对话框必须显示主机名和 ssh 给出的原始提示文本,并标明"由 ssh 请求"(防钓鱼); + - OpenSSH 8.4 之前还要求设置 `DISPLAY` 才会使用 askpass [推]; + - Win32-OpenSSH 是否支持 `SSH_ASKPASS` 需要实测 [推]。 + +### 2.8 连接失败(退出码 255)的界面 + +**事实**: + +- "ssh exits with the exit status of the remote command or with 255 if an error occurred" [实 OSSH ssh.1:1579-1582]; +- ssh 会把错误信息直接打印到终端里,例如: + - `ssh: Could not resolve hostname …` + - `Connection refused` + - `Permission denied (publickey)` + - `Host key verification failed.` + - `client_loop: send disconnect: Broken pipe` + +**判定** [定](ssh profile 的会话退出时): + +| 条件 | 状态 | 显示 | +|---|---|---| +| 退出码 255,且会话时长 < 30 s 或从未出现过远端输出 | **连接失败** | 横幅:"无法连接到 `prod-1`(ssh 退出码 255)。错误信息见上方输出。" | +| 退出码 255,会话已经运行了一段时间 | **连接中断** | 横幅:"与 `prod-1` 的连接已断开。" | +| 其他非 0 退出码 | 远端 shell 的退出码 | 07 §6.3 的通用处理 | +| 0 | 正常退出 | 按 `onExit`(默认关闭标签) | + +"远端输出"的判断:收到过 OSC 133 A,或者在 ssh 认证提示之后有超过 N 字节的输出——两者都是近似,所以横幅措辞保守,不下结论,只让用户看上方的输出。 + +- **按钮**: + - "重新连接"(Enter 键):在同一标签中启动新会话,旧内容保留,中间插入分隔线(07 §6.3); + - "详细模式重试":同样的命令加 `-v`,用于诊断; + - "编辑主机":打开设置页对应的 profile。对于探测得到的主机,按钮名改为"打开 ~/.ssh/config",用 TCode 的编辑器打开文件并定位到 `HostEntry.line`; + - "复制命令":复制等价的命令行(`LaunchPlan.DisplayCommand`)。 +- **onExit 对 255 无效**:即使用户设置 `onExit: close`,也不自动关闭,因为用户需要看到错误信息 [定]。 +- **常见错误的提示**(只匹配 ssh 自己打印的固定英文前缀,作为横幅中的附加说明,不替代原文): + +| 输出中包含 | 附加说明 | +|---|---| +| `Host key verification failed` / `REMOTE HOST IDENTIFICATION HAS CHANGED` | 主机密钥与 known_hosts 不一致。如果确认是服务器重装,可以运行 `ssh-keygen -R `(TCode 不自动执行) | +| `Permission denied (publickey` | 服务器只接受公钥认证;检查 ssh-agent 是否运行、`IdentityFile` 是否正确 | +| `Could not resolve hostname` | 主机名无法解析;检查 `HostName` | +| `Too many authentication failures` | agent 中的密钥过多,超过了服务器的 `MaxAuthTries`;可以在该主机的配置中设置 `IdentitiesOnly yes`(research/06 §3) | + +匹配失败时只显示通用横幅。远端的输出可以伪造这些字符串,但我们只是附加说明,不采取任何动作,所以没有风险。 + +### 2.9 与 ControlMaster 的关系 + +| 问题 | 结论 | +|---|---| +| TCode 会不会主动开启 ControlMaster | **不会**(一期)。用户在 config 中写了 `ControlMaster auto` + `ControlPath` + `ControlPersist`,系统 ssh 自然会复用:第二个标签连接同一主机时不需要再认证 | +| 第一个标签就是 master 时关闭它 | 没有 `ControlPersist` 时,master 会等所有共享会话结束后才退出,标签关闭会被延迟。我们的关闭确认(07 §6.1)把 `ssh` 视为"在运行",会弹出确认 | +| `ControlPersist` 让 master 转入后台 | master 进程脱离我们的会话,继续存活。关闭标签时我们发 SIGHUP 给进程组,它已经不在进程组里了,所以不受影响。这符合用户在 config 中表达的意图 | +| Windows | Win32-OpenSSH 不支持 ControlMaster(依赖 Unix socket 与文件描述符传递)[实 research/06 §1 Zed 行] | +| pty host `keepAlive` 模式 | 会话保持,与 ControlMaster 无关 | +| 第二期的 Tmds.Ssh | 无法复用 OpenSSH 的 master:PROTOCOL.mux 是私有协议 [实 research/06 §3]。所以文件树会独立认证一次(§3.4、§3.6) | +| 状态显示(可选) | Unix 上可以运行 `ssh -O check <别名>`(只在 `ssh -G` 显示配置了 `controlpath` 时),在主机列表中显示"已连接"标记 [实 research/06 §1 Zed 的做法]。二期再做 | + +### 2.10 第一期的安全规则 + +| 风险 | 规则 | +|---|---| +| 选项注入 | argv 直接传给 `execve`,不经过 shell;目标前面加 `--`;主机名、用户名不能以 `-` 开头,不能包含空白和控制字符(§2.4) | +| `ssh -G` 执行 `Match exec` | 只在用户的明确操作时运行,不批量运行(§2.3)。配置文件是用户自己的,执行其中的命令与用户运行 ssh 的效果相同 | +| `terminal.ssh.path` 指向任意程序 | settings.json 是用户自己的文件(0600)。**以后如果加入项目级配置,不允许项目级配置设置 `ssh.path`、`profiles.*.command`**(同 VS Code 的"受限模式"思路)[定] | +| 远端输出伪造 OSC | OSC 7 和 OSC 8 中的远端 `file://` 不当作本地路径(07 §6.4);OSC 52 读剪贴板默认询问(01 §8);伪造的 `633;E` 没有 nonce,不被信任(07 §5.1) | +| Agent 操作 SSH 终端 | 在 SSH 终端里执行命令的影响在远端,审批必须明确显示"远程主机 `prod-1`"。默认要求逐条审批,不适用"本地只读命令免审批"的规则 [定,细节由 09 定] | +| revive 快照中的远端输出 | 与本地会话相同:0600、行数上限、可以关闭(`persistence.*`,07 §7.3) | +| Agent forwarding | TCode 从不添加 `-A`;用户在 config 或 profile 的 `args` 中自己开启 | +| 凭据 | TCode 一期不接触任何密码或口令(都在 ssh 进程里);不在命令行中传递密码;`sshpass` 一类的做法不支持 | + +--- + +## 3. 第二期:Tmds.Ssh 用于 SFTP 与远程文件树 + +### 3.1 引入时机与 AOT 验证 + +**时机**:远程文件树或 Agent 远程只读工具(列目录、读文件)立项时引入,不早于 M7。一期只用系统 ssh,不引用 Tmds.Ssh。 + +**验证清单**(每次升级 Tmds.Ssh 版本都要跑一遍,写进 CI): + +1. 在 `samples/SshAotSmoke`(新,不随产品发布)中 `PublishAot`,6 个 RID 的发布都必须 **0 条 IL2xxx/IL3xxx 警告**。research/06 的本机实测为 0.26.0 osx-arm64:0 警告、7.2 MB [实 research/06 §2.1]; +2. 运行发布出来的二进制,连接 CI 中的 OpenSSH 容器(§5),完成:ed25519 公钥认证、经 agent 认证、ProxyJump、SFTP 列目录/读/写、`exec` 获取退出码; +3. 检查依赖:Tmds.Ssh 0.26.0 只依赖 `Microsoft.Extensions.Logging.Abstractions` [推,以 nuspec 为准];新增依赖需要重新评估; +4. pin 精确版本(`PackageReference Version="[0.26.0]"`),升级时审查 diff,重点看 `SshConfigParser`、认证和主机密钥部分。 + +**放在哪里** [定]: + +- 新项目 `src/TCode.Terminal.Ssh`,只依赖 Tmds.Ssh 和 BCL,不依赖其他 TCode 项目(D2)。内容: + - `SshConfigDump`(`ssh -G` 的运行与解析,一期就需要,届时从 `TCode.Terminal.Shell` 移过来); + - `SshSettingsMapper`; + - `SshConnectionPool`; + - `SshChannelConnection : ITerminalConnection`(§3.6)。 +- **运行在 pty host 中**: + - 文件树(Desktop)和 Agent 的远程操作(AgentHost)都是 pty host 的客户端,同一个主机只需要认证一次; + - Desktop 重启不会断开 SFTP 连接; + - 代价是 04 的协议要增加 `remote/*` 一组消息(控制帧传元数据,数据帧传文件内容),并沿用同样的流控。 + - 列入 §8 待决问题。 + +### 3.2 `ssh -G` → `SshClientSettings` 的映射 + +**原则** [定]: + +- **不让 Tmds.Ssh 解析任何配置文本**:用 `new SshClientSettings()` 直接构造,不用 `SshConfigSettings`。Tmds.Ssh 的解析器遇到 `ProxyCommand`、`SetEnv`、`Tag`、`AddressFamily`、`ControlPath` 会抛 `NotSupportedException`,遇到 `Match exec` 抛 `NotImplementedException` [实 research/06 §2.3;TMDS src/Tmds.Ssh/SshConfigParser.cs:738、917、961]。`ssh -G` 的输出又包含**所有**关键字(含默认值),喂给它肯定失败。 +- `ssh -G` 已经完成了 `Host`/`Match`(包括 `Match exec`)、`Include`、`CanonicalizeHostname` 和大部分 `%` token 的展开,这正是我们需要 OpenSSH 代劳的部分。 +- 每个关键字归入三类之一:**映射**(按下表转换)、**忽略**(对 SFTP 无影响)、**不兼容**(我们无法实现其语义)。出现任何"不兼容"的关键字且取值不是默认值时,整个主机判为不兼容,走 §3.4 的回退,**不尝试部分连接**。 + +| `ssh -G` 关键字 | 类别 | `SshClientSettings` | 说明 | +|---|---|---|---| +| `hostname` | 映射 | `HostName` | | +| `user` | 映射 | `UserName` | | +| `port` | 映射 | `Port` | | +| `connecttimeout` | 映射 | `ConnectTimeout` | `none` → Tmds.Ssh 的默认值 | +| `serveraliveinterval` / `serveralivecountmax` | 映射 | `KeepAliveInterval` / `KeepAliveCountMax` | 0 表示关闭 | +| `tcpkeepalive` | 映射 | `TcpKeepAlive` | | +| `compression` | 映射 | `EnableCompression` | | +| `batchmode` | 映射 | `NonInteractive` | | +| `userknownhostsfile` | 映射 | `UserKnownHostsFilePaths` | 空格分隔的多个路径,展开 `~`;`/dev/null` 表示不使用 | +| `globalknownhostsfile` | 映射 | `GlobalKnownHostsFilePaths` | 同上 | +| `hashknownhosts` | 映射 | `HashKnownHosts` | | +| `stricthostkeychecking` | 映射 | `HostAuthentication` 委托的行为(§3.5) | `yes`、`accept-new`、`no`/`off`、`ask` | +| `identityfile`(多个) | 映射 | `Credentials` 中逐个加 `PrivateKeyCredential(path, passphrasePrompt)` | 只加入存在且为 `openssh-key-v1` 格式的文件(Tmds.Ssh 只支持这种格式 [实 research/06 §2.1]);PEM/PPK 格式记一条"已跳过"的诊断。`-sk` 类型的密钥见下 | +| `certificatefile`(多个) | 映射 | `CertificateCredential(cert, PrivateKeyCredential(对应的私钥))` | | +| `identitiesonly` | 映射 | 为 `yes` 时**不加入** `SshAgentCredentials` | 例外:`IdentityFile` 指向 `.pub` 而私钥不在本地(1Password 等 agent 托管密钥的典型写法)→ 需要"只用 agent 中的这把钥匙",Tmds.Ssh 不支持 → **不兼容** | +| `identityagent` | 映射 / 不兼容 | 值为 `none` → 不加 agent;`SSH_AUTH_SOCK` 或与环境中的 `SSH_AUTH_SOCK` 相同 → `new SshAgentCredentials()`;**其他路径 → 不兼容** | `SshAgentCredentials(string address)` 是 internal [实 TMDS SshAgentCredentials.cs:9-25],无法指定 agent 地址(上游 PR,§3.7) | +| `pubkeyauthentication` / `passwordauthentication` / `kbdinteractiveauthentication` / `gssapiauthentication` / `preferredauthentications` | 映射 | 决定 `Credentials` 中包含哪些凭据以及顺序 | 默认顺序 `gssapi-with-mic,hostbased,publickey,keyboard-interactive,password`;password → `PasswordCredential(prompt)` | +| `gssapidelegatecredentials` | 映射 | `KerberosCredential(delegateCredential: …)` | | +| `requiredrsasize` | 映射 | `MinimumRSAKeySize` | | +| `proxyjump` | 映射 | `Proxy = Proxy.Chain(SshProxy(…) × N)` | 每一跳 `[user@]host[:port]` 分别运行 `ssh -G -l user -p port host` 并**递归**映射为 `SshClientSettings`,再用 `new SshProxy(settings)` [实 TMDS SshProxy.cs:23;Proxy.cs:18]。任何一跳不兼容 → 整体不兼容 | +| `kexalgorithms` / `hostkeyalgorithms` / `ciphers` / `macs` / `pubkeyacceptedalgorithms` / `casignaturealgorithms` | 映射 | 对应的 `AlgorithmList`:OpenSSH 的最终列表与 Tmds.Ssh 支持的列表求交集,保持 OpenSSH 的顺序 | 交集为空 → 不兼容,原因写明"服务器或配置要求的算法 X 不被托管实现支持"。Tmds.Ssh 只实现安全算法,没有 ssh-rsa(SHA-1),加密只有 aes-gcm 和 chacha20-poly1305 [实 research/06 §2.1] | +| `forwardagent` | 忽略 | 强制 `ForwardAgent = Off` | SFTP 不需要转发 agent;安全考虑(§4) | +| `forwardx11` / `forwardx11trusted` | 忽略 | 强制 Off | | +| `sendenv` / `setenv` | 忽略(SFTP);映射(managed 终端,§3.6) | `EnvironmentVariables` | managed 终端:`setenv` 的值,加上本地环境中匹配 `sendenv` 模式的变量 | +| `requesttty` / `remotecommand` / `sessiontype` / `stdinnull` / `forkafterauthentication` / `escapechar` / `loglevel` / `visualhostkey` / `fingerprinthash` / `obscurekeystroketiming` / `channeltimeout` / `localforward` / `remoteforward` / `dynamicforward` / `exitonforwardfailure` / `gatewayports` | 忽略 | — | 对 SFTP 没有影响 | +| `controlmaster` / `controlpath` / `controlpersist` | 忽略 | — | 无法复用(§2.9) | +| `updatehostkeys` / `verifyhostkeydns` / `checkhostip` | 忽略 | — | 不实现;只影响 known_hosts 的维护 | +| `canonicalizehostname` 等 | 忽略 | — | `ssh -G` 已经应用到 `hostname` 上 [推] | +| `addressfamily` | 忽略(`any`);不兼容(`inet`/`inet6`) | — | Tmds.Ssh 不支持 [实 research/06 §2.3] | +| `proxycommand` | **不兼容**(不为 `none` 时) | — | `Proxy` 的抽象成员 `ConnectToProxyAndForward` 是 internal [实 TMDS Proxy.cs:9-11],外部无法实现(上游 PR,§3.7) | +| `hostkeyalias` / `knownhostscommand` | **不兼容**(设置了时) | — | Tmds.Ssh 按实际主机名查 known_hosts,结果与 OpenSSH 不同,会造成"终端信任、文件树不信任"或者反过来 | +| `bindaddress` / `bindinterface` | **不兼容**(设置了时) | — | | +| `pkcs11provider` / `securitykeyprovider`(非默认) | **不兼容** | — | | +| `identityfile` 是 `id_*_sk` 类的 FIDO 密钥 | **不兼容** | — | Tmds.Ssh 不支持 sk 私钥文件,经 agent 签名也不行(源码中没有 sk 算法名)[实 research/06 §3](上游 PR,§3.7) | +| 其他未知关键字 | 忽略并记录诊断 | — | OpenSSH 新版本增加的关键字 | + +**解析注意事项**: + +- `ssh -G` 输出的路径中可能还留着 `~` [推],映射时统一展开;`%d`、`%u` 等 token 在 OpenSSH 10.x 中已经展开 [推],映射器遇到残留的 `%` 时判为不兼容,不自己实现 token 规则。 +- `ssh -G` 的结果按"主机别名 + 所有 config 文件的 mtime"缓存。 +- 结果中的 `user` 一定有值(默认是本地用户名)。 + +### 3.3 认证缺口与回退 + +| 缺口 | 现状 | 处理 | +|---|---|---| +| keyboard-interactive(Duo、Google Authenticator、PAM 的 OTP) | Tmds.Ssh 没有实现(`kbdinteractivedevices` 的注释是 "not supported")[实 research/06 §2.1] | ① 向上游提交 RFC 4256 的实现(协议很小,维护者活跃);② 合入之前:服务器只提供 keyboard-interactive 时,认证失败 → 文件树显示"此主机需要交互式认证,托管连接暂不支持" | +| FIDO(`sk-ssh-ed25519@openssh.com`、`sk-ecdsa-sha2-nistp256@openssh.com`) | 私钥文件需要 libfido2;经 agent 签名需要库认识 sk 公钥算法,目前不认识 | ① 上游 PR:增加 sk 公钥算法名与签名格式,让 agent 中的 sk 密钥可用(触摸由 agent 驱动);② 磁盘上的 sk 密钥文件**不支持**(不引入 libfido2 的 6 RID 原生依赖) | +| ProxyCommand | 无法实现(§3.2) | 上游 PR:公开一个基于 `Func>` 的 `StreamProxy`;TCode 用它运行 ProxyCommand 并把子进程的 stdin/stdout 作为 Stream | +| `IdentityAgent` 指定路径 | 无法指定 | 上游 PR:公开 `SshAgentCredentials(string address)` | +| PEM / PPK 私钥 | 不支持 | 提示用户 `ssh-keygen -p -f ` 转换为 OpenSSH 格式(只显示命令) | +| 以上任何一项不满足 | — | **回退** | + +**回退方案** [定]: + +| 平台 | 回退 | +|---|---| +| macOS / Linux | **系统 ssh + ControlMaster + SFTP over stdio**:
① 在一个**可见的** PTY 标签中运行 `ssh -M -S .sock> -o ControlPersist=10m -N <别名>`,用户在终端里完成 OTP 或 FIDO 触摸(与一期的体验相同);
② 之后运行 `ssh -S -s <别名> sftp`(`-s` 表示请求 subsystem),把子进程的 stdin/stdout 当作 SFTP 通道;
③ 在这个 Stream 上运行一个**最小的 SFTP v3 客户端**:Tmds.Ssh 的 `SftpClient` 只能通过 `SshClient` 创建 [实 TMDS SftpClient.cs:117-147]。可以向上游提议"基于 Stream 的 SftpClient",否则自己实现一个只读子集(opendir/readdir/stat/open/read/close,约 800 行)。
socket 目录 0700;TCode 退出时 `ssh -O exit` | +| Windows | 没有 ControlMaster。回退为"此主机的文件树不可用:原因 X。终端连接不受影响。" | + +### 3.4 known_hosts 的界面 + +Tmds.Ssh 读写的是与 OpenSSH **相同的** known_hosts 文件(`UserKnownHostsFilePaths`),所以信任关系在系统 ssh 和 TCode 之间是共享的 [实 research/06 §2.1]:在终端里接受过的主机,文件树不再询问;反之亦然。 + +`HostAuthentication` 委托收到 `KnownHostResult`(`Trusted` / `Revoked` / `Changed` / `Unknown`)[实 TMDS KnownHostResult.cs] 和 `ConnectionInfo`(含主机密钥): + +| 结果 | `StrictHostKeyChecking` | 行为 | +|---|---|---| +| `Trusted` | 任意 | 通过 | +| `Unknown` | `ask`(默认) | 弹出对话框(见下);对话框打开期间调用 `SuspendConnectTimeout()`,关闭后 `ResumeConnectTimeout()` [实 TMDS HostAuthentication.cs:47-54] | +| `Unknown` | `accept-new` | 不询问,接受并写入 known_hosts | +| `Unknown` | `yes` | 拒绝:显示"known_hosts 中没有该主机,并且配置了 StrictHostKeyChecking yes" | +| `Unknown` | `no` / `off` | 接受,**不写入** | +| `Changed` | 任意 | **拒绝,不提供覆盖按钮**(与 OpenSSH 相同)。显示记录的指纹和收到的指纹、known_hosts 的文件和行号,以及说明"如果服务器确实重装过,请运行 `ssh-keygen -R `"。即使是 `no`/`off` 也拒绝:OpenSSH 在这种情况下会禁用密码认证并继续,我们选择更严格的做法 [定] | +| `Revoked` | 任意 | 拒绝,显示"该主机密钥已被吊销(@revoked)" | +| 任何结果,且 `IsNonInteractive`(自动重连、rekey) | — | 不弹窗;`Unknown` 视为拒绝 [实 TMDS HostAuthentication.cs:33-41 的要求] | + +**首次连接对话框**: + +``` +首次连接 prod-1(10.0.3.17:22) +主机密钥类型:ssh-ed25519 +SHA256 指纹:SHA256:Xq2…9kE [复制] +(可选:随机艺术图,等宽字体) +将写入:~/.ssh/known_hosts(哈希形式:是) + +请通过可信渠道核对指纹后再继续。 + [取消] [仅本次连接] [信任并保存] +``` + +- 指纹格式与 `ssh-keygen -lf` 一致(SHA256 + base64,去掉末尾的 `=`); +- "信任并保存"用 `UpdateKnownHostsFileAfterAuthentication = true` 写入:只有认证也成功之后才写入,避免把一个认证失败的服务器的密钥留在文件里 [实 TMDS SshClientSettings.cs:420]; +- 对话框只能由 Desktop 显示:host 通过 IPC 请求 `ssh/hostKeyPrompt`。没有 UI 客户端时(例如只有 AgentHost 在连接)按"非交互"处理并拒绝。 + +### 3.5 远程终端在第二期是否也换成 Tmds.Ssh 的通道 + +**建议:不换,交互终端继续用系统 ssh;`managed` 模式作为按主机开启的选项** [定]。 + +| 维度 | 系统 ssh(一期方案) | Tmds.Ssh 通道 | +|---|---|---| +| 配置一致性 | 100%(ProxyCommand、Match exec、IdentityAgent、FIDO、keyboard-interactive、`~.` 转义、ControlMaster、`LocalForward`) | 子集,不兼容的主机连不上 | +| 认证次数 | 终端与文件树各认证一次(有 agent 或密钥时用户感觉不到);Unix 上可以通过 ControlMaster 只认证一次 | 终端与文件树共用一条连接 | +| 退出信息 | 远端退出码;255 有歧义 | 精确的 `exit-status` / `exit-signal`(`GetExitStatusAsync`)[实 TMDS RemoteProcess.cs:249-266、592] | +| 主机密钥与密码界面 | 在终端里(文本) | 结构化对话框 | +| 断线检测 | ssh 自己的 `ServerAlive*` | 可控的 KeepAlive;可以提示"已断开,重新连接" | +| 端口转发 | 用户的 config 与 `-L` 照常工作 | 要自己实现 | +| 实现成本 | 0(已经完成) | `SshChannelConnection` + 提示 UI + 测试 | + +理由: + +1. 第二期的价值在文件树。终端已经"与系统一致",替换它只会带来回归; +2. 用户最常见的 ssh 配置(`ProxyCommand` 走跳板、1Password 的 `IdentityAgent`、公司的 OTP)恰恰是 Tmds.Ssh 不支持的部分; +3. **managed 模式的真正用处**:Windows 上,只能用密码登录的主机会在终端和文件树中各要求输入一次密码(Windows 没有 ControlMaster)。对这些主机,允许用户设置 `"ssh:legacy-box": { "transport": "managed" }`,让终端也走文件树的那条连接。只有在该主机映射结果为"兼容"时,这个选项才可用。 + +### 3.6 `ITerminalConnection` 的 SSH 实现草案 + +运行在 pty host 中,替换本地 PTY 作为会话的连接;镜像核心、输出日志、快照、命令跟踪都不受影响(01 §3)。 + +```csharp +namespace TCode.Terminal.Ssh; + +/// managed 模式的远程终端:一条已认证的 SshClient 上的一个 session 通道(pty-req + shell)。 +public sealed class SshChannelConnection : ITerminalConnection +{ + private readonly SshConnectionPool.Lease _lease; // 引用计数:与文件树共用 SshClient + private readonly RemoteProcess _process; + private readonly Pipe _output = new(new PipeOptions(pauseWriterThreshold: 256 * 1024, resumeWriterThreshold: 64 * 1024)); + private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public string Kind => "ssh"; + public ConnectionCapabilities Capabilities => ConnectionCapabilities.Resize | ConnectionCapabilities.PixelSize | ConnectionCapabilities.Signals; + // 没有 ForegroundProcess、WorkingDirectory:远端进程不可见,cwd 只能来自 OSC 7(而且是远端路径) + public PipeReader Output => _output.Reader; + public Task Exited => _exited.Task; + + public static async ValueTask OpenAsync( + SshConnectionPool pool, SshHostKey key, SshTerminalLaunch launch, CancellationToken ct) + { + var lease = await pool.AcquireAsync(key, ct); // 必要时建立连接(含主机密钥 / 口令提示) + var options = new ExecuteOptions + { + AllocateTerminal = true, + TerminalType = launch.Term, // "xterm-256color" + TerminalSize = ToTmds(launch.InitialSize), + // TerminalSettings 使用 Tmds.Ssh 的默认值:VERASE=0x7F、VINTR=^C、IUTF8 等 [实 research/06 §2.3] + }; + foreach (var (k, v) in launch.Environment) options.EnvironmentVariables[k] = v; // SetEnv + 匹配 SendEnv 的变量 + COLORTERM 等 + var process = launch.RemoteCommand is { } cmd + ? await lease.Client.ExecuteAsync(cmd, options, ct) + : await lease.Client.ExecuteShellAsync(options, ct); + var c = new SshChannelConnection(lease, process); + c.StartPump(); + return c; + } + + // TerminalSize 中的像素是**单个 cell** 的尺寸(01 §6.2);pty-req / window-change 要的是整个区域的像素 + private static Tmds.Ssh.TerminalSize ToTmds(TerminalSize s) => + new(s.Columns, s.Rows, s.Columns * s.CellWidth, s.Rows * s.CellHeight); + + private async void StartPump() // 实现时改为显式的 Task 并记录异常 + { + try + { + while (true) + { + var mem = _output.Writer.GetMemory(16 * 1024); + var (isError, n) = await _process.ReadAsync(mem, mem); // 有 PTY 时 stderr 也进入同一个 tty,极少出现扩展数据 + if (n == 0) break; // EOF + _output.Writer.Advance(n); + var flush = await _output.Writer.FlushAsync(); // 背压:消费者不读,这里就停下, + if (flush.IsCompleted) break; // Tmds.Ssh 在读取时才补通道窗口 → 远端被压住 [实 research/06 §2.3] + } + var status = await _process.GetExitStatusAsync(); + _exited.TrySetResult(new TerminalExit( + ExitCode: status.ExitSignal is null ? status.ExitCode : null, + Signal: status.ExitSignal)); + } + catch (SshConnectionException ex) // 断线:退出码未知 + { + _exited.TrySetResult(new TerminalExit(null, null, Message: ex.Message)); + } + finally { await _output.Writer.CompleteAsync(); } + } + + public ValueTask WriteAsync(ReadOnlyMemory input, CancellationToken ct = default) => _process.WriteAsync(input, ct); + + public ValueTask ResizeAsync(TerminalSize size, CancellationToken ct = default) + { + _process.SetTerminalSize(ToTmds(size)); // window-change,无应答 + return ValueTask.CompletedTask; + } + + public ValueTask SignalAsync(TerminalSignal signal, CancellationToken ct = default) + => ValueTask.FromResult(_process.SendSignal(signal switch // RFC 4254 §6.9:名字不带 "SIG" + { + TerminalSignal.Hup => "HUP", TerminalSignal.Int => "INT", TerminalSignal.Term => "TERM", TerminalSignal.Kill => "KILL", + _ => throw new ArgumentOutOfRangeException(nameof(signal)), + })); + // 注意:交互中的 Ctrl+C 仍然以字节 0x03 写入,由远端 tty 产生信号;signal 请求只用于"强制结束" + + public ValueTask GetForegroundAsync(CancellationToken ct = default) => ValueTask.FromResult(null); + public ValueTask GetWorkingDirectoryAsync(CancellationToken ct = default) => ValueTask.FromResult(null); + + public async ValueTask DisposeAsync() + { + _process.Dispose(); // 关闭通道 + await _lease.DisposeAsync(); // 引用计数归零后,连接池按空闲超时关闭 SshClient + } +} +``` + +- **`SshConnectionPool`**: + - 键 = 映射后设置的规范化指纹(hostname、port、user、proxy 链、凭据来源),而不是别名,因此两个指向同一目标的别名共享连接; + - 引用计数;无人使用后保持 5 分钟再关闭; + - `AutoReconnect = false`:断线时由上层决定是否重连。终端的会话不能透明重连(SSH 协议没有会话恢复 [实 research/06 §4])。 +- **关闭确认**(07 §6.1):没有 `ForegroundProcess` 能力,除非远端有 shell 集成并处于提示符状态,否则视为"在运行"。 +- **`TerminalExit`** 区分退出码、信号和断线(`Message`),07 §6.3 和 §2.8 的界面据此显示。 + +--- + +## 4. 安全 + +| 主题 | 规则 | +|---|---| +| 凭据 | 二期的密码、密钥口令只保存在内存中,用完后尽量清零(`char[]`、`CryptographicOperations.ZeroMemory`);**从不写盘**,也不放进 settings.json;对话框不提供"记住密码"(以后如果要做,使用系统钥匙串,单独评审) | +| 主机密钥 | 只用 OpenSSH 的 known_hosts 文件,不建立 TCode 自己的信任库;`Changed` 和 `Revoked` 一律拒绝(§3.4) | +| 提示的来源 | 所有凭据对话框都显示主机名、端口和"由 TCode 托管连接请求",与 Agent 的审批对话框在视觉上区分开,防止远端或 Agent 诱导用户在错误的对话框中输入密码 | +| agent forwarding | 托管连接一律关闭 agent 和 X11 转发(§3.2) | +| 远程路径 | SFTP 返回的文件名是不可信输入:显示时过滤控制字符和换行;`..`、绝对路径和符号链接按远端语义处理,不拼接成本地路径;下载到本地时处理 Windows 的保留名(`CON`、`NUL` 等)和非法字符(`:`、`*`),覆盖前确认 | +| Agent 的远程操作 | Agent 读取远程文件、在远程终端中执行命令,都需要审批,并在审批界面中明确显示远程主机(09);远程写入默认不提供 | +| 子进程环境 | `ssh -G`、`ssh -V`、回退方案中的 master 都使用 07 §4 的环境(剔除 `TCODE_*`,防止 pty host 的令牌泄漏给 ssh 调用的 `ProxyCommand`、`Match exec`) | +| 控制 socket | 回退方案的 ControlPath 放在 `$TCODE_HOME/run/ssh/`(0700),文件名用哈希,避免超过 Unix socket 路径长度上限(104 字节)[推] | +| 日志 | 不记录 `ssh -G` 的完整输出(可能包含内部主机名和用户名);只在用户主动打开诊断时显示 | +| 项目级配置(以后) | 不允许项目级配置定义 ssh profile、`ssh.path`、`ssh.configFile`(§2.10) | + +--- + +## 5. settings 中的 `terminal.ssh` 节 + +| 字段 | 类型 | 默认 | 热重载 | 说明 | +|---|---|---|---|---| +| `ssh.enabled` | bool | true | 立即(刷新菜单) | false 时不列出主机,自定义的 `type: ssh` profile 仍然可用 | +| `ssh.path` | string | 自动探测(§2.1) | 新终端 | | +| `ssh.configFile` | string | 不设置(使用默认文件) | 立即(重新扫描) | 对应 `ssh -F` | +| `ssh.sendEnv` | bool | true | 新终端 | §2.5 | +| `ssh.hiddenHosts` | string[] | git 托管站点列表(§2.2) | 立即 | 写出时替换默认列表;支持 `*` 通配 | +| `ssh.shellIntegration` | `off` | `off` | — | 预留给 §2.6 的方案 A | +| `ssh.fileTree` | bool | true | 立即 | 二期:是否为 SSH 主机提供远程文件树 | + +设置页的"SSH"子页: + +- ssh 可执行文件(探测结果、版本、"重新检测"); +- 配置文件位置; +- 主机列表:显示来源文件和行号;点击后显示 `ssh -G` 的解析结果(按需运行);二期还显示"文件树:兼容 / 不兼容(原因)"; +- 隐藏主机; +- SendEnv 开关。 + +--- + +## 6. 测试计划 + +### 6.1 单元测试(三平台) + +| 组 | 用例 | +|---|---| +| `SshConfigScanner` | `Host a b c`(多个别名);`Host *`、`Host *.corp`、`Host !bad` 被排除;`Host=foo` 与引号;`Match` 块内的 HostName 不归属任何别名;`Include` 的相对路径(用户配置相对 `~/.ssh`,系统配置相对 `/etc/ssh`)、glob 的字典序、环境变量与 `~`;块内的 Include;递归深度 17 时停止;Include 循环;BOM、CRLF、制表符;别名大小写去重;`hiddenHosts` 过滤;文件不存在 | +| `ssh -G` 输出解析 | 用不同版本 OpenSSH 的真实输出做 fixture:7.4(RHEL 7)、8.0(RHEL 8)、9.6(Ubuntu 24.04)、10.3(macOS 26)、Win32-OpenSSH 9.5;多值关键字;`none`;包含空格的路径 | +| 命令行构造 | 一期 `BuildSshArgs` 的快照:各字段组合、`--` 的位置、`remoteCommand` 与 `-t`、`-F`、`sendEnv` 开关;非法主机名、用户名被拒绝 | +| 255 的界面判定 | 时长与输出量的组合;常见错误前缀的提示匹配 | +| 二期映射 | 每个关键字的映射;不兼容关键字的判定(包括"设置为默认值时不算不兼容");`identitiesonly` + `.pub` 的特例;ProxyJump 递归(含某一跳不兼容);算法交集为空;输出快照(`SshClientSettings` 的规范化 JSON) | +| known_hosts 策略 | `KnownHostResult` × `StrictHostKeyChecking` × `IsNonInteractive` 的完整矩阵(使用假的对话框服务) | + +### 6.2 集成测试:CI 中的 OpenSSH 容器 + +GitHub Actions 的 Linux runner 用 service container 运行。镜像是自己维护的 Dockerfile(`tests/ssh-server/Dockerfile`,基于 `debian:stable-slim` + `openssh-server`,因为需要 PAM 来测试 keyboard-interactive),通过 docker compose 启动两个实例: + +| 实例 | 配置 | +|---|---| +| `target` | 用户:`keyuser`(ed25519 公钥)、`pwuser`(密码)、`otpuser`(PAM keyboard-interactive,用固定应答模拟 OTP)、`certuser`(由测试 CA 签发的用户证书,`TrustedUserCAKeys`);`AcceptEnv COLORTERM TERM_PROGRAM TERM_PROGRAM_VERSION`;第二个监听端口使用一份**不含** AcceptEnv 的配置;固定的主机密钥(另有一份"更换后"的主机密钥,用于测试 Changed) | +| `bastion` | 只允许转发到 `target`,用于测试 ProxyJump | + +测试代码生成一个临时的 `~/.ssh/config`(`HOME` 指向临时目录)和 known_hosts,并启动 `ssh-agent` 加载测试密钥。 + +**一期 E2E**(通过真实的 `LaunchBuilder` + 本地 PTY 运行系统 ssh,只断言字节流,不依赖 UI): + +| 用例 | 断言 | +|---|---| +| 连接 `target`(agent 认证) | 远端 `echo $TERM` 输出 `xterm-256color`;`echo $COLORTERM` 输出 `truecolor`(AcceptEnv 端口)或空(另一个端口),并且后者**没有**任何报错 | +| 用户 config 中有 `SetEnv FOO=bar` | 远端 `echo $FOO` 为 `bar`(证明我们没有用 `-o SetEnv` 覆盖它) | +| `exit 7` | 本地会话的退出码为 7 | +| 端口关闭 | 退出码 255,状态为"连接失败" | +| 未知主机密钥 | 终端中出现 `Are you sure you want to continue connecting`;写入 `yes` 后连接成功 | +| 主机密钥已更换 | 255,并命中 `REMOTE HOST IDENTIFICATION HAS CHANGED` 的提示 | +| ProxyJump 经过 `bastion` | 连接成功 | +| `pwuser` 的密码提示 | 终端中出现 `password:`;写入密码后成功(证明没有设置 askpass) | +| ControlMaster | 配置 `ControlMaster auto` 后打开两个会话,第二个会话不经认证就连上(`-v` 输出中有 `mux_client_request_session`) | +| `ssh -G` | 带 `Match exec "test -n \"$X\""` 的配置,在两种环境下得到不同的 `hostname` | + +**二期 E2E**: + +- 映射 + 连接:`keyuser`(文件密钥与 agent 两种)、`certuser`、`pwuser`(假的密码对话框)、ProxyJump; +- `otpuser`:上游 PR 合入前,断言得到"需要交互式认证"的诊断;合入后断言成功; +- SFTP 列目录、读文件、写文件、大文件的流控(读取速度受消费方控制,内存不增长); +- known_hosts:Unknown → 假对话框选择"信任并保存" → 文件中多一行哈希记录 → 第二次连接为 Trusted;用更换后的主机密钥测试 Changed → 拒绝; +- managed 终端:resize 后远端 `stty size` 正确;`exit 3` → 3;`kill -9 $$` → Signal = `KILL`;在容器内断开网络 → `TerminalExit.Message` 不为空; +- **AOT**:`SshAotSmoke` 的发布物在同一个容器上跑一遍上述连接用例。 + +**macOS 与 Windows runner**:不能运行 Linux 容器。只跑单元测试,以及"ssh 探测 + 命令行构造 + 连接 `localhost` 上不存在的端口 → 255"这类不需要服务端的用例。Windows 上可以选择在 nightly 中安装 Win32-OpenSSH Server(`Add-WindowsCapability OpenSSH.Server`),对 `localhost` 跑一期的 E2E(验证 ConPTY 下的 ssh.exe 行为),这一项不阻塞 PR。 + +--- + +## 7. 风险 + +| 风险 | 影响 | 应对 | +|---|---|---| +| Windows 用户没有安装 OpenSSH 客户端(Win10 1803+ 默认安装,但可能被移除) | 没有 SSH 功能 | §2.1 的提示;不内置托管实现作为一期的兜底 | +| 用户只有 Git for Windows 的 ssh | agent 和路径行为与 Win32-OpenSSH 不同 | 排在最后、明确标注;二期的文件树按 Win32 的约定(命名管道 agent)工作 | +| 列表中混入无法登录的主机(git 托管站点、只做跳板的主机) | 菜单杂乱 | 默认隐藏列表 + 用户可调;以后可以按"最近使用"折叠 | +| `ssh -G` 执行 `Match exec` 带来的延迟和副作用 | 打开详情变慢 | 只按需运行,带超时,结果缓存 | +| 255 的歧义(远端命令自己返回 255) | 误报"连接失败" | 横幅措辞保守,以终端里的原始输出为准 | +| Tmds.Ssh 是单人维护的库(research/06:244 星) | 上游 PR 合入慢或者不合入;项目停滞 | pin 版本;上游 PR 不合入时在 TCode 内维护一个小的 fork(MIT);文件树功能本身有回退方案,可以降级 | +| Tmds.Ssh 不兼容的主机比例高(企业环境中 ProxyCommand、OTP 很常见) | 文件树对这些用户不可用 | 一期不受影响;二期提供"不兼容原因",并优先推进 ProxyCommand 和 keyboard-interactive 的上游 PR | +| 两次认证(Windows、没有 agent 的密码主机) | 体验差 | `managed` 模式(§3.5);鼓励使用 agent | +| 远程 shell 集成的方案 A 需要在输出路径上识别握手 | 安全面增加(远端可以伪造请求) | 一次性口令 + 只在 TCode 主动发起的引导会话中识别;v2 单独评审 | +| `SendEnv` 导致个别网络设备断开连接 | 连接失败 | `ssh.sendEnv: false`,或按 profile 关闭 | + +--- + +## 8. 与 01 的差异和待决问题 + +| # | 问题 | 本文的处理 | 需要谁决定 | +|---|---|---|---| +| 1 | 01 D4 写的是"系统 `ssh -t`" | 默认**不加** `-t`,只在有远程命令时加(§2.4),以尊重用户的 `RequestTTY` 配置 | 01 维护者确认措辞 | +| 2 | 01 §6.2 的 `ConnectionCapabilities` 需要能表达"没有前台进程信息"(SSH) | 沿用 01 的能力位,SSH 不声明 `ForegroundProcess`、`WorkingDirectory`;07 §6.1 据此处理 | — | +| 3 | SFTP 连接池放在 pty host 中,需要 04 的协议增加 `remote/*` 消息和 `ssh/hostKeyPrompt`、`ssh/credentialPrompt` 这类反向请求 | §3.1 建议放在 pty host | 04 | +| 4 | 远程 shell 集成方案 A 需要镜像核心或会话 actor 识别私有握手序列 | v2 再定 | 02 / 04 | +| 5 | Tmds.Ssh 的上游 PR:公开的 Stream 代理、`SshAgentCredentials(address)`、keyboard-interactive、sk 算法、基于 Stream 的 SftpClient | 二期开始前提交 | 用户确认是否向上游贡献 | +| 6 | 一期是否需要 askpass | 不需要(§2.7) | — | + +## 9. 来源 + +**源码**: + +- OpenSSH(OSSH `6545e6a`):`readconf.c:1936-1989`(SendEnv 追加 / SetEnv 第一组生效)、`readconf.c:2629-2641`(Include 深度 16)、`clientloop.c:2706`(env 请求 want_reply=0)、`ssh.1:276-283`(`-G`)、`ssh.1:1579-1582`(退出码 255)、`ssh_config.5:288-310`(Include)、`ssh_config.5:1899-1992`(SendEnv、SetEnv) +- Tmds.Ssh(TMDS `e186178`):`src/Tmds.Ssh/SshClientSettings.cs`(:194-743 公开属性)、`SshConfigParser.cs`(:560-740 关键字处理与异常)、`Proxy.cs:9-40`(internal 抽象成员)、`SshProxy.cs:11-50`、`SshAgentCredentials.cs:9-25`(internal 构造函数)、`HostAuthentication.cs:1-63`、`KnownHostResult.cs`、`RemoteProcess.cs:249-266、365-405、557-609`、`ExecuteOptions.cs:71-100`、`TerminalSize.cs:14-26`(像素为整个区域)、`SftpClient.cs:117-147` +- Ghostty(GH `b3dc3d7`):`src/cli/ssh.zig:225-335`(SetEnv/SendEnv 与 terminfo 缓存) +- Zed(ZED `6b218b8`):`crates/askpass/src/askpass.rs:34-60、225-405` +- Windows Terminal(WT `44b440b`):`src/cascadia/TerminalSettingsModel/SshHostGenerator.cpp:15-165` + +**文档**:research/06 的全部来源(RFC 4254、RFC 8160、RFC 9987、sshd_config(5)、Debian `debian-config.patch`、kitty ssh kitten、Tmds.Ssh、SSH.NET、DevTunnels 等);research/03 §7。 diff --git a/docs/terminal/09-agent-integration.md b/docs/terminal/09-agent-integration.md new file mode 100644 index 0000000..8b6c134 --- /dev/null +++ b/docs/terminal/09-agent-integration.md @@ -0,0 +1,840 @@ +# TCode 终端:Agent 集成(09) + +> 状态:设计稿 v1(2026-10-16)。上位文档:[01-architecture.md](01-architecture.md)(D3、D8、§3、§6.4、§8、§9、§10 M5)。pty host 的 IPC 帧格式与消息全集见 [04-pty-host.md](04-pty-host.md);本文定义 **Agent 需要的语义**,§10.4 逐条对应 04 §4.3–4.5 的消息,并列出需要 04 补充的字段。连接参数与受限令牌采用 04 §13.2。 +> +> 范围:内置 Agent 的 `bash` 工具在可见终端中执行命令;AgentHost 内的 `TerminalBroker`;Agent 访问终端的权限;ACP `auth.terminal` / `terminal/*`;Codex 命令输出的终端化显示;会话记录(transcript)中命令输出的展示;端到端测试。 + +**标注约定** + +- **[已核实]**:已对照下列来源(本仓库源码、上游源码的指定提交、官方文档)核对。上游源码引用格式为 `仓库@提交 路径:行号`。 +- **[推断]**:基于已核实事实的设计推理,实现前需要实测或评审。 +- 上游版本:VS Code `microsoft/vscode@4765bd3`(2026-10-16)、Ghostty `ghostty-org/ghostty@b3dc3d7`(2026-10-15)、Zed `zed-industries/zed@6b218b8`(2026-10-16)。 + +--- + +## 0. 结论摘要 + +| # | 主题 | 结论 | +|---|---|---| +| 1 | bash 工具的模式 | 新增设置 `agent.shell.mode`:`process`(现有实现)/ `terminal`(可见终端)/ `auto`(**默认**)。`auto` 在"pty host 可用 + 有 UI 连接 + 不是子 Agent 的并行批次"时用可见终端,否则用进程模式(§2.3) | +| 2 | 终端数量 | **每个 Agent 会话(含子 Agent)一个持久的"Agent 终端"**,前台命令依次复用;忙碌(等待输入、超时转后台)时临时再开一个;后台命令每条一个独立的"命令会话"(§3) | +| 3 | 执行策略 | 沿用 VS Code 的三档:`rich`(OSC 133 A/B/C/D + 带 nonce 的 OSC 633)直接在 shell 中键入命令;`basic`(只有部分 133)键入命令,靠 D / 回到提示符 / 空闲判断结束;`none` **不键入命令**,改为"每条命令一个 PTY 会话"(`exec`,进程退出码精确)。哨兵方案只保留给以后的远程 shell(§4) | +| 4 | 策略运行位置 | 命令执行器放在 **pty host 内**(贴着镜像核心,空闲检测不跨进程);AgentHost 的 `TerminalBroker` 只做策略、超时、结果格式化、后台任务、权限(§10) | +| 5 | 输出 | 交给模型的永远是**纯文本**:镜像核心的 formatter 按 `CommandRecord` 的输出区间取文本(已去 ANSI、`\r` 进度条已折叠成最终状态)。截断规则与现有 `OutputCapture` 相同(尾部 2000 行 / 50 KB,完整内容写日志文件)(§5) | +| 6 | 超时 / 中断 | 默认与进程模式一致:超时即中断。升级顺序:`^C` → 2 s → `^C` → 2 s → SIGTERM 前台进程组 → 2 s → SIGKILL 前台进程组 → 关闭整个会话。可选"超时转后台"(§6) | +| 7 | 交互提示 | 执行中输出静止 ≥ 3 s 时检查光标行与 termios(Unix:ECHO 关闭 = 密码提示);命中就提前返回 `waiting_for_input`,终端标签亮起"需要输入",用户可以直接在 Agent 终端里输入(接管)。v1 **不给模型写 stdin 的工具**(§7) | +| 8 | 审批 | **不变**:`DescribePermission` / `PermissionPolicy.Evaluate` / `tool/approve` 一字不改;审批弹窗只多一行"将在 Agent 终端中运行"(§8) | +| 9 | 后台任务 | `run_in_background` 在终端模式下创建独立的命令会话,仍然注册到 `BackgroundTasks`,仍然写日志文件、仍然注入 ``;`task_stop` = 中断升级(§9) | +| 10 | 权限 | Agent 只能操作自己创建的会话;读取用户终端需要用户在标签菜单中"共享给 Agent";共享只读、按 Agent 会话授权、会话结束即失效。pty host 按客户端角色做第二道检查(§11) | +| 11 | ACP `auth.terminal` | 实现并声明 `true`:用户点击"在终端中登录"后,在内嵌终端中运行"同一 Agent 程序 + 方法的 args/env",退出码 0 即成功,然后重启 Agent 进程并重新 `initialize`(§12.1) | +| 12 | ACP `terminal: true` | **实现但默认不声明**(设置 `agents.acp.clientTerminal: false`)。理由:v2 草案已删除;声明后 TCode 要替 Agent 承担输出缓冲、截断、进程树清理;TCode 集成的 ACP Agent 都自带 shell 工具。实现成本在 broker 落地后约 2–3 人日(§12.2) | +| 13 | Codex | `outputDelta` / `terminalInteraction` 改用**只读迷你终端**(回放连接)渲染;同时修复现有"进度只显示最后一个片段"的问题。放在 M5 末尾,约 3–4 人日(§13) | +| 14 | 会话记录 | 终端模式与 Codex/ACP 的命令输出用只读迷你终端显示(视口外不建核心);进程模式仍显示纯文本。模型看到的始终是纯文本(§14) | +| 15 | 连接与持久化 | AgentHost 用 Desktop 签发的受限令牌连接 pty host;host 未运行时经 Host→UI 的 `terminal/ptyHostEndpoint` 获取(04 §13.2)。**确认 Agent 终端默认不持久化**(`persist: false`),退出 TCode 时不保存快照,需要回看时用会话记录中的日志与 `.vt`(§3.3) | + +--- + +## 1. 现状与约束 + +### 1.1 现有 bash 工具 [已核实] + +| 项 | 现状 | 来源 | +|---|---|---| +| 进程 | `ProcessStartInfo` + 重定向 stdout/stderr,stdin 立即关闭;`ShellResolver` 选 shell(Unix 只接受 bash/zsh;Windows 依次 Git Bash → pwsh) | `src/TCode.Agent.Coding/Tools/Shell/BashTool.cs:204-230`;`ShellResolver.cs` | +| 环境 | `PAGER=cat`、`GIT_PAGER=cat`、`GIT_TERMINAL_PROMPT=0`、`TCODE_AGENT=1` | `BashTool.cs:222-226` | +| 信号 | 子进程继承 .NET 的 `SIGPIPE=SIG_IGN`;`trap - PIPE` 对 bash 无效(03 §12 实测)。修复:Unix 上经 `tcode-pty-spawn --exec` 启动 shell,exec 前复位全部信号(03 §12)。终端模式的 shell 由 helper 启动,天然没有这个问题 | 03 §12 | +| 超时 | 默认 120 s,最大 600 s;超时杀进程树,结果附"timed out" | `BashTool.cs:15-16,95-115` | +| 输出 | 完整内容流式写 `~/.tcode/tmp//bash-*.log`;交给模型保留尾部 2000 行或 50 KB;被截断时附日志路径;未截断时删除日志 | `OutputCapture.cs:7-13`;`BashTool.cs:132-160` | +| 进度 | 每 250 ms 推一次"截至目前的输出快照"(`ToolExecutionUpdated.Text`,最多 16 K 字符),UI 用它**替换**结果文本 | `BashTool.cs:239-262`;`src/TCode.Desktop/ViewModels/SessionViewModel.cs:351-357` | +| 后台 | `run_in_background` → `BackgroundTasks.Start("bash", …, stop)`,结束时 `Complete` → 会话注入 ``;`task_stop` 调用 stop 委托 | `BashTool.cs:84-97,166-182`;`Tools/BackgroundTasks.cs`;`TaskStopTool.cs` | +| 审批 | `DescribePermission` 返回 `ToolAccess.Execute + Command`;`PermissionPolicy.Evaluate`:只读白名单放行,其余在 Ask 档询问,可"本会话允许此命令前缀" | `BashTool.cs:58-62`;`src/TCode.Agent.Coding/Workspace/PermissionPolicy.cs:75-82` | +| 并发 | `ToolConcurrency.Exclusive`:同一批调用串行 | `BashTool.cs:56` | +| 宿主能力注入 | `ToolContext.Services`(`ToolServices` record)注入网页抓取、子 Agent 协调、MCP 等宿主能力 | `src/TCode.Agent.Coding/Tools/ToolServices.cs:7-22` | + +### 1.2 外部运行时 [已核实] + +- ACP:`initialize` 声明 `terminal:false`、`auth.terminal:false`(`src/TCode.Runtimes/Acp/AcpSession.cs:94-96`);`ToolCallContent` 的 `terminal` 显示为 `[terminal output unavailable]`(同文件 `:812`);`-32000` 时只自动调用白名单方法,否则提示用户自己去终端登录(`:195-220`)。 +- Codex:`item/commandExecution/outputDelta` 把**片段**交给 `ToolProgressAsync`(`src/TCode.Runtimes/Codex/CodexSession.cs:423-429`),而 UI 把每次进度文本当作**完整结果**替换(`SessionViewModel.cs:351-357`)。**结论:Codex 命令执行中界面只显示最后一个片段**,这是现存缺陷,§13 一并修复。`terminalInteraction` 未处理。 + +### 1.3 必须遵守的上位决策 + +- **D3**:终端会话只在 `tcode-ptyhost`。AgentHost 不持有 PTY,只是 pty host 的第二个客户端。因此"可见终端模式"的一切 PTY 操作都经 IPC 到 pty host。 +- **D8**:Agent 与终端打通属于 v1 范围(M5)。 +- **01 §6.4**:Agent 订阅者使用"按需读取",**不订阅原始字节流,不参与流控**。本文的流式进度据此改为轮询 `command/output`(§5.4)。 +- **01 §9**:命令边界依靠 OSC 133;无 shell 集成时退回进程模式或空闲检测 + 哨兵。本文在此基础上把 `none` 档具体化为 `exec`(§4.4),与 §9 的"退回进程模式"等价但保持可见。 +- **研究 05 §10 第 5 条**:交互终端的 `terminal` 配置**不影响** Agent 的 bash 工具(VS Code 的 `agentHostProfile` 与 `defaultProfile` 分离)。因此 Agent 终端使用自己的 profile 设置(§2.2)。 + +--- + +## 2. bash 工具的两种模式 + +### 2.1 模式定义 + +| 模式 | 执行方式 | 用户可见 | 有 TTY | cwd / 环境变量跨调用保留 | 退出码来源 | +|---|---|---|---|---|---| +| `process` | 现有实现:`shell -c `,管道重定向,stdin 关闭 | 只在会话记录中 | 否 | 否 | 进程退出码(精确) | +| `terminal` | 命令在 pty host 中的 Agent 终端里执行(§4 的策略之一) | 是:"Agent 终端"标签 | 是 | `rich`/`basic`:是;`exec`:否 | OSC 133;D(`rich`/`basic`)或进程退出码(`exec`) | + +### 2.2 设置项(`settings.json` 的 `agent.shell` 节) + +> `docs/settings.md` 目前没有 `agent` 节,落地时新增(另一个会话正在改 `TCode.Config`,届时合并)。热重载:只影响**之后**的工具调用。 + +| 键 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `agent.shell.mode` | `auto` \| `terminal` \| `process` | `auto` | 见 §2.3 | +| `agent.shell.profile` | string \| null | null | Agent 终端使用的 shell。null = 沿用 `ShellResolver` 的选择(Unix bash/zsh,Windows Git Bash → pwsh),**不使用**交互终端的默认 profile(研究 05 §10)。可以填 `terminal.profiles` 中的 id | +| `agent.shell.onTimeout` | `interrupt` \| `background` | `interrupt` | 超时后中断(与进程模式一致),或转成后台任务(VS Code 的做法)(§6) | +| `agent.shell.reveal` | `never` \| `onInput` \| `always` | `onInput` | Agent 终端标签何时自动前置:从不 / 需要输入时 / 每条命令。标签本身总是创建,只是不抢焦点 | +| `agent.shell.idleInputSeconds` | number | 3 | 执行中输出静止多少秒后做"是否在等输入"检查(§7) | +| `agent.shell.preventHistory` | bool | true | Agent 键入的命令不写入用户的 shell 历史(§2.5) | +| `agents.acp.clientTerminal` | bool | false | 是否向 ACP Agent 声明 `terminal:true`(§12.2) | + +### 2.3 `auto` 的判定 + +逐条检查,第一条命中即用 `process`;全部不命中用 `terminal`: + +| # | 条件 | 原因 | +|---|---|---| +| 1 | pty host 不可用(启动失败、握手失败、版本 / 原生库哈希不匹配) | 退化,不让工具失败。结果末尾附一行 `[ran without terminal: <原因>]` 供排查(不发给模型,放 Details) | +| 2 | 当前没有 UI 客户端连接 AgentHost(无头运行、`--self-test`、外部脚本驱动) | 可见终端没有观众;进程模式更确定 | +| 3 | 调用来自**子 Agent**,且父会话也在执行命令 | 避免同时冒出多个终端标签;子 Agent 单独执行时仍可用终端 | +| 4 | 命令被判定为"非交互的纯查询"(`ReadOnlyCommands.IsReadOnly` 为真,例如 `ls`、`git status`、`rg`) | 这类命令只读、很快、输出给模型用,放进终端只会刷屏。**[推断]** 需要 dogfood 后确认是否保留这一条 | +| 5 | Windows 上 `agent.shell.profile` 解析为 `cmd.exe` | cmd 没有 shell 集成,且 `exec` 档对 cmd 的引号规则与 Git Bash 不同;保持现有行为 | + +`terminal` 档(显式设置)跳过 2–4 条,只在第 1 条时退化。`process` 档永远走现有实现,**`BashTool` 现有代码保留不动**,两条路径通过 `ToolServices.Terminal` 是否为 null 切换(§10.1)。 + +**为什么默认 `auto` 而不是 `process`** [推断]:D8 把"Agent 与终端打通"定为 v1 目标,可见性和"卡住时可接管"是主要卖点;确定性的风险由三点控制:①输出永远经 formatter 转成纯文本;②没有 shell 集成就不键入命令(`exec`);③PAGER / 编辑器 / 交互提示被环境变量关掉(§2.5)。如果 M5 dogfood 发现模型行为退化,把默认值改成 `process` 只是一行设置。 + +### 2.4 两种模式对模型可见的差异 + +工具描述(`BashTool.Description`)按当前生效的模式生成: + +| 描述片段 | `process` | `terminal`(rich/basic) | `terminal`(exec) | +|---|---|---|---| +| 进程模型 | "Each call runs in a fresh process: `cd` and environment variables do not persist" | "Commands run in a persistent terminal session: `cd` and exported variables persist between calls. The working directory is reported after each command." | 同 `process` | +| stdin | "Avoid interactive commands; stdin is closed." | "Avoid interactive commands. If a command waits for input, the call returns early and the user may answer in the terminal." | 同左 | +| 输出 | 不变 | 不变(纯文本,无颜色码) | 不变 | + +**模式在一个会话中途变化时**(例如 pty host 崩溃后退化),下一条工具结果附一行说明 `"[Note: commands now run in a fresh process each time; cd no longer persists]"`,避免模型依赖已失效的 cwd。 + +### 2.5 Agent 终端的环境 + +环境的基础是 **Desktop 的 BaseEnvironment**:Desktop 在启动之初抓取原始环境,经 `host/configure` 发给 pty host(01 §3.3、07 §4);`LaunchBuilder` 在它之上叠加 profile 与集成变量。**Agent 终端不使用 AgentHost 自己的进程环境**(其中有 `TCODE_PTYHOST_*` 等内部变量,且可能被 AgentHost 修改过)。由于 Agent 会话是 agent 连接创建的,而 07 §4 写的是"使用发起请求的那个 Desktop 的 BaseEnvironment",需要 04 / 07 明确:**agent 连接创建的会话使用最近一次由 full 连接发送的 BaseEnvironment**(见 §18)。 + +在此之上追加 / 覆盖: + +| 变量 | 值 | 原因 / 来源 | +|---|---|---| +| `TCODE_AGENT` | `1` | 与进程模式一致(`BashTool.cs:226`) | +| `AI_AGENT` | `tcode` | 跨厂商约定,CLI 据此输出机器可读格式。VS Code 设置 `AI_AGENT` 与 `COPILOT_AGENT=1` [已核实 vscode@4765bd3 `chatAgentTools/browser/toolTerminalCreator.ts:151-160`] | +| `PAGER`、`GIT_PAGER` | `cat` | 防止分页器卡住;与进程模式一致 | +| `GIT_TERMINAL_PROMPT` | `0` | 与进程模式一致 | +| `GIT_EDITOR` | `:` | 防止 `git commit --amend`、`rebase` 打开编辑器(VS Code 同,`toolTerminalCreator.ts:163-167`) | +| `GIT_MERGE_AUTOEDIT` | `no` | 同上 | +| `DEBIAN_FRONTEND` | `noninteractive` | 防止 apt 弹出 TUI 对话框(VS Code 同) | +| `TCODE_PREVENT_HISTORY` | `1`(`preventHistory` 为 true 时) | TCode 的 shell 集成脚本读到后:bash 设 `HISTFILE=/dev/null` 或 `HISTCONTROL=ignorespace` 并在命令前加空格;zsh `setopt HIST_IGNORE_SPACE`;pwsh 设 `Set-PSReadLineOption -AddToHistoryHandler`。VS Code 的 `VSCODE_PREVENT_SHELL_HISTORY` 做法相同 [已核实 `toolTerminalCreator.ts:177-185`];脚本细节由 07 号文档实现 | +| `TCODE_PTYHOST_*`、`TCODE_PTY_HELPER` 等内部变量 | **删除** | 防止终端中的命令拿到 pty host 的连接参数(§11.2);host 构造环境时本来就剔除(04 §13.2 第 5 条),这里再列一次作为测试断言 | +| `TCODE_AGENT_ZSH_FIXUPS` | `1`(zsh) | 关闭 `!` 历史展开(双引号里的 `!` 会卡在 `dquote>`),开启 `INTERACTIVE_COMMENTS`。VS Code 同(`toolTerminalCreator.ts:187-192`) | + +**不设置 `NO_COLOR`**:颜色对用户有价值,formatter 会去掉颜色码,模型不受影响。 + +--- + +## 3. Agent 终端:数量与复用 + +### 3.1 规则 + +| 对象 | 规则 | +|---|---| +| 前台命令 | 每个 Agent 会话(主会话、每个子 Agent 各自)维护一个**持久的 Agent 终端**(shell 会话),前台命令依次复用 | +| 复用前检查 | 会话已退出 → 新建;前一条命令仍在运行(等待输入或已转后台)→ 新建一个,旧的保留给用户;用户在空闲提示符处输入过字符 → 先发 `^U`(清除当前输入行,不会杀掉任何东西;VS Code basic/none 策略的做法 [已核实 `executeStrategy/basicExecuteStrategy.ts:207`、`noneExecuteStrategy.ts:84`]) | +| 并存上限 | 每个 Agent 会话最多 4 个 Agent 终端(含忙碌的),超过时最旧的空闲终端被关闭 [推断:数值待 dogfood] | +| 后台命令 | 每条后台命令一个独立的 **命令会话**(`session/create{kind: agent, launch{command: , args: [-c, ]}}`,直接 exec,不经交互 shell),退出即结束 | +| 子 Agent | 与父会话不共享终端(并行执行时会互相干扰);终端标签标题前缀子 Agent 名 | +| 会话关闭 | Agent 会话关闭时(`BackgroundTasks.StopAllAsync` 的同一时机),pty host 关闭它的全部会话;标签保留为"已结束"状态,直到用户关闭(可以回看) | +| TCode 重启 | Agent 终端不保存 revive 数据(`persist: false`,即 04 §4.3 的默认值),理由见 §3.3 | + +### 3.2 为什么不是"每条命令一个终端" + +| 方案 | 优点 | 缺点 | +|---|---|---| +| 每会话一个持久终端(采用) | 与用户使用终端的方式一致;`cd`、`source venv/bin/activate`、`export` 跨调用保留,省掉模型的 `cd x &&` 前缀;标签少 | 前一条命令的副作用会影响后一条(例如误开的子 shell);需要 shell 集成才能可靠判断边界 | +| 每条命令一个终端 | 退出码精确、互不干扰 | 一次会话几十个标签;shell 启动开销(zsh + 插件可能 200–800 ms)每条都付;cwd 不保留,与"可见终端"的初衷不符 | + +折中:持久终端用于有 shell 集成的情况;没有 shell 集成时(§4.4)退回"每条命令一个命令会话",但**仍复用同一个标签**(标签内依次显示多个会话的内容,见 §15),用户感知不到会话切换。 + +### 3.3 持久化:确认"Agent 终端默认不持久化" + +04 §4.3 规定 `kind: agent` 的会话默认 `persist = false`(04 §19 第 11 条请 09 确认)。**本文确认这个默认值**,并补充两点: + +| 情形 | 行为 | 理由 | +|---|---|---| +| UI 崩溃 / 重载 | Agent 终端照常运行(host 独立于 Desktop,D3);Desktop 重连后按 `kind: agent` 重新建标签 | 这是 host 本身的保活,不涉及 revive | +| AgentHost 崩溃重启 | 会话不关;归属按 `agentSessionId` 找回(§10.5) | 同上 | +| 用户退出 TCode(`onQuit: revive`,默认) | Agent 终端**不保存快照**,随 host 退出关闭;`host/quitCheck` 默认不列出 Agent 终端(`includeAgent: false`),但正在运行命令的 Agent 终端要在退出确认里单独列一行"Agent 正在运行 N 条命令" | ① Agent 会话不会在重启后自动续跑,恢复出一个"死"的 Agent 终端只会让用户误以为 Agent 还在工作;② Agent 终端的输出可能很大(回滚设得大,§5.2),写盘代价高;③ 需要回看时,会话记录里已有每条命令的纯文本日志与 `.vt` 快照(§5.3、§14),信息不丢 | +| `onQuit: keepAlive` | Agent 终端与用户终端一样保留运行 | keepAlive 是用户明确要求"别停",不区分会话种类 | +| 用户想保留某个 Agent 终端 | 标签菜单"转为普通终端":Desktop 调 `session/setLabel` 并通过 `tags` 标记,之后 `persist` 按用户终端处理(**需要 04 支持在运行中修改 `persist`**,可以作为 `session/setLabel` 的附加字段) | 用户接管后(§7.2),这个终端事实上已是用户的 | + +--- + +## 4. 执行策略 + +### 4.1 shell 集成等级的判定 + +在 pty host 的命令跟踪器(01 §3 "命令跟踪")里,对每个 shell 会话维护 `IntegrationQuality`: + +| 等级 | 判定条件 | 来源 / 说明 | +|---|---|---| +| `rich` | 收到带本会话 nonce 的 `OSC 633 ; P ; HasRichCommandDetection=True ; nonce=…`,**或**同一条命令依次收到 133 A、B、C、D(D 带退出码) | VS Code 以 `HasRichCommandDetection` 属性为准 [已核实 `toolTerminalCreator.ts:240-262`]。TCode 自己的集成脚本(07)必须发出这个属性和 nonce | +| `basic` | 收到过任意 133 序列,但不满足 rich | 第三方 prompt 主题、用户自装的集成脚本 | +| `none` | 会话启动后 `T_si` 内没有收到任何 133 | `T_si` 默认 5 s(本地);VS Code 的同类超时可配置 [已核实 `toolTerminalCreator.ts:80-88`] | + +实现要点: + +- **libghostty-vt 解析 OSC 133,不解析 OSC 633**。133 通过 `GHOSTTY_TERMINAL_OPT_SEMANTIC_PROMPT` 回调给出 `kind`(PROMPT_START / INPUT_START / OUTPUT_START / COMMAND_END)、`has_exit_code`、`exit_code`、`command`(kitty 的 `cmdline=` 扩展)[已核实 ghostty@b3dc3d7 `include/ghostty/vt/terminal.h:1405-1500`];633 不在库的 OSC 表里(`src/terminal/osc` 下无 633),会经 `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` 回调交出原文 [已核实 `terminal.h:104,144-157`]。因此命令跟踪器 = 133 回调 + 未知序列回调中的 633 解析。 +- **对 01 §6.1 的影响**:`ITerminalEffects` 需要增加一个 `UnknownOsc(ReadOnlySpan content)` 回调(见文末"待决问题")。 +- **等级只升不降**(VS Code `refreshShellIntegrationQuality` 同样只升级 [已核实 `toolTerminalCreator.ts:136-147`]):在 `none` 会话里用户后来手动 source 了集成脚本,下一条命令就按新等级执行。 +- **nonce**:每个会话启动时生成 128 bit 随机数,经环境变量 `TCODE_SI_NONCE` 交给集成脚本(脚本读取后立即 unset,研究 05 §3.3)。只有带正确 nonce 的 633 才算数;**不带 nonce 的 133;D 也接受为命令结束,但 Agent 结果里标注 `boundary: unverified`**——程序输出可以伪造 133;D 让命令"提前结束",结果只是模型拿到不完整输出,没有安全后果;伪造 633;E 命令行才有安全后果(只用于显示,不执行,研究 03 §2.4)。 + +### 4.2 `rich` + +``` +前置:会话处于 Prompt 状态(最近事件是 133 A 或 B,且没有正在执行的命令) +1. 记录 baseline = 当前 Commands.Count;记录输出日志序号 seq0 +2. 写入命令(§4.5) +3. 等待下列事件中最先发生的: + a. 新的 CommandRecord 收到 D → 完成(exitCode = D 的值;无值时 null) + b. 会话进程退出 → 完成(exitCode = 进程退出码,reason=processExit) + c. 进入备用屏幕(DECSET 1049/1047/47) → 返回 reason=alternateScreen(命令仍在运行,§7.3) + d. 输入等待判定命中(§7) → 返回 reason=waitingForInput(命令仍在运行) + e. 超时 / 取消 → §6 +4. 取输出:§5 +``` + +与 VS Code 的差异:VS Code 的 rich 策略在 `runCommand` 中可能先发 `^C` 清空提示符,需要额外过滤"上一条命令被 ^C 产生的 D"[已核实 `executeStrategy/richExecuteStrategy.ts:100-118`]。TCode 只在**确认 Prompt 状态**时键入,且清空输入用 `^U` 而不是 `^C`,所以不存在这个误判。 + +### 4.3 `basic` + +步骤同 rich,结束判定换成(任一): + +1. 收到 D(可能没有退出码 → `exitCode: null`); +2. 在 C 或 D 之后又收到 A(回到提示符),之后输出静止 `idle = 1 s`;VS Code `trackIdleOnPrompt` 的状态机(Initial → Prompt → Executing → PromptAfterExecuting)[已核实 `executeStrategy/executeStrategy.ts:175-332`]; +3. 兜底:执行态下输出静止 30 s,且光标行匹配常见提示符模式(`$`、`#`、`%`、`>`、`❯`、`PS C:\>` 结尾)——VS Code `detectsCommonPromptPattern` 的同一组正则 [已核实 `executeStrategy.ts:64-110`]。 + +`basic` 的输出可能夹带提示符文本(VS Code 文档化了同样的局限 [已核实 `basicExecuteStrategy.ts` 类注释])。TCode 处理:用命令行文本在输出首行做一次"去回显"(与 VS Code `stripCommandEchoAndPrompt` 同义),末尾若是提示符行则去掉。 + +### 4.4 `none` → `exec` + +没有 shell 集成时**不往 shell 里键入命令**,改为: + +``` +command/run { strategy: process, create: { kind: agent, agentSessionId, launch: { command: , args: [<-c 或 -Command>, ] }, cwd: <上一条命令结束时的 cwd 或工作区根>, env: §2.5, persist: false } } +``` + +- 在同一个 Agent 终端标签内显示(§15),用户看到的是"一条命令一段输出"; +- 退出码 = 进程退出码,精确;没有边界判定问题; +- 代价:cwd / 变量不跨调用保留——工具描述同步切换成 `process` 的措辞(§2.4); +- 这就是 01 §9 所说的"退回进程模式",但保留了 TTY 和可见性。 + +**为什么不用 VS Code 的 none 策略(空闲检测)或哨兵**:VS Code 的 none 策略只能等输出静止后猜测命令结束、拿不到退出码 [已核实 `noneExecuteStrategy.ts`]。哨兵(命令后追加 `; printf '\e]133;D;%s\a' $?`)在本地 shell 上可行,但会被回显到终端、对 `cmd.exe` 无效、命令里的 `exit`/`exec` 会让它永远不出现。TCode 能自己 spawn 进程,`exec` 严格更好。哨兵留给以后"在 ssh 会话里替 Agent 执行"的场景(不在 v1)。 + +### 4.5 写入命令 + +| 情况 | 写入方式 | +|---|---| +| 单行,≤ 1000 字节 | 原样写入 + `\r` | +| 多行或 > 1000 字节,shell 已开启 bracketed paste(模式 2004,从镜像核心的模式表读取) | `ESC[200~` + 命令 + `ESC[201~` + `\r`(命令中的 `ESC[201~` 先剔除,防止提前结束粘贴;与用户粘贴的检查相同,01 §8) | +| 多行或 > 1000 字节,且未开启 2004 | 把命令写入 `~/.tcode/tmp//cmd-.sh`(pwsh:`.ps1`),键入 `. ''`(pwsh:`. ''`)。macOS 规范模式下单行上限 `MAX_CANON` = 1024 [推断:zle/readline 处于非规范模式时不受此限,但无行编辑器的 `sh` 受限] | +| `preventHistory` 且 shell 为 bash/zsh | 命令前加一个空格(配合 `HIST_IGNORE_SPACE` / `HISTCONTROL=ignorespace`) | + +VS Code 在 macOS 或多行命令时强制使用 bracketed paste [已核实 `richExecuteStrategy.ts:178-179`];TCode 的规则是它的超集。 + +### 4.6 与 VS Code 的对照 + +| 项 | VS Code | TCode | +|---|---|---| +| 等级 | rich / basic / none,按能力事件和 200 ms 窗口判定 | 同三档;`none` 改为 `exec` | +| 命令边界 | OSC 633(xterm.js 在渲染进程里解析) | OSC 133(libghostty,镜像核心,在 pty host)+ 633 nonce(未知序列回调) | +| 执行器位置 | 渲染进程(workbench) | pty host(贴着镜像核心) | +| 超时默认 | sync 模式默认不超时,超时后转后台 [已核实 `runInTerminalTool.ts:1892-1898,2233-2245`] | 默认 120 s 后中断(与现有进程模式一致),可选转后台 | +| 静默转交 | 输出静止 5 min 时把控制权交回模型 [已核实 `terminalChatAgentToolsConfiguration.ts:43`;`runInTerminalTool.ts:2193-2203`] | 不需要:超时已覆盖 | +| 输出上限 | 20000 字符,首 500 + 尾部 [已核实 `outputHelpers.ts:9-41`] | 沿用 TCode 现有的尾部 2000 行 / 50 KB + 日志文件 | +| 模型写 stdin | 有 `send_to_terminal` 工具 | v1 没有(§7.4) | + +--- + +## 5. 输出读取 + +### 5.1 来源 + +镜像核心的 `CommandRecord`(01 §6.1 `TerminalCore.Commands`): + +```csharp +public sealed record CommandRecord( + int Index, + GridRef PromptStart, GridRef? InputStart, GridRef? OutputStart, GridRef? End, // tracked grid ref(libghostty grid_ref_tracked) + int? ExitCode, string? CommandLine, bool CommandLineVerified, // Verified = 来自带 nonce 的 633;E + string? Cwd, // OSC 7 / 633 P;Cwd,不可信,只用于显示与下一条 exec 的 cwd(需校验目录存在) + long OutputStartSeq, long? EndSeq, // 输出日志序号 + DateTimeOffset StartedAt, DateTimeOffset? EndedAt, + bool OutputEvicted); // 输出起点已被挤出回滚 +``` + +- 取文本:`TerminalCore.Format(new FormatRequest(Target.CommandOutput(index), TextFormat.Plain, Unwrap: true, TrimTrailingSpaces: true))`。底层是 `ghostty_terminal_select_output` 得到命令输出选区,再用 formatter 输出纯文本 [已核实 ghostty@b3dc3d7 `include/ghostty/vt/selection.h:846-866`;`include/ghostty/vt/formatter.h:135-226`]。 +- 输出区间 = `OutputStart`(133 C)到 `End`(133 D)。`basic` 没有 C 时用"命令行所在行的下一行"作为起点。 +- **formatter 的天然好处**:`\r` 刷新的进度条、`\b`、光标回退覆盖都已折叠成最终屏幕状态;颜色和 OSC 8 已去除。这比"对原始字节流去 ANSI"更接近用户看到的结果,也更省 token。 + +### 5.2 交给模型的文本 + +``` +[Output truncated: showing the last lines of 48213. Full output: /Users/x/.tcode/tmp//bash-….log] ← 仅截断时 +<纯文本输出,或 "(no output)"> + +Command exited with code 2. ← 非 0 时 +[Current directory: /path/to/sub] ← cwd 与工作区根不同时(rich/basic) +``` + +规则: + +1. **截断**:与 `OutputCapture` 完全相同——保留尾部 2000 行或 50 KB(先到为准)[已核实 `OutputCapture.cs:7-13`]。复用 `OutputCapture.Tail()` 的算法,不另起一套。 +2. **日志文件**:命令执行期间,broker 每 250 ms 用 `command/output { since: cursor }` 拉取增量纯文本并追加写入日志(§5.4),所以日志与模型看到的文本格式一致。截断时在结果首行给出路径(与现在相同),模型可以用 `read` 工具读完整内容。 +3. **回滚溢出**:若 `OutputEvicted` 为真(命令输出超过镜像核心的回滚上限),日志文件仍然完整(增量是在溢出前拉走的),结果首行写 `[Output exceeded terminal scrollback; full output: ]`。**镜像核心回滚上限对 Agent 终端单独设大**:建议 50 000 行(libghostty 按字节计,约 50 MB 上限),因为 Agent 终端不参加 revive。 +4. **非 UTF-8**:formatter 输出 UTF-8,非法字节已在解析阶段替换为 U+FFFD。 +5. **备用屏幕内容不进入结果**:命令进入备用屏幕时只返回进入前的主屏输出 + 提示(§7.3)。 + +### 5.3 VT 版本(供会话记录渲染) + +同一区间再用 `TextFormat.Vt` 导出一份**带样式的 VT 文本**(formatter 支持 VT 输出 [已核实 `formatter.h`]),写入 `bash-….vt`,路径放进 `CommandDetails`(§14)。它不交给模型,只用于会话记录里的只读迷你终端回放。上限 1 MB,超出时只保留尾部(UI 显示"已截断,打开日志查看全部")。 + +### 5.4 流式进度 + +- broker 每 250 ms 调 `command/output { commandId, since: cursor, format: plain, maxBytes: 64 KiB }`,把新增文本追加到日志、更新内存尾部,并按现有约定推 `ToolExecutionUpdated.Text = 截至目前的尾部快照`(最多 16 K 字符,与现在一致)。 +- 同一个 `ToolExecutionUpdated` 的 `DetailsJson` 带上 `{ terminalSessionId, commandId }`,Desktop 据此在会话记录里**直接 attach 到 pty host 会话**显示实时迷你终端(Desktop 本来就是 pty host 的客户端),而不是渲染纯文本快照(§14;注意 §14.2 的流控约束)。 +- 不订阅原始字节:符合 01 §6.4;即使模型侧拉取停顿,也不会反压 PTY。 + +--- + +## 6. 超时、中断与取消 + +### 6.1 触发 + +| 触发 | 来源 | +|---|---| +| 超时 | `timeout` 参数(默认 120 s,最大 600 s,与进程模式相同) | +| 取消 | 用户停止本轮(`cancellationToken`) | +| `task_stop` | 后台任务 | + +### 6.2 升级序列(建议由 pty host 的 `command/interrupt` 执行,见 §10.4;也可由 broker 用 `session/input` + `session/signal` + `session/kill` 编排) + +| 步 | 动作 | 等待 | 说明 | +|---|---|---|---| +| 1 | 向 PTY 写 `0x03`(`^C`) | 2 s | 经线路规程变成 SIGINT 发给**前台进程组**,与用户按 Ctrl+C 完全相同;Windows 上 ConPTY 把它转成 `CTRL_C_EVENT`(输入未处于 VT 原始模式时)[推断:需在 M5 验证 ConPTY + pwsh/Git Bash 的行为] | +| 2 | 再写一次 `0x03` | 2 s | 部分程序第一次 ^C 只取消当前操作(npm、python REPL) | +| 3 | Unix:`killpg(tcgetpgrp(master), SIGTERM)`;Windows:跳过 | 2 s | 程序把 ISIG 关掉(raw 模式,如 vim、部分 TUI)时 ^C 不会产生信号,必须直接发信号 | +| 4 | Unix:`killpg(…, SIGKILL)`;Windows:结束 shell 以外的子进程树(ConPTY 下用 Job/快照枚举 shell 的子进程) | 1 s | | +| 5 | 关闭整个会话(SIGHUP 进程组 → 关闭 master → SIGKILL) | — | Agent 终端被标为"已结束",下一条命令新建终端 | + +- 每一步之后只要命令结束(D、回到提示符、或 `exec` 会话退出)就停止升级。 +- `tcgetpgrp` 返回 shell 自己的 pgid 时(命令已结束,只是没收到 D),不发信号。 +- 超时的工具结果:`Command timed out after 120 seconds and was interrupted (Ctrl+C).`;与进程模式措辞对齐,并说明停在第几步。 + +### 6.3 超时转后台(`agent.shell.onTimeout: background`) + +不发 `^C`;把这条命令登记为后台任务(`BackgroundTasks.Start("bash", …)`,stop 委托 = §6.2),立刻返回 `"Command is still running after 120 s; continued in background as task b3. Output file: …"`;该 Agent 终端标为"忙碌",下一条前台命令新建终端(§3.1)。完成时照常注入 ``。 + +--- + +## 7. 交互提示:检测与用户接管 + +### 7.1 检测(pty host 内,命令处于执行态时) + +每当输出静止 `idleInputSeconds`(默认 3 s),执行一次检查;命中任一高置信条件即发 `command/inputNeeded` 通知: + +| 信号 | 置信度 | 判定 | 来源 | +|---|---|---|---| +| termios:`ICANON` 开、`ECHO` 关 | 高(密码类) | Unix:`tcgetattr(master)` 读到的是从设备的 termios [推断:Linux 与 macOS 都应返回从设备设置,需在 libtcodepty 里实测]。`sudo`、`ssh`、`gpg` 读密码时都会关 ECHO | — | +| 光标行匹配 y/n | 高 | `(y/n)`、`[Y/n]`、`(yes/no)`、`[no/yes]` 等结尾 | VS Code `outputMonitor.ts:667-672` [已核实] | +| 光标行匹配密码提示 | 高(密码类) | `/password(?: for [^:]+)?:\s*$/i`,以及 passphrase、token、OTP、verification code | VS Code `outputMonitor.ts:619,688-692` [已核实] | +| 光标行匹配 "press any key"、`(END)`、`--More--` | 高 | 分页器(PAGER 被覆盖的程序仍可能自带) | VS Code `outputMonitor.ts:368-371,693-705` [已核实] | +| Linux:前台进程 `/proc//wchan` 为 `n_tty_read` / `wait_woken` | 中 | 前台进程在读 tty | [推断:仅 Linux;只作为辅助] | +| 光标行以 `:`、`?`、`>` 结尾且前台进程不是 shell | 低 | 不单独触发,只在 30 s 静止后触发 | — | + +**密码类**单独标记 `sensitive: true`。 + +### 7.2 发生后的行为 + +1. broker 立即结束本次工具调用,结果: + + ``` + <到目前为止的输出> + + [The command is waiting for input and is still running in the Agent terminal (session t2). + The user has been asked to respond in the terminal. Do not retry the command.] + ``` + + `IsError = false`,Details 中 `status: "waitingForInput"`。命令**不被中断**;该终端转为"忙碌",登记为后台任务(类型 `bash`,描述加"(等待输入)"),结束时注入 ``——这样用户在终端里回答之后,模型会在下一轮知道结果。 +2. Desktop:Agent 终端标签显示"需要输入"徽标;`agent.shell.reveal = onInput`(默认)时前置标签但**不抢键盘焦点**;会话记录中的工具卡片显示"在终端中回答"按钮(聚焦该终端);窗口不在前台时发系统通知。 +3. `sensitive` 时文案明确"请直接在终端输入,TCode 不会把它发给模型"。 +4. 用户在 Agent 终端输入任何内容 → pty host 发 `session/userInput { sessionId }`(只发事件,不带内容),broker 在后续结果中注明 `user typed input into the terminal`。**用户输入的内容永远不进入模型上下文**(终端回显的输出除外——密码不会回显)。 + +### 7.3 备用屏幕 + +命令进入备用屏幕(vim、less、htop、fzf……)时:立即返回 `[The command opened a full-screen program (alternate screen). It is still running in the Agent terminal; the user can interact with it. Avoid full-screen programs; use non-interactive flags.]`,并按 §7.2 登记为后台任务。VS Code 对备用屏幕也是立即返回 [已核实 `richExecuteStrategy.ts:181-193`]。 + +### 7.4 v1 不提供"模型写 stdin"的工具 + +VS Code 有 `send_to_terminal`。TCode v1 不提供,理由:①写入可能是对密码提示的回答,秘密会经过模型;②模型可以用非交互参数重跑(`-y`、`--yes`、`CI=1`);③Codex 的 `writeStdin` 审批(`kind: writeStdin`,`docs/protocols/codex-app-server.md` §6.2)说明这类写入需要单独审批,UX 要另行设计。**以后要加时**:工具名 `terminal_send`,`ToolAccess.Execute`,审批标题"向终端写入:<内容>",`sensitive` 提示期间直接拒绝。 + +--- + +## 8. 审批 + +- `BashTool.DescribePermission` 不变 → `PermissionPolicy.Evaluate` 不变 → `tool/approve`(`ToolApprovalRequest`)不变 [已核实 `PermissionPolicy.cs:75-82`;`src/TCode.Rpc.Contracts/Dtos.cs:242-253`]。只读白名单、"本会话允许此命令前缀"、AutoApprove / FullAccess 档位的语义与进程模式完全一致。 +- **审批在键入命令之前完成**:broker 只在 `ExecuteAsync` 内被调用,而 `ExecuteAsync` 在审批钩子放行之后才运行。被拒绝的命令不会出现在终端里。 +- 审批弹窗新增一行只读说明"将在 Agent 终端中运行"(从 `ToolApprovalRequest.ArgumentsJson` 无法得知模式,所以 `ToolPermission` 不变,改为在 `ToolApprovalRequest` 增加可选字段 `Target: "terminal" | "process"`;RPC 协议版本 +1。注意:另一个会话正在修改 `TCode.Rpc.Contracts`,合并时协调版本号)。 +- **用户在 Agent 终端里自己输入的命令不经过审批**:那是用户本人的操作,与在自己的终端里输入相同。 +- ACP 的 `terminal/create`(若启用,§12.2):ACP Agent 在调用前会通过 `session/request_permission` 请求批准,TCode 的审批照常生效;`terminal/create` 本身不再二次询问(避免双重弹窗)[推断:需要用实际 Agent 验证它们是否总在 create 之前请求权限;若某 Agent 不请求,则对该 Agent 关闭 clientTerminal]。 + +--- + +## 9. 与后台任务(BackgroundTasks)的关系 + +| 项 | 进程模式(现状) | 终端模式 | +|---|---|---| +| 启动 | `Process` + 输出泵 | `TerminalBroker.StartBackgroundAsync(...)`(`kind: agent` 的命令会话,exec `shell -c cmd`) | +| 登记 | `BackgroundTasks.Start("bash", description, logPath, stop)` | 相同;`stop` = `command/interrupt` 升级序列 | +| 输出文件 | `OutputCapture` 写日志 | broker 每 250 ms 拉增量纯文本写同一格式的日志(§5.4);另写 `.vt` | +| 完成 | `WaitForExitAsync` → `Complete(task, status, 摘要)` | `session/exited { exitCode, signal }` → 同一个 `Complete` 调用,摘要格式不变("Exit code: … last 20 lines") | +| 通知 | `` | 不变 | +| 可见 | 只在任务面板 | 任务面板 + Agent 终端标签(后台会话作为该标签的子页或单独的"任务终端"标签,§15) | +| 关闭会话 | `StopAllAsync` 杀进程 | `StopAllAsync` → broker 关闭该 Agent 会话的全部会话 | + +`task_stop` 无需改动:它只调用 `BackgroundTasks.StopAsync(id)`。由等待输入 / 备用屏幕 / 超时转后台产生的"后台任务"(§6.3、§7.2)同样可以被 `task_stop` 停止。 + +--- + +## 10. TerminalBroker(AgentHost 内) + +### 10.1 位置与依赖 + +``` +TCode.Agent.Coding + Tools/Shell/ITerminalExecutor.cs ← 新接口(只用 BCL 类型),BashTool 依赖它 + Tools/ToolServices.cs ← 新增 ITerminalExecutor? Terminal { get; init; } +TCode.AgentHost + Terminal/TerminalBroker.cs ← 实现 ITerminalExecutor;ACP / auth.terminal 也用它 + Terminal/PtyHostEndpointProvider.cs ← 环境变量 / Host→UI `terminal/ptyHostEndpoint`(§10.5) + → TCode.Terminal.Protocol(PtyHostClient) +``` + +`TCode.Agent.Coding` 不引用任何 `TCode.Terminal.*` 项目(保持 01 §4 的依赖方向):`BashTool` 只看到 `ITerminalExecutor`,`Services.Terminal == null` 时走现有进程路径。 + +### 10.2 C# API(设计稿) + +```csharp +namespace TCode.Agent.Coding.Tools.Shell; + +/// 宿主提供的"在可见终端中执行命令"的能力。线程安全;所有方法按 Agent 会话隔离。 +public interface ITerminalExecutor +{ + /// 当前是否可用(pty host 已连接、有 UI 客户端)。BashTool 据此和 agent.shell.mode 选择路径。 + TerminalAvailability Availability { get; } + + /// 前台执行一条命令,直到完成、等待输入、进入备用屏幕、超时或取消。 + Task RunAsync(TerminalCommandRequest request, IProgress? progress, CancellationToken ct); + + /// 以独立命令会话启动后台命令,立即返回。 + Task StartBackgroundAsync(TerminalCommandRequest request, CancellationToken ct); + + /// 关闭某个 Agent 会话的全部终端(会话关闭时调用)。 + Task CloseAllAsync(string agentSessionId); +} + +public sealed record TerminalCommandRequest( + string AgentSessionId, string ToolCallId, string Command, string Description, + string WorkspaceRoot, TimeSpan Timeout, TimeoutBehavior OnTimeout, string LogPath); + +public sealed record TerminalCommandProgress(string TailText, long TotalLines, string TerminalSessionId, string CommandId); + +public sealed record TerminalCommandResult( + TerminalCommandStatus Status, // Completed / WaitingForInput / AlternateScreen / TimedOut / Interrupted / SessionLost + int? ExitCode, string OutputText, // 已按 §5.2 截断 + bool Truncated, string? LogPath, string? VtPath, + string Strategy, // rich / basic / exec + bool BoundaryVerified, string? Cwd, + string TerminalSessionId, string CommandId, + string? BackgroundTaskId); // 等待输入 / 转后台时登记的任务 + +public sealed record TerminalBackgroundHandle(string TerminalSessionId, Task Exited, Func StopAsync); +``` + +AgentHost 内部(ACP 用)的额外方法,不进接口: + +```csharp +internal sealed partial class TerminalBroker +{ + // ACP terminal/*(§12.2)与 auth.terminal(§12.1) + Task CreateCommandSessionAsync(CommandSessionSpec spec, CancellationToken ct); // 返回 sessionId + Task GetOutputAsync(string sessionId, int? byteLimit, CancellationToken ct); + Task WaitForExitAsync(string sessionId, CancellationToken ct); + Task KillAsync(string sessionId); // 只结束进程,保留会话内容 + Task ReleaseAsync(string sessionId); // 结束并释放;UI 继续显示快照 + // 用户共享的终端(§11) + Task> ListSharedAsync(string agentSessionId); + Task ReadSharedAsync(string agentSessionId, string sessionId, SharedReadTarget target, int maxLines); +} +``` + +### 10.3 pty host 侧:CommandExecutor 的语义 + +01 §4 把"Agent 命令执行器"放在 `TCode.Terminal.Host`。它在会话 actor 线程上运行(独占镜像核心,01 §5.1),职责: + +| 职责 | 细节 | +|---|---| +| 状态机 | 每个 shell 会话:`Starting → Prompt ⇄ Executing`,外加 `AltScreen`、`Exited`;输入是 133/633 事件、备用屏幕切换、输出到达、进程退出、计时器 | +| 一次只跑一条 | 同一会话已有 Agent 命令执行中时,`command/run` 返回错误 `busy` | +| 写入 | §4.5 规则;写之前若用户在提示符输入过字符则先 `^U` | +| 结束判定 | §4.2 / §4.3;`exec` 会话以进程退出为准 | +| 输入等待检测 | §7.1;只在 `Executing` 且输出静止时检查 | +| 中断 | §6.2 升级序列(每一步都等结束事件) | +| 输出 | `command/output` 直接调用镜像核心 formatter;`since` 游标 = "已返回的纯文本行数"(以命令输出区间内的行号计,软换行行合并后计数) | + +### 10.4 与 04 号文档 IPC 消息的对应 + +04 号文档 §4 已经定义了消息全集([04-pty-host.md](04-pty-host.md) §4.3–4.5)。下表列出 Agent 集成用到的消息、本文赋予的语义,以及**需要 04 补充的字段 / 消息**(标"需补充")。名称以 04 为准。 + +**会话(04 §4.3)** + +| 04 的消息 | Agent 集成中的用法与语义 | +|---|---| +| `session/create` | Agent 终端:`kind: agent`、`agentSessionId`、`profileId` = `agent.shell.profile` 解析结果(或 `launch{command,args}`)、`env` = §2.5、`shellIntegration: auto`、`persist: false`(§3.3)。`exec` 命令会话与后台命令:`launch{command: , args: [-c, ]}`。ACP `terminal/create`:`launch{command, args}`(§12.2)。**需补充**:`scrollbackBytes`(Agent 终端单独设大,§5.2 第 3 条)、`reveal: background\|focus`(登录终端需要聚焦,§12.1)、`role: shell\|command\|login` 元数据(可以放进 04 已有的 `tags`,由 Desktop 解释,不必改协议) | +| `host/sessionAdded`(H→C 通知,发给 full 连接) | Desktop 据此为 `kind: agent` 的会话建"Agent 终端"标签(§15) | +| `session/list` | broker 重连后找回自己的会话(按 `agentSessionId` 过滤) | +| `session/read` | `terminal_read` 工具读共享终端(§11);`range: screen\|scrollback\|command` | +| `session/signal` | §6.2 第 3、4 步:`target: foreground`,`TERM` / `KILL`(Windows 只支持 INT / KILL,04 已说明) | +| `session/kill` | §6.2 第 5 步与 ACP `terminal/kill`:结束进程,**会话保留**(04 的语义正好满足 ACP"kill 后仍可 output / wait") | +| `session/close` | ACP `terminal/release`、Agent 会话关闭;`force: true`(Agent 侧不弹确认,确认在 UI 关闭标签时由 Desktop 完成) | +| `session/share` | 只有 full(Desktop)可调用;`access: read`。v1 的 UI 不提供 `write`(§11);撤销 = `access: none` | +| `session/input` | v1 中 broker **只用于**写入 `^U`、bracketed paste 包裹的命令文本由 host 的 `command/run` 完成;broker 本身不直接写用户输入 | +| `session/commandStarted` / `session/commandFinished`(事件) | broker 订阅后更新工具卡片;`commandFinished.exitCode` 即 133;D 的退出码 | +| `session/shellIntegration`(事件) | 注入状态。**需补充**:增加 `quality: rich\|basic\|none`(§4.1),或者新增 `session/integrationQuality` 事件,并且发送给 agent 连接(04 目前只发给 ui 订阅者) | +| `session/exited`(事件) | `exec` 会话、后台命令、ACP `wait_for_exit`、登录终端的完成信号 | + +**命令(04 §4.5)** + +| 04 的消息 | 本文语义 | +|---|---| +| `command/run {sessionId?, create?, commandLine, strategy, timeoutMs?, outputLimitBytes?}` | `strategy: auto` 时 host 按 §4.1 解析:`rich`/`basic` → 04 的 `shellIntegration`(在 shell 中键入命令,§4.2–4.5);`none` → 04 的 `process`(用 `create` 新建一个命令会话执行,§4.4,即本文的 `exec`)。04 的 `idle`(VS Code none 策略)**v1 的 broker 不使用**,保留给以后的远程 shell(§4.4)。`timeoutMs` 由 broker 管理,传给 host 的值为 null(超时后的处理是 §6 的策略,属于 broker)。**需补充**:返回值中的 `strategy` 增加 `quality: rich\|basic`;参数增加 `preventHistory`、`idleInputSeconds` | +| `command/output {sessionId, commandId, cursor?, maxBytes, format}` → `{text, cursor, truncated, done}` | §5.1 / §5.4:broker 每 250 ms 以上次的 `cursor` 续读纯文本(`cursor` 不透明);`format: vt` 用于导出 §5.3 的 `.vt`。ACP 的 `outputByteLimit` 截断(从头部、字符边界)由同一方法完成,04 已写明。**需补充**:返回 `evicted: bool`(命令输出起点已被挤出回滚,§5.2 第 3 条) | +| `command/wait {sessionId, commandId, timeoutMs}` → `{exitCode?, signal?, timedOut, durationMs}` | 长轮询。**需补充**:`state: finished\|waitingForInput\|alternateScreen\|running`、`sensitive`、`boundaryVerified`、`cwd`(§4.2 第 3 步的 c、d,§7) | +| —(**需补充**)`command/interrupt {sessionId, commandId, upTo: ctrlC\|sigterm\|sigkill\|close}` → `{stoppedAt}` | §6.2 的升级序列在 host 内执行(每一步都要等 133;D 或回到提示符,放在 host 才不需要跨进程往返)。也可以由 broker 用 `session/input`(`\x03`) + `session/signal` + `session/kill` 自己编排,代价是每一步多一次往返、判断"已结束"要再调 `command/wait`——可以接受,所以这条是"建议补充"而不是必需 | +| —(**需补充**)事件 `command/inputNeeded {commandId, sensitive, promptLine}` | §7.1 的检测在 host 内完成(要读 termios 和镜像核心的光标行)。发给该会话的 agent 连接与 ui 订阅者(标签徽标) | +| —(**需补充**)事件 `session/userInput {sessionId}` | 用户(ui 连接的 `Input` 帧)向 `kind: agent` 的会话输入时发给 owner 连接,不带内容;同一命令内只发一次(§7.2 第 4 条) | + +### 10.5 连接、令牌与重连 + +采用 04 §13.2 的方案(替代本文初稿中"AgentHost 自己读 `ptyhost.json` 并自行启动 host"的做法): + +| 步 | 做法 | 对 Agent 集成的影响 | +|---|---|---| +| 1 | Desktop 启动 AgentHost 时,若 host 已在运行,就用 `auth/mintToken{scope:"agent"}` 签发**受限令牌**,经 `TCODE_PTYHOST_ENDPOINT` / `_TOKEN` / `_INSTANCE` / `_PROTOCOL` 环境变量传入 | broker 启动时直接连接 | +| 2 | host 尚未运行,或认证失败(host 重启过、令牌已吊销)时,AgentHost 通过现有 JSON-RPC 向 UI 发 Host→UI 请求 `terminal/ptyHostEndpoint`;Desktop 按需启动 host、签发新令牌并返回同样四个字段 | **没有 UI 连接时拿不到终端**——与 §2.3 第 2 条(无 UI 时走进程模式)一致。broker 的 `Availability` 在请求未完成时为 `Connecting`,`auto` 模式下本次调用直接走进程模式,不等待 | +| 3 | AgentHost 退出 / 重启时 Desktop `auth/revoke` 旧令牌 | 见下方"重连后的会话归属" | +| 4 | **AgentHost 必须从它启动的所有子进程环境中删除 `TCODE_PTYHOST_*`** | 包括 `BashTool` 进程模式的 `ProcessStartInfo`(`BashTool.cs:204-227` 需要增加 `info.Environment.Remove(...)`)、外部 Agent CLI、MCP server;终端模式下由 host 在构造 shell 环境时删除(04 §13.2 第 5 条) | + +`terminal/ptyHostEndpoint` 在 `RpcMethods` 中登记(另一个会话正在修改 `TCode.Rpc.Contracts`,合并时协调协议版本号)。断线后 broker 按 0.2 s 起、上限 5 s 的指数退避重连,每次重连都重新走第 2 步。 + +**重连后的会话归属(需要 04 确认)**:04 把 agent 会话的 owner 记为 `agent:`(§4.3)。AgentHost 重启后令牌是新的,按 tokenId 判断归属会让旧的 Agent 终端变成"无主"。本文的建议: + +- 归属以 `(scope = agent, agentSessionId)` 判断,而不是 tokenId:AgentHost 重启后,只要会话恢复了相同的 `agentSessionId`,就能重新拿回它的 Agent 终端(`session/list` 按 `agentSessionId` 过滤); +- 令牌吊销时,正在运行命令的 Agent 终端**不杀**:Desktop 把标签标为"Agent 已断开",用户可以继续使用或关闭;broker 重连后,若该 Agent 会话仍存在,就把这些终端当作"忙碌"处理(不再往里键入命令),新的命令开新终端。 + +--- + +## 11. 权限 + +### 11.1 规则 + +| 操作 | Agent 自己创建的会话 | 用户共享的会话 | 其他会话 | +|---|---|---|---| +| 列出 | ✔ | ✔(只显示共享给本 Agent 会话的) | ✘ 不可见 | +| 运行命令 / 写入 | ✔(经 §8 审批) | ✘(v1 不支持) | ✘ | +| 读取(屏幕、回滚、命令输出) | ✔ | ✔ | ✘ | +| 中断 / 关闭 | ✔ | ✘ | ✘ | + +- **共享**:终端标签右键菜单"共享给 Agent…"→ 选择目标 Agent 会话(默认当前会话)→ UI 调 `session/share`。标签上显示"已共享"图标;再次点击可撤销。共享**只读**、只对选定的 Agent 会话有效,Agent 会话关闭、终端关闭或用户撤销时失效。共享关系只在 pty host 内存中,不持久化。 +- **读取工具**:新增内置工具 `terminal_read { terminal_id, target: "last_command" | "screen" | "scrollback", max_lines? }`。只有当前 Agent 会话存在共享终端时才出现在工具列表(与 MCP 动态工具同样的机制)。`DescribePermission` 返回 `ToolAccess.Read`(无路径 → 放行),因为"共享"本身就是用户的授权。 +- **组合器附件**:用户在输入框用"@终端"把某个终端的"最近一条命令 + 输出"附进消息——这是用户主动提供上下文,不需要共享授权(与附加文件相同)。 +- Agent 终端的标签菜单不提供"共享"(本来就可读)。 + +### 11.2 连接参数不得泄漏给 Agent 执行的任何程序 + +AgentHost 从环境变量拿到 pty host 的端点与受限令牌(04 §13.2;01 §3.3 已写入)。**这些变量必须从 AgentHost 启动的所有子进程环境中剔除**,否则 Agent 执行的任意命令(`bash` 进程模式、MCP server、外部 Agent CLI 及其工具调用)都能直接连上 pty host,绕过 broker 的全部检查与 §8 的审批: + +| 子进程 | 剔除位置 | +|---|---| +| `BashTool` 进程模式 | `BashTool.Start` 的 `ProcessStartInfo.Environment`(`BashTool.cs:204-227`),删除所有 `TCODE_PTYHOST_` 前缀的变量 | +| MCP server(stdio) | MCP 客户端启动进程处(另一个会话正在实现 MCP,需要同步这条规则) | +| 外部 Agent CLI(Claude Code、Codex、ACP Agent) | `TCode.Runtimes` 的进程启动处(`src/TCode.Runtimes/Process/`) | +| `ProcessRunner`(rg、git) | 同样剔除(这些程序不会滥用,但统一规则更简单) | +| 终端模式下的 shell | pty host 构造环境时剔除(04 §13.2 第 5 条),本文 §2.5 再列一次 | + +实现建议:在 AgentHost 启动时把这些变量读入内存后**立即从自身进程环境中删除**(`Environment.SetEnvironmentVariable(name, null)`),这样之后任何 `ProcessStartInfo` 继承的环境天然不含它们;再在上表各处加一条测试断言(启动 `env` / `cmd /c set`,输出中不得出现 `TCODE_PTYHOST_`)。Agent 的文件工具也应把 `$TCODE_HOME/run/` 列为敏感路径(读取总要审批,04 §14.1),否则 Agent 可以直接读 `ptyhost-*.json` 中的完整令牌。 + +### 11.3 双重检查 + +1. **AgentHost(TerminalBroker)**:按 `agentSessionId` 记录自己创建的 `sessionId` 集合;工具参数里的 `terminal_id` 必须在"自己创建 ∪ 共享给本会话"之内。子 Agent 使用自己的 `agentSessionId`,看不到父会话的终端(父会话的共享也不继承)。 +2. **pty host**(04 §2.4、§4、§13.2):AgentHost 使用 Desktop 签发的**受限令牌**,认证后 `scope = agent`。受限连接只能写入 / 中断 / 关闭自己创建的 `kind: agent` 会话;对用户会话只能在存在 `session/share` 授权时调用 `session/read`;`session/share`、`host/*`、`persistence/*` 只接受 full 连接(Desktop)。这是纵深防御:令牌对同一用户的任意进程都可得,所以它不是安全边界(真正的边界是 01 §8 的"仅当前用户"),但能防止 AgentHost 内的逻辑错误越权。 + +### 11.4 TCode 审批流程如何覆盖终端命令 + +| 路径 | 审批 | +|---|---| +| 内置 Agent `bash`(终端模式) | §8:与进程模式相同的 `tool/approve` | +| 内置 Agent `terminal_read` | 不弹审批;前提是用户已共享 | +| ACP Agent 的 `terminal/create` | ACP `session/request_permission` → TCode 审批(`docs/protocols/acp-v1.md` §8) | +| ACP `auth.terminal` | 用户点击按钮即视为同意;运行的是 Agent 自己的可执行文件 | +| Codex | Codex 自己执行命令,`item/commandExecution/requestApproval` → TCode 审批(不变) | +| 用户在任何终端里输入 | 不审批 | + +--- + +## 12. ACP + +### 12.1 `auth.terminal: true` + +规范要点 [已核实 https://agentclientprotocol.com/protocol/authentication,2026-10-16 读取]:Client 只有能"在交互式终端中重现所配置的 Agent 启动方式"时才声明;`type: "terminal"` 的方法给出 `args`、`env`;Client 用**同一个 Agent 程序**,追加 `args`,用 `env` 覆盖同名变量,在交互终端中运行,等待退出;**退出码 0 = 成功**,非 0、无退出码、取消 = 失败;之后**重新连接并重新 initialize**;不得对 terminal 方法调用 `authenticate`。 + +TCode 流程: + +``` +1. initialize 声明 auth.terminal = true(AcpSession.cs:96 改为 true)。 +2. session/new(或 resume/load/prompt)返回 -32000: + - authMethods 中有 type:"terminal" 的方法 → 界面"需要登录"卡片增加按钮「在终端中登录:」 + (仍然不自动执行:登录需要用户在场;符合现有"不主动发起"的原则,acp-v1.md §4.3)。 +3. 用户点击 → AgentHost.TerminalBroker.CreateCommandSessionAsync: + file = 与当前 ACP 进程相同的可执行文件(AcpAgentInfo.Executable 解析后的绝对路径) + args = 基础启动参数 + method.args ← 见下方"待核实" + env = 基础启动环境,method.env 覆盖同名变量 + cwd = 会话工作区 + kind = agent,agentSessionId = "login:"(不对应任何 Agent 会话),tags = { role: login },persist = false + Desktop 收到 host/sessionAdded,看到 tags.role = login,就把它当作普通终端标签打开并聚焦(不加 Agent 标记)。 +4. 等待 session/exited: + - exitCode == 0 → 关闭当前 ACP 进程,重新启动并 initialize,然后重试第 2 步失败的那个请求一次; + 登录标签保留,显示"登录成功,可关闭"。 + - 其他 → 卡片显示"登录未完成(退出码 N)",标签保留供用户查看输出,可再次点击重试。 + - 用户关闭标签 → 视为取消(失败)。 +5. 不设超时(登录可能需要浏览器往返)。 +``` + +- **待核实**:规范说"同一个 Agent 程序"并"追加 args",但没有说明基础参数(如 `gemini --acp` 中的 `--acp`)是否保留。字面理解是保留(`gemini --acp `)。实现时在 `AcpAgents` 表中加一个可选字段 `TerminalAuthDropsBaseArgs`,按 Agent 实测结果逐个设置。Zed 的实现把命令、args、env 组成 `SpawnInTerminal` 任务,`use_new_terminal: true` [已核实 zed@6b218b8 `crates/acp_thread/src/connection.rs:73-92`],基础参数由各 Agent 连接自行决定。 +- 登录终端的 `agentSessionId` 是 `login:` 前缀的保留值,broker 不把它暴露给任何工具:内置 Agent 看不到它,ACP Agent 也读不到它的输出。(04 的 `kind` 只有 `user | agent`;如果 04 愿意增加 `kind: system`,这里改用它更清楚。) +- 失败路径不影响其他会话;同一 Agent 的其他会话在下次请求时自然拿到新的登录状态。 + +### 12.2 是否声明 `terminal: true` + +**建议:实现,但默认不声明**(`agents.acp.clientTerminal: false`,设置页标为实验功能)。 + +| 因素 | 分析 | +|---|---| +| v2 草案 | 删除了 Client 侧 terminal,改为 Agent 自有、只读的终端输出(`docs/protocols/acp-v1.md` §16)[已核实]。v1 的投入在 v2 上不可复用(v2 只需要"只读回放",§13 的组件就够) | +| TCode 集成的 Agent | 21 个 ACP Agent(`src/TCode.Runtimes/Acp/AcpAgents.cs:25-45`)都有自己的 shell 工具;不声明时它们自己执行并经 `request_permission` 请求审批,审批照样生效(`acp-v1.md` §3 表)[已核实] | +| 声明后的责任 | Client 负责执行、输出缓冲与截断、进程树清理、release 后继续显示 [已核实 https://agentclientprotocol.com/protocol/terminals] | +| 收益 | ACP Agent 的命令出现在用户可见的终端里、可接管交互提示 | +| 实现成本 | broker 与 pty host 已具备 command 会话、输出、等待、kill;5 个方法只是映射:约 2–3 人日(含测试)[推断] | +| 风险 | Agent 可能在 `terminal/create` 之前不请求权限(§8 末尾);命令以 TCode 的环境运行,可能与 Agent 自己的沙箱策略不一致 | + +结论:成本低,所以实现并保留开关;但默认值遵循 `acp-v1.md` 的既定决策(不声明),等确有 Agent 因此获益且验证了权限行为后再按 Agent 打开(`AcpAgents` 表加 `ClientTerminal: true`)。 + +**5 个方法到 pty host 的映射**(启用时): + +| ACP 方法 | 参数 / 结果 | TCode 实现 | +|---|---|---| +| `terminal/create` | `{sessionId, command, args?, env?: [{name,value}], cwd?, outputByteLimit?}` → `{terminalId}`,**立即返回** | `session/create { kind: agent, agentSessionId: "acp:", launch: {command, args}, env: 基础环境 + env(覆盖同名), cwd: cwd ?? 会话 cwd(必须是绝对路径,否则 -32602), label: " 命令", persist: false }`。`terminalId` = TCode 生成的不透明 id,映射到 pty host `sessionId`,并记录 `outputByteLimit` | +| `terminal/output` | `{sessionId, terminalId}` → `{output, truncated, exitStatus?: {exitCode, signal}}` | `command/output { sessionId, format: plain, tail: true, maxBytes: outputByteLimit ?? 1 MiB }`;`truncated = truncatedHead \|\| evicted`;进程已退出时附 `exitStatus` | +| `terminal/wait_for_exit` | → `{exitCode, signal}` | 等待 `session/exited`;ACP 请求被取消(`$/cancel_request`)时停止等待,不杀进程 | +| `terminal/kill` | 结束命令但**不释放**;之后仍可 output / wait | `session/kill`(04:SIGHUP → 2 s → SIGKILL,**会话保留**,正好满足"kill 后仍可 output / wait") | +| `terminal/release` | 若仍在运行则结束,然后释放;之后 id 失效;已嵌入工具调用的终端**应继续显示** | `session/close { keepContent: true }`;先导出 VT 快照(§5.3)交给 Desktop 持久显示;删除 id 映射 | + +截断语义(ACP 规范 [已核实]):"超出 `outputByteLimit` 时从**输出开头**截断,必须在字符边界截断"。`command/output` 以 UTF-8 字节计数,从头部丢弃,再向后跳过续字节(`10xxxxxx`)到下一个字符起点;建议同时对齐到下一个换行,便于阅读(规范只要求字符边界,允许保留的少于上限)。**注意**:TCode 的"输出"是镜像核心格式化后的文本(不是原始字节),进度条等会被折叠——这对 Agent 更友好;Zed 也是从终端网格取文本 [推断:未核实 Zed 的具体实现]。 + +其他规则: + +- 每个 ACP 会话最多 16 个未 release 的终端;超出时 `terminal/create` 返回错误。ACP 会话关闭 / Agent 进程退出时,release 该会话的全部终端。 +- `env` 合并而非替换(规范未说明,见 [已核实] 的同一页"Gaps")[推断]。 + +### 12.3 `ToolCallContent` 中的 `terminal` + +- 启用 `clientTerminal` 时:`{type:"terminal", terminalId}` 渲染为**只读迷你终端**(§14 的同一组件),实时 attach 到对应的 pty host 会话;release 后改为显示导出的 VT 快照。规范要求 release 后继续显示 [已核实]。 +- 未启用时(默认):Agent 不应发送此类内容;万一出现,仍显示占位"终端输出不可用"(保持 `AcpSession.cs:812` 的现有行为)。 +- `ResultText()` 中给模型 / 历史的文本:启用时改为该终端的纯文本输出(截断到 2000 行 / 50 KB)。 + +--- + +## 13. Codex:命令输出改用只读终端视图 + +### 13.1 现状与目标 + +- 现状:`outputDelta` 是 Codex 侧 UTF-8 lossy 解码后的字符串片段(多字节字符跨片段时出现 U+FFFD,以 `aggregatedOutput` 为准)[已核实 `docs/protocols/codex-app-server.md:304`];`terminalInteraction { processId, stdin }` 是写给交互式进程的输入(`:305`),未处理;进度显示有 §1.2 的缺陷。 +- 目标:命令卡片内显示只读迷你终端,按到达顺序回放输出;颜色、进度条、`\r` 覆盖都正确显示。 + +### 13.2 设计 + +| 部件 | 做法 | +|---|---| +| 连接 | `ReplayTerminalViewSession : ITerminalViewSession`(01 §6.5),在 **Desktop 进程内**,不经 pty host。数据来源是会话事件流;`SendInput` 丢弃(只读) | +| 数据 | CodexSession 不再把片段当作完整文本:`ToolProgressAsync` 改为携带增量标记(`ToolExecutionUpdated` 增加 `Append: bool` 字段,或 Details 中 `{"outputDelta": "..."}`);Desktop 端按 itemId 累积 | +| 尺寸 | Codex 不告知 PTY 尺寸 → 列数取卡片宽度对应的列数(最少 80),创建后**固定不变**(换行由 Codex 侧程序决定,事后改列数会让 TUI 输出错位)[推断:Codex unified exec 的 PTY 默认尺寸待核实] | +| `terminalInteraction` | **不喂给终端**(PTY 回显已经包含在输出里 [推断],重复喂会出现两次);在卡片底部显示一行"已向进程输入:",内容 ≤ 80 字符 | +| 完成 | `item/completed` 的 `aggregatedOutput` 与已回放内容不一致时(丢片段、U+FFFD),以 `aggregatedOutput` 重放一次(清空核心后整体 write) | +| 历史 | 会话记录持久化的仍是纯文本 `aggregatedOutput`(不变);重新打开历史时用它回放 | + +### 13.3 工作量与阶段 + +| 项 | 估计 [推断] | +|---|---| +| `ReplayTerminalViewSession`(测试里已需要一个回放实现,01 §6.5,可复用) | 0.5 天 | +| 会话记录中的迷你终端项(虚拟化、最大高度、展开 / 复制 / 在终端中打开) | 1.5 天(与 §14 共用) | +| CodexSession 增量语义 + RPC 字段 + 修复替换缺陷 | 0.5 天 | +| Headless 截图测试(彩色输出、跨片段 UTF-8、`\r` 进度条) | 1 天 | +| 合计 | **3–4 人日** | + +阶段:依赖 M3(`TerminalView` 可用),不依赖 pty host。放在 **M5 末尾**;其中"修复进度只显示最后一个片段"的缺陷与终端无关,可以立即单独修。 + +--- + +## 14. 会话记录中命令输出的展示 + +### 14.1 建议 + +| 来源 | 显示 | 原因 | +|---|---|---| +| 内置 bash,进程模式 | **纯文本**(现状) | 没有 TTY,程序通常不输出颜色;已有的显示足够 | +| 内置 bash,终端模式 | **只读迷你终端** | 输出本来就带颜色和光标控制;纯文本会丢失进度条的最终状态以外的信息并显示乱码风险 | +| Codex `commandExecution` | 只读迷你终端(§13) | 同上 | +| ACP `terminal` 内容 | 只读迷你终端(§12.3) | 规范要求实时显示 | +| ACP / Claude Code 普通文本工具结果 | 纯文本 | 不变 | + +模型看到的永远是纯文本(§5.2);迷你终端只是显示层。 + +### 14.2 迷你终端组件(`TranscriptTerminalView`) + +- 内部是 `TerminalView`(`TCode.Terminal.Avalonia`),`Session` 为三种之一: + 1. **Live**:attach 到 pty host 会话(`ToolExecutionUpdated.DetailsJson` 中的 `terminalSessionId`),只订阅该命令的日志区间(从 `OutputStartSeq` 起)——Desktop 是 UI 角色客户端,可以订阅原始字节; + 2. **Snapshot**:命令结束后,读取 `CommandDetails.VtPath` 指向的 VT 文本回放(§5.3); + 3. **Replay**:Codex / ACP 的事件流。 +- 默认高度最多 12 行,可展开到 40 行;超过时内部滚动;工具栏:复制纯文本、在终端中打开(仅 Live / Agent 终端)、展开。 +- **虚拟化**:只有进入视口的卡片才创建核心(每个核心有原生内存);离开视口 2 屏后释放核心,只保留 VT 文本。会话记录里同时存在的核心数上限 8 [推断:数值待测]。 +- 回滚上限 2000 行;Snapshot 文本超过 1 MB 时只回放尾部。 +- **Live 模式受流控约束**:Live 迷你终端是 Desktop 连接上的一个 View 订阅者,参与 01 §6.4 / 04 §7 的流控——它不 Ack,会话就会被拖住(最多 10 s,之后该订阅者被降级并要求 resync)。所以迷你终端离开视口时必须 `session/detach`,而不是只停止绘制;重新进入视口时用 `snapshot: vt` 重新 attach。同一会话已经在 Agent 终端标签中显示时,迷你终端改用 Snapshot 模式定时刷新(每 500 ms 调 `command/output{format: vt}`),避免同一会话出现两个 View 订阅者。 +- 字体与主题与终端标签一致(`Terminal.*` 资源键)。 +- 无障碍:AutomationPeer 暴露纯文本(与 `TerminalView` 的可访问缓冲视图相同)。 + +### 14.3 RPC / 持久化变化 + +- `CommandDetails` 增加 `TerminalSessionId?`、`CommandId?`、`VtPath?`、`Strategy?`、`BoundaryVerified?`(只进 Details,不发给模型)[已核实现有字段:`src/TCode.Agent.Coding/Tools/ToolDetails.cs:48`]。 +- `.vt` 与 `.log` 一样放在 `~/.tcode/tmp//`,随会话清理;历史回放时文件不存在则退回纯文本。 + +--- + +## 15. Desktop 侧呈现 + +| 元素 | 规则 | +|---|---| +| Agent 终端标签 | 收到 `host/sessionAdded`(`kind: agent`)时在工作台创建标签:图标带 Agent 标记,标题"<会话标题> · Agent"(子 Agent 加前缀),**不抢焦点**。只在该 Agent 会话所在的工作台出现 | +| 标签内容 | 持久 shell 会话直接显示;`exec` 模式的多个命令会话在同一标签内**按时间顺序拼接**显示(每段上方一行分隔:命令行 + 退出码),实现方式是标签持有一个会话列表,当前会话可交互,之前的会话以只读快照显示 | +| 状态徽标 | 运行中(转圈)/ 需要输入(黄点,§7.2)/ 已结束 | +| 用户接管 | 标签可以直接输入;首次在 Agent 正在运行命令时输入,标签顶部提示"你正在向 Agent 的命令输入"(不阻止) | +| 关闭 | 关闭 Agent 终端标签 = 关闭该会话;若命令仍在运行,确认后中断(§6.2),broker 收到 `session/exited` 后让工具调用以 `SessionLost` 结束 | +| 共享 | 用户终端标签右键"共享给 Agent…"(§11.1) | +| 登录终端 | ACP 登录(§12.1)以普通终端标签显示并聚焦,标题" 登录" | + +--- + +## 16. 测试计划 + +原则:全部用 **Fake / Scripted 模型**驱动(`tests/TCode.Agent.Tests/ScriptedWireApi.cs` 按脚本发出工具调用;`src/TCode.Ai/Fake/FakeWireApi.cs` 用于 UI 冒烟),**不发起真实模型调用**;不合成 GUI 输入,"用户输入"一律以 UI 角色客户端向 pty host 写字节来模拟(遵守项目的 UI 验证约定)。 + +### 16.1 测试位置 + +| 项目 | 内容 | +|---|---| +| `tests/TCode.Terminal.Host.Tests` | CommandExecutor 的状态机与策略(真实 PTY + 真实 shell),协议级 | +| `tests/TCode.Agent.Tests` | `BashTool` + 测试用 `ITerminalExecutor`(连接进程内启动的 pty host)+ ScriptedWireApi 的端到端 | +| `tests/TCode.AgentHost.Tests` | TerminalBroker:权限、发现与重连、ACP 映射 | +| `tests/TCode.Runtimes.Tests` | ACP fixture(假的 ACP Agent 脚本):`terminal/*`、`auth.terminal`;Codex fixture 的增量语义 | +| `tests/TCode.Desktop.Tests` | Agent 终端标签、迷你终端、"需要输入"徽标的 Headless 截图 | + +### 16.2 用例 + +| # | 场景 | 断言 | +|---|---|---| +| A1 | rich:`echo hi; (exit 3)` | 输出 `hi`;exitCode 3;`IsError`;Strategy=rich;BoundaryVerified | +| A2 | 状态保持:`mkdir sub && cd sub` 然后 `pwd` | 第二次输出以 `/sub` 结尾;结果附 `[Current directory: …]` | +| A3 | 进度条:`printf 'a\r b\r c\n'` | 模型文本为 ` c`(formatter 折叠);VT 快照回放后截图一致 | +| A4 | 大输出:`seq 1 300000` | 结果尾部截断 + 日志路径;日志首行 `1`、末行 `300000`(即使超过回滚上限) | +| A5 | 超时:`sleep 100`,timeout 1 s | 第 1 步 `^C` 即结束;终端可复用;结果文案 | +| A6 | 忽略 SIGINT:`trap '' INT; sleep 100` | 升级到 SIGTERM / SIGKILL;`stoppedAt` 正确 | +| A7 | raw 模式程序:`python3 -c "import tty,sys;tty.setraw(0);sys.stdin.read(1)"` | `^C` 无效,SIGTERM 生效 | +| A8 | 等待输入:`read -p "Continue? (y/n) " x; echo got=$x` | 3 s 内返回 `WaitingForInput`;登记后台任务;UI 客户端写 `y\r` 后任务完成,通知内容含 `got=y` | +| A9 | 密码:`read -s -p "Password: " p` | `sensitive=true`(termios ECHO 关);结果文案不含"发给模型"的引导 | +| A10 | 备用屏幕:`less /etc/hosts` | 立即返回 `AlternateScreen`;`task_stop` 后会话回到提示符 | +| A11 | 无集成:`agent.shell.profile` 指向未注入的 `/bin/sh` | 等级 `none` → `exec`;退出码精确;工具描述切换为 fresh-process 措辞 | +| A12 | basic:集成脚本只发 133 A/D(测试夹具脚本) | 以 D 结束;无退出码时 exitCode=null | +| A13 | 伪造边界:命令输出中打印 `\e]133;D;0\a` 后 `sleep 1; echo tail` | 结果标 `BoundaryVerified=false`(rich 下应以带 nonce 的 633 为准,不受伪造影响) | +| A14 | 后台:`run_in_background` + `sleep 1; echo done` | 立即返回任务 id;完成后注入 ``;日志含 `done` | +| A15 | `task_stop` 后台任务 | 状态 stopped;会话已结束 | +| A16 | 取消本轮 | `^C` 发出;工具以取消结束 | +| A17 | pty host 不可用(指向不存在的 host) | `auto` 退化为进程模式,结果正确;Details 记录原因 | +| A18 | pty host 中途被杀 | 进行中的调用以 `SessionLost` 结束;下一条命令自动重连或退化 | +| A19 | 审批 | 终端模式与进程模式产生的 `ToolApprovalRequest` 除 `Target` 外逐字段相等;拒绝时终端中没有任何输入 | +| A20 | 权限 | agent 角色对 user 会话 `command/run`、`session/share` 均被拒;共享后 `terminal_read` 可读,撤销后不可读;子 Agent 读不到父会话的终端 | +| A23 | 环境隔离:终端模式与进程模式中执行 `env`(Windows `cmd /c set`),以及测试 MCP server 打印自身环境 | 输出中没有 `TCODE_PTYHOST_` 前缀的变量 | +| A24 | SIGPIPE(进程模式与终端模式) | `bash -c 'yes \| head -1; echo ${PIPESTATUS[0]}'` 输出 `141`(03 §14 T3/T19) | +| A21 | 历史 | `preventHistory` 下 zsh/bash 的 HISTFILE 中没有 Agent 命令 | +| A22 | Windows | pwsh(rich)与 Git Bash(rich)各跑 A1、A2、A5、A8 | +| C1 | ACP `terminal/*`(fixture Agent) | create 立即返回;output 的 `outputByteLimit` 截断在 UTF-8 字符边界(样本含中文与 emoji);kill 后 output 仍可用;release 后 id 失效且 UI 仍显示快照 | +| C2 | ACP `auth.terminal`(fixture:首次 `session/new` 返回 -32000 + terminal 方法;登录脚本 `exit 0`) | 声明 `auth.terminal:true`;用户动作后运行登录命令(args/env 正确合并);重启并重新 initialize;原请求重试成功;`exit 1` 时显示失败且不重启 | +| C3 | Codex 增量(fixture:带 ANSI 颜色、跨片段的多字节字符、`\r` 进度) | 进度累积而非替换(修复 §1.2 缺陷);完成时以 `aggregatedOutput` 校正;迷你终端截图 | +| D1 | Desktop:收到 `host/sessionAdded(kind: agent)` | 工作台出现 Agent 终端标签,不抢焦点(截图) | +| D2 | Desktop:`command/inputNeeded` | 标签徽标与工具卡片按钮(截图) | +| D3 | Desktop:迷你终端 Live → Snapshot 切换 | 前后截图一致 | +| S1 | AOT 冒烟 | `TCode --smoke` 中加入:Fake 模型发出一次 bash 调用,在终端模式下执行 `echo tcode-smoke`,检查工具结果与 Agent 终端标签(12 号文档 aot-smoke 作业) | + +--- + +## 17. M5 内的实施拆分 + +| 步 | 内容 | 依赖 | +|---|---|---| +| 1 | pty host:命令跟踪器(133 回调 + 633 未知序列)、IntegrationQuality、`command/*` 消息 | M2、07 的集成脚本发出 nonce 与 `HasRichCommandDetection` | +| 2 | `ITerminalExecutor` + TerminalBroker + 受限令牌 / `terminal/ptyHostEndpoint`(§10.5);BashTool 双路径;子进程环境清理;`agent.shell.*` 设置 | 1 | +| 3 | 交互提示检测、中断升级、后台任务对接 | 2 | +| 4 | Desktop:Agent 终端标签、徽标、接管提示 | M4 | +| 5 | 迷你终端(Live/Snapshot/Replay)+ Codex 增量修复 | M3 | +| 6 | 权限:共享菜单、`terminal_read`、pty host 角色检查 | 2、4 | +| 7 | ACP `auth.terminal`;`terminal/*`(开关,默认关) | 2 | +| 8 | 测试 §16 全部进 CI;AOT 冒烟 | 全部 | + +--- + +## 18. 待决问题(需评审) + +1. **`auto` 第 4 条**(只读查询走进程模式)是否保留——它减少刷屏,但让"可见终端"看起来不完整。 +2. **超时默认值**:`interrupt`(与现状一致)还是 VS Code 式 `background`。 +3. **04 需补充的消息与字段**(§10.4 中标"需补充"的各项):集成等级 `quality`、`command/wait` 的 `state`、`command/output` 的 `evicted`、`command/inputNeeded`、`session/userInput`,以及建议性的 `command/interrupt`;agent 会话归属改按 `agentSessionId`(§10.5);运行中修改 `persist`(§3.3)。 +4. **01 §6.1 `ITerminalEffects`** 需要 `UnknownOsc` 回调以支持 OSC 633(§4.1)。 +5. **ACP 终端登录的基础参数**是否保留(§12.1"待核实")。 +6. **termios ECHO 检测**在 macOS 上 `tcgetattr(master)` 是否返回从设备设置(§7.1),M5 第 3 步先做 spike。 +7. **agent 连接创建的会话使用哪份 BaseEnvironment**(§2.5):建议为最近一次由 full 连接发送的那份,需要 04 / 07 写明。 + +--- + +## 来源 + +- 本仓库:`src/TCode.Agent.Coding/Tools/Shell/BashTool.cs`、`OutputCapture.cs`、`TaskStopTool.cs`、`Tools/BackgroundTasks.cs`、`Tools/ToolServices.cs`、`Tools/ToolDetails.cs`、`Workspace/PermissionPolicy.cs`;`src/TCode.Runtimes/Acp/AcpSession.cs`、`Acp/AcpAgents.cs`、`Codex/CodexSession.cs`、`TurnWriter.cs`;`src/TCode.Rpc.Contracts/Dtos.cs`、`SessionEvents.cs`;`src/TCode.Desktop/ViewModels/SessionViewModel.cs`;`docs/design/builtin-agent-tools.md` §6、§10;`docs/design/external-agent-runtimes.md`;`docs/protocols/acp-v1.md` §1、§4、§7.5、§12、§16;`docs/protocols/codex-app-server.md` §5、§6.2。 +- VS Code `microsoft/vscode@4765bd3`:`src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/{executeStrategy,richExecuteStrategy,basicExecuteStrategy,noneExecuteStrategy}.ts`、`toolTerminalCreator.ts`、`outputHelpers.ts`、`tools/runInTerminalTool.ts`、`tools/monitoring/outputMonitor.ts`、`common/terminalChatAgentToolsConfiguration.ts`。 +- Ghostty `ghostty-org/ghostty@b3dc3d7`:`include/ghostty/vt/terminal.h`(effects 表、semantic prompt 事件、unknown sequence)、`selection.h`(`ghostty_terminal_select_output`)、`formatter.h`。 +- Zed `zed-industries/zed@6b218b8`:`crates/acp_thread/src/connection.rs:73-92`。 +- ACP 官方文档(2026-10-16 读取):https://agentclientprotocol.com/protocol/terminals 、https://agentclientprotocol.com/protocol/authentication 。 +- 研究文档:[research/06](research/06-ssh-transport.md) §7.2–7.4;[research/03](research/03-vt-protocol.md) §2.4;[research/05](research/05-platform-behavior-config.md) §3、§10。 diff --git a/docs/terminal/10-protocol-compat.md b/docs/terminal/10-protocol-compat.md new file mode 100644 index 0000000..927396f --- /dev/null +++ b/docs/terminal/10-protocol-compat.md @@ -0,0 +1,341 @@ +# TCode 终端:协议支持范围与兼容性规格 + +> 状态:设计稿 v1(2026-10-17)。上位文档:[01-architecture.md](01-architecture.md);核心实现:[02-core-ghostty.md](02-core-ghostty.md);pty host:[04-pty-host.md](04-pty-host.md);输入:[06-input.md](06-input.md)。 +> 本文把 [research/03-vt-protocol.md](research/03-vt-protocol.md)(下称 R03)的建议**落实为决策**:每项功能由谁实现(libghostty-vt 原生、镜像核心的 effects、UI 侧、host、不支持)、最终的应答字符串、环境变量、宽度模式、安全规则和测试方式。序列与模式号保留原文。 + +## 0. 标注 + +| 标记 | 含义 | +|---|---| +| `[H:file:行]` / `[S:file:行]` | **已核实**:ghostty @ `b3dc3d7` 的头文件(`include/ghostty/vt/`)/ 实现(`src/terminal/`) | +| `[R03 §x]` | 调研报告的章节 | +| `[02 §x]` `[04 §x]` `[06 §x]` | 本目录的设计文档 | +| **O1–O4** | [02 §2.4] 的 overlay 补丁(9001 登记、5522 停用、OSC 52 读延后应答、modifyOtherKeys 导出) | +| **[推断]** / **[待测]** | 未核实 / 需要测试确认 | + +"实现方"一栏的取值: + +| 取值 | 含义 | +|---|---| +| **原生** | libghostty-vt 内部完成(解析、状态、应答字节),TCode 只做配置 | +| **原生 + 回调** | libghostty 解析,结果通过回调交给 TCode;回调在**镜像核心**(pty host)或 **UI 侧核心**上处理,见 [02 §7.2] | +| **TCode** | TCode 自己实现(托管代码、host 或视图) | +| **O*n*** | 依赖 overlay 补丁;补丁不可用时按 [02 §2.4] 的退路降级 | +| **—** | 不支持 | + +--- + +## 1. 结论摘要 + +1. **绝大多数协议由 libghostty-vt 原生完成**,包括全部 Tier 0、DECRQM、DECRQSS、XTGETTCAP、颜色查询、Kitty 键盘、2026 / 2027 / 2031 / 2048、OSC 133、Kitty 图形。TCode 需要提供的只有:DA / XTVERSION / 尺寸 / 明暗这 4 个"答案"(回调)、副作用(标题、剪贴板、通知、进度、命令跟踪)、输入编码的平台部分(win32-input-mode)、以及安全策略。 +2. **所有应答只由镜像核心写回 PTY**;UI 侧核心算出同样的应答后丢弃 [A01 §3.1]。 +3. **与 R03 建议的主要偏差**(全部来自 libghostty 的现有行为,v1 接受):XTGETTCAP 用 Ghostty 自带的能力表、未知名字不应答;DECRQSS 不支持 `" q`;CSI 参数上限 24(R03 建议 32);字形簇每格上限 64 码点(R03 建议 32);标题截断到 1024 字节(R03 建议 4 KiB);1005 / 1015 鼠标编码被实现而不是忽略;DECRQM 对 1001、2(VT52)回答 0 而不是 4;没有"歧义宽度按宽字符"的选项。详见 §8。 +4. **Windows 宽度锁定为字形簇模式**(ConPTY 的字宽在创建时固定、不理会 2027);macOS / Linux 默认兼容模式,程序开启 2027 后切换(§5)。 +5. **v1 明确不支持**:sixel、iTerm2 内联图像、Kitty 动画自动播放、Kitty 粘贴事件(5522)、OSC 99、OSC 1337 `SetMark` 等扩展、矩形区域操作、DECDHL/DECDWL、2033 可见性的真实值(永远报告"可见")。 + +--- + +## 2. 查询应答(最终字符串) + +所有应答由镜像核心产生,经会话写队列写回 PTY,**同一批输入中的查询按顺序应答**(libghostty 在 `vt_write` 中同步调用 `write_pty`,天然保序 [H:terminal.h:79-84])。表中 `ESC` = `\x1b`,`ST` = `ESC \`。 + +| 查询 | 应答 | 实现方 | 与 libghostty 的映射 | +|---|---|---|---| +| DA1 `CSI c` / `CSI 0 c` | `ESC [ ? 62 ; 22 ; 52 c`;`clipboard.oscWrite = deny` 时 `ESC [ ? 62 ; 22 c` | 原生 + 回调 | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`:`primary = { conformance_level = 62, features = [22, 52 或无] }`;库按 `ESC [ ? Pp ; Ps… c` 编码 [H:device.h:82-100][S:device_attributes.zig:70-74]。理由见 R03 §3.2(不声明 4 / 21 / 28)。`52` 是否出现由 `host/configure` 下发 [04 §11.2] | +| DA2 `CSI > c` / `CSI > 0 c` | `ESC [ > 1 ; 10 ; 0 c`(2026-10-17 用户决定,与 Ghostty 一致) | 原生 + 回调 | 同一回调的 `secondary = { device_type = 1, firmware_version = 10, rom_cartridge = 0 }`,库编码为 `ESC [ > Pp ; Pv ; Pc c` [S:device_attributes.zig:88-95]。与 Ghostty 一致,兼容性经过大量用户验证。**已知影响**:vim 按 DA2 版本号自动启用 SGR 鼠标(≥ 277)、DECRQSS 光标样式(≥ 279)、下划线颜色(≥ 2500)[R03 §3.3],版本 10 不会触发这些自动探测。宽于 223 列时 vim 的鼠标需要用户在 vimrc 中设置 `ttymouse=sgr`(Ghostty 用户的通行做法);neovim 不依赖 DA2,不受影响 | +| DA3 `CSI = c` | `ESC P ! \| 00000000 ST` | 原生 + 回调 | `tertiary.unit_id = 0`,库编码为 8 位大写十六进制 [S:device_attributes.zig:112-120] | +| XTVERSION `CSI > q` / `CSI > 0 q` | `ESC P > \| TCode <版本> ST`,例 `ESC P > \| TCode 0.9.0 ST` | 原生 + 回调 | `GHOSTTY_TERMINAL_OPT_XTVERSION` 返回 `"TCode 0.9.0"`,库包装为 `ESC P > \| {s} ESC \`;回调返回空串时库用 `libghostty` [S:stream_terminal.zig:1788-1797]。版本 = Desktop 的 SemVer(与 `TERM_PROGRAM_VERSION` 相同) | +| DSR 5 `CSI 5 n` | `ESC [ 0 n` | 原生 | [S:stream_terminal.zig:1722-1724] | +| CPR `CSI 6 n` | `ESC [ r ; c R`(1 起算;DECOM 置位时相对滚动区) | 原生 | [S:stream_terminal.zig:1712-1736] | +| DECXCPR `CSI ? 6 n` | `ESC [ ? r ; c ; 1 R` | 原生 | 同上 | +| 明暗 `CSI ? 996 n` | 暗色 `ESC [ ? 997 ; 1 n`,亮色 `ESC [ ? 997 ; 2 n` | 原生 + 回调 | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME`:返回最近一次 `SetColors` 记录携带的明暗(Desktop 主题)[S:device_status.zig:33-41] | +| 2031 主动通知 | 同上 | TCode(host) | 主题切换 → `SetColors` 记录;镜像核心若 `DATA_MODE(2031)` 为真,用 `ghostty_color_scheme_report_encode` 编码后写入 PTY [H:color_scheme.h:61-65] | +| 可见性 `CSI ? 998 n` / 2033 | 永远 `ESC [ ? 999 ; 1 n`(可见) | 原生 | C API 没有设置可见性的接口,`flags.visible` 默认 true [S:Terminal.zig:136]。Tier 2 | +| DECRQM `CSI [?] Ps $ p` | `ESC [ [?] Ps ; Pm $ y` | 原生 | 按模式表应答;表外的模式答 0 [S:stream_terminal.zig:1908-1940]。9001 依赖 O1;5522 经 O2 答 0 | +| Kitty 键盘查询 `CSI ? u` | `ESC [ ? flags u` | 原生 | [S:stream_terminal.zig:1942-1950] | +| DECRQSS `DCS $ q Pt ST` | `m`:`ESC P 1 $ r m ST`(颜色用冒号写法 `38:2::r:g:b`);`SP q`:`ESC P 1 $ r Ps SP q ST`;`r`:`ESC P 1 $ r t ; b r ST`;`s`:仅 DECLRMM 置位时 `ESC P 1 $ r l ; r s ST`,否则 `ESC P 0 $ r ST`;其他(含 `" q`):`ESC P 0 $ r ST` | 原生 | 内容完全由终端状态格式化 [S:dcs.zig:250-300] | +| XTGETTCAP `DCS + q hex… ST` | 每个已知名字一条 `ESC P 1 + r = ST`;`TN` 答 `xterm-256color`;**未知名字不应答** | 原生 | 能力表是 Ghostty 编译期内置的 `xterm-ghostty` 表(含 `Co=256`、`RGB=8`、`Tc`、`Su`、`Smulx`、`Setulc`、`Ss`、`Se`、`Ms`、`Sync`、`BE`/`BD`/`PS`/`PE`、`XM`、`fe`/`fd`、`kbs=^?` 等);`TN` 由 `GHOSTTY_TERMINAL_OPT_TERMINFO_NAME` 设置,未设置时不应答 [S:stream_terminal.zig:852-895][S:terminfo/ghostty.zig][H:terminal.h:2366-2380] | +| OSC 4 / 10 / 11 / 12 `?` | `ESC ] 4 ; n ; rgb:RRRR/GGGG/BBBB <终止符>`、`ESC ] 1x ; rgb:… <终止符>`(16 位分量,**终止符与请求相同**) | 原生 | [S:stream_terminal.zig:2131-2189] | +| XTWINOPS `CSI 14 t` | `ESC [ 4 ; 高px ; 宽px t`(= 行×cell 高、列×cell 宽,物理像素) | 原生 + 回调 | `GHOSTTY_TERMINAL_OPT_SIZE`:返回 `{ rows, columns, cell_width, cell_height }`,即核心最近一次 `Resize` 的 `TerminalSize`(cell 像素 [A01 §6.1])[S:size_report.zig:45-92] | +| `CSI 16 t` | `ESC [ 6 ; cell高 ; cell宽 t` | 同上 | 同上 | +| `CSI 18 t` | `ESC [ 8 ; rows ; cols t` | 同上 | 同上 | +| `CSI 19 t` | `ESC [ 9 ; rows ; cols t` | 同上 | 同上(库按 VTE 的做法报告文本区) | +| `CSI 21 t`(标题)、`CSI 20 t` | 不应答 | 原生 | `GHOSTTY_TERMINAL_OPT_TITLE_REPORT = false`(默认)[H:terminal.h:2296-2306] | +| 2048 带内尺寸报告 | `ESC [ 48 ; rows ; cols ; 高px ; 宽px t` | 原生 + 回调 | 同一个 `SIZE` 回调。开启 2048 时立即报告一次;之后每次 `ghostty_terminal_resize` 报告一次 [H:terminal.h:3027-3030][S:stream_terminal.zig:2016-2019]。host 先执行 `TIOCSWINSZ` / `ResizePseudoConsole`,再在日志中追加 Resize 记录,所以报告晚于 PTY 尺寸更新([2048] 规范要求)| +| OSC 52 读 `ESC ] 52 ; c ; ? <终止符>` | 允许:`ESC ] 52 ; ; <原终止符>`;拒绝或超时:不应答 | 原生 + 回调 + TCode(O3)| 镜像核心回调登记请求后不应答;host 发 `clipboard/readRequest` 给主视图,UI 回 `clipboard/readResponse` 后,host 编码并写入 PTY [A01 §3.2 规则 6][02 §14.3]。O3 不可用时回调内同步阻塞(最多 30 s),拒绝时库回空剪贴板 | +| OSC 7501 查询 `ESC ] 7501 ; ? ST` | `ESC ] 7501 ; ?:states=idle,working,done,blocked,error:kinds=permission,question,auth ST` | 原生 + 回调 | 镜像核心安装了 `PROGRAM_STATUS` 回调时库自动应答 [H:terminal.h:2507-2528] | +| ENQ `0x05` | 无 | 原生 | 不安装 `ENQUIRY` 回调 [H:terminal.h:1688-1703](R03 §1.4:answerback 永远为空) | +| DECRQCRA `CSI … * y` | 默认无;测试模式下应答校验和 | 原生 | `GHOSTTY_TERMINAL_OPT_XT_CHECKSUM_REPORT` 只在 esctest 测试模式打开 [H:terminal.h:2470-2484] | +| Kitty 图形 `a=q` 等 | `ESC _ G i=[,…] ; OK ST` 或错误码 | 原生 | 只允许直接传输(§7.3)| + +ConPTY 握手:ConPTY 启动时发出 `ESC[c`、`ESC[?1004h`、`ESC[?9001h` [R03 §4.5]。镜像核心在 PTY 读线程启动后立即开始解析并应答 DA1,满足 ConPTY 的等待 [R02 §3 W3]。 + +--- + +## 3. 由 effects 或宿主层实现的功能 + +| 功能 | 序列 | libghostty 回调 | 处理位置与行为 | +|---|---|---|---| +| 窗口标题 | OSC 0 / 2(OSC 1 图标名忽略) | `TITLE_CHANGED`(标题已写入终端,回调里读 `DATA_TITLE`)[H:terminal.h:1725-1738] | 两份核心都处理:镜像核心记入会话元数据(持久化、Agent、attach),UI 侧核心更新标签。显示前按 §6.2 清洗 | +| 标题栈 | `CSI 22 / 23 ; … t` | 原生 | — | +| 工作目录 | OSC 7、OSC 9;9、OSC 1337 `CurrentDir` | `PWD_CHANGED`,读 `DATA_PWD`(原文,不解析)[H:terminal.h:1860-1883] | 镜像核心:解析 `file://` URI、校验(§6.5),用于"在相同目录打开";UI 侧:显示 | +| 响铃 | BEL | `BELL` | UI 侧核心:视觉 / 声音按设置;镜像核心只在无 UI 时记 `bellPending` [04 §11.3] | +| 写剪贴板 | OSC 52、OSC 1337 `Copy`、OSC 5522 写 | `CLIPBOARD_WRITE`(已解码、按 MIME 分组)[H:terminal.h:852-881] | 镜像核心按 `clipboard.oscWrite` 策略经 host 发 `session/clipboardWrite` 给主视图 [04 §11.2] | +| 读剪贴板 | OSC 52 `?` | `CLIPBOARD_READ` | 见 §2 和 §6.1 | +| OSC 5522 读 | kitty 剪贴板协议 | `CLIPBOARD_READ` | v1 回 `UNSUPPORTED`(库按协议回 ENOSYS)| +| 桌面通知 | OSC 9 ``、OSC 777 `notify` | `DESKTOP_NOTIFICATION` [H:terminal.h:1077-1111] | 镜像核心 → Desktop;窗口未聚焦时显示系统通知,限频(§6.6)。OSC 9 与 ConEmu 子命令、`9;4` 的区分由库完成 [R03 §2.5] | +| 进度 | OSC 9;4 | `PROGRESS_REPORT`(`progress = -1` 表示省略)[H:terminal.h:1113-1169] | UI 侧:标签进度条;镜像核心:保存最新状态,attach 时补发 | +| 程序状态 | OSC 7501(Claude Code 等会发送) | `PROGRAM_STATUS` [H:terminal.h:1171-1395] | 镜像核心按协议规则维护记录表(≤ 256 条),转发给 Desktop / Agent;新提示符出现(OSC 133 PROMPT_START)时清除 working / blocked 记录 | +| 语义提示 | OSC 133 A/B/C/D/L/N/P/I(含 kitty `cmdline=`、`D;exit`) | 原生(网格语义标记、按 prompt 滚动、`select_output`)+ `SEMANTIC_PROMPT` | 镜像核心:`CommandTracker` [02 §12];UI 侧:命令块装饰 | +| VS Code 集成 | OSC 633 | `UNKNOWN_SEQUENCE`(需设置 `UNKNOWN_MAX_BYTES`)[H:terminal.h:131-197] | 镜像核心解析 A/B/C/D/E/P 进 `CommandTracker`;**不产生网格语义标记**,所以 `select_output` / 按 prompt 跳转对只发 633 的 shell 无效 [02 §12.3] | +| 同步输出 | mode 2026 | 原生 + `RENDER_HOLD` | UI 侧核心冻结帧、1 s 超时后忽略 hold(不修改模式)[02 §8.7] | +| 2031 通知 | — | — | host(§2) | +| 2048 | — | `SIZE` 回调 | §2 | +| 窗口尺寸请求 | `CSI 8 ; rows ; cols t` | `RESIZE_WINDOW` | 不安装回调:忽略 | +| 鼠标指针形状 | OSC 22 | 原生(`DATA_MOUSE_SHAPE`) | UI 侧:`FrameSnapshot.PointerShape` | +| 超链接 | OSC 8 | 原生(格子属性;URI 经 grid ref 读取) | UI 侧:显示、悬停、打开策略(§6.3) | +| win32-input-mode | `CSI ? 9001 h/l` | O1(登记进模式表) | 状态在核心;编码由 Windows 平台层完成(`CSI Vk;Sc;Uc;Kd;Cs;Rc _`)[06 §3.7] | +| 粘贴 | 2004 | 原生(`ghostty_terminal_paste`:剥离危险控制字符、包裹、安全检查)| UI 侧核心的 `EncodePaste`(捕获作用域)[02 §13.3] | +| 焦点上报 | 1004 | 原生(模式)+ `ghostty_focus_encode` | UI 线程 | +| 重置 | RIS | 原生 + `RESET` | 两边清空各自的 TCode 侧缓存(命令记录、程序状态) | + +--- + +## 4. 环境变量与 TERM + +由 `TCode.Terminal.Shell` 的启动构造器设置(完整的环境变量策略见 07)。 + +| 变量 | 值 | 平台 | 说明 | +|---|---|---|---| +| `TERM` | `xterm-256color` | 全部(Windows 也设置:WSL 通过 `WSLENV` 之外的方式不继承,但 Git Bash / MSYS、`ssh.exe` 会读取)| v1 不发布自定义 terminfo [R03 §7.3][A01 D9]。与 XTGETTCAP 的 `TN` 一致(`OPT_TERMINFO_NAME`)| +| `COLORTERM` | `truecolor` | 全部 | neovim 等据此直接启用真彩色 [R03 §7.2] | +| `TERM_PROGRAM` | `TCode` | 全部 | 与 XTVERSION 名称一致 | +| `TERM_PROGRAM_VERSION` | Desktop 的 SemVer,例如 `0.9.0` | 全部 | 与 XTVERSION 版本一致 | +| 清除 | `VTE_VERSION`、`KITTY_WINDOW_ID`、`KITTY_PID`、`WT_SESSION`、`WT_PROFILE_ID`、`ITERM_SESSION_ID`、`TERM_SESSION_ID`、`TERMINFO`、`GHOSTTY_RESOURCES_DIR`、`GHOSTTY_BIN_DIR` | 全部 | 继承自启动 TCode 的父终端时会误导程序(Ghostty 的做法 [R03 §7.3])**[推断:列表需要 07 最终确认]** | + +SSH(D4 第一期,系统 `ssh -t`):远端 `TERM` 仍为 `xterm-256color`;`COLORTERM`、`TERM_PROGRAM*` 只在远端 `AcceptEnv` 允许时传递。所有查询应答在 SSH 下照常工作,这是"依赖查询而不是 terminfo"的主要理由 [R03 §7.3]。 + +tmux:tmux 查询 XTVERSION 得到 `TCode`,不在它的已知表中,退回 `xterm*` 的默认特性;文档建议用户配置 `set -as terminal-features ',xterm-256color:RGB:hyperlinks:usstyle:extkeys'`,长期向 tmux 提交 PR 把 TCode 加入表中 [R03 §7.3]。 + +--- + +## 5. Unicode 宽度 + +### 5.1 两种模式(libghostty 已核实的行为) + +| | 兼容模式(2027 复位) | 字形簇模式(2027 置位) | +|---|---|---| +| 规则 | 逐码点计宽;零宽码点附着到前一格;VS16 / VS15 只在前一格是 emoji 时附着,**不改变宽度**;ZWJ 不合并,序列中每个 emoji 各自计宽 [S:Terminal.zig:1475-1530] | UAX #29 分簇;VS16 → 2、VS15 → 1(仅对合法的 emoji 变体序列);ZWJ 序列整体宽 2 [S:Terminal.zig:1286-1460][H:unicode.h:96-140] | +| 列宽预测 API | `ghostty_unicode_codepoint_width` 逐码点求和 | `ghostty_unicode_grapheme_width` | +| 码点宽度表 | Ghostty 内置表:东亚宽 / 全角、默认 emoji 呈现、区域指示符为 2;组合符、默认可忽略码点(ZWJ、VS 等)为 0;≤ U+00FF 一律为 1 [H:unicode.h:40-58] | 同左 | +| 每格码点上限 | 64(超出静默丢弃)[S:page.zig:92] | 同左 | + +### 5.2 平台策略 + +| 平台 | 默认 | 程序发 `CSI ? 2027 h/l` | ConPTY 标志 | +|---|---|---|---| +| macOS / Linux | 兼容模式(2027 复位)[A01 D9] | 切换;DECRQM 2027 回答 1 / 2。neovim 会主动开启 [R03 §6.3] | — | +| Windows(ConPTY:本地 shell、WSL、`ssh.exe`)| **字形簇模式,锁定**(2027 的复位默认值设为置位,[02 §13.4])| libghostty 仍会执行复位(没有"永久置位"开关),DECRQM 回答 1 / 2 而不是 WT 的 3。ConPTY 的字宽在创建时固定、不理会 2027,所以程序复位 2027 后,emoji 序列的布局可能与 ConPTY 内部缓冲区不一致 —— v1 接受,记为已知问题 | `PSEUDOCONSOLE_GLYPH_WIDTH_GRAPHEMES`(0x08)[R02 §3.2] | + +理由:ConPTY 自己维护一份屏幕缓冲区,按创建时的字宽算法排布,并据此生成光标定位序列;模拟器必须与它一致,否则 CJK / emoji 会让光标错位 [R02 §3 W5]。Windows Terminal 同样把 2027 视为永久置位 [R03 §2.2]。 + +### 5.3 其他 + +- **歧义宽度(EAW = A)**:一律窄(1)。libghostty 没有"按宽字符处理"的选项,v1 不提供该设置(R03 §6.3 建议的"宽"选项推迟);ConPTY 不设 `PSEUDOCONSOLE_AMBIGUOUS_IS_WIDE`。 +- **区域指示符**:兼容模式下每个 RI 宽 2(Ghostty 表),与 macOS libc `wcwidth` 的 1 不同 [R03 §6.2]。这是 Ghostty 的既有行为,接受。 +- **CPR 与网格一致**:宽度只以核心网格为准,ucs-detect 就是通过 CPR 测量的 [R03 §6.3]。 +- **渲染不改变宽度**:字体测得的字形宽度不影响格子宽度(渲染层溢出或缩放,见 05)。 +- **IME 预编辑**:UI 线程按 `InputModes.GraphemeCluster` 选择上面两个预测 API [02 §13.4]。 + +--- + +## 6. 安全规则的落地 + +### 6.1 OSC 52 剪贴板 + +| 操作 | 默认策略(`clipboard.oscWrite` / `oscRead`) | 实现 | +|---|---|---| +| 写 | `allow`,每次写入在 UI 上显示短暂提示 | 镜像核心 `CLIPBOARD_WRITE` → host → 主视图 [04 §11.2]。上限:OSC 52 受库的 OSC 长度上限约束(可分配缓冲最大 8 MiB [S:osc.zig:361-365]);OSC 5522 写入受 `CLIPBOARD_WRITE_MAX_BYTES` 约束(设为 8 MiB)[H:terminal.h:2393-2414]。无 UI 连接时丢弃 | +| 读 | `ask`:按"会话 + 来源"记住"本会话允许";SSH 会话单独询问 | O3 延后应答流程(§2);拒绝、超时(60 s)、无 UI 时**不应答** | +| DA1 的 `52` | `oscWrite ≠ deny` 时声明 | §2 | + +### 6.2 回显类查询 + +| 查询 | 落地 | +|---|---| +| `CSI 21 t` / `CSI 20 t` | `TITLE_REPORT = false`(库默认)。头文件写明原因:程序可设置标题再查回,向输入流注入命令 [H:terminal.h:2296-2306] | +| DECRQSS | 库只应答 `m`、`SP q`、`r`、`s`,内容由终端状态格式化,**不回显请求文本** [S:dcs.zig:250-300](iTerm2、mintty 的 CVE 来源 [R03 §8.2])| +| XTGETTCAP | 应答只含请求名的十六进制和编译期常量;未知名字不应答 | +| DECRQCRA | 默认关闭(逐格校验和可读出整屏)[H:terminal.h:2470-2482];只在测试模式打开 | +| OSC 50 字体查询、OSC 1337 `ReportVariable` / `RequestUpload` | 不支持 | +| ENQ | 不应答 | +| 标题内容 | 库截断到 1024 字节 [S:stream_terminal.zig:1871-1886]。TCode 在 `TitleChanged` 中再去除 C0 / C1 控制字符和双向控制符(U+202A–202E、U+2066–2069)后再显示 | +| OSC 7501 文本 | `title` / `message` 已解码且无控制字符,仍按头文件要求去除不可见格式字符,并标明来源终端 [H:terminal.h:1348-1353] | + +### 6.3 超链接(OSC 8) + +UI 侧实现(05 / 06):默认普通点击不打开,需 ⌘ / Ctrl + 点击;悬停显示真实 URI;协议白名单 http、https、mailto、file(本机);其他协议弹确认;`file://` 的主机名必须为空、`localhost` 或本机;链接文本看起来像 URL 但主机不同时醒目提示;打开时只把 URL 作为参数传给系统 API [R03 §8.3]。URI 长度由库的 OSC 上限约束;显式 `id=` 拿不到([02 §8.5]),按 URI 分组。 + +### 6.4 粘贴注入 + +- 库的 `ghostty_terminal_paste`:把不安全的控制字节替换为空格(与 xterm `disallowedPasteControls` 同源),2004 置位时包裹 `ESC[200~ … ESC[201~`,未置位时 `\n` → `\r`;内容含 `ESC[201~`(括号模式)或换行(非括号模式)时返回 `GHOSTTY_REJECTED` [H:paste.h:16-60,193-211]。 +- TCode:`REJECTED` → 确认框(预览前几行)→ `allow_unsafe` 重试;另提供"总是确认多行粘贴"选项(用 `ghostty_paste_is_safe` 预检)。 +- Agent 写入:host 用 `ghostty_paste_encode`,永不放行不安全内容 [02 §13.3]。 + +### 6.5 不可信的输入信道 + +| 信道 | 落地 | +|---|---| +| OSC 7 / OSC 9;9 / OSC 1337 `CurrentDir` / OSC 633 `P;Cwd=` | 库只存原文 [H:terminal.h:1867-1871]。镜像核心解析:只接受 `file://` 且主机为空、`localhost` 或本机名;百分号解码;必须是存在的绝对路径;**从不拼进命令行**(WT CVE-2022-44702);不做 DNS 查询 | +| OSC 633 `E`(命令行) | 只有 nonce 与本会话注入脚本生成的 nonce 一致时才采用;只用于显示 | +| OSC 133 `C;cmdline=` | 只用于显示,不执行、不写入历史 | +| 窗口尺寸请求 `CSI 8 t` | 忽略 | +| Kitty 图形 `t=f/t/s` | 两份核心都关闭(`KITTY_IMAGE_MEDIUM_* = false`,与库默认一致 [S:Terminal.zig:318-325]);只允许直接传输 [02 §11.2] | + +### 6.6 通知与频率限制 + +桌面通知(OSC 9、777):默认只在窗口失焦时显示系统通知;每会话每 10 s 最多 3 条,超出合并为一条"N 条通知"**[推断:取值待 UI 设计确认]**;内容只作纯文本。 + +### 6.7 资源上限(DoS) + +| 项 | 值 | 来源 | +|---|---|---| +| CSI 参数个数 | 24(超出部分丢弃)| 库 `MAX_PARAMS` [S:Parser.zig:209](R03 建议 32,见 §8)| +| intermediate 个数 | 4 | 库 `MAX_INTERMEDIATE` [S:Parser.zig:196](R03 建议 2)| +| OSC 长度 | 2048 字节栈缓冲,可分配上限 8 MiB | [S:osc.zig:361-365] | +| 未知 OSC / APC 转交 | 4 KiB(`UNKNOWN_MAX_BYTES`),超出截断并标记 `truncated` | [H:terminal.h:2345-2364] | +| APC(Kitty 图形) | 库默认上限 | `APC_MAX_BYTES_KITTY` 保持默认,两份核心一致 [02 §7.1] | +| 标题 | 1024 字节 | §6.2 | +| 每格字形簇 | 64 码点 | §5.1 | +| Kitty 键盘栈 | 8(每屏)| R03 §4.4(Ghostty `kitty/key.zig:9`)| +| Kitty 图像存储 | 每屏 64 MB(两份核心一致)| [02 §11.3] | +| 写回 PTY 的应答 | 应答进入会话写队列;写队列上限见 [04 §7.5](8 MiB)| 程序高速发查询时应答不会无限缓冲 | +| 回滚 | 行数 + 字节双上限 | [02 §10] | + +### 6.8 Agent + +Agent 只能写它自己创建或被用户共享的终端;写入经过 TCode 审批;读取用户终端内容需要授权(09)。写入路径使用 §6.4 的编码,不走原始字节。 + +--- + +## 7. 功能分级表 + +图例:v1 ✅ 支持;◐ 部分支持(说明);✗ 不支持。测试:**U** = 核心单元测试(字节 → 网格 / 应答断言,`TCode.Terminal.Core.Tests`);**E** = esctest2(测试模式,CI);**R** = 真实程序录制回放快照;**K** = 键盘 / 鼠标编码表驱动测试;**C** = ucs-detect;**M** = 手工验收(vttest 菜单等,不用合成 GUI 输入);**D** = 差分测试([02 §15]);**P** = PTY / host 集成测试。 + +### 7.1 Tier 0 + +| 功能 | v1 | 实现方 | 测试 | +|---|---|---|---| +| 光标移动(CUU…VPR)、DECAWM 与延迟换行、擦除(ED 0–3、EL、ECH)、ICH/DCH/IL/DL、SU/SD/IND/RI/NEL、DECSTBM、DECOM、DECSC/DECRC、SCOSC/SCORC | ✅ | 原生 | U、E、D | +| SGR 基本属性、256 色、真彩色(分号与冒号写法)| ✅ | 原生 | U、E;DECRQSS 回读(neovim 探测法)| +| DECTCEM、备用屏 47/1047/1048/1049、DECCKM、DECKPAM/DECKPNM、?66、IRM、LNM | ✅ | 原生 | U、E、R | +| 制表位(HTS、TBC、CHT、CBT、DECST8C)、字符集(ASCII、DEC 线框、UK;SO/SI)、REP | ✅ | 原生 | U、E | +| DECSTR、RIS | ✅ | 原生(+ `RESET` 回调)| U、E | +| 括号粘贴 2004 | ✅ | 原生 + UI 侧 `EncodePaste` | U、K | +| 焦点事件 1004 | ✅ | 原生 + `ghostty_focus_encode` | K、P(ConPTY 握手)| +| 鼠标 9/1000/1002/1003 + 1006 | ✅ | 原生 + UI 线程编码器 | K | +| DECSCUSR(0 = 用户默认形状)| ✅ | 原生;`DEFAULT_CURSOR_STYLE/BLINK` 来自设置 | U | +| DA1 / DA2 / DSR / CPR / DECRQM | ✅ | 原生 + 回调(§2)| U(应答字节断言,含 `CSI ? u` + DA1 的顺序)、E | +| 窗口标题 OSC 0/2 | ✅ | 原生 + 回调 | U | +| 超链接 OSC 8 | ✅ | 原生 + UI | U、R | + +### 7.2 Tier 1 + +| 功能 | v1 | 实现方 | 测试 | +|---|---|---|---| +| 同步输出 2026(1 s 超时)| ✅ | 原生 + UI 侧 render hold | U、R(neovim / Claude Code 回放)| +| DECRQM 全表 | ✅ | 原生(9001 依赖 O1)| U | +| 下划线样式 `4:0–5`、`21`、下划线颜色 58/59、上划线 53/55 | ✅ | 原生(渲染见 05)| U | +| SGR 221 / 222 | ✗ | — | — | +| xterm 修饰键编码、modifyOtherKeys(`CSI > 4 ; n m`)| ✅ | 原生状态 + UI 线程编码器(modifyOtherKeys 状态经 O4 读出)| K | +| Kitty 键盘协议(5 个 flag、栈)| ✅ | 原生 + 编码器 | K、R | +| OSC 52 写 / 读 | ✅ | 原生 + 回调 + host(读依赖 O3)| U、P | +| OSC 7 / 9;9 / 1337 CurrentDir | ✅ | 原生 + 回调 | U | +| OSC 133(A/B/C/D/L/N/P/I)| ✅ | 原生 + 回调 | U、R(bash / zsh / fish / pwsh 集成脚本)| +| OSC 633 | ◐ 只做命令跟踪,无网格语义 | 原生(unknown 回调)+ 镜像核心 | U | +| OSC 9;4 进度、OSC 9 / 777 通知 | ✅ | 原生 + 回调 | U | +| 颜色查询 / 设置 OSC 4/10/11/12/104/110–112 | ✅ | 原生 | U | +| 2031 调色板变更通知、`CSI ? 996 n` | ✅ | 原生 + host | U、P | +| 2048 带内尺寸报告 | ✅ | 原生 + `SIZE` 回调 | U、P(顺序:TIOCSWINSZ 在前)| +| 2027 字形簇模式 | ✅(Windows 锁定为置位,§5.2)| 原生 | U、C | +| XTVERSION | ✅ | 原生 + 回调 | U | +| XTGETTCAP | ◐ Ghostty 能力表,未知名字不应答 | 原生 | U | +| DECRQSS | ◐ 不支持 `" q` | 原生 | U、E | +| XTWINOPS 14/16/18/19、22/23(标题栈)| ✅;其他操作忽略;21/20 关闭 | 原生 + `SIZE` 回调 | U、E | +| 左右边距 69 / DECSLRM | ✅ | 原生 | E | +| 鼠标 SGR-Pixels 1016、alternate scroll 1007 | ✅ | 原生 + 编码器 / UI 滚轮逻辑 | K | +| G2/G3、SS2/SS3、LS2/LS3 | ✅ | 原生 | U | +| win32-input-mode 9001(Windows)| ✅ | O1 + Windows 平台层编码 | K、P(ConPTY + PSReadLine、Far)| +| DECALN | ✅ | 原生 | E | +| Kitty 图形协议(直接传输、PNG / RGB / RGBA / zlib、放置、删除、z 层、Unicode 占位符、客户端驱动换帧)| ✅(D8 提升到 v1)| 原生 + 托管 PNG 解码器 + UI 渲染;占位符解码由 TCode 实现 | U、D、R(`kitten icat`、yazi、neovim 图片插件)| + +### 7.3 Tier 2 与不支持 + +| 功能 | v1 | 说明 | +|---|---|---| +| Kitty 图形:文件 / 临时文件 / 共享内存传输 | ✗ | 两份核心各读一次,临时文件 / 共享内存读完即删,必然分叉;也有本地文件探测风险 [02 §11.2] | +| Kitty 图形:动画自动播放 | ✗ | C API 没有 tick 接口 [02 §8.9] | +| Kitty 图形:重连后恢复已有图像 | ✗ | 快照不含图像 [02 §11.5] | +| sixel | ✗ | libghostty 不实现;DA1 不声明 `4` | +| iTerm2 内联图像 OSC 1337 `File=` | ✗ | | +| OSC 1337 其他(`SetMark`、`SetUserVar` 等)| ✗ | 1337 编号被库识别,不会转交 unknown 回调,无法自行实现 | +| OSC 99(kitty 通知)| ✗ | 库已解析但不交给 C API | +| OSC 66(文本缩放)| ✗ | render state 不提供缩放信息 | +| OSC 5522 kitty 剪贴板协议 | ◐ 写入随 OSC 52 策略;读取回 ENOSYS | | +| 5522 粘贴事件 | ✗ | O2 停用 [A01 §3.2 规则 7] | +| 2033 可见性报告 | ◐ 永远报告可见 | 无设置接口 | +| OSC 7501 程序状态 | ✅(超出 R03 的 Tier 2 预期)| 库原生 + 回调,Claude Code 已发送 | +| XTSAVE / XTRESTORE(`CSI ? Pm s/r`)| ✅ | 原生 | +| DECSCA / DECSED / DECSEL | ✅ | 原生 | +| 反向换行 45 / 1045 | ✅ | 原生 | +| 矩形区域操作(DECFRA、DECERA、DECCRA…)| ✗ | DA1 不声明 `28`(否则 ConPTY 会改用 DECCRA [R03 §2.3])| +| DECDHL / DECDWL | ✗ **[未核实:未在库中找到对应处理]** | | +| DECCOLM(?3)| ◐ | 库在 `?40` 允许时执行 deccolm;不改窗口尺寸 | +| 鼠标 1005 / 1015 | ◐ 库实现了这两种编码 | R03 建议忽略;接受库的行为 | +| 鼠标 1001、DEC Locator | ✗ | DECRQM 回答 0 | +| VT52 模式 | ✗ | DECRQM 2 回答 0 | +| tmux 控制模式 | ✗ | 库有编译开关,v1 不启用 | +| Glyph Protocol(Ghostty 自有 APC)| ✗ | 两份核心都关闭(快照不含注册的字形)[02 §3] | + +--- + +## 8. 与 R03 建议的偏差 + +| R03 建议 | v1 实际 | 原因 | 影响 | +|---|---|---|---| +| XTGETTCAP 用 TCode 自己的编译期能力表,未知名字回 `DCS 0 + r hex ST`(§3.6)| Ghostty 内置的 `xterm-ghostty` 能力表 + `TN=xterm-256color`;未知名字不应答 | 库的实现 [S:stream_terminal.zig:852-863];没有配置接口 | 使用超时等待应答的客户端(neovim `vim.tty.query`)会在未知名字上等到超时。能力表中的按键串与库的编码器一致,所以不会自相矛盾 | +| DECRQSS 支持 `" q`(§3.9)| 回答无效 | 库只支持 4 种 | 极少使用 | +| CSI 参数 32 个(§1.5)| 24 个 | 库常量 | Kakoune 的 17 参数 SGR 不受影响 | +| 字形簇上限 32(§6.4)| 64 | 库常量 | 无 | +| 标题 4 KiB(§1.5)| 1024 字节 | 库常量 | 无 | +| 1005 / 1015 忽略、DECRQM 回答 4 | 库实现了这两种编码 | 库行为 | 只影响主动请求它们的旧程序 | +| DECRQM:1001、2(VT52)、80(sixel)回答 4 | 回答 0 | 不在库的模式表中 | 探测这些模式的程序视为"不认识",行为相同 | +| 歧义宽度可选"宽"(§6.3)| 不提供 | 库无此选项 | CJK 旧软件用户无法调整 | +| OSC 52 写入的数据不是合法 base64 时忽略(§8.1)| 由库解码;非法数据的处理是库的行为 **[待测]** | — | 在 U 测试中确认,若库会清空剪贴板则在 `ClipboardWrite` 中对空内容做保护 | +| Kitty 图形 Tier 2,v1 只"安全跳过"(§2.3)| v1 支持 | D8 决策 | 见 §7.2 | +| 2033 可见性"可以早做"(§2.3)| 永远可见 | 无接口 | 后台标签的 TUI 无法降低刷新率 | + +--- + +## 9. 测试计划要点 + +完整计划见 11 号文档;这里只列与协议直接相关的门槛。 + +| 套件 | 内容 | 门槛 | +|---|---|---| +| 应答断言(U)| §2 表中每一行至少一个用例;`CSI ? u` + `CSI c` 的应答顺序;OSC 查询的 BEL / ST 终止符各一例;ConPTY 启动握手字节 | 全部通过 | +| esctest2(E)| 测试模式(DECRQCRA、XTWINOPS 全集打开),无头 PTY 宿主运行,`--expected-terminal=xterm` | Tier 0 / 1 相关测试通过率 ≥ 95%,失败项进 known-bugs(含 §8 的已知偏差)| +| 回放快照(R)| vim、htop、tmux、less、fzf、neovim(含 2027、2026、kitty 键盘探测)、Claude Code、Codex 的录制;xterm.js 与 Alacritty 的语料 | 三平台逐字节一致 | +| 编码表(K)| 键 × 修饰 × 模式(legacy / DECCKM / mok2 / kitty 各 flag / 9001);鼠标按钮 × 修饰 × 编码 × 坐标边界 | 全部通过 | +| ucs-detect(C)| 每个发布版本;macOS / Linux 兼容模式与 2027 模式各一次,Windows 字形簇模式 | 2027 模式下 WIDE / ZWJ / VS16 / VS15 ≥ 98% | +| 差分(D)| [02 §15] | 0 差异 | +| vttest(M)| 菜单 1–8、11 | 人工验收通过 | + +--- + +## 10. 待决问题 + +1. ~~DA2 应答~~:已决定用 Ghostty 的 `>1;10;0c`(2026-10-17)。 +2. **Windows 程序复位 2027 后的错位**:是否值得为 Windows 构建再加一个 overlay,让 2027 永久置位(DECRQM 回答 3)。本文建议不做,先收集实际案例([02] S8)。 +3. **XTGETTCAP 未知名字不应答**:如果 neovim 等出现明显的启动延迟,考虑加 overlay 对未知名字回 `DCS 0 + r`。 +4. **通知限频的具体取值**(§6.6)。 diff --git a/docs/terminal/11-testing-benchmarks.md b/docs/terminal/11-testing-benchmarks.md new file mode 100644 index 0000000..30ccb50 --- /dev/null +++ b/docs/terminal/11-testing-benchmarks.md @@ -0,0 +1,503 @@ +# TCode 终端:测试与基准(11) + +> 状态:设计稿 v1(2026-10-16)。上位文档:[01-architecture.md](01-architecture.md)(§1 数字目标、§4 项目划分、§10 里程碑)。依据:[research/03](research/03-vt-protocol.md) §9(测试计划)、[research/04](research/04-rendering-performance.md) §7.3、§9(数字目标、基准计划)、[research/01](research/01-emulator-cores.md) §4.1.5(libghostty 的一致性测试)、[research/02](research/02-pty.md) §5、§9(PTY 验证与压力测试)。CI 作业的布局与 YAML 见 [12-build-and-ci.md](12-build-and-ci.md)。Agent 集成的端到端用例见 [09-agent-integration.md](09-agent-integration.md) §16。 +> +> 本文规定:测试分几层、每层测什么、用什么夹具和文件格式、在 xunit v3 中怎么组织;基准项目的结构、场景 S1–S15 的数据集与测量方法、JIT/AOT 两种工具链、CI 门禁、端到端指标的采集;数字目标表与对应的测量方法。 + +**标注约定**:**[已核实]** = 对照源码、文档或本机实测核对过;**[推断]** = 设计推理或经验值,需要实测。上游版本:Ghostty `b3dc3d7`(2026-10-15)。 + +--- + +## 0. 结论摘要 + +| 主题 | 结论 | +|---|---| +| 分层 | L1 核心(回放 → 网格快照、增量 vs 全量差分、应答、编码表、esctest2、fuzz 语料回放);L2 PTY(真实 PTY 往返、退出码、关闭语义、ConPTY);L3 pty host 协议(握手、流控、重连快照、持久化、多客户端);L4 视图截图(Avalonia Headless + Skia);L5 端到端 TUI(录制字节流 → 网格快照,真实 PTY + shell 冒烟);L6 手工(vttest、ucs-detect、"与系统一致"清单、Typometer) | +| 夹具格式 | 字节流用 **asciicast v2**(`.cast`),加一个 TCode 扩展事件码 `"ob"` 承载非 UTF-8 的原始字节;网格快照用自定义的纯文本格式 **`.grid`**(文本 + 样式表 + 行内属性游程 + 光标 / 模式),跨平台逐字节一致 | +| esctest2 | 自写无头驱动(C#,`tools/terminal/EscTestRunner`):真实 PTY 中运行 esctest2,输出经镜像核心处理并回写应答;开启 libghostty 的 DECRQCRA 校验和选项(`GHOSTTY_TERMINAL_OPT_XT_CHECKSUM_REPORT = 44`)。CI 中只报告、不阻断,失败项进 known-failures 列表,**新增失败**才阻断 | +| fuzz | **不用 SharpFuzz**:它对托管 IL 插桩,而解析器在原生库里,插桩覆盖不到;NativeAOT 也不适用。改为①回放上游 AFL++ 语料(`test/fuzz-libghostty/corpus/*`)经过我们的 C# 门面;②自写的确定性结构化随机生成器(固定种子进 PR,长跑进每晚);③不变量检查 | +| 基准 | `tests/TCode.Terminal.Benchmarks`(BenchmarkDotNet)。数据集由 `tools/terminal/gen-bench-data` 确定性生成到 `artifacts/bench-data/`,仓库只提交清单和 SHA-256。JIT 与 NativeAOT 两种工具链都跑。PR 跑短作业并做"同机 A/B"比较;每晚跑完整的 S1–S15 | +| 端到端指标 | 进程内 `System.Diagnostics.Metrics`(`Meter`)计数,`TCODE_TERMINAL_METRICS=` 时由 `MeterListener` 写 JSON Lines;需要 `dotnet-counters` 时另做打开 `EventSourceSupport` 的诊断构建(NativeAOT 默认关闭 EventSource [推断:需核对当前 .NET 10 默认值]) | +| 延迟 | 自动化部分:内部分段时间戳(KeyDown → 写 PTY → 回显解析 → 快照发布 → 绘制结束)。端到端:Typometer **仅人工**在开发机上测量,不进 CI,不由 Agent 执行(遵守项目"不合成输入、不截屏幕区域"的约定) | + +--- + +> **M1 实际落地(2026-10-17)**: +> - `.grid` 与 asciicast 读取在 `src/TCode.Terminal.Core/Testing/`(`GridSnapshot`、`Cast`),与 §2.2 的差异写在 `GridSnapshot` 的注释里(不写 margins、`e:`;语义区间按行写游程)。 +> - 黄金样本在 `tests/TCode.Terminal.Core.Tests/Replay/`,夹具在 `Fixtures/{xtermjs,alacritty}/`,每个夹具有 `.cast`、`.ref.txt`(上游参照)、`.grid`(TCode 的回归锁定);参照差异登记在 `Fixtures/known-differences.txt`。 +> - fuzz 在 `Fuzz/`:上游语料子集打包为 `Fixtures/fuzz/corpus.jsonl`;暂在进程内运行(子进程隔离未做)。 +> - esctest 驱动是 `tools/terminal/EscTestRunner`,esctest2 由 `tools/terminal/fetch-esctest.sh` 按 `native/ghostty/esctest.json` 下载校验;§1.1.4 的 `TestMode` 实现为 `TerminalCoreOptions.Conformance`(`ConformanceMode`),另外开启了标题回报。 +> - 每晚任务:`TCODE_DIFF_SEEDS=200`、`TCODE_FUZZ_MINUTES=20`、`TCODE_PTY_STRESS=1`。实测数字见 [13](13-roadmap.md) "M1 实施状态"。 + +## 1. 测试分层 + +| 层 | 项目 | 运行时机 | 平台 | 里程碑 | +|---|---|---|---|---| +| L1 核心 | `tests/TCode.Terminal.Core.Tests` | 每次 PR | 3 平台(linux-x64、osx-arm64、win-x64) | M0 起,M1 完整 | +| L1b esctest2 | `tools/terminal/EscTestRunner` | 每次 PR(报告)+ 每晚 | linux-x64 | M1 | +| L1c fuzz 长跑 | `tests/TCode.Terminal.Core.Tests`(`[Trait("Category","FuzzLong")]`) | 每晚 1 h | linux-x64 | M1 | +| L2 PTY | `tests/TCode.Terminal.Pty.Tests` | 每次 PR | 3 平台 + 每晚 linux-arm64、win-arm64 | M0 往返,M1 完整 | +| L3 pty host | `tests/TCode.Terminal.Host.Tests` | 每次 PR | 3 平台 | M2 | +| L4 视图截图 | `tests/TCode.Desktop.Tests`(`Terminal*SnapshotTests`)、`tests/TCode.Terminal.Avalonia.Tests`(控件级,不依赖 Desktop) | 每次 PR | 3 平台 | M3 | +| L5 TUI 回放 | `tests/TCode.Terminal.Core.Tests/Tui/` | 每次 PR | 3 平台 | M1(夹具逐步补齐到 M4) | +| L5b 真实 shell 端到端 | `tests/TCode.Terminal.Host.Tests/Shell/` | 每次 PR | 3 平台 | M4 | +| L6 手工 | 本文 §7 清单 | 每个发布版本 | 3 平台实机 | M4 起 | +| Agent 端到端 | 见 09 §16 | 每次 PR | 3 平台 | M5 | + +> `TCode.Terminal.Avalonia.Tests` 是新增项目(01 §4 的测试列表中只有 `TCode.Desktop.Tests` 承担截图)。理由:`TCode.Terminal.Avalonia` 不依赖 AtomUI 与 TCode(01 §4),它的截图测试不应依赖启动整个 TCode App;Desktop 侧只测集成(主题映射、标签、分屏)。列入待决问题。 + +### 1.1 L1 核心 + +#### 1.1.1 回放 → 网格断言(黄金样本) + +``` +tests/TCode.Terminal.Core.Tests/ + Fixtures/ + xtermjs/ *.cast + *.grid xterm.js escape_sequence_files 转换而来(MIT) + alacritty/ *.cast + *.grid Alacritty ref tests 转换而来(Apache-2.0 / MIT) + ucs/ *.cast + *.grid ucs-detect docs/ucs_example_files + tui/ *.cast + *.grid 自录:vim、htop、tmux、less、fzf、Claude Code、Codex(§1.5) + replies/ *.cast + *.replies 查询应答 + THIRD_PARTY.md 来源、许可、转换脚本版本 + Replay/GoldenTests.cs +``` + +- 每个夹具一对文件:`.cast`(输入字节流,§2.1)、`.grid`(期望网格,§2.2)。 +- 测试:读 `.cast` → 按事件依次 `TerminalCore.Write` / `Resize` → `GridSnapshot.Capture(core)` → 与 `.grid` 逐字节比较;不一致时把实际结果写到 `artifacts/test-results/grid/.actual.grid` 并输出 unified diff。 +- **分块不变性**:同一夹具再按 1 字节、7 字节、4 KiB、随机块大小各回放一次,结果必须相同(覆盖 UTF-8 和转义序列跨块截断,research/03 §9.2 第 1 条)。 +- **更新黄金样本**:`TCODE_UPDATE_GOLDEN=1 dotnet test --filter Category=Golden` 覆盖写回 `.grid`;PR 中黄金样本的变化需要人工审阅(CI 里永远不设这个变量)。 +- 第三方素材的转换器在 `tools/terminal/fixtures/`:`convert-xtermjs.py`(`.in` + `.text` → `.cast` + `.grid`,按 xterm.js NOTES 处理末尾空行)、`convert-alacritty.py`(`alacritty.recording` + `grid.json` + `size.json` → `.cast` + `.grid`)。转换结果入库,转换器只在更新素材时运行。 + +#### 1.1.2 增量 vs 全量差分(render state 正确性) + +研究 01 §4.1.7 记录了 RoyalTerminal 在 libghostty 上发现的多处"该标脏未标脏"问题,所以这一项是 D1 选型的必要配套。 + +``` +对每个 .cast 夹具、每种分块方式: + coreA:每写一块就 CaptureFrame(只复制脏行,01 §6.1),维护一份"增量拼出的帧" + coreB:每写一块后重建一个新 RenderState 并全量复制(不依赖脏标记) + 断言:每一块之后,增量帧 == 全量帧(逐行比较 cells、styles、graphemes、flags、cursor) +``` + +- 差异报告给出第一处不一致的块序号、行号与两边的 `.grid` 片段。 +- 已知上游缺陷:放进 `KnownDirtyBugs.cs`(夹具名 + 上游 issue 链接),在该处跳过并计数;上游修复后删除。 +- 同样的差分也用于 fuzz 语料(§1.1.5)。 + +#### 1.1.3 应答与编码表 + +| 测试 | 内容 | 来源 | +|---|---|---| +| `Replies/*.cast` + `.replies` | 每个查询序列 → 期望的应答字节(DA1/DA2/DA3、XTVERSION、DECRQM 全表、DECRQSS 回读 SGR、XTGETTCAP、CPR、OSC 4/10/11、2048、`CSI ? u` 与 DA1 的顺序) | research/03 §3、§9.2 第 3 条;附录 B 中各 TUI 的启动查询 | +| `Encoding/KeyTable.cs` | 键 × 修饰 × 模式(legacy / DECCKM / DECKPAM / modifyOtherKeys 1/2 / Kitty 各 flag / win32-input-mode)→ 字节;表格驱动(`TheoryData`) | research/03 §4;期望值取自 xterm ctlseqs、Kitty 键盘协议文档、WT `kittyKeyboardProtocol.cpp` | +| `Encoding/MouseTable.cs` | 按钮 × 修饰 × 编码(X10 / SGR / UTF-8 / urxvt / SGR-pixels)× 坐标边界(223、2015、> 2015) | research/03 §5 | +| `Encoding/PasteTests.cs` | bracketed paste 包裹;`ESC[201~` 与控制字符过滤 | 01 §8 | + +`.replies` 格式:每行一个"在第几个输入事件之后期望的应答",字节以 C 风格转义写出(`\x1b[?62;22c`),便于审阅。 + +#### 1.1.4 esctest2 + +- 驱动:`tools/terminal/EscTestRunner`(.NET 控制台程序,引用 `TCode.Terminal.Core` 与 `TCode.Terminal.Pty`)。参照 Ghostty `test/esctest` 的做法 [已核实 ghostty@b3dc3d7 `test/esctest/README.md`、`src/main.zig`]: + 1. 在真实 PTY 中启动 `python3 esctest.py --expected-terminal=xterm --xterm-checksum=411 --xterm-reverse-wrap=411 --max-vt-level=5 --logfile=`; + 2. 屏幕 80×25(esctest 每个测试前重置到这个尺寸); + 3. 输出全部写入 `TerminalCore`;核心通过 `WriteToPty` 回写的应答写回 PTY; + 4. 开启 DECRQCRA 校验和应答:`GHOSTTY_TERMINAL_OPT_XT_CHECKSUM_REPORT`(默认关闭,因为程序可以借此探测屏幕内容)与 `GHOSTTY_TERMINAL_OPT_XT_CHECKSUM_EXTENSION = 23`(xterm 334+ 的算法)[已核实 `include/ghostty/vt/terminal.h:2471-2505`]; + 5. DA 应答为 VT520 级别;XTWINOPS 尺寸报告开启。 +- 这两个选项只在 `TerminalCoreOptions.TestMode` 中开启,产品构建的门面不暴露。 +- esctest2 本身:pin 一个提交(`native/ghostty/esctest.json` 中记录 URL + 提交 + SHA-256),CI 下载 tarball,只运行 Python 脚本,没有构建步骤。 +- 结果处理:解析日志中的 `tests passed` 汇总与失败用例名 → 与 `tests/TCode.Terminal.Core.Tests/EscTest/known-failures.txt` 比较: + - 新增失败 → 作业失败(PR 门禁); + - known-failures 中的用例通过了 → 作业警告,提示从列表中删除; + - 汇总写入 GitHub 步骤摘要,完整日志作为工件上传。 +- 已知会失败的类别(Ghostty 同样遇到):DECSTR 没有完整实现导致的状态残留;终端不改变尺寸导致 XTWINOPS / DECCOLM 相关测试失败 [已核实 Ghostty esctest README]。TCode 的驱动可以真正执行 resize(我们有完整的连接层),所以第二类有机会通过。 +- 验收门槛(research/03 §9.3):xterm 模式下 Tier 0 / Tier 1 相关测试文件通过率 ≥ 95%。 + +#### 1.1.5 fuzz + +**SharpFuzz 评估** [推断]:SharpFuzz 通过改写托管程序集的 IL 插入覆盖率探针(需要 .NET 8+,配合 AFL 或 libFuzzer)[已核实 https://github.com/Metalnem/sharpfuzz 页面;"改写 IL"一点页面未明说,按其依赖 Mono.Cecil / dnlib 推断]。对 TCode 的问题:①VT 解析与网格都在 libghostty(原生 Zig)里,IL 插桩看不到它的分支,覆盖率反馈几乎只来自门面的薄层;②插桩后的程序集以 JIT 运行,无法用于 NativeAOT。因此**不采用**。 + +采用的方案: + +| 手段 | 内容 | 运行 | +|---|---|---| +| 上游语料回放 | 上游 `test/fuzz-libghostty/corpus/{stream,parser,osc}-{initial,cmin}` [已核实目录存在]。`stream` 语料的**第一个字节**是上游驱动用来选择 SIMD / 标量路径的选择位 [已核实 `test/fuzz-libghostty/README.md`],回放时丢弃。每个文件按 1 字节、随机块各喂一次 | 每次 PR(cmin 子集,约数百个文件,< 30 s);每晚全量 | +| 结构化随机生成器 | `Fuzz/VtGenerator.cs`:在合法序列模板(CSI、OSC、DCS、APC、SGR 冒号子参数、Kitty 图形、UTF-8 多字节、组合字符、ZWJ 序列)中随机插入 ESC / CAN / SUB / C1 / 超长参数 / 超长 OSC / 截断;穿插 `Resize`、`Reset`、视口滚动、选区、搜索、快照编码 → 解码 | PR:固定 20 个种子 × 1 MiB;每晚:随机种子 1 h,失败时打印种子 | +| 不变量 | 不抛异常、不崩溃;原生内存有上限(libghostty 分配器统计);光标在范围内;宽字符两半成对;`EncodeSnapshot` → `Restore` 后网格相同;增量帧 == 全量帧(§1.1.2) | 每个输入 | +| 崩溃回归 | 发现的输入最小化后放进 `Fixtures/fuzz-regressions/*.bin`,永久回放 | 每次 PR | +| 上游 AFL++ | 不在我们的 CI 中运行(上游已经在跑 [已核实])。升级 Ghostty 时检查上游 fuzz 目录的变更 | — | + +原生崩溃会带走测试进程:fuzz 测试在**子进程**中运行(`tests/TCode.Terminal.Core.Tests` 以 `--fuzz-child ` 启动自身),父进程收集退出码与 stderr,得到崩溃时可以复现的种子。 + +### 1.2 L2 PTY + +`tests/TCode.Terminal.Pty.Tests`,全部使用真实 PTY 和真实进程,不 mock。**用例清单以 [03-pty.md](03-pty.md) §14(T1–T19、W-T1–W-T6)为准**,下表是分组概览与对应关系。 + +| 组 | 用例 | 依据 | +|---|---|---| +| 往返 | 启动 `/bin/sh -c 'printf hello'` / `cmd /c echo hello`,读到 `hello`;输入回显 | research/02 §9 M1 | +| 控制终端 | 子进程 `tty` 输出 `/dev/ttys*`/`/dev/pts/*`;`ps -o tpgid` 与 `tcgetpgrp(master)` 一致;macOS 上经 helper 获得控制终端 | research/02 §2.2–2.3 | +| 退出码 | `exit 7` → 7;`kill -TERM $$` → signal=TERM;早于 watch 注册就退出的进程(kqueue `ESRCH` 竞态);RHEL 8 内核回退路径(`TCODE_PTY_FORCE_WAITPID=1` 强制) | research/02 §2.5 | +| 信号默认值 | `bash -c 'yes \| head -1; echo ${PIPESTATUS[0]}'` 输出 `141`(PTY 路径与 helper `--exec` 路径各一次;`trap - PIPE` 对 bash 无效,03 §12 实测) | 03 §14 T3、T19 | +| 与 .NET SIGCHLD 共存 | 同一进程中同时运行 `Process.Start` 与 PTY 子进程各 100 个,全部正确回收,`Process.ExitCode` 不受影响 | research/02 §1 第 3 条 | +| resize | `TIOCSWINSZ` 后子进程 `stty size` 与像素尺寸(`ws_xpixel = cols × cellWidth`,01 已改为每 cell 像素)正确 | 01 §6.1;04 §19 第 10 条 | +| 读模型 | macOS gather:`cat` 64 MiB 文件的读取块大小分布(应明显大于 1 KiB);EOF / HUP 语义 | research/02 §2.7 | +| 关闭语义 | SIGHUP 进程组 → 2 s → SIGKILL;后台进程(`sleep 100 &` + `disown`)的处理;关闭后无僵尸(`waitpid` 检查) | research/02 §2.4 | +| fd 泄漏 | 子进程 `ls /proc/self/fd`(Linux)/ `lsof -p`(macOS)中除 0/1/2 外没有 master 或其他 fd | research/02 §4 U6 | +| 前台进程与 cwd | `sleep 5` 期间前台进程名为 `sleep`;`cd /tmp` 后 cwd | research/02 §2.9 | +| ConPTY | 随包 `conpty.dll` + `OpenConsole.exe` 被加载(检查加载路径,而不是 System32);DA1 握手;`ClosePseudoConsole` 在旧系统路径上不死锁(关闭期间持续读输出);`ReleasePseudoConsole`;`ClearPseudoConsole`;GLYPH_WIDTH 标志 | research/02 §3 | +| 压力(每晚) | `cat` 1 GB;并发打开 50 个会话;快速开关 500 次;TUI resize 风暴 | 03 §14 T11–T14 | +| helper | `--detach`(新会话、无控制终端、fd 只剩 0–2)、`--exec`;Linux helper 为 musl 静态 | 03 §14 T18、T19 | +| RHEL 8 | 在 `almalinux:8` 容器中运行 AOT 冒烟(glibc 2.28) | 03 §14 test-rhel8;12 §5.3 | + +平台条件:xunit v3 的 `[Fact(SkipUnless = nameof(Platform.IsUnix))]` 或在测试体中 `Assert.SkipUnless(...)`(v3 支持动态跳过)[推断:以 xunit.v3 3.2.2 的 API 为准]。 + +### 1.3 L3 pty host 协议 + +`tests/TCode.Terminal.Host.Tests`。host 在测试进程内以库方式启动(`PtyHostServer.StartInProcess(options)`),另有一组用例启动**构建出的可执行文件**(`TCODE_PTYHOST_DIR`,与 `TCODE_AGENTHOST_DIR` 的做法相同 [已核实 `src/TCode.Desktop/Services/AgentHostConnection.cs:54-58`]);设置 `TCODE_TEST_PTYHOST_EXE` 时改用 AOT 发布的二进制(与现有 `TCODE_TEST_HOST_EXE` 的做法相同 [已核实 `tests/TCode.Agent.Tests/RpcEndToEndTests.cs:13-18`])。 + +| 组 | 用例 | +|---|---| +| 帧 | 12 字节头的编解码;超长帧、深层嵌套 JSON、未认证时超过 16 KiB 的帧都被拒绝并断开(04 §3、§14) | +| 握手与认证 | HMAC 双向挑战;错误令牌;受限令牌的 scope;`auth/revoke` 断开连接;对端 uid / SID 校验(在 CI 上只能测"同用户通过") | +| 单例与发现 | 锁可得 = host 已死;陈旧 json 清理;`instanceId` 不一致拒绝;两个 Desktop 同时启动只产生一个 host | +| 流控 | 256 KiB 暂停读 PTY(用 `yes` 洪流 + 不 Ack 的客户端验证子进程 `write` 阻塞);Ack 后 < 64 KiB 恢复;**慢订阅者 10 s 后降级并收到 `session/resync`**(01 §6.4 已改);Agent 模式订阅不影响流控 | +| 重连 | attach 时 `S = head` 原子性:并发洪流中反复 attach / detach 1000 次,每次"快照 + Output(≥ S)"重建的网格 == 镜像核心网格(不重不漏) | +| 一致性 | Resize / SetColors / Reset / Clear 记录在两侧核心的同一位置生效:UI 侧核心网格 == 镜像核心网格(`.grid` 比较) | +| 多客户端 | 两个 UI 客户端 + 一个 agent 客户端;尺寸所有者切换;剪贴板读取只发给主视图 | +| 持久化 | revive 写入与恢复;裁剪到 N 行;`snapshot.bin` 不兼容时回退 `tail.vt`;文件权限 0600 / 目录 0700 | +| 生命周期 | 宽限期(用可注入的时钟);`host/quit` 三种模式;host 被 SIGKILL 后 Desktop 侧"会话已丢失" | +| Agent 命令 | 09 §16 中 A1–A13 的协议级部分 | + +### 1.4 L4 视图截图(Headless + Skia) + +沿用 TCode 现有做法:`AppBuilder.UseSkia().UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })`,`TopLevel.CaptureRenderedFrame()` 截图,`TCODE_SNAPSHOT_DIR` 指定输出目录 [已核实 `tests/TCode.Desktop.Tests/HeadlessApp.cs`]。 + +- **输入来源**:`TerminalView.Session` 接一个 `ReplayTerminalViewSession`(读取 `.cast`),不启动 PTY,不合成任何键鼠事件(遵守项目约定)。 +- **覆盖**:box drawing 与块元素的拼接(每个字号、1×/1.5×/2× 缩放)、Powerline 符号、光标四种形状与闪烁相位、宽字符与 emoji(含 ZWJ、VS16、旗帜)、下划线五种样式与颜色、删除线 / 上划线、超链接下划线、选区、搜索高亮、最小对比度、暗淡、反显、Kitty 图形放置(M6)。 +- **比较方式**:像素比较不能跨平台逐字节一致(字体栅格化不同)。规则: + 1. 每个平台各自一套基线 PNG:`tests/TCode.Terminal.Avalonia.Tests/Baselines//.png`; + 2. 比较采用"允许每像素 ΔE ≤ 2 且不同像素 ≤ 0.1%"的容差; + 3. **几何断言优先**:对 box drawing、光标、下划线这类程序化绘制的元素,直接断言像素坐标(例如"└ 的竖线在 cell 的第 x 列像素,且与上一行的 │ 连续"),这类断言跨平台成立; + 4. 字体:测试使用随测试项目分发的固定字体(JetBrains Mono,OFL),不依赖系统字体。 +- **帧构建耗时**:同一组用例在 Headless 下记录 `Render` 的 CPU 耗时,作为 S4 的辅助数据(Headless 不代表 GPU 性能,research/04 §9.1)。 +- **R1(`CompositionCustomVisual`)能否参与 `CaptureRenderedFrame`** 是 research/04 §11 第 7 条风险:M6 之前先写一个最小用例验证;不能截图时,R1 的截图测试改为直接调用 `TerminalPainter` 画到离屏 `SKSurface`。 + +### 1.5 L5 端到端 TUI 场景 + +#### 1.5.1 录制 + +在开发机上用 **TCode 自己的录制功能**(pty host 的调试开关 `TCODE_PTYHOST_RECORD=

`:把每个会话的输出日志按 §2.1 格式落盘,包括 Resize 记录)或 `asciinema rec`(v2 格式)录制。录制脚本 `tools/terminal/record/*.sh` 记录了步骤,便于重录: + +| 夹具 | 步骤(人工操作,录制 PTY 输出) | 尺寸 | 关注点 | +|---|---|---|---| +| `vim-edit` | `vim -u NONE -N sample.c`:移动、插入一行、`:set number`、分屏 `:vsp`、`:q!` | 100×30 | 滚动区、备用屏幕、DECSET 1049、光标形状 | +| `nvim-startup` | `nvim --clean`(产生 research/03 附录 B 的全部启动查询) | 100×30 | DECRQM、DECRQSS、Kitty 键盘查询、OSC 11 | +| `htop-3frames` | `htop` 刷新 3 次后 `q` | 120×40 | 全屏重画、颜色、2026 | +| `tmux-split` | `tmux -f /dev/null new` → 横竖分屏 → 每格运行 `ls --color` → 退出 | 120×40 | 滚动区、DECSLRM、外层查询 | +| `less-page` | `less -R` 一个带颜色的 2000 行文件,翻 5 页、搜索 | 100×30 | 备用屏幕、反显 | +| `fzf-filter` | `seq 1 5000 \| fzf`,输入 `42`,回车 | 100×30 | 非备用屏幕的局部重画、bracketed paste | +| `claude-code-turn` | Claude Code 启动 → 输入一句话 → 完成一轮 → `/exit` | 120×40 | Kitty 键盘、2026、OSC 8、OSC 9;4、宽字符 | +| `codex-turn` | Codex 启动 → 一轮 → 退出 | 120×40 | CPR、OSC 10/11、DA1/DA2、`CSI ? u`、1007 | +| `zsh-completion` | zsh 中 Tab 补全菜单 | 100×30 | 提示符重画 | + +**Claude Code 与 Codex 的录制不得发起真实的模型调用**(研究 03 §9.2):使用各自的离线 / mock 模式;若没有,录制者本人在本地完成一次录制即可(录制一次、入库永久回放,CI 中只回放字节流,不运行这两个程序)。录制文件入库前人工检查并删除路径、用户名、令牌等敏感内容。 + +#### 1.5.2 回放与快照 + +- 测试读 `.cast` 回放,在**若干关键点**(`.cast` 中的 `m` 标记事件,§2.1)各取一次 `.grid`,与期望比较。期望文件 `.grid` 中按标记名分段。 +- 同一夹具同时用于 §1.1.2 的差分、§1.4 的截图(取最后一个标记)和 §3 的基准(S4)。 +- 跨平台:`.grid` 在三个平台上必须逐字节一致(research/03 §9.2 第 2 条)。 + +#### 1.5.3 真实 shell 冒烟(L5b) + +`tests/TCode.Terminal.Host.Tests/Shell/`:真实 pty host + 真实 shell(bash、zsh、fish(若安装)、pwsh、Git Bash),只向 PTY **写字节**: + +- shell 集成注入成功,OSC 133 A/B/C/D 与退出码到达,`CommandRecord` 正确; +- `echo $TERM $COLORTERM $TERM_PROGRAM`; +- 多行粘贴(bracketed paste)与 `ESC[201~` 注入防护; +- `exit` 后会话结束、退出码; +- macOS 上 login shell 的 argv0(`ps -o comm`)。 + +### 1.6 跨平台"与系统一致"检查清单(手工,L6) + +每个发布版本在三台实机上执行一次,结果记录在发布说明的附件中。对照对象:macOS = Terminal.app / iTerm2;Windows = Windows Terminal;Linux = GNOME Terminal(GNOME)/ Konsole(KDE)。 + +| 类 | 检查项(预期:与对照终端相同) | 依据 | +|---|---|---| +| 启动 | 默认 shell 取自账户记录(`getpwuid`);macOS login shell(`ps` 中 `-zsh`);Linux 非 login;Windows 优先 pwsh;新标签继承 cwd | research/05 §1、D9 | +| 环境 | `TERM=xterm-256color`、`COLORTERM=truecolor`、`TERM_PROGRAM=TCode`;`LANG` 处理;PATH 与系统终端一致(macOS 的 `path_helper`) | research/05 §2 | +| 键盘 | macOS:⌘C/⌘V/⌘K(清屏)/⌘T/⌘W;Option 作 Meta 的默认值;Windows / Linux:Ctrl+字母交给 shell,Ctrl+Shift+C/V;Ctrl+C 中断;Alt+Backspace;Home/End/PgUp/PgDn | D6、research/05 §4 | +| 鼠标 | 双击选词规则、三击选行;Linux 中键粘贴 PRIMARY;右键菜单;Shift 强制本地选择 | research/05 §4 | +| 剪贴板 | 复制时去除软换行;粘贴多行警告 | 01 §8 | +| 外观 | 默认字体与字号;光标形状与闪烁;铃声(系统提示音 / 视觉) | research/05 §5 | +| 输入法 | 中文、日文 IME 的组合窗口位置与提交 | research/04 §6.2 | +| 生命周期 | 有程序运行时关闭标签确认;退出 TCode 的汇总确认;revive 恢复 | 01 §3.3 | +| 程序 | vim、tmux、htop、less、fzf、neovim、Claude Code、Codex 的日常操作 | 01 §1 | +| vttest | 菜单 1–8、11 人工通过 | research/03 §9.3 | +| ucs-detect | `ucs-detect --save-yaml`;2027 模式下 WIDE / ZWJ / VS16 / VS15 ≥ 98%;与 Ghostty 的 data/*.yaml 对照 | research/03 §9.1、§9.3 | +| 无障碍 | VoiceOver / Narrator / Orca 能读出新输出 | research/04 §6.3 | + +--- + +## 2. 测试工具与文件格式 + +### 2.1 字节流:asciicast v2 + TCode 扩展 + +格式规范 [已核实 https://docs.asciinema.org/manual/asciicast/v2/]:NDJSON;首行为头(必填 `version: 2`、`width`、`height`;可选 `timestamp`、`command`、`title`、`env`、`theme`);之后每行一个事件 `[time, code, data]`,`code` 为 `o`(输出)、`i`(输入)、`m`(标记)、`r`(尺寸,`"COLSxROWS"`);**事件码表是开放的,读取方应忽略不认识的事件码**;`data` 必须是合法的 UTF-8 JSON 字符串。 + +问题:PTY 输出不一定是合法 UTF-8,且 UTF-8 字符可能跨块;asciicast 的字符串无法逐字节保真。TCode 的约定: + +| 事件 | 含义 | 何时使用 | +|---|---|---| +| `o` | 输出,UTF-8 文本 | 块是合法 UTF-8 且不在字符中间截断 | +| `ob` | **TCode 扩展**:输出,`data` 为原始字节的 base64 | 其他情况(含跨块截断)。其他播放器会忽略它,所以这类文件只用 TCode 回放 | +| `r` | resize,`"COLSxROWS"`;TCode 扩展可选第二种写法 `"COLSxROWS@CWxCH"`(每 cell 像素,与 01 的 `TerminalSize` 定义一致) | | +| `m` | 标记,`data` 为快照点名称 | `.grid` 按标记分段 | +| `i` | 输入 | 只用于录制说明,回放时忽略(输入的效果已在输出中) | +| `xc` | **TCode 扩展**:`SetColors`,data 为 JSON 调色板 | 对应 pty host 日志的 SetColors 记录 | +| `xr` | **TCode 扩展**:`Reset`(`"hard"`/`"soft"`) | | + +头部扩展字段(读取方忽略未知字段):`"tcode": {"widthMode": "compat", "scrollback": 1000, "palette": "default-dark", "source": "xtermjs:vttest-2.in", "license": "MIT"}`。 + +时间戳:回放测试不使用时间(按顺序写入);基准与 S4 的"按真实节奏回放"使用时间。 + +为什么不用 asciicast v3(2025-09 发布,使用相对时间间隔、新增 `x` 退出事件 [已核实 https://docs.asciinema.org/manual/asciicast/v3/]):v3 与 v2 不兼容,`asciinema` 2.x 与很多现有素材是 v2;v2 足够。读取器同时支持 v3(多 10 行代码),写出统一为 v2。 + +### 2.2 网格快照:`.grid` + +要求:纯文本、可读、可 diff、跨平台逐字节一致(LF、UTF-8、无 BOM)、与 libghostty 的内部布局无关(以 `FrameSnapshot` 为准,01 §6.1)。 + +``` +tcode-grid 1 +size 80x24 cell 9x18 +cursor 12,3 shape=block visible=1 blink=0 wide=0 +screen primary +modes decckm=0 deckpam=0 2004=1 1004=0 mouse=none kitty=0 2026=0 2027=0 1049=0 origin=0 autowrap=1 insert=0 +margins top=0 bottom=23 left=0 right=79 +title "vim sample.c" +cwd file://host/tmp +styles + s0 default + s1 fg=1 bold + s2 fg=#ff8800 bg=default underline=curly ul-color=#00ff00 + s3 fg=4 link=1 +links + l1 id="" uri="https://example.com" +rows + 0 |$ ls --color | s0 + 1 |src tests README.md | s3:0-2 s0:3-4 s3:5-9 s0:10-79 + 2 |中文测试 ✓ | s0 w:0,2,4,6 + 14 |~ | s1:0 s0:1-79 wrap +scrollback 3 + -3 |old line 1 | s0 +semantic + prompt rows=0 input=0:2-13 output=1-1 exit=0 +---- mark: after-insert +... +``` + +规则: + +| 项 | 规则 | +|---|---| +| 行文本 | 两个 `\|` 之间是该行全部列,**不截去行尾空格**(列宽可见);宽字符占两列,第二列写 `⁣`(不可见分隔符,以免显示错位),行末 `w:` 列出宽字符起始列 | +| 字形簇 | 多码点字形簇整体写在起始列;`g:` 列出起始列与码点(`g:5=U+1F468,U+200D,U+1F469`) | +| 不可打印 | 控制字符不会出现在网格中;空 cell 写空格,`e:` 列出"真正为空(从未写入)"的游程,与写入空格区分 | +| 样式 | 每帧一张去重的样式表 `sN`;行尾为游程 `sN:起-止`,整行同一样式时只写 `sN`;颜色:调色板索引写十进制,真彩写 `#rrggbb` | +| 软换行 | 行尾 `wrap` | +| 回滚 | 默认只写最后 `scrollback N` 行(N 由测试指定,默认 0);行号为负 | +| 语义 | OSC 133 区间与退出码 | +| 多段 | 每个 `m` 标记一段,以 `---- mark: ` 分隔 | + +实现:`TCode.Terminal.Core` 中的 `GridSnapshot.Capture(TerminalCore core, GridSnapshotOptions options)` → `string`,属于测试辅助,但放在主程序集的 `TCode.Terminal.Core.Testing` 命名空间(L3、L4、Agent 测试都要用,且必须 AOT 安全,`tcode-ptyhost --self-test` 也用它)。 + +### 2.3 在 xunit v3 中的组织 + +TCode 现有测试项目是 xunit v3 3.2.2 + `Microsoft.NET.Test.Sdk`,项目 `OutputType=Exe` [已核实 `Directory.Packages.props`、`tests/TCode.Desktop.Tests/TCode.Desktop.Tests.csproj`]。新增测试项目沿用同样配置。 + +| 机制 | 用法 | +|---|---| +| 数据驱动 | 夹具枚举:`public static TheoryData Fixtures => FixtureCatalog.Enumerate("tui")`;`[Theory, MemberData(nameof(Fixtures))]`。每个夹具是一个独立的测试用例,失败时显示夹具名 | +| 分类 | `[Trait("Category", "Golden" \| "Diff" \| "Fuzz" \| "FuzzLong" \| "EscTest" \| "Pty" \| "Stress" \| "Snapshot")]`。PR 作业排除 `FuzzLong`、`Stress`;每晚全部运行。过滤:`dotnet test -- --filter-not-trait "Category=FuzzLong"`(xunit v3 原生命令行)[推断:以 xunit.v3 runner 实际参数为准] | +| 平台条件 | `Assert.SkipUnless(OperatingSystem.IsWindows(), "ConPTY")` 动态跳过 | +| 取消与超时 | 每个 PTY / host 用例使用 `TestContext.Current.CancellationToken` 并设 30 s 超时;超时时 dump 会话日志 | +| 夹具复制 | `Fixtures/**` 设为 `CopyToOutputDirectory=PreserveNewest`;`.cast`、`.grid` 在 `.gitattributes` 中设为 `-text`(禁止 CRLF 转换) | +| 并行 | L1 可并行;L2/L3/L4 设 `[assembly: CollectionBehavior(DisableTestParallelization = true)]`(与 `TCode.Desktop.Tests` 相同 [已核实 `HeadlessApp.cs:6`]),避免 PTY 与时钟竞争 | +| 工件 | 失败时写 `artifacts/test-results//`:actual `.grid`、diff、截图、host 日志;CI 统一上传 | + +--- + +## 3. 基准 + +### 3.1 项目结构 + +``` +tests/TCode.Terminal.Benchmarks/ + TCode.Terminal.Benchmarks.csproj OutputType=Exe;BenchmarkDotNet;引用 Core、Protocol、Host(库方式)、Avalonia(R1/R3 绘制) + Program.cs BenchmarkSwitcher;--data ;--profile pr|nightly + Config/ + JitAotConfig.cs 两个 Job:Jit(默认 RyuJIT + 动态 PGO)、NativeAot(BDN 的 NativeAOT 工具链) + PrConfig.cs ShortRun 风格:1 次启动、3 次预热、5 次迭代 + Core/ + ParseThroughputBenchmarks.cs S1、S2、S3、S5、S6(关闭渲染:只 vt_write) + SnapshotBenchmarks.cs FrameSnapshot 发布(全脏 / 1–3 行脏)、EncodeSnapshot / Restore(10k / 100k 行) + ReflowBenchmarks.cs S7 的核心部分 + MemoryBenchmarks.cs S9(自定义列:原生字节数,通过 libghostty 分配器统计) + FormatterBenchmarks.cs Agent 读取输出(09 §5):1k / 50k 行命令输出的 plain 格式化 + Ipc/ + FrameCodecBenchmarks.cs 帧编解码 + HostThroughputBenchmarks.cs S13:host → 客户端的端到端字节吞吐(进程内 socket 对) + AttachBenchmarks.cs S14:attach 快照耗时 + Render/ + PainterBenchmarks.cs S4:200×60 全量重画、1–3 脏行(Headless Skia,离屏 SKSurface) + E2E/ 不用 BDN:独立的计时驱动,见 §3.5 + E2EDriver.cs 启动 Desktop(--bench-mode),回放数据集,读 metrics 文件 +``` + +- 数据集路径通过 `--data` 或环境变量 `TCODE_BENCH_DATA` 传入,默认 `artifacts/bench-data/`。 +- 基准中的"关闭渲染"= 只调 `TerminalCore.Write`,不调 `CaptureFrame`。另有一组"开启渲染"= 每 8 ms(模拟 120 Hz)调用一次 `CaptureFrame` + Painter。 +- 每个基准都加 `[MemoryDiagnoser]`;热路径基准另加 `[DisassemblyDiagnoser]`(只在 JIT 作业中有意义)。 +- BenchmarkDotNet 的版本:使用支持 .NET 10 与 `nativeaot10.0` 运行时标识的版本,进入 `Directory.Packages.props` [推断:落地时选当时的最新稳定版]。 + +### 3.2 JIT 与 NativeAOT 两种工具链 + +产品发布的是 NativeAOT(项目约定),而 AOT 没有动态 PGO、默认指令集基线较低(research/04 §9.1、§11 第 5 条),所以两者都跑: + +``` +dotnet run -c Release --project tests/TCode.Terminal.Benchmarks -- --runtimes net10.0 nativeaot10.0 --filter '*Parse*' +``` + +- NativeAOT 作业:BDN 为每个基准类生成并发布一个 AOT 程序,首次运行较慢(每个类一次 ILC),所以 PR 作业只对 S1/S3/S5 跑 AOT。 +- 指令集:AOT 作业另加一个 `IlcInstructionSet=native` 的变体,与默认基线对比,判断是否值得在发布时按 RID 指定 `IlcInstructionSet`(例如 x64 用 `x86-x64-v2`)。不过解析主路径在 libghostty(原生)中,托管侧的向量化主要影响 `FrameSnapshot` 复制与绘制;结论写回本文 [推断]。 +- libghostty 本身的 SIMD 开关(`-Dsimd`)对吞吐影响很大:12 号文档中 win-x64 可能使用标量构建。基准报告必须注明所用原生库的 `ghostty_build_info(SIMD)` [已核实 `include/ghostty/vt/build_info.h:60`]。 + +### 3.3 场景与数据集 + +S1–S12 来自 research/04 §9.2;S13–S15 为本文新增(pty host 架构 D3 引入的路径)。 + +| # | 场景 | 数据集(生成方式) | 测量 | 通过线 | +|---|---|---|---|---| +| S1 | `cat` 大文件 | `ascii-src-100m.txt`:拼接 dotnet/runtime 与 ghostty 源码(固定提交的 tarball,按文件名排序拼接到 100 MiB);`ascii-150m.txt`:生成器输出 150 MiB 可打印 ASCII,80 列换行 | BDN:解析吞吐(关闭渲染);E2E:从 host 读到最后一字节到 UI 帧呈现的总耗时 | 核心 ≥ 500 MB/s(libghostty,SIMD 构建);端到端 ≥ 关闭渲染时的 0.8×;150 MB ≤ 1 s | +| S2 | `seq` | `seq-10m.txt`:`seq 1 10000000` 的输出(约 78 MB),生成器内置,不调用系统 `seq` | 每行开销(滚动、换行) | ≥ 200 MB/s | +| S3 | 彩色密集 | vtebench `dense_cells`、`medium_cells`(运行 vtebench 生成器脚本一次、录下 stdout);`truecolor-gradient.vt`(生成器) | SGR 解析与样式去重;分配 | ≥ 100 MB/s;稳态零分配 | +| S4 | 全屏刷新 | `tui/vim-edit`、`tui/htop-3frames`(§1.5,按真实时间回放);vtebench `sync_medium_cells` | 每帧脏行数、绘制耗时 p50/p99、2026 期间无撕裂(帧序列中不出现半更新的帧) | 绘制 p99 ≤ 2 ms(200×60,2×);1–3 脏行 ≤ 0.3 ms | +| S5 | Unicode | `ja-5m.txt`:青空文库公版作品拼接到 5.4 MiB(与 Ghostty devlog 的规模相当,公版、无许可问题);`emoji-zwj.vt`(生成器:ZWJ 家庭、旗帜、肤色、VS16);`combining.vt` | 宽度、字形簇、回退字体首帧不阻塞 | ≥ 100 MB/s | +| S6 | 光标移动 | vtebench `cursor_motion`、`scrolling_bottom_region`、`scrolling_top_region` | CSI 快路径 | 不低于同机 Alacritty | +| S7 | resize 与 reflow | 回滚 1 万 / 10 万行(`ascii-src` 前缀),80↔200 列每 16 ms 切换,持续 5 s | reflow 耗时、UI 帧时间、PTY 暂停时长 | 1 万行 ≤ 15 ms;10 万行 ≤ 150 ms;UI 不掉帧 | +| S8 | 输入延迟 | 不需要数据集;后台洪流用 `yes` | 自动:内部分段时间戳(§3.5);人工:Typometer(§3.6) | 自身附加 p50 ≤ 1 ms;端到端均值 ≤ 对照组 + 2 ms | +| S9 | 内存 | 1 万 / 10 万行的纯文本、彩色(dense_cells 截取)、CJK(ja-5m 截取) | 托管堆、原生页、压缩后大小 | 每 1 万行 × 120 列 ≤ 10 MB,压缩后 ≤ 3 MB | +| S10 | 空闲 | 10 个终端空闲,光标闪烁,10 min | CPU、唤醒次数 | 合计 < 0.5% CPU | +| S11 | 背压 | `yes` 运行 30 s 后发 `^C`(写字节,不合成按键) | 内存是否有界;`^C` 到提示符出现的时间 | 内存平稳;≤ 100 ms | +| S12 | 与 Agent UI 共存 | 终端 S1 洪流 + StreamMark 流式渲染长 Markdown(TCode 现有的 Fake 模型流式输出) | 两边的帧时间 | 终端帧率不受 UI 线程负载影响(R1 阶段的验收,M6) | +| S13 | host → UI 吞吐(新增) | S1 数据经真实 pty host(`cat` 文件)→ IPC → UI 侧核心 | 吞吐、Ack 往返、暂停次数 | 不低于单进程核心吞吐的 0.7× [推断:目标值] | +| S14 | attach / 重连(新增) | 10 k / 100 k 行回滚的会话 | `session/attach` 到 READY 段可渲染的时间;全部回滚到达的时间 | READY ≤ 50 ms;10 k 行快照编码 < 20 ms(04 §18 第 1 条) | +| S15 | Agent 读取(新增) | 50 k 行命令输出 | `command/output` 的 plain 格式化耗时(09 §5.4 每 250 ms 调用) | ≤ 5 ms / 64 KiB [推断:目标值] | + +**数据集的存放与生成**: + +- 生成器:`tools/terminal/gen-bench-data/`(C# 单文件程序,`dotnet run`)。所有内容确定性生成(固定种子);需要外部来源的(dotnet/runtime 与 ghostty 源码 tarball、青空文库文本、vtebench 仓库)按 `tools/terminal/gen-bench-data/sources.json` 中的 URL + SHA-256 下载并校验。 +- 输出:`artifacts/bench-data/`(`.gitignore`),附 `manifest.json`(文件名、大小、SHA-256、生成器版本)。仓库只提交 `sources.json` 与期望的 `manifest.json`;生成后校验一致,否则报错。 +- CI:数据集按 `manifest.json` 的哈希作为缓存键缓存(12 号文档 §7)。PR 短作业只用 ≤ 16 MiB 的子集(`--profile pr` 时生成器输出 `*-pr` 变体)。 +- vtebench 生成器是 shell 脚本:在 Linux 上运行一次录下 stdout 即可,结果是确定的字节流;录下的文件也纳入 `manifest.json` [推断:需确认 vtebench 各脚本不含随机内容]。 + +### 3.4 CI 门禁 + +GitHub 托管 runner 的性能噪声通常在 ±10% 以上 [推断],直接与历史基线 JSON 比较会频繁误报。做法: + +| 作业 | 内容 | 判定 | +|---|---|---| +| **PR:`bench-pr`** | 在同一台 runner 上依次构建并运行 **base(目标分支的合并基)** 与 **head** 两个版本的 S1、S3、S5 核心吞吐、快照发布、帧编解码(JIT + AOT),短作业 | head 的中位数比 base 差 > 10%,且 BDN 报告的置信区间不重叠 → 失败;5–10% → 警告注释。改了原生库版本(Ghostty 升级)的 PR 自动标记"需要人工审阅基准",不自动失败 | +| **每晚:`bench-nightly`** | 完整 S1–S11、S13–S15(JIT + AOT),Linux x64 与 macOS arm64 | 结果 JSON 上传为工件,并追加到 `bench-history` 分支的 `results//`;与 7 天中位数相比回退 > 10% 时开 issue | +| **手动:`bench-e2e`** | S4、S8 自动部分、S10、S11、S12(需要真实窗口与 GPU) | 在固定的自托管机器上运行(若有);否则开发者本地运行 `tools/terminal/bench-e2e.sh` | + +建议有条件时配一台固定硬件的自托管 runner(Apple Silicon Mac mini)专跑 `bench-nightly`,GitHub 托管 runner 只做 A/B。 + +### 3.5 端到端指标采集 + +**为什么不直接用 EventCounters**:`EventSource` / `EventCounters` 在 NativeAOT 中由 `EventSourceSupport` 特性开关控制,AOT 发布默认关闭 [推断:需核对 .NET 10 的默认值;Microsoft 文档的 trimming 选项列表中 `EventSourceSupport` 在 AOT 下默认 false]。打开它会增加体积和启动成本,所以产品构建不开。 + +方案: + +1. 代码中只用 `System.Diagnostics.Metrics.Meter`(AOT 安全):`Meter("TCode.Terminal")`,仪表: + - `terminal.bytes_parsed`(Counter,按 view/mirror 标签); + - `terminal.snapshots_published`、`terminal.frames_rendered`(Counter); + - `terminal.render_ms`、`terminal.snapshot_us`、`terminal.input_latency_us`(Histogram;input 分段:`keydown→pty_write`、`pty_write→echo_parsed`、`echo_parsed→snapshot`、`snapshot→render_end`); + - `terminal.flow_paused`(Counter,host 侧)、`terminal.ack_rtt_us`(Histogram); + - `gc.gen0`(从 `GC.CollectionCount` 每秒采样)。 +2. 设置环境变量 `TCODE_TERMINAL_METRICS=` 时,进程内 `MeterListener` 每秒把聚合结果(计数、Histogram 的 p50/p95/p99)写成 JSON Lines;未设置时不注册监听器(零开销)。 +3. 需要 `dotnet-counters` / `dotnet-trace` 实时观察时,用 `-p:EventSourceSupport=true` 构建诊断版(JIT 构建本身就支持)。 +4. `E2EDriver` 启动 Desktop(`--bench-mode`:Headless 关闭,真实窗口,打开指定场景的终端标签),按数据集回放,读取 metrics 文件并输出汇总表。 + +### 3.6 Typometer 手工测量流程 + +Typometer 通过向目标窗口发送按键并截取屏幕区域来测量延迟(Java 程序)。它属于**人工**测量:由开发者在自己的机器上运行,**不进入 CI,也不由 Agent 执行**。 + +| 步骤 | 内容 | +|---|---| +| 1 环境 | 接电源、关闭省电与 True Tone / Night Shift、关闭其他应用;显示器原生刷新率记录在结果里;macOS 关闭"降低透明度"之外的视觉效果保持默认 | +| 2 对照组 | 同机安装 Ghostty、Alacritty、kitty、WezTerm;macOS 加 Terminal.app、iTerm2;Windows 加 Windows Terminal;字体、字号、窗口尺寸(100×30)一致 | +| 3 被测状态 | TCode 的终端标签单独占据窗口(工作台其他面板折叠);shell 为 `sh`(避免提示符主题影响);分别测 R3 与 R1 构建 | +| 4 测量 | Typometer 默认设置,每个终端 200 次按键 × 3 轮;第二组在后台运行 `yes > /dev/null`(S8"洪流"条件)——注意这条命令不输出到终端,用来测 CPU 争用;第三组在另一个 TCode 终端标签运行 `yes`(测本进程负载) | +| 5 记录 | min / avg / max / 标准差,写入 `docs/terminal/bench/typometer--.md`(表格 + 环境描述) | +| 6 判定 | TCode 均值 ≤ 同机 Alacritty 或 Ghostty 均值 + 2 ms(research/04 §7.3) | + +--- + +## 4. 数字目标与测量方法 + +来源:01 §1、research/04 §0.7 与 §7.3(标"目标值"的均为 [推断:目标值]),以及 04 号文档的流控参数。 + +| 指标 | 目标 | 测量方法 | 场景 / 作业 | +|---|---|---|---| +| 核心解析吞吐,ASCII,关闭渲染 | ≥ 500 MB/s(libghostty SIMD 构建,01 §1);标量构建另行记录,不设门槛(见待决问题) | BDN `ParseThroughputBenchmarks`,数据在内存中,只调 `Write` | S1 / bench-pr、nightly | +| dense SGR | ≥ 100 MB/s | 同上,vtebench `dense_cells` | S3 | +| Unicode / CJK | ≥ 100 MB/s | 同上,`ja-5m.txt` | S5 | +| 端到端 `cat`(开启渲染) | ≥ 关闭渲染的 0.8×;150 MB ASCII ≤ 1 s | E2EDriver:真实 host + Desktop,`cat` 数据集,`terminal.bytes_parsed` 与墙钟 | S1、S13 / bench-e2e | +| 渲染线程每帧(200×60,2×) | 全量重画 p99 ≤ 2 ms;1–3 脏行 ≤ 0.3 ms | BDN `PainterBenchmarks`(离屏 Skia)+ E2E `terminal.render_ms` Histogram | S4 | +| UI 线程每帧 | ≤ 0.1 ms(R1 阶段) | E2E:Avalonia `Dispatcher` 钩子计时 | S4、S12 | +| 快照发布 | 全脏 ≤ 50 µs;渲染线程等锁时间 = 0 | BDN `SnapshotBenchmarks`;E2E `terminal.snapshot_us` | S4 | +| 输入延迟(自身附加) | KeyDown → Present 提交 p50 ≤ 1 ms,p99 ≤ 1 帧 | `terminal.input_latency_us` 分段直方图(回显由真实 shell 产生;按键事件来自真实键盘,开发者手动打字——不合成输入) | S8 / 人工 | +| 输入延迟(端到端) | 均值 ≤ 同机 Alacritty / Ghostty + 2 ms | Typometer(§3.6) | S8 / 人工 | +| 回滚内存(每 1 万行 × 120 列) | ≤ 10 MB;压缩后 ≤ 3 MB | BDN `MemoryBenchmarks`:libghostty 分配器统计的原生字节 + 托管堆差值 | S9 | +| reflow | 1 万行 ≤ 15 ms;10 万行 ≤ 150 ms;不阻塞 UI 与渲染 | BDN `ReflowBenchmarks`;E2E 帧时间 | S7 | +| 空闲 CPU | 10 个空闲终端合计 < 0.5% | E2E:进程 CPU 时间差 / 墙钟,10 min;macOS 用 `powermetrics` 记录唤醒次数(人工) | S10 | +| GC | 热路径稳态零分配;洪流下 Gen0 ≤ 1 次/s | BDN `MemoryDiagnoser`(Allocated = 0 B);E2E `gc.gen0` | S1、S3 | +| 背压 | 洪流时内存平稳;`^C` 到提示符 ≤ 100 ms | E2E:`yes` 30 s,记录 RSS 曲线;写 `\x03` 后到 133;A 的时间 | S11 | +| 流控参数 | 暂停阈值 256 KiB、恢复 64 KiB、Ack 32 KiB(04 §7.1);慢订阅者最多拖住 10 s(01 §6.4) | L3 测试断言行为;S13 记录暂停次数与 Ack 往返 | S13 | +| attach | READY ≤ 50 ms;10 k 行快照编码 < 20 ms | BDN `AttachBenchmarks` | S14 | +| 与 Agent UI 共存 | 终端帧率不受 UI 线程负载影响 | E2E:S1 洪流 + Fake 模型流式 Markdown,比较有无 UI 负载时的 `terminal.frames_rendered` 速率 | S12 / M6 | +| esctest2 | Tier 0 / Tier 1 文件通过率 ≥ 95% | §1.1.4 | esctest | +| ucs-detect | 2027 模式下 WIDE / ZWJ / VS16 / VS15 ≥ 98% | 人工(§1.6) | L6 | +| fuzz | 1 h 无失败 | §1.1.5 | 每晚 | + +--- + +## 5. 测试与里程碑的对应 + +| 里程碑(01 §10) | 必须通过的测试 | +|---|---| +| M0 Spike | 6 个 RID 加载 libghostty 并通过一个回放用例;PTY 往返(Unix helper + ConPTY);AOT 二进制运行 `vim` 并退出(人工 + 一个 L5b 冒烟) | +| M1 核心与 PTY | L1 全部(黄金样本、差分、应答、编码表、fuzz PR 子集);esctest 进 CI 且门槛达标;L2 全部(压力测试在每晚) | +| M2 pty host | L3 全部;`tcode-ptyhost --self-test` 在 aot-smoke 中运行 | +| M3 视图 | L4 全部;S1、S3、S4、S5 达标;S8 自动部分 | +| M4 集成 | L5b;§1.6 手工清单三平台完成 | +| M5 Agent | 09 §16 全部 | +| M6 R1 + 图形 | S12;Kitty 图形截图;R1 截图方式验证(research/04 §11 第 7 条) | + +--- + +## 6. 待决问题 + +1. **win-x64 的 libghostty 可能使用标量构建**(12 号文档 §4.3),S1 的 500 MB/s 目标在该 RID 上是否放宽,或者要求 SIMD 构建通过 CPU 兼容性验证后再发布。 +2. 是否新增 `tests/TCode.Terminal.Avalonia.Tests`(§1 表后的说明);01 §4 的测试项目列表需要相应补充 `TCode.Terminal.Avalonia.Tests` 与 `tools/terminal/EscTestRunner`。 +3. 有无固定硬件的自托管 runner 跑每晚基准(§3.4)。 +4. Claude Code / Codex 录制夹具的来源:是否允许开发者本人录制一次真实会话入库(不在 CI 中调用模型)。 + +--- + +## 来源 + +- 本仓库:`Directory.Packages.props`;`tests/TCode.Desktop.Tests/HeadlessApp.cs`、`TCode.Desktop.Tests.csproj`;`tests/TCode.Agent.Tests/RpcEndToEndTests.cs`;`src/TCode.Desktop/Services/AgentHostConnection.cs`。 +- Ghostty `ghostty-org/ghostty@b3dc3d7`:`test/esctest/README.md`、`test/esctest/src/main.zig`、`test/fuzz-libghostty/README.md` 与 `corpus/`、`include/ghostty/vt/terminal.h`(XT_CHECKSUM 选项)、`include/ghostty/vt/build_info.h`。 +- asciicast 规范:https://docs.asciinema.org/manual/asciicast/v2/ 、https://docs.asciinema.org/manual/asciicast/v3/ (2026-10-16 读取)。 +- SharpFuzz:https://github.com/Metalnem/sharpfuzz (2026-10-16 读取)。 +- 研究文档:[research/01](research/01-emulator-cores.md) §4.1.5、§4.1.7;[research/02](research/02-pty.md) §2、§3、§5、§9;[research/03](research/03-vt-protocol.md) §9、附录 B;[research/04](research/04-rendering-performance.md) §0.7、§2.9、§7.3、§9、§11;[04-pty-host.md](04-pty-host.md) §7、§16、§18。 diff --git a/docs/terminal/12-build-and-ci.md b/docs/terminal/12-build-and-ci.md new file mode 100644 index 0000000..450ca82 --- /dev/null +++ b/docs/terminal/12-build-and-ci.md @@ -0,0 +1,907 @@ +# TCode 终端:构建与 CI(12) + +> 状态:设计稿 v1(2026-10-17)。上位文档:[01-architecture.md](01-architecture.md)(D1、D3、D9,§4 项目划分,§8 原生库签名,§10 每个里程碑"AOT 发布 0 警告并运行二进制")。相关:[04-pty-host.md](04-pty-host.md) §1.1(`ptyhost/` 发布布局、`ghostty-build.txt`、`--self-test`)、[06-input.md](06-input.md) §3.7(需要 overlay 补丁登记模式 9001)、[07-shell-profiles-config.md](07-shell-profiles-config.md)(libtcodepty 的 `tcode_get_account`)、[11-testing-benchmarks.md](11-testing-benchmarks.md)(各测试作业的内容)。 +> +> 本文规定:本地开发环境;`native/` 与 `tools/terminal/` 的目录结构;一条命令的本地构建;MSBuild 如何拾取原生产物;libghostty-vt、libtcodepty、tcode-pty-spawn 的构建;ConPTY 的引用与发布布局;pty host 的打包、签名与公证;CI 工作流;升级流程;故障排查。 + +**标注约定**:**[已核实]** = 对照源码、官方下载索引、上游 CI 或本机核对过;**[推断]** = 设计推理,需要在 M0 实测。上游版本:Ghostty `b3dc3d794513c11633e7c3347935693957744e83`(`b3dc3d7`,2026-10-15)、RoyalTerminal `01833b8`(2026-10-09)、StreamMark 本地仓库 `.github/workflows/ci.yml`。 + +--- + +> **M0 实际落地(2026-10-17)**:已有的脚本是 `tools/terminal/install-zig.sh`、`tools/terminal/build-ghostty.sh [rid] [--optimize …]`、`tools/terminal/prefetch-zig-deps.sh`(代理环境下预取 Zig 依赖)、`native/tcodepty/build.sh [rid]`、`tools/streammark/pack.sh`;MSBuild 拾取规则在 `native/TerminalNative.targets`;CI 是 `.github/workflows/terminal.yml`。本文中的一键脚本 `build-native.sh`、`verify-native.sh`、ConPTY 打包与签名流程尚未实现,按本文在 M1 补齐。 + +## 0. 结论摘要 + +| 主题 | 结论 | +|---|---| +| TCode 现有 CI | **没有**:仓库根目录没有 `.github/`(2026-10-16 检查)[已核实]。本文的工作流是 TCode 的第一个 CI,需要同时覆盖现有的 .NET 测试 | +| 工具链 | .NET SDK 10.0.300+(`global.json` 已锁定,`rollForward: latestFeature`)[已核实];**Zig 精确为 0.16.0**(官方 tarball + SHA-256 校验;本机 Homebrew 装的是 0.17.0,不可用);Linux 的 libtcodepty 与 helper 用 `zig cc`(不需要 gcc),macOS 用 Apple clang(03 §11.1);Windows 上 libghostty 的 `x86_64-windows-msvc` 目标需要 VS 2022 Build Tools(MSVC + Windows SDK);CMake 不需要 | +| 一条命令 | `tools/terminal/build-native.sh [rid]` / `build-native.ps1 -Rid `:只构建当前(或指定)RID;按"上游提交 + overlay 补丁 + Zig 版本 + 构建参数 + 脚本版本"的哈希做缓存;产物在 `artifacts/native//` | +| MSBuild | 仿照 StreamMark:根目录 `Directory.Build.targets` 按使用方的 RID 把 `artifacts/native//` 中的文件平铺复制到输出与发布目录;`TCode.Terminal.Core` / `.Pty` 另以 `runtimes//native/` 打包,留给将来拆成独立仓库(D2) | +| 动态 vs 静态 | **libghostty-vt:v1 全部动态**(Debug 与 AOT 同一条路径);**libtcodepty:按 03 §11.5,ptyhost 的 Unix AOT 发布静态链接 `.a`,Debug / 测试用动态库**;libghostty 静态链接作为 M6 之后的可选优化 | +| libghostty-vt | 每个 RID 显式 `-Dtarget`(禁止本机 CPU 推断),`-Doptimize=ReleaseFast -Dstrip=true`,从 git 检出构建以便 `build_info` 带上提交号;Linux 目标写成 `*-linux-gnu.2.28`(RHEL 8);win-x64 先用 RoyalTerminal 验证过的标量构建,同时跑 SIMD 构建的 CPU 兼容性验证;win-arm64 用 `aarch64-windows-gnu` 且 `-Dsimd=false` | +| libtcodepty + helper | 4 个 Unix RID,构建以 03 §11 为准:Linux `zig cc -target *-linux-gnu.2.28`,helper 用 musl 完全静态;macOS Apple clang;约 690 行 C(03 §1);Windows 不需要 | +| ConPTY | `Microsoft.Windows.Console.ConPTY` **1.25.260930003**,只由 `TCode.Terminal.Host` 直接引用,`ConptyRequires{x64,ARM64}Host=true`;布局 `ptyhost\conpty.dll` + `ptyhost\{x64,arm64}\OpenConsole.exe`(03 §11.3–11.4),发布后校验 | +| Linux AOT | AOT 主程序链接发布机的 glibc → Linux 发布在 `almalinux:8`(glibc 2.28)容器中进行(§5.3) | +| macOS 下限 | 以 .NET 10 的下限为准(**15.0**,03 §11.1 引 supported-os.md);`LSMinimumSystemVersion` 从 11.0 提高到 15.0(§6.2) | +| 打包 | 采用 04 §1.1 的 `ptyhost/` 子目录;macOS 包内 Mach-O 文件留在 `Contents/MacOS/**`,非 Mach-O 文件(`ghostty-build.txt`)必须移出(§6.2);签名由内向外,不用 `--deep` | +| CI 作业 | `native-ghostty ×6` → `native-tcodepty ×4` → `build-test ×3` → `esctest` → `aot-smoke ×3` → `bench-pr`;每晚:`nightly-stress`、`bench-nightly`、`fuzz-long`、ARM 平台测试 | + +--- + +## 1. 本地开发环境 + +### 1.1 必需工具 + +| 工具 | 版本 | macOS | Linux | Windows | +|---|---|---|---|---| +| .NET SDK | 10.0.300 及以上同一特性带(`global.json`)[已核实] | 官方安装包 / `dotnet-install.sh` | 同左 | 官方安装包 / `winget install Microsoft.DotNet.SDK.10` | +| NativeAOT 前置 | — | Xcode 命令行工具(`xcode-select --install`) | `clang`、`zlib1g-dev`(.NET NativeAOT 的链接需要)[推断:按 .NET 官方 AOT 先决条件] | VS 2022 Build Tools:"使用 C++ 的桌面开发"工作负载(MSVC 链接器 + Windows SDK) | +| **Zig** | **0.16.0(精确)** | 见 §1.2 | 见 §1.2 | 见 §1.2 | +| Git | 任意现代版本 | 系统自带 | 包管理器 | Git for Windows(同时提供 Git Bash,用于 shell 集成测试) | +| Python 3 | ≥ 3.9 | 系统 / Homebrew | 包管理器 | `winget install Python.Python.3.12` | +| PowerShell 7 | 可选(Windows 上运行 `.ps1`、测试 pwsh 集成) | `brew install powershell` | 包管理器 | `winget install Microsoft.PowerShell` | + +- Python 只用于:运行 esctest2(11 §1.1.4)、`tools/gen_*.py` 等现有脚本。 +- **gcc 不是构建原生库的必需项**:libghostty 由 Zig 构建;Linux 的 libtcodepty 与 helper 用 `zig cc`(可以精确指定 glibc 2.28 与 musl 静态)。macOS 的 libtcodepty 与 helper 按 03 §11.1 用 Apple clang(Xcode 命令行工具,NativeAOT 本来就需要)。 +- **CMake 不需要**。上游提供的 CMake 包装内部也是调用 zig [已核实 research/01 §4.1.4],对我们没有额外价值。 + +### 1.2 安装 Zig 0.16.0 + +Zig 0.16.0 于 2026-04-13 发布,0.17.0 于 2026-10-01 发布;Ghostty `b3dc3d7` 的 `build.zig.zon` 写明 `minimum_zig_version = "0.16.0"` [已核实];RoyalTerminal 记录"需要恰好 0.16.0,升级到 0.17 需要协调的 ABI 和行为评审"[已核实 research/01 §4.1.4]。**包管理器给的通常是最新版**(本机 `brew` 安装的是 0.17.0 [已核实 `zig version`]),所以统一用官方 tarball: + +| 平台 | 文件(`https://ziglang.org/download/0.16.0/` 下)[已核实 `https://ziglang.org/download/index.json`] | +|---|---| +| macOS arm64 | `zig-aarch64-macos-0.16.0.tar.xz` | +| macOS x64 | `zig-x86_64-macos-0.16.0.tar.xz` | +| Linux x64 | `zig-x86_64-linux-0.16.0.tar.xz` | +| Linux arm64 | `zig-aarch64-linux-0.16.0.tar.xz` | +| Windows x64 | `zig-x86_64-windows-0.16.0.zip` | +| Windows arm64 | `zig-aarch64-windows-0.16.0.zip` | + +安装脚本 `tools/terminal/install-zig.sh` / `install-zig.ps1`: + +1. 从 `native/toolchain.json`(见 §2)读取版本与各平台 SHA-256(值取自 `index.json` 的 `shasum` 字段,脚本入库时一并写入); +2. 下载到 `~/.cache/tcode/zig/0.16.0/`(Windows:`%LOCALAPPDATA%\tcode\zig\0.16.0\`),校验 SHA-256,解压; +3. 不修改 PATH。构建脚本按以下顺序找 zig:环境变量 `TCODE_ZIG` → 上述缓存目录 → PATH 中的 `zig`(**必须** `zig version` 输出 `0.16.0`,否则报错并提示运行安装脚本)。 + +也可以用 `mise use zig@0.16.0` 或 `zigup 0.16.0`,只要 `zig version` 正确即可。 + +### 1.3 各平台的额外要求 + +| 平台 | 要求 | +|---|---| +| macOS | Xcode 命令行工具(`codesign`、`install_name_tool`、`otool`、`lipo`)。libghostty 的 macOS 目标需要 Apple SDK,**只能在 macOS 上构建**(上游注释:"lib-vt requires macOS runner for macOS/iOS builds because it requires the `apple_sdk` path")[已核实 Ghostty `.github/workflows/test.yml:825`] | +| Linux | `patchelf`(可选,规范化 soname)、`binutils`(`objdump`、`strip`,用于校验 glibc 符号版本与剥离符号) | +| Windows | VS 2022 Build Tools(MSVC v143 + Windows 11 SDK);`x86_64-windows-msvc` 目标需要能找到 MSVC 头文件与库,交叉编译不可行(上游注释:"doesn't work yet, we need a way to find msvc libc/c++ headers")[已核实 `test.yml:617-619`],所以 Windows 原生库在 Windows 上构建。MinGW **不需要**:`aarch64-windows-gnu` 目标使用 Zig 自带的 mingw-w64 | + +--- + +## 2. 目录结构 + +``` +native/ + toolchain.json Zig 版本与各平台 tarball 的 SHA-256;esctest2 的 pin + ghostty/ + ghostty-upstream.json 上游 pin(§4.1) + overlay/ overlay 补丁(*.patch),按文件名顺序应用;每个补丁附 README 段落说明原因与上游 issue + 0001-win32-input-mode-9001.patch 06 §3.7:在模式表登记 9001(若采纳) + build-flags.json 各 RID 的 zig 参数(§4.3),脚本与 CI 共用,单一来源 + THIRD_PARTY_NOTICES.md Ghostty 及其依赖(simdutf、Highway、Wuffs、uucode)的许可证 + tcodepty/ 布局以 03-pty.md §1 为准 + include/tcodepty.h + src/tcpty_{spawn,io,exit,proc}.c 合计约 690 行 + helper/tcode-pty-spawn.c PTY / --exec / --detach 三种模式 + build.sh check-glibc.sh 03 §11.1;build-native.sh 调用它 + exports.txt 导出符号白名单,CI 用 nm 校验 +tools/terminal/ + install-zig.sh / install-zig.ps1 + build-native.sh / build-native.ps1 一条命令的本地构建(§3) + verify-native.sh / verify-native.ps1 产物校验(导出符号、glibc 版本、CPU 指令、体积、签名) + package-macos.sh 扩展现有 tools/package_macos_app.sh:放置 ptyhost/、签名、公证(§6.2) + sign-windows.ps1 + gen-bench-data/ 11 §3.3 + fixtures/ 11 §1.1.1 的素材转换器 + record/ 11 §1.5.1 的录制脚本 + EscTestRunner/ 11 §1.1.4(.NET 控制台项目) +artifacts/ (.gitignore) + native// 原生产物(MSBuild 从这里拾取) + native//.stamp 缓存键 + native-symbols// 未剥离的调试符号(.dSYM / .debug / .pdb) + src/ghostty-/ 上游检出(只读,构建在独立的 zig cache 中进行) +``` + +--- + +## 3. 一条命令的本地构建 + +### 3.1 用法 + +```bash +# 构建当前机器的 RID(osx-arm64 等),首次约 2–5 分钟,之后命中缓存秒级返回 +tools/terminal/build-native.sh + +# 指定 RID(只允许本平台可以构建的 RID:macOS 可以构建 osx-*、linux-*;Linux 可以构建 linux-*;Windows 构建 win-*) +tools/terminal/build-native.sh linux-arm64 + +# 强制重建;只构建某一部分 +tools/terminal/build-native.sh --force --only ghostty +``` + +```powershell +tools\terminal\build-native.ps1 -Rid win-x64 +``` + +之后 `dotnet build` / `dotnet test` / `dotnet publish -r ` 自动拾取产物(§3.4)。没有产物时,引用原生库的项目在构建时给出明确错误:"artifacts/native// 中没有 libghostty-vt,请先运行 tools/terminal/build-native.sh "(Directory.Build.targets 中的 `Error` 任务,仅对需要原生库的可执行项目生效)。 + +### 3.2 脚本步骤 + +``` +1. 解析 RID(默认取本机:uname -s/-m → osx|linux × x64|arm64;Windows 由 ps1 处理) +2. 找到 zig 0.16.0(§1.2),否则失败 +3. 计算缓存键 key = sha256( + ghostty-upstream.json 的 commit + ‖ overlay/*.patch 的内容(按文件名排序) + ‖ build-flags.json 中该 RID 的参数 + ‖ zig 版本 + ‖ tcodepty/*.c|*.h 的内容 + ‖ 脚本自身的版本号) + artifacts/native//.stamp == key 且文件齐全 → 直接返回 +4. libghostty-vt(§4): + a. 检出:artifacts/src/ghostty-/ 不存在时 + git init && git remote add origin && git fetch --depth 1 origin && git checkout FETCH_HEAD + (从 git 检出而不是下载 tarball:Ghostty 的构建用 git 计算版本号中的提交号 [已核实 src/build/Config.zig:283-339]) + b. 校验 HEAD == pin 的 commit + c. 应用 overlay:每个补丁先 `git apply --check`,再 `git apply`;任一补丁失败即报错(不静默跳过——与 StreamMark 的"已应用则跳过"不同,因为这里每次都从干净检出开始) + d. zig build -Demit-lib-vt --prefix artifacts/obj/ghostty/ + ZIG_GLOBAL_CACHE_DIR 保持默认(~/.cache/zig),依赖包(simdutf、highway、wuffs、uucode)在这里缓存 + e. 规范化文件名(§4.4),剥离符号到 artifacts/native-symbols// +5. libtcodepty + tcode-pty-spawn(仅 Unix RID,§5) +6. 写 artifacts/native//ghostty-build.txt(04 §2.4:版本字符串 + 库文件 SHA-256 前 16 位) +7. verify-native(§4.6) +8. 写 .stamp +``` + +### 3.3 产物 + +``` +artifacts/native/osx-arm64/ + libghostty-vt.dylib + libtcodepty.dylib Debug / 测试 + libtcodepty.a ptyhost 的 AOT 发布(DirectPInvoke,03 §11.5) + tcode-pty-spawn + ghostty-build.txt + .stamp +artifacts/native/win-x64/ + ghostty-vt.dll + ghostty-build.txt + .stamp +``` + +ConPTY 的 `conpty.dll` / `OpenConsole.exe` 不在这里:它们来自 NuGet 包(§5.3)。 + +### 3.4 MSBuild 如何拾取 + +**仓库内(项目引用)**:仿照 StreamMark 根目录的 `Directory.Build.targets`——"SDK 不会把 RuntimeIdentifier 传给被引用项目,所以在使用方自己的构建中按其目标 RID 复制原生库" [已核实 `/Users/token/code/StreamMark/Directory.Build.targets`]。TCode 新增根目录 `Directory.Build.targets`: + +```xml + + + + <_TermRid>$(RuntimeIdentifier) + <_TermRid Condition="'$(_TermRid)' == ''">$(NETCoreSdkPortableRuntimeIdentifier) + <_TermNativeDir>$(MSBuildThisFileDirectory)artifacts\native\$(_TermRid)\ + + + + + + + + +``` + +要点: + +- `Link="%(Filename)%(Extension)"` 把文件**平铺**到输出目录根,`LibraryImport("ghostty-vt")` 按默认探测路径(应用目录)即可找到。不使用 `runtimes//native/` 子目录:那是 NuGet 包内的布局,发布时 SDK 才会平铺;项目内直接平铺更简单,Debug 与发布一致。 +- **Desktop 的 Debug 构建不带 RID**:用 `NETCoreSdkPortableRuntimeIdentifier`(例如 `osx-arm64`)。 +- 可执行位:MSBuild 的 `Copy` 在 Unix 上保留源文件权限 [推断:.NET SDK 的 Copy 任务使用 `File.Copy`,在 Unix 上保留 mode];`verify-native` 与发布后的校验步骤再检查一次 `tcode-pty-spawn` 的 `+x`(研究 02 §4 U13)。 +- `ptyhost/` 子目录:Desktop 的 `CopyPtyHost` / `PublishPtyHost` 目标(04 §1.1,仿照现有 `CopyAgentHost` / `PublishAgentHost` [已核实 `src/TCode.Desktop/TCode.Desktop.csproj:36-54`])把 `TCode.Terminal.Host` 的输出复制 / 发布到 `ptyhost/`;Host 项目本身声明 `TCodeNeedsTerminalNative=true`,所以 `ptyhost/` 中自带 libghostty、helper,以及(仅 Debug / 测试构建)libtcodepty 动态库。Desktop 根目录再有一份 libghostty(UI 侧核心使用),两份来自同一文件,字节相同(04 §1.1)。 + +**将来拆成独立仓库(D2)时的 NuGet 布局**:`TCode.Terminal.Core.csproj` 中 + +```xml + +``` + +与 StreamMark.RaTeX 相同 [已核实 `src/StreamMark.RaTeX/StreamMark.RaTeX.csproj:20-22`]。v1 不发布 NuGet 包,这一段只用于 `dotnet pack` 的冒烟检查。 + +### 3.5 Debug 用动态库,AOT 用静态还是动态 + +| 方案 | 做法 | 优点 | 缺点 | +|---|---|---|---| +| A 全部动态(**采用**) | Debug 与 AOT 都加载 `libghostty-vt.{dylib,so}` / `ghostty-vt.dll` | 一条路径、一份产物;与 StreamMark RaTeX、RoyalTerminal 一致;04 的 `ghosttyBuildId` 直接对文件做哈希;Windows 两个架构都没有 ABI 问题 | 多两个文件(每份约 2 MB,Desktop 根目录与 `ptyhost/` 各一份);每个都要签名;加载一次 `dlopen` | +| B AOT 静态链接 | `` + ``;Debug 仍用动态库 | 少文件;P/Invoke 直接调用(略快);不需要单独签名 dylib | ①Debug 与发布走两条路径,问题只在发布时出现;②Windows:静态库必须与 MSVC 链接器兼容,`win-x64` 需 `x86_64-windows-msvc`(可以),`win-arm64` 只能 `aarch64-windows-gnu` 构建(§4.3),gnu 的 `.a` 交给 MSVC `link.exe` 有 compiler_rt / 符号名风险 [推断];③静态库中打包了 compiler_rt 与 ubsan_rt(上游注释)[已核实 research/01 §4.1.4],可能与 .NET 运行时的静态库冲突 [推断];④Desktop 与 ptyhost 各嵌一份,`ghosttyBuildId` 改为编译期常量;⑤SIMD 构建需要 libc(上游注释:"Setting simd to false will force a pure static build that doesn't even require libc")[已核实 `example/c-vt-static/build.zig:21-25`],链接时要补齐系统库 | +| C 混合 | libtcodepty 静态、libghostty 动态 | libtcodepty 很小、只依赖 libc,静态链接风险低;少一个需要签名的文件 | 03 §11.5 已对 ptyhost 的 Unix AOT 发布采用 | + +**结论**:libghostty-vt 采用 A;libtcodepty 采用 C(03 §11.5,§5.2)。M6 之后若要减少 libghostty 的文件数,先做 research/04 §11 第 6 条的 spike(静态链接 + `DirectPInvoke` + 跑 S1/S4),只在 5 个非 win-arm64 的 RID 上启用 B。 + +--- + +## 4. libghostty-vt 构建 + +### 4.1 pin 与校验 + +`native/ghostty/ghostty-upstream.json`: + +```jsonc +{ + "repository": "https://github.com/ghostty-org/ghostty.git", + "commit": "b3dc3d794513c11633e7c3347935693957744e83", // 2026-10-15,research 全部基于它 + "commitDate": "2026-10-15", + "zig": "0.16.0", + "versionString": "1.3.2-tcode", // 传给 -Dversion-string 的前缀;脚本追加 +<短提交号>. + "overlaySha256": "…", // overlay/*.patch 串接后的 SHA-256,CI 校验 + "expected": { // 构建后的期望导出(防止上游 API 悄悄变化) + "exportsFile": "native/ghostty/exports-b3dc3d7.txt", + "minExports": 200 + } +} +``` + +校验: + +- 检出后 `git rev-parse HEAD` 必须等于 `commit`(防止 `fetch` 拿到别的对象); +- overlay 内容哈希必须等于 `overlaySha256`(防止有人改了补丁却没有更新 pin); +- Zig 依赖包由 `build.zig.zon` 中的内容哈希保证完整性(Zig 包管理器按 `.hash` 校验下载内容)[已核实 `build.zig.zon` 中各依赖都带 `.hash`]; +- `-Demit-lib-vt` 只拉取 lib-vt 用到的惰性依赖 [推断:按 `b.lazyDependency` 的用法]; +- 构建后用 `nm` / `dumpbin /exports` 导出 `ghostty_*` 符号列表,与 `exports-b3dc3d7.txt` 比较:缺少符号即失败,多出符号只警告(上游新增 API)。 +- `-Dversion-string=1.3.2-tcode+b3dc3d7.`:使 `ghostty_build_info(VERSION_STRING / VERSION_BUILD)` 带上我们的构建标识 [推断:需确认 lib-vt 的 build_info 读取的是 `version` 还是 `lib_version`(`src/build/Config.zig:276-350` 中两者分开定义);若是后者,改传 `-Dlib-version-string`]。 + +### 4.2 overlay 补丁 + +做法沿用 RoyalTerminal 的思路(构建时对干净检出打补丁,不改 submodule,每个补丁校验原文件)[已核实 research/01 §4.1.7],但用标准 `git apply`: + +- 每个补丁只做一件事,文件名带序号;补丁头部注释写明:原因、对应的上游 issue / PR、上游合并后删除的条件; +- 当前已知候选:`0001-win32-input-mode-9001.patch`(06 §3.7、§末待决:libghostty 模式表中没有 9001); +- 研究 01 §4.1.7 列出的 RoyalTerminal 修正(脏标记等)在 `b3dc3d7` 上是否仍存在**未核实**;M1 的差分测试(11 §1.1.2)发现问题时,优先向上游提交,必要时临时加补丁。 + +### 4.3 6 个 RID 的命令行 + +公共参数:`zig build -Demit-lib-vt -Doptimize=ReleaseFast -Dstrip=true -Dversion-string=<见 4.1>`。 + +| RID | runner | 额外参数 | 说明 | +|---|---|---|---| +| `osx-arm64` | macOS arm64 | `-Dtarget=aarch64-macos` | 需要 Apple SDK,只能在 macOS 上构建 [已核实 上游注释]。最低系统版本由上游定为 macOS 13.0 [已核实 `src/build/Config.zig:789-797`] | +| `osx-x64` | macOS(arm64 或 Intel 均可) | `-Dtarget=x86_64-macos` | 在 arm64 runner 上交叉编译即可(RoyalTerminal 的做法)[已核实 RoyalTerminal `release.yml:69-71`];测试仍需在 Intel runner(`macos-15-intel`)上运行 | +| `linux-x64` | Ubuntu x64 | `-Dtarget=x86_64-linux-gnu.2.28` | glibc 2.28 = RHEL 8(01 §1)。显式 target 使 CPU 模型为基线 x86-64,而不是 runner 的本机 CPU | +| `linux-arm64` | Ubuntu arm64 | `-Dtarget=aarch64-linux-gnu.2.28` | 也可以在 x64 上交叉编译;测试需要 arm64 runner | +| `win-x64` | Windows x64 | `-Dtarget=x86_64-windows-msvc -Dcpu=x86_64-vzeroupper -Dsimd=false`(**初始**) | 见下文"Windows x64 的 CPU 基线" | +| `win-arm64` | Windows(x64 或 arm64) | `-Dtarget=aarch64-windows-gnu -Dsimd=false` | RoyalTerminal 的注释:"Zig 0.16.0's Windows ARM64 stdlib stack-trace code fails to compile when Ghostty's vendored SIMD archive is enabled" [已核实 RoyalTerminal `release.yml:262-268`]。aarch64 的 msvc 目标同样需要 MSVC 头文件,未验证,所以用 gnu | + +这些参数写在 `native/ghostty/build-flags.json` 中,脚本与 CI 都从这里读,**不在 YAML 中重复**。 + +**Windows x64 的 CPU 基线 / SIMD 问题**: + +- 事实:RoyalTerminal 发现默认构建可能在启动阶段就执行 AVX 指令,在不支持 AVX 的虚拟机或模拟环境中直接崩溃;发布版改用 `-Dcpu=x86_64-vzeroupper -Dsimd=false`,并在 CI 中反汇编确认没有 AVX [已核实 research/01 §4.1.4;RoyalTerminal `release.yml:269-278` 的注释:"x86_64-windows-msvc alone still allows LLVM/third-party SIMD code to leave VEX/AVX instructions in startup paths"]。RoyalTerminal 在 **Linux** 上也加了 `-Dsimd=false`,注释写的原因是"runner-native Ghostty build can SIGILL … on another machine with the same RID" [已核实 `release.yml:166-176`]——那是不带 `-Dtarget` 的本机构建引起的。 +- 代价:`-Dsimd=false` 关闭 simdutf / Highway 路径,ASCII 解析会明显变慢(Ghostty devlog:SIMD ASCII 处理比标量快 7.32×)[已核实 research/04 §2.9],与 01 §1 的"≥ 500 MB/s"目标冲突。 +- TCode 的做法: + 1. **Linux / macOS**:显式 `-Dtarget` + **保留 SIMD**。Highway 与 simdutf 都以运行时 CPU 检测分派 [推断:需确认 Ghostty 的 Highway 构建是否启用动态分派],基线 target 保证非 SIMD 代码不含高级指令。验证见 §4.6 的"CPU 兼容性"。 + 2. **win-x64**:发布物初始使用标量构建(与 RoyalTerminal 一致,风险已知且可接受);CI 并行构建一份 `-Dsimd=true` 的版本,跑同样的 CPU 兼容性验证与 S1 基准。验证连续通过后,把发布物切换为 SIMD 构建(只改 `build-flags.json`)。 + 3. **win-arm64**:标量,直到 Zig 升级解决编译问题。 + +### 4.4 文件名规范化 + +| 平台 | 上游产物 | 规范化后 | 处理 | +|---|---|---|---| +| macOS | `libghostty-vt....dylib`(及符号链接)[已核实 `src/build/GhosttyLibVt.zig:395-402`] | `libghostty-vt.dylib` | `cp -L`;`install_name_tool -id @rpath/libghostty-vt.dylib`(改了 Mach-O 头之后签名失效,脚本随即 `codesign -s - -f` 做临时签名,正式签名在打包时进行);`dsymutil` 生成的 `.dSYM` 移到 `native-symbols/` | +| Linux | `libghostty-vt.so.`(及 `.so.0`、`.so` 符号链接)[已核实 上游 CI 检查 `zig-out/lib/libghostty-vt.so.0.1.0`] | `libghostty-vt.so` | `cp -L`;可选 `patchelf --set-soname libghostty-vt.so`(.NET 按完整路径 `dlopen`,soname 不影响加载,只影响以后有人链接它);`objcopy --only-keep-debug` 后 `strip --strip-unneeded` | +| Windows | `ghostty-vt.dll`(`zig-out/bin/`)[已核实 上游 CI 从缓存中查找 `ghostty-vt.dll`] | 不变 | `.pdb`(若生成)移到 `native-symbols/` | + +`LibraryImport("ghostty-vt")` 在三个平台分别探测 `libghostty-vt.dylib`、`libghostty-vt.so`、`ghostty-vt.dll`(.NET 的默认名称变换)。 + +### 4.5 体积目标 + +| RID | 目标(剥离后) | 依据 | +|---|---|---| +| osx-arm64 / osx-x64 | ≤ 2.5 MB | RoyalTerminal 0.6.3:1.94 / 2.07 MB(含其扩展)[已核实 research/01 §4.1.4] | +| win-x64 / win-arm64 | ≤ 2.5 MB | RoyalTerminal:1.72 / 1.74 MB(标量)[已核实 同上] | +| linux-x64 / linux-arm64 | ≤ 2.5 MB | RoyalTerminal 的 Linux 包约 9.7 MB,`file` 显示"with debug_info, not stripped"[已核实 同上]——说明 Linux 必须显式剥离;剥离后应与其他平台相当 [推断] | + +`verify-native` 中超过目标 20% 即失败,提示检查是否漏剥离或上游新增了大依赖。 + +### 4.6 产物校验(`verify-native`) + +| 检查 | 方法 | 失败条件 | +|---|---|---| +| 导出符号 | `nm -gU`(macOS)/ `nm -D --defined-only`(Linux)/ `dumpbin /exports`(Windows) | 缺少 `exports-.txt` 中的符号 | +| 不依赖 libc++ | `otool -L` / `ldd` / `dumpbin /dependents` | 出现 `libc++`、`libc++abi`、`libstdc++`(上游 CI 有同样断言)[已核实 `test.yml:464-469`] | +| glibc 基线 | `objdump -T libghostty-vt.so \| grep -o 'GLIBC_[0-9.]*' \| sort -V \| tail -1` | > `GLIBC_2.28` | +| macOS 最低版本 | `otool -l` 中 `LC_BUILD_VERSION` 的 `minos` | > 应用的 `LSMinimumSystemVersion`(15.0) | +| 体积 | `stat` | §4.5 | +| 已剥离 | `file`(Linux 不含 "not stripped") | — | +| CPU 兼容性(x64) | ①反汇编扫描:`objdump -d` 中出现 `ymm`/`zmm` 寄存器的函数列表,与 SIMD 分派函数白名单比较(非白名单函数中出现即失败);②运行时:在 **Intel SDE**(`sde64 -wsm --`,模拟没有 AVX 的 Westmere)下运行 `TCode.Terminal.Core.Tests` 的一个子集(加载库 + 回放 10 个夹具) | ①白名单外出现 AVX;②SIGILL / 非法指令异常 [推断:SDE 可在 Linux 与 Windows runner 上运行] | +| 构建标识 | 加载库调用 `ghostty_build_info(VERSION_STRING)`,应包含 `tcode+<短提交号>`;`SIMD` 字段与 `build-flags.json` 一致 | 不一致 | +| 签名(打包后) | `codesign --verify --strict` / `signtool verify /pa` | 失败 | + +--- + +## 5. libtcodepty、tcode-pty-spawn 与 ConPTY + +> 本节以 [03-pty.md](03-pty.md) §1、§11 为准(源文件布局、编译命令、签名、发布布局、ConPTY 引用、静态链接决定)。这里只记录它们在一条命令构建与 CI 中的落点,以及与 libghostty 的差异。 + +### 5.1 libtcodepty 与 helper(4 个 Unix RID) + +源码布局(03 §1):`native/tcodepty/include/tcodepty.h`、`src/tcpty_{spawn,io,exit,proc}.c`(合计约 690 行,01 估计的 300 行偏低)、`helper/tcode-pty-spawn.c`(PTY / `--exec` / `--detach` 三种模式,约 85 行)、`build.sh`、`check-glibc.sh`。`tools/terminal/build-native.sh --only tcodepty` 只是调用 `native/tcodepty/build.sh ` 并把产物放到 `artifacts/native//`。 + +| 产物 | linux-x64 / linux-arm64 | osx-arm64 / osx-x64 | +|---|---|---| +| 编译器 | `zig cc -target -linux-gnu.2.28`(与 libghostty 共用同一个 Zig 0.16.0,任何 Linux 主机都能交叉编译两个架构) | Apple clang(`-arch arm64` / `-arch x86_64`,arm64 runner 上一次产出两个架构) | +| `libtcodepty.so` / `.dylib` | 动态库,glibc 基线 2.28;`close_range`、`pidfd_open` 只经 `syscall()` 调用(03 §11.1) | `-install_name @rpath/libtcodepty.dylib` | +| `libtcodepty.a` | `zig ar rcs`,供 AOT 静态链接 | `libtool -static` | +| `tcode-pty-spawn` | **`zig cc -target -linux-musl -static`:完全静态**,不依赖目标机的 glibc(03 §3、§11.1) | clang,动态链接 libSystem(macOS 不支持完全静态) | +| 最低系统 | glibc 2.28(`check-glibc.sh` 校验) | `-mmacosx-version-min` = **TCode 的 macOS 下限**(§6.2:.NET 10 的下限 15.0;03 §11.1 写的 11.0 应随之提高) | + +- 编译选项:`-O2 -fPIC -fvisibility=hidden -D_GNU_SOURCE -Wall -Wextra -Werror`;Linux CI 另跑一遍 `-fsanitize=address,undefined` 的测试构建(03 §11.1)。 +- 本文 §1.1 原先写"Unix 上全部用 `zig cc`、不需要 clang":macOS 改为按 03 使用 Apple clang(Xcode 命令行工具本来就是 NativeAOT 的前置条件,不增加依赖)。 +- `verify-native --only tcodepty`:导出符号白名单、`check-glibc.sh`、helper 的可执行位、Linux helper 为静态(`file` 输出含 "statically linked")。 + +**helper 的另一个用途**:03 §12 实测 `trap - PIPE` 对 bash 无效,BashTool(进程模式)的 SIGPIPE 修复改为经 `tcode-pty-spawn --exec` 启动 shell。所以 helper 也要能被 AgentHost 找到:Desktop 启动 AgentHost 时注入 `TCODE_PTY_HELPER=`,后备路径 `/../ptyhost/tcode-pty-spawn`。打包时不需要再复制一份。 + +### 5.2 AOT 下 libtcodepty 静态链接,libghostty 动态链接 + +03 §11.5 的决定:**tcode-ptyhost 的 Release AOT 发布在 Unix 上用 `` + `` 静态链接;Debug、JIT 测试使用动态库**。这与 §3.5 对 libghostty 的"全部动态"不冲突(§3.5 的方案 C):libtcodepty 小、只依赖 libc、没有 compiler_rt,静态链接风险低;libghostty 的风险点(Windows ABI、compiler_rt、`ghosttyBuildId` 按文件哈希)都不适用于它。所以 Unix 发布目录的 `ptyhost/` 中**没有** `libtcodepty.*`。 + +`artifacts/native//` 中同时有 `.so`/`.dylib` 与 `.a`;§3.4 的 `Directory.Build.targets` 排除 `*.a`(与 StreamMark 相同 [已核实 StreamMark `Directory.Build.targets`]),`.a` 只由 Host 项目的 `NativeLibrary` 项引用。 + +### 5.3 Linux AOT 二进制的 glibc 基线 + +03 §11.5 指出:NativeAOT 可执行文件链接的是**发布机器**的 glibc。`ubuntu-latest`(24.04,glibc 2.39)上发布的 `TCode`、`TCode.AgentHost`、`tcode-ptyhost` 在 RHEL 8(glibc 2.28)上无法启动,与 01 §1 的平台要求冲突。原生库用 `zig cc -target …gnu.2.28` 解决了自己的部分,但 AOT 主程序必须另行处理: + +| 方案 | 做法 | 评价 | +|---|---|---| +| A(**采用**) | Linux 的发布作业在 `almalinux:8` 容器(glibc 2.28)中运行:容器内安装 .NET SDK(`dotnet-install.sh`)与 `clang`、`zlib-devel`,执行 `dotnet publish -r linux-x64` | 简单直接;arm64 用 `ubuntu-24.04-arm` runner + `almalinux:8` 的 arm64 镜像 | +| B | .NET 官方的交叉构建方式:`mcr.microsoft.com/dotnet-buildtools/prereqs` 的 crossdeps 镜像 + rootfs sysroot,`-p:SysRoot=…` | 更接近 dotnet/runtime 自己的做法,但配置复杂 | +| C | 只在 Ubuntu 上发布,声明最低 glibc 为 runner 的版本 | 违背 01 §1,不采用 | + +Avalonia / SkiaSharp / HarfBuzz 等第三方原生库自带的 glibc 要求也要一并检查(`objdump -T`)[推断:SkiaSharp 的 Linux 原生库通常以较老的 glibc 构建,需实测]。CI:`aot-smoke` 的 linux-x64 行改为容器内发布(§7.3),另在 `almalinux:8` 容器中运行 `--self-test`(03 §14 的 test-rhel8)。 + +### 5.4 Windows:不需要我们自己的原生代码 + +Windows 侧全部是固定参数的 Win32 API,C# `LibraryImport` 即可 [已核实 research/02 §5 第 4 条]。 + +### 5.5 ConPTY NuGet 的引用与发布布局 + +采用 03 §11.4: + +- `Microsoft.Windows.Console.ConPTY` **1.25.260930003**(2026-10-02,MIT)[已核实 research/02 §3.4],**只在 `TCode.Terminal.Host` 中直接引用**(包内 `build/` targets 只对直接引用生效,经 ProjectReference 传递会丢失 OpenConsole,而 conpty.dll 找不到 OpenConsole 时会静默回退到 System32 的 conhost [已核实 03 §11.4 引 Porta.Pty.targets]);`TCode.Terminal.Pty` 不引用它。 +- 设置 `ConptyRequiresx64Host=true`、`ConptyRequiresARM64Host=true`(属性名取自 Porta.Pty 的用法),得到 03 §11.3 的布局:`ptyhost\conpty.dll`、`ptyhost\x64\OpenConsole.exe`、`ptyhost\arm64\OpenConsole.exe`(x64 包两份都带,arm64 包只带 arm64)。conpty.dll 的查找顺序是"同目录 → `<本机架构>\OpenConsole.exe` → 静默回退 conhost"[已核实 research/02 §3.4],所以 x64 构建运行在 arm64 Windows 上时会用 arm64 的 OpenConsole。 +- 这些属性在 NativeAOT 发布中是否生效是 03 的 spike S8;不生效时用 `GeneratePathProperty="true"` + 显式 `` 兜底。 +- **M1 实际落地(2026-10-17,S8 结论)**:包自带的 targets 按 `$(PlatformTarget)` / `$(Platform)` 选架构,AnyCPU 的 C# 项目两者都拿不到有效值,`ConptyRequires*Host` 不会被设置,所以**不用包的 targets**。改为 `native/ConPty.targets`:`PackageReference ExcludeAssets="all" PrivateAssets="all" GeneratePathProperty="true"`,按 RID 显式加 `None` 项(win-x64:x64 的 conpty.dll + `x64\`、`arm64\` 两份 OpenConsole;win-arm64:arm64 的 conpty.dll + `arm64\`)。只由可执行项目导入(`TCode.Terminal.Host`、`TCode.Terminal.Pty.Tests`):库项目经 ProjectReference 构建时不带 `RuntimeIdentifier`,会按本机 RID 选错。CI 在 Windows 上检查发布目录布局,`--self-test` 断言 `LocalPty.ConPtyImplementation == Bundled`。 +- **发布后校验**(`check-layout`):上述三个文件都在;`tcode-ptyhost --self-test` 报告 `ConPty=Bundled` 及加载路径,不是随包的就失败。 +- `conpty.dll` 与 `OpenConsole.exe` 来自同一个包,天然成对升级(§8.2);二者都是微软签名,TCode 不重新签名。 + +--- + +## 6. pty host 的发布与打包 + +### 6.1 发布布局(采用 04 §1.1) + +``` +<发布目录>/ + TCode[.exe] Desktop(AOT) + libghostty-vt.* / ghostty-vt.dll UI 侧核心 + ghostty-build.txt + (Avalonia / Skia / HarfBuzz / StreamMark RaTeX 等现有原生库) + agenthost/ + TCode.AgentHost[.exe] … 现有 + ptyhost/ + tcode-ptyhost[.exe] + libghostty-vt.* / ghostty-vt.dll 与根目录的字节相同 + ghostty-build.txt + (libtcodepty 在 Unix AOT 发布中静态链接进 tcode-ptyhost,不单独存在;Debug 构建中有 .so/.dylib) + tcode-pty-spawn Unix,0755 + conpty.dll Windows + x64/OpenConsole.exe arm64/OpenConsole.exe Windows(x64 包两份,arm64 包只有 arm64;03 §11.3) +``` + +`dotnet publish src/TCode.Desktop -c Release -r ` 一次产出全部内容(`PublishAgentHost` 与 `PublishPtyHost` 两个 AfterTargets)。 + +### 6.2 macOS `.app` + +现有 `tools/package_macos_app.sh` 把整个发布目录复制到 `Contents/MacOS/`,`LSMinimumSystemVersion` 为 11.0,没有签名步骤 [已核实]。终端上线后改为: + +**位置**: + +| 文件 | 位置 | 说明 | +|---|---|---| +| `TCode`、现有 dylib、`libghostty-vt.dylib` | `Contents/MacOS/` | 与现在一致 | +| `agenthost/`、`ptyhost/` 中的 Mach-O 文件(可执行文件、dylib、helper) | `Contents/MacOS/agenthost/`、`Contents/MacOS/ptyhost/` | 04 §1.1 的选择。Apple 推荐 helper 放 `Contents/Helpers/` 或 `Contents/MacOS/` [推断:TN2206 的推荐];放在 `Contents/MacOS` 的子目录中、且子目录只含 Mach-O 时,`codesign` 可以逐个签名 [推断:需在 M2 用公证实测] | +| `ghostty-build.txt`(两处) | **移到 `Contents/Resources/ghostty-build.txt`** | `Contents/MacOS` 下不能有非代码文件,否则签名 / 公证失败或被当作需要单独签名的代码 [推断:Apple 的 "Placing content in a bundle" 规则]。host 与 Desktop 读取时按"可执行文件目录 → `../Resources`(macOS 包内)"的顺序查找。**需要 04 的作者确认**这一改动(见待决问题) | +| AOT 产生的 `.dSYM`、`.pdb`、`.json` | 不进入包 | 发布后脚本删除;符号另行归档 | + +`LSMinimumSystemVersion` 改为 **15.0**:这是 .NET 10 官方支持的最低 macOS 版本(03 §11.1 引 dotnet/core `supported-os.md`),比 libghostty-vt 的 13.0 [已核实 `Config.zig:789-797`] 更高,是实际的下限。旧值 11.0 在 macOS 11–14 上不受支持。libtcodepty / helper 的 `-mmacosx-version-min` 同步为 15.0;libghostty 保持上游的 13.0 即可(低于应用下限无害)。 + +**签名顺序**(由内向外,**不用 `--deep`**;全部使用 Developer ID Application 证书、`--options runtime`(hardened runtime)、`--timestamp`): + +```bash +ID="Developer ID Application: … (TEAMID)" +S="codesign --force --timestamp --options runtime --sign $ID" +# 1. 所有 dylib(最内层) +find TCode.app/Contents/MacOS -name '*.dylib' -exec $S {} \; +# 2. helper 与辅助可执行文件(它们 exec 用户 shell,自身不需要额外 entitlements) +$S TCode.app/Contents/MacOS/ptyhost/tcode-pty-spawn +$S --entitlements tools/terminal/entitlements/ptyhost.plist TCode.app/Contents/MacOS/ptyhost/tcode-ptyhost +$S --entitlements tools/terminal/entitlements/agenthost.plist TCode.app/Contents/MacOS/agenthost/TCode.AgentHost +# 3. 主可执行文件 +$S --entitlements tools/terminal/entitlements/app.plist TCode.app/Contents/MacOS/TCode +# 4. 整个包 +$S --entitlements tools/terminal/entitlements/app.plist TCode.app +codesign --verify --strict --deep --verbose=2 TCode.app # 验证可以用 --deep +# 5. 公证 +ditto -c -k --keepParent TCode.app TCode.zip +xcrun notarytool submit TCode.zip --keychain-profile tcode-notary --wait +xcrun stapler staple TCode.app +spctl --assess --type execute --verbose TCode.app +``` + +Entitlements: + +- NativeAOT 不生成 JIT 代码,**不需要** `com.apple.security.cs.allow-jit` / `allow-unsigned-executable-memory` [推断:需用 hardened runtime 实测 Avalonia + Skia 的 AOT 二进制]; +- 所有 dylib 同一 Team ID 签名,**不需要** `disable-library-validation`; +- 终端会运行用户的任意程序,这些程序访问"文稿"、"桌面"等受保护目录时,系统以 TCode 为"负责进程"弹出授权提示——`Info.plist` 需要 `NSDesktopFolderUsageDescription`、`NSDocumentsFolderUsageDescription`、`NSDownloadsFolderUsageDescription`、`NSRemovableVolumesUsageDescription` 等说明文字。host 脱离 Desktop 后的归属问题见 04 §18 第 7 条 [推断]; +- 不启用 App Sandbox(终端不可能在沙箱中工作)。 + +### 6.3 Windows 签名 + +- `signtool sign /fd sha256 /tr /td sha256 /a`:`TCode.exe`、`agenthost\TCode.AgentHost.exe`、`ptyhost\tcode-ptyhost.exe`、`ghostty-vt.dll`(两份)、其他我们构建的 DLL; +- 不重新签名 `conpty.dll`、`OpenConsole.exe`(微软签名)以及已有签名的第三方 DLL(`signtool verify /pa` 通过的跳过); +- 脚本:`tools/terminal/sign-windows.ps1`,证书来自 CI 机密(Azure Trusted Signing 或 PFX);没有证书时(PR 构建)跳过签名但仍执行 `verify` 之外的检查。 + +### 6.4 Linux + +- 发布物为 tar.gz(以后再考虑 AppImage / deb / rpm)。打包时 `chmod 0755`:`TCode`、`agenthost/TCode.AgentHost`、`ptyhost/tcode-ptyhost`、`ptyhost/tcode-pty-spawn`;**tar 保留权限**(`tar --mode` 不覆盖);zip 不用于 Linux(会丢权限)。 +- 运行时自检:host 启动时检查 helper 可执行,否则报出明确错误(研究 02 §4 U13;RoyalTerminal 在运行时补设 `UserExecute` [已核实 research/02]——TCode 选择报错而不是静默修复,因为包被改动本身就是问题)。 + +--- + +## 7. CI 工作流 + +### 7.1 作业总览 + +参照 StreamMark:`native-*` 按 RID 矩阵在原生 runner 上构建并上传工件 → `build-test` 与 `aot-smoke` 下载对应 RID 的工件 [已核实 `/Users/token/code/StreamMark/.github/workflows/ci.yml`]。 + +``` + ┌─────────────────────────┐ +push / PR ──▶ │ native-ghostty ×6 │──┐ + │ native-tcodepty ×4 │──┤ artifacts: native- + └─────────────────────────┘ │ + ┌───────────────────┼──────────────────────────┐ + ▼ ▼ ▼ + build-test ×3 aot-smoke ×3 esctest (linux-x64) + (L1–L5, Agent E2E) (Desktop + ptyhost AOT, (报告 + 新增失败阻断) + 0 IL 警告, --smoke, + --self-test) + │ + ▼ + bench-pr (linux-x64,base vs head) + +nightly (cron) ──▶ native ×6 ──▶ test-arm (linux-arm64, win-arm64, osx-x64) + nightly-stress (PTY 压力, fuzz-long 1h) + bench-nightly (S1–S15, JIT+AOT) +``` + +| 作业 | 矩阵 | 触发 | 时长目标 | +|---|---|---|---| +| `native-ghostty` | 6 RID(见 §7.3 的 runner) | 每次 push / PR(缓存命中时 < 1 min) | 冷构建 < 10 min | +| `native-tcodepty` | linux(构建 2 个 linux RID)、macOS(构建 2 个 osx RID) | 同上 | < 2 min | +| `build-test` | linux-x64、osx-arm64、win-x64 | 每次 | < 20 min | +| `esctest` | linux-x64 | 每次 | < 5 min | +| `aot-smoke` | linux-x64、osx-arm64、win-x64 | 每次 | < 15 min | +| `bench-pr` | linux-x64 | PR(可通过 label `skip-bench` 跳过) | < 15 min | +| `test-arm` | linux-arm64、win-arm64、osx-x64 | 每晚 | — | +| `nightly-stress` | linux-x64、osx-arm64、win-x64 | 每晚 | < 90 min | +| `bench-nightly` | linux-x64、osx-arm64 | 每晚 | < 120 min | + +### 7.2 缓存策略 + +| 缓存 | 键 | 路径 | +|---|---|---| +| Zig 工具链 | `zig-0.16.0-${{ runner.os }}-${{ runner.arch }}` | `~/.cache/tcode/zig`(Windows:`%LOCALAPPDATA%\tcode\zig`) | +| Zig 包缓存(Ghostty 依赖) | `zigpkg-${{ hashFiles('native/ghostty/ghostty-upstream.json') }}` | `~/.cache/zig/p`(Windows:`%LOCALAPPDATA%\zig\p`)。**不缓存** Zig 的本地编译缓存 `.zig-cache`(RoyalTerminal 的注释:恢复的 Windows 缓存引用了不存在的 `build.exe`,因此关闭了缓存)[已核实 RoyalTerminal `release.yml:251-254`] | +| 原生产物 | `native--<缓存键>`(与 `.stamp` 相同,由 `build-native.sh --print-key` 输出) | `artifacts/native/`。命中时跳过整个构建 | +| NuGet | `nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }}` | `~/.nuget/packages` | +| 基准数据集 | `benchdata-${{ hashFiles('tools/terminal/gen-bench-data/sources.json') }}` | `artifacts/bench-data` | +| esctest2 | `esctest-${{ hashFiles('native/toolchain.json') }}` | `artifacts/src/esctest2` | + +现有依赖的注意事项:`nuget.config` 中 StreamMark 通过**本地 NuGet 源**引用 [已核实 `Directory.Packages.props` 的注释"经本地 NuGet 源引用,见 nuget.config"]。CI 上没有这个本地源——**在 TCode 的 CI 能运行之前,StreamMark 的包必须发布到可访问的源**(GitHub Packages 或 nuget.org),或者在 CI 中先检出 StreamMark 并 `dotnet pack` 到本地目录。列入待决问题。 + +### 7.3 工作流 YAML 骨架 + +`.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + TCODE_ZIG_VERSION: 0.16.0 + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # libghostty-vt:6 个 RID。参数来自 native/ghostty/build-flags.json,YAML 只给出 RID 与 runner。 + native-ghostty: + strategy: + fail-fast: false + matrix: + include: + - { rid: osx-arm64, os: macos-latest } + - { rid: osx-x64, os: macos-latest } # 在 arm64 上交叉编译 + - { rid: linux-x64, os: ubuntu-latest } + - { rid: linux-arm64, os: ubuntu-24.04-arm } + - { rid: win-x64, os: windows-latest } + - { rid: win-arm64, os: windows-latest } # aarch64-windows-gnu 交叉编译 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Cache key + id: key + shell: bash + run: echo "key=$(tools/terminal/build-native.sh --print-key ${{ matrix.rid }} --only ghostty)" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + id: cache + with: + path: artifacts/native/${{ matrix.rid }} + key: native-ghostty-${{ matrix.rid }}-${{ steps.key.outputs.key }} + - uses: actions/cache@v4 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: | + ~/.cache/tcode/zig + ~/.cache/zig/p + ~/AppData/Local/tcode/zig + ~/AppData/Local/zig/p + key: zig-${{ env.TCODE_ZIG_VERSION }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/ghostty/ghostty-upstream.json') }} + - name: Install Zig + if: steps.cache.outputs.cache-hit != 'true' + shell: bash + run: tools/terminal/install-zig.sh # Windows 上由 Git Bash 执行;脚本内部区分平台 + - name: Build (Unix) + if: steps.cache.outputs.cache-hit != 'true' && runner.os != 'Windows' + run: tools/terminal/build-native.sh ${{ matrix.rid }} --only ghostty + - name: Build (Windows) + if: steps.cache.outputs.cache-hit != 'true' && runner.os == 'Windows' + shell: pwsh + run: tools/terminal/build-native.ps1 -Rid ${{ matrix.rid }} -Only ghostty + - name: Verify + shell: bash + run: tools/terminal/verify-native.sh ${{ matrix.rid }} --only ghostty + - uses: actions/upload-artifact@v4 + with: + name: native-ghostty-${{ matrix.rid }} + path: artifacts/native/${{ matrix.rid }}/ + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: symbols-ghostty-${{ matrix.rid }} + path: artifacts/native-symbols/${{ matrix.rid }}/ + if-no-files-found: ignore + + # x64 的 CPU 兼容性:在 Intel SDE(无 AVX 的 Westmere)下加载库并回放夹具 + native-cpu-compat: + needs: native-ghostty + strategy: + fail-fast: false + matrix: + include: + - { rid: linux-x64, os: ubuntu-latest } + - { rid: win-x64, os: windows-latest } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { global-json-file: global.json } + - uses: actions/download-artifact@v4 + with: { name: native-ghostty-${{ matrix.rid }}, path: artifacts/native/${{ matrix.rid }} } + - name: Run under SDE -wsm + shell: bash + run: tools/terminal/cpu-compat.sh ${{ matrix.rid }} # 下载 SDE(pin 版本 + SHA-256),运行 Core.Tests 的 CpuCompat 子集 + + # libtcodepty + tcode-pty-spawn:4 个 Unix RID,两个 runner 各构建两个 + native-tcodepty: + strategy: + fail-fast: false + matrix: + include: + - { rids: "linux-x64 linux-arm64", os: ubuntu-latest, name: linux } + - { rids: "osx-arm64 osx-x64", os: macos-latest, name: osx } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/cache@v4 + with: + path: ~/.cache/tcode/zig + key: zig-${{ env.TCODE_ZIG_VERSION }}-${{ runner.os }}-${{ runner.arch }} + - run: tools/terminal/install-zig.sh + - name: Build + run: for rid in ${{ matrix.rids }}; do tools/terminal/build-native.sh "$rid" --only tcodepty; tools/terminal/verify-native.sh "$rid" --only tcodepty; done + - uses: actions/upload-artifact@v4 + with: + name: native-tcodepty-${{ matrix.name }} + path: artifacts/native/ + if-no-files-found: error + + build-test: + needs: [native-ghostty, native-tcodepty] + strategy: + fail-fast: false + matrix: + include: + - { rid: linux-x64, os: ubuntu-latest, pty: linux } + - { rid: osx-arm64, os: macos-latest, pty: osx } + - { rid: win-x64, os: windows-latest, pty: "" } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { global-json-file: global.json } + - uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.csproj') }} + - uses: actions/download-artifact@v4 + with: { name: native-ghostty-${{ matrix.rid }}, path: artifacts/native/${{ matrix.rid }} } + - uses: actions/download-artifact@v4 + if: matrix.pty != '' + with: { name: native-tcodepty-${{ matrix.pty }}, path: artifacts/native } + - name: Restore executable bits # upload-artifact 不保留权限 + if: runner.os != 'Windows' + run: chmod +x artifacts/native/*/tcode-pty-spawn + - name: Shells for integration tests + if: runner.os == 'Linux' + run: sudo apt-get install -y zsh fish + - run: dotnet build TCode.slnx -c Release + - name: Test + run: dotnet test TCode.slnx -c Release --no-build -- --filter-not-trait "Category=FuzzLong" --filter-not-trait "Category=Stress" + env: + TCODE_SNAPSHOT_DIR: ${{ github.workspace }}/artifacts/snapshots + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.rid }} + path: | + artifacts/snapshots/ + artifacts/test-results/ + if-no-files-found: ignore + + esctest: + needs: [native-ghostty, native-tcodepty] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { global-json-file: global.json } + - uses: actions/download-artifact@v4 + with: { name: native-ghostty-linux-x64, path: artifacts/native/linux-x64 } + - uses: actions/download-artifact@v4 + with: { name: native-tcodepty-linux, path: artifacts/native } + - run: chmod +x artifacts/native/*/tcode-pty-spawn + - name: Fetch esctest2 (pinned) + run: tools/terminal/fetch-esctest.sh # native/toolchain.json 中的提交与 SHA-256 + - name: Run + run: dotnet run -c Release --project tools/terminal/EscTestRunner -- --out artifacts/esctest --known tests/TCode.Terminal.Core.Tests/EscTest/known-failures.txt + - name: Summary + if: always() + run: cat artifacts/esctest/summary.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + if: always() + with: { name: esctest-log, path: artifacts/esctest/ } + + # NativeAOT:发布 Desktop(含 agenthost/ 与 ptyhost/),0 个 IL 警告;运行 --smoke 与 --self-test + aot-smoke: + needs: [native-ghostty, native-tcodepty] + strategy: + fail-fast: false + matrix: + include: + - { rid: linux-x64, os: ubuntu-latest, pty: linux, exe: "", container: "almalinux:8" } # glibc 2.28(§5.3) + - { rid: osx-arm64, os: macos-latest, pty: osx, exe: "", container: "" } + - { rid: win-x64, os: windows-latest, pty: "", exe: ".exe", container: "" } + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + steps: + - name: Container prerequisites + if: matrix.container != '' + run: dnf install -y git tar xz clang zlib-devel libicu fontconfig findutils which # NativeAOT 链接 + 无头 Skia 的字体 + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { global-json-file: global.json } + - uses: actions/download-artifact@v4 + with: { name: native-ghostty-${{ matrix.rid }}, path: artifacts/native/${{ matrix.rid }} } + - uses: actions/download-artifact@v4 + if: matrix.pty != '' + with: { name: native-tcodepty-${{ matrix.pty }}, path: artifacts/native } + - if: runner.os != 'Windows' + run: chmod +x artifacts/native/*/tcode-pty-spawn + - name: Publish + shell: bash + run: | + dotnet publish src/TCode.Desktop -c Release -r ${{ matrix.rid }} -o artifacts/aot 2>&1 | tee publish.log + if grep -E "warning IL[0-9]+" publish.log; then + echo "::error::NativeAOT publish produced IL warnings" + exit 1 + fi + - name: Layout check + shell: bash + run: tools/terminal/check-layout.sh artifacts/aot ${{ matrix.rid }} # ptyhost/ 文件齐全、可执行位、conpty + x64/arm64 OpenConsole、两份 libghostty 字节相同、Linux 上所有 ELF 的 GLIBC_ ≤ 2.28 + - name: ptyhost self-test # 启动 shell、echo、校验输出、resize、快照往返 + shell: bash + run: ./artifacts/aot/ptyhost/tcode-ptyhost${{ matrix.exe }} --self-test + - name: AgentHost self-test + shell: bash + run: ./artifacts/aot/agenthost/TCode.AgentHost${{ matrix.exe }} --self-test + - name: Desktop smoke # 无头:Fake 模型一轮 + 打开终端标签执行 echo + Agent 终端模式 bash(09 §16 S1) + shell: bash + run: ./artifacts/aot/TCode${{ matrix.exe }} --smoke --out=artifacts/smoke.png + - name: Host E2E tests against AOT binaries + shell: bash + run: dotnet test tests/TCode.Terminal.Host.Tests -c Release --filter "Category=AotE2E" + env: + TCODE_TEST_PTYHOST_EXE: ${{ github.workspace }}/artifacts/aot/ptyhost/tcode-ptyhost${{ matrix.exe }} + TCODE_TEST_HOST_EXE: ${{ github.workspace }}/artifacts/aot/agenthost/TCode.AgentHost${{ matrix.exe }} + - uses: actions/upload-artifact@v4 + if: always() + with: { name: smoke-${{ matrix.rid }}, path: artifacts/smoke.png, if-no-files-found: ignore } + + bench-pr: + if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'skip-bench') + needs: [native-ghostty] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: actions/setup-dotnet@v4 + with: { global-json-file: global.json } + - uses: actions/cache@v4 + with: + path: artifacts/bench-data + key: benchdata-${{ hashFiles('tools/terminal/gen-bench-data/sources.json') }} + - name: A/B(base 与 head 在同一台机器上) + run: tools/terminal/bench-ab.sh "${{ github.event.pull_request.base.sha }}" HEAD --profile pr + - run: cat artifacts/bench-ab/summary.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: { name: bench-pr, path: artifacts/bench-ab/ } +``` + +`.github/workflows/nightly.yml`(骨架): + +```yaml +name: Nightly +on: + schedule: [{ cron: "17 3 * * *" }] + workflow_dispatch: +jobs: + native: + uses: ./.github/workflows/native.yml # 把 native-ghostty / native-tcodepty 抽成可复用工作流,ci.yml 与 nightly.yml 共用 + test-arm: + needs: native + strategy: + fail-fast: false + matrix: + include: + - { rid: linux-arm64, os: ubuntu-24.04-arm } + - { rid: win-arm64, os: windows-11-arm } + - { rid: osx-x64, os: macos-15-intel } + runs-on: ${{ matrix.os }} + steps: [ "…与 build-test 相同,外加 aot-smoke 的发布与自检…" ] + nightly-stress: + needs: native + strategy: { matrix: { os: [ubuntu-latest, macos-latest, windows-latest] } } + runs-on: ${{ matrix.os }} + steps: [ "dotnet test … --filter-trait Category=Stress", "dotnet test … --filter-trait Category=FuzzLong(仅 Linux,1 h)" ] + bench-nightly: + needs: native + strategy: { matrix: { os: [ubuntu-latest, macos-latest] } } + runs-on: ${{ matrix.os }} + steps: [ "dotnet run -c Release --project tests/TCode.Terminal.Benchmarks -- --profile nightly --runtimes net10.0 nativeaot10.0 --exporters json github", + "推送结果到 bench-history 分支;回退 > 10% 时开 issue" ] +``` + +runner 标签(`ubuntu-24.04-arm`、`windows-11-arm`、`macos-15-intel`、`macos-latest`)与 StreamMark 当前使用的一致 [已核实 StreamMark `ci.yml`]。 + +--- + +## 8. 升级流程 + +### 8.1 Ghostty 升级 + +| 步 | 内容 | +|---|---| +| 1 | 选目标提交(优先 tag 或经过上游 CI 全绿的提交);阅读两次 pin 之间 `include/ghostty/vt/*.h` 的 diff(API 标为 work-in-progress,不稳定 [已核实 research/04 §0.8]),以及 `test/fuzz-libghostty`、`test/esctest` 的变化 | +| 2 | 更新 `ghostty-upstream.json`(commit、date、versionString);逐个重放 overlay:`git apply --check`,失败的补丁重写或删除(上游已修复时) | +| 3 | 本地 `build-native.sh --force`;重新生成 `exports-.txt`,审阅新增 / 删除的符号;同步 C# 绑定(`TCode.Terminal.Core/Interop/Ghostty`) | +| 4 | 若 `minimum_zig_version` 变化 → 先走 §8.3 | +| 5 | 运行全部测试;黄金样本若有变化,逐个确认是上游行为修正还是回退(不允许不看就 `TCODE_UPDATE_GOLDEN`);esctest known-failures 更新 | +| 6 | 快照格式:libghostty 快照 v1 **不保证二进制兼容** [已核实 04 §18 第 4 条]。升级后 revive 依赖 `tail.vt` 回退;在测试中用旧版本生成的 `snapshot.bin` 验证回退路径 | +| 7 | `bench-pr` 自动标记"原生库版本变更",人工审阅吞吐变化 | +| 8 | PR 描述列出:上游提交范围、API 变化、补丁变化、黄金样本变化、基准对比 | + +### 8.2 ConPTY 升级 + +1. 读 `Microsoft.Windows.Console.ConPTY` 新版本的发行说明(以及对应的 Windows Terminal 发行说明); +2. 只改 `Directory.Packages.props` 中的版本——`conpty.dll` 与 `OpenConsole.exe` 来自同一个包,天然成对(WezTerm 曾因只升级其一导致 pwsh 崩溃 [已核实 research/02 §3.4]); +3. 在 Windows 10 1809(最低支持,01 §1)虚拟机、Windows 11 x64、Windows 11 arm64 上跑 L2 ConPTY 组与 L5b; +4. 检查 `dwFlags` 定义是否变化(0x8 / 0x10 曾被重新定义为字宽模式 [已核实 research/02 §3.2]); +5. 预览版(如 1.26.x-preview)不进入发布分支。 + +### 8.3 Zig 升级 + +Zig 版本由 Ghostty 决定:只有当 pin 的 Ghostty 提交要求新版本时才升级(升级 Zig 的同时必然升级 Ghostty)。 + +1. 更新 `native/toolchain.json`(版本 + 6 个 tarball 的 SHA-256,取自 `https://ziglang.org/download/index.json`); +2. 重新评估 `build-flags.json`:win-arm64 的 `-Dsimd=false` 原因是 Zig 0.16.0 的标准库问题(§4.3),新版本可能已修复;`zig cc` 的 glibc 目标写法是否变化; +3. Linux 的 libtcodepty 与 musl 静态 helper 用同一个 Zig 编译(macOS 用 Apple clang,不受影响),跑 L2 全部用例(03 §14); +4. 清空 CI 的 Zig 缓存(缓存键含版本号,自动失效); +5. 在开发者文档中提醒更新本地 Zig(构建脚本会因版本不符直接报错)。 + +--- + +## 9. 故障排查 + +| 现象 | 原因 | 处理 | +|---|---|---| +| `build-native` 报 "zig 0.17.0 is not 0.16.0" | PATH 中是 Homebrew 的新版 | 运行 `tools/terminal/install-zig.sh`,或设置 `TCODE_ZIG` | +| `error: unable to find Visual Studio installation` / 找不到 MSVC 头文件(win-x64) | 没有 VS Build Tools,或在 Linux / macOS 上尝试构建 `*-windows-msvc` | 在 Windows 上安装"使用 C++ 的桌面开发"工作负载;msvc 目标不能交叉编译 [已核实 上游注释] | +| win-arm64 构建在标准库的 stack-trace 代码处编译失败 | Zig 0.16.0 + SIMD 归档的已知问题 | 确认 `build-flags.json` 中有 `-Dsimd=false` [已核实 RoyalTerminal 注释] | +| macOS 目标报 Apple SDK 路径错误 | 在 Linux 上构建 osx RID,或 `xcode-select` 指向了错误位置 | macOS 目标只能在 macOS 上构建;`sudo xcode-select -s /Applications/Xcode.app` | +| `DllNotFoundException: ghostty-vt` | `artifacts/native//` 为空,或 Debug 构建的 RID 与产物 RID 不同(例如 Rosetta 下运行 x64 SDK) | 运行 `build-native.sh `;`dotnet --info` 检查 RID | +| macOS:`Library not loaded … image not found` 或 `code signature invalid` | 改过 install name 后没有重新签名;hardened runtime 下 dylib 的 Team ID 不同 | `codesign -s - -f`(开发);打包时全部用同一证书签名 | +| Linux 发布物在 RHEL 8 上报 `GLIBC_2.3x not found` | 某个原生库链接了新版 glibc 符号(忘了 `.2.28` 后缀,或用了系统 gcc) | `verify-native` 的 glibc 检查;所有 Unix 原生代码都用 `zig cc -target …-gnu.2.28` | +| 用户机器上 SIGILL / `0xC000001D`(非法指令) | 原生库包含 runner CPU 的指令(构建时没有显式 `-Dtarget`),或 SIMD 代码未做运行时分派 | 检查 `build-flags.json`;`native-cpu-compat` 作业必须通过;win-x64 退回标量构建 | +| Windows 终端行为异常(OSC 丢失、resize 整屏重画、清屏后旧内容回来) | 加载了 System32 的 conhost,而不是随包的 OpenConsole | `--self-test` 输出加载路径;检查 `ptyhost\OpenConsole.exe` 是否存在(conpty.dll 找不到时静默回退 [已核实 research/02 §3.4]) | +| Linux:AOT 二进制在 RHEL 8 上报 `GLIBC_2.3x not found`(原生库检查通过) | AOT 主程序在新 glibc 的 runner 上发布 | 在 `almalinux:8` 容器中发布(§5.3) | +| Unix:`posix_spawn` 返回 EACCES,或子进程立即退出码 126 | `tcode-pty-spawn` 丢失可执行位(工件下载、zip 打包) | CI 中 `chmod +x`;Linux 用 tar.gz;`check-layout.sh` 检查 | +| AOT 发布出现 `IL2026` / `IL3050` | 新代码用了反射或运行时封送 | 改为 `LibraryImport` + `DisableRuntimeMarshalling`、源生成 JSON(01 §1 的约束);不允许用 `NoWarn` 压制 | +| `esctest` 大量失败且集中在某一类 | 未开启 DECRQCRA 校验和选项,或屏幕不是 80×25 | 检查 `TestMode` 是否设置 `XT_CHECKSUM_REPORT` 与 `XT_CHECKSUM_EXTENSION = 23` [已核实 `terminal.h:2471-2505`] | +| CI 上 `dotnet restore` 找不到 StreamMark 包 | `nuget.config` 指向本地源 | 见 §7.2 末尾,需要发布 StreamMark 包 | +| overlay 补丁 `git apply --check` 失败 | 上游改动了补丁涉及的代码 | 按 §8.1 第 2 步重写补丁;确认上游是否已修复 | +| `zig build` 下载依赖失败(网络受限) | Ghostty 的依赖从 `deps.files.ghostty.org` 与 GitHub 下载 [已核实 `build.zig.zon`] | 预先 `zig build --fetch` 填充 `~/.cache/zig/p`;CI 中缓存该目录 | + +--- + +## 10. 待决问题与发现的问题 + +1. **TCode 目前没有任何 CI**(无 `.github/`)。本文的 `ci.yml` 同时承担现有项目的测试;StreamMark 依赖本地 NuGet 源,CI 跑起来之前必须先解决(§7.2)。 +2. **`LSMinimumSystemVersion` = 11.0 低于 .NET 10 的下限(15.0)与 libghostty-vt 的下限(13.0)**(§6.2);01 §1 的平台表没有写 macOS 最低版本,建议补上 15.0。 +2a. **Linux AOT 主程序的 glibc 基线**:在 Ubuntu runner 上发布的 AOT 二进制无法在 RHEL 8 上运行,必须在 glibc 2.28 容器中发布(§5.3);这同样影响现有的 Desktop 与 AgentHost。 +3. **win-x64(以及 win-arm64)可能使用标量构建**,与 01 §1"ASCII ≥ 500 MB/s"的性能目标冲突(§4.3);需要决定是放宽该 RID 的目标,还是以 CPU 兼容性验证为前提使用 SIMD 构建。 +4. **04 §1.1 把 `ghostty-build.txt` 放在 `Contents/MacOS/` 下**,会影响 macOS 签名 / 公证;建议移到 `Contents/Resources/`(§6.2),需要 04 的作者确认。 +5. `-Dversion-string` 与 `-Dlib-version-string` 哪一个进入 lib-vt 的 `build_info`(§4.1),M0 确认。 +5a. **03 §14 另设计了一个 `.github/workflows/terminal-pty.yml`**(test-rhel8、ASan 构建、macos-26 runner、Windows 降级路径)。建议并入本文的 `ci.yml` / `nightly.yml`(PTY 用例本来就在 `build-test` 中运行;ASan 与 Windows 降级矩阵放进 nightly),避免两套原生构建作业。 +6. 01 §4 的目录结构中 `native/ghostty/` 写的是"构建脚本",本文把脚本放在 `tools/terminal/`,`native/` 只放 pin、补丁与 C 源码——与 01 不矛盾,但建议 01 的注释同步。 + +--- + +## 来源 + +- 本仓库:`global.json`、`Directory.Build.props`、`Directory.Packages.props`、`nuget.config` 的用法注释、`src/TCode.Desktop/TCode.Desktop.csproj:36-54`、`src/TCode.AgentHost/TCode.AgentHost.csproj`、`tools/package_macos_app.sh`、`src/TCode.Desktop/SmokeMode.cs`、`src/TCode.AgentHost/SelfTest.cs`;仓库根目录无 `.github/`(2026-10-16 检查)。 +- StreamMark(本地):`.github/workflows/ci.yml`、`native/build.sh`、`native/build.ps1`、`Directory.Build.targets`、`src/StreamMark.RaTeX/StreamMark.RaTeX.csproj`。 +- Ghostty `ghostty-org/ghostty@b3dc3d7`:`build.zig.zon`(`minimum_zig_version`、依赖哈希)、`src/build/Config.zig`(`simd`、`strip`、`version-string`、`osVersionMin`)、`src/build/GhosttyLibVt.zig`(产物命名、静态库处理)、`example/c-vt-static/build.zig`、`.github/workflows/test.yml`(`build-libghostty-vt*`、`test-lib-vt-pkgconfig`、`test-esctest`)。 +- RoyalTerminal `royalapplications/RoyalTerminal@01833b8`:`.github/workflows/release.yml`(Windows / Linux 的 zig 参数与注释)。 +- Zig 下载索引:https://ziglang.org/download/index.json (2026-10-16 读取:0.16.0 发布于 2026-04-13)。 +- 研究文档:[research/01](research/01-emulator-cores.md) §4.1.4–4.1.7;[research/02](research/02-pty.md) §3.2、§3.4、§4、§5、§9;[research/04](research/04-rendering-performance.md) §0.8、§2.9、§11;[04-pty-host.md](04-pty-host.md) §1.1、§2.4、§15、§18。 diff --git a/docs/terminal/13-roadmap.md b/docs/terminal/13-roadmap.md new file mode 100644 index 0000000..6e87242 --- /dev/null +++ b/docs/terminal/13-roadmap.md @@ -0,0 +1,254 @@ +# TCode 终端:路线图与验收 + +> 状态:设计稿 v1(2026-10-17)。本文把 01–12 中的实施拆分、spike 和待决问题汇总为一份按里程碑排列的计划。工期是**单人全职的粗略估计** [推断],M0 完成后按实测修订。 + +## 1. 共同的完成标准 + +每个里程碑都要满足以下几条才算完成: + +1. **AOT**:`dotnet publish -c Release -r `(Desktop 和 tcode-ptyhost)0 个 IL2xxx/IL3xxx 警告,并且实际运行过发布后的二进制([12](12-build-and-ci.md))。 +2. **平台**:在 macOS arm64、Windows x64、Linux x64 三台机器上跑过本里程碑的测试,其余 RID 至少在 CI 中构建并加载成功。 +3. **测试**:[11](11-testing-benchmarks.md) §5 中本里程碑对应的测试全部进入 CI。 +4. **文档**:实现中偏离设计的地方,要回写到对应的设计文档里。 + +## 2. 依赖关系 + +``` +M0 Spike ──▶ M1 核心 + PTY ──▶ M2 pty host ──▶ M3 视图 ──▶ M4 集成 ──▶ M5 Agent + │ │ + └──▶ M6 R1 + 图形 ◀─────┘ + M4 ──▶ M7 SSH 一期 +``` + +M3 的视图可以先接**回放连接**开发([01](01-architecture.md) §6.5 的 `ITerminalViewSession`),不必等 M2 完成;所以 M2 与 M3 可以部分并行。 + +## 3. 里程碑 + +### M0 Spike(约 2 周) + +目标:把所有"能不能做"的问题在写正式代码之前验证掉。 + +| 任务 | 文档 | 通过标准 | +|---|---|---| +| libghostty-vt 在 6 个 RID 上构建(Zig 0.16.0)、加载、通过一个回放用例;Windows x64 反汇编确认没有 AVX | [02](02-core-ghostty.md) S1、[12](12-build-and-ci.md) §4 | 6 个 RID 的 CI 全绿 | +| overlay O1–O4 能打上并通过上游测试;O1 后 `ModePacked` 的位宽与快照编解码 | 02 §2.4、S13 | 补丁可应用,快照往返通过 | +| 绑定生成器原型(ClangSharp);按值传结构体的 ABI 往返;`terminal_get_multi` 行为 | 02 S2、S3、S15 | 6 个 RID 往返测试通过 | +| libtcodepty + helper 在 4 个 Unix RID 上构建;`tcpty_spawn` 往返;ConPTY 往返(DA1 握手) | [03](03-pty.md) | 三个平台上 `echo` 往返成功 | +| macOS login shell:argv0 `-zsh` 直接启动(不经 login),断言退出码和 PATH | 03 §8.3、07 §3.2 | `exit 3` 得到退出码 3;Homebrew 路径在 PATH 中 | +| `Control.Render` 画出一个静态网格(含宽字符、box drawing) | [05](05-rendering.md) | 截图测试通过 | +| AOT 二进制中运行 `vim` 并退出 | 11 §5 | 人工 + 冒烟 | +| 建立 CI(TCode 目前没有 CI;需要先解决 StreamMark 本地源的问题) | 12 §7 | `ci.yml` 跑通 | + +#### M0 实施状态(2026-10-17) + +| 任务 | 状态 | 说明 | +|---|---|---| +| libghostty-vt 构建 | **完成(本机)**:osx-arm64 构建并通过全部测试;linux-x64、win-arm64 从 macOS 交叉编译成功;6 个 RID 待 CI 验证 | `tools/terminal/build-ghostty.sh`;Zig 0.16.0 由 `tools/terminal/install-zig.sh` 按 `native/toolchain.json` 安装并校验 SHA-256。linux-x64 剥离后约 2.2 MB,glibc 最高 2.27。**win-arm64 开启 SIMD 也能编译**(12 号文档的担忧未出现,运行时待验证) | +| overlay O1–O4 | **完成** | `native/ghostty/overlays/0001`(O1 9001、O2 停用 5522)、`0002`(O3 延后应答 + 终止符、O4 modifyOtherKeys);macOS 的导出列表追加 `_tcode_ghostty_*`。C 检查(`native/ghostty/test/overlay_check.c`)13/13,C# 测试覆盖同样的行为;O1 后快照往返保留 9001(S13) | +| 绑定 | **M0 手写子集**(约 40 个函数),生成器(S3)推迟到 M1 | `TCode.Terminal.Core/Interop`;结构体布局用 `ghostty_type_json()` 清单逐字段校验。注意 `DisableRuntimeMarshalling` 下 `Marshal.SizeOf/OffsetOf` 测的是封送布局,不能用于校验 | +| Unix PTY | **完成**:libtcodepty(spawn、管道模式 spawn、gather 读、wait、resize、退出监视、信号、进程信息)+ helper(PTY / `--exec` / `--exec-cwd` / `--detach`) | `native/tcodepty/build.sh`;macOS clang,Linux `zig cc`(glibc 2.28、helper musl 静态);`-Werror` 通过。C 冒烟测试 9/9;C# 测试 11/11(控制终端、cwd、像素 resize、退出码、信号、SIGPIPE 复位、前台进程、挂断关闭、vim) | +| Windows ConPTY | **代码完成,未运行**(本机无 Windows) | `WindowsPtyConnection`:绝对路径加载随包 conpty.dll,否则回退 kernel32;读线程先于 CreateProcess;Close 在独立线程;每会话 Job。待 CI 验证;随包 conpty.dll / OpenConsole.exe 的打包放到 M1 | +| macOS login 退出码 | **取消**(2026-10-17 决定不经 login) | — | +| `Control.Render` 静态网格 | **完成** | `TCode.Terminal.Avalonia.TerminalView`(R3):背景合并、按格 GlyphRun(x 对齐网格)、逐码点回退字体、四种光标;截图 `terminal-static-grid.png` 已人工检查(颜色、粗体、truecolor、CJK 宽字符、box drawing、光标)。`CaptureFrame` 暂为逐格读取,增量算法在 M1 | +| AOT 运行 vim | **完成(osx-arm64)** | `tcode-ptyhost --self-test`:AOT 发布 0 个 IL 警告,二进制 1.8 MB;自检覆盖核心解析、O1、快照往返、PTY 往返与退出码、vim 进入备用屏并 `:q!` 退出 | +| CI | **工作流已写好,未推送**(`.github/workflows/terminal.yml`,actionlint 通过) | 只构建终端项目,不依赖 StreamMark。全仓库 CI 需要 StreamMark 子模块指向包含当前 `StreamMarkVersion` 的已推送提交 | +| StreamMark 子模块 | **完成** | `external/StreamMark`(074f7ff);`nuget.config` 改为 `packages/streammark/`;`tools/streammark/pack.sh [源码目录] [版本]` | + +M0 中发现、需要后续处理的: + +- Zig 0.16 的 HTTP 客户端经本机 HTTP 代理时报 `HttpConnectionClosing`;`tools/terminal/prefetch-zig-deps.sh` 用 curl / git 预取依赖(CI 不需要)。 +- 快照要求开启续传跟踪(`CONTINUATION_MAX_BYTES`),否则在转义序列中途编码会返回 `INVALID_VALUE`;`TerminalCoreOptions.ContinuationMaxBytes` 默认 8 MiB,恢复时同时设置解码器的 `RETAIN_CONTINUATION`。 +- libghostty 的版本字符串是 libghostty-vt 自己的 `0.1.0-dev`,不是 Ghostty 应用版本;`ghosttyBuildId` 依靠库文件 SHA-256 区分构建。 +- 空屏时 formatter 返回空指针(已处理)。 + +### M1 核心与 PTY(约 4–5 周) + +| 任务 | 文档 | +|---|---| +| `TCode.Terminal.Core`:151 个函数的绑定、门面 API、`FrameSnapshot` 复制算法、`InputModes`、键盘 / 鼠标 / 粘贴编码、选区、搜索、formatter、CommandTracker | 02 | +| 差分测试(增量 vs 全量);核对 RoyalTerminal 修过的问题中在 HEAD 仍存在的 6 个 | 02 §15 | +| esctest2 驱动进 CI;黄金样本回放;fuzz 子集 | 11 | +| `TCode.Terminal.Pty`:Unix(spawn、gather 读线程、写线程、退出监视、关闭语义、前台进程、cwd、管道模式启动)与 Windows(ConPTY、Job、宽度标志、W1–W12 对策) | 03 | +| PTY 压力测试(每晚):`cat` 1 GB、50 个并发会话、快速开关、resize 风暴、`yes \| head` | 03 §14 | + +验收:Tier 0 全部通过([10](10-protocol-compat.md) §7.1);ASCII 解析吞吐达到实测修订后的目标。 + +#### M1 实施状态(2026-10-17) + +| 阶段 | 状态 | 内容 | +|---|---|---| +| A | **完成** | 绑定生成器(`tools/terminal/ghostty_bindgen.py`,类型取自 `ghostty_type_json()`,函数取自头文件);增量 `CaptureFrame`(只重建脏行、按内容哈希复用行对象);差分测试;overlay O5(漏标脏行) | +| B | **完成** | `ITerminalEffects`(剪贴板、通知、进度、标题、cwd…);核心应答的查询(DA1/DA2/XTVERSION/尺寸/配色);OSC 133/633 `CommandTracker` | +| C | **完成** | `InputModes`;按键 / 鼠标 / 焦点编码;粘贴安全检查与 bracketed paste | +| D | **完成** | 选区(范围、矩形、词、行、全部、命令输出)与选区文本;视口滚动(顶 / 底 / 行 / 指定行 / 按提示符);搜索(`TerminalSearch`,视口高亮在捕获时换算);OSC 8 超链接解析进帧(`FrameSnapshot.Links` + `ResolvedStyle.HyperlinkId`,同一地址跨帧 id 不变,上限 65535)。帧新增 `Selection` / `Search` / `Links`,仅选区或搜索变化也会产生新帧 | +| E | **完成** | 随包 ConPTY:`native/ConPty.targets`(不用包自带 targets,见 12 §5.5),CI 检查布局、自检断言 Bundled;Windows 前台进程:`ProcessTree`(Toolhelp32 + `GetProcessTimes` 防 pid 复用,排除 conhost / OpenConsole),纯函数部分跨平台测试;PTY 压力测试 T10–T14(`PtyStressTests`,默认 CI 规模,`TCODE_PTY_STRESS=1` 按文档规模,每晚运行) | +| F | **完成** | `.grid` 快照格式(`TCode.Terminal.Core.Testing.GridSnapshot`)与 asciicast 读取(`Cast`);黄金样本:xterm.js 76 个 + Alacritty 45 个(`tools/terminal/fixtures/convert-fixtures.py`),每个夹具三项断言(上游参照文本、`.grid` 回归锁定、分块不变性);fuzz:上游 AFL++ 语料 PR 子集 694 个 + 结构化生成器 20 种子 × 1 MiB,不变量含增量 = 全量、宽字符成对、快照往返;esctest2 驱动 `tools/terminal/EscTestRunner`(一致性测试模式 `TerminalCoreOptions.Conformance`),CI 作业以 known-failures 为门禁 | + +阶段 D 的实测: +- 行选区(`select_line`)止于最后一个非空格列,不是整行宽度;视图按选区区间绘制即可,不要自行补齐。 +- 选区与搜索匹配都是未跟踪的 grid ref,只在下一次终端变更前有效,所以视口区间在 `CaptureFrame` 内换算,不跨线程传递 grid ref。 +- 超链接 id 是捕获器内部编号:两个捕获器(例如差分测试的增量 / 全量)对同一地址可能给出不同 id,比较时按地址比较。 + +阶段 E 的实测: +- macOS 内核在输出队列满、写端阻塞时会重复输出 ONLCR 插入的 `\r`(`y\r\r\n`),不经本库的 Python 慢读者同样复现;显示上无差别。T10 单独计数这类字节,其余任何偏差都算失败。 +- 背压:消费方暂停 2 s 后积压约 263–266 KiB(上限 256 KiB + 一次 64 KiB 读取)。 +- 吞吐:1 GiB 约 23–34 MiB/s(macOS arm64),与 Python 直接读 PTY 相同,见 03 §14 T11。 +- fd 基线要在首个会话之后取:退出监视器、线程池等进程级单例在首次使用时创建,不算泄漏。 + +阶段 F 的实测: +- **参照文本**:121 个夹具中 96 个与上游参照(xterm / Alacritty 的实际输出)完全一致;25 个登记在 `Fixtures/known-differences.txt`。其中 8 个是 Alacritty 参照本身与 xterm 不同(不支持 DECSCA、忽略 DECCOLM);其余是 libghostty 与 xterm 的差异,最集中的一类是**带多个参数的光标移动**(`CSI 3;5 A`:xterm 取第一个参数,libghostty 忽略整个序列,6 个夹具),其次是未实现的 VPB、SR/SL、DECIC/DECDC。 +- xterm.js 的参照经由 tty 生成,转换时必须按 ONLCR 把 `\n` 换成 `\r\n`;Alacritty 的 `grid.json` 是环形缓冲(`inner[0]` 为最底行),制表符本身存在起始格。 +- **快照往返**(fuzz 不变量)发现 libghostty 快照不保存两项状态:mode 2026(同步输出,临时状态,可接受)与直接用 `CSI ? 12 h/l` 设置的光标闪烁(经 DECSCUSR 设置的会保存)。后者影响 pty host 重连后的光标闪烁,列为 overlay 候选(§5 第 11 项)。 +- **esctest2**(macOS arm64,与 Ghostty 同一 esctest 提交):387 通过、42 个 xterm 已知缺陷、138 失败;通过率 387 / 525 = 73.7%。失败中 44 个是查询没有应答(OSC 5 特殊颜色、若干 DECDSR),其余为应答值或屏幕内容不同;分类见 `EscTest/known-failures.txt`。11 §1.1.4 的"Tier 0/1 相关文件通过率 ≥ 95%"尚未按 Tier 拆分统计,留到 M3 与 10 §7 的 Tier 表对照后再评估。 +- esctest 偶发整轮失败(启动时第一个应答丢失,之后全部超时),驱动会整轮重跑一次;新增失败会单独重跑,单独能通过的只记为不稳定。 + +### M2 pty host(约 3–4 周) + +按 [04](04-pty-host.md) §17 的 7 步:协议与客户端 → host 核心(监听、认证、actor、日志、流控)→ attach 快照与多客户端 → 发现、启动、心跳、宽限期 → 持久化与 revive → 受限令牌与 `command/*` 接口 → 升级并存。外加 04 §18.1 采纳的补充(BaseEnvironment、集成等级、`setPersist` 等)。 + +验收:Desktop 被杀后重启,标签和内容完全恢复;host 崩溃后 Desktop 显示"会话已丢失"并能在原目录重开。 + +#### M2 实施状态(2026-10-17) + +| 步骤 | 状态 | 内容 | +|---|---|---| +| 1 协议与客户端 | **完成** | `TCode.Terminal.Protocol`:12 字节帧头、`Envelope`(`Utf8JsonReader` 扫描,不建 JsonDocument)、源生成 `PtyHostJsonContext`、HMAC 握手、`PtyConnection`(读 / 写线程、控制优先 + 数据轮转、Ping)、`PtyHostClient` / `PtySessionHandle` / `IPtySessionSink` | +| 2 host 核心 | **完成** | `PtyHostServer`(监听、对端 uid 校验、认证超时、按方法分派)、会话 actor(命令与输出块同一队列)、`OutputLog`(64 KiB 记录的环、二分查找)、流控(256 / 64 KiB,4 块在途,慢订阅者 10 s 后 resync) | +| 3 attach | **完成** | 原子 attach(取 S、编码快照、登记游标)、`resumeFrom`、VT 格式、多客户端、尺寸所有者(最近获得焦点者) | +| 4 发现与启动 | **完成** | `PtyHostPaths` / `PtyHostLocator`(生命周期锁判活、start.lock 串行化、READY 行)、Unix 经 `tcode-pty-spawn --detach`、Windows `CreateProcessW` + breakaway(含 Job 回退)、`HostLifetime`(宽限期只计清醒时间、到期保留有程序在运行的会话、空 host 60 s 退出)、SIGTERM / SIGHUP、`HostLog` | +| 5 持久化 | **完成** | `PersistenceStore`(0700 / 0600、原子写入、meta 最后写)、裁剪快照 + `tail.vt` 回退、定时保存只写有变化的会话、`onQuit` 三种模式、SIGTERM 1.5 s 内保存、revive 推入回滚 + 分隔文字 + 干净屏幕 | +| 6 受限令牌 | **部分** | `auth/mintToken` / `revoke`、按权限列拒绝、agent 只能访问 agent 会话与共享会话、shell 环境去掉 `TCODE_PTYHOST_*`。`command/*` 的语义属于 09,随 M5 实现;AgentHost 的环境变量注入属于 Desktop 集成(M4) | +| 7 升级并存 | **完成(基础)** | 客户端的 ghosttyBuildId 与 host 不同 → 连接标为外来构建:attach 强制 VT 格式、create 返回 `HostDraining` 并使 host 进入排空、最后一个会话关闭后退出。主版本不同时的引导子集流程由 Desktop 实现(M4) | + +验证:`TCode.Terminal.Protocol.Tests` 11 个、`TCode.Terminal.Host.Tests` 28 个(进程内 host + 真实客户端 + 真实 PTY;发现类用例启动真实 host 进程,CI 中另用 AOT 发布的 host 跑一遍);AOT 发布 0 个 IL 警告。 + +尚未做(列入 M3 / M4 或待验证): +- Windows 命名管道仍用 .NET 的 `CurrentUserOnly`,04 §2.1 要求的 `PIPE_REJECT_REMOTE_CLIENTS` 与 `FILE_FLAG_FIRST_PIPE_INSTANCE`(自己调 `CreateNamedPipeW`)未做;客户端侧的服务端 SID 校验依赖 `CurrentUserOnly`。 +- Windows 关机时的隐藏窗口(`WM_ENDSESSION`,§12.3)未做;当前只靠定时保存兜底。 +- `session/foregroundChanged` 轮询、`session/inputOverflow`(8 MiB 输入上限)、环在空闲 60 s 后缩小、按回滚大小预估编码耗时(18.1 第 10 条)未做。 +- profile 探测与 shell 集成注入(`profiles/list`、`shellIntegration`)属于 `TCode.Terminal.Shell`(M4);当前 `session/create` 只支持显式命令与系统默认 shell。 + +M2 中的实测与设计调整: +- **受限令牌的握手**:serverProof 原本只用完整令牌计算,持受限令牌的 AgentHost 无法验证 host。改为 hello 带上非秘密的 `tokenId`,host 用对应令牌计算 serverProof、认证时只验证这一个令牌;未知 tokenId 用随机密钥作答,不泄露哪些 id 存在(04 §2.4 已同步)。 +- **create 带 attach 的顺序**:会话 id 在应答前未知,客户端在读线程上收到应答时(`IPtyConnectionHandler.OnResponse`)同步登记 sink 并调用 `OnAttached`;host 在应答排队之后才把订阅挂到写线程上,所以快照块不会先于应答。 +- **裁剪粒度**:libghostty 的回滚上限按页生效,`scrollbackLines = 100` 实际保留约 330 行(≥ N,符合 §10.3)。 +- **宽限期到期后**:保留下来的会话在程序结束后才关闭,host 每 2 s 复查一次;全部关闭后以 `orphanTimeout` 退出。 + +### M3 视图(约 4–5 周) + +| 任务 | 文档 | +|---|---| +| `TerminalPainter`、R3 宿主、行缓存、cell 度量、字体回退、彩色 emoji、sprite、下划线样式、光标、选区、搜索高亮、最小对比度 | [05](05-rendering.md) | +| 按键路由、KeyDown / TextInput 分工、9001、IME、鼠标上报、选区手势、剪贴板、粘贴确认、链接、搜索栏 | [06](06-input.md) | +| 可访问性 v1:AutomationPeer、播报、可访问缓冲视图 | 06 §10 | +| 27 个截图用例 | 05 §13 | + +验收:截图测试通过;基准 S1、S3、S4、S5 达标;输入延迟自动测量部分达标。 + +#### M3 实施状态(2026-10-17,首轮对接) + +本轮把终端接进了桌面端的工作台标签("终端"工具每次新建一个会话,在项目目录启动)。 + +| 项 | 状态 | 说明 | +|---|---|---| +| 桌面端接入 | **完成** | `TCode.Desktop` 引用 `TCode.Terminal.Avalonia` / `Protocol`;pty host 按 AgentHost 的方式复制到 `ptyhost/`(开发构建)并 AOT 发布到 `ptyhost/`(`PublishPtyHost`)。`PtyHostService` 负责发现 / 启动 / 配置当前构建的 host | +| 会话管道 | **完成** | `TerminalSession`:UI 侧核心只在一个工作线程上使用(写入、记录、选区、滚动、捕获帧都经同一队列);帧在 UI 线程上合并为最新一帧;每批处理完 Ack;resize 只在 Record 到达后生效(01 §3.2) | +| 标签与生命周期 | **完成** | `TerminalTabViewModel`:启动、退出后按 Enter 重开、host 断开后在相同目录重开、关闭时有程序运行则保留标签并提示。`Workbench` 的终端标签不再是占位 | +| 键盘 | **完成(基础)** | `TerminalView` 获得焦点并转发 KeyDown / KeyUp / TextInput;物理键映射(`TerminalKeys`,有单测);编码交给 `InputEncoder`,模式取自工作线程的缓存。⌘C / Ctrl+Shift+C 复制(有选区时),⌘V / Ctrl+Shift+V 粘贴,⌘K 清屏(macOS) | +| 粘贴 | **完成** | bracketed paste 编码(2004);包含换行等不安全内容先进入确认栏([TerminalTabView](../../src/TCode.Desktop/Views/TerminalTabView.axaml)),确认后以 allowUnsafe 发送,取消则丢弃 | +| 指针 | **完成(基础)** | 单击清除选区,拖动选区,双击选词,三击选行;程序开启鼠标模式时编码鼠标报告(Shift 绕过);滚轮:鼠标模式上报、备用屏转方向键(1007)、否则滚动回滚 | +| 链接 | **完成(基础)** | ⌘/Ctrl+点击打开 OSC 8 链接,仅 http / https / mailto | +| 剪贴板写入 | **完成** | OSC 52 写入转到系统剪贴板(按 `oscWrite` 策略) | +| 剪贴板读取 | **未做** | `ui/clipboardRead` 直接拒绝(需要询问界面) | +| 输入法(IME) | **完成(基础)** | `TerminalView` 提供 `TextInputMethodClient`:光标矩形(候选窗口位置)、preedit 支持;平台推送的组合文字画在光标处、带下划线,提交后经 TextInput 发送,失去焦点时中止组合。验证:组合文字的绘制与清除、宽字符对齐的 headless 快照;真实系统输入法未在本机验证 | +| 搜索栏 | **完成** | ⌘F(macOS)/ Ctrl+Shift+F 打开,浮在终端右上角;搜索词变化即时搜索(UI 侧核心,不经 host);"当前/总数";Enter / Shift+Enter 或箭头切换;Esc 关闭并清除高亮。高亮来自帧里的 `SearchHighlights`。验证:计数与切换的集成测试(真实会话)、关闭后高亮清除、headless 截图 | +| 关闭运行中的会话 | **完成** | 有程序在运行时 host 不关闭,标签显示确认栏,确认后强制关闭并移除标签 | +| 验证 | **完成(部分)** | 键映射单测(23 项);会话管道集成测试 6 项(真实 host + 真实 PTY);视图 headless 快照(状态层、颜色、宽字符、反色);AOT 发布 0 个 IL 警告,冒烟 `TCode --smoke` 在发布二进制中启动终端并看到 shell 输出 | + +本轮发现并修掉的问题: +- **helper 拒绝只有 argv0 的启动**(`tcode-pty-spawn` 的参数个数检查为 `argc < 5`,默认 shell 不带参数时被拒,errno 22)。冒烟在 AOT 二进制中发现,已修并加回归测试。 +- **host 的应答与事件竞态**:会话瞬间退出时,退出通知会排在 create 的应答之前发出,客户端丢弃了它。host 改为应答排队之后才启动输出泵,协议上保证"应答先于该会话的任何数据 / 事件"。 + +尚未做(M3 剩余,按优先级): +- 剪贴板读取确认(OSC 52 read,需要询问界面); +- 可访问性(AutomationPeer); +- 通知、铃声、进度的界面呈现(事件已到达,未显示); +- 装饰(下划线样式、超链接悬停)、彩色 emoji、box drawing 的 sprite、字形行缓存; +- 27 个截图用例中目前只有 2 个(05 §13); +- 外观设置(字体、字号、调色板随明暗切换)与 `terminal` 设置节(M4)。 + +### M4 集成(约 3 周) + +| 任务 | 文档 | +|---|---| +| 工作台:`TerminalTabViewModel`、多标签、分屏树、关闭确认、标题、退出后的行为 | [01](01-architecture.md) §7、[07](07-shell-profiles-config.md) §6 | +| `MainWindow` 快捷键路由(D6);命令面板 ⌘⇧P / Ctrl+Shift+P | 06 §2 | +| profile 探测(三个平台)、启动命令、环境变量策略 | 07 §2–§4 | +| shell 集成脚本(bash、zsh、fish、pwsh;改编 VS Code 的 MIT 脚本,不拷贝 GPL 脚本)、注入与失败提示 | 07 §5 | +| `settings.json` 的 `terminal` 节、Schema、热重载、设置页 | 07 §7、§8 | +| 主题映射(AtomUI → `Terminal.*`);链接打开到工作台 | 05 §11、06 §8 | +| 更新 `TerminalDescription` 文案和 [architecture.md](../architecture.md) 路线图 | 00 | + +验收:[11](11-testing-benchmarks.md) §1.6 的"与系统一致"手工清单在三个平台上完成。 + +### M5 Agent(约 3 周) + +按 [09](09-agent-integration.md) §17 的 8 步:命令跟踪器 → TerminalBroker 与 BashTool 双路径(含子进程环境清理)→ 交互提示检测与中断 → Agent 终端标签 → 迷你终端与 Codex 输出修复 → 共享权限 → ACP `auth.terminal` / `terminal/*`(默认关)→ 测试与 AOT 冒烟。 + +验收:09 §16 的端到端测试(Fake 模型)全部通过。 + +### M6 R1 + Kitty 图形(约 3 周) + +| 任务 | 文档 | +|---|---| +| 按 05 §4 的 7 步从 R3 迁到 R1(保留 R2 作开关);research/04 §11 的 spike | 05 | +| Kitty 图形:托管 PNG 解码器、内存上限、绘制与 z 序;重连时图像丢失的提示 | 02 §11、05 | + +验收:S12(终端洪流 + StreamMark 流式输出)时终端帧率不受 UI 线程负载影响;Kitty 图形截图测试通过。 + +### M7 SSH 一期(约 1–2 周) + +按 [08](08-ssh.md) 第一期:主机列表(`~/.ssh/config` 的 Host / Include,按需 `ssh -G`)、ssh profile、Windows `ssh.exe` 探测、`SendEnv`、连接失败(255)的提示。 + +验收:在 CI 的 OpenSSH 容器中通过 ProxyJump 连接;手工验证 FIDO 密钥和 keyboard-interactive(由系统 ssh 在终端里提示)。 + +**合计**:约 23–29 周(单人)[推断]。M2 与 M3 并行时可以缩短 3–4 周。 + +## 4. 之后(v1 之外) + +| 项 | 说明 | +|---|---| +| SSH 二期 | Tmds.Ssh 的 SFTP 与远程文件树;先向上游提 PR 补 3 个公开 API 缺口([08](08-ssh.md)) | +| 远程 shell 集成 | kitty 式 tty bootstrap 或 Ghostty 式脚本上传,默认关 | +| 完整无障碍 | 向 Avalonia 贡献跨平台 `ITextProvider`,或者写平台专用的 peer | +| Linux 系统 PRIMARY 选区 | 等 Avalonia 12.2 | +| 升级 .NET 11 | 用 `UnixProcessStartArguments` 回调替换自研的退出监视([research/02](research/02-pty.md) §2.5) | +| 托管核心 | 只在 libghostty 路线遇到无法绕过的问题时,适配 XTerm.NET 实现同一门面 | + +## 5. 待决问题汇总 + +设计阶段留下的问题。1–5 已于 2026-10-17 决定;6–10 仍待决,每项都写了建议。 + +| # | 问题 | 建议 | 何时必须决定 | +|---|---|---|---| +| 1 | macOS login 包装会吞掉 shell 退出码 | **已决定(2026-10-17):放弃 login 包装**,改用 argv0 `-zsh` | — | +| 2 | DA2 应答 | **已决定:`>1;10;0c`**(与 Ghostty 一致;vim 宽屏鼠标需 `ttymouse=sgr`,见 10 §2) | — | +| 3 | 原生库编译模式 | **已决定:ReleaseFast**(与 Ghostty 一致);fuzz 与差分测试用 ReleaseSafe 构建 | — | +| 4 | 异常断开超过宽限期后,正在运行程序的会话 | **已决定:保留**,直到程序结束或达到 `keepAliveMaxHours` | — | +| 5 | `terminal` 设置节的容错方式 | **已决定:字段级容错**(错误字段回退默认值并警告);M4 时在 settings.md 写明这一节的例外 | — | +| 6 | Agent 只读查询(`ls`、`git status` 一类)在 `auto` 模式下走进程模式还是可见终端([09](09-agent-integration.md) §18) | 走进程模式,减少刷屏 | M5 | +| 7 | Agent 命令超时的默认行为:中断还是转入后台(09 §18) | 中断,与现有 bash 工具一致 | M5 | +| 8 | 是否向 Tmds.Ssh 上游提 PR 补 API 缺口 | 是,在 SSH 二期开始前 | SSH 二期 | +| 9 | 每晚基准是否需要固定硬件的自托管 runner | 需要;没有的话基准只作参考,不作门禁 | M1 | +| 10 | 是否允许开发者录制一次真实的 Claude Code / Codex 会话作为测试夹具入库(CI 中不调用模型) | 允许,录制前清理掉密钥和私有内容 | M3 | +| 11 | M1 回放与 fuzz 发现的 libghostty 与 xterm 的差异是否用 overlay 修正:①带多个参数的光标移动(CUU/CUD/CUF/CUB/HPR/VPR)取第一个参数;②快照保存 mode 12;③DEC 特殊图形字符集的 `_` 显示为空白 | ①②做 overlay(改动小,①直接影响"与系统终端一致"),同时向上游提议题;③先向上游确认再定 | M3 | + +## 6. 与终端无关、可单独处理的现有问题 + +| 问题 | 位置 | 处理 | +|---|---|---| +| Codex 命令输出只显示最后一个片段 | `CodexSession.cs:427` 把单个片段当作进度结果,`SessionViewModel.cs:353` 又把每次进度当作完整结果 | 进度改传累计的 `command.Output`;随时可修 | +| BashTool 子进程继承 `SIGPIPE=SIG_IGN`(`trap - PIPE` 对 bash 无效) | `BashTool.cs` | 改用 `tcode-pty-spawn --exec` 启动([03](03-pty.md) §12),随 M1 完成 | +| `PeerDirectory` 创建 socket 目录时没有设为 0700,`/tmp` 回退目录不检查所有者 | `src/TCode.AgentHost/Peers/PeerDirectory.cs` | 单独修复 | +| 打包脚本的 macOS 最低版本写的是 11.0,.NET 10 要求 15.0 | `tools/package_macos_app.sh` | 单独修复 | +| Linux AOT 发布需要在 glibc 2.28 容器中进行,否则在 RHEL 8 上运行不了 | 发布流程 | 随 CI 建设一起处理(12) | diff --git a/docs/terminal/README.md b/docs/terminal/README.md new file mode 100644 index 0000000..cdeb0e8 --- /dev/null +++ b/docs/terminal/README.md @@ -0,0 +1,98 @@ +# TCode 终端组件 + +> 状态:设计文档 v1 完成;**M0 实施基本完成**(2026-10-17,见 [13-roadmap.md](13-roadmap.md) 的 M0 实施状态)。决策见 [01-architecture.md](01-architecture.md) §2;待你拍板的问题汇总在 [13-roadmap.md](13-roadmap.md) §5。 + +## 目录 + +| 文档 | 内容 | +|---|---| +| [00-current-state.md](00-current-state.md) | 仓库现状:只有占位标签页;现有 shell 代码与终端的关系;缺口 | +| [01-architecture.md](01-architecture.md) | **总体架构**:决策、进程划分(UI / pty host / AgentHost)、双核心一致性、项目划分、线程模型、层间接口 | +| [02-core-ghostty.md](02-core-ghostty.md) | 核心:libghostty-vt 的 pin、绑定、门面 API、快照、差分测试 | +| [03-pty.md](03-pty.md) | PTY:libtcodepty + spawn helper、ConPTY、退出监视、关闭语义 | +| [04-pty-host.md](04-pty-host.md) | pty host 进程与 IPC 协议:帧、控制消息、流控、重连、持久化 | +| [05-rendering.md](05-rendering.md) | 渲染:Painter、行缓存、字体与 sprite、R3 → R1 | +| [06-input.md](06-input.md) | 输入:按键路由、IME、鼠标、选区、剪贴板、粘贴、链接、搜索、可访问性 | +| [07-shell-profiles-config.md](07-shell-profiles-config.md) | profile 探测、启动命令、环境变量、shell 集成、`terminal` 设置 | +| [08-ssh.md](08-ssh.md) | SSH:一期系统 ssh,二期 Tmds.Ssh | +| [09-agent-integration.md](09-agent-integration.md) | Agent:可见终端中的 bash、ACP、Codex 输出展示 | +| [10-protocol-compat.md](10-protocol-compat.md) | 协议支持范围、应答、TERM、宽度、安全 | +| [11-testing-benchmarks.md](11-testing-benchmarks.md) | 测试分层与基准 | +| [12-build-and-ci.md](12-build-and-ci.md) | 构建文档:工具链、原生库、打包签名、CI | +| [13-roadmap.md](13-roadmap.md) | 里程碑与验收 | +| [research/01-emulator-cores.md](research/01-emulator-cores.md) | 终端模拟核心选型:libghostty-vt、XTerm.NET、alacritty_terminal、wezterm-term、libvterm、xterm.js、纯 C# 自研 | +| [research/02-pty.md](research/02-pty.md) | 跨平台 PTY:posix_spawn + helper、控制终端、与 .NET 的 SIGCHLD 共存、ConPTY 全集与陷阱、现有 .NET 库评估 | +| [research/03-vt-protocol.md](research/03-vt-protocol.md) | 协议规格:解析器状态机、Tier 0/1/2 功能矩阵、查询应答、键盘与鼠标编码、Unicode 宽度、TERM、安全、测试 | +| [research/04-rendering-performance.md](research/04-rendering-performance.md) | 缓冲区结构、Avalonia 12 绘制路径、字体字形、线程模型、IME 与可访问性、性能目标、基准计划 | +| [research/05-platform-behavior-config.md](research/05-platform-behavior-config.md) | 与系统终端一致:默认 shell、环境变量、shell 集成、键鼠约定、外观默认值、生命周期;`terminal` 配置草案 | +| [research/06-ssh-transport.md](research/06-ssh-transport.md) | SSH 接入方式、.NET SSH 库(含 AOT 实测)、传输抽象接口草案、进程架构、JSON-RPC 承载终端数据的实测代价 | + +## 调研结论汇总 + +### 1. 分层 + +无论最终怎么选,组件都分成以下五层,层与层之间只通过接口交互: + +``` +┌──────────────────────── 宿主(TCode.Desktop) ────────────────────────┐ +│ 工作台标签 / 分屏 · profile 与 settings.json · 快捷键路由 · 链接打开 │ +└──────────────────────────────────┬────────────────────────────────────┘ +┌──────────────── 视图层(Avalonia,自绘) ───────────────────────────────┐ +│ TerminalView:网格渲染 · 光标 / 选区 / 搜索高亮 · 键盘与 IME · 鼠标 · 无障碍 │ +└──────────────────────────────────┬──── 只读快照(脏行) ─────────────────┘ +┌──────────────── 终端核心(与 UI 无关) ─────────────────────────────────┐ +│ VT 解析 · 屏幕 / 回滚 / reflow · 模式与应答 · 键盘 / 鼠标 / 粘贴编码 · 搜索 │ +└──────────────────────────────────┬──── 字节流 ─────────────────────────┘ +┌──────────────── 连接(ITerminalConnection) ───────────────────────────┐ +│ 本地 PTY(Unix posix_spawn + helper;Windows ConPTY) · SSH · WSL · 回放 │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +### 2. 已经比较明确、不需要大的取舍的结论 + +| 主题 | 结论 | 来源 | +|---|---|---| +| Unix PTY | 不在托管代码里 fork。用 posix_spawn 加一个小 helper(macOS 必须用 helper 才能拿到控制终端),再用一个约 300 行的 C shim 承接 `ioctl` / `fcntl` 等可变参数函数(Apple arm64 上直接 P/Invoke 会静默传错参数) | 02 §2、§4 | +| 子进程回收 | 只能用 `waitpid(pid)` 回收自己的子进程,不能 `waitpid(-1)`;退出通知 Linux 用 pidfd,macOS 用 kqueue;子进程启动前把所有信号恢复为默认(.NET 会让子进程继承 `SIGPIPE=SIG_IGN`) | 02 §2.5 | +| Windows | ConPTY;新版会原样透传程序输出;关闭时必须继续读输出管道并在另一个线程关闭,否则旧系统上会死锁;ConPTY 启动时会发 DA1,我们必须回应 | 02 §3 | +| 默认 shell | 用 `getpwuid` 取账户登记的 shell,`$SHELL` 只作后备;macOS 起 login shell,Linux 起非 login shell;Windows 和 Windows Terminal 一样优先 pwsh。Agent 的 `ShellResolver` 不复用 | 05 §1 | +| TERM | `xterm-256color` + `COLORTERM=truecolor` + `TERM_PROGRAM=TCode`;v1 不发布自定义 terminfo,靠 XTVERSION / XTGETTCAP 等查询应答补充能力 | 03 §7、05 §2 | +| 解析器 | 以 Williams 的 DEC 状态机为骨架,加冒号子参数;UTF-8 下不执行 C1;参数和字符串长度设上限 | 03 §1 | +| 线程 | 每个终端一个读线程 + 一个核心线程;读缓冲满了就阻塞读取(背压);渲染只读快照,从不等锁 | 04 §0.3 | +| 交互延迟 | 输入回显路径上不加任何合并延迟;大量输出时才合并到每帧一次 | 04 §0.3 | +| 传输数据 | 终端字节流不走现有 JSON-RPC(base64 + 无界队列 + 队头阻塞);跨进程时使用独立的二进制通道和确认式流控 | 06 §7.3 | +| 安全 | OSC 52 读剪贴板要询问;标题回报默认关闭;粘贴时检查 `ESC[201~`;OSC 8 链接打开前显示真实地址 | 03 §8 | +| 测试 | 字节流回放 → 网格快照断言;esctest2 进 CI;vttest、ucs-detect 人工验收;fuzz;Headless 截图测试;BenchmarkDotNet 基准 | 03 §9、04 基准计划 | + +### 3. 决策(2026-10-16 已确定) + +| # | 问题 | 选项 | 调研推荐 → **结果** | +|---|---|---|---| +| 1 | 终端核心 | A 纯 C# 自研(约 5–7 人月);A′ 基于 XTerm.NET(纯 C#,单人维护,无逐行脏标记);B libghostty-vt + 自维护绑定(API 未冻结、Zig 工具链、要 pin commit);E 以 B 为主,托管核心保底 | B → **libghostty-vt(用户选择)** | +| 2 | 组织方式 | 独立仓库(像 StreamMark:核心 + 视图 + PTY + 主题包,经 NuGet 引用);或者 TCode 仓库内的独立项目 | 独立库 → **TCode 仓库内独立项目(用户选择)** | +| 3 | 终端会话所在进程 | v1 在 UI 进程(UI 退出 shell 就结束);一开始就做独立的 pty host(可重连、可保活,工作量大一档);AgentHost(不推荐:它会崩溃重启) | v1 在 UI 进程,核心不依赖 UI,以后可迁出 → **一开始就做独立 pty host(用户选择)** | +| 4 | SSH | A 系统 ssh 跑在本地 PTY;B 托管库(Tmds.Ssh 最接近系统行为,AOT 0 警告);C 混合:交互用系统 ssh,SFTP 等用 Tmds.Ssh | C → **混合(用户选择)** | +| 5 | 渲染路径 | `Control.Render`(UI 线程,简单);`CompositionCustomVisualHandler`(渲染线程,Agent 流式输出时不抢 UI 线程) | 先 Render 做 MVP,再迁到渲染线程 → **先 Render 再迁渲染线程(用户选择)** | +| 6 | 终端获得焦点时的快捷键 | macOS 的 ⌘K 是清屏还是命令面板;Windows / Linux 上的 Ctrl+K/N/B 交给 shell 还是 TCode | ⌘K 清屏,命令面板另配 ⌘⇧P;Win/Linux 上 Ctrl+字母交给 shell → **按系统终端惯例(用户选择)** | +| 7 | shell 集成 | 默认自动注入(VS Code、Ghostty)或默认关闭(Windows Terminal、iTerm2) | 默认开,可关,失败时显示原因 → **默认开(用户选择)** | +| 8 | macOS 启动方式 | 通过 `/usr/bin/login -flp` 启动(和 Terminal.app 完全一致);或者只把 argv0 改成 `-zsh` | login → **argv0 `-zsh`,不经 login(2026-10-17 用户决定:login 会吞掉 shell 退出码)** | +| 9 | Unicode 宽度 | 默认兼容(按码点)+ 程序开启 2027 后按字形簇;或者默认按字形簇 | 默认兼容 → 默认兼容(采纳推荐) | +| 10 | Windows | 是否随应用分发 `conpty.dll` + `OpenConsole.exe`(每种架构约 1.2 MB);win32-input-mode 是否 v1 必做 | 都分发;win32-input-mode 做 → 分发;做 win32-input-mode(采纳推荐) | +| 11 | v1 范围 | 图形协议(Kitty graphics / sixel)、多标签 / 分屏、会话恢复、Agent 在可见终端中运行命令、ACP `terminal:true` | 图形与 Agent 共享终端放到 v1 之后 → **全部进入 v1:多标签分屏、保活恢复、Agent 打通、Kitty 图形;不做 sixel(用户选择)** | + +## 另外发现的问题 + +- `BashTool` 启动的子进程会继承 .NET 的 `SIGPIPE=SIG_IGN`,所以 `yes | head` 这类管道的表现和系统终端不同(02 §2.5)。这和终端无关,可以单独修。 +- `TerminalDescription` 文案和 [architecture.md](../architecture.md) 的路线图仍然写着 Iciclecreek.Avalonia.Terminal,选型确定后要更新。 + +### 4. 设计阶段的后续决定(2026-10-17) + +| 问题 | 决定 | +|---|---| +| macOS login 包装 | 放弃,改用 argv0 `-zsh`(login 父进程总是 `exit(0)`) | +| DA2 应答 | `>1;10;0c`(与 Ghostty 一致) | +| libghostty 编译模式 | ReleaseFast(与 Ghostty 一致) | +| 异常断开超过宽限期后仍有程序在运行的会话 | 保留,直到程序结束或达到 `keepAliveMaxHours` | +| `terminal` 设置的容错方式 | 字段级容错 | + +其余待决问题见 [13-roadmap.md](13-roadmap.md) §5。 diff --git a/docs/terminal/research/01-emulator-cores.md b/docs/terminal/research/01-emulator-cores.md new file mode 100644 index 0000000..46e944a --- /dev/null +++ b/docs/terminal/research/01-emulator-cores.md @@ -0,0 +1,532 @@ +# 终端模拟核心选型调研(01) + +状态:调研完成(2026-10-16),待决策 +范围:终端模拟核心,即 VT 解析、屏幕与光标状态、回滚缓冲、resize reflow,以及选择和搜索辅助。PTY、SSH、渲染器、输入法由其他调研负责,本文只在它们影响核心选型时提及。 +关联文档:[architecture.md](../../architecture.md)(原计划使用 Iciclecreek.Avalonia.Terminal)、StreamMark [decisions.md](../../../../StreamMark/docs/decisions.md)(RaTeX 原生库 + 6 RID CI 的先例) + +**标注约定**: + +- 带来源键的结论(例如 `[G:render.h:42]`)为**已核实**,依据是阅读源码或头文件、官方元数据及 GitHub / NuGet / crates.io API 查询。 +- 标 **[推断]** 的内容是基于已核实事实的推理或估算,没有实测。 +- 仓库均为 2026-10-16 拉取的 `--depth 1` 快照,提交号见 §8。 + +--- + +## 1. 结论摘要 + +1. **libghostty-vt 是目前唯一同时满足以下三点的核心:功能完整、为自绘渲染器设计、提供 C ABI。** + - 它的 render state API 直接提供:全局和逐行 dirty、跨帧稳定的行 ID(可按行缓存整形结果,与 StreamMark 按块缓存 `TextLayout` 的做法一致)、overscan(平滑滚动)、逐行选区区间、光标与调色板。还能一次取出整行 cell(`CELLS_RAW`),减少 FFI 调用 [G:render.h:42-200,456-460]。 + - 现成功能:Kitty 键盘编码器、paste 安全检查、OSC 133 prompt 导航和命令输出选区、含回滚的增量搜索、终端快照编码与恢复、回滚压缩。 + - 代价:头文件明确写着"API 未稳定、一定会变",上游也没有打过 libghostty 版本 tag [G:vt.h:10,25][G:README.md:160,168]。我们需要 pin commit、自己维护绑定并跟随上游;构建需要 Zig 0.16.0 [G:build.zig.zon:6]。 +2. **6 个 RID 已有人在 CI 中跑通。** + - RoyalTerminal(MIT,.NET 10 + Avalonia 12.1.3)为 win / linux / osx × x64 / arm64 构建了 libghostty-vt,产物 1.7–2.1 MB;Linux 包因未 strip 为 9.7 MB [RT:release.yml][NUGET:RT-native]。 + - 但它在固定提交上打了 12 个正确性补丁,多数是 render state 漏标 dirty [RT:ghostty-vt-extensions/README.md:30]。这说明 C API 层还年轻,我们要有自己的差分测试和快照测试。 +3. **.NET 生态中最强的纯托管核心是 XTerm.NET。** + - 它是 Iciclecreek.Avalonia.Terminal 的底层,自称 xterm.js 的 C# 移植,MIT 许可,2026-10-14 仍在发版 [IC:README.md:15][XN:csproj]。 + - 功能面广:Kitty 键盘、2026 / 2027、OSC 133、sixel / Kitty 图形、reflow。代码中没有反射。 + - 不足:单人主导;每个 cell 24 字节;没有逐行 dirty 和行 ID;grapheme 表是进程级静态表,永不回收,上限 10 万条 [XN:ClusterTable.cs:40-43]。 + - 适合作**保底**,不适合作首选。 +4. **其余原生核心都有明显缺口。** + - **alacritty_terminal**:质量高,但没有 C API,也没有 grapheme 聚类(ucs-detect 37 分)。不支持 OSC 7 / 133 和图形协议,Kitty 键盘的编码逻辑在 alacritty 应用里,不在这个 crate 里 [AL][VTE:ansi.rs:1350-1521][UCS]。 + - **wezterm-term**:功能全,但没有发布到 crates.io,WezTerm 正式版停在 2024-02 [CRATES][GH-API]。 + - **libvterm**:上游停在 0.3.3(2023-08)。不支持 Kitty 键盘、2026、2027、OSC 133,回滚由宿主自己存 [LV]。 + - **Windows Terminal TerminalCore**:依赖 WinRT 和 HRESULT,不能单独复用 [MSWT]。 +5. **xterm.js + WebView 不适合 TCode。** + - Avalonia.Controls.WebView 12.1.1 是 MIT 许可,并声明 AOT 兼容。但它在 Windows / macOS 上嵌入的是原生子窗口,窗口内的覆盖层会被遮挡;Linux 默认需要 WPE 运行库 [WV]。 + - xterm.js 稳定版 6.0.0 还没有 Kitty 键盘(2026-01 才进 master),ucs-detect 只有 26 分;仓库有 26 个未关闭的 IME issue,其中包括中文输入法丢字 [XJ][UCS][GH-API]。 + - 这与"自绘、与原生 UI 融合、中文输入"的目标冲突。 +6. **纯 C# 自研**要做到"行为与系统终端一致",估计需要 16–25K 行实现加同等规模的测试,约 5–7 人月,之后还有持续的长尾兼容问题 **[推断]**(§4.8)。 + +**推荐路线 B:libghostty-vt + 自己维护的 C# 绑定(LibraryImport)+ 自己构建 6 个 RID 的原生库。渲染器只依赖我们自己的 C# 门面接口。XTerm.NET 作为路线 B 受阻时的托管保底,不预先做双引擎。** 详见 §6。 + +--- + +## 2. 评估口径 + +核心问题:**自绘的 Avalonia 渲染器能否每帧低成本地拿到 cell 级屏幕状态和变化信息。** 具体要求如下: + +| 需求 | 说明 | +|---|---| +| cell 级读取 | 码点或 grapheme、宽度(宽字符及其 spacer)、前景 / 背景 / 下划线颜色、SGR 属性、超链接、语义类型(prompt / input / output) | +| 变化信息 | 整帧是否变化、哪些行变化、行身份是否保持(用于复用每行的整形结果) | +| 视口 | 视口在回滚中的位置、滚动条数据、平滑滚动时需要的额外行 | +| 同步输出 | mode 2026 期间冻结在程序希望显示的那一帧 | +| 线程 | PTY 读线程写入、UI 线程渲染,锁的持有时间要短 | +| AOT | P/Invoke 用 `LibraryImport`,回调用 `[UnmanagedCallersOnly]`,不用反射 | + +--- + +## 3. 对比表 + +### 3.1 基本信息与维护状况 + +| 候选 | 形态 | 许可证 | 最新版本(日期) | 最近 commit | 维护状况 | +|---|---|---|---|---|---| +| **libghostty-vt** | Zig 实现,C ABI(约 207 个导出函数) | MIT | 未打 libghostty tag。Ghostty 应用 v1.3.1(2026-03-13),主干为 1.3.2-dev | 2026-10-15;`include/ghostty/vt` 近 90 天 82 次提交 | 非常活跃,Ghostty 官方 [G][GH-API] | +| **XTerm.NET**(+ Iciclecreek) | 纯 C# | MIT | XTerm.NET 3.0.0 / Iciclecreek 4.1.0(均为 2026-10-14) | 2026-10-14 | 活跃;作者 2 人(tomlm 主导)[XN][IC][NUGET] | +| **RoyalTerminal** | C# 全栈:libghostty-vt(打过补丁)+ 托管 VT 双引擎 | MIT | 0.6.3(NuGet) | 2026-10-09 | 活跃;公司项目(Royal Apps),28★,2026-02 创建 [RT][GH-API] | +| DeBlasis.GhosttyVt(libghostty-vt-dotnet) | C# 绑定 | MIT | 1.3.2-ci.*,每天跟随 ghostty main | 2026-10-16 | 个人项目,2★,只有 3 个 RID [DN] | +| Dotty | 纯 C#(含 Avalonia 控件) | Apache-2.0 | — | 2026-07-05 | 低频 [DT] | +| XtermSharp | 纯 C#(xterm.js 移植) | MIT | NuGet 1.0.0-alpha.10 | 2020-12-04 | 停滞 [XS][GH-API] | +| VtNetCore | 纯 C# | MIT | 1.0.30 | 2022-10-16 | 停滞 [VN][GH-API] | +| **alacritty_terminal** | Rust crate,无 C API | Apache-2.0 | 0.26.0(2026-04-06) | 2026-10-16 | 活跃;Zed 使用其 fork [AL][CRATES][ZED] | +| wezterm-term / termwiz | Rust,无 C API;wezterm-term 未发布到 crates.io | MIT | termwiz 0.23.3(2025-03);WezTerm 正式版 20240203 | 2026-10-10 | 维持中,节奏放缓 [WZ][CRATES][GH-API] | +| libvterm | C99 | MIT | 0.3.3(2023-08) | neovim 已把它 vendor 进 `src/nvim/vterm` 并自行修改;neovim/libvterm 镜像已归档 | 上游停滞 [LV][NV] | +| contour vtbackend | C++20,无 C API | Apache-2.0 | v0.7.0(2026-08-17) | 2026-10-16 | 活跃,主要由一人维护 [CT] | +| **xterm.js** | TypeScript,需 WebView | MIT | 6.0.0(2025-12-22);beta 6.1.0-beta.315 | 2026-10-13 | 非常活跃 [XJ][NPM] | + +### 3.2 协议与功能覆盖 + +图例:✅ 内置;⚠️ 部分支持或需要 addon / 宿主补齐;❌ 不支持。 + +| 候选 | truecolor | OSC 8 | OSC 52 | OSC 133 | OSC 7 | Kitty 键盘 | 2026 同步输出 | 2027 grapheme | sixel | Kitty 图形 | reflow | +|---|---|---|---|---|---|---|---|---|---|---|---| +| libghostty-vt | ✅ | ✅ | ✅ 读写回调 | ✅ 回调 + prompt 导航 + `select_output` | ✅ 7 / 9 / 1337 | ✅ 编码器,5 个 flag | ✅ render hold 回调 | ✅ | ❌ | ✅ 需宿主提供 PNG 解码 | ✅ 主屏(alt 屏不 reflow) | +| XTerm.NET | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| alacritty_terminal | ✅ | ✅ | ✅ | ❌ | ❌ | ⚠️ 只跟踪模式,编码不在 crate 内 | ✅(vte,150 ms 超时) | ❌ 逐码点 unicode-width | ❌ | ❌ | ✅ | +| wezterm-term | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| libvterm 0.3.3 | ✅ | ❌(neovim 分支自行添加) | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ 每格最多 6 个码点 | ❌ | ❌ | ⚠️ 有,但回滚由宿主保存 | +| xterm.js 6.0 | ✅ | ✅ | ⚠️ addon | ⚠️ 需自己注册 OSC handler | ⚠️ 同左 | ⚠️ 只在 master | ✅ | ⚠️ 实验 addon | ⚠️ addon-image | ⚠️ 只在 master | ✅ | + +来源: + +- libghostty-vt:[G:terminal.h:84-105(effects 表), 2391, 3023][G:key/encoder.h:57,104][G:modes.h][G:kitty_graphics.h];没有 sixel 的依据是 `src/terminal` 下只有 DA 常量里出现 sixel。 +- XTerm.NET:[XN:README.md:20-40][XN:TerminalMode.cs:135,251][XN:InputHandler.Osc.cs:111]。 +- alacritty_terminal:[AL:term/mod.rs:14,81][VTE:ansi.rs:36-48,1350-1521][AL:alacritty/src/input/keyboard.rs:298]。 +- wezterm-term:[WZ:escape-parser/osc.rs:41-53][WZ:term/terminalstate/{kitty,sixel,iterm}.rs]。 +- libvterm:[LV:vterm.h:24,541-571][LV:state.c:828-908,1777-1803]。 +- xterm.js:[XJ:InputHandler.ts:298,2036][XJ:addons/*];Kitty 键盘于 2026-01-09 合入 master(2265fa2ad)。 + +### 3.3 集成维度(与自绘渲染器和 TCode 硬约束相关) + +| 候选 | cell 级 API | dirty / 行身份 | 线程模型 | NativeAOT | 6 RID 构建 | 原生产物 | 每 cell 内存 | 一致性测试 / Unicode | +|---|---|---|---|---|---|---|---|---| +| libghostty-vt | render state 行迭代器:逐 cell `get` / `get_multi`,或整行 `CELLS_RAW`(packed u64,位布局从 `ghostty_type_json` 读取) | 全局 FALSE / PARTIAL / FULL + 逐行 dirty + 行 ID + `next_dirty` 迭代器 | 库内不建线程;单个 terminal 不线程安全;回调在写入线程同步执行,禁止重入;render state 分两阶段更新,只有 begin 阶段需要持锁 | C ABI → `LibraryImport` + `UnmanagedCallersOnly`,可动态或静态链接 | Zig 交叉编译。上游 CI 覆盖 macOS ×2、Linux ×2、x86_64-windows-gnu,并在 Windows runner 上原生构建;win-arm64 不在上游 CI 中 | 1.7–2.1 MB | 8 B,样式按页去重 | 上游 CI 跑 esctest(只报告,失败不阻断构建)+ AFL++ fuzz;Ghostty 应用 ucs-detect 93 分 | +| XTerm.NET | 托管 `BufferLine` / `BufferCell`(struct),直接读取 | 无逐行 dirty,只有 `Scrolled` / `LineFed` / `BufferChanged` 等事件 | 无锁,由宿主串行化(Iciclecreek 用 `_terminalLock`) | 纯托管,无反射;未声明 `IsAotCompatible`,需要实测 | 无需原生构建 | — | 24 B + 行对象 | 自带 vttest 无头驱动(与 tmux 对照);ucs-detect 截图各项 100% | +| alacritty_terminal | `renderable_content()` 迭代器,需要自写 C ABI,把数据拷成平面数组 | `TermDamage::Full`,或 `Partial`(行 + 左右列范围) | `Term` 不是 Sync;crate 自带 EventLoop 线程、FairMutex,以及 tty 模块(含 ConPTY) | 自写 cdylib,与 RaTeX 同一路线 | cargo-zigbuild(RaTeX 已验证) | **[推断]** 1–2 MB | 24 B(有测试断言 ≤ 24) | 45 个 ref 录制测试(含 vttest 场景);ucs-detect 37 分 | +| wezterm-term | `Line` / `Cell`(Rust),需要自写 C ABI | 每行一个 SequenceNo(`changed_since`) | 单线程 | 同上 | 同上,但只能用 git 依赖 | **[推断]** 2–4 MB | 24 B | ucs-detect 71 分 | +| libvterm | `vterm_screen_get_cell`,逐 cell 读取 | damage 回调(矩形,可合并) | 单线程,回调式 | C,最简单 | 任何 C 编译器(zig cc) | **[推断]** < 200 KB | 由宿主决定 | 45 个 `.test` 脚本 | +| xterm.js | 不对原生渲染器开放,在 DOM / WebGL 里自己渲染 | — | JS 单线程 | 取决于 WebView 包(声明 AOT 兼容) | 无需原生构建,但 Linux 需要 WPE 或 WebKitGTK 系统库 | 依赖 WebView 运行时 | 12 B(3 × uint32) | ucs-detect 26 分 | + +来源: + +- libghostty-vt:[G:render.h][G:terminal.h:79-82,3284][G:page.zig:2137][G:test.yml:610-619,936-951][G:test/esctest/README.md][NUGET:RT-native]。 +- XTerm.NET:[XN:BufferCell.cs:25][XN:docs/perf-results.md][IC:TerminalView.Input.cs:1018][IC:compliance.png]。 +- alacritty_terminal:[AL:term/mod.rs:178,458,637][AL:cell.rs:308-313][AL:tests/ref]。 +- wezterm-term:[WZ:wezterm-cell/lib.rs:1033][WZ:wezterm-surface/line.rs:283]。 +- xterm.js:[XJ:BufferLine.ts:20]。 +- ucs-detect 分数:[UCS]。注意这是**整机终端**的测试结果(含其渲染器和配置),不是核心库的单元测试。 + +### 3.4 吞吐数据(公开可查的部分) + +| 对象 | 数据 | 来源 / 口径 | +|---|---|---| +| Ghostty 解析器 | SIMD 路径处理 ASCII 比标量路径快 7.3 倍,接近 memcpy 基线;UTF-8 解码快 16.6 倍(M3 Max)。只有相对值,没有 MB/s | [DEV6] | +| XTerm.NET | 240 × 67 headless:scroll-ascii 126.4 MiB/s,unicode 64.6 MiB/s;字节入口 scroll-ascii 594.2 MiB/s(两张表口径不同);150 MB cat 端到端(经 Avalonia 渲染)119.2 MiB/s,此前的 1.1.0 只有 9.3 MiB/s | [XN:docs/perf-results.md:13-20,38-45,77-84]。这些优化来自 John Campion Jr 的性能 fork,文档已在上游仓库中 | +| 整机终端 | kitty 0.33 的 benchmark:ASCII 121.8 / alacritty 0.13.1 43.1 / wezterm 20230712 16.4 MB/s(2024 年左右数据,不含 Ghostty);社区对 Ghostty 1.3.x 的 cat 测试结果互相矛盾 | [KP][WEB-BENCH] | + +**[推断]** 公开数据大多是整机测试(PTY + 渲染),无法横向比较核心本身。选定方案后,应在独立库里建立 headless 吞吐基准(参考 XTerm.NET 的 Bench 语料:scroll-ascii / sgr-churn / truecolor / alt-redraw / unicode / flood),并与 libghostty-vt 原生基线对比。 + +--- + +## 4. 各候选详述 + +### 4.1 libghostty-vt(Ghostty) + +#### 4.1.1 定位与状态 + +- **定义**:从 Ghostty 中抽出的 C 库,"实现现代终端模拟器的核心部分:解析转义序列、维护终端状态、编码输入事件;处理回滚、换行、resize reflow" [G:vt.h:15-23]。 +- **稳定性声明**: + - 头文件写明:"WARNING: This is an incomplete, work-in-progress API. It is not yet stable and is definitely going to change." [G:vt.h:10] + - README 写明功能本身很稳定(已在 Ghostty GUI 中长期验证),"but the API signatures are still in flux";"We haven't tagged libghostty with a version yet" [G:README.md:158-170]。 + - 2025-09-22 的公告博客曾计划约半年内发布带 tag 的 libghostty-vt [BLOG]。到 2026-10 仍未发布,比计划晚了约 7 个月。 +- **变动频率**:`include/ghostty/vt` 共 242 次提交,最近 90 天 82 次(2026-07-16 至 2026-10-16),最近 5 次在 10-10 至 10-14 之间,包括 CSI 8 t、prompt 导航、paste 保护等改动 [GH-API]。 +- **为 ABI 兼容所做的设计**(在头文件中核实): + - 所有枚举都以 `int` 为底层类型,并带 `*_MAX_VALUE = INT_MAX` 哨兵 [G:types.h]。 + - 23 个结构体采用 sized-struct 模式(首字段 `size`,用 `GHOSTTY_INIT_SIZED` 初始化)。 + - 部分结构体标注 "frozen layout"。 + - `ghostty_type_json()` 返回当前构建的类型清单,包含偏移、大小、枚举值、packed 位布局。官方明确要求绑定从清单读取,不要硬编码,并说明"这些布局不承诺跨版本稳定" [G:types.h:355-410]。 + - **[推断]** 上游在为将来的 ABI 稳定做准备,但现阶段一切以 pin 住的 commit 为准。 + +#### 4.1.2 API 能力总览 + +`vt.h` 汇总了 30 个子头文件(另有 `key/`、`mouse/` 下 4 个),合计约 14K 行(注释很多)[G:include/ghostty/vt/*]。 + +| 分组 | 头文件 | 能力 | 主要 API | +|---|---|---|---| +| Terminal | terminal.h(3455 行) | 创建 / 释放 / RIS;`vt_write`(输入不可信,永不失败);`vt_write_until_ground`(在序列边界插入带外序列);resize(主屏 reflow,同时更新像素尺寸,解除 2026,发送 2048 尺寸报告);视口滚动(按行、按页、**按 prompt**);48 个选项、42 个可查询数据项(光标、模式、滚动条、标题、pwd、颜色、鼠标形状、内存占用等) | `ghostty_terminal_new/free/reset/resize/set/get/get_multi/vt_write/scroll_viewport` | +| Effects | terminal.h:56-105 | 副作用全部通过回调交给宿主:write_pty、bell、title、pwd、ENQ、XTVERSION、尺寸查询、颜色方案查询、DA、剪贴板读写(OSC 52 / 1337 / 5522)、桌面通知(OSC 9 / 777)、进度(OSC 9;4)、程序状态(OSC 7501)、未知 APC / OSC 透传、**render hold(2026)**、**semantic prompt(OSC 133)**、RIS、CSI 8 t | `GHOSTTY_TERMINAL_OPT_*` | +| 颜色主题 | terminal.h:172-232 | 前景 / 背景 / 光标 / 256 色调色板分两层:"默认值"由宿主设置,"覆盖值"由程序通过 OSC 4 / 10 / 11 / 12 设置;可分别读取实际值和默认值 | `OPT_COLOR_*`、`DATA_COLOR_*` | +| Render state | render.h(1104 行) | 见 §4.1.3 | `ghostty_render_state_*` | +| 屏幕类型 | screen.h | `GhosttyCell` 是 packed `uint64_t`;内容 tag(码点 / grapheme / 只有背景色);宽度属性(narrow / wide / spacer_tail / spacer_head);语义类型(output / input / prompt);超链接与保护标记;行的 wrap / continuation / prompt 等标记 | `ghostty_cell_get`、`ghostty_row_get` | +| 样式 | style.h | `GhosttyStyle`:前景、背景、下划线颜色,bold / italic / faint / blink / inverse / invisible / strikethrough / overline,以及下划线样式 | `ghostty_style_default/is_default` | +| Grid ref | grid_ref.h、grid_ref_tracked.h | 非跟踪引用(快照)和跟踪引用(跟随滚动、回滚裁剪、reflow);文档注明不适合放在渲染主循环里 | `ghostty_terminal_grid_ref(_track)` | +| 选区 | selection.h(1086 行) | 选全部 / 行 / 词 / 两点间的词 / **命令输出**;调整、包含判断、格式化复制;矩形选区;**手势状态机**(按下、拖动、释放、自动滚动、deep press) | `ghostty_terminal_select_*`、`ghostty_selection_gesture_*` | +| 搜索 | search.h(502 行) | 覆盖主屏、alt 屏及回滚;能处理 resize / reflow / 重置 / 裁剪;`tick` 不碰终端,**可以放到工作线程**;`feed` 需要独占终端;匹配结果就是选区 | `ghostty_search_new/feed/tick/run/get` | +| Formatter | formatter.h | 把终端内容输出为纯文本、VT 序列或 HTML | `ghostty_formatter_*` | +| 快照 | snapshot.h(616 行) | 带 CRC 的二进制快照,包括未完成的解析器输入;先恢复到 READY(可以开始渲染),再增量补齐更早的回滚 | `ghostty_snapshot_encode/decoder_*` | +| 回滚压缩 | terminal.h:41-52, 3248-3300 | 由宿主驱动的增量压缩:宿主根据活动令牌计时,空闲时分步执行;对逻辑内容透明 | `ghostty_terminal_compression_activity/compress` | +| 键盘编码 | key/encoder.h、key/event.h | 支持 legacy 和 Kitty 协议(5 个 flag)、cursor / keypad application 模式、alt 发 ESC 前缀、macOS option-as-alt、backarrow;可直接从终端同步模式(`setopt_from_terminal`) | `ghostty_key_encoder_*` | +| 鼠标 / 焦点 | mouse/*.h、focus.h | X10 / normal / button / any 跟踪;SGR / UTF-8 / urxvt / SGR-pixels 编码;焦点进出报告 | `ghostty_mouse_encoder_*`、`ghostty_focus_encode` | +| Paste | paste.h | `ghostty_terminal_paste` 按终端当前状态决定行为:Kitty 5522 paste 事件、bracketed paste、未包裹时 `\n` → `\r`;可能注入命令的内容返回 `GHOSTTY_REJECTED`,由宿主确认后再带 `allow_unsafe` 重试;也有不依赖终端的 `is_safe` / `encode` | [G:paste.h:16-60] | +| OSC / SGR 解析器 | osc.h、sgr.h | 可单独使用的 OSC 解析器(28 种命令)和 SGR 解析器 | `ghostty_osc_*`、`ghostty_sgr_*` | +| Unicode | unicode.h | 与终端排版一致的码点宽度和 grapheme 宽度,可用来**预测 IME 预编辑文本的列宽** | `ghostty_unicode_codepoint_width/grapheme_width` | +| Kitty 图形 | kitty_graphics.h(871 行) | 遍历 placement、读取图片像素、计算渲染矩形;需要宿主通过 `ghostty_sys_set(GHOSTTY_SYS_OPT_DECODE_PNG)` 注入 PNG 解码器 | `ghostty_kitty_graphics_*` | +| 其他 | allocator.h、sys.h、build_info.h、modes.h、size_report.h、color.h | 自定义分配器、日志、构建信息(SIMD / Kitty 图形 / tmux control mode / 版本)、43 个模式(含 2004 / 2026 / 2027 / 2031 / 2048 / 5522)、颜色解析 | — | + +#### 4.1.3 Render state:给自绘渲染器的接口 + +关键设计 [G:render.h:22-200]: + +- **只在 update 调用期间需要访问终端。** 两阶段更新:持锁执行 `begin_update`,解锁,再执行 `end_update`(只用 render state 自己的内存)。这样 IO 线程几乎不会被渲染阻塞。 +- **两层 dirty。** 全局状态为 `FALSE` / `PARTIAL` / `FULL`,另有逐行 dirty,两者由调用方分别清除,`ghostty_render_state_clean()` 一次清掉两层。`row_iterator_next_dirty` 只遍历变化的行。 +- **行身份(Row Identity)。** 每行有一个跨帧稳定、永不复用的 ID,滚动时 ID 跟着行走。只要"ID 存在且该行不 dirty",就可以复用缓存的整形结果或纹理。dirty 判断偏保守,例如视口滚动后目前所有行都会被标为 dirty。 +- **Overscan。** 可以请求视口上下各多抓几行,用于亚行级平滑滚动,`VIEWPORT_Y` 用有符号值表示行的位置。 +- **Render hold(2026)。** 在回调中更新 render state,抓到程序希望显示的那一帧;hold 期间不再更新;超时(建议 1 秒)由宿主自行解除。 +- **批量读取。** `ROW_DATA_CELLS_RAW` 返回一整行 `GhosttyCell` 的借用视图(`ptr` + `len`),注释说这是给"调用边界开销大"的宿主准备的;位布局要从 `ghostty_type_json` 解析 [G:render.h:446-460]。 + +摘录: + +```c +// 每帧(UI 线程) +lock(term); ghostty_render_state_begin_update(rs, term); /* 同时读 DATA_SCROLLBAR */ unlock(term); +ghostty_render_state_end_update(rs); +ghostty_render_state_get(rs, GHOSTTY_RENDER_STATE_DATA_DIRTY, &dirty); // FALSE / PARTIAL / FULL +ghostty_render_state_get(rs, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &rows); +while (ghostty_render_state_row_iterator_next_dirty(rows, &y)) { + ghostty_render_state_row_get_multi(rows, n, keys /*ID, VIEWPORT_Y, SELECTION, CELLS_RAW*/, vals, NULL); + // 有样式的 cell:row_cells_select(x) + get(STYLE / FG_COLOR / BG_COLOR / GRAPHEMES_UTF8) +} +ghostty_render_state_clean(rs); +``` + +**[推断] FFI 开销。** + +- 200 × 60 视口全量刷新时,每行只需 3–4 次调用;最坏情况每个有样式的 cell 再加 2 次,合计约 2.5 万次调用。 +- `LibraryImport` 的无封送 blittable 调用开销在个位纳秒级,每帧约 0.05–0.25 ms,在 16.6 ms 帧预算内。 +- 如果 profile 显示瓶颈,可以像 RoyalTerminal 那样加一个 Zig shim,导出"一次调用拷出整行的平面结构体数组"。 + +#### 4.1.4 构建、平台与产物 + +| 项 | 事实 | +|---|---| +| 工具链 | Zig **0.16.0**(`minimum_zig_version`)[G:build.zig.zon:6]。Zig 0.17.0 已于 2026-10-01 发布 [ZIG];RoyalTerminal 记录"需要 Zig 0.16.0 恰好这个版本,升到 0.17 需要协调的 ABI 和行为评审"[RT:build-test-release.md:38] | +| 构建命令 | `zig build -Demit-lib-vt`,同时产出 shared 与 static 库;也提供 CMake 包装(内部调用 zig),可用 `ghostty_vt_add_target(... ZIG_TARGET x86_64-linux-gnu)` 交叉编译 [G:CMakeLists.txt:1-60] | +| 依赖 | vt 模块用 simdutf、Highway(SIMD,`no_libcxx`)、Wuffs(Kitty 图形像素处理)、uucode,**不用** oniguruma;CI 断言 `.so` 不依赖 libc++ [G:GhosttyZig.zig:72-156][G:test.yml:464-469] | +| 静态库 | 打包 compiler_rt 和 ubsan_rt,启用 PIC;Windows 静态库需要链接 ntdll / kernel32,MSVC 下关闭 stack protector [G:GhosttyLibVt.zig:239-271] | +| 上游 CI 目标 | aarch64 / x86_64 macOS、aarch64 / x86_64 Linux、x86_64-linux-musl、x86_64-windows-gnu;"x86_64-windows-msvc 交叉编译还不行(找不到 msvc 头文件)",改在 Windows runner 上原生构建并运行 `test-lib-vt`;另有 wasm、freestanding、android、xcframework [G:test.yml:610-648,936-951] | +| win-arm64 | **不在上游 CI 中**。RoyalTerminal 在 Windows runner 上用 `-Dtarget=aarch64-windows-gnu` 构建 [RT:release.yml:264] | +| Windows x64 兼容性 | RoyalTerminal 发现默认构建可能在启动阶段就执行 AVX 指令,在不支持 AVX 的虚拟机或模拟环境中直接崩溃,所以发布版改用 `-Dcpu=x86_64-vzeroupper -Dsimd=false` 的标量构建,并在 CI 中反汇编确认没有 AVX [RT:windows-x64-native-compatibility.md:24-45] | +| 产物大小 | RoyalTerminal 0.6.3 的包:osx-arm64 1.94 MB、osx-x64 2.07 MB、win-x64 1.72 MB(标量)、win-arm64 1.74 MB;linux-x64 / arm64 约 9.7 MB(`file` 显示 with debug_info, not stripped)。导出约 220 个 `ghostty_*` 符号(含 RoyalTerminal 自己的扩展)[NUGET:RT-native] | +| 许可证 | MIT [GH-API]。依赖是否均为宽松许可,**[推断]** 需在发布时逐一核对,并附第三方声明 | + +#### 4.1.5 一致性与质量保障 + +- `test/esctest`:在 PTY 下用 libghostty-vt 无头运行 esctest2,并把终端的回应(DECRQCRA 校验和、DA、光标位置等)写回。CI 的 `test-esctest` job **只报告,不因失败阻断构建** [G:test/esctest/README.md]。 +- `test/fuzz-libghostty`:AFL++ 覆盖 OSC 解析器、VT 解析器和完整 stream(SIMD 与标量两条路径)[G:test/fuzz-libghostty/README.md]。 +- `src/terminal` 共约 18.4 万行 Zig,含数千个内联测试 [G]。 +- ucs-detect(Ghostty 1.3.1 整机):总分 93,WIDE / NARROW / LANG / ZWJ / VS16 / VS15 全部 100,支持 2027 [UCS]。 + +#### 4.1.6 已有绑定与使用者 + +- **.NET**: + - RoyalTerminal.GhosttySharp(MIT,全部为 `LibraryImport`,但链接的是打过补丁的构建)[RT]。 + - DeBlasis.GhosttyVt(`IsAotCompatible=true`,每天跟随 main,只有 win-x64 / linux-x64 / osx-arm64)[DN:README][DN:Ghostty.Vt.csproj:8]。 + - LibGhostty.Net(NuGet,没有源码仓库)[NUGET]。 +- **其他语言**:go-libghostty(mitchellh,155★)、libghostty-rs(394★)、libghostty-cpp、Dart / Flutter、Python、Elixir、Node-API(coder)、Odin、MoonBit 等 [AW][GH-API]。 +- **参考实现**:Ghostling,用约 2000 行 C(单个 `main.c`,68 KB)加 raylib 实现的最小终端 [AW][GH-API]。 +- **使用者**:awesome-libghostty 收录约 130 个项目,包括大量面向 AI Agent 的终端(cmux、Supacode 等),以及 Windows 上的 phantty / mightty [AW]。 + +#### 4.1.7 RoyalTerminal 发现的问题(对路线 B 的风险提示) + +RoyalTerminal 在 pin 住的 `b40acce5` 上通过构建时生成 overlay 的方式应用了 12 处修正(不改 submodule;每处修正都校验原文件 SHA-256 和片段数量)[RT:ghostty-vt-extensions/README.md:28-80]。例如: + +- mode 2027 关闭时追加零宽字符,没有标记光标行 dirty,导致 render state 只显示基字符。 +- 覆盖软换行宽字符的任一半时,前一行没有标记 dirty。 +- OSC 133 和隐式续行修改了行元数据,但没有标记 dirty。 +- Kitty 动画删除帧时索引错误。 +- 流续传导出时没有规范化 APC→C1 的切换。 + +这些问题是否已在当前 HEAD(b3dc3d7)修复,**未核实**。**[推断]** render state 这层的 dirty 正确性还在打磨,路线 B 需要自己做"增量渲染结果 vs 全量重建结果"的差分测试。 + +### 4.2 .NET 生态 + +#### 4.2.1 Iciclecreek.Avalonia.Terminal / XTerm.NET(architecture.md 原计划) + +- **构成**:Iciclecreek 是 Avalonia 控件(`TerminalControl` / `TerminalWindow`,含渲染、输入、PTY 接线),模拟核心是 **XTerm.NET**,PTY 是 **Porta.Pty** [IC:README.md:15]。三者作者相同(tomlm,另有 John Campion Jr 参与),均为 MIT。 +- **依赖**:Avalonia 12.0.4、Avalonia.Skia、SkiaSharp.HarfBuzz 3.119.4、Unicode.net 2.0.0、Wcwidth 4.0.1、Porta.Pty 2.2.3、XTerm.NET 3.0.0 [IC:csproj:39-48]。没有声明 `IsAotCompatible`;Porta.Pty 声明了 `IsAotCompatible=true` [PP:csproj:11]。 +- **渲染方式**:以 `GlyphRun` 快速路径为主,回退到 `FormattedText`;图片用 Skia `ICustomDrawOperation` 图层;多个终端共享节流帧,**默认 30 FPS** [IC:TerminalView.Render.cs:1689-1900][IC:README.md:219]。它是一个完整控件,与我们"自绘渲染层 + 独立库"的计划重叠。如果采用,实际可用的只有其中的 XTerm.NET。 +- **XTerm.NET**: + - 约 2.9 万行 C#,测试约 3.3 万行,net10.0。 + - 代码分布:InputHandler 共 9.1K、Buffer 4.0K、Common 4.1K、Graphics 3.5K、Parser 1.5K、Input 1.6K。 + - 功能:Kitty 键盘、左右边距、OSC 133(reflow 后仍保留)/ 8 / 52 / 9 / 99 / 777、sixel、Kitty 图形(含动画和 Unicode placeholder)、Kitty text sizing(OSC 66)、XTGETTCAP、mode 2027、宽度表与 python wcwidth 逐码点对齐 [XN:README.md:17-45]。 + - 代码中没有 `System.Reflection` 用法,也没有锁 [XN:grep]。 +- **弱点**: + - 没有逐行 dirty 和行 ID。 + - `BufferCell` 为 24 字节;grapheme 存在进程级静态 `ClusterTable`(两个 `ConcurrentDictionary`),永不回收,上限 10 万条,超过后只显示基字符 [XN:ClusterTable.cs:22-45]。 + - 单人主导,大版本有破坏性变更(2.0 删除了 per-cell 图片 API,3.0 改了 sixel 存储)[XN:README.md:47-140]。 + +#### 4.2.2 RoyalTerminal(royalapplications) + +- **定位**:".NET 10 高性能终端栈",包括与后端无关的 Avalonia 控件、Skia CPU 渲染器、HarfBuzz 整形、着色器,以及 PTY / SSH / Telnet / Serial 传输。VT 有两套:`GhosttyVtProcessor`(libghostty-vt)和 `BasicVtProcessor`(托管,约 1.65 万行,对照原生做差分测试)[RT:README.md][RT:architecture.md]。 +- **规模**:GhosttySharp 1.26 万行、Vt.Ghostty 4.9K、Vt.Managed 16.5K、Avalonia 控件 16.8K、Skia 渲染 9.4K、Terminal 契约与屏幕模型 28.1K;共有 463 处 `LibraryImport`;全仓库**没有** `IsAotCompatible` 或 `PublishAot` 声明 [RT:grep]。 +- **原生**:submodule pin ghostty `b40acce5`,加 overlay 和自有扩展(drag-drop、通知、窗口 resize 回调),6 个 RID 全部构建 [RT:ghostty-integration.md:16][RT:release.yml]。 +- **启示**: + 1. 6 RID + Zig 0.16 已被验证可行。 + 2. 它的 Ghostty 处理器每帧把 render state **拷贝进托管的 `TerminalScreen`**(`lock (_screen.SyncRoot)`)[RT:GhosttyVtProcessor.cs:999],多了一次拷贝。 + 3. 项目非常庞大(文档极长,**[推断]** 大量由 AI 生成),版本 0.x,28★,不适合作为依赖,适合作为参考。 + +#### 4.2.3 其他 + +| 项目 | 说明 | +|---|---| +| Dotty(starling-browser/dotty) | 纯托管引擎 + Avalonia 控件 + MCP,约 1.5 万行,Apache-2.0,最近提交 2026-07。awesome-libghostty 把它列为基于 libghostty 的项目,但在当前提交 1c0b397 的源码中找不到任何 ghostty 引用,按纯托管处理 [DT][AW] | +| XtermSharp | Miguel de Icaza 的 xterm.js C# 移植,master 最后提交 2020-12 [XS] | +| VtNetCore | 最后提交 2022-10;有派生版 AgentSpace.VtDotNet(readonly struct cell、环形回滚)[VN][NUGET] | +| Terminal.Emulation / Terminal.Avalonia(b-y-t-e) | 纯 C# VT500 引擎,0.4.4,仓库已不可访问 [NUGET] | +| VirtualTerminal(Rikitav)、CodeBrix.Terminal、AvaloniaTerminal、SvcSystems.UI.Terminal | 小众,下载量和星数都很低 [NUGET] | +| XenoAtom.Ansi / XenoAtom.Terminal | ANSI 生成和 tokenizer、Console 替代品,**不是**终端模拟器 [NUGET] | + +### 4.3 alacritty_terminal(Rust) + +- **基本情况**:crate 0.26.0(2026-04-06),Apache-2.0,约 1.17 万行,基于 `vte` 0.15 [AL:Cargo.toml:2-28][CRATES]。Zed 使用的是 zed-industries 的 **fork**(固定在某个 rev)[ZED]。 +- **API 形状**: + - `Term::new(config, dims, listener)`;用 `vte::ansi::Processor::advance(&mut term, bytes)` 喂入数据。 + - `term.damage()` 返回 `TermDamage::Full`,或 `Partial`(`LineDamageBounds{line,left,right}` 迭代器)。 + - `renderable_content()` 返回显示迭代器、选区、光标;`resize()` 做 reflow。 + - 事件枚举有 `Title`、`ClipboardStore/Load`、`ColorRequest`、`PtyWrite`、`TextAreaSizeRequest`、`Bell`、`ChildExit` 等,其中三项格式化器是 `Arc` [AL:term/mod.rs:178,410-660,2393][AL:event.rs:14-50]。 + - crate 自带 `event_loop`(PTY 读线程)、`sync::FairMutex`、`tty`(Unix 和 Windows ConPTY)。 +- **Cell**:`{c: char, fg, bg, flags, extra: Option>}`,≤ 24 B。零宽字符追加到 cell 上,没有 grapheme 聚类 [AL:cell.rs:132-160,308-313]。 +- **缺口**: + - OSC 只支持 0 / 2 / 4 / 8 / 10–12 / 50 / 52 / 104 / 110–112,**没有 7 / 133 / 9 / 777** [VTE:ansi.rs:1350-1521]。 + - 没有 2027、sixel、Kitty 图形。 + - Kitty 键盘只记录模式,**编码在 alacritty 应用里**(依赖 winit 类型),需要我们自己移植 [AL:alacritty/src/input/keyboard.rs:298-306]。 + - ucs-detect 37 分(ZWJ 1、VS15 0)[UCS]。 +- **自写 C ABI 的工作量** **[推断]**: + - Rust cdylib 约 2–3K 行:生命周期、写入、resize、事件回调(通过函数指针和 userdata)、按 damage 把视口 cell 拷进调用方提供的平面缓冲区、选区、`RegexSearch` 搜索、模式查询。 + - C# 绑定约 2K 行;移植键盘编码约 1K 行。 + - 补 OSC 7 / 133 / 通知需要 fork vte 或在字节流上预扫描。 + - 合计 5–8 周,6 RID 走 RaTeX 的 cargo-zigbuild 路线即可。之后要长期维护 fork,Zed 走的就是这条路。 + +### 4.4 wezterm-term / termwiz(Rust) + +- `wezterm-term`(MIT)的 README 说明:完整的转义解析、键盘和鼠标编码、含回滚的屏幕模型、sixel 和 iTerm2 图片、OSC 8;不含 GUI 和 PTY;入口是 `Terminal::advance_bytes` [WZ:term/README.md][WZ:term/terminal.rs:164]。 +- 另外已核实支持:OSC 7 / 133(`FinalTermSemanticPrompt`)/ 52 / 通知、Kitty 键盘、Kitty 图形、2026、grapheme(finl_unicode)、bidi [WZ:osc.rs:41-53][WZ:terminalstate/*.rs]。 +- 变化跟踪:每行一个 `SequenceNo`(`Line::changed_since`);`Cell` 24 B [WZ:line.rs:283][WZ:wezterm-cell/lib.rs:1033]。 +- **问题**: + - `wezterm-term` **没有发布到 crates.io**,只能用 git 依赖;termwiz 最新 0.23.3(2025-03);WezTerm 正式版停在 20240203 [CRATES][GH-API]。 + - 依赖树较重(image、finl_unicode、wezterm-* 一组 crate)。 + - ucs-detect 71 分 [UCS]。 + - 自写 C ABI 的工作量与 alacritty 相当,但上游节奏放缓,维护风险更高 **[推断]**。 + +### 4.5 libvterm(C) + +- 上游 0.3.3,文件时间戳为 2023-08-04;主仓库在 Launchpad(bzr)[LV]。neovim 已把它 vendor 进 `src/nvim/vterm` 并持续修改(最近一次 2026-10-06),`neovim/libvterm` 镜像已归档 [NV][GH-API]。 +- **API**: + - 状态层 `VTermState` + 屏幕层 `VTermScreen`。 + - 屏幕通过回调输出 `damage`(矩形,可用 `set_damage_merge` 合并)、`moverect`、`sb_pushline` / `sb_popline` / `sb_clear`(**回滚由宿主保存**)。 + - `vterm_screen_enable_reflow`;每格最多 `VTERM_MAX_CHARS_PER_CELL = 6` 个码点 [LV:vterm.h:14-24,439-571]。 +- **缺口**:没有 Kitty 键盘、2026、2027、OSC 8(上游)、OSC 133、图形 [LV:state.c]。 +- 约 5.8K 行,最容易构建。但要"行为与系统终端一致",需要在它之上补大量功能,并接受上游停滞。 + +### 4.6 其他原生实现 + +| 候选 | 结论 | +|---|---| +| Windows Terminal TerminalCore | `Terminal.hpp` 依赖 `winrt::Microsoft::Terminal::Core`、`HRESULT`、`til/winrt.h` 以及控制台的 buffer / renderer / adapter,只能在 Windows 上构建,**不能单独跨平台复用** [MSWT]。Windows 上值得复用的是 ConPTY(`Microsoft.Windows.Console.ConPTY` NuGet),属于 PTY 调研 | +| contour vtbackend | C++20,Apache-2.0;ucs-detect 99 分(最高);但没有 C API,依赖 GSL、boxed-cpp、libunicode 等,用 CMake 构建 6 RID 并自写 C 包装的成本高;主要由一人维护 [CT][UCS] | +| foot | 只支持 Wayland 的 C 应用,没有可嵌入的库 | +| kitty | GPL-3.0,C + Python,没有可嵌入的核心,许可证也不适合 Apache-2.0 的 TCode | + +### 4.7 xterm.js + WebView(VS Code / Tabby / Hyper 方案) + +- **xterm.js**: + - 6.0.0(2025-12-22)为最新稳定版,beta 6.1.0-beta.315(2026-10-13)[NPM]。 + - `src/common` 约 1.53 万行,`src/browser` 约 1.3 万行,addons 约 1.62 万行 [XJ]。 + - Kitty 键盘(`KittyKeyboard.ts`)和 win32-input-mode 在 2026-01 合入 master,**稳定版没有** [GH-API]。 + - 默认回滚 1000 行,每 cell 12 B [XJ:OptionsService.ts:34][XJ:BufferLine.ts:20]。 + - ucs-detect(6.1.0 beta)26 分,2027 不支持 [UCS]。 +- **Avalonia 12 的 WebView**: + - 官方包 `Avalonia.Controls.WebView` 12.1.1(2026-10-12),MIT,依赖 Avalonia ≥ 12.0.0;README 声明兼容 AOT [WV:nuget]。 + - 平台引擎:Windows 用 WebView2;macOS 用 WKWebView;Linux 默认用 WPE WebKit(离屏 SHM 渲染,需要用户安装 `libwpewebkit-2.0` 等),也可改用 WebKitGTK [WV:docs]。 + - 实现方式:Win / macOS 走 `NativeControlHost`,离屏缓冲走 composition host [WV:README]。 + - JS 互操作:`InvokeScript(string)` 和 `WebMessageReceived`(`invokeCSharpAction`)[WV:docs]。 +- **融合问题** **[推断]**,基于 NativeControlHost 的机制和上述事实: + 1. **遮挡(airspace)**:Win / macOS 上原生子窗口压在 Avalonia 内容之上。窗口内的覆盖层(AtomUI 的消息提示、拖拽预览、StreamMark 的 `OverlayLayer` 卡片)会被遮住;以独立窗口实现的 Popup 不受影响。 + 2. **焦点与快捷键**:焦点进入 WebView 后,Avalonia 的 KeyBinding 收不到按键,需要在 JS 中拦截再桥接回来;Tab 焦点遍历要特殊处理。 + 3. **IME**:xterm.js 依赖隐藏的 textarea。仓库有 26 个未关闭的 IME issue,其中有"豆包输入法在 macOS 英文模式下第二个字符丢失"、"首个提交字符丢失"、"日文输入法组合重复"等,均为 2026 年的报告 [GH-API]。 + 4. **数据通路**:PTY 字节要经 C# → 字符串(base64)→ JS 往返,高吞吐时额外占用 CPU 并增加延迟。 + 5. **主题**:AtomUI token 需要同步成 CSS 变量。 + 6. **资源**:WebView2 会启动多个浏览器进程,冷启动约数百毫秒。 + 7. **Linux 发行**:WPE 不是常见桌面发行版的默认组件。 +- 变体:coder/ghostty-web(MIT,2975★)提供与 xterm.js API 兼容、基于 libghostty-vt WASM 的实现,可以缓解 Unicode 和功能缺口,但 WebView 带来的问题照旧 [AW][GH-API]。 + +### 4.8 纯 C# 自研:工作量估算 + +参考规模(只统计实现代码,测试单列): + +| 实现 | 语言 | 实现行数 | 测试 | 覆盖范围 | +|---|---|---|---|---| +| xterm.js `src/common` | TS | 15.3K(parser 2.2K、buffer 2.5K、input 2.1K、`InputHandler.ts` 3.7K) | 14.2K | 不含渲染;图形、grapheme、剪贴板在 addons 中(16.2K) | +| XTerm.NET | C# | 29.1K(InputHandler* 9.1K、Buffer 4.0K、Common 4.1K、Graphics 3.5K) | 33.0K | 含 sixel / Kitty 图形 | +| RoyalTerminal Vt.Managed | C# | 16.5K(另有契约和屏幕模型 28.1K) | — | 对标 libghostty-vt | +| alacritty_terminal | Rust | 11.7K(不含 vte) | 有 | 无图形,无 grapheme | +| wezterm term + escape-parser + surface + cell | Rust | 10.2K + 10.9K + 5.2K + 1.9K | 有 | 全功能 | +| libvterm 0.3.3 | C | 5.8K | 45 个脚本 | 无回滚存储 | +| Ghostty `src/terminal` | Zig | 184K,含内联测试、C API 25.5K、kitty 18.9K、snapshot 14.4K、formatter 7.6K;核心部分:Terminal 17.3K、Screen 12.8K、PageList 20.8K、page 4.5K、Parser 1.1K、stream 5.7K | 内联 | 全功能 | + +TCode 自研的模块拆分与估算 **[推断]**,按 1 名熟练开发者计: + +| 模块 | 内容 | 行数 | 人周 | +|---|---|---|---| +| Parser | DEC ANSI 状态机(Paul Williams)、跨块 UTF-8、C0 / C1、CSI 参数与子参数、OSC / DCS / APC 收集与长度上限 | 1.5–2K | 1–2 | +| State / Screen | 光标、约 45 个模式、G0–G3 字符集、制表位、上下和左右边距、SGR(下划线样式和颜色)、DECSC / DECRC、1047 / 1049、DECRQM、DSR / DA / XTVERSION / XTWINOPS | 6–9K | 6–9 | +| Buffer | 行存储、回滚环形缓冲、样式去重、grapheme 存储、软换行标记、reflow、宽字符 spacer 规则、逐行 dirty 和行 ID | 3–5K | 4–6 | +| Unicode | 从 UCD 生成宽度表和 grapheme 断句表的生成器、mode 2027 | 1–2K + 生成的表 | 2 | +| 输入编码 | legacy 键盘、modifyOtherKeys、Kitty 键盘(5 个 flag + 栈)、各种鼠标模式和编码、焦点、bracketed paste 与安全检查 | 2–3K | 2–3 | +| 外围功能 | 选区(词 / 行 / 矩形 / 命令输出)、含回滚的搜索、formatter、OSC 7 / 8 / 52 / 133 / 9 / 777 | 2–4K | 3–4 | +| 图形(可选) | sixel、Kitty graphics | 4–8K | 4–8 | +| 测试 | 单元测试 + 无头 vttest / esctest + ucs-detect | 约与实现等量 | 已计入上面 | +| **合计(不含图形)** | | **16–25K + 同量测试** | **约 20–30 人周(5–7 人月)** | + +**[推断]** AI 辅助能压缩编码时间,但"与系统终端一致"的长尾(vim / tmux / htop / less / fzf / 各种 shell 集成、ConPTY 重写过的输出、CJK 和 emoji 边界情况)需要真实程序回归,无法省掉。 + +--- + +## 5. 可选路线 + +### A:纯 C# 自研核心 + +| | | +|---|---| +| 优点 | 零原生依赖,AOT 天然没问题;数据结构完全按自绘渲染器设计(行 ID、dirty、grapheme 存储与 StreamMark 风格一致);调试只需一种语言 | +| 缺点 | 5–7 人月才能接近 XTerm.NET 或 libghostty-vt 的覆盖度;性能要靠长期优化(XTerm.NET 从 9.3 优化到 119 MiB/s 花了一个专门的性能 fork);一致性问题只能自己发现 | +| 风险 | 进度不可控;长尾 bug 长期拖累"与系统终端一致"的目标 | +| 工作量 | 核心 20–30 人周,不含渲染、PTY、SSH **[推断]** | + +### A′:以 XTerm.NET 为核心(fork 或直接依赖)+ 自绘渲染 + +| | | +|---|---| +| 优点 | 今天就能用;纯 C#;功能面广(Kitty 键盘、2026 / 2027、OSC 133、图形);MIT;作者活跃;有 vttest 驱动和 ucs-detect 结果 | +| 缺点 | 没有逐行 dirty 和行 ID,要在外面自己做 diff 或补丁;每 cell 24 B;静态 `ClusterTable` 不回收;破坏性大版本频繁;没有 reflow 之外的高级功能(快照、回滚压缩、命令输出选区要自己做) | +| 风险 | 单人主导,可能停更;fork 后要自己维护约 2.9 万行代码 | +| 工作量 | 集成 2–3 周,加上 dirty 跟踪和 AOT 实测;长期维护成本中等 **[推断]** | + +### B:libghostty-vt + 自维护 C# 绑定(推荐) + +| | | +|---|---| +| 优点 | 功能和一致性最完整的可嵌入核心,Ghostty GUI 已长期验证其逻辑;render state 正是为自绘渲染器设计的(两阶段更新、两层 dirty、行 ID、overscan、render hold);SIMD 解析;每 cell 8 B,回滚可压缩;内置对 AI 场景有用的功能:OSC 133 prompt 导航和命令输出选区、formatter(纯文本 / HTML)、快照恢复;MIT;6 RID 已被他人验证;生态活跃(约 130 个下游项目,其中 2 个 .NET 绑定) | +| 缺点 | API 未稳定、没有版本 tag,需要 pin commit 并定期升级;工具链要求 Zig 0.16.0 恰好这个版本;没有 sixel;跨 FFI 回调要注意线程和重入 | +| 风险 | ① API 变动导致绑定频繁调整。② render state dirty 的边界 bug(RoyalTerminal 已修 12 处)。③ Windows x64 需要用基线 CPU 或关闭 SIMD 构建,性能会下降。④ win-arm64 没有上游 CI。⑤ 上游方向由 Ghostty 主导,我们的需求只能通过 PR 或本地 overlay 解决 | +| 工作量 | 原生构建与 CI 约 1 周;v1 所需约 80 个函数的绑定 2–3K 行,1.5–2 周;C# 门面和线程模型 1–2 周;差分和一致性测试 1–2 周;**核心层合计 5–7 周**,不含渲染、PTY、SSH **[推断]** | + +### C:alacritty_terminal + 自写 C ABI(Rust) + +| | | +|---|---| +| 优点 | 成熟稳定,Zed 在用;Rust → C ABI → .NET 与 RaTeX 完全同一路线;API 简洁(`TermDamage` 有列范围);crate 自带 PTY 和 ConPTY | +| 缺点 | 没有 grapheme 聚类(ucs-detect 37 分)、没有 OSC 7 / 133 / 通知、没有图形;Kitty 键盘编码要自己移植;C ABI 完全由我们设计和维护 | +| 风险 | 要补功能就得 fork vte 或 alacritty_terminal(Zed 就是这样做的),之后长期合并上游 | +| 工作量 | 5–8 周,补齐 grapheme、OSC 133 等还要另加数周 **[推断]** | + +### D:xterm.js 跑在 WebView 中 + +| | | +|---|---| +| 优点 | 生态最大(VS Code 同款);渲染、选择、搜索、链接都是现成的;不需要原生构建 | +| 缺点 | 与"自绘、原生融合"的目标相反;稳定版没有 Kitty 键盘;Unicode 分数低;IME 问题多(含中文);Win / macOS 有遮挡问题;Linux 需要 WPE;数据要跨 JS 桥;每个 WebView 有独立进程开销 | +| 风险 | 体验不一致(主题、焦点、快捷键、输入法)且不受我们控制;Linux 发行和支持成本高 | +| 工作量 | 集成 3–4 周,之后是持续的融合问题 **[推断]** | + +### E:B 为主 + 托管引擎保底(RoyalTerminal 模式) + +| | | +|---|---| +| 优点 | 原生库加载失败(老 CPU、未知平台)时仍可用 | +| 缺点 | 两套引擎、两套语义,要做差分测试;工作量接近 A + B | +| 结论 | **不建议预先做**。只把核心抽象成接口,以后需要时再加一个托管实现(例如适配 XTerm.NET) | + +--- + +## 6. 推荐及理由 + +**选择路线 B**。理由按 TCode 的硬约束逐条对应: + +| 约束 | B 的满足情况 | +|---|---| +| 完整终端、行为与系统终端一致 | Ghostty 的核心就是一个完整终端的核心,并在 CI 中跑 esctest 和 fuzz;Unicode 表现接近最好(ucs-detect 93 分) | +| 自绘渲染、性能 | render state 的设计与我们的需求一一对应;行 ID 可以直接用作每行 `GlyphRun` 或 `TextLayout` 缓存的 key | +| NativeAOT 0 警告 | 只用 `LibraryImport`、blittable 结构体、`[UnmanagedCallersOnly]` 回调和 `delegate* unmanaged`,不需要反射;DeBlasis.GhosttyVt 已声明 `IsAotCompatible` | +| 6 RID | RoyalTerminal 已用 Zig 0.16 全部构建;我们的 CI 可沿用 StreamMark 的矩阵(每个 RID 在原生 runner 上构建,或在 macOS 上交叉编译) | +| 以后接入 SSH | 核心只接收字节流,与传输方式无关;快照 API 可用于断线重连后恢复画面 | +| AI Agent 场景 | OSC 133 语义区和 `select_output` 能准确取出"某条命令的输出"交给 Agent;formatter 可导出纯文本或 HTML | + +**落地要点** **[推断]**,供后续设计文档细化: + +1. **pin 上游 commit**,把它记录在仓库里(类似 DeBlasis 的 `ghostty-upstream.json`),按固定节奏(例如每月)升级。升级时:重新生成绑定;比对 `ghostty_type_json` 清单;运行差分和一致性测试。 +2. **绑定分两层**: + - 底层:从头文件生成的 `LibraryImport` 原始声明。 + - 门面:我们自己的稳定 C# API(`TerminalCore`、`RenderSnapshot` 等)。渲染器和控件只依赖门面,保留以后换核心(A′ 或 E)的可能。 +3. **线程模型**: + - PTY 读线程在持锁时调用 `vt_write`。`write_pty` 等回调只入队,不阻塞,不重入。 + - UI 帧回调里:持锁执行 `begin_update`,读滚动条,解锁;然后执行 `end_update`,遍历 dirty 行。 + - 搜索的 `tick` 放到工作线程;回滚压缩由空闲计时器驱动。 +4. **原生构建**: + - 优先**动态库**(`runtimes//native/`,与 RaTeX 一致)。静态链接进 NativeAOT 可执行文件时,Zig 静态库自带的 compiler_rt 可能与工具链运行库冲突,留到以后评估。 + - Windows x64 先用基线 CPU 构建。是否启用 SIMD 在实测后决定(要么用运行时分派,要么接受标量性能)。 +5. **测试**: + - 无头 esctest / vttest 驱动(参考 Ghostty 的 `test/esctest` 和 XTerm.NET 的 `tools/vttest`)。 + - 每帧"增量渲染结果 = 全量重建结果"的差分快照测试,专门捕获 dirty 漏标。 + - ucs-detect 回归。 + - 与 libghostty-vt 原生基线对比的吞吐基准。 +6. **如果路线 B 在 PoC 阶段(建议 1–2 周)遇到无法绕过的问题**(AOT 链接、某个 RID 跑不起来、API 缺口),退回 **A′(XTerm.NET)**,门面接口保持不变。 + +--- + +## 7. 需要用户决策的点 + +1. **是否接受"上游 API 尚未冻结"的原生核心(路线 B)**,并为此承担 pin commit、自维护绑定、Zig 0.16 工具链和定期升级的成本?如果不接受,选纯托管的 A′(XTerm.NET)或 A(自研),代价是功能、性能和长期维护的差距。 +2. **是否放弃 architecture.md 中"直接使用 Iciclecreek.Avalonia.Terminal"的计划**,改为仿照 StreamMark 建独立库(核心门面 + 自绘渲染 + AtomUI 主题包)?现成控件(Iciclecreek、RoyalTerminal)能更快可用,但渲染、主题、AOT 都不受我们控制。 +3. **v1 的协议范围**:图形协议(Kitty graphics / sixel)、Kitty 键盘协议、OSC 133 shell 集成脚本自动注入是否进 v1?这决定渲染层和输入层的工作量;路线 B 本身没有 sixel,如需 sixel 要另行实现。 + +--- + +## 8. 来源 + +拉取日期 2026-10-16,均为 `git clone --depth 1`,路径为仓库内相对路径。 + +| 键 | 来源 | +|---|---| +| G | ghostty-org/ghostty @ `b3dc3d794513`(2026-10-15):`include/ghostty/vt.h`、`include/ghostty/vt/*.h`、`README.md`、`build.zig.zon`、`CMakeLists.txt`、`src/build/GhosttyLibVt.zig`、`src/build/GhosttyZig.zig`、`src/build/SharedDeps.zig`、`src/terminal/page.zig`、`src/terminal/modes.zig`、`.github/workflows/test.yml`、`test/esctest/README.md`、`test/fuzz-libghostty/README.md`、`test/windows/README.md` — https://github.com/ghostty-org/ghostty | +| BLOG | Mitchell Hashimoto, "Libghostty Is Coming"(2025-09-22)— https://mitchellh.com/writing/libghostty-is-coming | +| DEV6 | Ghostty Devlog 006 — https://mitchellh.com/writing/ghostty-devlog-006 | +| AW | Uzaaft/awesome-libghostty README(2026-10-16)— https://github.com/Uzaaft/awesome-libghostty | +| RT | royalapplications/RoyalTerminal @ `01833b8`(2026-10-09):`README.md`、`docs/articles/{ghostty-integration,architecture,build-test-release,windows-x64-native-compatibility}.md`、`native/ghostty-vt-extensions/README.md`、`.github/workflows/release.yml`、`src/RoyalTerminal.Terminal.Vt.Ghostty/Terminal/GhosttyVtProcessor.cs`、`src/RoyalTerminal.GhosttySharp/*` — https://github.com/royalapplications/RoyalTerminal | +| NUGET:RT-native | RoyalApps.RoyalTerminal.GhosttySharp.Native.{OSX,Linux64,Win64} 0.6.3 的 nupkg 内容(`unzip -l`、`file`、`nm`)— https://www.nuget.org/packages/RoyalApps.RoyalTerminal.GhosttySharp.Native.OSX | +| DN | deblasis/libghostty-vt-dotnet @ `c6f23d8`(2026-10-16):`README.md`、`ghostty-upstream.json`、`src/Ghostty.Vt/Ghostty.Vt.csproj` — https://github.com/deblasis/libghostty-vt-dotnet | +| IC | tomlm/Iciclecreek.Avalonia.Terminal @ `cb710a5`(2026-10-14):`README.md`、`compliance.png`、`src/Iciclecreek.Avalonia.TerminalWindow/*` — https://github.com/tomlm/Iciclecreek.Avalonia.Terminal | +| XN | tomlm/XTerm.NET @ `453ed95`(2026-10-14):`README.md`、`docs/perf-results.md`、`src/XTerm.NET/*`、`tools/README.md` — https://github.com/tomlm/XTerm.NET | +| PP | tomlm/Porta.Pty(2026-10-01):`src/Porta.Pty/Porta.Pty.csproj` — https://github.com/tomlm/Porta.Pty | +| DT | starling-browser/dotty @ `1c0b397`(2026-07-05)— https://github.com/starling-browser/dotty | +| XS | migueldeicaza/XtermSharp @ `1bed529`(2020-12-04)— https://github.com/migueldeicaza/XtermSharp | +| VN | darrenstarr/VtNetCore @ `4e723ac`(2022-10-16)— https://github.com/darrenstarr/VtNetCore | +| AL | alacritty/alacritty @ `5421470`(2026-10-16):`alacritty_terminal/{Cargo.toml,src/term/mod.rs,src/term/cell.rs,src/event.rs,tests/ref}`、`alacritty/src/input/keyboard.rs` — https://github.com/alacritty/alacritty | +| VTE | alacritty/vte @ `abeae76`(2026-02-28):`src/ansi.rs` — https://github.com/alacritty/vte | +| ZED | zed-industries/zed:`crates/terminal/Cargo.toml`、根 `Cargo.toml:534`(alacritty_terminal 的 git fork)— https://github.com/zed-industries/zed | +| WZ | wezterm/wezterm @ `e2a98e1`(2026-10-10,稀疏检出):`term/`、`wezterm-escape-parser/`、`wezterm-surface/`、`wezterm-cell/` — https://github.com/wezterm/wezterm | +| LV | libvterm 0.3.3 源码包(文件时间 2023-08-04):`include/vterm.h`、`src/state.c`、`t/` — https://www.leonerd.org.uk/code/libvterm/ | +| NV | neovim/neovim `src/nvim/vterm/`(最近提交 2026-10-06);neovim/libvterm(archived)— https://github.com/neovim/neovim/tree/master/src/nvim/vterm | +| MSWT | microsoft/terminal `src/cascadia/TerminalCore/Terminal.hpp` — https://github.com/microsoft/terminal | +| CT | contour-terminal/contour `src/vtbackend`,release v0.7.0.8982(2026-08-17)— https://github.com/contour-terminal/contour | +| XJ | xtermjs/xterm.js @ `991857a`(2026-10-13):`package.json`、`src/common/**`、`src/browser/**`、`addons/**` — https://github.com/xtermjs/xterm.js | +| NPM | @xterm/xterm dist-tags(latest 6.0.0 于 2025-12-22;beta 6.1.0-beta.315)— https://registry.npmjs.org/@xterm/xterm | +| WV | Avalonia.Controls.WebView 12.1.1 — https://www.nuget.org/packages/Avalonia.Controls.WebView/12.1.1 ;NativeWebView 文档 — https://docs.avaloniaui.net/controls/web/nativewebview ;仓库 README — https://github.com/AvaloniaUI/Avalonia.Controls.WebView | +| UCS | ucs-detect 结果汇总 — https://ucs-detect.readthedocs.io/results.html | +| KP | kitty performance — https://sw.kovidgoyal.net/kitty/performance/ | +| WEB-BENCH | 社区基准(结论互相矛盾,只作背景):https://github.com/pg83/shitty 、https://github.com/yanyaoer/tty7 | +| CRATES | crates.io API:alacritty_terminal、vte、termwiz、wezterm-term(不存在)— https://crates.io | +| ZIG | Zig 下载索引(0.16.0 于 2026-04-13,0.17.0 于 2026-10-01)— https://ziglang.org/download/index.json | +| NUGET | NuGet 搜索与注册 API(Iciclecreek、XTerm.NET、Porta.Pty、DeBlasis.GhosttyVt、RoyalApps.*、XenoAtom.*、AgentSpace.VtDotNet、Terminal.Emulation、LibGhostty.Net 等)— https://api.nuget.org | +| GH-API | GitHub REST API 查询(2026-10-16):仓库元数据(许可证、星数、pushed_at)、release / tag 日期、`include/ghostty/vt` 提交计数、xterm.js 的 Kitty 键盘首次提交和 IME issue 搜索 | diff --git a/docs/terminal/research/02-pty.md b/docs/terminal/research/02-pty.md new file mode 100644 index 0000000..9450bc7 --- /dev/null +++ b/docs/terminal/research/02-pty.md @@ -0,0 +1,546 @@ +# 终端调研 02:跨平台 PTY 层 + +状态:调研完成(2026-10-16),待决策 +范围:在 macOS / Linux / Windows 上创建伪终端、启动系统 shell,以及读写、resize、退出回收、关闭、前台进程和 cwd 探测。VT 解析、渲染、shell 选择与环境变量策略见 01 / 04 / 05,本文只在它们影响 PTY 设计时提及。 +关联文档:[05-platform-behavior-config.md](05-platform-behavior-config.md)(默认 shell、login(1)、环境变量、关闭确认)、[architecture.md](../../architecture.md)、StreamMark RaTeX("小型原生库 + C FFI + 6 RID CI"的先例) + +**标注约定** + +- **[实]**:已在下文所列提交的源码、官方文档、NuGet / npm / crates.io 元数据或本机实测中核实。 +- **[推]**:基于已核实事实的推理,实现前需要实测。 +- 源码引用格式为 `前缀 路径:行号`,前缀与提交号的对应见 §0。 + +--- + +## 0. 调研对象与版本 + +| 前缀 | 对象 | 提交 / 版本 | 日期 | +|---|---|---|---| +| RT | dotnet/runtime | `bc110ba`(main,12.0 alpha);另核对 `release/10.0` `591ede8`、`release/11.0` | 2026-10-16 | +| NP | microsoft/node-pty | `d182c60`(npm latest 1.1.0;beta 1.2.0-beta.15) | 2026-10-02 | +| WT | microsoft/terminal | `44b440b`(另核对 `release-1.24`、`release-1.25`、`v1.18.10301.0`) | 2026-10-13 | +| GH | ghostty-org/ghostty | `b3dc3d7` | 2026-10-15 | +| XEV | mitchellh/libxev | `9ce8e8e` | 2026-05-06 | +| AL | alacritty/alacritty | `5421470`(alacritty_terminal 0.26.1-dev) | 2026-10-16 | +| WZ | wezterm/wezterm(`pty/` 即 portable-pty 0.9.0) | `e2a98e1` | 2026-10-10 | +| XNU | apple-oss-distributions/xnu | 标签 `xnu-12377.121.6`(最新);本地快照 `xnu-12377.1.9` | — | +| LX | torvalds/linux | master(7.3-rc7) | 2026-10-16 | +| GLIBC | glibc | HEAD(NEWS 已到 2.45) | 2026-10-16 | +| MUSL | musl | HEAD | 2026-10-16 | +| PP | tomlm/Porta.Pty | `0186b60`(NuGet 2.2.3) | 2026-10-01 | +| VSP | microsoft/vs-pty.net | `8351527` | 2024-05-14 | +| QP | aaasoft/Quick.PtyNet | `304c9bb`(NuGet 1.0.4) | 2026-03-02 | +| MP | guitarrapc/MiniPty | `7460059`(NuGet 1.3.0) | 2026-10-15 | +| RTM | royalapplications/RoyalTerminal | `01833b8`(NuGet 0.7.0-preview.2) | 2026-10-09 | +| GF | ghostflyby/Pty.Net | `9dc4a8f`(NuGet Ghostflyby.Pty 1.0.0) | 2026-10-12 | +| CONPTY | NuGet Microsoft.Windows.Console.ConPTY | 1.25.260930003(stable) | 2026-10-02 | +| local | 本机 macOS 26.6.2(25G83),.NET SDK 10.0.301,Xcode SDK 头文件 | — | 2026-10-16 | + +TCode 现状(已读):`ProcessRunner.cs`、`BashTool.cs`、`ShellResolver.cs` 只用 `System.Diagnostics.Process` 以 `-c` 方式运行非交互命令,没有任何 PTY 代码。Desktop 与 AgentHost 两个进程都会用 `Process` 启动子进程。 + +--- + +## 1. 结论摘要 + +1. **不要在 .NET 进程里从托管代码 fork。** vs-pty.net、Quick.PtyNet 在 `forkpty` 返回后的子进程里继续执行托管代码(`Environment.CurrentDirectory = ...`)[实 VSP src/Pty.Net/Mac/PtyProvider.cs:57-72]。fork 后的子进程只剩一个线程,GC、JIT、锁的状态都是 fork 那一刻的复制品,只允许调用 async-signal-safe 函数。Porta.Pty 把 fork 移到了 C 里,但子进程里仍调用 `setenv`、`execvp`(可能 malloc)[实 PP porta_pty.c:245,265,272],严格来说也不安全。Ghostflyby.Pty 甚至在 C# 里直接 `fork()`,靠 `GC.TryStartNoGCRegion` 规避 [实 GF PtyProcess.Start.Unix.cs:227-238]。**推荐全部改用 `posix_spawn`。** +2. **posix_spawn 在 Linux 上可以独立完成,在 macOS 上必须加一个小 helper。** + - Linux:glibc 和 musl 都是**先 `setsid` 再执行 file actions** [实 GLIBC spawni.c:159-174;MUSL posix_spawn.c:69,121]。内核规定"会话首进程第一次不带 `O_NOCTTY` 打开 tty 时,该 tty 自动成为控制终端" [实 LX tty_io.c:2138-2144、tty_jobctrl.c:132-154]。所以 `POSIX_SPAWN_SETSID` + `addopen(0, slave)` 就能得到控制终端(RoyalTerminal 正是这么做的 [实 RTM UnixPtyProcess.cs:56-63])。 + - macOS:XNU **先执行 file actions,后执行 `POSIX_SPAWN_SETSID`** [实 XNU kern_exec.c:4894 vs 5140 @ xnu-12377.121.6]。打开 slave 时进程还不是会话首进程,拿不到控制终端,而 posix_spawn 也没有 TIOCSCTTY 动作。因此 node-pty、RoyalTerminal、MiniPty 都在 macOS 上用一个十几行的 helper:它先 `ioctl(0, TIOCSCTTY)` 再 `execvp` [实 NP src/unix/spawn-helper.cc:1-23;RTM native/unix-pty/spawn-helper.c]。 +3. **.NET 的 SIGCHLD 处理器默认不会抢走我们的子进程**,但有两个例外和一个副作用。 + - 默认只回收 `Process` 类启动的子进程 [实 RT pal_signal.c:374-413;ProcessWaitState.Unix.cs:609-700]。 + - 例外:启动时 SIGCHLD 的原始处置为 `SIG_IGN`,或者进程是 pid 1(容器里),这时会 `waitpid(-1)` 回收所有子进程 [实 同上]。 + - 副作用:它用 `waitid(P_ALL, WNOWAIT)` 窥探。我们的子进程退出后如果迟迟不回收,每次 SIGCHLD 都会触发 .NET 扫描全部托管子进程 [实 ProcessWaitState.Unix.cs:622-640]。所以要**立即**用 `waitpid(pid)` 回收,且**永远不要** `waitpid(-1)`。 + - 退出通知:Linux 用 `pidfd_open`(5.3+)+ poll,macOS 用 kqueue `EVFILT_PROC` + `NOTE_EXIT|NOTE_EXITSTATUS`。RHEL 8(内核 4.18,.NET 10 仍支持)要回退为专用线程阻塞 `waitpid(pid)`。 +4. **.NET 会把 SIGPIPE 设为 `SIG_IGN`,`Process.Start` 启动的子进程会继承这个设置**(本机实测:`Process.Start` 启动的 perl 报告 `SIGPIPE=IGNORE`)[实 local;RT PalUnix.cpp:204-210、pal_process.c macOS posix_spawn 路径只重置"自定义处理器"]。在 shell 里会表现为 `yes | head` 之类的管道行为异常。PTY 启动时必须用 `POSIX_SPAWN_SETSIGDEF` 把**全部**信号重置为默认(node-pty 在 macOS 的 pty_posix_spawn 中用 `sigfillset` [实 NP pty.cc:740-866])。TCode 现有的 BashTool 也有这个问题,见 §6 陷阱 U3。 +5. **Apple arm64 上不能直接 P/Invoke 可变参数函数**(`ioctl`、`fcntl`、`open`):可变参数走栈,固定参数走寄存器,结果参数错位 [实 dotnet/runtime#48752 已关闭,通用方案 #48796 仍为 Open/Future]。Porta.Pty 实测 `fcntl(F_SETFL)` 写入了垃圾标志 [实 PP porta_pty.c:363-375],RoyalTerminal 的 resize 只好退化为调用 `stty` [实 RTM UnixPty.cs:197-214]。**所以 Unix 侧本来就需要一个 C shim**,"纯 C#"路线在 macOS 上并不纯。 +6. **Windows:随应用分发 `conpty.dll` + `OpenConsole.exe`,不依赖系统 conhost。** + - 2024-08 ConPTY 重写(#17510)后,应用输出的 VT 序列**原样透传**,不再被"重新渲染";`PASSTHROUGH_MODE`、`RESIZE_QUIRK`、`WIN32_INPUT_MODE` 三个 flag 已从源码删除 [实 WT PR #16954/#17510/#16916]。 + - Windows 10 的 inbox conhost 不再回移修复(DHowett,2024-06);Windows 11 的 inbox 版本只在"大版本换代"时同步 OpenConsole,下一次可能是 26H2(lhecker,2025-10)[实 WT discussion #17608]。 + - NuGet 包 `Microsoft.Windows.Console.ConPTY` 1.25.260930003(2026-10-02,MIT)包含 x86/x64/arm64 的 `conpty.dll` 和 `OpenConsole.exe` [实 CONPTY]。VS Code(node-pty)、WezTerm、Alacritty、Zed、Porta.Pty 都在分发或支持加载它。 +7. **现有 .NET 库没有一个可以直接用**:AOT 安全性、fork 安全性、ConPTY 版本、维护状况至少有一项不达标(§8)。**推荐路线 B**:C# `LibraryImport` 加一个约 300 行的 C shim(`libtcodepty`)和约 40 行的 helper 可执行文件(`tcode-pty-spawn`),只在 4 个 Unix RID 上构建;Windows 不需要我们自己的原生代码。 + +--- + +## 2. macOS / Linux 推荐实现 + +### 2.1 三种创建方式对比 + +| 方式 | 过程 | 在 .NET 进程中的风险 | 谁在用 | +|---|---|---|---| +| `forkpty`(= `openpty` + `fork` + `login_tty`) | 子进程里 setsid、TIOCSCTTY、dup2,再 exec | fork 复制整个多线程进程;fork 到 exec 之间只能调用 async-signal-safe 函数;CoreCLR 的信号处理器在子进程里会把 SIGTERM 转发给**父进程**(`kill(gPID, sig)`,gPID 缓存的是父进程 pid)[实 PP porta_pty.c:180-200 的事故记录];完整 fork 大堆进程有页表复制开销 | node-pty(Linux)、Porta.Pty、MiniPty(Linux)、vs-pty.net | +| `openpty` + `fork` + 子进程自己 setup | 同上 | 同上;Rust 的 `Command::pre_exec`(Alacritty、portable-pty)也属于此类,且 portable-pty 在 pre_exec 里 `read_dir("/dev/fd")`,会分配内存 [实 WZ pty/src/unix.rs:152-180,238-290] | Ghostty、Alacritty、WezTerm | +| `openpty` + `posix_spawn` | 内核或 libc 在受控路径里完成 setsid、dup2、exec;glibc 用 `clone(CLONE_VM\|CLONE_VFORK)`,在子进程里屏蔽全部信号 [实 GLIBC spawni.c:31-47,99-135] | 不在子进程里执行任何我们的代码;不复制页表 | node-pty(macOS)、RoyalTerminal、MiniPty(macOS) | + +### 2.2 控制终端的语义差异 + +| 事实 | Linux | macOS(XNU) | 来源 | +|---|---|---|---| +| 会话首进程打开 tty 时自动成为控制终端 | 是(未设 `O_NOCTTY`、尚无控制终端、tty 不属于其他会话;还要求读权限) | 是(`!(flags & O_NOCTTY)` 且没有 `P_CONTROLT`) | LX tty_io.c:2138-2144、tty_jobctrl.c:132-154;XNU vfs_syscalls.c:5168-5176 [实] | +| `TIOCSCTTY` | 要求调用者是会话首进程且尚无控制终端,否则 `EPERM` | 同左 | LX tty_jobctrl.c:365-395 [实] | +| posix_spawn 中 SETSID 与 file actions 的顺序 | glibc / musl:**先 setsid,后 file actions** | **先 file actions,后 setsid** | GLIBC spawni.c:159-161 vs 174;MUSL posix_spawn.c:69 vs 121;XNU kern_exec.c:4894 vs 5140 [实] | +| 结论 | `SETSID` + `addopen(0, slave_path, O_RDWR)` 即可获得控制终端,不需要 helper | 必须在 exec 之后的新进程里做 `TIOCSCTTY`,即需要 helper | 推导自上面三行 [实] | + +`POSIX_SPAWN_SETSID` 的可用性 [实]: + +| libc | 版本 | 备注 | +|---|---|---| +| glibc | 2.26 起(`0x80`) | GLIBC NEWS 2.26、spawn.h:62。.NET 10 的 glibc 基线是 2.27,因此总能使用 | +| musl | 1.1.17 起(`128`) | MUSL WHATSNEW 1.1.17。.NET 基线 musl 1.2.3 | +| macOS | `0x0400` | SDK sys/spawn.h:61;node-pty 为 10.14 以前的系统自行定义 [NP pty.cc:84-86] | + +相关的 file action 扩展 [实 GLIBC NEWS、MUSL WHATSNEW、macOS SDK spawn.h]: + +| 函数 | glibc | musl | macOS | +|---|---|---|---| +| `addchdir_np` | 2.29 | 1.1.24 | 10.15;26.0 起标记为废弃,改用 `addchdir` | +| `addclosefrom_np` | 2.34 | 无 | 无,改用 `POSIX_SPAWN_CLOEXEC_DEFAULT`(`0x4000`) | +| `addtcsetpgrp_np` | 2.35 | 无 | 无 | +| `pidfd_spawn` | 2.39 | 无 | — | + +**注意:RHEL 8 是 glibc 2.28,而 .NET 10 仍支持 RHEL 8** [实 dotnet/core supported-os.md]。它没有 `addchdir_np` 和 `addclosefrom_np`。这是 Linux 上也统一使用 helper 的主要理由:cwd 和关闭多余 fd 都交给 helper 处理,不依赖 libc 版本。 + +### 2.3 推荐的启动序列(macOS 与 Linux 统一) + +``` +父进程(C shim: tcpty_spawn,从 C# 一次 LibraryImport 调用进入) + 1. 全局互斥锁(macOS 的 openpty / posix_openpt 并发时会以负 errno -6(-ENXIO) 失败, + Porta / MiniPty / Ghostflyby 都实测到了 [实 PP porta_pty.c:110-130;MP minipty_unix.c:179-200;GF PtyProcess.Start.Unix.cs:34-45]) + 2. master = posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC) ; macOS 失败且 errno<=0 时重试 ≤8 次,间隔 1 ms + grantpt(master); unlockpt(master) + 取 slave 路径:macOS 用 ioctl(TIOCPTYGNAME)(线程安全,NP pty.cc:788),Linux 用 ptsname_r + slave = open(path, O_RDWR | O_NOCTTY | O_CLOEXEC) + (也可以直接 openpty(),再对两个 fd 设 FD_CLOEXEC,Ghostty 的做法 [GH pty.zig:156-170]) + 3. termios:tcgetattr(slave) 取得系统默认值,只额外加上 IUTF8 → tcsetattr(§2.6) + ioctl(slave, TIOCSWINSZ, {rows, cols, xpixel, ypixel}) + 4. pipe2(statusPipe, O_CLOEXEC) ; helper 通过它报告 exec 之前的错误 + 5. posix_spawnattr: + flags = POSIX_SPAWN_SETSID | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK + (macOS 另加 POSIX_SPAWN_CLOEXEC_DEFAULT) + sigdefault = sigfillset() ; 必须是全集,才能清除继承来的 SIGPIPE=SIG_IGN + sigmask = sigemptyset() + file actions: + adddup2(slave, 0); adddup2(slave, 1); adddup2(slave, 2) + adddup2(statusPipe[1], 3) + (master、slave 原 fd 都带 CLOEXEC,不用显式 close) + 6. posix_spawn(&pid, HELPER_PATH, &fa, &attr, + argv = [helper, cwd, file, argv0, args...], ; argv0 允许 "-zsh"(login shell) + envp = 由 C# 构造的完整环境块) + EINTR 时重试;失败 → 回滚并返回 errno + 7. close(statusPipe[1]); close(slave) ; 父进程必须关闭 slave,否则永远读不到 EOF + read(statusPipe[0], &err, 4): + 返回 0 → exec 成功(fd 3 因 CLOEXEC 被关闭) + 返回 4 字节 → helper 失败:waitpid(pid, 0) 回收,把 err 交给调用方 + 8. 解锁;fcntl(master, F_SETFL, O_NONBLOCK)(在 C 里调用,见 §5 的可变参数问题) + 9. 注册退出监视(§2.5),返回 {master, pid} + +helper(tcode-pty-spawn,exec 后是一个全新的单线程进程,没有 async-signal-safe 的限制) + a. ioctl(0, TIOCSCTTY, 0) ; Linux 上自动获得的控制终端此调用直接成功,macOS 上则必需 + b. close_range(4, ~0U, 0) ; Linux ≥5.9;否则遍历 /proc/self/fd;macOS 已由 CLOEXEC_DEFAULT 处理 + c. if cwd 非空: chdir(cwd),失败则回退到 $HOME(与 Ghostty 一致,忽略错误继续 [GH Command.zig:212]) + d. execve(resolved_file, argv0..., environ) ; 用 PATH 查找后的绝对路径,查找规则与 execvp 相同 + e. 失败: write(3, &errno, 4); _exit(127) +``` + +为什么不用 helper 的"exec 后再 setsid"写法:子进程必须在 exec 之前就是会话首进程,否则 shell 的作业控制(`tcsetpgrp`)会失败。`POSIX_SPAWN_SETSID` 已经在 exec helper 前完成了这一步。 + +macOS 的 login shell:05 报告建议用 `/usr/bin/login -flp /bin/bash --noprofile --norc -c 'exec -l '`。这只改变 helper 要 exec 的 `file` 和 argv,PTY 层不受影响。注意 login(1) 会 fork 一次,并以 root 身份等待子进程(system_cmds login/login.c:851-879 @ `408bba7`)[实],所以我们拿到的 pid 是 login 的 pid,退出码由 login 转交 [推,需实测]。 + +### 2.4 进程组、会话与关闭语义 + +- shell 是新会话的首进程,它自己管理作业的进程组和前台进程组(`tcsetpgrp`),PTY 层不要干预。 +- **关闭标签时应模拟系统终端**: + 1. `kill(-pgid_of_shell, SIGHUP)`(即 killpg),再 `close(master)`。master 关闭后内核也会对会话挂断(hangup),向前台进程组发 SIGHUP。 + 2. 等待最多 N ms(建议 2000),然后 `kill(-pgid, SIGKILL)`。 + 3. `nohup`、`disown` 或自行 `setsid` 的后台程序**保留**,与 Terminal.app、Ghostty 一致。 +- Ghostty 循环执行 `killpg(pgid, SIGHUP)`,直到 `waitpid(WNOHANG)` 返回 [实 GH termio/Exec.zig:1167-1211]: + - 用来覆盖"setsid 尚未执行时 pgid 还等于父进程"的窗口(`getpgid` 循环,Exec.zig:1213-1245)。采用 posix_spawn 后,`posix_spawn` 返回时 setsid 已经完成,这个窗口不存在 [推]。 + - Darwin 上 killpg 偶尔返回 `EPERM`,Ghostty 选择忽略 [实 Exec.zig:1186-1191]。 +- Alacritty 在 Drop 时只对子进程发 SIGHUP,再阻塞 `wait` [实 AL tty/unix.rs:309-319]。 +- **"杀进程树"不是系统终端的行为**,不作为默认做法。只有 TCode 自身退出时,才对所有仍存活的会话执行上面的 SIGHUP → SIGKILL 序列。 +- Linux 可选 `prctl(PR_SET_PDEATHSIG)`:需要在 helper 里设置,而且只作用于直接子进程(login 或 shell),作为 TCode 崩溃时的兜底 [推]。 + +### 2.5 退出码获取:与 .NET 运行时共存 + +**.NET 的行为** [实,main 与 release/10.0 逻辑相同]: + +1. 任何 `Process`、`Console` 或 `PosixSignalRegistration` 的使用都会安装 SIGCHLD 处理器(`SA_RESTART|SA_NOCLDSTOP`)。处理器只往管道写一个字节,由 ".NET SigHandler" 线程处理 [RT pal_signal.c:226-266,345-413]。 +2. `reapAll = (g_pid == 1) || 原始处置是 SIG_IGN` [pal_signal.c:384]。为真时调用 `waitpid(-1, WNOHANG)`,**连我们的子进程一起回收** [pal_signal.c:388-402;ProcessWaitState.Unix.cs:688-708]。 +3. 否则走 `CheckChildren`: + - `waitid(P_ALL, WEXITED|WNOHANG|WNOWAIT)`(只窥探不回收)[pal_process.c:1152-1173]; + - 窥探到的 pid 若属于自己则回收;不属于自己(例如我们的 shell)就设 `checkAll`,逐个 `waitpid(pid, WNOHANG)` 自己的子进程,**不碰我们的子进程** [ProcessWaitState.Unix.cs:609-686]。 + +**对我们的要求**: + +| 规则 | 原因 | +|---|---| +| 只 `waitpid(pid, …)`,绝不 `waitpid(-1)` | 否则会抢走 `Process` 的退出状态,导致它的 `WaitForExit` 永远挂起(Porta 的 PtyReaper 注释里有同样的结论 [实 PP PtyReaper.cs:14-36]) | +| 收到退出通知后立刻回收 | 未回收的僵尸会让 .NET 每次 SIGCHLD 都走 `checkAll` 全量扫描 | +| `waitpid` 返回 `ECHILD` 时,退出码记为"未知" | 出现在 reapAll 场景(pid 1 容器、父进程把 SIGCHLD 设为 SIG_IGN);UI 显示"进程已退出",不显示退出码 | +| 不要把 SIGCHLD 设为 SIG_IGN 或 SA_NOCLDWAIT | 会让内核自动回收,所有人都拿不到退出码 | + +**通知机制**: + +| 平台 | 机制 | 要点 | 来源 | +|---|---|---|---| +| Linux ≥5.3 | `pidfd_open(pid, 0)` → poll/epoll 可读 → `waitpid(pid, &st, 0)`(或 `waitid(P_PIDFD)`,需 5.4) | pidfd 对未回收的已退出子进程立即可读;不能用 `/proc/pid` 的 fd 代替,它不可 poll | man pidfd_open(2)、waitid(2) [实];XEV src/watcher/process.zig:45,110 [实] | +| Linux <5.3(RHEL 8) | 每个会话一个专用线程阻塞 `waitpid(pid, &st, 0)`,处理 EINTR | node-pty 在 Linux 上就是这么做的 | NP pty.cc:200-218 [实] | +| macOS | kqueue `EVFILT_PROC`,fflags = `NOTE_EXIT \| NOTE_EXITSTATUS` → `waitpid(pid)` | 注册时返回 `ESRCH` 表示已经退出,要立即 `waitpid` 兜底;node-pty 参照 Chromium 处理了这个"僵尸竞态" | NP pty.cc:170-200;XEV backend/kqueue.zig:45-48,1270-1282;SDK sys/event.h:261-267 [实] | + +建议做法:全进程**一个**退出监视线程(epoll/kqueue 上挂所有 pidfd 或 proc filter,再加一个唤醒管道),Porta.Pty 与 MiniPty 都是这样 [实 PP PtyReaper.cs;MP minipty_unix_exit.c]。读线程见 §2.7。 + +**.NET 11 的新选项** [实 RT PR #128862,2026-06-30 合并,release/11.0 已含]:`UnixProcessStartArguments.Start(ProcessStartInfo, Func)` 允许我们用自己的系统调用创建进程,再把 pid 交给 `Process` 跟踪。之后 `WaitForExitAsync`、`ExitCode`、`Kill` 都由运行时维护,SIGCHLD 的冲突从根本上消失。缺点:回调在内部的启动锁里执行;TCode 目前用 .NET 10,.NET 11 GA 预计在 2026-11 [推]。**建议 v1 用自研退出监视,抽象成 `IPtyExitWatcher`,升级到 .NET 11 后再评估切换。** + +### 2.6 termios 初始值 + +| 项 | Linux pts 默认 | macOS 默认 | 结论 | +|---|---|---|---| +| iflag | `ICRNL\|IXON`(无 IUTF8) | `BRKINT\|ICRNL\|IMAXBEL\|IXON\|IXANY`(无 IUTF8) | 保留系统默认,**只加 IUTF8**,否则 canonical 模式下退格删不掉完整的 UTF-8 字符 | +| oflag | `OPOST\|ONLCR` | 同 | — | +| cflag | `B38400\|CS8\|CREAD` | `CREAD\|CS8\|HUPCL` | — | +| lflag | `ISIG\|ICANON\|ECHO\|ECHOE\|ECHOK\|ECHOCTL\|ECHOKE\|IEXTEN` | `ECHO\|ICANON\|ISIG\|IEXTEN\|ECHOE\|ECHOKE\|ECHOCTL` | — | +| VERASE | `0x7f` | `0177` | 退格键发送 `0x7f` | +| macOS 特有 | — | `VDSUSP=^Y`、`VSTATUS=^T` | 显式写死 termios 的库(node-pty、vs-pty.net)会丢掉其他平台的默认值,所以不要整体覆盖 | + +来源 [实]:LX pty.c:884-893、tty_io.c:124-134、termios_internal.h:21-35;XNU bsd/sys/ttydefaults.h:78-104;NP pty.cc:353-385;GH pty.zig:172-180;AL tty/unix.rs:205-211。 + +结构体布局:macOS 的 `tcflag_t` 和 `speed_t` 是 `unsigned long`(8 字节),`NCCS=20`;Linux 是 4 字节,`NCCS=32` [实 SDK sys/termios.h:108,263-265]。**termios 只在 C shim 里处理**,不在 C# 里镜像这个结构体。 + +### 2.7 读写模型 + +| 事实 | 来源 | +|---|---| +| Linux:最后一个 slave fd 关闭后,`read(master)` 返回 **`EIO`**,poll 报 `POLLHUP` | LX n_tty.c:2140-2141,2449-2450;pty.c:57-69 [实];WZ 把 EIO 当作 EOF [WZ unix.rs:95-107] | +| macOS:子进程退出后 `read(master)` 返回 **0**(而不是 EAGAIN),随后 poll 报 HUP | XNU tty_dev.c `ptcread` 中 `TS_CONNECTED==0 → EOF` [实];GH Exec.zig:1680-1684 [实] | +| macOS:内核 tty 输出队列让**每次 read 最多返回约 1 KiB**,与缓冲区大小无关 | GH Exec.zig:1264-1299 [实,Ghostty 实测] | +| macOS:`poll(2)` 的 man 页 BUGS 写着 "does not support devices",但 Ghostty 实际对 pty master 使用 poll;XNU 实现了 `ptmx_kqfilter` | local man 2 poll;GH Exec.zig:1710-1726;XNU tty_ptmx.c:773 [实];libuv 对 macOS 上 /dev 的 fd 改用 select 线程 [实 libuv src/unix/stream.c:273-298] | +| 后台进程仍持有 slave(例如 shell 退出前 `cmd &` 了一个守护进程)时,Linux 不会出现 EIO | 推导自上 [推] | + +**推荐**: + +- 每个会话一个**专用读线程**(`new Thread`,不占用线程池;macOS 上设 QoS user-initiated,Ghostty 实测吞吐提升约 15%)。 +- master 设为非阻塞。用 `poll([master, wakePipe])` 等待;可读时连续 `read` 直到 EAGAIN,攒成一批(最多 64 KiB,或最长 3 ms)后交给解析线程。**这就是 Ghostty 的 gather 设计**,用来弥补 macOS 每次只能读 1 KiB 的限制。 +- `read` 返回 0 或 `EIO` 一律视为 EOF;`EINTR` 重试。 +- 没有使用 epoll/kqueue 管理多个会话:几十个标签对应几十个线程完全可以接受,代码也简单 [推]。 +- **写入**:用户输入、粘贴、resize 引发的应答(DA、CPR)放进一个 channel,由**专用写线程**写入。写满(`EAGAIN`)时 poll `POLLOUT`。不能在 UI 线程上阻塞写:子进程不读时,输入队列很快就满(macOS 的 tty 输入队列较小 [推])。大段粘贴按 4 KiB 分块。 +- 关闭时:写入 wakePipe → 读线程退出 → 关闭 master。 + +### 2.8 resize + +- 对 **master** 调用 `ioctl(TIOCSWINSZ, {ws_row, ws_col, ws_xpixel, ws_ypixel})`。内核只在尺寸确实变化时向前台进程组发 SIGWINCH [实 LX tty_io.c:2299-2317;XNU tty.c:1594-1600],所以 RoyalTerminal 手动补发 SIGWINCH [RTM UnixPty.cs:216-225] 是多余的。 +- 务必填写像素尺寸(`cols×cellW`、`rows×cellH`):sixel、kitty 图形协议和一些 TUI 需要它们。Ghostty 与 Alacritty 都会填写 [实 GH Exec.zig:1140-1147;AL unix.rs:427-436];Porta.Pty 写的是 0 [实 PP porta_pty.c resize]。 +- 调用必须走 C shim(ioctl 是可变参数函数)。 +- UI 侧 resize 做 16–33 ms 去抖 [推]。 + +### 2.9 前台进程、标题与 cwd + +| 需求 | Linux | macOS | 来源 | +|---|---|---|---| +| 前台进程组 | `tcgetpgrp(master)` | `tcgetpgrp(master)` | NP pty.cc:659-720;GH pty.zig:266-290 [实] | +| 进程名 | `/proc//comm`(15 字符)或 `/proc//cmdline` 第一段 | `proc_name(pid)` / `proc_pidinfo(PROC_PIDTBSDINFO).pbi_name`,或 `sysctl{CTL_KERN,KERN_PROC,KERN_PROC_PID}` 的 `p_comm`(node-pty) | SDK libproc.h:96-102、sys/proc_info.h:72-73,723;NP pty.cc:700-720 [实] | +| 子进程 cwd | `readlink /proc//cwd` | `proc_pidinfo(pid, PROC_PIDVNODEPATHINFO)` → `pvi_cdir.vip_path` | SDK sys/proc_info.h:342,741 [实] | +| "是否有程序在运行"(关闭确认) | `tcgetpgrp(master) != shell 的 pgid`,再按忽略列表比对进程名 | 同左 | 05 报告 §8.1 | + +- cwd 的首选来源是 shell 集成的 OSC 7;`/proc` 和 libproc 只作兜底。按需查询(标题刷新时节流到 ≤1 Hz),不要轮询 [推]。 +- macOS 用 login 包装时,`tcgetpgrp` 得到的是 shell 的 pgid。shell 是 login 的子进程,对比时要用"shell 的 pgid",不能用 spawn 返回的 pid(那是 login)[推]。 +- 这些查询都放进 C shim(`sysctl`、`proc_pidinfo` 的结构体太大,不适合在 C# 中镜像),只返回字符串或整数。 + +### 2.10 环境变量 + +- 由 C# 构造完整的 `envp`(`KEY=VALUE\0` 数组)传给 `posix_spawn`。**不要在子进程里 setenv**(Porta.Pty 的做法)。 +- PTY 层只负责传递,策略由 05 报告 §2 决定,例如 `TERM=xterm-256color`、`COLORTERM=truecolor`、`TERM_PROGRAM=TCode`,剔除 `TCODE_AGENT`、`VTE_VERSION`、`TMUX` 等。 +- `LANG` / `LC_*`:macOS 从 Dock 启动的程序常常没有 `LANG`。是否补 UTF-8 locale 由 05 报告决定。PTY 侧只保证 IUTF8 已经打开。 + +--- + +## 3. Windows(ConPTY)推荐实现 + +### 3.1 API 全集(以 WT `44b440b` 与 Microsoft Learn 为准) + +| API | kernel32(inbox) | conpty.dll 导出 | 说明 | +|---|---|---|---| +| `CreatePseudoConsole(COORD, hIn, hOut, flags, HPCON*)` | 1809(17763)+ | `ConptyCreatePseudoConsole`,另有同名别名 | 文档要求 hIn 和 hOut 是同步句柄;新版源码已支持 overlapped(#17510)[实] | +| `ResizePseudoConsole(HPCON, COORD)` | 1809+ | 有,带别名 | 实现上是往 signal 管道写 `{RESIZE, X, Y}` [实 WT winconpty.cpp:288-301] | +| `ClosePseudoConsole(HPCON)` | 1809+;**24H2(26100)起立即返回**,此前会一直等到 conhost 退出 | 有,带别名;OSS 版本已对齐 24H2,不再等待 [实 WT PR #17704] | 向所有客户端发 `CTRL_CLOSE_EVENT` | +| `ReleasePseudoConsole(HPCON)` | **24H2(26100)+** | `ConptyReleasePseudoConsole`,带别名 | 释放 `\Reference` 句柄。之后最后一个客户端断开时 conhost 自动退出,output 管道 `ReadFile` 返回 `ERROR_BROKEN_PIPE` [实 Learn;WT winconpty.cpp:548-575] | +| `ClearPseudoConsole(HPCON, BOOL keepCursorRow)` | 不在公开文档里 | 有,带别名 | 用户执行"清除缓冲区"时同步清空 conhost 缓冲区(GH#1193) | +| `ConptyShowHidePseudoConsole(HPCON, BOOL)` | — | 只有 Conpty 前缀的名字 | 同步伪窗口的最小化状态(GH#12515) | +| `ConptyReparentPseudoConsole(HPCON, HWND)` | — | 只有 Conpty 前缀的名字 | 让 `GetConsoleWindow()` 返回宿主拥有的 HWND(GH#2988) | +| `ConptyCreatePseudoConsoleAsUser`、`ConptyPackPseudoConsole` | — | 只有 Conpty 前缀的名字 | 以其他用户身份运行 / 接收 handoff 会话,TCode 用不到 | + +来源 [实]:WT src/inc/conpty-static.h:26-49、src/winconpty/dll/winconpty.def、winconpty.cpp:430-622;Learn CreatePseudoConsole、ClosePseudoConsole、ReleasePseudoConsole 页面。 + +### 3.2 dwFlags 的真实定义(以现行源码为准) + +| flag | 值 | 现状 | 说明 | +|---|---|---|---| +| `PSEUDOCONSOLE_INHERIT_CURSOR` | `0x1` | 有效 | ConPTY 启动时先发 `ESC[6n`,**必须应答**,否则在旧版本中会卡住输入;文档也警告关闭时可能死锁。TCode **不使用** | +| `PSEUDOCONSOLE_RESIZE_QUIRK` | `0x2` | **已删除**(#17510,2024-08) | 旧 inbox 版本中用于改善 resize 重绘;新版本忽略 | +| `PSEUDOCONSOLE_WIN32_INPUT_MODE` | `0x4` | **已删除**(#16916,2024-06);现在 ConPTY 总是请求 9001 | — | +| `PSEUDOCONSOLE_PASSTHROUGH_MODE` | `0x8` | **已删除**(#16954,2024-03) | **注意:0x8 和 0x10 已被重新用于字宽模式**。新版 conpty.dll 会把旧的 0x8 解释为 GLYPH_WIDTH_GRAPHEMES | +| `PSEUDOCONSOLE_GLYPH_WIDTH_GRAPHEMES` / `_WCSWIDTH` / `_CONSOLE` | `0x08` / `0x10` / `0x18`(掩码 `0x18`) | 有效(#16916 起) | 对应 `--textMeasurement graphemes\|wcswidth\|console`。**必须与我们模拟器的字宽算法一致**,否则光标会错位 | +| `PSEUDOCONSOLE_AMBIGUOUS_IS_WIDE` | `0x20` | 有效,**只在 1.25 中** | 东亚歧义宽度字符按宽字符处理,需与模拟器设置一致 | + +来源 [实]:WT conpty-static.h:26-38、winconpty.cpp:169-185;`v1.18.10301.0` 的 winconpty.h:53-63(旧 flag 定义);`release-1.24` 和 `release-1.25` 的 conpty-static.h 对比。WezTerm 至今仍传 `0x1|0x2|0x4`(WZ pseudocon.rs:85-87),其中 0x4 不落在字宽掩码内,所以无害。 + +### 3.3 调用序列 + +``` +初始化(进程级): + conpty = LoadLibraryExW(\conpty.dll 绝对路径, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|…) + 失败 → 回退 kernel32(inbox,功能受限;按"降级模式"记录日志) + 通过 GetProcAddress 取函数指针(delegate* unmanaged[Stdcall],AOT 安全) + job = CreateJobObject(); SetInformationJobObject(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) ; TCode 崩溃时兜底 + +每个会话: + 1. CreatePipe(&inRead, &inWrite) ; CreatePipe(&outRead, &outWrite) ; 匿名、同步、不可继承 + 2. hr = CreatePseudoConsole({cols, rows}, inRead, outWrite, flags, &hpc) + flags = 0,或 GLYPH_WIDTH_GRAPHEMES(取决于模拟器字宽,见 §3.2) + ConPTY 内部会 DuplicateHandle 这两个句柄 [WT winconpty.cpp:496], + 所以可以立即关闭 inRead / outWrite(文档建议在 CreateProcess 之后关闭;两种都可以) + 3. 启动读线程(ReadFile(outRead) 循环)和写线程,**必须在 CreateProcess 之前启动**, + 以便及时应答 ConPTY 的 DA1 握手 + 4. InitializeProcThreadAttributeList(NULL, 2, …) 取大小 → 分配 → 初始化 + UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, lpValue = hpc(HPCON 值本身,不是 &hpc), sizeof(HPCON)) + UpdateProcThreadAttribute(PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(HANDLE)) ; Win10+ + 5. STARTUPINFOEXW si = {cb = sizeof}; si.dwFlags = STARTF_USESTDHANDLES; hStd* = NULL + (显式置空,防止 TCode 自身被重定向的 stdio 被子进程继承;WZ pseudocon.rs:114-123、NP conpty.cc:396-401) + CreateProcessW(NULL, cmdline(可写缓冲区), NULL, NULL, bInheritHandles = FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT, + envBlock, cwd, &si, &pi) + DeleteProcThreadAttributeList;CloseHandle(pi.hThread) + 6. 如果 ReleasePseudoConsole 可用(conpty.dll,或 inbox 26100+):立即调用 + → 此后 outRead 上的 ERROR_BROKEN_PIPE 就是"所有客户端都已断开"的可靠 EOF(node-pty 同样做法,NP conpty.cc:450-460) + 7. 退出通知:RegisterWaitForSingleObject(pi.hProcess, WT_EXECUTEONLYONCE) 或专用等待线程 + → GetExitCodeProcess(AL tty/windows/child.rs:40-80) + +resize:ResizePseudoConsole(UI 侧去抖) +清屏:用户执行"清除缓冲区"时调用 ClearPseudoConsole(hpc, keepCursorRow = TRUE)(conpty.dll 才有) + +关闭标签: + a. 读线程**继续读取**,直到 ERROR_BROKEN_PIPE + b. 在**读线程以外**的线程上调用 ClosePseudoConsole(hpc)(会发 CTRL_CLOSE_EVENT) + 旧版 inbox 中此调用会一直阻塞到 conhost 退出,期间必须有人在读 output 管道,否则死锁 + c. 关闭 inWrite;等进程句柄最多 N 秒;超时则 TerminateJobObject 或 TerminateProcess 兜底 + d. 关闭 outRead、hProcess +``` + +### 3.4 系统 conhost 与随包 conpty.dll + +| 维度 | inbox conhost | 随包 `conpty.dll` + `OpenConsole.exe` | +|---|---|---| +| 版本 | 跟随 OS。Windows 10 不再回移修复;Windows 11 只在大版本换代时同步(下一次可能是 26H2)[实 WT discussion #17608] | 由 TCode 锁定版本,跨 Windows 10 1809 到 11 26H1 行为一致 | +| VT 原样透传、OSC 透传、同步输出等 | 取决于 OS 构建 [推:24H2/25H2 是否已含 #17510 未核实] | 1.22 起具备(WT 1.22 发行说明 [实 devblogs 2024-08-27]) | +| `ReleasePseudoConsole`、不阻塞的 Close | 26100+ | 全部 Windows 版本 | +| Kitty 键盘协议、不等待 DA1 | 否 | 1.25(#19817、#20536)[实] | +| 代价 | 0 | 约 1.2 MB / 架构(x64 的 conpty.dll 120 KB,OpenConsole.exe 1.09 MB)[实 CONPTY] | + +**`Microsoft.Windows.Console.ConPTY` NuGet 包** [实,下载 1.25.260930003 解包核实]: + +- 版本:stable 线有 `1.24.261001001` 和 **`1.25.260930003`**(两者都发布于 2026-10-02);预览线是 `1.26.260930004-preview`。许可证 MIT;描述写明 "should work on all versions of Windows 10.0.17763.0 and above"。 +- 文件: + - `runtimes/win-{x86,x64,arm64}/native/conpty.dll`; + - `build/native/runtimes/{x86,x64,arm64}/OpenConsole.exe`; + - `runtimes/*/lib/uap10.0/conpty.lib`; + - `inc/conpty.h`。 +- `build/*.props` 和 `*.targets` 按 `PlatformTarget` 把 OpenConsole 复制到 `x64\`、`arm64\` 子目录:x64 应用同时带上 x64 和 arm64 两份,arm64 应用只带 arm64。 +- conpty.dll 的查找顺序:同目录 `OpenConsole.exe` → `<本机架构>\OpenConsole.exe`(按 `IsWow64Process2` 判断本机架构)→ **找不到时静默回退 System32\conhost.exe** [实 WT winconpty.cpp:49-95]。**打包时必须验证 OpenConsole.exe 确实在位**,否则功能会悄悄降级。 + +**同类项目的做法**: + +| 项目 | 做法 | 来源 | +|---|---|---| +| node-pty | 随包带 conpty 1.25.260303002(x64、arm64);`useConptyDll` 仍标为 EXPERIMENTAL,默认 false。VS Code 1.109(2026-02)移除 winpty 后,建议用 `terminal.integrated.windowsUseConptyDll: true` 兜底,仍称实验性 | NP third_party/conpty、typings/node-pty.d.ts:99-111;code.visualstudio.com/updates/v1_109 [实] | +| WezTerm | 随包带 `assets/windows/conhost/{conpty.dll, OpenConsole.exe}`,优先加载同目录的 conpty.dll,否则用 kernel32。2026-05 的 issue #7774:随包的 1.22 版本导致 pwsh 在 TUI 退出时崩溃,换成 1.24 版本后修复,并提醒**两个文件必须成对升级** | WZ pseudocon.rs:44-58 [实];wezterm#7774 [实] | +| Alacritty | 在 PATH 或可执行文件目录中找到 conpty.dll 就使用,否则用系统 API | AL tty/windows/conpty.rs:30-90 [实] | +| Zed | 2025 年起随包分发 conpty.dll + OpenConsole.exe(PR #39090) | [实 提交说明] | +| Porta.Pty | 依赖 ConPTY 1.24.260710001,带绝对路径的 `DllImportResolver`,防止误绑定 System32 下的 conpty.dll | PP Porta.Pty.csproj:44、ConPtyImportResolver.cs [实] | + +### 3.5 Windows 专属陷阱 + +| # | 陷阱 | 对策 | +|---|---|---| +| W1 | **ClosePseudoConsole 死锁**:24H2 以前的 inbox Close 会等 conhost 退出,而 conhost 可能卡在向已满的 output 管道写"最后一帧" [实 Learn ClosePseudoConsole、Creating a Pseudoconsole session] | 读线程一直读到 BROKEN_PIPE;Close 在其他线程调用;优先使用随包 conpty.dll。Alacritty 的 Drop 里明确写着"会死锁"[实 AL conpty.rs:96-103] | +| W2 | 客户端还在启动时就 Close,会弹出 `0xc0000142` 错误框 [实 Learn] | 启动后约 500 ms 内的关闭请求排队,等进程完成初始化(或先 `WaitForInputIdle`)再执行 [推] | +| W3 | **DA1 握手**:ConPTY 启动时输出 `ESC[c`、`ESC[?1004h`、`ESC[?9001h` [实 WT VtIo.cpp:197-204]。1.24 及更早版本会**阻塞最多 3 秒**等待 DA1 回复(`WaitUntilDA1(3000)`);1.25 只在 INHERIT_CURSOR 时等待,且只等 1 秒(#20536,2026-08-12)[实 release 分支对比] | 我们是真正的模拟器:读线程在 CreateProcess 之前启动,解析到 DA1 立即回复。**不需要** Porta.Pty 那种 DA1 过滤流(它是为不应答的消费者准备的 hack)[实 PP StartupDa1FilterStream.cs:11-40] | +| W4 | **win32-input-mode(9001)**:ConPTY 请求终端改用 `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` 发送完整的 KEY_EVENT_RECORD | 键盘层要支持 9001,否则 Ctrl+Space、AltGr 组合、按键抬起等无法传给 Win32 控制台程序(WT spec #4999)[实]。1004 焦点事件也要支持 | +| W5 | 字宽不一致:ConPTY 自己维护一个缓冲区,按自己的字宽模式布局;模拟器的字宽不同时,emoji 和 CJK 会让光标错位 | flags 设为与模拟器一致的 GLYPH_WIDTH_*(01/04 报告决定字宽模型后再定)[推] | +| W6 | **旧版 inbox 的"重新渲染"**:#17510 之前,ConPTY 把屏幕当作渲染目标重新生成 VT,会改写或丢弃未知序列和 OSC,resize 时整屏重绘,清屏会出现奇怪的回滚 [实 PR #17510 说明] | 随包 conpty.dll;用户执行"清除缓冲区"时同时调用 ClearPseudoConsole,否则下一次重绘会把旧内容画回来(GH#1193)| +| W7 | resize 后 ConPTY 会重新获取光标位置:1.24 之后的版本会在 resize 后发 `ESC[6n`(#19535)[实 WT VtIo.cpp:747] | 模拟器任何时候都要应答 CPR | +| W8 | 编码:ConPTY 的输入和输出都是 UTF-8 + VT [实 Learn]。客户端程序的控制台代码页(例如 936)只影响程序自己的 `WriteFile` 字节如何解码,ConPTY 交给我们的总是 UTF-8 | 宿主端不需要 chcp;`ReadFile` 的结果直接按 UTF-8 增量解码(处理跨块截断)[推:中文 Windows 上用 `cmd /c type gbk.txt` 实测] | +| W9 | 句柄泄漏:`bInheritHandles=TRUE` 会把 TCode 的可继承句柄泄漏给 shell | 固定传 FALSE(node-pty 注释:"VERY IMPORTANT",NP conpty.cc:434)| +| W10 | 进程树:Close 后,挂在这个控制台上的客户端收到 CTRL_CLOSE_EVENT;脱离控制台的进程(GUI、`start` 出去的进程)不受影响 | 与 WT 一致,接受。Job 只用于 TCode 退出或崩溃时兜底。注意:KILL_ON_JOB_CLOSE 会连"用户在终端里 `start` 出去的 GUI 程序"一起杀掉,需要决定是否设置 `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` [推] | +| W11 | 前台进程和 cwd:Windows 没有 tcgetpgrp。`GetConsoleProcessList` 需要先 AttachConsole,而一个进程只能附加一个控制台,所以 node-pty 专门 fork 一个进程来查 [实 NP conpty_console_list.cc、conpty_console_list_agent.ts] | 用 Toolhelp32 遍历以 shell 为根的进程树判断"是否在运行"(VS Code、WezTerm 的做法,见 05 §8.1);cwd 只取 shell 集成的 OSC 7 / OSC 9;9 | +| W12 | DLL 劫持 / 误绑定:裸名 `conpty.dll` 可能解析到其他位置 | 只用绝对路径 `LoadLibraryExW`;无法加载时明确降级为 kernel32 并记录日志 | + +--- + +## 4. 陷阱清单(Unix) + +| # | 陷阱 | 对策 | 来源 | +|---|---|---|---| +| U1 | 托管代码中 fork、子进程运行托管代码、CoreCLR 信号处理器把信号转发给父进程 | 只用 posix_spawn;子进程逻辑全部在 helper 里 | §1.1 | +| U2 | `waitpid(-1)` 抢走 `Process` 的退出状态;不及时回收导致 .NET 反复全量扫描 | 只 `waitpid(pid)`,收到通知立即回收 | §2.5 | +| U3 | **SIGPIPE 继承为 SIG_IGN** | `SETSIGDEF` 用全集;**另外建议修复 TCode 现有的 BashTool**(`Process.Start` 启动的 bash 继承了 SIG_IGN,`… \| head` 的上游不会被 SIGPIPE 终止,只会收到 EPIPE)| 本机实测 [实] | +| U4 | 可变参数函数(ioctl、fcntl、open)在 Apple arm64 上 P/Invoke 后参数错位,而且**静默成功** | 全部放进 C shim | dotnet/runtime#48752/#48796;PP porta_pty.c:363-375 [实] | +| U5 | 父进程没有关闭 slave → 永远读不到 EOF | spawn 后立即关闭 | — | +| U6 | master 或 slave 泄漏给其他子进程(包括 AgentHost、`Process` 启动的工具进程) | 全部用 `O_CLOEXEC` 打开;macOS 用 `CLOEXEC_DEFAULT`,Linux 由 helper 执行 `close_range` | GH pty.zig:156-170;WZ unix.rs:139-180(GNOME/mutter 会给子进程泄漏 fd)[实] | +| U7 | macOS 上 openpty / posix_openpt 并发失败,errno 为 -6 | 加锁 + 重试 | §2.3 | +| U8 | macOS 每次 read 只得到约 1 KiB;单线程"读一次解析一次"会让 `cat` 大文件变慢 | gather 读线程 + 批量交付 | GH Exec.zig:1264-1299 [实] | +| U9 | Linux 上 EIO 和 macOS 上 read 返回 0 是同一种 EOF | 统一处理 | §2.7 | +| U10 | termios 结构体布局因平台而异 | 只在 C 里处理 | §2.6 | +| U11 | resize 不传像素尺寸 | 传 xpixel / ypixel | §2.8 | +| U12 | 写入阻塞 UI | 专用写线程 + channel | §2.7 | +| U13 | helper 被 NuGet 解压后丢失可执行位;macOS 需要签名和公证 | 构建和打包时 `chmod +x`;放在 `Contents/MacOS/` 或 `Contents/Helpers/` 并用 hardened runtime 签名 [推];RoyalTerminal 在运行时补设 `UserExecute` [实 RTM UnixPtyProcess.cs:112-115] | — | +| U14 | 在 PTY 会话里 `Process` 又启动了带终端的子进程?不会:TCode 自己的 stdin 不是这个 pty,.NET 的 `ConfigureTerminalForChildProcesses` 只作用于 TCode 自身的控制终端 | 无需处理 [推] | RT SafeProcessHandle.Unix.cs:297-310 | + +--- + +## 5. NativeAOT 注意事项 + +1. **全部用 `[LibraryImport]`,参数只用 blittable 类型**:`int`、`nint`、指针、`[StructLayout(Sequential)]` 的纯值类型。字符串用 `StringMarshalling.Utf8` / `Utf16` 或手动 `byte*`;程序集上加 `[assembly: DisableRuntimeMarshalling]`。不使用 `StringBuilder`、`[MarshalAs]` 等运行时封送。 +2. **动态加载的 conpty.dll 用函数指针**:`NativeLibrary.Load(absolutePath)` + `NativeLibrary.GetExport` → `delegate* unmanaged[Stdcall]`。不用 `Marshal.GetDelegateForFunctionPointer`(它依赖运行时生成代码)。 +3. **C shim 的加载方式**: + - 默认按名称 `libtcodepty` 懒绑定,文件放在 `runtimes//native/`,发布时平铺到输出目录; + - 可选 `` + `` **静态链接进 AOT 可执行文件**,少一个文件,调用也更快 [实 Learn native-aot/interop]。代价是需要为每个 RID 提供 `.a`,而且 JIT(Debug)构建仍然需要 `.so` / `.dylib`; + - helper 可执行文件无论如何都是单独的文件。 +4. **Windows 侧不需要我们自己的原生代码**:所有 Win32 API 都是固定参数(非可变参数)的 stdcall,可以全部 `LibraryImport`(或使用 CsWin32 源生成,Porta.Pty 的做法)。`STARTUPINFOEXW`、`PROCESS_INFORMATION`、`COORD` 都是 blittable 类型。 +5. **线程**:读、写和退出监视都用 `new Thread { IsBackground = true }`,不在线程池里阻塞。回调回 UI 时通过 `Dispatcher` 投递,不在原生线程上调用托管回调,因此不需要 `UnmanagedCallersOnly`。 +6. **fd 和句柄所有权**:用 `SafeHandle` 子类包装 master fd、HPCON、进程句柄,`ReleaseHandle` 里调用 `close` / `ClosePseudoConsole`。注意 W1:`ClosePseudoConsole` 不能在终结器线程上同步执行。 +7. 验证:按 TCode 的规则跑 `dotnet publish -r -p:PublishAot=true`,要求 0 个 IL2xxx/IL3xxx 警告,再运行 AOT 二进制做一次 PTY 往返冒烟测试(StreamMark 的 aot-smoke 模式)。 + +--- + +## 6. 进程归属(PTY 放在哪个进程) + +| 方案 | 说明 | 取舍 | +|---|---|---| +| a. Desktop 进程内 | 读线程直接把数据交给模拟器 | 延迟最低,实现最简单;PTY 原生代码崩溃会带走 UI | +| b. AgentHost 进程内 | 经 JSON-RPC 转发字节流 | 多一跳,而且 JSON 不适合传二进制(要 base64,约 33% 开销);好处是 Agent 的 bash 工具以后可以复用同一个 PTY | +| c. 独立的 pty-host 进程(VS Code 的 ptyHost 模式) | UI 重启或崩溃时终端会话仍然存活 | 复杂度最高;需要二进制帧协议 | + +PTY 库本身与进程无关(独立库 `TCode.Terminal.Pty`,未来 SSH 也实现同一个 `ITerminalTransport` 接口)。**v1 建议选 a** [推],这个选择不影响库的设计。 + +--- + +## 7. 现有库与参考实现评估 + +| 库 | 最新版本 / 最近提交 | 许可证 | TFM | AOT | Unix 子进程创建 | Windows | 结论 | +|---|---|---|---|---|---|---|---| +| **Pty.Net**(microsoft/vs-pty.net) | 无新版;最后提交 2024-05-14 | MIT | netstandard2.0 | DllImport + `StringBuilder` 等非 blittable 封送 | **C# 里 forkpty 后,子进程执行托管代码** [VSP Mac/PtyProvider.cs:57-72] | 随包带旧的 `os64\conpty.dll` + winpty | 不可用 | +| **Quick.PtyNet** | 1.0.4(2026-03-02) | **仓库和包都没有声明许可证**(GitHub license 为 null) | netstandard2.0 | 同上 | 同 vs-pty.net(是它的 fork)[QP Mac/PtyProvider.cs:57-72] | winpty x86/x64(无 arm64)+ 旧 conpty | 不可用(法律风险,且不安全) | +| **Porta.Pty** | 2.2.3(2026-10-01),2.x 从 2026-08 开始密集发版;22.7 万次下载 | MIT | net10.0 | `IsAotCompatible=true`,Windows 用 CsWin32 源生成;但 Mac/NativeMethods.cs 和 OOB ConPTY 仍有 `[DllImport]` [PP] | 原生 shim 内 `forkpty`,fork 前屏蔽信号并加锁;**子进程里调用 `setenv`、`execvp`**(不是 async-signal-safe);退出用 pidfd/kqueue;resize 不传像素 | 依赖 ConPTY NuGet 1.24.260710001(不是最新),Job Object,DA1 过滤 hack | 最成熟,但子进程实现的安全性和 ConPTY 版本都需要改;可以作为参考或 fork 的基础 | +| **MiniPty** | 1.3.0(2026-07-14),仓库 2026-10-16 仍活跃,6 星 | MIT | net10.0 | 声明 AOT ready,主要用 LibraryImport | Linux:shim 内 forkpty;macOS:posix_spawn + helper;FreeBSD 也支持 | **只用 inbox ConPTY**,不分发 conpty.dll | 设计接近推荐方案,但 Windows 侧不够,社区很小 | +| **RoyalTerminal**(RoyalApps.\*.Pty.\*) | 0.7.0-preview.2(2026-10-11),28 星 | MIT | net10.0(Avalonia 12.1.3 + libghostty) | 以 LibraryImport 为主,resize、waitpid 仍是 DllImport | **C# 直接调 posix_spawn**:Linux 用 SETSID + addopen;macOS 用 helper + 状态管道 [RTM UnixPtyProcess.cs] | ConPTY | Unix 启动方式是最好的参考;resize 在 macOS 上退化为 `stty`;预览版;整体绑定 libghostty | +| Ghostflyby.Pty | 1.0.0(2026-09-05) | Apache-2.0 | net10.0 | 有 LibraryImport | **C# 里直接 `fork()`**,靠 `GC.TryStartNoGCRegion` 规避 | ConPTY | 不可用 | +| Devolutions terminal | 2026-10-09 | 包 MIT,仓库 NOASSERTION | net10.0 | 部分项目 PublishAot | Linux 上用独立的 `dt-pty-host` 原生进程 | — | 可作为"独立 pty 宿主进程"的参考 | +| 其他(Terminal.Pty 0.4.4、PtySharp 1.1.0 只支持 net8 和 Unix、LibGhostty.Net、ConPtySharp 等) | — | — | — | — | — | — | 体量小或不完整,未深入 | + +非 .NET 参考实现: + +| 实现 | Unix | Windows | 备注 | +|---|---|---|---| +| node-pty(MIT,1.1.0 / 1.2.0-beta.15) | Linux:forkpty(C++);macOS:posix_spawn + spawn-helper;退出:Linux 专用线程 waitpid,macOS kqueue | 可选 conpty.dll 1.25.260303002,ReleasePseudoConsole | 是"macOS 必须用 helper"的权威出处 | +| portable-pty(MIT,crate 0.9.0,2025-02-11) | `Command::pre_exec`(fork):setsid、TIOCSCTTY、`close_random_fds`(会分配内存) | 优先同目录 conpty.dll,flags 0x7 | Rust 的 fork 路径在 .NET 进程中同样有 U1 风险 [推] | +| Alacritty | pre_exec 中 setsid + TIOCSCTTY;macOS 用 login;IUTF8;SIGCHLD 通过 signal-hook 管道 | 可选 conpty.dll | — | +| Ghostty | Zig 自己 fork + childPreExec;IUTF8;gather 读线程;killpg 循环;macOS 用 login | inbox CreatePseudoConsole(flags=0),Windows 支持仍初步 | 读路径性能设计的最佳参考 | + +--- + +## 8. 可选路线 + +| 路线 | 内容 | 优点 | 缺点 | 评估 | +|---|---|---|---|---| +| **A. 纯 C# LibraryImport** | Unix:C# 直接调 openpty、posix_spawn、waitpid、kqueue / pidfd;Windows:C# 调 ConPTY | 没有自研原生代码,也不需要 CI 构建原生产物 | **macOS 做不到**:拿不到控制终端(需要 helper),Apple arm64 上可变参数的 ioctl/fcntl 不安全(resize、非阻塞都受影响);termios 结构体要按平台镜像;RHEL 8 上没有 addchdir_np | 只有 Linux 和 Windows 能完全满足,不能作为整体方案 | +| **B. C# + 极小的 C shim 和 helper(推荐)** | `libtcodepty`(约 300 行:openpty + termios、spawn、resize、fg pgrp/名称/cwd、exit watch、set_nonblock)+ `tcode-pty-spawn`(约 40 行)。Windows 侧纯 C#,加上 ConPTY NuGet 的二进制 | 所有 fork/exec、可变参数调用、结构体差异都在 C 里一次处理好;子进程逻辑运行在 exec 后的干净进程中;与 StreamMark RaTeX 的 6 RID CI 先例一致(这里只需 4 个 Unix RID);完全由我们掌控 | 需要维护少量 C 代码和 CI;helper 要随包分发并签名 | **推荐** | +| C. 通过 FFI 复用 Rust portable-pty | cdylib 包一层 C ABI | 有现成的跨平台实现 | Unix 走 fork + pre_exec,`close_random_fds` 会分配内存,回到 U1 风险;退出监视和 SIGPIPE 要自己补;Windows flags 过时;crate 最后发布于 2025-02;需要引入 Rust 工具链和 6 RID 构建 | 不推荐 | +| D. 直接引用现有 .NET 库 | 首选 Porta.Pty 2.2.3 | 马上能用,22.7 万下载,正在维护 | 子进程里调用 setenv/execvp;ConPTY 版本被锁定在 1.24;仍有 DllImport;单人维护;不传像素尺寸;无法插入 login 和 helper 的定制 | 原型阶段可用;产品阶段建议 fork 后改造,实质上就回到了 B | + +--- + +## 9. 推荐 + +**路线 B**,建议以独立库 `TCode.Terminal.Pty` 的形式实现,未来 SSH 共用 `ITerminalTransport`: + +1. **Unix**: + - posix_spawn 启动 helper,由 helper 执行 TIOCSCTTY、close_range、chdir、execve;Linux 和 macOS 用同一套代码; + - 退出:pidfd(RHEL 8 回退为 waitpid 线程)或 kqueue,只 `waitpid(pid)`; + - termios 保留系统默认值并加 IUTF8;resize 带像素尺寸; + - 关闭:SIGHUP 进程组 → 关闭 master → 超时后 SIGKILL。 +2. **Windows**: + - 随包分发 `Microsoft.Windows.Console.ConPTY` **1.25.260930003** 的 conpty.dll 和 OpenConsole.exe,按绝对路径加载;加载失败时降级为 kernel32; + - flags 设为 0,或与模拟器字宽一致的 GLYPH_WIDTH_*; + - 读写各一个线程;Release 可用时立即调用;在读线程以外关闭;Job 只用于 TCode 退出时兜底; + - 模拟器必须支持 DA1、CPR、9001、1004。 +3. **顺带修复**:TCode 现有的 BashTool 启动的子进程继承了 `SIGPIPE=SIG_IGN`(U3)。可以在 `-c` 前面加 `trap - PIPE;`,或者以后统一走新的 spawn 路径。 +4. **.NET 11 升级后**:评估用 `UnixProcessStartArguments` / `WindowsProcessStartArguments` 回调接入 `Process` 的退出跟踪,替换自研的 exit watch。 + +实施里程碑建议: + +- M1:Linux 和 macOS 的 spawn、读写、resize、退出、关闭,以及 AOT 冒烟测试; +- M2:Windows ConPTY 与随包 OpenConsole; +- M3:前台进程、cwd、关闭确认; +- M4:压力测试(`cat` 1 GB、并发打开 50 个标签、快速开关、`yes | head`、TUI resize 风暴)。 + +--- + +## 10. 需要用户决策的点 + +| # | 决策 | 选项 | 建议 | +|---|---|---|---| +| 1 | 实现路线 | A 纯 C# / **B C# + C shim + helper** / C portable-pty / D Porta.Pty | **B**;原型期可以用 D 快速验证 UI | +| 2 | Windows 是否随包分发 conpty.dll + OpenConsole.exe(每架构约 1.2 MB,x64 包需同时带 arm64 的 OpenConsole) | 分发(锁定 1.25.x)/ 只用系统 conhost | **分发**;系统 conhost 只作降级 | +| 3 | PTY 放在哪个进程 | a Desktop 进程内 / b AgentHost / c 独立 pty-host | **a**(v1),库保持与进程无关 | +| 4 | Windows Job Object 的 KILL_ON_JOB_CLOSE 是否连带杀掉用户从终端 `start` 出去的程序 | 杀 / 允许 breakaway | 允许 breakaway,只兜底 shell 树 [推] | +| 5 | 退出监视最低内核要求 | 支持 RHEL 8(加 waitpid 线程回退)/ 要求 Linux ≥5.3 | 支持,回退代码约 30 行 | + +--- + +## 11. 来源 + +### 源码(浅克隆或 GitHub API,只读) + +| 来源 | 版本 | 用途 | +|---|---|---| +| github.com/dotnet/runtime:`src/native/libs/System.Native/pal_signal.c`、`pal_process.c`;`System.Diagnostics.Process/src/…/ProcessWaitState.Unix.cs`、`ProcessUtils.Unix.cs`、`SafeProcessHandle.Unix.cs`、`ProcessStartInfo.cs`、`Runtime/InteropServices/UnixProcessStartArguments.cs`;`nativeaot/Runtime/unix/PalUnix.cpp`;`pal/src/exception/signal.cpp` | main `bc110ba`、release/10.0 `591ede8`、release/11.0 | SIGCHLD、reapAll、WNOWAIT、SIGPIPE、posix_spawn 路径、.NET 11 回调 API | +| dotnet/runtime PR #128862、issue #71595、#48752、#48796 | — | 回调 API、可变参数 P/Invoke | +| github.com/microsoft/node-pty `src/unix/pty.cc`、`spawn-helper.cc`、`src/win/conpty.cc`、`conpty_console_list.cc`、`windowsPtyAgent.ts`、`third_party/conpty` | `d182c60` | | +| github.com/microsoft/terminal `src/winconpty/*`、`src/inc/conpty-static.h`、`src/host/VtIo.cpp`、`doc/specs/#4999…`;PR #14041、#16916、#16954、#17510、#17704、#19535、#19817、#19864、#20536、#20774;discussion #17608 | `44b440b`、release-1.24、release-1.25、v1.18.10301.0 | | +| github.com/ghostty-org/ghostty `src/pty.zig`、`src/pty.c`、`src/Command.zig`、`src/termio/Exec.zig` | `b3dc3d7` | | +| github.com/mitchellh/libxev `src/watcher/process.zig`、`src/backend/kqueue.zig` | `9ce8e8e` | | +| github.com/alacritty/alacritty `alacritty_terminal/src/tty/unix.rs`、`tty/windows/{conpty,child}.rs` | `5421470` | | +| github.com/wezterm/wezterm `pty/src/{unix,cmdbuilder}.rs`、`pty/src/win/pseudocon.rs`、`assets/windows/conhost`;issue #7774 | `e2a98e1` | | +| github.com/apple-oss-distributions/xnu `bsd/kern/kern_exec.c`、`bsd/vfs/vfs_syscalls.c`、`bsd/kern/tty.c`、`tty_dev.c`、`tty_ptmx.c`、`bsd/sys/{spawn.h,ttydefaults.h}` | xnu-12377.121.6 / 12377.1.9 | | +| github.com/apple-oss-distributions/system_cmds `login/login.c` | `408bba7` | | +| github.com/torvalds/linux `drivers/tty/{tty_io.c,tty_jobctrl.c,pty.c,n_tty.c}`、`include/linux/termios_internal.h` | master 7.3-rc7 | | +| sourceware.org glibc `sysdeps/unix/sysv/linux/spawni.c`、`posix/spawn.h`、`bits/spawn_ext.h`、`NEWS` | HEAD | | +| git.musl-libc.org musl `src/process/posix_spawn.c`、`include/spawn.h`、`WHATSNEW` | HEAD | | +| github.com/libuv/libuv `src/unix/stream.c` | v1.x | macOS 上 /dev 设备的 kqueue 问题 | +| github.com/tomlm/Porta.Pty | `0186b60` | | +| github.com/microsoft/vs-pty.net | `8351527` | | +| github.com/aaasoft/Quick.PtyNet | `304c9bb` | | +| github.com/guitarrapc/MiniPty | `7460059` | | +| github.com/royalapplications/RoyalTerminal | `01833b8` | | +| github.com/ghostflyby/Pty.Net | `9dc4a8f` | | +| github.com/Devolutions/devolutions-terminal | `be7ad21` | | +| zed-industries/zed 提交 `8af3f58`("Better conpty #39090") | — | | + +### 文档与元数据 + +- https://learn.microsoft.com/windows/console/createpseudoconsole +- https://learn.microsoft.com/windows/console/closepseudoconsole (24H2 行为说明,2025-07 更新) +- https://learn.microsoft.com/windows/console/releasepseudoconsole (26100+) +- https://learn.microsoft.com/windows/console/creating-a-pseudoconsole-session (2025-08 更新) +- https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute +- https://learn.microsoft.com/dotnet/core/deploying/native-aot/interop (DirectPInvoke / NativeLibrary) +- https://www.nuget.org/packages/Microsoft.Windows.Console.ConPTY (api.nuget.org registration;1.25.260930003 nupkg 已解包核实) +- https://www.nuget.org/packages/Porta.Pty 、/Quick.PtyNet 、/MiniPty 、/RoyalApps.RoyalTerminal.Terminal.Pty.Unix 、/Ghostflyby.Pty (NuGet 搜索与 registration API) +- https://registry.npmjs.org/node-pty ;https://crates.io/api/v1/crates/portable-pty +- https://code.visualstudio.com/updates/v1_109 (移除 winpty、windowsUseConptyDll) +- https://devblogs.microsoft.com/commandline/windows-terminal-preview-1-22-release (ConPTY 重写) +- https://github.com/dotnet/core/blob/main/release-notes/10.0/supported-os.md (RHEL 8、glibc 2.27 / musl 1.2.3 基线) +- man7.org:pidfd_open(2)(Linux 5.3)、waitid(2)(P_PIDFD 需 5.4) +- 本机 macOS SDK 头文件:`sys/spawn.h`、`spawn.h`、`_stdlib.h`、`sys/termios.h`、`sys/ttycom.h`、`sys/event.h`、`sys/proc_info.h`、`libproc.h`、`sys/sysctl.h`;man 页 poll(2)、kqueue(2) + +### 本机实测(macOS 26.6.2,.NET SDK 10.0.301) + +- 用 `dotnet run` 运行单文件脚本,以 `Process.Start` 启动 `/usr/bin/perl -e 'print $SIG{PIPE}'`,输出 `IGNORE`,证明 .NET 的子进程继承了 `SIGPIPE=SIG_IGN`(§1.4、U3)。 diff --git a/docs/terminal/research/03-vt-protocol.md b/docs/terminal/research/03-vt-protocol.md new file mode 100644 index 0000000..58fd678 --- /dev/null +++ b/docs/terminal/research/03-vt-protocol.md @@ -0,0 +1,868 @@ +# 终端协议与兼容性规格(VT / xterm 及现代扩展) + +> 用途:TCode 终端组件(Avalonia 12.1.3 + .NET 10,NativeAOT,6 个桌面 RID)的**协议规格书**。实现解析器、屏幕模型、输入编码、查询应答与安全策略时以本文为准。 +> 编写日期:2026-10-16。序列、模式号、API 名保留原文。 +> 目标:默认启动系统 shell,vim / neovim / tmux / htop / less / fzf / Claude Code / Codex 等 TUI 的行为必须与主流终端一致;以后要支持 SSH(远端未必有我们的 terminfo)。 + +## 0. 来源与可信度 + +每条关键结论后用方括号标出处。**[已核实]** 表示读过原文或源码;**[推断]** 表示由多份资料推出、没有直接出处;**[未核实]** 表示没查到可靠资料。 + +| 标记 | 来源(版本 / commit / 日期) | +|---|---| +| **[ctlseqs]** | XTerm Control Sequences,*updated for XTerm Patch #411 (2026/08/23)*,,已全文通读。引用时写章节名,例如 [ctlseqs §Mouse Tracking] | +| **[xterm-man]** | xterm(1) manpage(同站 `manpage/xterm.html`),资源 `allowWindowOps`、`disallowedWindowOps`、`disallowedPasteControls` | +| **[modkeys]** | Dickey, *XTerm – "Other" Modified Keys*, | +| **[ECMA-48]** | ECMA-48 第 5 版(1991,1998 年重印 PDF),§5.4 控制序列、§5.6 控制串、§8.3.117 SGR 等 | +| **[Williams]** | Paul Flo Williams, *A parser for DEC's ANSI-compatible video terminals*,(最后修订 2017-08-25) | +| **[VT510]** | VT510 Programmer Information,:DA1 / DA2 / DA3、DECRQM / DECRPM、DECRQSS / DECRPSS、DECSCUSR、DECSTBM、DECSLRM、DECOM、DECSC、DECSTR、ICH / DCH / ECH / IL / DL、chapter4 §4.3.3 | +| **[kitty-kbd] [kitty-gfx] [kitty-ul] [kitty-misc] [kitty-clip] [kitty-si]** | kitty 仓库 `docs/keyboard-protocol.rst`、`graphics-protocol.rst`、`underlines.rst`、`misc-protocol.rst`、`clipboard.rst`、`shell-integration.rst` @ `f426c08`(2026-10-16,kitty 0.49.2) | +| **[osc8]** | egmontkob, *Hyperlinks in terminal emulators*, | +| **[sync]** | contour `docs/vt-extensions/synchronized-output.md` @ `8f3a4a4`(2026-10-16),以及原始 gist christianparpart/d8a62cc1… | +| **[2027]** | contour-terminal/terminal-unicode-core `spec/terminal-unicode-core.tex` @ `64f5385`(draft rev.1) | +| **[2031]** | contour `docs/vt-extensions/color-palette-update-notifications.md` | +| **[2048]** | rockorager, *In-Band Window Resize Notifications*(gist e695fb2924d36b2bcf1fff4a3704bd83) | +| **[2033]** | rockorager, *Visibility reports*, | +| **[semprompt]** | Per Bothner, `proposals/semantic-prompts.md`(freedesktop terminal-wg,2019) | +| **[FinalTerm]** | p-e-w/finalterm `src/TerminalStream.vala:375,542-549` @ `f46ffd0` | +| **[iterm2-esc] [iterm2-feat]** | 、(2026-10 抓取) | +| **[vscode-si]** | ,"Supported escape sequences" 一节 | +| **[conemu]** | ,"ConEmu specific OSC" | +| **[wt-progress]** | Microsoft Learn, *Set the progress bar in the Windows Terminal*(updated 2025-08-21) | +| **[wt-4999]** | microsoft/terminal `doc/specs/#4999 - Improved keyboard handling in Conpty.md`(win32-input-mode) | +| **[UAX11]** | UAX #11 rev 46(Unicode 18.0.0,2026-07-31) | +| **[UAX29]** | UAX #29 rev 49(Unicode 18.0.0,2026-09-01) | +| **[wcwidth-spec]** | jquast/wcwidth `docs/specs.rst` @ `bcdf2c1`(v0.9.3) | +| **[ucs-detect]** | jquast/ucs-detect `README.rst`、`data/*.yaml` @ `50f011d`:2026-09/10 实测 35 个以上终端的结果 | +| **[quast-2026]** | Jeff Quast, *Perfecting Terminal Character Width Using Correction Tables*(2026-06-07) | +| **[TTWG]** | Unicode L2/25-263, *Text Terminal Working Group report*(UTC #185) | +| **[dgl-2023]** | David Leadbeater, *ANSI Terminal Security in 2023 and finding 10 CVEs*, | +| **[ncurses]** | ncurses `terminfo.src` $Revision 1.1198$(invisible-island current) | +| **[ghostty-terminfo]** | | +| 源码 | `ghostty@b3dc3d7`(2026-10-15);`wezterm@e2a98e1`(2026-10-10);`alacritty@5421470`(2026-10-16,0.18.0-dev);`alacritty/vte@abeae76`;`xterm.js@991857a`(2026-10-13,package 6.0.0);`kitty@f426c08`;`microsoft/terminal@44b440b`(2026-10-13);`iTerm2@5794785`(2026-10-15);`tmux@bfb01dc`(2026-10-16);`esctest2@2798f12`;`vttest-snapshots@0229d71`(vttest 2.7 / 20251205);vim `src/term.c`、`src/optiondefs.h`(master,2026-10-16 抓取);neovim `src/nvim/tui/tui.c`、`runtime/lua/vim/_core/defaults.lua`(master);emacs `lisp/term/xterm.el`(master);openai/codex `codex-rs/tui/src/{tui.rs,terminal_probe.rs,tui/keyboard_modes.rs}`(main);anthropics/claude-code `CHANGELOG.md`(2.1.301) | +| **[本机]** | macOS 26.6.2:`infocmp -V` 输出 ncurses 6.0.20150808;系统没有 `xterm-ghostty` 条目,有 `tmux-256color`;libc `wcwidth` 实测(见 §6.2) | + +文中写到 "Ghostty / WezTerm / WT / xterm.js / iTerm2 / kitty / Alacritty" 的支持情况时,凡是标 ✅ / ❌ 的都查过上表中的源码或 ucs-detect 实测数据。查不到的标"未核实"。 + +--- + +## 1. 解析器状态机规格 + +### 1.1 基本模型 + +TCode 的解析器以 **[Williams] 的 DEC ANSI 状态机**为骨架,叠加 xterm 及现代终端公认的扩展。Alacritty `vte`、WezTerm `vtparse`、Ghostty `Parser.zig`、Windows Terminal `stateMachine.cpp` 都是这样做的 [已核实:各仓库文件头注释]。 + +状态(14 个,不含 UTF-8 子状态): + +| 状态 | 进入条件 | 字节处理(GR 区 A0–FF 的处理见 §1.3) | +|---|---|---| +| `ground` | 初始;各序列 dispatch 之后 | 00–17, 19, 1C–1F → **execute**;20–7F → **print**(DEL 7F 实际忽略,§1.4) | +| `escape` | 任意状态遇到 1B | entry 执行 **clear**;C0 → execute;7F 忽略;20–2F → collect,转 `escape_intermediate`;`[`(5B) → `csi_entry`;`]`(5D) → `osc_string`;`P`(50) → `dcs_entry`;`X`(58) `^`(5E) `_`(5F) → `sos_pm_apc_string`;其余 30–7E → **esc_dispatch** 后回 `ground` | +| `escape_intermediate` | ESC 之后来了 20–2F | 20–2F → collect;30–7E → esc_dispatch;C0 → execute | +| `csi_entry` | `ESC [` 或 C1 9B | entry 执行 clear;30–39、3B → param,转 `csi_param`;**3A(冒号)→ 子参数**,转 `csi_param`(这是对 Williams 的偏离,见下);3C–3F → collect(私有标记),转 `csi_param`;20–2F → collect,转 `csi_intermediate`;40–7E → **csi_dispatch** | +| `csi_param` | | 30–39、3A、3B → param;3C–3F → `csi_ignore`;20–2F → `csi_intermediate`;40–7E → csi_dispatch | +| `csi_intermediate` | | 20–2F → collect;30–3F → `csi_ignore`;40–7E → csi_dispatch | +| `csi_ignore` | 格式错误 | 20–3F、7F 忽略;40–7E → 回 `ground`,**不 dispatch**;C0 仍然 execute | +| `dcs_entry` / `dcs_param` / `dcs_intermediate` | `ESC P` / 90 | 与 CSI 对应状态相同,但 **C0 一律忽略**;40–7E → **hook**,转 `dcs_passthrough` | +| `dcs_passthrough` | | 00–17、19、1C–7E → **put**;7F 忽略;以 ST / CAN / SUB / ESC 结束 → **unhook** | +| `dcs_ignore` | DCS 头部格式错误 | 一律忽略,直到 ST | +| `osc_string` | `ESC ]` / 9D | entry 执行 osc_start;20–7F → osc_put;**BEL(07) 结束 OSC**(xterm 扩展);其他 C0 忽略;exit 执行 osc_end | +| `sos_pm_apc_string` | `ESC X` / `ESC ^` / `ESC _` | Williams 规定全部忽略;**TCode 必须把 APC 内容交给处理器**(kitty 图形协议用 `ESC _G…ESC \`),SOS / PM 照旧忽略 | + +"anywhere"转换,在任何状态下优先生效 [Williams §anywhere;ghostty `parse_table.zig:56-85`]: + +- `18` CAN、`1A` SUB:取消当前序列,execute(SUB 本应显示 ␦,xterm 不显示),回 `ground`。 +- `1B` ESC:取消当前序列,进入 `escape`。控制串里的 `ESC \` 由此自然变成"ESC 取消串,然后 `\` 作为 ST 的 esc_dispatch,等于空操作",所以**不需要向前看** [Williams §escape]。 +- C1(80–9F):见 §1.3。 + +动作的执行顺序是:**旧状态 exit → 转换动作 → 新状态 entry** [Williams §The State Diagram]。 + +### 1.2 与 Williams 原版的偏离(必须做) + +| 偏离 | 理由与来源 | +|---|---| +| CSI 参数里的 `:`(3A)作为**子参数分隔符**,不再进入 ignore | ECMA-48 §5.4.2(b) 允许 3/10 作为参数子串内的分隔;xterm 支持 `38:2:Pi:R:G:B`、`4:3` 等 [ctlseqs §CSI Pm m];kitty 键盘协议的应答也用冒号 [kitty-kbd]。Ghostty 只对 `m` 接受冒号,其他 final 带冒号就丢弃整个序列(`Parser.zig` csi_dispatch,`warnCsiSepMismatch`)。WT 用白名单 `_CanSeqAcceptSubParam` [`OutputStateMachineEngine.hpp:251`]。**TCode 做法**:解析层保留每个参数的子参数,由 dispatch 决定;`m`、`=u`、`>4:Nm`(XTMODKEYS)、`48t` 之外的 final 带了冒号就丢弃 | +| OSC 可以用 BEL 结束 | [ctlseqs §Operating System Commands]:"XTerm accepts either BEL or ST… uses the same terminator used in a query"。**应答必须沿用请求的结束符** | +| ground 与 OSC 中接收 UTF-8 | 见 §1.3 | +| APC 内容不再丢弃 | kitty 图形协议(Tier 2)。即使暂不实现,也要能**正确跳过任意长度的 APC** | +| DCS 的 `put` 接收 80–FF | ghostty `parse_table.zig:240-262`:DCS 载荷(如 tmux 控制模式)承载 UTF-8,不能被 C1 切断 | + +### 1.3 C1 与 UTF-8 + +结论 [已核实 ctlseqs §Control Bytes;ghostty `stream.zig:1213-1240` 及其测试 `stream.zig:3360-3450`;dgl-2023 建议"Do not implement C1 control characters in UTF-8"]: + +1. **字节流始终按 UTF-8 解码**。TCode 不支持 ISO-8859 等非 UTF-8 编码,也不支持 `ESC % @`。 +2. **原始 8 位 C1 字节(80–9F)在 ground 中视为非法 UTF-8**,输出 U+FFFD,**不当作控制符**。xterm 原文的说明:"It is not possible to use a C1 control obtained from decoding the UTF-8 text, because that would require reprocessing the data." +3. **UTF-8 解码得到的 U+0080–U+009F 码点直接丢弃**,既不打印也不执行(与 xterm、Ghostty 一致)。差异提醒:WezTerm 会把解码出的 C1 当控制符执行(`vtparse/src/lib.rs` `next_utf8` 的"Slightly gross special cases"),Alacritty vte 也会执行(`lib.rs` `ground_dispatch`)。TCode 跟随 xterm / Ghostty,这条在安全上更稳妥。 +4. 在 escape / CSI / DCS 头部,高位字节只可能来自 C1 或垃圾数据。CSI 参数区只接受 7 位字节。不接受 8 位 C1 引导符(9B / 9D / 90),也就不需要 S8C1T。**`ESC SP G`(S8C1T)直接忽略,应答永远用 7 位形式** [推断:xterm 默认 7 位输出;VT510 S8C1T 页]。 +5. OSC 载荷按 UTF-8 收集,例如标题、URL、`OSC 52` 的 base64。CAN / SUB / ESC 能打断它。 +6. **UTF-8 解码器状态独立于 VT 状态机**。块边界上被截断的多字节序列要跨 `Feed()` 调用保存(Alacritty `partial_utf8`)。多字节序列被 ESC 打断时,先输出 U+FFFD,再处理 ESC(alacritty-vte `lib.rs` `advance_ground` 的 "Process bytes cut off by escape")。 + +### 1.4 C0 语义(ground) + +| 字节 | 行为 | 备注 | +|---|---|---| +| NUL 00 | 忽略 | Ghostty 也忽略 SOH / STX(WT #10786)[ghostty `stream.zig` execute] | +| ENQ 05 | 应答空串 | xterm 默认 answerback 为空 [ctlseqs §Single-character functions]。**禁止配置成可变内容** | +| BEL 07 | 响铃 | UI 可设为视觉提示、系统通知或忽略 | +| BS 08 | 光标左移一格,左边界处停止 | 反向换行(45 / 1045)属于 Tier 2 | +| HT 09 | 移到下一个制表位,没有则到右边界 | | +| LF 0A / VT 0B / FF 0C | 都等同于 LF:IND 语义;LNM(`CSI 20 h`)置位时再加 CR | [ctlseqs]:"FF is treated the same as LF"、"VT … treated the same as LF" | +| CR 0D | 回到行首;DECOM / DECLRMM 时回到左边距 | | +| SO 0E / SI 0F | LS1 / LS0 切换 GL | 用于 G1 线框字符集 | +| 其他 C0 | 忽略 | | +| DEL 7F | 忽略 | 只在 96 字符集映射到 GL 时可打印 [Williams §ground],TCode 不支持 96 字符集 | + +### 1.5 参数与上限 + +| 项 | 规范说法 | 各实现 | **TCode 规格** | +|---|---|---|---| +| 参数个数 | VT510 / Williams:16 个,多余的静默忽略 [VT510 §4.3.3;Williams §param] | xterm.js 32 [`Params.ts:78`];WT 32 [`stateMachine.hpp:32`];alacritty-vte 32 [`params.rs:5`];Ghostty 24(因为 Kakoune 发过 17 个参数的 SGR)[`Parser.zig:196-209`];WezTerm 256;kitty 256 [`vt-parser.c:22`];iTerm2 feature-reporting 要求 SGR / DECSET 至少支持 15 个 [iterm2-feat §Parsing] | **32 个参数 + 共 256 个子参数槽**。超出部分丢弃,但仍要识别 final 并跳过整个序列。**不要**因为参数溢出就把整个 SGR 作废,前 32 个照常执行(Alacritty 会作废,那是反例) | +| 单个子参数个数 | 无规定 | WT 每个参数最多 6 个 [`stateMachine.hpp:35`];xterm.js 共 32 个 | 每个参数最多 8 个。`38:2::R:G:B` 需要 5 个 | +| 参数值上限 | VT510:大于 9999 按 9999 处理;Williams:至少要能表示 16383 | WT 65535;xterm.js 0x7FFFFFFF;Ghostty u16 饱和 | **饱和到 65535**,具体控制函数再自行钳制,例如光标移动钳到屏幕尺寸,REP 钳到 65535 | +| 默认值 | 空参数和 0 都表示默认值,但默认值随函数而定(多数为 1)[Williams §param;ECMA-48 §5.4.2(e)] | | 解析层区分"空"和"0",dispatch 再按函数解释。**例外**:SGR 的空参数等于 0;`CSI 0 J` 就是 `ED 0` | +| 私有标记 | 只能出现在第 1 个字节:`< = > ?` | | 出现在其他位置时进入 ignore | +| intermediate 个数 | 规范不限;DEC 最多用 2 个 | 2(Alacritty、WezTerm);4(Ghostty) | **最多 2 个**,超出则标记 ignore,dispatch 改为空操作 | +| OSC 长度 | 无规定 | Ghostty 2048 字节以内放栈缓冲,最多 8 MiB [`osc.zig:361-365`];xterm.js OSC / DCS / APC 载荷上限 10,000,000 字节 [`Constants.ts:67`];kitty 单个转义序列上限 256 KiB [`vt-parser.c:18-21`];alacritty-vte no_std 时 1024 | **按 OSC 编号设上限**:标题 4 KiB;OSC 8 的 URI 8 KiB([osc8] 写到 VTE / iTerm2 用 2083 字节)、id 256 字节;OSC 52 默认 8 MiB(可配置);OSC 4 / 10–19 / 104 和 133 / 633 / 7 / 9 / 1337 一律 64 KiB;未知编号 4 KiB。超限后剩余字节**吞掉直到 ST / BEL**,不 dispatch | +| DCS 长度 | | 同上 | XTGETTCAP / DECRQSS 4 KiB;sixel 和 tmux 控制模式(Tier 2)流式处理,不整体缓冲;未知 DCS 流式丢弃 | +| APC 长度 | | | kitty 图形每个 chunk 4096 字节(规范要求客户端如此分块);图像总量另设配额 [kitty-gfx §Image persistence:kitty 每个 buffer 320 MB] | + +**防护要求**:所有缓冲都要有上限;超限后进入"吞掉"子状态,**不得**提前回到 ground,否则串尾残余会被当成可打印文本或命令注入。参数数字累加用饱和运算,参见 Ghostty `Parser.zig` 的 `*|=`、`+|=` 和 `param_acc_idx` 溢出保护。用 fuzz 测试保证"任意字节流不崩溃、内存有界"(§9)。 + +### 1.6 dispatch 规则 + +- 按 (私有标记, intermediates, final) 选择处理函数,未知组合静默忽略。 +- 参数按从左到右顺序处理,冲突时后者覆盖前者,例如 `CSI 7;0 m` 结果是不反显 [Williams §3.5.5]。 +- 实现上,SM / RM、DECSET / DECRST 的多参数形式等于逐个执行。 + +--- + +## 2. 分级功能矩阵 + +图例:✅ 支持;❌ 不支持;◐ 部分支持或有附加条件;? 未核实。依据见每节表后的注。 + +- **Tier 0**:常见 TUI 必需,缺了 vim / tmux / htop / less / fzf / Claude Code / Codex 会出错。 +- **Tier 1**:现代主流终端都支持,TUI 会探测后启用,缺了会降级。 +- **Tier 2**:可选,以后再做。 + +### 2.1 Tier 0:基础控制与编辑 + +| 功能 | 序列 | 语义要点与边界 | Gh / Wz / WT / xjs / iT2 / kit / Ala | +|---|---|---|---| +| 光标移动 | `CUU A` `CUD B` `CUF C` `CUB D` `CNL E` `CPL F` `CHA G` `CUP H` `HVP f` `VPA d` `VPR e` `HPA \`` `HPR a` | 参数 0 等于 1;结果钳到屏幕或滚动区边界。CUU / CUD 在滚动区内时停在边距,在区外时停在屏幕边缘。CUP / HVP / VPA / HPA 受 DECOM 影响。**所有移动都会清除 pending-wrap 标志** | 全部 ✅ | +| 自动换行 DECAWM | `CSI ? 7 h/l`,默认开 | **延迟换行(xenl)**:写满最后一列后光标留在原处,置 pending-wrap,下一个可打印字符到来时才换行。CR / BS / 光标移动会清除该标志。宽字符在最后一列放不下时:本行末格留空(Ghostty 称 spacer_head),整个字符换到下一行 | 全部 ✅ | +| 擦除 | `ED J`(0 / 1 / 2 / **3 清空 scrollback**)`EL K`(0 / 1 / 2)`ECH X` | 被擦除的格子用**当前背景色**填充(bce)[ncurses xterm 条目带 `bce`]。ECH 不移动光标,也不受滚动区限制 [VT510 ECH]。ED / EL 擦到宽字符的一半时,整个宽字符都要清掉 | 全部 ✅ | +| 插入 / 删除 | `ICH @` `DCH P` `IL L` `DL M` | ICH / DCH 只影响光标到右边距之间。IL / DL 只在滚动区内生效,光标在区外时无效,执行后光标回到左边距 [VT510 IL、DL;ghostty `Terminal.zig:2998,3172`]。被移入的空白带当前 SGR 背景。DCH 切开宽字符时用空格替换 [ghostty deleteLines 注释] | 全部 ✅ | +| 滚动 | `SU S` `SD T`(及 ECMA 早期的 `CSI ^`)`IND ESC D` `RI ESC M` `NEL ESC E` | 只在滚动区内滚动。**只有滚动区等于全屏、处于主屏、且行从顶部滚出时**才进入 scrollback。RI 在顶边距时整区下滚 | 全部 ✅ | +| 滚动区 DECSTBM | `CSI t;b r` | 参数非法(t ≥ b)时重置为全屏。设置后光标移到原点(受 DECOM 影响)[VT510 DECSTBM] | 全部 ✅ | +| 原点模式 DECOM | `CSI ? 6 h/l` | 置位时坐标相对滚动区,光标不能移出滚动区。**CPR 应答也取相对坐标** [ghostty `stream_handler.zig:850-860`] | 全部 ✅ | +| 保存 / 恢复光标 | `DECSC ESC 7` / `DECRC ESC 8`;`CSI s` / `CSI u`(SCOSC / SCORC,DECLRMM 置位时 `CSI s` 改为 DECSLRM) | 保存内容:位置、SGR、G0–G3 与 GL / GR、pending-wrap、DECOM、DECSCA、单次切换 [VT510 DECSC]。主屏和 alt 屏**分别保存**。没保存过就 DECRC 时,回到原点并重置属性 | 全部 ✅ | +| SGR 基本属性 | `CSI … m`:0 1 2 3 4 5 7 8 9 21 22 23 24 25 27 28 29 30–37 39 40–47 49 90–97 100–107 | 22 同时清除粗体和淡色。21 表示双下划线(xterm 如此),不是"取消粗体"。8 隐藏,28 显示 [ctlseqs §CSI Pm m;ECMA-48 §8.3.117] | 全部 ✅ | +| 256 色与真彩色 | `38;5;n` `48;5;n` `38;2;r;g;b` `38:5:n` `38:2:Pi:r:g:b` `38:2::r:g:b` | 两种写法都要支持:"xterm allows either colons (standard) or semicolons (legacy)… (but after the first colon, colons must be used)" [ctlseqs]。分号写法的 `38;2` 吃掉后面 3 个参数,`38;5` 吃掉 1 个。冒号写法 `38:2:` 后面若有 4 个值,第一个是色彩空间 Pi(忽略);若只有 3 个值,按 R:G:B 解释(兼容写法,iTerm2 feature 文档要求 " should be ignored")[推断 + iterm2-feat §24BIT] | 全部 ✅ | +| 光标显隐 DECTCEM | `CSI ? 25 h/l` | | 全部 ✅ | +| 备用屏 | `?47` `?1047` `?1048` `?1049` | **1049** = 保存光标 + 切换到 alt 屏 + 清空 alt 屏;退出时切回主屏并恢复光标 [ctlseqs DECSET 1049]。1047 退出时如在 alt 屏则先清空 alt 屏。47 只切换,不清屏也不保存光标。alt 屏没有 scrollback。**kitty 键盘协议的标志栈按屏分开**(§4.4) | Gh ✅ Wz ✅ WT ◐(只有 1049)xjs ✅ iT2 ✅ kit ✅ Ala ◐(只有 1049) | +| 光标键模式 DECCKM | `CSI ? 1 h/l` | 置位时方向键、Home、End 发 `SS3`(`ESC O A`)[ctlseqs §PC-Style Function Keys] | 全部 ✅ | +| 小键盘模式 | `ESC =` DECKPAM / `ESC >` DECKPNM;`?66` DECNKM | 见 §4.1 | Gh ✅ Wz ✅ WT ✅ xjs ✅ iT2 ✅ kit ? Ala ? | +| 插入模式 IRM / 自动换行模式 LNM | `CSI 4 h/l` / `CSI 20 h/l` | IRM 置位时打印字符前先执行 ICH。LNM 很少用,但 vttest 会测 | Gh ✅ Wz ✅ WT ✅ xjs ✅ iT2 ✅ kit ✅ Ala ✅ | +| 制表位 | `HTS ESC H`、`TBC CSI g`(0 / 3)、`CHT CSI I`、`CBT CSI Z`、`DECST8C CSI ? 5 W` | 初始每 8 列一个。缩小窗口不丢失制表位,**放大后新增的列也按 8 列间隔补齐** [推断:xterm / ghostty 行为]。TBC 只认 0 和 3 [ctlseqs CSI Ps g] | 全部 ✅ | +| 字符集 | `ESC ( 0` / `ESC ( B` / `ESC ) 0`,SO / SI,`ESC ( A`(英国) | 只需支持 **ASCII、DEC Special Graphics(`0`)和 UK**。DEC 线框映射表见 ghostty `charsets.zig:53-87`(0x60–0x7E 映射到 U+25C6 … U+00B7)。tmux、ncurses 在 `acsc` 里用到它,terminfo 中 `smacs=\E(0`、`rmacs=\E(B`。G2 / G3 和 SS2 / SS3、LS2 / LS3 解析后归入 Tier 1 | 全部 ✅ | +| REP | `CSI n b` | 重复**前一个可打印字符**。前一个是控制符时不起作用 [ECMA-48 §8.3.103]。n 要钳制。ncurses 的 `rep` 能力会用到(xterm-new 包含 `ansi+rep`)[ncurses] | Gh ✅ Wz ✅ WT ✅ xjs ✅ iT2 ✅ kit ✅ Ala ✅ | +| 软复位 / 硬复位 | `DECSTR CSI ! p` / `RIS ESC c` | DECSTR 的复位项见 [VT510 DECSTR 表 5-9]:DECTCEM 开、IRM 关、DECOM 关、**DECAWM 关**(DEC 原文如此;xterm 实际保持开启。**TCode 跟 xterm 一样复位为开**,因为 terminfo `rs2` 中 `\E[!p` 之后不会再开 DECAWM [推断])、DECCKM 关、DECSTBM 全屏、SGR 0、字符集默认、保存的光标回原点。RIS 额外清屏、清 scrollback,并重置调色板、标题、所有模式、kitty 键盘栈、2027 | 全部 ✅ | +| 括号粘贴 | `CSI ? 2004 h/l` | 粘贴内容用 `ESC[200~ … ESC[201~` 包裹 [ctlseqs §Bracketed Paste Mode]。安全处理见 §8.4 | 全部 ✅ | +| 焦点事件 | `CSI ? 1004 h/l` | 获得焦点发 `CSI I`,失去焦点发 `CSI O` [ctlseqs §FocusIn/FocusOut]。ConPTY 启动时会主动开启 1004(§4.5) | 全部 ✅ | +| 鼠标 | 9 / 1000 / 1002 / 1003 + 1006 | 见 §5 | 全部 ✅ | +| 光标形状 DECSCUSR | `CSI Ps SP q`:0 / 1 闪烁块,2 稳定块,3 / 4 下划线,5 / 6 竖线 | xterm 中 0 等于 1,`7` 恢复为配置值 [ctlseqs]。iTerm2 和 Ghostty 把 0 当作"恢复用户默认"(`Se=\E[0 q` / `\E[2 q`)[iterm2-esc §DECSCUSR 0;ghostty-terminfo]。**TCode:0 = 用户默认形状**(vim / neovim 退出时会发 `Se`)。同时支持 `CSI ? 12 h/l`(闪烁) | 全部 ✅ | +| 设备查询 | DA1 / DA2 / DSR 5 / DSR 6(CPR)/ DECRQM | 应答内容见 §3。vim、neovim、tmux、Emacs、Codex 启动时都会发出查询(§7.3)。**没有应答会导致这些程序超时**,例如 Codex 每次探测等 250 ms [`terminal_probe.rs:21`] | 全部 ✅ | +| 窗口标题 | `OSC 0` / `OSC 2`(标题),`OSC 1`(图标名,可以忽略) | 标题按 UTF-8 解析,截断到 4 KiB,**去掉控制字符**(iTerm2 先把控制字符转成脱字符表示 [`VT100Terminal.m:2721-2731`])。标题栈见 Tier 1 | 全部 ✅ | +| 超链接 OSC 8 | `OSC 8 ; params ; URI ST` … `OSC 8 ; ; ST` | 超链接是每个格子的一项属性,类似颜色。`id=` 用来把分离的格子连成一个链接。URI 只允许 32–126 范围的字节。安全要求见 §8.3 [osc8] | Gh ✅ Wz ✅ WT ✅ xjs ✅ iT2 ✅ kit ✅ Ala ✅ | + +**Tier 0 的注**:Alacritty 支持的模式清单在 `alacritty/vte src/ansi.rs:939-967`;WT 在 `DispatchTypes.hpp:518-549`;Ghostty 在 `modes.zig:362-416`;xterm.js 是 `InputHandler.ts` 中 `setModePrivate` 的 case 列表;kitty 在 `modes.h`;WezTerm 在 `wezterm-escape-parser/src/csi.rs:885-959`;iTerm2 是 `VT100Terminal.m` 的 mode case 列表。 + +### 2.2 Tier 1:现代主流终端都支持 + +| 功能 | 序列 | 语义要点与边界 | Gh / Wz / WT / xjs / iT2 / kit / Ala | +|---|---|---|---| +| 同步输出 2026 | `CSI ? 2026 h`(BSU)/ `l`(ESU);用 `CSI ? 2026 $ p` 探测 | 置位期间渲染器继续显示上一帧,解析照常进行。复位时再取最新状态渲染。**必须有超时**:Ghostty 1000 ms [`termio/Thread.zig:38`],kitty 默认 2000 ms [`screen.c:3842`],WT 100 ms [`renderer.cpp:576`]。**TCode 取 1000 ms**。DECRPM 应答 1 / 2 [sync]。neovim 和 tmux 会先探测再使用 [`tui.c:575-605`;`tty.c:413-430`] | ✅ ✅ ✅ ✅ ✅ ✅ ✅ | +| DECRQM / DECRPM | `CSI Ps $ p`、`CSI ? Ps $ p` → `CSI [?]Ps;Pm $ y` | Pm:0 = 不认识,1 = 置位,2 = 复位,3 = 永久置位,4 = 永久复位 [VT510 DECRPM]。**每次只查一个模式**。所有已实现的模式都必须如实应答,否则 neovim / kitty 生态的探测会失败 | ✅ ✅ ✅ ✅ ✅ ✅ ✅ | +| SGR 下划线样式 | `4:0` `4:1` `4:2` `4:3`(波浪)`4:4`(点)`4:5`(虚线),`21`(双下划线) | [kitty-ul] terminfo 扩展能力 `Smulx=\E[4:%p1%dm`。**`4:` 之后的子参数不能被误读成 SGR 4 + 下一个参数**。只出现 `4` 时表示单下划线 | ✅ ✅ ◐(单 / 双 [`adaptDispatchGraphics.cpp:247-253`])✅ ✅ ✅ ✅ | +| 下划线颜色 | `58:2::r:g:b`、`58:5:n`、`58;5;n`、`58;2;r;g;b`、`59` | 语法同 38 / 48 [kitty-ul]。反显时不反转下划线颜色(未设色时跟随前景)。terminfo 扩展能力 `Setulc`、`Su` | ✅ ✅ ✅ ✅ ✅ ✅ ✅ | +| 上划线 | `SGR 53` / `55` | ECMA-48 定义;terminfo 扩展能力 `Smol` / `Rmol` | ✅ ? ? ? ? ? ? | +| 独立复位粗体 / 淡色 | `SGR 221` / `222` | kitty 扩展 [kitty-misc]。成本低,建议实现 | ? ? ? ? ? ✅ ? | +| DECSCUSR | 见 Tier 0 | | | +| 修饰键编码(xterm) | 方向键、F1–F12、编辑键带修饰参数 `CSI 1;m A`、`CSI n;m ~` | 见 §4.1。属于 Tier 0 级别的必需功能 | ✅ 全部 | +| modifyOtherKeys | `CSI > 4 ; Pv m`(0 / 1 / 2,3 只能通过资源设置);查询用 `CSI ? 4 m` | 见 §4.2。neovim 在 kitty 键盘协议探测失败后改用它 [`tui.c:316-330,2554-2563`];vim 默认对 `xterm*` 使用 `mok2` [vim `optiondefs.h:1582-1584`] | ? ✅ ? ? ? ❌(kitty 明确反对)✅(`ansi.rs:1684-1700`) | +| kitty 键盘协议 | `CSI = f ; m u`、`CSI ? u`、`CSI > f u`、`CSI < n u` | 见 §4.4。Claude Code、Codex、neovim、vim、fish 都会使用 | ✅ ◐(需开启 `enable_kitty_keyboard`,默认 false)◐(新代码有;ConPTY 中被强制关闭,见 §4.5)✅ ✅ ✅ ✅(默认开)| +| OSC 52 写剪贴板 | `OSC 52 ; Pc ; base64 ST` | Pc 为空、`c`、`p`、`s`、`0`–`7` 时都按系统剪贴板处理(主选区只在 Linux 有)。数据不是合法 base64 时**清空剪贴板**(xterm 语义)。改为**忽略**更安全 [推断] | ✅ ✅ ✅ ◐(addon)✅(需在偏好设置开启)✅ ✅(默认 OnlyCopy) | +| OSC 52 读剪贴板 | `OSC 52 ; Pc ; ? ST` | **默认询问或拒绝**(§8.1) | ◐ ask / ❌ 忽略 [`performer.rs:792`] / ❌ 忽略 [`OutputStateMachineEngine.cpp:821-830`] / ? / ◐ / ◐ ask / ❌ | +| OSC 7 当前目录 | `OSC 7 ; file://host/path ST` | URL 要做百分号解码。host 不是本机时只显示,不用来新建标签页(SSH 场景)。安全要求见 §8.5 | ✅ ✅ ✅ ❌(核心库没注册)✅ ✅ ?(未核实) | +| OSC 133 语义提示 | `A` `B` `C` `D[;exit]`,扩展 `L` `N` `P` `I` | 见 §2.4 | ✅ ✅ ✅ ❌(核心没有,VS Code 在上层实现)✅ ✅ ❌ | +| OSC 633(VS Code) | `A B C D E P` | 见 §2.4 | ❌ ❌ ✅ —(VS Code 上层)❌ ❌ ❌ | +| OSC 9;4 进度 | `OSC 9 ; 4 ; st ; pr ST` | 见 §2.5 | ✅ ✅ ✅ ◐ addon-progress ✅ ✅(0.47.0)? | +| 颜色查询与设置 | `OSC 4 ; n ; spec|?`、`OSC 10/11/12 ; spec|?`、`OSC 104 / 110 / 111 / 112` | 查询应答 `OSC 11 ; rgb:rrrr/gggg/bbbb ST`(每个分量 16 位)。**应答的结束符与请求相同**。neovim、Codex、tmux、Emacs 用它判断明暗主题 [`tui.c:353`;`tty.c:413-430`;`xterm.el:815-818`] | ✅ ✅ ✅ ✅ ✅ ✅ ✅ | +| 2031 调色板变更通知 | `CSI ? 2031 h/l`;查询 `CSI ? 996 n` → `CSI ? 997 ; 1|2 n` | 1 = 暗色,2 = 亮色。2031 置位后,主题或调色板一变化就主动发送 [2031] | ✅ ❌ ❌ ✅ ✅ ✅ ❌ | +| 2048 带内尺寸报告 | `CSI ? 2048 h/l` → `CSI 48 ; rows ; cols ; hpx ; wpx t` | 开启时**立即报告一次**,之后每次尺寸变化(包括字号变化)在 **PTY 尺寸更新完成后**报告。像素值不可得时填 0 [2048] | ✅ ❌ ❌ ❌ ✅ ✅ ❌ | +| 2027 字形簇模式 | `CSI ? 2027 h/l` | 见 §6 | ✅(可改)◐(永久置位)◐(永久置位)❌ ◐(永久复位)❌ ❌(据 [ucs-detect] data/*.yaml) | +| XTVERSION | `CSI > q` / `CSI > 0 q` → `DCS > | name version ST` | tmux 靠它识别外层终端 [`tty-keys.c` extended DA] | ✅ ✅ ❌ ✅ ✅ ✅ ❌ | +| XTGETTCAP | `DCS + q hex;hex ST` → 每项 `DCS 1 + r hex=hex ST`,未知项 `DCS 0 + r hex ST` | 见 §3.6 | ✅ ✅ ❌ ❌ ✅ ✅ ❌ | +| DECRQSS | `DCS $ q Pt ST` → `DCS 1 $ r … ST` 或 `DCS 0 $ r ST` | **xterm 的有效 / 无效码和 VT510 手册正好相反**(ctlseqs §Further reading:"the documented code for a valid or invalid response to DECRQSS was reversed")。**TCode 按 xterm:1 = 有效**。需要支持:`m`、`r`、`s`、`SP q`、`" q` | ✅(m / SP q / r / s)✅ ✅ ? ✅ ✅ ❌ | +| XTWINOPS 子集 | `CSI 14 t`(文本区像素 → `CSI 4;h;w t`)、`CSI 16 t`(格子像素 → `CSI 6;h;w t`)、`CSI 18 t`(行列 → `CSI 8;rows;cols t`)、`CSI 22;0|1|2 t` 和 `CSI 23;… t`(标题栈) | 其他操作(1–11、13、15、19–21、24+)**默认禁用**。xterm 默认也禁用 GetWinTitle 等 [xterm-man disallowedWindowOps]。只支持 18 t 的话 esctest 的部分用例跑不了(§9) | Gh ◐ / Wz ◐(栈只解析不生效 [`mod.rs:2215-2217`])/ WT ◐(只有 1,2,7,8,14,16,18 [`DispatchTypes.hpp:584-594`])/ xjs ◐(可配置)/ iT2 ✅ / kit ✅ / Ala ◐(14,18,22,23) | +| 标题栈 | `CSI 22 ; 0|1|2 t` / `CSI 23 ; … t` | vim 和 neovim 用 terminfo 的 `XT` 推断后会使用。栈深上限 10 [ctlseqs XTWINOPS 说明] | ✅ ❌ ❌ ◐ ✅ ✅ ✅ | +| 左右边距 | `CSI ? 69 h/l`(DECLRMM)、`CSI l;r s`(DECSLRM) | 69 置位时 `CSI s` 不再是 SCOSC。影响 ICH / DCH / IL / DL / 滚动 / CR / 换行。neovim 探测到后用它加速局部滚动 [`tui.c:575-605`];tmux 有 `margins` 特性 | ✅ ✅ ✅ ❌ ✅ ❌ ❌ | +| 鼠标 SGR-Pixels | `?1016` | §5 | ✅ ✅ ❌ ✅ ✅ ✅ ❌ | +| alternate scroll | `?1007` | alt 屏中滚轮转成方向键。Codex 会主动开启 [`tui.rs` EnableAlternateScroll] | ✅(默认开)? ✅ ? ✅ ? ✅ | +| G2 / G3、SS2 / SS3、LS2 / LS3 | `ESC * x` `ESC + x` `ESC N/O` `ESC n/o` | 解析并维护状态即可;只需要映射 ASCII 和 DEC 线框 | ✅ ◐ ✅ ◐ ✅ ? ? | + +### 2.3 Tier 2:可选 / 以后 + +| 功能 | 序列 | 评估 | +|---|---|---| +| kitty 图形协议 | `ESC _ G k=v,… ; base64 ESC \` | 规模大:传输方式 d / f / t / s(**f / t / s 涉及本地文件和共享内存,有安全约束**,见 [kitty-gfx §The transmission medium])、分块传输、放置、Unicode 占位符 U+10EEEE、动画、存储配额。支持方:kitty、Ghostty、WezTerm、iTerm2、Konsole(WT ❌,Alacritty ❌)。fzf、neovim、yazi 用它预览图片。**建议**:v1 只做 Tier 1 的"可识别并安全跳过 APC",并对查询 `a=q` **不应答**(应用会据此判断不支持)[kitty-gfx §Querying support] | +| Sixel | `DCS Pa;Pb;Ph q … ST` | 支持方:WezTerm、WT、iTerm2、xterm、foot、contour;不支持:Ghostty、kitty、Alacritty(ucs-detect 的 sixel 字段)。实现量中等(调色板加位图),但与 reflow、滚动交互复杂。**建议放在 v2**。DA1 中**未实现前不得声明 `4`** | +| iTerm2 内联图像 | `OSC 1337 ; File=… ST` | 与 kitty 图形二选一实现即可 | +| OSC 1337 其他 | `SetUserVar` `CurrentDir` `SetMark` `ClearScrollback` `CursorShape` `RemoteHost` `ReportCellSize` | 建议支持 `CurrentDir`(同 OSC 7)和 `SetMark`(VS Code 也认 [vscode-si])。其余忽略。**拒绝** `Copy=`、`CopyToClipboard`(与 OSC 52 走同一套权限)、`RequestUpload`、`SetBackgroundImageFile`、`OpenURL`、`Custom` | +| 2033 可见性报告 | `CSI ? 2033 h` → `CSI ? 999 ; 1|2 n`;查询 `CSI ? 998 n` | [2033] 成本低,kitty 和 Ghostty 已支持。有利于后台标签页降低 TUI CPU 占用,可以早做 | +| 5522 kitty 剪贴板 / 粘贴事件 | `OSC 5522` | 多 MIME 剪贴板,带密码授权 [kitty-clip]。v2 再做 | +| OSC 3008、7501 | 层级上下文、程序状态 | Claude Code 已在发送 OSC 7501(changelog 2.1.x:"Added Program Status Protocol (OSC 7501) support")。**v1 静默忽略即可**;如果 UI 想显示 Agent 运行状态,可以作为 Tier 2 的增值功能 | +| 反向换行 | `?45` `?1045` | vttest 会测;bash / readline 不依赖 | +| 矩形区域操作 | DECFRA `$x`、DECERA `$z`、DECCRA `$v`、DECSERA、DECCARA、DECRARA、DECIC / DECDC | WT 和 iTerm2 支持。**DA1 声明 `28` 之后,ConPTY 会改用 DECCRA 做滚动** [WT `getset.cpp:1044`],所以声明与实现必须一致。esctest 依赖 DECRQCRA(测试模式专用,见 §9) | +| DECDHL / DECDWL | `ESC # 3/4/5/6` | vttest 有专门测试;实际很少用。v2 | +| DECALN | `ESC # 8` | 实现简单(用 E 填满全屏、重置边距、光标归位 [VT510 DECALN]),**vttest / esctest 的基础**。建议放入 Tier 1 | +| XTSAVE / XTRESTORE | `CSI ? Pm s` / `CSI ? Pm r` | 低成本,tmux 和 vim 偶尔使用。kitty 扩展了不带参数的形式 [kitty-misc] | +| XTPUSHSGR / XTPOPSGR | `CSI # {` / `CSI # }` | 低成本,可选 | +| DECSCA / DECSED / DECSEL | `CSI " q`、`CSI ? J`、`CSI ? K` | vttest 会测。低成本 | +| 80 / 132 列 DECCOLM | `?3`(需要 `?40` 允许) | **不改窗口大小**。按 xterm 的 `?40` 默认关闭语义忽略即可,但 DECRPM 要如实应答 | +| 鼠标 1005 / 1015 / 1001、DEC Locator | | §5:解析后忽略,DECRPM 应答 0 或 4 | +| win32-input-mode 9001 | `CSI ? 9001 h/l` | **在 Windows + ConPTY 下应升为 Tier 1**(§4.5)。非 Windows 不需要 | +| VT52 模式 | `?2` 复位 | 不支持;DECRPM 对 2 应答 4(永久复位)[推断] | + +### 2.4 OSC 133 / 633 详细规格 + +**FinalTerm 原始定义** [FinalTerm `TerminalStream.vala:542-549`]:`133;A` 提示开始,`B` 命令输入开始,`C` 命令执行(输出开始),`D` 命令结束,`E` / `F` 文本菜单,`G` 进度,`H` 执行命令。**E–H 现代终端都不实现,TCode 必须忽略**(尤其 `H` 是安全隐患)。 + +**iTerm2 的约定** [iterm2-esc §Shell Integration/FinalTerm]: +- `D;Ps` 的 Ps 是 0–255 的退出码。 +- 如果 `B` 之后、`C` 之前就收到 `D`,表示命令被取消,当前提示状态全部丢弃。 +- 光标位置早于 `B` 时,命令视为空。 + +**Per Bothner 扩展** [semprompt],Ghostty 实现在 `osc/parsers/semantic_prompt.zig`,WezTerm 在 `osc.rs:706-747`: + +| 子命令 | 含义 | 选项 | +|---|---|---| +| `L` | fresh-line:光标不在行首就执行 `\r\n` | | +| `A` | fresh-line,然后开始新命令,进入提示状态 | `aid=`、`cl=line|m|v|w`;kitty 扩展:`k=s`、`redraw=0`、`special_key=1`、`click_events=1|2` [kitty-si] | +| `N` | 同 A,但先结束同 aid 的未闭合命令 | `aid`、`cl` | +| `P` | 显式开始提示 | `k=i|r|c|s`(初始 / 右侧 / 续行 / 次级) | +| `B` / `I` | 提示结束,用户输入开始;`I` 表示输入到行尾为止 | | +| `C` | 输入结束,输出开始 | kitty 扩展:`cmdline=`(%q 编码)、`cmdline_url=`(百分号编码) | +| `D` | 命令结束 | `;exitcode`、`err=`、`aid=` | + +选项格式是 `;key=value`,**未知 key 必须忽略** [semprompt §General options syntax]。 + +**VS Code OSC 633** [vscode-si]:`A B C D` 与 133 相同;`E ; [; nonce]` 中命令行用 `\xAB` 转义,**分号和 0x20 以下字符必须转义**;`P ; Cwd=…`、`IsWindows=`、`HasRichCommandDetection=`。只有 nonce 与本会话一致时,才把 `E` 的内容当作可信命令行。WT 已实现 633(`OscActionCodes::VsCodeAction`)。 + +**TCode 规格**: +- 解析 133 的 A / B / C / D / L / N / P / I,以及 633 的 A / B / C / D / E / P(Cwd)。在行模型上打语义标记,用于命令块、跳转到上一个提示、退出码徽标、"复制输出"等 UI。 +- 光标位于输入区内时,**调整窗口大小时不要 reflow 该区域**(semprompt §Window re-size 的建议)。 +- `cl` 点击移动光标属于 Tier 2;实现时必须先检查 shell 是否声明了 `cl=`,未声明不得注入方向键。 +- 命令行内容(`E`、`cmdline=`)**只用于显示**,不得自动执行,也不得写入 shell 历史。 + +### 2.5 OSC 9;4 进度与 OSC 9 通知 + +- 格式 `OSC 9 ; 4 ; st ; pr ST` [conemu;wt-progress;iterm2-esc §Progress Bar]。 + - st = 0:清除。 + - st = 1:正常,pr 为 0–100,必填。 + - st = 2:错误,pr 可选。 + - st = 3:不确定进度,忽略 pr。 + - st = 4:暂停或警告,pr 可选;iTerm2 规定省略 pr 时保留原百分比。 +- 还有简写 `OSC 9 ; 4 ST`,等于清除 [iterm2-esc]。 +- `OSC 9 ; ` 是 iTerm2 的桌面通知;`OSC 9 ; 1..12` 是 ConEmu 的其他子命令。**解析顺序**:先看第一个字段是不是 ConEmu 子命令号(1–12)。Ghostty `osc9.zig` 就是这样先判断 ConEmu 的。kitty 曾因 `4;` 被当作通知而改为丢弃(changelog 0.4x:"Discard OSC 9 notifications that start with `4;`")。Claude Code 也修过同类问题("stray '4;0;' desktop notification … in Kitty")。 +- **ConEmu 的 9;1(sleep)、9;2(MessageBox)、9;5、9;6(GuiMacro)、9;7(运行进程)、9;8(输出环境变量)一律忽略**。9;9(cwd)当作 OSC 7 的别名,并按 §8.5 视为不可信(WT 的 CVE-2022-44702 就出在 OSC 9;9 [dgl-2023])。 +- 桌面通知(OSC 9 文本、OSC 777;notify、OSC 99):**默认只在窗口失焦时显示**,并限制频率 [推断]。 + +--- + +## 3. 查询应答规格 + +总原则: +- (1) **只声明真正实现了的功能**。tmux、ConPTY、Emacs、vim 都会依据应答改变行为(§7.3)。 +- (2) **应答内容只能由终端自身状态格式化生成**,绝不回显攻击者可控的文本 [dgl-2023]。 +- (3) 应答通过单一写队列写回 PTY,保证顺序。DA1 往往被用作"栅栏":先发功能查询,再发 DA1;只收到 DA1 应答就说明不支持 [kitty-kbd §Detection;kitty-gfx §Querying]。所以**同一批输入中的查询必须按顺序应答**。 + +### 3.1 各终端实际应答(对照) + +| 终端 | DA1 | DA2 | XTVERSION | +|---|---|---|---| +| xterm #411 | `?63;1;2;3;4;6;9;15;17;22;28c` | `>41;411;0c`(推断自 ncurses `xterm-p370` 的 `rv=\E\\[>41;[1-6][0-9][0-9];0c`) | `XTerm(411)` [ucs-detect xterm.yaml] | +| Ghostty | `?62;22;52c`(OSC 52 写被拒时为 `?62;22c`)[`stream_handler.zig:825-848`] | `>1;10;0c` | `ghostty 1.3.1` [`stream_handler.zig:975-990`] | +| WezTerm | `?65;4;6;18;22;52c` | `>1;277;0c`(注释写明是为了让 vim 选 SGR 鼠标)[`mod.rs:1407-1432`] | `WezTerm ` | +| Windows Terminal | `?61;4;6;7;14;21;22;23;24;28;32;42;52c` [`adaptDispatch.cpp:1435-1462`] | `>0;10;1c` | 无 | +| xterm.js | `?1;2c` | `>0;276;0c` [`InputHandler.ts:1707-1752`] | `xterm.js()` | +| iTerm2 | `?64;1;2;4;6;17;18;21;22;52c` [ucs-detect] | `>Pp;2500;0c`(xtermVersion 默认 2500 [`iTermAdvancedSettingsModel.m:453`]) | `iTerm2 3.7.3` | +| kitty | `?62;52;c` [`window.py:664-669`] | `>1;4000+major;minor c` [`setup.py:759-761`] | `kitty(0.49.2)` | +| Alacritty | `?6c` | `>0;;1c` [`term/mod.rs:1257-1272`] | 无 | + +### 3.2 DA1:`CSI c` / `CSI 0 c` + +**建议应答**:`CSI ? 62 ; 22 ; 52 c`。只有用户允许 OSC 52 写入时才带 `52`。 + +理由: +- 62 表示"VT220 级",与 Ghostty、kitty 一致。不声明 VT420 / 525 级别,因为 tmux 会把 DA1 中的 `21` 理解为 `margins`、`28` 理解为 `rectfill`、`4` 理解为 sixel、`52` 理解为 clipboard [tmux `tty-keys.c:1406-1470`];ConPTY 会依据 `28` 改用 DECCRA [WT `getset.cpp:1044`]。 +- 22:ANSI 彩色。 +- 52:Emacs 看到后会启用 OSC 52 写入 [`xterm.el` `xterm--primary-da-handler`]。 +- 实现 DECSLRM 之后,可以加 `21`?**不要加**:DEC 定义 21 是"水平滚动"(SL / SR),tmux 把它当作 margins 是 tmux 的启发式。实现 SL / SR 和 DECSLRM 后再评估。 +- 实现矩形操作之后才能加 `28`;实现 sixel 之后才能加 `4`。 + +### 3.3 DA2:`CSI > c` / `CSI > 0 c` + +**建议应答**:`CSI > 0 ; 2500 ; 0 c`。这是 [推断],依据 vim 与 Emacs 的源码,需要用户确认(见第 2 个决策点的备注)。 + +逐项理由: +- vim [`term.c:5176-5390`]: + - 版本 ≥ 141 时会用 XTGETTCAP 请求按键码(TCode 支持); + - ≥ 277 时设置 `ttymouse=sgr`(窗口超过 223 列时必须); + - ≥ 279 时认为可以查询光标样式(我们支持 DECRQSS `SP q`); + - ≥ 2500 时启用下划线颜色 `t_8u`。 +- Emacs [`xterm.el:793-850`]:只有首参数为 1 或 65 且版本 > 2000 时才会把版本降到 200,**首参数用 0 正好避开**。于是 ≥ 242 时查询 OSC 10 / 11 背景色,≥ 216 时启用 modifyOtherKeys,≥ 203 时启用 OSC 52 写入。 +- iTerm2 的注释正是这样分析的,并选择了 2500 [`VT100Output.m:1131-1170`]。 +- tmux 只根据首参数 'M' / 'T' / 'U' 识别 mintty / tmux / urxvt,不受影响。 +- 备选:`>1;277;0c`(WezTerm 的保守选择)。代价是 vim 的下划线颜色和光标样式探测被关闭。 + +### 3.4 DA3:`CSI = c` + +应答 `DCS ! | 00000000 ST`(与 xterm、Ghostty、WezTerm 相同)。 +- 不用它做身份标识。VTE 用 `~VTE`,iTerm2 用 `iTrm` 的十六进制 [terminal-wg issue #7;`VT100Output.m:1177`],但没有主流程序依赖这个应答。 + +### 3.5 XTVERSION:`CSI > q` / `CSI > 0 q` + +- 应答 `DCS > | TCode ST`,例如 `ESC P > | TCode 0.9.0 ESC \`。参数不为 0 时不应答 [xterm.js `InputHandler.ts:1772-1777`]。 +- 格式参照 iTerm2 和 Ghostty:名称 + 空格 + 版本。只用 ASCII 可打印字符。 +- 理由:这是**最可靠的终端识别方式,通过 SSH 也能用**("XTVERSION is the most reliable, supported by 21 terminals" [quast-2026])。tmux 按前缀匹配一张已知终端表;不认识的名字不会出错,只是拿不到默认特性,需要依赖 DA1 或 `terminal-features` [tmux `tty-features.c:593-730`]。 + +### 3.6 XTGETTCAP:`DCS + q [;…] ST` + +- 每个名字单独应答一条 `DCS 1 + r = ST`。无值的布尔能力写成 `DCS 1 + r ST`(kitty [`terminfo.py:529-537`])。未知名字应答 `DCS 0 + r ST`。 +- xterm 原文说"An invalid name … ends processing of the list"[ctlseqs §DCS + q],kitty 和 WezTerm 则对每个名字逐一应答。**TCode 逐一应答**,大多数客户端(neovim `vim.tty.query`)都能处理。 +- 能力表(编译期常量,不读系统 terminfo): + - `TN` / `name`:**与 TERM 一致**,即 `xterm-256color`(§7)。 + - `Co` / `colors`:`256`。 + - `RGB`:`8`(Ghostty)。 + - `Tc`:布尔。 + - `Su`、`Smulx=\E[4:%p1%dm`、`Setulc=\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m`、`Ss=\E[%p1%d q`、`Se=\E[0 q`、`Ms=\E]52;%p1%s;%p2%s\007`、`Sync=\E[?2026%?%p1%{1}%-%tl%eh%;`、`BE` / `BD` / `PS` / `PE`(括号粘贴)、`fe` / `fd`、`XM`、`xm`、`E3`。 + - 所有按键能力(`kcuu1` 等)必须**与 §4 的实际编码一致**。 +- 依据: + - neovim 用 XTGETTCAP 查询 `Tc`、`RGB`、`setrgbf`、`setrgbb` 来决定 `termguicolors` [`defaults.lua:1008-1035`]; + - vim 在 DA2 版本 ≥ 141 时请求按键码; + - ucs-detect 用 `Ms` 判断 OSC 52(Ghostty 实测数据)。 +- 带参数的字符串按 terminfo 源码形式返回(`%p1%d` 原样),无参数的字符串返回转义后的字节。这是 Ghostty 的做法 [`terminfo/Source.zig:95-115`]。 + +### 3.7 DSR / CPR + +| 请求 | 应答 | 说明 | +|---|---|---| +| `CSI 5 n` | `CSI 0 n` | | +| `CSI 6 n` | `CSI r ; c R` | 1 起算。**DECOM 置位时相对滚动区** [ghostty `cursorReportPos`]。处于 pending-wrap 时报告最后一列(光标实际所在位置)[推断:xterm 行为]。ucs-detect 正是靠 CPR 测量宽度,所以宽度策略必须与 CPR 一致 | +| `CSI ? 6 n`(DECXCPR) | `CSI ? r ; c ; 1 R` | xterm 版本带页号 1 [ctlseqs DSR DEC-specific] | +| `CSI ? 15 n` / `? 25 n` / `? 26 n` / `? 55 n` / `? 56 n` | `? 13 n`(无打印机)/ `? 20 n` / `? 27;1;0;0 n` / `? 53 n` / `? 57;0 n` | 低优先级 | +| `CSI ? 996 n` | `CSI ? 997 ; 1|2 n` | 明暗主题 [2031] | +| `CSI ? 998 n` | `CSI ? 999 ; 1|2 n` | 可见性 [2033](Tier 2) | + +### 3.8 DECRPM:`CSI [?]Ps $ p` + +**模式状态表**: + +| 应答 Pm | 模式 | +|---|---| +| 1 / 2(可切换) | ANSI 模式 4、20;DEC 私有模式 1、6、7、12、25、45、47、66、67、69、1000、1002、1003、1004、1006、1007、1016、1047、1048、1049、2004、2026、2027、2031、2048,以及 Windows 下的 9001 | +| 4(永久复位) | 2(VT52)、3(DECCOLM,不改尺寸)、1001、1005、1015、80(sixel,未实现前) | +| 3(永久置位) | 无 | +| 0(不认识) | 其他全部 | + +ANSI 模式 2(KAM)、12(SRM)按 xterm 应答:SRM 永久置位 3(Ghostty 默认 true [`modes.zig:364`])。 + +### 3.9 DECRQSS:`DCS $ q Pt ST` + +| Pt | 应答示例 | +|---|---| +| `m` | `DCS 1 $ r 0;1;38:2::255:0:0 m ST`(用冒号写法输出当前 SGR)。neovim 用"先设 SGR 再用 DECRQSS 读回"的办法探测真彩色,**能识别 `48:2:` 前缀,能接受可选的空颜色空间** [`defaults.lua:1037-1085`]。所以**颜色应答必须用 `38:2::r:g:b` 或 `48:2::r:g:b` 形式** | +| `r` | `DCS 1 $ r 1;24 r ST` | +| `s` | `DCS 1 $ r 1;80 s ST`(仅在 DECLRMM 置位时) | +| `SP q` | `DCS 1 $ r 2 SP q ST` | +| `" q` | `DCS 1 $ r 0 " q ST` | +| 其他 | `DCS 0 $ r ST` | + +### 3.10 OSC 查询的应答 + +- `OSC 4 ; n ; ?`、`OSC 10/11/12 ; ?`:应答 `OSC 4 ; n ; rgb:RRRR/GGGG/BBBB` 或 `OSC 1x ; rgb:RRRR/GGGG/BBBB`,用 16 位十六进制(Ghostty 的 `osc-color-report-format` 默认 16-bit)。**结束符沿用请求的结束符**。 +- `OSC 52 ; c ; ?`:按 §8.1。 +- `OSC 1337 ; ReportCellSize`、`ReportVariable`:不应答(Tier 2)。 + +### 3.11 XTWINOPS 报告与 2048 + +| 请求 | 应答 | +|---|---| +| `CSI 14 t` | `CSI 4 ; 文本区高 px ; 宽 px t` | +| `CSI 16 t` | `CSI 6 ; 格高 ; 格宽 t` | +| `CSI 18 t` | `CSI 8 ; rows ; cols t` | +| `CSI 19 t` | 同 18(屏幕即窗口) | + +- **`CSI 20 t` 和 `CSI 21 t`(图标名、标题)默认不应答**(§8.2)。 +- 像素值用**物理像素**(DPI 缩放后),与 kitty、Ghostty 一致 [推断]。 +- `CSI 11 t` 应答 `CSI 1 t`。`CSI 13 t` 默认不应答(属于信息泄露类)。 + +--- + +## 4. 键盘编码规格 + +### 4.1 Legacy(xterm PC 风格,默认) + +**修饰键参数** [ctlseqs §PC-Style Function Keys]:值 = 1 + 位掩码,Shift = 1,Alt = 2,Ctrl = 4,Meta = 8。 + +| 键 | 无修饰(普通) | 无修饰(应用模式 DECCKM / DECKPAM) | 带修饰 | +|---|---|---|---| +| ↑ ↓ → ← | `CSI A/B/C/D` | `SS3 A/B/C/D` | `CSI 1;m A`(带修饰时**一律用 CSI**) | +| Home / End | `CSI H` / `CSI F` | `SS3 H` / `SS3 F` | `CSI 1;m H/F` | +| Insert / Delete / PgUp / PgDn | `CSI 2~` `CSI 3~` `CSI 5~` `CSI 6~` | 同左 | `CSI n;m ~` | +| F1–F4 | `SS3 P/Q/R/S` | 同左 | `CSI 1;m P/Q/R/S`(xterm:带修饰时 SS3 改为 CSI)。**F3 带修饰的 `CSI 1;m R` 与 CPR 冲突**,ctlseqs 在 DSR 一节中专门提到这一点。kitty 协议的 F3 改用 `CSI 13~` [kitty-kbd] | +| F5–F12 | `CSI 15~ 17~ 18~ 19~ 20~ 21~ 23~ 24~` | 同左 | `CSI n;m ~` | +| Shift+Tab | `CSI Z` | | | +| Enter | `CR` | 小键盘 Enter 在 DECKPAM 下发 `SS3 M` | Alt+Enter 发 `ESC CR` | +| 小键盘 | 数字 / 运算符字符 | DECKPAM 下发 `SS3 j..y`(见 [ctlseqs VT220-Style Function Keys]) | | +| Esc | `ESC` | | | + +**Backspace / Delete**: +- Backspace 默认发 **DEL 0x7F**。Ctrl+Backspace 发 **BS 0x08**,Alt+Backspace 发 `ESC 7F`。 + - 依据:kitty 表 [kitty-kbd §Legacy functional keys:Backspace 0x7f,Ctrl 0x8];WT 的 Backspace 在 DECBKM 复位时发 DEL、Ctrl 反转 [`terminalInput.cpp:909-921`];Ghostty 的 terminfo 为 `kbs=^?`。 + - 支持 `?67` DECBKM(置位时 Backspace 发 BS)。 +- **注意系统差异**:[本机] macOS 自带的 `xterm-256color` 条目是 `kbs=^H`(ncurses 2015 版),而终端实际发 0x7F。readline 和 zsh 以 `stty erase`(通常是 `^?`)为准,所以一般没问题。只依赖 terminfo `kbs` 的程序可能出错。这是所有使用 xterm-256color 的终端共同的已知问题(ncurses 的 `xterm+kbs` 注释:"people who cannot agree on what the backspace key should send")。**TCode 跟随主流,发 0x7F**。 +- Delete 键发 `CSI 3~`(`?1037` 控制是否改发 DEL,默认不改)。 + +**Ctrl 映射**:按 kitty 给出的表 [kitty-kbd §Legacy ctrl mapping]: +- `Ctrl+a..z` → 1..26;`Ctrl+@`、`Ctrl+Space`、`Ctrl+2` → 0;`Ctrl+[`、`Ctrl+3` → 27;`Ctrl+\`、`Ctrl+4` → 28;`Ctrl+]`、`Ctrl+5` → 29;`Ctrl+^`、`Ctrl+6`、`Ctrl+~` → 30;`Ctrl+_`、`Ctrl+/`、`Ctrl+7` → 31;`Ctrl+8`、`Ctrl+?` → 127。 +- 表外的 ASCII 键不受 Ctrl 影响。 + +**Alt / Meta**: +- **Alt 的默认行为是加 ESC 前缀**(对应 xterm 的 `?1036` metaSendsEscape / `?1039` altSendsEscape,二者默认都置位)。**不使用"置第 8 位"的方式**(`?1034` 默认关),因为在 UTF-8 下会产生非法字节。 +- macOS 上 Option 默认用于输入字符(例如 Option+b 得到 ∫)。提供 `option_as_alt = left|right|both|none` 配置,默认值可以像 Ghostty 一样按键盘布局决定:美式布局默认开 [ghostty `Config.zig` `macos-option-as-alt`]。 +- 组合后不产生可打印字符的情况(如 Option+Ctrl+a)一律按 Alt 处理。 + +**AltGr(Windows)**:Windows 上 AltGr 表现为 LeftCtrl + RightAlt。WT 的判断办法:带有效图形码点时视为 AltGr;否则在 50 ms 窗口内用时间差区分"真 Ctrl" [`terminalInput.cpp:364-395`]。TCode 在 Avalonia 层得到文本输入事件时,**优先使用 TextInput 的文本**;只有没有文本输入时才走按键编码。这样 AltGr 输入的字符不会被误判为 Ctrl+Alt 组合。 + +**IME**:组字(preedit)期间不发送任何字节;提交(commit)时把文本以 UTF-8 发出。如果处于括号粘贴模式,**提交文本不加括号粘贴包裹**,它不是粘贴 [推断:Ghostty 的 `paste.Source.text` 也与剪贴板粘贴分开处理]。 + +### 4.2 modifyOtherKeys(xterm) + +[ctlseqs §Alt and Meta Keys;modkeys] + +| 设置 | 效果 | +|---|---| +| `CSI > 4 ; 0 m` | 关闭 | +| `CSI > 4 ; 1 m` | Shift / Ctrl 的常规组合照旧;其他修饰组合(Alt 等)按 `CSI 27 ; m ; code ~` 编码,例如 Alt+Tab 为 `CSI 27;3;9~` | +| `CSI > 4 ; 2 m` | 所有带修饰的键都编码,例如 Shift+Tab 为 `CSI 27;2;9~`,Ctrl+Shift+a 为 `CSI 27;6;65~`… | + +- 应答格式默认用 xterm 的 `CSI 27;m;k ~`。`formatOtherKeys=1`(`CSI > 4 ; 1 f`)时改为 `CSI k ; m u`。 +- `CSI > 4 m`(省略值)恢复默认,`CSI > 4 n` 禁用。查询 `CSI ? 4 m` 应答 `CSI > 4 ; v m`。 +- **必须实现**: + - vim 的默认 keyprotocol 对 `xterm*` 使用 `mok2` [vim `optiondefs.h:1584`]; + - neovim 在 kitty 协议探测失败时发 `CSI >4;2m` [`tui.c:316-330`]; + - Codex 在 tmux(csi-u 格式)下会发 `CSI >4;2m` [`keyboard_modes.rs:270-282`]; + - Emacs 在 DA2 版本 ≥ 216 时初始化 modifyOtherKeys。 +- 与 kitty 协议的优先级:**kitty 标志不为 0 时使用 kitty 编码**,否则 modifyOtherKeys 生效,否则 legacy。win32-input-mode 优先级最高(§4.5)。这与 WT(`terminalInput.cpp` 中 kitty 优先于 win32)的顺序有所不同;在 Windows ConPTY 场景下按 §4.5 处理。 + +### 4.3 应用光标键与小键盘模式 + +- 只有在**无修饰**时,DECCKM / DECKPAM 才改变编码 [kitty-kbd:在 cursor key mode 下 "used only when no modifiers are present"]。 +- terminfo 中 `smkx=\E[?1h\E=`、`rmkx=\E[?1l\E>`,所以 ncurses 程序会同时开启两者,并且期望 `kcuu1=\EOA`、`khome=\EOH`、`kend=\EOF`。 +- NumLock 开时小键盘数字发数字字符(`?1035` 的语义)。 + +### 4.4 kitty 键盘协议 + +[kitty-kbd 全文] + +**控制序列**: +- 设置:`CSI = flags ; mode u`。mode 1 = 整体设置(默认),2 = 按位置位,3 = 按位清除。 +- 查询:`CSI ? u` → `CSI ? flags u`。 +- 入栈:`CSI > flags u`(flags 省略时为 0)。出栈:`CSI < n u`(n 默认 1)。 +- **主屏和 alt 屏各有一个独立的栈**。栈上限 **8**(Ghostty [`kitty/key.zig:9`]、kitty [`screen.h:173`]、WT [`terminalInput.hpp:187`] 都是 8)。栈满时淘汰最旧的一项;栈出空时 flags 归零。 +- RIS 清空两个栈。 + +**flags**: + +| 位 | 名称 | 效果 | +|---|---|---| +| 1 | disambiguate | Esc、Alt+键、Ctrl+键、Ctrl+Alt+键、Shift+Alt+键都改用 `CSI code;mods u`;小键盘键单独编码(PUA 57399…)。**Enter / Tab / Backspace 保持 legacy 编码**,方便程序崩溃后用户还能在 shell 里敲 `reset` | +| 2 | report event types | 修饰字段带 `:1` 按下、`:2` 重复、`:3` 释放。Enter / Tab / Backspace 不报释放,除非同时开了 8 | +| 4 | report alternate keys | `code:shifted:base-layout`。base-layout 键是 PC-101 布局下的键(例如俄语布局 Ctrl+С 的 base 为 `c`) | +| 8 | report all keys as escapes | 所有按键(含纯文本、Enter、Tab、Backspace 和修饰键本身)都编码成转义序列 | +| 16 | report associated text | 第 3 个字段带文本码点(冒号分隔),只在与 8 同时开启时有意义 | + +**编码细节**: +- key code 取**未按 Shift 时的码点**(如 a 是 97,不是 65)。 +- 修饰值 = 1 + 位掩码:shift 1、alt 2、ctrl 4、super 8、hyper 16、meta 32、caps_lock 64、num_lock 128。 +- 功能键使用规范中的表:方向键 `CSI 1;m A`;F1 `CSI 1;m P` 或 `CSI 11~`;F3 只能用 `CSI 13~`;F13 及以后、媒体键、左右修饰键用 PUA 码 57344–63743。 +- 修饰键自身的事件(如 LEFT_SHIFT 57441)只在 flag 8 下报告。按下修饰键时,该修饰键的位要包含在本次事件里;释放时要清除。 +- **探测**:应用发 `CSI ? u` 加 `CSI c`。TCode 必须按顺序先应答 `CSI ? flags u`,再应答 DA1。 + +**各程序的实际使用**: +- neovim:`CSI >3u`(1 | 2)[`tui.c:316-325`]。 +- Codex:基础为 1 | 4;在 Ghostty / iTerm2 上不加 2,原因是"can leak shortcut release events"[`keyboard_modes.rs:242-259`];入栈用 `CSI >5u` / `CSI >7u`。 +- Claude Code:靠"回应 kitty 键盘查询"获得 Shift+Enter 等能力(changelog:"terminals that answer the kitty keyboard query (such as foot and Alacritty 0.16+) now get Shift+Enter")。 +- **教训**:开启 flag 2 时,**TCode 自身消费的快捷键(例如 Cmd+C 复制)不得把释放事件发给应用**。按下事件被终端拦截时,对应的释放事件也要一并拦截 [推断,源自 Codex 的注释]。 + +### 4.5 Windows:ConPTY 与 win32-input-mode + +**ConPTY 启动时会向宿主终端发送** `\x1b[c`(DA1)、`\x1b[?1004h`、`\x1b[?9001h`,退出时发 `?1004l`、`?9001l` [WT `host/VtIo.cpp:197-204,251-260`]。ConPTY 会解析我们的 DA1 应答,并据此决定是否使用 DECCRA 等功能 [`getset.cpp:1044`;`InputStateMachineEngine.hpp:52-76`]。 + +**win32-input-mode(`?9001`)编码**:`CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _` [wt-4999;`terminalInput.cpp:541-563`]: + +| 字段 | 含义 | 省略时默认 | +|---|---|---| +| Vk | 虚拟键码 | 0 | +| Sc | 扫描码 | 0 | +| Uc | UTF-16 码元的十进制值 | 0 | +| Kd | 按下 1 / 释放 0 | 0 | +| Cs | `dwControlKeyState` 位掩码 | 0 | +| Rc | 重复次数 | 1 | + +- 一次按键要发送修饰键按下、主键按下、主键释放、修饰键释放**完整的 4 个或更多事件**。 +- 依据:WezTerm 在 Windows 上默认 `allow_win32_input_mode = true`,原因是"best compatibility with win32 console applications such as Far Manager" [wezterm `docs/config/key-encoding.md`]。 +- **ConPTY 内部当前强制关闭 KKP 转换**(`ForceDisableKittyKeyboardProtocol(true)`,TODO GH#19847 [`VtIo.cpp:125-131`]),并且在 ConPTY 下忽略 `CSI = u` 等序列 [`adaptDispatch.cpp:2079-2103`]。 +- 所以在 Windows 下,**宿主终端只有支持 9001,原生控制台程序(PowerShell / PSReadLine、cmd、Far)才能收到 Ctrl+Space、Shift+Enter、修饰键单独按下等事件** [wt-4999 §Abstract 列出的问题]。 +- 要注意的是,WSL / ssh 内的 Linux TUI 发出的 kitty 键盘请求,经过 ConPTY 后不会传到我们这里。ConPTY 会把 win32 事件重新编码成该程序请求的 VT 格式(spec 中 "WT → conpty → wsl" 的场景)。 +- **TCode 规格(Windows)**: + - 支持 `?9001`。置位时**所有键盘输入**都按 win32 格式发送(包括 IME 提交的字符:Vk = 0 或 VK_PACKET,Uc = 码元)。 + - Avalonia 的 KeyEventArgs 要还原出 Vk、扫描码、`dwControlKeyState`(含 NUMLOCK_ON、ENHANCED_KEY 等位)。这需要 Win32 原生消息信息,属于 AOT 安全的 P/Invoke 范围,由平台层负责(参见 05 号报告)。 + - 焦点事件(1004)照常发送。 +- 非 Windows 平台:DECRPM 对 9001 应答 0(不认识)。 + +--- + +## 5. 鼠标编码规格 + +[ctlseqs §Mouse Tracking 全文] + +**跟踪模式**(同一时间只有一个生效,后设置的覆盖先设置的): + +| 模式 | 报告内容 | +|---|---| +| `?9` X10 | 只报告按下;不带修饰键 | +| `?1000` | 按下和释放(滚轮没有释放事件) | +| `?1001` 高亮跟踪 | **不支持**,需要应用配合,否则 xterm 会挂起。DECRPM 应答 4 | +| `?1002` | 再加上**按住按钮时的移动**,且只在进入新格子时报告 | +| `?1003` | 所有移动 | + +**编码模式**(与跟踪模式正交): + +| 模式 | 格式 | 结论 | +|---|---|---| +| 默认 X10 | `CSI M Cb Cx Cy`,三个值都 +32 编码成单字节 | 坐标上限 223(255 − 32)。超出时**不发送该事件**(xterm 不发)[推断] | +| `?1005` UTF-8 | 把 Cx、Cy(及 Cb)按 UTF-8 编码,上限 2015 | 会混淆非 UTF-8 应用。xterm.js 已移除。**TCode:解析后忽略,DECRPM 应答 4** | +| `?1006` SGR | `CSI < Cb ; Px ; Py M`(按下 / 移动)/ `m`(释放) | Cb 不加 32;释放时保留按钮号。**主力编码** | +| `?1015` urxvt | `CSI Cb ; Px ; Py M`,Cb 加 32 | 与 DL(`CSI M`)冲突,ctlseqs 明确不推荐。xterm.js 已移除。TCode 忽略 | +| `?1016` SGR-Pixels | 同 1006,但 Px、Py 为像素坐标 | 1 起算还是 0 起算:xterm 以文本区左上角为 (1,1) [推断]。TCode 跟随 Ghostty / kitty 的实现,测试时用 kitty 的 `kitten show-key`、ucs-detect 校准 | + +**Cb 位定义**: +- 低 2 位:0 左键,1 中键,2 右键,3 释放(只在非 SGR 编码中使用)。 +- +4 Shift,+8 Meta(Alt),+16 Ctrl。 +- +32 移动。 +- +64:滚轮。按钮 4 / 5(上 / 下滚)→ 64 / 65;按钮 6 / 7(左 / 右)→ 66 / 67。 +- +128:按钮 8–11。 +- 无按钮的移动(1003):Cb = 35,即 32 + 3。 + +**交互规则**: +- **Shift 覆盖**:鼠标跟踪开启时,按住 Shift 拖动执行终端自己的选择,不报告给应用(xterm 的 `XTSHIFTESCAPE` 默认 0 即"shift-key overrides mouse protocol"[ctlseqs CSI > Ps s])。macOS 上也可以用 Option 作为覆盖键(iTerm2 惯例)[推断]。 +- **滚轮**: + - 跟踪开启时发送滚轮事件; + - 未开启跟踪、处于 alt 屏且 `?1007` 置位时,转成方向键(按 DECCKM 选 `CSI A` 或 `SS3 A`),每格 3 行 [推断:各终端常用值]; + - 其他情况滚动 scrollback。 +- **触控板高精度滚动**要累积成整格事件后再发送,避免一次发出几十个事件。 +- 坐标钳制在网格范围内。窗口外的释放事件按最后位置报告。 +- **聚焦点击**:点击用于激活窗口时,该次点击不报告给应用 [推断:各终端的常见做法]。 +- **焦点(1004)**:与鼠标模式独立,只在焦点状态真正变化时发送。ConPTY 依赖它。 + +--- + +## 6. Unicode 宽度与字形簇策略 + +### 6.1 规范依据 + +**UAX #11** [UAX11]: +- East_Asian_Width 有 6 个取值:A / F / H / Na / W / N,最终归为窄或宽。 +- **明确说明该属性"is not intended for use by modern terminal emulators without appropriate tailoring"**。 +- 歧义宽度字符在无法确定上下文时"should be treated as narrow characters by default"。 +- 私用区字符默认为 A;CJK 区段内未分配的码点为 W。 +- 带 Emoji_Presentation 的字符为 W;区域指示符例外,归为 N。 +- 变体序列可以用来解决歧义宽度(§4.3)。 + +**UAX #29** [UAX29]:使用扩展字形簇(EG,含 GB9a / GB9b / GB9c)。 +- GB9c(Unicode 18 修订):`\p{InCB=Linker} \p{InCB=Extend}* × \p{InCB=Consonant}`。 +- GB11:emoji ZWJ 序列。 +- GB12 / 13:区域指示符成对组合。 +- 规范说字形簇长度没有上限,"There is no limit on the size of a grapheme cluster",所以**实现必须自行设上限**(§6.4)。 + +**Mode 2027** [2027 draft]: +- 置位后终端必须按 UTS #29 划分字形簇,每个字形簇占一个格子(或宽字符的两格)。 +- emoji 一律占 2 格;ZWJ 序列显示为一个整体,宽 2。 +- VS16 把宽度提升为 2,可能因此触发自动换行;VS15 只改变显示方式,**不改变宽度**。 +- 光标移入的格子在写入时整体覆盖。 +- DECSTR / RIS 复位此模式。 + +**wcwidth 规范** [wcwidth-spec v0.9.3]: +- 宽度 0:Default_Ignorable(软连字符 U+00AD 和 U+115F 除外)、Mn、Me、Cf、ZWJ 及其后被消耗的码点、成对区域指示符的第二个、Hangul Jungseong U+1160–11FF。 +- 宽度 2:EAW 为 W / F、区域指示符、单独出现的 Fitzpatrick 肤色符、**VS16 跟在窄字符后时 +1**。 +- 宽度 1:**VS15 跟在宽字符后时 −1**(规范注明存在争议,见 issue #211)。 +- Mc 把字形簇宽度封顶为 2;"Any grapheme cluster width is limited to 2 cells since 0.8.0"。 + +**行业现状** [quast-2026;ucs-detect data 2026-09]: + +| 终端(版本) | 2027 | WIDE | ZWJ | VS16 | VS15 | 语言 | +|---|---|---|---|---|---|---| +| Ghostty 1.3.1 | 置位且可切换 | 100% | 100% | 100% | 100% | 83/85 | +| WezTerm 20260716 | 永久置位 | 95% | 100% | 50% | 0% | 61/85 | +| WT 1.24 | 永久置位 | 98% | 100% | 100% | 0% | 106/118 | +| kitty 0.48.2 | 不认识 | 98% | 100% | 98% | 100% | 88/118 | +| iTerm2 3.7.3 | 永久复位 | 98% | 17% | 100% | 0% | 98/118 | +| xterm.js 6.1-beta(VS Code) | 不认识 | 97% | 1% | 50% | 0% | 98/118 | +| Alacritty | 不认识 | 95% | 1% | 50% | 0% | 63/85 | +| xterm 411 | 不认识 | 95% | 1% | 50% | 0% | 65/85 | + +"Even well-meaning terminals who report to support … 2027 have varying interpretations" [ucs-detect README]。 + +**Unicode TTWG**:TTWG 的结论是"application and terminal have to have the same understanding of how many cells a given piece of text will occupy",计划制定独立于字体的测量规则,但**截至 2026-10 尚未发布标准** [TTWG L2/25-263;quast-2026]。 + +### 6.2 与 shell / 程序 wcwidth 不一致的问题 + +**核心矛盾**:shell 和 readline 用 libc 的 `wcwidth` 逐码点计算宽度,而终端按字形簇显示。二者不一致时,光标会错位,行编辑出现残影。 + +[本机] macOS libc `wcwidth` 实测结果: + +| 码点 | 结果 | 说明 | +|---|---|---| +| U+2764 ❤ | 1 | 后接 VS16 时终端显示为 2 格 | +| U+1F1E6(区域指示符) | 1 | wcwidth-spec 规定为 2 | +| U+1F3FB 肤色符 | 2 | 按字形簇规则应为 0 | +| U+FE0F VS16、U+200D ZWJ | 0 | | +| U+2500 线框、U+00B7(A 类) | 1 | | + +结论:同一个 ZWJ 家庭 emoji,shell 认为宽 6–8 格,2027 终端显示为 2 格。 + +各终端的处理方式: +- Ghostty:`grapheme-width-method = unicode`(默认)或 `legacy`;程序显式开启 2027 时强制使用 `unicode` [`Config.zig:515`]。注释承认默认的 unicode 模式"may result in cursor-desync issues with some programs (such as shells)"。 +- fish:提供 `fish_emoji_width`(默认 2)和 `fish_ambiguous_width` 两个变量,让用户与终端对齐 [fish 4.x 文档]。 +- iTerm2:`OSC 1337 ; UnicodeVersion=8|9` 用来切换宽度表 [iterm2-esc]。 + +### 6.3 TCode 建议策略 + +1. **两套宽度模式**: + - **兼容模式(默认,2027 复位)**:逐码点计算宽度,组合字符(宽度 0)附着在前一个格子上;**VS16 不改变宽度**,**ZWJ 不合并**(每个 emoji 各占 2 格);区域指示符各宽 1 或 2,按 wcwidth 表。 + - **字形簇模式(2027 置位)**:完整 UAX #29(EG)划分 + 上面 wcwidth-spec 的规则:VS16 → 2,VS15 → 1,ZWJ 序列 → 2,旗帜 → 2,单个字形簇封顶 2。 + - 依据:主流 shell(bash / readline、zsh)仍逐码点计算;neovim 会**主动探测并开启 2027**(`tui.c:254-258`:模式处于复位状态时会置位它),fish 4 和 Claude Code 自己处理字形簇。默认选兼容模式,能最大限度避免 shell 光标错位;需要的程序可以自己开启。**这是决策点**(见第 2 个决策),备选是像 Ghostty 一样默认使用 unicode。 +2. **宽度表随 Unicode 版本更新**。在 NativeAOT 下由源码生成器从 UCD 18.0 的 `EastAsianWidth.txt`、`emoji-data.txt`、`DerivedCoreProperties.txt`、`GraphemeBreakProperty.txt`、`IndicSyllabicCategory`(InCB)生成静态表,**不依赖 .NET 运行时的 `StringInfo`**,因为其 Unicode 版本跟随运行时、不可控 [推断]。 +3. **歧义宽度**:默认窄(1),符合 UAX #11 的建议,与 WezTerm、WT 默认一致(`treat_east_asian_ambiguous_width_as_wide = false`;WT `compatibility.ambiguousWidth = Narrow`)。提供"宽"选项,供使用 CJK 遗留软件的用户,并提示需要与 shell / locale 同时调整。私用区默认 1;Nerd Font 图标靠字体溢出显示,宽度仍算 1。 +4. **CPR 必须与网格一致**:宽度测量完全以 TCode 的网格为准,ucs-detect 正是靠 CPR 来测的。 +5. **DECRPM 2027**:可切换,应答 1 或 2。 +6. **渲染与宽度解耦**:字体测得的字形宽度不改变格子宽度。宽度为 1 的格子里放了宽字形时,允许溢出到右侧格子,或按比例缩放(属于渲染层,参见 04 号报告)。 + +### 6.4 字形簇存储上限 + +- 每个格子的字形簇码点数设上限。Ghostty 是 64 [`page.zig:92`],超出的码点静默丢弃。TCode 取 **32 个码点**,能覆盖最长的 ZWJ emoji(约 10 个码点)和梵文合字,又能防止 `e + 10000 个组合符` 一类攻击 [推断]。 +- 兼容模式下组合符同样受此上限约束。 + +--- + +## 7. TERM / terminfo 策略 + +### 7.1 各终端的做法与 SSH 问题 + +| 终端 | 默认 TERM | SSH 问题与对策 | +|---|---|---| +| Ghostty | 自带 terminfo 时用 `xterm-ghostty`,找不到时退回 `xterm-256color`;`COLORTERM=truecolor`;`TERM_PROGRAM=ghostty`;`TERM_PROGRAM_VERSION`;会清除 `VTE_VERSION` [`termio/Exec.zig:~640-660,750-755`] | 远端没有条目时报 "unknown terminal type"。对策:① 用 `infocmp -x xterm-ghostty \| ssh host tic -x -` 手动安装;② `SetEnv TERM=xterm-256color`(会丢失样式下划线等);③ shell integration 的 `ssh-env`(改 TERM 为 xterm-256color 并 SendEnv COLORTERM / TERM_PROGRAM*)和 `ssh-terminfo`(首次连接时自动执行 `tic`,结果缓存)[ghostty-terminfo;`Config.zig` shell-integration-features;`cli/ssh.zig`]。名字带 `xterm-` 前缀是因为很多程序检查 TERM 中是否含 "xterm";ncurses 6.5-20241228 起已收录该条目。sudo 会过滤 `TERMINFO` | +| kitty | `xterm-kitty` | 同样的问题;对策是 `kitten ssh`(自动复制 terminfo 并启用 shell integration),或用 `env_keep += "TERM TERMINFO"` 处理 sudo [kitty `docs/faq.rst`] | +| WezTerm | **默认 `xterm-256color`**;`wezterm` 条目需要用户手动安装后改配置 [wezterm `docs/config/lua/config/term.md`] | 默认配置天然兼容 SSH;代价是 Smulx 等能力需要程序自行探测 | +| Windows Terminal | ConPTY;Linux 侧通常是 `xterm-256color` | ncurses 中有 `ms-terminal` 条目,但未被普遍使用 [ncurses] | +| VS Code / xterm.js | `xterm-256color`,`TERM_PROGRAM=vscode` | | +| tmux 内部 | `tmux-256color` 或 `screen-256color` | tmux 通过 DA1 / DA2 / XTVERSION、`CSI ? 2026 $ p` 和 OSC 10 / 11 识别外层终端 [`tty.c:413-430`];默认 `terminal-features = "xterm*:clipboard:ccolour:cstyle:focus:title"` [`options-table.c:606`]。**TERM 匹配 `xterm*` 时,tmux 默认认为外层支持 OSC 52、光标颜色、光标样式、焦点和标题** | + +### 7.2 程序如何探测特性(按可靠度排序) + +1. **主动查询**:XTVERSION(最可靠 [quast-2026])、DA1 / DA2、DECRQM(2026、2027、2031、2048、69)、XTGETTCAP、`CSI ? u` + DA1、DECRQSS 回读 SGR、OSC 10 / 11。 +2. **环境变量**:`TERM_PROGRAM` / `TERM_PROGRAM_VERSION`(neovim 用它识别 WezTerm 版本 [`tui.c:525-551`];VS Code 文档建议据此发送 633);`COLORTERM=truecolor|24bit`(neovim 据此直接开启 termguicolors [`defaults.lua:1014-1018`])。**这些变量默认不会通过 SSH 传递**,需要 `SendEnv` / `AcceptEnv`。 +3. **TERM / terminfo**:vim 用 `keyprotocol` 按 TERM 名选择 kitty 协议或 mok2;neovim 按 terminfo 族名判断 `xterm-kitty`、`xterm-ghostty` [`tui.c:2180-2205`]。 + +### 7.3 TCode 推荐策略 + +**本地(启动系统 shell)**: +- `TERM=xterm-256color`,`COLORTERM=truecolor`,`TERM_PROGRAM=TCode`(建议与 XTVERSION 名称一致),`TERM_PROGRAM_VERSION=`。 +- 清除继承来的 `VTE_VERSION`、`KITTY_WINDOW_ID`、`WT_SESSION`、`ITERM_SESSION_ID`、`TERMINFO` 等可能误导程序的变量(Ghostty 的做法)[推断 + `Exec.zig`]。 +- 设置 `LANG` / `LC_CTYPE` 保证 UTF-8(参见 05 号报告)。 +- **v1 不发布自定义 terminfo**。理由: + - (a) xterm-256color 在所有系统上都有,SSH、sudo、容器都开箱即用; + - (b) 现代能力(样式下划线、同步输出、kitty 键盘、真彩色)已经可以通过 §7.2 的查询方式探测,neovim、Codex、Claude Code、fish、tmux 都会主动查询; + - (c) Ghostty 和 kitty 为自有 terminfo 付出了大量 SSH / sudo / tcell 兼容成本(Ghostty 注释提到 tcell 要求 terminfo 主名必须等于 TERM [`terminfo/ghostty.zig:6-16`])。 +- **代价**:xterm-256color 没有 `Smulx` / `Setulc` / `Sync` / `Su` / `Tc`,只看 terminfo 的程序(例如未做探测的 ncurses 应用)用不到这些能力。可以用 XTGETTCAP 部分弥补,vim 和 neovim 会查询。 +- **v2 可选**:提供 `xterm-tcode` 条目(`use=xterm-256color` + 扩展能力),作为用户主动选择的选项。参照 Ghostty `ssh-terminfo` 的办法自动安装。 + +**SSH(以后内建 SSH 客户端时)**: +- 远端 `TERM=xterm-256color`。通过 SSH 的 env 请求发送 `COLORTERM`、`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`;远端拒绝时静默跳过。 +- 远端 shell integration(OSC 7 / 133)需要用户的远端 rc 文件支持,不注入脚本 [推断]。 +- **所有查询应答在 SSH 下照常工作**,这也是"不依赖 terminfo、依赖查询"的主要理由。 +- 远端发来的 OSC 7 / OSC 8 `file://` 带远端主机名,**不得当作本地路径打开**(§8.3、§8.5)。 + +**tmux 兼容性**: +- 在 TCode 里运行 tmux 时,tmux 会查询 XTVERSION。名字 "TCode" 不在 tmux 的表中,会落到 DA1 + `xterm*` 的默认特性:clipboard、ccolour、cstyle、focus、title,再加上 2026 探测到的 sync。RGB 需要用户设置 `terminal-features` 或 `Tc`。 +- **建议**:TCode 文档提示 `set -as terminal-features ',xterm-256color:RGB:hyperlinks:usstyle:extkeys'`。长期可以向 tmux 提交 PR,把 TCode 加入 `tty_default_features` 表 [推断]。 +- tmux 内部的 TUI 需要 `allow-passthrough` 才能把 OSC 9 / 1337 等透传给 TCode(Claude Code changelog:"reach the outer terminal when running inside tmux with `set -g allow-passthrough on`")。 + +--- + +## 8. 安全规格 + +总纲来自 [dgl-2023]: +- 严格按状态机解析; +- UTF-8 下不实现 C1; +- **默认关闭会回显可变内容的查询**; +- 应答不得包含攻击者可控的文本或控制字符; +- 对字体、剪贴板操作加限制或询问; +- 所有解析都做边界检查; +- 交给外部程序的值必须正确转义。 + +已知 CVE 涉及:DECRQSS 回显(iTerm2、mintty、SwiftTerm)、OSC 50 字体查询回显(xterm、mintty)、标题回报(ConEmu CVE-2022-46387 / 2023-39150)、OSC 9;9 cwd 引号注入(WT + WSL CVE-2022-44702)、OSC 8 序列未正确终止(less CVE-2022-46663)。 + +### 8.1 OSC 52 剪贴板 + +| 操作 | 默认 | 依据 | +|---|---|---| +| 写 | 允许;单次 8 MiB 上限;**每次写入在 UI 上给出提示** | Ghostty 默认写允许、读询问 [`Config.zig:2458-2459`];kitty 默认 `write-clipboard write-primary read-clipboard-ask read-primary-ask`,并注明关闭读确认"is a security risk … even the ones running on a remote server via SSH can read your clipboard"[`definition.py:3194-3207`];Alacritty 默认 OnlyCopy,理由是读剪贴板"can be easily abused while not providing significant benefits"[`alacritty.5.scd:618-626`];WezTerm 和 WT 忽略读 | +| 读 | **询问**(按会话 + 来源记忆"本会话允许";SSH 远端单独询问),可以设为拒绝 | 同上 | + +- 读请求在等待用户确认期间**不能阻塞输出解析**。应答稍后再写入;拒绝时**不应答**。Ghostty 的同类处理是"Requests are ignored if denied"[推断]。 +- 写入内容超过上限或不是合法 base64 时,忽略且不清空剪贴板。 + +### 8.2 标题回报与其他回显类查询 + +| 查询 | 默认 | +|---|---| +| `CSI 21 t`(标题)、`CSI 20 t`(图标名) | **关闭**。Ghostty `title-report = false`,注释写明"can expose sensitive information at best and enable arbitrary code execution at worst"[`Config.zig:2513`];WezTerm 需要 `enable_title_reporting`;xterm 默认 disallowed GetWinTitle / GetIconTitle [xterm-man]。开启时,应答中的标题要去掉所有 C0 / C1 控制符并截断 | +| OSC 50(字体查询) | 不支持 | +| DECRQSS | 只应答 §3.9 列出的设置,内容完全由内部状态格式化,**绝不回显请求文本**(iTerm2 和 mintty 的 CVE 都是因为把请求内容放进了应答) | +| XTGETTCAP | 应答只包含 hex 编码的名字(与请求相同)和编译期常量值,安全 | +| DECRQCRA(区域校验和) | 默认关闭(信息泄露 [dgl-2023 §Information leaks]),只在测试模式开启(§9) | +| OSC 1337 `ReportVariable`、`RequestUpload` | 不支持 | +| ENQ answerback | 永远为空 | + +### 8.3 超链接(OSC 8)欺骗 + +- 默认**普通点击不打开链接**,必须 Cmd / Ctrl + 点击 [osc8 §Security 的建议]。 +- 悬停时**显示真实 URI**(状态栏或 tooltip),并高亮同一 id 的所有格子。 +- 协议白名单:http、https、mailto、file(本机)。其他协议(含自定义 URI 协议)需要确认对话框 [osc8:custom URI handler 可能存在漏洞]。 +- 链接文本看起来像 URL,但与实际 URI 的主机不同(典型的钓鱼手法)时,**在确认框中醒目提示** [推断]。 +- `file://host/…`:只有 host 为空、`localhost` 或本机 `gethostname()` 时才在本地打开;否则不打开同名本地文件 [osc8 §file:// URIs and the hostname]。 +- 长度上限:URI 8 KiB、id 256 字节;超出则丢弃该链接属性,但照常显示文本。 +- 不对 URI 做任何 shell 拼接。打开时调用系统 API(macOS `NSWorkspace`、Windows `ShellExecute` 只传 URL、Linux `xdg-open` 以参数数组传递),参见 05 号报告。 + +### 8.4 粘贴注入 + +- **过滤**:所有文本插入(粘贴、拖放)都把以下字节替换成空格:NUL、BS、ENQ、EOT、**ESC**、DEL,以及 tty 特殊字符 ^C、^\、^U、^Z、^Q、^S、^W、^V、^R、^O。这与 xterm `disallowedPasteControls` 默认值 `BS,DEL,ENQ,EOT,ESC,NUL,STTY` 一致 [xterm-man],列表直接取自 Ghostty 从 xterm 源码复制的版本 [ghostty `input/paste.zig` `encode` 的 strip 表]。替换掉 ESC 后,**粘贴内容中的 `ESC[201~` 就无法提前结束括号粘贴**。 +- **安全检查**: + - 括号粘贴模式开启时:内容若包含 `\x1b[201~`,视为不安全; + - 未开启时:内容若包含 `\r` 或 `\n`,视为不安全; + - 不安全时弹出确认(显示前几行预览)。 + - 依据:Ghostty `isSafe` / `isSafeWith` [`input/paste.zig:180-199`];`clipboard-paste-protection = true`、`clipboard-paste-bracketed-safe = true` 为默认值。 +- 未开启括号粘贴时,`\n` 转换为 `\r`(xterm 行为)[ghostty paste.zig 注释]。 +- 也可以提供"总是确认多行粘贴"选项,供更谨慎的用户使用。 +- Agent 场景(TCode 特有):Agent 工具向终端写入输入时**也走同一套过滤**,并且永远不走括号粘贴之外的原始写入 [推断:TCode 设计需要;与 05 号报告一致]。 + +### 8.5 其他输入信道 + +| 信道 | 要求 | +|---|---| +| OSC 7、OSC 9;9、OSC 633 P Cwd、OSC 1337 CurrentDir | 只用于 UI(新标签页继承目录、显示)。用作新进程工作目录前要验证是本机的绝对路径且存在;**不拼进任何 shell 命令行**(WT 的 CVE-2022-44702)。OSC 7 不触发 DNS 查询(dgl 提到 OSC 7 可以造成 DNS 泄露) | +| 标题 | 去除控制字符,并截断长度 | +| 通知(OSC 9、777、99) | 限制频率;内容只当纯文本处理 | +| kitty 图形 `t=f/t/s` | 如果实现:只读普通文件,拒绝 /proc、/sys、/dev 等路径,所有失败返回同一个错误码(防止探测文件是否存在)[kitty-gfx §The transmission medium]。**远端会话(SSH)一律禁止 f / t / s 方式** | +| DoS 防护 | §1.5 的长度上限;kitty 键盘栈 8;标题栈 10;REP / ICH / IL 等参数钳到屏幕尺寸;SU 大参数要先钳制(kitty 注释提到 `CSI 2147483647 S` 会导致忙循环);字形簇码点上限 32;超链接表数量上限(如 64 K 项)与 LRU 回收 | +| 写回 PTY 的总量 | 应答速率限制。例如程序以 1 MB/s 发 DA1,应答写入不能阻塞解析线程,也不能无限缓冲。设应答队列上限(如 1 MiB),超出时丢弃 [推断] | + +--- + +## 9. 测试计划 + +### 9.1 套件 + +| 套件 | 性质 | 用法 | +|---|---|---| +| **vttest** 2.7(20251205) | 交互式、菜单驱动,覆盖 VT100–VT520、xterm 扩展、鼠标、字符集、键盘 | `vttest -l` 记录日志,`-c` 回放(键盘和鼠标测试仍需人工)[vttest.1]。用于人工验收和录制回放:在 TCode 中运行 vttest,同时录制 PTY 输出流,用作 §9.2 的黄金样本。**不要用合成的 GUI 输入去驱动**(遵守项目的 UI 验证约定) | +| **esctest2**(George Nachman / Thomas Dickey) | 全自动,80 个测试文件,"xterm, but without bugs in George's opinion" | 依赖 **DECRQCRA** 读格子内容(`escutil.AssertScreenCharsInRectEqual` 逐格求校验和)、`CSI 18 t` 读尺寸、CPR、DECRQSS、XTGETTCAP(`Co`)。`--expected-terminal` 只认 xterm / iTerm2;`--max-vt-level`。**做法**:TCode 提供"测试模式"开关,开启 DECRQCRA 和 XTWINOPS 全集;用无头 PTY 宿主(不需要 GUI)把 esctest 当子进程运行,CI 中跑 `--expected-terminal=xterm`,失败的用例维护一份 known-bugs 列表。这一项**只读运行 Python 脚本,没有构建步骤** | +| **ucs-detect** 2.3.x | 通过 CPR 测宽度,并探测 2026 / 2027 / 2031 / 2048 / 5522、kitty 键盘、OSC 52、XTGETTCAP、DECRQSS 等,输出 YAML | 每个发布版本跑一次 `ucs-detect --save-yaml`,与 Ghostty 等的 data/*.yaml 对照;目标是 WIDE / ZWJ / VS16 / VS15 在 2027 模式下 ≥ 98%。可以把结果提交给上游 data 目录 | +| xterm.js 回归素材 | `test/fixtures/escape_sequence_files/*.in` + `.text`(80×25,由 xterm 生成期望值),约 80 对,覆盖 CUP / IND / RI / DECSTBM / IRM / alt screen / SGR / vttest / vim | MIT 许可,可以直接作为黄金样本(需要按 NOTES 处理末尾空行)[xterm.js `test/fixtures/escape_sequence_files/NOTES`] | +| Alacritty ref tests | `alacritty_terminal/tests/ref//{alacritty.recording, grid.json, size.json, config.json}`,45 个(含 tmux_htop、vim_simple_edit、zsh_tab_completion、vttest_*) | Apache-2.0 / MIT。recording 就是原始字节流;grid.json 是期望网格(需要写转换器读成我们的快照格式)。很适合作为"真实 TUI 字节流 → 网格"的黄金样本 | +| iTerm2 feature-reporting 脚本 | `aliasing.sh`、`parser_unknown.sh` 等 | 解析器健壮性 [iterm2-feat §Parsing] | +| terminal-wg | freedesktop terminal-wg/specifications(2019 年以后基本停滞,仓库只有模板);issue 清单(37 个)可以作为功能雷达:#2 同步更新、#4 语义提示、#7 终端识别、#20 OSC 7、#29 进度条、#36 emoji 宽度 | 只作参考 | + +### 9.2 自动化回归架构 + +1. **解析层单测**:给定字节 → 期望的动作序列(print、execute、csi(params, subparams, intermediates, final)、osc(...))。对照 alacritty/vte、Ghostty `Parser.zig` 测试里的边界用例: + - 参数溢出(`"1;".repeat(MAX+1)`); + - 冒号 SGR `\x1b[38:2:255:0:255;1m`; + - CSI 被 ESC 打断(`\x1b[3;1\x1b[?1049h`); + - OSC 用 BEL / ST 结束; + - UTF-8 在 chunk 边界截断; + - 解码得到的 C1 被忽略;原始 0x9B 输出 U+FFFD。 +2. **屏幕模型快照**:用 `.in`(原始字节)+ `.snap`(文本 + 每格属性 + 光标 + 模式 + 滚动区 + 应答字节的规范化文本)构成黄金样本对,覆盖: + - xterm.js 与 Alacritty 的素材(转换后入库,注明来源和许可); + - 自行录制的真实程序:用 `script -q` / asciinema 在 TCode 或其他终端里录制 vim 打开文件并编辑、htop 刷新 3 帧、tmux 分屏、less 翻页、fzf 过滤、Claude Code 和 Codex 一轮对话(**不发起真实的模型调用,可以用各自的离线 / mock 模式或录制回放**); + - 宽度:ucs-detect 的 `docs/ucs_example_files` 素材。 + - 快照在 Windows / macOS / Linux 上必须**逐字节一致**,确保与平台无关。 +3. **应答测试**:断言查询序列的应答字节,例如 DA1、`CSI ? u` + DA1 的顺序、DECRPM 全表、XTGETTCAP、DECRQSS 回读 SGR(模拟 neovim 的探测)。 +4. **键盘编码表测试**:用表格驱动(键 × 修饰 × 模式(legacy / DECCKM / mok1 / mok2 / kitty 各 flag / win32))断言输出字节。期望值来自 [ctlseqs]、[kitty-kbd] 中的表格,以及 WT `terminalInput` 单测(`src/terminal/adapter/ut_adapter/kittyKeyboardProtocol.cpp`)。 +5. **鼠标编码表测试**:按钮 × 修饰 × 编码 × 坐标边界(223 / 2015 / 大于 2015)。 +6. **Fuzz**:SharpFuzz 或自写随机生成器,加上结构化变异(在合法序列中插入 ESC / CAN / C1 / 超长参数 / 超长 OSC)。不变量:不抛异常、内存有上限、网格结构一致(宽字符的两半成对、光标在范围内)、`Feed` 之后 `IsGround` 可达。 +7. **端到端(无头)**:真实 PTY + 真实 shell(bash / zsh / pwsh),驱动脚本只向 PTY **写字节**,不合成 GUI 事件,然后对网格做快照断言。渲染层另用 Avalonia 无头快照测试(遵守项目约定:不合成输入,不截取屏幕区域)。 +8. **一致性对照(可选)**:把同一份 `.in` 交给 libghostty-vt 或 alacritty_terminal,结果作为第二参考,差异需要人工裁定。只作离线对照工具,不进入产品。 + +### 9.3 验收门槛(建议) + +- esctest2 在 xterm 模式下:Tier 0 / Tier 1 相关测试文件通过率 ≥ 95%,剩余失败项列入 known-bugs。 +- vttest 菜单 1–8、11(xterm 扩展)人工验收通过。 +- ucs-detect:2027 模式下 WIDE / ZWJ / VS16 / VS15 ≥ 98%;兼容模式下与 xterm 行为一致。 +- 真实 TUI 回放快照全部稳定;fuzz 运行 1 小时以上无失败。 + +--- + +## 附录 A:控制序列速查(TCode 实现清单) + +ESC 序列: +- `ESC 7` / `8`、`ESC D` / `E` / `H` / `M`、`ESC c`、`ESC =` / `>` +- `ESC ( ) * +` 加 `0` / `A` / `B` +- `ESC # 8` +- `ESC N` / `O`(SS2 / SS3)、`ESC n` / `o` / `~` / `}` / `|` +- `ESC SP F` / `G`(忽略)、`ESC %`(忽略) +- `ESC \`(ST) + +CSI 序列: +- `@ A B C D E F G H I J K L M P S T X Z ^ \` a b c d e f g h l m n r s t u` +- `? J`、`? K`、`? W`、`? h`、`? l`、`? n`、`? s`、`? r`、`? $ p`、`$ p` +- `! p`、`SP q`、`" q`、`> c`、`= c`、`> q` +- `> m`、`? m`(XTMODKEYS)、`> f`(XTFMTKEYS) +- `= u`、`? u`、`> u`、`< u` +- `# {`、`# }`(Tier 2)、`$ x / z / v`(Tier 2)、`' } / ~`(Tier 2) +- `* y`(测试模式)、`48 t`(应答方向) + +OSC: +- `0 1 2 4 7 8 9 10 11 12 52 104 110 111 112 133 633 1337(CurrentDir|SetMark)` + +DCS: +- `$ q`、`+ q`;sixel `q`(Tier 2);未知 DCS 跳过 + +APC: +- `G`(Tier 2);其他跳过 + +## 附录 B:主流 TUI 的启动查询(用于编写回归脚本) + +| 程序 | 启动时发出(摘录) | 来源 | +|---|---|---| +| neovim | `?2004h`;DECRQM 69 / 2026 / 2027 / 2031 / 2048;`ESC[0m ESC[4:3m DCS $qm ST`(样式下划线探测);`CSI ? u` + `CSI c`;`OSC 11;?` + `CSI 5n`;之后发 `>3u` 或 `>4;2m` | `tui.c:300-353,575-605` | +| tmux(外层) | `CSI c`、`CSI > c`、`CSI > q`、`CSI ? 2026 $ p`、`OSC 10;?`、`OSC 11;?` | `tty.c:413-430` | +| vim | `CSI > c`(t_RV);版本 ≥ 141 时发 XTGETTCAP 请求;kitty 类 TERM 发 `CSI ? u`;xterm 类发 mok2 | `term.c` | +| Emacs | `CSI > c`;DA1 带 52 时启用 OSC 52 写;版本 ≥ 242 时发 OSC 10 / 11 | `xterm.el:793-860` | +| Codex | CPR、OSC 10 / 11、DA1、DA2、`CSI ? u`(单次 250 ms 超时);括号粘贴、焦点、alternate scroll 1007、kitty `>5u` / `>7u` 或 mok2 | `terminal_probe.rs`、`tui.rs:245-266`、`keyboard_modes.rs` | +| Claude Code | kitty 键盘查询、2026、OSC 8、OSC 9;4、OSC 7501、OSC 52、标题;在 Windows 上处理 win32-input-mode 状态 | `CHANGELOG.md`(多条,见正文) | diff --git a/docs/terminal/research/04-rendering-performance.md b/docs/terminal/research/04-rendering-performance.md new file mode 100644 index 0000000..e7b6a4d --- /dev/null +++ b/docs/terminal/research/04-rendering-performance.md @@ -0,0 +1,1693 @@ +# 04 · 终端组件调研:缓冲区数据结构、渲染与性能 + +> 调研日期:2026-10-16 | 范围:缓冲区/回滚、渲染管线、字体字形、线程模型、输入/IME/可访问性、性能目标与基准计划 +> 结论适用于两种终端核心:**纯 C# 核心**,或 **libghostty-vt 经 FFI 提供屏幕状态**。 + +## 读前说明 + +**来源标注** + +- **[已核实]**:在下表所列 commit 的源码或仓库文档中亲眼读到。 +- **[推断]**:由源码推算、由架构推论,或属于本文提出的设计与目标。 +- 引用格式为 `仓库/路径:行号 @ commit`。 + +**源码版本** + +源码均为浅克隆,只读,未构建也未运行。克隆位于 scratchpad `repos/render/`。 + +| 仓库 | commit / 版本 | 日期 | +|---|---|---| +| ghostty-org/ghostty | `b3dc3d7` | 2026-10-15 | +| alacritty/alacritty | `5421470` | 2026-10-16 | +| alacritty/vte | `abeae76`(crate 0.15.0,即 Alacritty 依赖的版本) | 2026-02-28 | +| wezterm/wezterm | `e2a98e1` | 2026-10-10 | +| microsoft/terminal | `44b440b` | 2026-10-13 | +| xtermjs/xterm.js | `991857a` | 2026-10-13 | +| dnkl/foot | `cb27717` | 2026-09-18 | +| kovidgoyal/kitty | `f426c08` | 2026-10-16 | +| contour-terminal/contour | `8f3a4a4` | 2026-10-16 | +| AvaloniaUI/Avalonia | tag `12.1.3`(`8eeda4f`) | 2026-09-22 | +| TCode | `dfd3657` | — | +| StreamMark | `074f7ff` | 2026-10-16 | + +--- + +## 0. 结论摘要 + +### 0.1 缓冲区:8 字节 cell + 按页管理 + +一流终端的 cell 大小如下: + +| 终端 | 每 cell 字节数 | 说明 | +|---|---|---| +| Ghostty | 8 | `packed struct(u64)`,样式按页去重 | +| xterm.js | 12 | — | +| foot | 12 | — | +| Windows Terminal | 约 5.2 | UTF-16 加列偏移表,属性按行做 RLE | +| Alacritty | 24 | — | +| WezTerm | 24 | 回滚区会压缩成 "UTF-8 文本 + 属性游程" | +| kitty | 32 | CPU 12 + GPU 20 | + +推荐纯 C# 核心照搬 Ghostty 的布局: + +- 8 字节 cell; +- 页内 style 表,u16 id,带引用计数; +- grapheme 放在旁路存储; +- 行环形索引,滚动为 O(1); +- 回滚上限按页裁剪,冷页压实。 + +在 120 列下,每 1 万行回滚约 9.7 MB;压实后约 1–3 MB。[推断] + +### 0.2 渲染:推荐在 Avalonia 渲染线程上绘制 + +推荐方案(Avalonia 12.1.3): + +- 用 `CompositionCustomVisual` + `CompositionCustomVisualHandler` 在渲染线程绘制。 +- 用 `ImmediateDrawingContext.DrawGlyphRun` 绘制文字。 +- 用 `ISkiaSharpApiLeaseFeature` 绘制背景和程序化字形(sprite)。 +- UI 线程完全不参与每帧绘制,只负责输入、IME 和布局。 + +已核实的 API 支撑: + +- `CompositionCustomVisualHandler` 提供 `OnRender`、`Invalidate(Rect)` 和 `RegisterForNextAnimationFrameUpdate`。 +- 12.1.3 中实现 `IPlatformRenderInterface` 的只有 Skia 和 Headless 两种,所以 Skia lease 在 6 个桌面 RID 上都可用。 +- Headless 测试只要 `UseSkia()` + `UseHeadlessDrawing=false`,lease 同样可用。TCode 的测试已经是这样配置的。 + +### 0.3 线程:每个终端一读线程加一核心线程(actor 模型),渲染线程永不等锁 + +- **读线程**:从 PTY 读入,使用 4 块 × 64 KiB 的有界缓冲池,池满即形成背压。 +- **核心线程**:独占终端状态,负责解析、执行命令,并发布不可变的"帧快照"。 +- **渲染线程**:每个 vsync 原子地取最新快照,从不等待终端锁。 + +这样做的原因:Avalonia 的渲染线程由整个窗口共用,一旦阻塞,整个应用都会掉帧。参考了 Ghostty 的 RenderState 两阶段更新和 contour 的 try-lock 双缓冲。[推断:设计] + +### 0.4 解析快路径:.NET 有现成积木,但缺 UTF-8 → UTF-32 的批量 API + +可直接使用的 API [已核实]: + +- `IndexOfAnyExceptInRange(0x20, 0x7E)` +- `SearchValues` +- `Ascii.IsValid` +- `Utf8.ToUtf16` / `Utf8.IsValid` +- `Vector128/256/512.Widen` + +缺口:.NET 10 没有 UTF-8 → UTF-32 的批量 API [已核实]。ASCII 部分需要自己用向量做 widen;非 ASCII 部分退回 `Rune.DecodeFromUtf8`。 + +各家的做法: + +- Ghostty 用 Highway 加 simdutf,实现"找 ESC + 解码"一体化。 +- vte 只用 `memchr(ESC)` 加 `str::from_utf8`。 +- WT 用 SSE2/NEON 找控制字符。 + +### 0.5 文字:网格对齐,程序化绘制图形字符 + +- x 坐标强制对齐网格。cell 宽高四舍五入到整数设备像素。 +- 下划线粗细取 ceil,且不小于 1。 +- 以下区段一律程序化绘制,保证像素对齐: + - box drawing、block、braille; + - powerline 中的几何类字符; + - legacy computing 和 octant; + - 各种下划线样式与光标。 +- 连字:按同样式 run 用 HarfBuzz 做 shaping(Avalonia `TextShaper` + `FontFeatures`)。在光标处断开 run。纯 ASCII 且关闭连字时走 cmap 快路径,不做 shaping。 + +### 0.6 输入与可访问性 + +**输入** + +- 用 `TextInputMethodClient` 承担 IME:`SupportsPreedit=true`,预编辑文字自己画在光标处,`CursorRectangle` 用于定位候选框。 +- `KeyDown` 负责编码特殊键和 Ctrl/Alt 组合,`TextInput` 负责可打印文本。 +- Win32 上有一个重要细节:KeyDown 设了 `Handled` 之后,随后的 `WM_CHAR` 会被吞掉 [已核实]。 + +**可访问性** + +- Avalonia 12.1.3 没有公开的 `ITextProvider`(UIA TextPattern)[已核实]。 +- 第一版用两样东西替代: + - 隐藏的 LiveSetting 播报元素(Win32 上 Name 变化会触发 `LiveRegionChanged`,[已核实]); + - 一个"可访问缓冲视图",即只读 TextBox,参考 VS Code 的 Accessible View。 + +### 0.7 数字目标 [推断:目标值] + +| 项目 | 目标 | +|---|---| +| 核心吞吐(关闭渲染) | ASCII ≥ 300 MB/s,dense SGR ≥ 100 MB/s | +| 开启渲染后 | 不低于关闭渲染时的 80% | +| 帧预算(120 Hz) | 渲染线程绘制 p99 ≤ 2 ms;UI 线程每帧 ≤ 0.1 ms | +| 快照发布 | ≤ 50 µs | +| 回滚内存 | 每 1 万行 × 120 列 ≤ 10 MB,压实后 ≤ 3 MB | +| 输入延迟 | 按键到 Present 的自身附加时间 p50 ≤ 1 ms,另加等待 vsync;端到端(Typometer)不超过同机 Ghostty/Alacritty + 2 ms | + +### 0.8 FFI 路线(libghostty-vt)要点 [已核实] + +- C API 已经相当完整:Terminal、RenderState(含脏行迭代、整行 `CELLS_RAW` 批量视图)、key/mouse 编码器、选区、搜索、压缩。 +- 但 API 明确标为 "work-in-progress… not yet stable"。 +- 库不带线程、PTY 和渲染,锁、mode 2026 超时和压缩调度都要嵌入方自己做。 +- 默认 `max_scrollback_bytes = 10_000`,几乎等于没有回滚,必须显式设置。 + +--- + +## 1. 一流终端的缓冲区与渲染:对比总表 + +### 1.1 Cell 与行存储 + +| 终端 | cell 结构(字节) | 颜色存储 | grapheme / 组合字符 | 行与滚动 | 来源 | +|---|---|---|---|---|---| +| **Ghostty** | `packed struct(u64)` **8 B**:content_tag(2),content(24:u21 码点、调色板或 RGB),style_id(u16),wide(2),protected,hyperlink,semantic(2) | cell 里只存 style_id,Style 在页内 `RefCountedSet` 去重;"只有背景色"的 cell 直接把 RGB 内联进 content | `.codepoint_grapheme`,后续码点放在页内 `BitmapAllocator`(4×u21 一块)+ `grapheme_map[cell offset]`,每 cell 上限 64 个码点 | Row 也是 `packed struct(u64)`,包含 wrap/styled/dirty 等标志;PageList 是页的双向链表,页约 400 KB,全部用 offset 指针 | `ghostty/src/terminal/page.zig:2008-2235, 90-99, 1890 @ b3dc3d7` | +| **Alacritty** | `Cell{c: char, fg, bg: Color, flags: u16, extra: Option>}`,≤**24 B**(有测试断言) | 内联 `Color`(Named/Spec RGB/Indexed) | 没有 grapheme 概念,零宽字符追加到 `extra.zerowidth`,最多 9 个 | `Storage{inner: Vec, zero, len}` 旋转 `zero` 实现 O(1) 滚动;`Row{inner: Vec, occ}` | `alacritty_terminal/src/term/cell.rs:141-147,308-314`;`grid/storage.rs:33-53,180-185 @ 5421470` | +| **WezTerm** | `Cell{text: TeenyString(8), attrs: CellAttributes(16)}` = **24 B** | 只有调色板色走 `SmallColor`;truecolor、下划线色、超链接放进 `Box` | `TeenyString` 内联整个 grapheme(<8 字节) | `VecDeque`;`CellStorage::{V(Vec), C(ClusteredLine)}`,回滚行压成"UTF-8 文本 + 属性游程" | `wezterm-cell/src/lib.rs:719-729,1029-1036`;`wezterm-surface/src/line/clusterline.rs:18-42 @ e2a98e1` | +| **Windows Terminal** | 每行一段 UTF-16 `_chars` 加 `_charOffsets[cols+1]`,约 **4 B/cell**;属性用 `til::small_rle` 按行 RLE | `TextColor` 4 B(Default/Index16/Index256/Rgb) | 字素按 UTF-16 存放,偏移表最高位 `0x8000` 标记宽字符的尾半格 | 一次 `VirtualAlloc(MEM_RESERVE)` 预留,按需 commit(每次预读 128 行);`_firstRow` 环形,O(1) 滚动 | `terminal/src/buffer/out/Row.hpp:241-320`;`TextAttribute.cpp:10-15`;`textBuffer.cpp:92-143,728-746 @ 44b440b` | +| **xterm.js** | `Uint32Array` 每格 3 个 u32 = **12 B**:content(width 2 / combined 1 / codepoint 21),fg(flags 8 + RGB 24),bg(flags 8 + RGB 24) | 内联 | `_combined[index]: string` 稀疏表;扩展属性(下划线样式/颜色、URL)放在 `_extendedAttrs` 稀疏表 | `CircularList`,满了就 `recycle()` 复用最旧的行 | `xterm.js/src/common/buffer/BufferLine.ts:14-34,71-74`;`Constants.ts:36-157 @ 991857a` | +| **foot** | `struct cell{char32_t wc; struct attributes attrs;}` = **12 B**(`static_assert`);attrs 8 B 位域,含 `clean` 脏位 | fg/bg 各 24 bit,加 2 bit `color_src`(调色板只存索引) | `wc ∈ [0x200000, …]` 是 composed 二叉树的 key;`CELL_SPACER+n` 表示宽字符的占位格 | 行指针环形数组,长度为 2 的幂,用掩码取下标;行懒分配;滚动只改 `offset` | `foot/terminal.h:36-72,148-160,220-247`;`grid.h:34-61 @ cb27717` | +| **kitty** | `CPUCell` 12 B + `GPUCell` 20 B = **32 B**(两个数组,SoA 的一半) | GPUCell 里放 fg/bg/decoration_fg | `TextCache` 索引(哈希表 + arena,带 GC),每 cell ≤ 24 码点 | `line_map` 间接索引;历史按 2048 行一个 mmap segment | `kitty/kitty/line.h:12-100`;`history.c:17-44 @ f426c08` | +| **contour** | `LineSoA`:码点、宽度、SGR(16B)、超链接等分层 SoA,约 **29 B/cell** [推断] | SGR 内联 | 每行一个 `clusterPool` | 空行不展开(O(1)),用 `LineShape::Uniform` 走整行快路径 | `contour/src/vtbackend/grid/LineSoA.hpp:43-180 @ 8f3a4a4` | + +### 1.2 脏跟踪、渲染缓存与图集 + +| 终端 | 脏跟踪 | 每帧工作量 | 字形缓存与图集 | 来源 | +|---|---|---|---|---| +| **Ghostty** | 三层:`Row.dirty`(可假阳性、不可假阴性)、`Page.dirty`、全局 `Terminal.Dirty`/`Screen.Dirty`。由 `RenderState.beginUpdate` 统一消费,用 8 行一组的 `@Vector` 掩码扫描 | 只重建脏行的 `CellText`;bg 是 `[rows*cols]u8x4` 加一个全屏三角形 | skyline 打包;一张灰度 atlas 加一张 BGRA atlas,起始 512,满了翻倍;键 `GlyphKey packed u64`;shaping 缓存以 run hash(码点 + 相对 cluster + font index)为键 | `ghostty/src/terminal/render.zig:552-597,766-833`;`src/font/Atlas.zig`;`src/font/shaper/Cache.zig:31-44` | +| **Alacritty** | 每行 `[left,right]` 区间,再加"上一帧/当前帧光标点";所有滚动都直接标全脏 | **每帧全量**重画,damage 只交给 Wayland 合成器 | 行式 atlas 1024²,满了就新建一张;`GlyphKey{font_key,size,char}`;不做 shaping 和连字 | `term/mod.rs:137-226,458-486`;`alacritty/src/display/mod.rs:607-623,838,878`;`renderer/text/atlas.rs:12` | +| **WezTerm** | 每行一个 `SequenceNo`;普通滚动不改 seqno(用 `StableRowIndex`) | 每帧全量重建 quad,但有 5 层缓存(seqno→shape hash、line quad、line element、cluster shape、glyph) | guillotiere 分配器,从 128 起翻倍;回退字体在后台线程查找 | `wezterm-gui/src/termwindow/render/pane.rs:371-554`;`mod.rs:430-436` | +| **Windows Terminal** | `invalidatedRows` 加每行 `dirtyTop/Bottom`;滚动交给 `Present1` 的 scroll rect | 每帧对所有行重新生成 quad(很便宜),只有失效行重新 shaping | `stb_rect_pack`,满了整张清空(无 LRU);颜色不进缓存键;背景用"每格 1 像素"的颜色位图加全屏 quad | `src/renderer/atlas/BackendD3D.cpp:1119,1706-1721`;`AtlasEngine.cpp:58-263` | +| **xterm.js** | `DirtyRowTracker` 维护 start/end,再由 `RenderDebouncer` 合并到 rAF | WebGL:逐格比较 4×u32 model,不同才更新;每帧整屏绘制 | 多页 atlas(512 起,单页上限 4096),4 页合并成 1;**颜色进缓存键**,因为背景色一起光栅化 | `addons/addon-webgl/src/WebglRenderer.ts:354-600`;`TextureAtlas.ts:60-61,155-277` | +| **foot** | 每 cell 一个 `clean` 位、每行一个 `dirty`、滚动损伤列表(会合并) | **CPU(pixman)**按 cell 跳过干净格;多线程按行分发;buffer age 复用上一帧 | 用 fcft 光栅化 A8 mask;box drawing 自绘并缓存 | `foot/render.c:696-705,3325-3719,2184-2300` | +| **kitty** | `LineAttrs.has_dirty_text` | 只要有变化就整屏 `rows*cols*20B` 重写 GPU 缓冲,只对脏行重新 shaping | `GL_TEXTURE_2D_ARRAY` sprite map,每个字形单独 `glTexSubImage3D` | `kitty/shaders.c:937-960,1229-1253` | + +### 1.3 IO、线程与锁 + +| 终端 | PTY 读取 | 解析在哪里、是否持锁 | 渲染侧持锁时间 | mode 2026 超时 | 来源 | +|---|---|---|---|---|---| +| **Ghostty** | POSIX 两段:`io-gather` 读线程配 4 块 × 64 KiB 环形缓冲(背压),`io-reader` 解析线程;macOS 每次读约 1 KiB,所以要 gather | reader 线程**持渲染器 mutex**直接解析,每批 64 KiB,批次边界调用 `yieldToDemand()`(最多让 1 ms) | 只执行 `beginUpdate`(复制脏行原始 cell),样式反规范化在锁外的 `endUpdate` | 1000 ms | `src/termio/Exec.zig:1264-1352`;`Termio.zig:697`;`renderer/State.zig:35-105`;`generic.zig:1400-1558` | +| **Alacritty** | 单线程,1 MiB 栈缓冲;先 `try_lock_unfair`,拿不到继续读,攒满再阻塞拿锁 | 持锁解析,最多 64 KiB 后放锁;`FairMutex` 用两把锁实现 lease 公平 | UI 线程持锁收集 `RenderableCell` 到 Vec 后立刻 drop 锁 | 150 ms,缓冲上限 2 MiB(vte) | `alacritty_terminal/src/event_loop.rs:24-27,104-171`;`sync.rs:11-48`;`vte/src/ansi.rs:36-39 @ abeae76` | +| **WezTerm** | 读线程(1 MiB)经 socketpair 交给解析线程 | 解析**在锁外**产出 `Vec`,3 ms 合并窗口,只在应用 action 时持锁 | GUI **在锁内**渲染(缓存未命中时 shaping 也在锁内) | 无超时 | `mux/src/lib.rs:118,142-247,283-345` | +| **Windows Terminal** | ConPTY 重叠 IO,128 KiB 缓冲 | 持写锁整块解析;锁是**公平的 ticket lock** | 锁内 shaping(DirectWrite),锁外 Present | 等待最多 100 ms | `src/renderer/base/renderer.cpp:402-468,574-610`;`ConptyConnection.cpp:737-843` | +| **xterm.js** | (浏览器)单线程 | `WriteBuffer` 按 12 ms 时间片解析,然后 `setTimeout` 让出 | rAF 合并渲染 | 1000 ms | `src/common/input/WriteBuffer.ts:11-34,219-315`;`RenderService.ts:21-23` | +| **foot** | 单线程 epoll,24 KiB × 最多 10 次读 | 主线程解析;有两个延迟渲染定时器(lower 0.5 ms,upper 8.33 ms) | 多个 worker 线程按行渲染 | 1 s | `foot/terminal.c:243-366,3834-3862`;`config.c:3652-3653` | +| **kitty** | I/O 线程直接读进 parser 的 1 MiB 缓冲;macOS 会 gather 到 64 KiB | 主线程解析;`input_delay` 3 ms,回显(<1 KiB)立即处理 | `repaint_delay` 10 ms | 2000 ms(渲染快照) | `kitty/child-monitor.c:1748-1797,2061-2100`;`vt-parser.c:18-27,1593-1604` | +| **contour** | — | 终端线程构建 `RenderBuffer` | 双缓冲交换时写端只做 `try_lock`,拿不到就放弃 | — | `src/vtbackend/render/RenderBuffer.cpp:59-86` | + +--- + +## 2. 各终端详述 + +### 2.1 Ghostty(最重要的参照,也是 libghostty-vt 的来源) + +#### 2.1.1 Page 内存布局 + +**分配** [已核实] + +- POSIX 上用 `mmap(PRIVATE|ANONYMOUS)`,Windows 上用 `VirtualAlloc`。依赖 OS 提供清零页,所以全零即为空 cell,也不需要初始化 cell(`page.zig:34-81, 263, 1742-1756`)。 +- 页池是 `UntouchedPool`,闲置的池项只占虚拟内存。 + +**标准容量**(`page.zig:1890`) + +```zig +pub const std_capacity: Capacity = .{ + .cols = 215, .rows = 215, .styles = 128, + .grapheme_bytes = if (builtin.is_test) 512 else 8192, +}; +``` + +- 页大约 400 KB(`include/ghostty/vt/terminal.h:2217-2219` 的注释原文 "about 400KB")。 +- 列数不同时,`Capacity.adjust` 在总字节不变的前提下用行数换列数。按此推算:80 列约 575 行/页,120 列约 390 行/页 [推断]。 + +**页内引用全部是 `Offset(T)`**(u32 相对偏移,`size.zig:8-70`)。整页可以 memcpy、压缩、序列化,不需要修指针。 + +#### 2.1.2 Row 与 Cell [已核实] + +```zig +// ghostty/src/terminal/page.zig:2008-2131, 2137-2235 @ b3dc3d7 +pub const Row = packed struct(u64) { + cells: Offset(Cell), // u32 + wrap: bool, wrap_continuation: bool, grapheme: bool, + styled: bool, // 可假阳性;跳过 unstyled 行让 erase 快约 4x + hyperlink: bool, + semantic_prompt: SemanticPrompt, // u2 + kitty_virtual_placeholder: bool, + dirty: bool, + _padding: u23 = 0, +}; +pub const Cell = packed struct(u64) { + content_tag: ContentTag, // codepoint / codepoint_grapheme / bg_color_palette / bg_color_rgb + content: packed union { codepoint: {data: u21, _pad: u3}, color_palette: {...}, color_rgb: RGB }, // 24 bit + style_id: StyleId, // u16, 0 = 默认样式 + wide: Wide, // narrow / wide / spacer_tail / spacer_head + protected: bool, hyperlink: bool, + semantic_content: SemanticContent, // u2 + _padding: u16 = 0, +}; +``` + +`page.Mask(T, fields, n)` 把多个 cell 的底层 u64 放进 `@Vector` 做批量掩码比较(`page.zig:2467`)。用到它的地方有三处: + +- print 快路径判断"目标格是否简单"; +- RenderState 扫描脏行; +- 检测样式 run。 + +**对 C# 的启示** [推断]:用 `ulong` 做 cell,配合 `Vector256`,可以一一对应地实现。 + +#### 2.1.3 样式、grapheme 与超链接 [已核实] + +- **Style 结构**:fg、bg、underline_color 三种颜色(`union{none, palette:u8, rgb}`),加 `flags: packed u16`(bold/italic/faint/blink/inverse/invisible/strikethrough/overline,以及 3 bit 下划线)。哈希时打包成 `PackedStyle = packed struct(u128)` 并断言 16 字节(`style.zig:21-60,604,672-678`)。 +- **StyleSet**:每页一个 `RefCountedSet`,开放寻址加 Robin Hood,load factor 0.8125。引用计数归零的项会延迟删除,在此之前还能"复活"(`ref_counted_set.zig:19-23,52-131`)。标准页大约能放 103 种样式 [推断]。 +- **容量溢出**:页内样式或 grapheme 表写满时,`increaseCapacity` 新建一页,把该维度翻倍后整页 clone 过去;到达上限后 split(`PageList.zig:4157-4219`;`Screen.zig:2527-2646`)。 + +**为什么要按页存样式** [推断]:样式 id 只需 u16 且在页内有效。页被裁剪时它的样式随之释放,不会出现"全局样式表被 truecolor 刷爆"的问题。这一点对纯 C# 核心同样关键。 + +#### 2.1.4 回滚与压缩 [已核实] + +**上限按字节计**(`src/config/Config.zig:1394,1412,1436`): + +- `scrollback-limit-bytes` 默认 50,000,000; +- `scrollback-limit-lines` 默认无限; +- `scrollback-compression` 默认 true。 + +按 400 KB 一页推算,50 MB 约 125 页,80 列时约 7.2 万行 [推断]。 + +**`grow()` 的两条路径**(`PageList.zig:4003-4130`): + +- 快路径:末页还有空余行就直接 `rows += 1`。 +- 超限时:把首页弹出,`memset(0)` 后作为新的末页复用,整个过程零分配。 + +**压缩**(`src/terminal/compress*.zig`,`mem.zig:56-160`): + +- 算法是 LZ4 块压缩。 +- 压缩后原映射 decommit:Linux 用 `MADV_DONTNEED`,Darwin 用 `MADV_FREE_REUSABLE`,Windows 用 `DiscardVirtualMemory`。 +- 只压完全位于活动区之前、且不与 viewport 相交的页。 +- 调度:终端空闲 250 ms 后开始,每步间隔 1 ms,用 `tryLock` 取锁(`renderer/Thread.zig:755-870`)。 +- 文档称文本类历史一般能压到 10–30%(`Config.zig:1423-1426`)。 + +**libghostty-vt 的默认值不一样**: + +```zig +// ghostty/src/terminal/Terminal.zig:285-287 @ b3dc3d7 +/// The maximum size of scrollback in bytes. Null is unlimited and zero +/// disables scrollback. +max_scrollback_bytes: ?usize = 10_000, +``` + +10 KB 不足一页,实际会被抬到最小值"活动区所需页数 + 1 页"(`PageList.zig:7135-7176`)。嵌入方必须设置 `GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES`。[已核实;后果为推断] + +#### 2.1.5 Reflow [已核实] + +- 入口在 `PageList.resize`(`:1261-1340`):只改行数时不 reflow;改列数时调用 `resizeCols`(`:1343-1586`),用 `ReflowCursor` 逐行遍历**整个屏幕加全部回滚**。 +- 源页处理完立即回收,复用为下一个目标页。 +- 有两项缓存:`StyleCache`(src→dst style id 映射)和 `cap_memo`。 +- 代价:resize 会先强制解压所有页(`:1263-1267`,注释 `TODO(mitchellh): Deferred reflow on non-viewport/non-active pages`),所以每次列变化是 O(全部回滚 cell),同步且急切。 +- alt screen 不 reflow。 +- termio 线程把 resize 合并 25 ms 再处理(`termio/Thread.zig:31`)。 + +#### 2.1.6 解析快路径 [已核实] + +**批处理主循环**:`stream.zig:705-843` 在 release 构建中每批最多处理 4096 个码点(`var cp_buf: [4096]u32`)。 + +**Ground 状态的处理**: + +```zig +// ghostty/src/simd/vt.zig:6 +extern "c" fn ghostty_simd_decode_utf8_until_control_seq( + input: [*]const u8, count: usize, output: [*]u32, output_count: *usize) usize; +``` + +- 实现在 `src/simd/vt.cpp`,用 Google Highway 做多目标动态分发: + - ASCII 段:一次装一个向量,`FindFirstTrue(Eq(ESC) | Ge(0x80))`,同时把字节加宽成 u32 写出(`:297-362`); + - 遇到非 ASCII:`simdutf::convert_utf8_to_utf32_with_errors`(`:158-183`)。 +- 解码之后再用 `@Vector(u32)` 做 `(cp & ~0x9F) == 0` 测试,把 C0/C1 切出去,可打印段作为 `print_slice` 整段交给 handler。 + +**print 快路径**:`Terminal.printSliceFast/printSliceFill`(`Terminal.zig:581,876-899`)用 `Mask(Cell,{content_tag,style_id,wide,hyperlink},4)` 一次判断 4 个目标格,批量写入。 + +**码点宽度不走 SIMD**:用的是查找表,≤0xFF 硬编码为宽度 1(`src/unicode/main.zig:26`)。 + +#### 2.1.7 IO 线程与锁 [已核实] + +```zig +// ghostty/src/termio/Exec.zig:1306-1352 @ b3dc3d7 +const buffer_count = 4; // 环形缓冲个数 = 背压上限("Less than 4 there are minor slowdowns") +const buffer_capacity = 64 * 1024; // 一批 = 一次加锁的工作量 +const bridge_threshold = 1024; // ≥1KiB 视为饱和流 +const bridge_spin_max = 16; +const bridge_poll_timeout_ms = 1; +const gather_budget_ns = 3 * std.time.ns_per_ms; + +// ghostty/src/termio/Termio.zig:697 +pub fn processOutput(self: *Termio, buf: []const u8) void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + self.processOutputLocked(buf); // queueRender() + terminal_stream.nextSlice(buf) +} +``` + +**两段式读取**: + +- 原因:macOS 上一次 read 最多拿到约 1 KiB,单线程循环会让生产者停顿。 +- 批次交付规则:交互式的小输出在第一次 EAGAIN 时立即交付;饱和流先自旋最多 16 次,再以 1 ms poll 衔接,每批总预算 3 ms。 +- 背压:4 块缓冲都在途时,gather 线程阻塞。PTY 内核队列于是保持满,反压子进程。 +- Windows 仍是单线程循环,缓冲只有 1024 字节(`Exec.zig:1784`)。 + +**锁的公平性**(`renderer/State.zig:35-105`):mutex 不公平,所以加了 "demand handoff": + +- 渲染线程调用 `lockDemand()`,把 `demand` 计数加一; +- IO 线程在每个 64 KiB 批次边界调用 `yieldToDemand()`,用 futex 等最多 1 ms,让渲染线程先拿到锁。 + +#### 2.1.8 RenderState(两阶段快照)[已核实] + +`src/terminal/render.zig:19-118` 的注释说明:它取代了 1.2.x 时"每帧 clone viewport"的做法,因为 clone 会阻塞 IO。 + +- **`beginUpdate`**(持锁):按页遍历视口行,消费 page/row 的 dirty 位,只对脏行 `fastmem.copy` 原始 cell,grapheme 拷进行内 arena(`:511,766-833,1408-1470`)。 +- **`endUpdate`**(无锁):样式反规范化。之后渲染器做 regex 链接匹配、搜索高亮、`rebuildCells`、shaping 和 GPU 提交,全部在锁外(`generic.zig:1555-1700`)。 +- 每行带 `Row.Id{serial, y}`,可以跨更新识别同一行,用来缓存已 shaping 的行(`:371`)。 + +#### 2.1.9 渲染器 [已核实] + +**节拍** + +- 没有固定帧率,也没有合并定时器:每次 wakeup 都立即执行 `updateFrame` 和 `drawFrame`。旧的 10 ms 定时器已被注释掉(`renderer/Thread.zig:474-517`)。 +- 唤醒用 `xev.Async`,多次 notify 自然合并成一次。 +- macOS 有 CVDisplayLink:只在 vsync 时 draw,没有变化时停止(`generic.zig:1297-1303`)。 +- 光标闪烁间隔 `CURSOR_BLINK_INTERVAL = 600` ms;IO 有输出时重置闪烁,节流 500 ms(`Thread.zig:20`;`Termio.zig:714-725`)。 +- 动画(自定义 shader、kitty 动图)使用 `draw_interval_ms = 8`(`generic.zig:1102`)。 +- 交换链 3 缓冲,用 `frame_sema` 限制在飞帧数(`Metal.zig:38`;`generic.zig:299-347`)。 + +**GPU cell**: + +```zig +// ghostty/src/renderer/metal/shaders.zig:267-293 @ b3dc3d7 +pub const CellText = extern struct { // @sizeOf == 32(测试断言) + glyph_pos: [2]u32 align(8), glyph_size: [2]u32 align(8), + bearings: [2]i16 align(4), grid_pos: [2]u16 align(4), + color: [4]u8 align(4), // A 通道用于 faint 和光标透明度 + atlas: Atlas align(1), // grayscale / color + bools: packed struct(u8){ no_min_contrast, is_cursor_glyph, _pad: u6 } align(1), +}; +pub const CellBg = [4]u8; // bg 是 [rows*cols] 扁平数组,用一个全屏三角形采样 +``` + +**每个 cell 的绘制顺序**(`generic.zig:2914-3360`):bg → 下划线(压在字形下面)→ 上划线 → 字形 → 删除线。 + +- **block 光标下的文字**:顶点着色器按 `cursor_pos` 把它改成 `cursor-text` 色(`cell_text.v.glsl:134-141`)。 +- **faint**:alpha = `ceil(0.5*255)`。 + +#### 2.1.10 run 切分与 shaping [已核实] + +`RunIterator.next`(`src/font/shaper/run.zig:47-305`)在以下位置切断 run: + +- 选区边界; +- 除 bg 以外任何样式变化(`comparableStyle` 先把 bg 置空再比较); +- 字体 face 变化; +- 光标:光标前一格切断,光标格单独成 run,从而在光标下禁用连字(可配置 `font-shaping-break`); +- 硬编码的坏连字 `f+l`、`f+i`、`s+t`。 + +此外: + +- run 没有最大长度限制。 +- 只看 grapheme 的第一个附加码点决定 VS15/VS16 呈现方式,VS 本身不送进 shaper。 +- shaping 缓存键是 Wyhash(码点 + 相对 cluster + 长度 + font_index),颜色不进键(`run.zig:262-287`;`Cache.zig:31-44`)。 +- HarfBuzz 后端强制 LTR;每个 cluster 的 x 对齐到网格,advance 只用于 cluster 内部偏移(`harfbuzz.zig:131-300`)。 +- 默认特性 `liga=1`。要关掉编程连字,文档建议配 `-calt`;全部关闭用 `-calt, -liga, -dlig`(`feature.zig:287-289`;`Config.zig:236-253`)。 + +#### 2.1.11 字体度量 [已核实] + +```zig +// ghostty/src/font/Metrics.zig:243-327 @ b3dc3d7 +const face_width = face.cell_width; // ASCII 32..126 的最大 advance +const face_height = face.lineHeight(); // ascent - descent + line_gap +const cell_width = @round(face_width); // round,不是 ceil +const cell_height = @round(face_height); +const face_baseline = face.line_gap/2 - face.descent; +const cell_baseline = @round(face_baseline - (cell_height - face_height) / 2); // 从底部量 +const underline_thickness = @max(1, @ceil(face.underlineThickness())); +const strikethrough_thickness = @max(1, @ceil(face.strikethroughThickness())); +const underline_position = @round(top_to_baseline - face.underlinePosition()); // 从顶部量 +``` + +**缺失度量的估算**(`:120-230`): + +- cap height = 0.75 × ascent +- ex height = 0.75 × cap height +- 下划线粗细 = 0.15 × ex height + +**`adjust-cell-height`**:把差值上下各分一半,基线和各条装饰线跟着平移(`:360-405`)。 + +**点数到像素**:`px = pt × dpi / 72`;`xdpi = round(content_scale × default_dpi)`,其中 default_dpi 在 macOS 为 72,其他平台为 96(`face.zig:29,52-56`)。 + +#### 2.1.12 字体回退 [已核实] + +`CodepointResolver.getIndex`(`src/font/CodepointResolver.zig:85-235`)依次尝试: + +1. `font-codepoint-map` 覆盖; +2. sprite face; +3. 已加载字体,要求 style 和 presentation 精确匹配; +4. 非 regular 样式时改用 regular 再找; +5. 系统发现回退字体。新找到的字体带 `size_adjustment = .ic_width`,即按"水"字宽度与主字体对齐(`Collection.zig:572-660`); +6. regular + 任意 presentation。 + +**合成字形**: + +- 合成粗体:CoreText 用描边 `max(pt/14,1)`,FreeType 用 `FT_Outline_Embolden`。 +- 合成斜体:CoreText 斜切约 15°,FreeType 12°。 +- 开关:`font-synthetic-style`,默认全开。 + +**emoji 约束**:强制 `.size=.cover`,居中,左右 padding 0.025(`SharedGrid.zig:416-432`)。 + +**光栅化**:灰度 AA 加亚像素定位,不做 LCD 子像素(`coretext.zig:404-416`)。 + +#### 2.1.13 Sprite(程序化字形)[已核实] + +文件在 `src/font/sprite/`,用 z2d 画布在 `alpha8` 表面上绘制。函数名形如 `draw_`,编译期扫描函数名自动生成码点范围表(`Face.zig:56-160`)。 + +| 文件 | 范围 | +|---|---| +| box.zig | U+2500–257F | +| block.zig | U+2580–259F | +| braille.zig | U+2800–28FF | +| powerline.zig | E0B0–E0BF、E0D2、E0D4(只实现几何类,不含风格化字形) | +| geometric_shapes.zig | U+25E2–25E5、25F8–25FA、25FF | +| branch.zig | U+F5D0–F60D(git 分支图) | +| symbols_for_legacy_computing(_supplement).zig | 1FB00–1FBEF 若干段;1CC1B–1CEAF 若干段(含 octant 1CD00–1CDE5) | +| special.zig | 下划线(single/double/dotted/dashed/curly)、删除线、上划线、各种光标(rect/hollow/bar/underline) | + +**像素对齐规则**(`draw/common.zig:22-36,205-233`): + +- 线宽:base = `metrics.box_thickness`(默认等于下划线粗细);light = base,heavy = 2 × base。 +- 分数坐标: + ``` + Fraction.min = s - round((1-f)*s) + Fraction.max = round(f*s) + ``` + 这样两个半块在奇数尺寸下也能无缝拼合。 +- 盲文:点宽 = `min(w/4, h/8)`,剩余像素按"点宽 > 边距 > 间距"的优先级分配(`braille.zig:57-120`)。 + +#### 2.1.14 其他渲染细节 [已核实] + +- **minimum-contrast**:在顶点着色器里逐字形实例计算 WCAG 对比度。不达标时改用黑或白中对比更高的一个。box、block、powerline 等字符豁免(`common.glsl:80-110`;`cell.zig:294-345`)。默认值为 1,即关闭。 +- **alpha-blending**:macOS 默认 `native`,其他平台默认 `linear-corrected`。后者用前景和背景的亮度反推 alpha,以接近 gamma 正确的混合(`cell_text.f.glsl:34-66`)。 +- **SGR 5 闪烁文本**:终端记录了这个标志,但渲染器从不读取,所以**不渲染**。 + +### 2.2 Alacritty(与 vte) + +#### 环形缓冲 [已核实] + +```rust +// alacritty_terminal/src/grid/storage.rs:13,33-53,180-185 @ 5421470 +const MAX_CACHE_SIZE: usize = 1_000; +pub struct Storage { inner: Vec>, zero: usize, visible_lines: usize, len: usize } +pub fn rotate(&mut self, count: isize) { + let len = self.inner.len(); + self.zero = (self.zero as isize + count + len as isize) as usize % len; +} +``` + +- **`scroll_up`**:当 `region.start == 0` 时只调用 `rotate`,被滚出的行自动进入历史;带 margin 时逐行 swap(`grid/mod.rs:252-307`)。 +- **`Row`**:`occ` 记录写过的上界,reset 时只清 `[0..occ]`(`row.rs:17-25,73-92`)。 +- **扩容与收缩**:一次至少扩 1000 行;`inner.len() > len + 1000` 时才 truncate(`storage.rs:107-138`)。 + +#### Damage 与重绘 [已核实] + +- 字符写入本身不标 damage,而是把"上一帧光标点"和"当前光标点"并入每行的 `[left,right]` 区间(`term/mod.rs:458-486`)。 +- 所有滚动、颜色变化、alt screen 切换、resize 都直接标全脏。 +- 前端每帧 `renderer.clear()` 后 `draw_cells` 遍历全部可见 cell。damage 只用于 Wayland 的 `swap_buffers_with_damage`(`display/mod.rs:607-623,838,878`),也就是说,**damage 不减少 GPU 或 CPU 工作量,只是给合成器的提示**。 + +#### 线程与锁 [已核实] + +```rust +// alacritty_terminal/src/event_loop.rs:24,27 @ 5421470 +pub(crate) const READ_BUFFER_SIZE: usize = 0x10_0000; // 1 MiB +const MAX_LOCKED_READ: usize = u16::MAX as usize; // 65535 +// alacritty_terminal/src/sync.rs:11-48 +pub struct FairMutex { data: Mutex, next: Mutex<()> } +pub fn lock(&self) -> MutexGuard<'_, T> { let _next = self.next.lock(); self.data.lock() } +pub fn lease(&self) -> MutexGuard<'_, ()> { self.next.lock() } +``` + +FairMutex 的工作方式: + +- PTY 线程在整个读批次期间持有 `lease`(也就是 `next` 锁),自己用 unfair 方式取 `data`。 +- UI 线程的 `lock()` 必须先排队拿 `next`,所以只能在两个批次之间插进来,但一定插得进来。 + +**帧调度**:vsync 被显式关闭(`SwapInterval::DontWait`),由 `FrameTimer` 按显示器刷新间隔对齐下一帧(`display/mod.rs:512-515,1576-1601`)。 + +#### vte 的 ground 快路径 [已核实] + +```rust +// vte/src/lib.rs:593-620 @ abeae76 (0.15.0) +/// The ground state is handled separately since it can only be left using +/// the escape character (`\x1b`). This allows more efficient parsing by +/// using SIMD search with [`memchr`]. +fn advance_ground(&mut self, performer: &mut P, bytes: &[u8]) -> usize { + let plain_chars = memchr::memchr(0x1B, bytes).unwrap_or(num_bytes); + ... + match str::from_utf8(&bytes[..plain_chars]) { Ok(parsed) => Self::ground_dispatch(performer, parsed), ... } +// vte/src/ansi.rs:36,39 +const SYNC_UPDATE_TIMEOUT: Duration = Duration::from_millis(150); +const SYNC_BUFFER_SIZE: usize = 0x20_0000; // 2 MiB +``` + +这和 .NET 里的 `span.IndexOf((byte)0x1B)`(已向量化)加 `Utf8.IsValid` 是同一个思路。 + +**Reflow** [已核实]:`grow_columns` 从下往上,把带 `WRAPLINE` 标记的行拼接起来;`shrink_columns` 截断后把溢出部分前置到下一行(`grid/resize.rs:101-388`)。代价为 O(行数 × 列数),并且要重新分配 `Vec`。alt screen 不 reflow。 + +**默认回滚**:10,000 行,上限 100,000(`alacritty/src/config/scrolling.rs:7,34`)。 + +**字体**:不做 shaping,也不做连字(在源码里 grep harfbuzz 无结果)。atlas 为行式 1024²(`renderer/text/atlas.rs:12`)。内置绘制范围:U+2500–259F、1FB00–1FB3B、1FB82–1FB8B、E0B0–E0B3(`builtin_font.rs:29-39`)。 + +### 2.3 WezTerm + +**Cell** [已核实](`wezterm-cell/src/lib.rs:719-729,1029-1036`) + +- `Cell = TeenyString(8) + CellAttributes(16) = 24 B`。 +- truecolor、下划线色、超链接都放进 `Box`。在 VecStorage 里每个 truecolor cell 各持有一个 Box,内存和分配成本都偏高 [推断]。 + +**ClusteredLine** [已核实](`wezterm-surface/src/line/clusterline.rs:18-42`;`line.rs:102-115,760-793,1047-1075`) + +```rust +struct Cluster { cell_width: u16, attrs: CellAttributes } +pub(crate) struct ClusteredLine { text: String, is_double_wide: Option>, clusters: Vec, len: u32, ... } +``` + +- 新行默认就是 C(clustered)形态,追加写入有快路径。 +- 随机写会把它展开成 `Vec`。 +- 行滚出屏幕顶部时调用 `compress_for_scrollback()` 压回 C 形态。 +- 这正是"冷行压实"的参考实现:普通 shell 输出每行只占"文本字节 + 每个属性游程约 24 B"。 + +**StableRowIndex 与 seqno** [已核实](`term/src/screen.rs:646-761`) + +- 普通滚动不改变行的 seqno 和 shape hash,所以缓存全部命中,只有新露出的行算脏。 +- GUI 每帧仍然遍历全部可见行,但走 5 层缓存: + 1. seqno → shape hash; + 2. line quad,键里包含像素 y,滚动后会失效; + 3. 整行 element; + 4. cluster shape; + 5. glyph。 + + 见 `termwindow/render/pane.rs:371-554`。 + +**回退字体异步解析** [已核实](`wezterm-font/src/lib.rs:193-203,520-557`) + +- 缺字的码点交给后台线程做系统字体查找,第一帧先画占位。 +- 找到后递增 `shape_generation`,清空 shaping 缓存。 +- 首帧不会被系统字体查找阻塞,这个做法值得借鉴。 + +**光标下的连字** [已核实](`screen_line.rs:546-620`):不拆连字,而是按光标和选区的像素区间把字形纹理切成最多 7 条竖条,每条用各自的颜色。 + +**默认回滚**:3500 行(`config/src/config.rs:1705-1716`)。 + +### 2.4 Windows Terminal(AtlasEngine) + +**ROW** [已核实](`src/buffer/out/Row.hpp:241-320`) + +- 文本是 UTF-16 `_chars` 加 `_charOffsets[cols+1]` 偏移表。宽字符的尾半格复用同一个偏移,并置最高位 `0x8000`。例如 `"a猫d"` 存为 `0 1 1 2 3`。 +- 属性按行 RLE。 +- `ROW::_init` 用 AVX2/SSE 批量填充。为此把数组补齐到 16B 对齐,注释原文:"wastes up to 5.8% memory but increases overall scrolling performance by around 40%"(`Row.hpp:101-122`)。 + +**内存** [已核实] + +- 实测注释(`textBuffer.hpp:383-387`):"120 columns = 80KB chunks, 5.6MB buffer at 9001 rows",折合每 cell 约 5.2 B。 +- 一次 `VirtualAlloc(MEM_RESERVE)` 预留,按需 commit(每次预读 128 行)。注释说 conhost 启动内存因此从约 7 MB 降到约 2 MB。 + +**AtlasEngine** + +主要设计 [已核实]: + +- **ShapedRow 缓存**:每行缓存 shaping 结果,只有失效行重新 shaping;滚动时只轮换行指针(`AtlasEngine.cpp:166-235`)。 +- **网格对齐**:shaping 后把 cluster 的 advance 强制对齐到网格(`:1173`): + ```cpp + _api.glyphAdvances[nextCluster - 1] += expectedAdvance - actualAdvance; + ``` +- **颜色位图**:背景色、前景色、下划线色各一张"每格 1 个 u32"的位图。背景只画**一个全屏 quad**,像素着色器按 `position / cellSize` 采样(`shader_ps.hlsl:38-44`)。D2D 回退路径则用 bitmap brush 做最近邻拉伸(`BackendD2D.cpp:188-207`)。 +- **选区**:直接写进颜色位图,没有叠加层(`AtlasEngine.cpp:410-484`)。 +- **atlas**:`stb_rect_pack` 分配,满了先 flush 再整张清空,没有 LRU。缓存键是 `IDWriteFontFace*` + glyphIndex + 行渲染方式(LineRendition),颜色不进键(`BackendD3D.cpp:1706-1721`;`BackendD3D.h:104-153`)。 +- **QuadInstance** 只有 20 字节。注释说调用量可达"每秒 5M 次",结论是"实例数据打包得越紧越好"(`BackendD3D.h:61-100`;`BackendD3D.cpp:964-1037`)。 +- **BuiltinGlyphs**:覆盖 U+2500–259F、E0B0–E0BF、1FB00–1FB94,每字符最多 4 条 32 位指令(`BuiltinGlyphs.h:13-30`)。 + +线程与帧节奏 [已核实]: + +- 渲染线程在锁内完成 shaping,锁外 Present(`renderer.cpp:402-468`)。 +- 锁是**公平的 ticket lock**(`src/inc/til/ticket_lock.h:10`),输出洪流下渲染线程不会饿死。 +- 交换链 `FLIP_SEQUENTIAL` + `SetMaximumFrameLatency(1)`,配合 frame latency waitable object(`AtlasEngine.r.cpp:352-536`)。 + +**Reflow** [已核实]:新建一个 TextBuffer,把旧内容流式复制进去,然后 swap(`textBuffer.cpp:2718-2957`)。整个过程同步执行,并持有终端锁。 + +### 2.5 xterm.js + +**BufferLine** [已核实] + +```ts +// xterm.js/src/common/buffer/BufferLine.ts:14-34 @ 991857a +// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21) +// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8) +// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8) +// src/common/input/WriteBuffer.ts:11-34 +DISCARD_WATERMARK = 50000000, // ~50 MB,超过直接 throw +WRITE_TIMEOUT_MS = 12, // 每片最多解析 12ms 后让出 +WRITE_BUFFER_LENGTH_THRESHOLD = 50 +``` + +**流控** [已核实](`WriteBuffer.ts:161-174,219-315`): + +- `write(data, callback)` 中的 callback 在该块**解析完成**时触发,生产者据此按高低水位暂停或恢复 PTY 读取。 +- 用户输入后的第一个 `write` 会同步立即解析,这是一项延迟优化。 + +**解析器** [已核实](`EscapeSequenceParser.ts:33-90,684-770`): + +- 转移表是 `Uint16Array`,下标 `state<<8|code`;所有 ≥0xA0 的码点归为同一列。 +- 有 EXE 快路径和 CSI 快路径。 +- 可打印字符做 4 路展开扫描,整段交给 print handler。 + +**WebGL 渲染器** [已核实]: + +- `RenderModel` 每格 4 个 u32;每格四个值都与上一帧相同就跳过。 +- glyph atlas 的**缓存键包含颜色**:字形连同背景色一起光栅化,以换取次像素效果,代价是 atlas 会膨胀。 +- 页大小 512,单页上限 4096²。页数达到上限时,挑 4 张合并成 1 张;凑不齐就全部清空(`TextureAtlas.ts:34-46,155-277`)。 + +**customGlyphs**(默认 true)[已核实]:覆盖 2500–259F、2800–28FF、E0A0–E0BF、EE00–EE0B、F5D0–F60D、1FB00–1FBFA(`CustomGlyphDefinitions.ts`)。 + +**可访问性** [已核实](`AccessibilityManager.ts:15-17,64-87,141-160`): + +- 屏幕外放一个 `role=list` 的 DOM,每行一个节点,再加一个 `aria-live="assertive"` 区域。 +- 每次最多播报 `MAX_ROWS_TO_READ = 20` 行,超出则提示 "too much output"。 +- 用户自己输入的字符不重复播报。 + +**默认值**:回滚 1000 行,`minimumContrastRatio = 1`(OptionsService.ts:34,44)。 + +### 2.6 foot(CPU 渲染加 damage tracking 的标杆) + +**为什么用 CPU 还能这么快** [已核实]: + +1. **cell 级跳过**:`if (cell->attrs.clean) return 0;`(`render.c:696-705`)。 +2. **行级多线程**:主线程把脏行号推入共享队列,worker 各自 pop,在指向**同一块共享内存**的各自的 `pixman_image` 上画,并各自累计 damage 区域(`render.c:3556-3588,2184-2300`)。worker 数默认等于 CPU 核数(`config.c:3627`)。 +3. **滚动不重画**:在 memmove 和 "SHM scrolling" 之间二选一(`render.c:1319-1430`)。后者预留 512 MB 虚拟空间,滚动只移动 buffer 的 offset,用 `fallocate(PUNCH_HOLE)` 释放前面的物理页(`shm.c:395-450,657-800`)。判断规则: + ```c + bool try_shm_scroll = shm_can_scroll(buf) && + (dmg->lines + dmg->region.start + (term->rows - dmg->region.end)) < term->rows / 2; + ``` +4. **Buffer age**:age==0 直接复用;age==1 只从上一帧拷贝"上一帧 dirty 减去本帧整行重画"的部分;空闲时由 worker 预拷(pre-apply damage)(`render.c:3198-3293,5408-5480`)。 +5. **延迟渲染合并输出**: + ```c + // foot/config.c:3652-3653 @ cb27717 + .delayed_render_lower_ns = 500000, /* 0.5ms */ + .delayed_render_upper_ns = 16666666 / 2, /* half a frame period (60Hz) */ + ``` + lower 定时器在每次收到 PTY 数据时重置,upper 只启动一次,任一到期就渲染。另外,只要还有挂起的 frame callback,就只把帧标为 pending,等回调来了再画(`render.c:5146-5218,4313-4359`)。 +6. **PGO**:`INSTALL.md:74-76`:"foot runs **much** faster when compiled with gcc instead of clang. A profile-guided gcc build can be more than twice as fast as a clang build." + +**其他实现细节** [已核实]: + +- **ASCII 快路径**是一个函数指针 `ascii_printer_fast`,开启 sixel、OSC 8、插入模式等任一特性时会切换到 generic 版本(`terminal.c:4112-4180`)。没有 SIMD。 +- **PTY 读取**:单线程,24 KiB 栈缓冲,每个事件最多读 10 次(`terminal.c:243-297`)。 +- **交互式 resize**:拖动期间只对一份快照做截断式 resize;停止 100 ms(`resize-delay-ms`)后,才做真正的 reflow 并发送 TIOCSWINSZ,期间暂停读取 PTY(`render.c:4379-4400`;`terminal.c:264-272`)。这对大回滚下的 resize 卡顿是很好的参考。 +- **box drawing 线宽**:`0.04pt × cell 对角线 × dpi/72`,heavy = 3 × light(`box-drawing.c:3346-3352`)。 + +**对 Skia CPU 路径的启示** [推断]: + +- 可以给同一块像素内存创建多个 `SKSurface`,按行并行绘制,行与行互不重叠。 +- foot 的 memfd punch-hole 技巧依赖 Linux 和 wl_shm,跨平台用不了。替代方案是"位图按 y 方向做成环形,提交时分两段绘制"。 +- 但 Avalonia 的 WriteableBitmap 每次修改都要整图快照并上传(见 §3.4),foot 的这些优化在 Avalonia 里只能拿到一半。 + +### 2.7 kitty + +**Cell** [已核实]:`CPUCell` 12 B + `GPUCell` 20 B,每格共 32 B(`line.h:12-100`)。默认回滚 2000 行;pager 历史以带 SGR 的 UTF-8 文本存放,默认 0(关闭),不压缩(`options/definition.py:524,746`)。 + +**SIMD** [已核实](`simd-string.c:15-130,343-430`) + +- 提供的函数:`utf8_decode_to_esc`、`find_either_of_two_bytes`、`printable_ascii_run_length` 等。 +- 通过 simde 编出 128 和 256 两版;x86 上另有 AVX-512 版。 +- 运行时分派。ARM64 直接用 avx2 版,由 simde 翻译成 NEON。 +- 可打印 ASCII 的写入:先用 `printable_ascii_run_length` 求出 run 长度,再按模板 memcpy 整块 cell(`screen.c:1360-1395`)。 +- 预取下一个历史写入槽,注释称能让批量文本吞吐提升约 35%(`history.c:333-345`)。 + +**IO 与调度** [已核实] + +- I/O 线程直接读进 parser 的 1 MiB 缓冲。 +- macOS 上一次读满 1 KiB 就继续读,最多 gather 到 64 KiB(`child-monitor.c:1748-1764`)。 +- 默认 `input_delay 3`、`repaint_delay 10`、`sync_to_monitor yes`(`definition.py:1491-1535`)。 + +**连字** [已核实]:`disable_ligatures` 可选 never/cursor/always,默认 never;cursor 模式下在光标处把 run 切成三段(`fonts.c:2135-2170`)。 + +**box drawing** [已核实]:在 `decorations.c:2363` 中用 C 实现,4× 超采样后下采样;覆盖范围比 foot 宽,包含 powerline。 + +**性能文档**(`docs/performance.rst:72-82`)[已核实]: + +- 测试环境:Ryzen 7 PRO 5850U,Linux/X11。 +- 方法:`kitten __benchmark__` 用 `\x1b[?2026h…l` 包住数据以**抑制渲染,只测解析**(`tools/cmd/benchmark/main.go:65-90`)。 +- 结果见 §2.9。 + +### 2.8 contour + +- **行存储** [已核实]:用 `LineSoA` 分层 SoA,空行不展开;`LineShape::Uniform`(整行同一样式、每格一个码点)时整行批量输出(`LineSoA.hpp:43-180`;`Line.hpp:52-80`)。 +- **双缓冲** [已核实]:`RenderDoubleBuffer` 交换时,终端线程(写端)对 `readerLock` **只做 `try_lock`**;渲染线程还在读时就放弃这次交换,绝不让渲染线程拖慢终端线程(`RenderBuffer.cpp:59-86`)。 + +**对本项目的意义** [推断]:这与本文推荐的"核心线程发布快照、渲染线程永不等锁"方向一致,只是持锁的一方相反。 + +--- + +### 2.9 公开的吞吐与延迟数据 + +标注说明:[已核实] 表示已抓取原页面核对过;[摘要] 表示只来自搜索摘要或二手转述,原文未核实。 + +#### 吞吐 + +**kitty `kitten __benchmark__`** + +- 来源:`kitty/docs/performance.rst:72-82 @ f426c08`,以及 https://sw.kovidgoyal.net/kitty/performance/ 。[已核实] +- 环境:Ryzen 7 PRO 5850U,Linux/X11。 +- 方法:用 2026 包住数据**抑制渲染**,所以只测解析。 +- 单位 MB/s,越高越好: + +| 终端 | ASCII | Unicode | CSI | Images | 平均 | +|---|---:|---:|---:|---:|---:| +| kitty 0.33 | 121.8 | 105.0 | 59.8 | 251.6 | 134.55 | +| gnome-terminal 3.50.1 | 33.4 | 55.0 | 16.1 | 142.8 | 61.83 | +| alacritty 0.13.1 | 43.1 | 46.5 | 32.5 | 94.1 | 54.05 | +| wezterm 20230712 | 16.4 | 26.0 | 11.1 | 140.5 | 48.5 | +| xterm 389 | 47.7 | 18.3 | 0.6 | 56.3 | 30.72 | + +**foot 的 vtebench 结果** + +- 来源:`foot/doc/benchmark.md @ cb27717`。[已核实] +- 日期 2022-05-12;环境 i9-9900,窗口 2040x1884;单位 ms,越低越好: + +| 测试 | foot GCC+PGO | foot | Alacritty 0.10.1 | XTerm 372 | +|---|---:|---:|---:|---:| +| cursor motion | 10.40 | 14.07 | 24.97 | 1622.86 | +| dense cells | 29.58 | 45.46 | 97.45 | 2323 | +| light cells | 4.34 | 4.40 | 12.84 | 49.81 | +| scrolling | 135.31 | 116.35 | 121.69 | 4041 | +| scrolling fullscreen | 5.70 | 5.66 | 10.92 | 124.25 | +| unicode | 11.56 | 10.92 | 15.94 | 4779 | + +**其他吞吐数据** + +| 终端 / 来源 | 数据 | +|---|---| +| **vtebench 本身** | [已核实] 只测 "the speed at which a terminal reads from the PTY",不测帧率和延迟。默认每个样本 ≥1 MiB、预热 1 次、最长 10 s。`dense_cells` 每格都输出 `\e[38;5;fg;48;5;bg;1;3;4m`。来源 https://github.com/alacritty/vtebench | +| **Ghostty devlog 006**(2024-02-12) | [已核实] SIMD ASCII 处理比标量快 7.32×,与 memcpy 基线持平(52.3 ms vs 52.7 ms);UTF-8→UTF-32 快 16.6×。`cat` 5.4 MB 日文文本:Ghostty 73 ms、Alacritty 66 ms、Terminal.app 124 ms、WezTerm 140 ms、kitty 392 ms、iTerm2 470 ms。作者自称这个测试 "very contrived"。来源 https://mitchellh.com/writing/ghostty-devlog-006 | +| **Ghostty 1.3.0**(2026-03-09) | [已核实] renderer 持锁时间降为 1/2 到 1/5,"in most frames, the renderer doesn't hold the lock at all"。来源 https://ghostty.org/docs/install/release-notes/1-3-0 | +| **Ghostty(第三方转述)** | [摘要] 150 MB ASCII `cat`:nightly 575 ms,1.3.2 版 1.5 s,Alacritty 1.2 s,Kitty 1.7 s(约 2026-07)。来源 https://digg.com/ai/tfu92ilx | +| **第三方横测** | [已核实,日期不明] 11 MB `cat`:foot 251 ms、kitty 401 ms、alacritty 404 ms、ghostty 407 ms、wezterm 1246 ms。来源 https://github.com/vizenc/terminal-benchmark | +| **Windows Terminal** | [已核实] AtlasEngine 相对旧引擎:纯文本约 2×,彩色 VT ≥10×(PR #11623,2021)。ConPTY 重写后 WT 吞吐约 20×(PR #17510,2024-08)。1.22 版纯文本 I/O 最高 16×。1.25 版重新启用 PGO,再快 10–20%(2026-03-05 博文) | +| **xterm.js** | [已核实] 吞吐 "rather low (5 - 35 MB/s)"。写缓冲硬上限 50 MB。建议水位 HIGH = 100000、LOW = 10000,HIGH 不宜超过 500 KB。来源 https://xtermjs.org/docs/guides/flowcontrol/ | + +#### 输入延迟 + +| 来源 | 方法 | 关键数字(ms) | +|---|---|---| +| beuke.org,2024-03 [已核实] | Typometer(纯软件,不含键盘和显示器),Ryzen 4750U,Arch + Xorg | min/max/avg:xterm 2.8/9.8/5.3;**alacritty 0.13.1 5.2/17.8/6.9**;kitty 调优后 8.1/16.3/10.7;**kitty 默认 11.5/34.4/23.8**;wezterm 11.3/40.9/26.1;gnome-terminal 29.0/32.3/30.2 | +| Zutty,2021-01 [已核实] | Typometer,60 Hz,无合成器 | mean:xterm 2.21;Zutty 6.51;Kitty 8.96;Alacritty 17.48;gnome-terminal 27.98 | +| vizenc [已核实,日期不明] | 高速摄像,从键灯亮到字符出现,含显示链路 | avg:foot 15.0;alacritty 16.7;kitty 18.3;wezterm 30.8;ghostty 38.3 | +| Chad Austin,2024-02 [已核实] | 240 Hz 摄像,Win10,4K@120 Hz | conhost 33.3;WezTerm 62.5–66.7;Alacritty 62.5;WT 1.18 66.7。作者后来补充 "WT 1.19 contains a fix that reduces latency by half" | +| Dan Luu,2017-07 [已核实] | 按键到屏幕截图,macOS 10.12 | idle p50:terminal.app 6,alacritty 31,iterm2 44 | + +**结论** [推断] + +1. 延迟的大头是**调度延迟**,不是渲染速度。kitty 默认的 `input_delay 3`/`repaint_delay 10` 让平均延迟达到 23.8 ms,调优后降到 10.7 ms。因此本设计的交互路径**不加任何合并定时器**。 +2. Windows 上的延迟主要由合成器、交换链和 vsync 链路决定。WT 用 `SetMaximumFrameLatency(1)` 加 waitable object 来压缩这一段。Avalonia 的交换链参数不是我们能改的,所以 Windows 端只能把"自身附加延迟"压到最低。 + +### 2.10 内存、默认回滚与 reflow 横向对比 + +| 终端 | 每 cell | 1 万行 × 120 列(推算) | 默认回滚 | 回滚上限计量 | 冷数据压缩 | reflow | +|---|---|---|---|---|---|---| +| Ghostty | 8 B,另有每行 8 B | ≈ 9.7 MB(按页触碰时提交) | 50 MB(≈7.2 万行 @80 列) | **字节** | LZ4,压到 10–30%(文档值) | 急切执行,O(全部回滚),并解压所有页;resize 合并 25 ms | +| libghostty-vt | 同上 | 同上 | **10 KB**(会被抬到最小页数) | 字节 | 有,但由嵌入方调度 | 同上 | +| Alacritty | ≤24 B | ≈ 28.8 MB | 10,000(上限 100,000) | 行 | 无 | O(行 × 列),并新建 Vec | +| WezTerm | 活动区 24 B;回滚为 clustered | 活动区 28.8 MB;回滚 ≈ 1–2 MB [推断] | 3,500 | 行 | 行压实(文本 + 游程) | `rewrap_lines` O(行) | +| Windows Terminal | ≈5.2 B(实测注释) | ≈ 6.2 MB | 9001 [推断,依据注释示例] | 行 | 无(只按需 commit) | 新建 buffer 后复制,持锁 | +| xterm.js | 12 B | ≈ 14.4 MB,另有 JS 对象开销 | 1,000 | 行 | 空闲时回收内存 | 原地执行,同步 | +| foot | 12 B | ≈ 14.4 MB | 1,000(行数向上取 2 的幂) | 行 | 无(行懒分配) | 新建 grid;拖动期间先截断,空闲 100 ms 后才真正 reflow | +| kitty | 32 B | ≈ 38.4 MB | 2,000,另有 pager 历史 0 MB | 行 | 无 | — | +| contour | ≈29 B | ≈ 35 MB(空行 O(1)) | — | 行 | 空行不展开 | — | + +**结论** [推断] + +- **用户心智与内存控制的折中**:用户习惯按"行"理解回滚,但按"字节"更能控制内存。建议配置项暴露为"行数",内部换算成页数上限;也可以像 Ghostty 一样同时提供两种上限。 +- **reflow 的代价都与回滚大小成正比,必须离开 UI 线程。** 具体做法: + - 采用 foot 的"交互期间截断,空闲后再 reflow",或采用 Ghostty 的 25 ms 合并; + - reflow 在核心线程完成后发布新快照,再发送 `TIOCSWINSZ`。 + +--- + +## 3. Avalonia 12.1.3 的绘制路径 + +### 3.0 先弄清 Avalonia 12 的渲染管线 [已核实] + +**UI 线程 `Control.Render` 只做录制** + +`Control.Render(DrawingContext)` 在 UI 线程上执行,但只是把绘制命令录成 opcode 流(`RenderDataStream`)。真正的光栅化发生在合成器的渲染线程上: + +```csharp +// Avalonia/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs:140-148 @ 12.1.3 +public override void Custom(ICustomDrawOperation custom) => Stream.DrawCustom(custom); +public override void DrawGlyphRun(IBrush? foreground, GlyphRun? glyphRun) +{ ... + Stream.DrawGlyphRun(foreground.GetServer(_compositor), glyphRun.PlatformImpl.Clone()); +} +// RenderDataStream.Replay.cs:57-58 —— 渲染线程回放 +public void OnDrawCustom(ICustomDrawOperation? operation) + => operation?.Render(new ImmediateDrawingContext(_context, false)); +``` + +**渲染线程与 UI 线程的关系** + +- 桌面平台的渲染定时器都在后台线程运行(`RunsInBackground => true`): + - Windows:`DxgiConnection` / `DirectCompositionConnection` / `WinUiCompositorConnection`; + - macOS:`AvaloniaNativeRenderTimer`; + - X11:`SleepLoopRenderTimer` / `DefaultRenderTimer`; + - Wayland:`WaylandWorker.RenderTimer`。 +- UI 到合成器**同一时间只有一个批次在途**(`MediaContext.Compositor.cs:64-82`,`CommitCompositorsWithThrottling`)。UI 线程卡住,新帧就提交不上去。 + +**脏矩形** + +- 合成器保留上一帧(layer),只重画脏矩形(`ServerCompositionTarget.cs:198-260`)。 +- 默认是 `SingleDirtyRectTracker`,即把所有脏区合并成**一个外接矩形**。要多矩形或 region 裁剪,必须在应用级打开 `CompositionOptions.UseRegionDirtyRectClipping`(`ServerCompositionTarget.cs:65-75`;`CompositionOptions.cs`)。Avalonia 12.1 博文也写明"多 dirty-rect 裁剪默认关闭(#21553)"。 + +**渲染后端** + +- 12.1.3 中实现 `IPlatformRenderInterface` 的只有 Skia(`src/Skia/Avalonia.Skia/PlatformRenderInterface.cs`)和 Headless 桩(`HeadlessPlatformRenderInterface.cs`,其中 `GetFeature` 一律返回 null,见 `:516`)。Direct2D 后端已在 12.0 移除。 +- 各平台默认渲染模式: + +| 平台 | 默认顺序 | 来源 | +|---|---|---| +| macOS | Metal → OpenGl → Software | `AvaloniaNativePlatformExtensions.cs:67-72` | +| Win32 | AngleEgl → Software | `Win32PlatformOptions.cs:118-121` | +| X11 | Glx → Software | `X11Platform.cs:410-414` | + +- 版本:SkiaSharp 3.119.4,HarfBuzzSharp 8.3.1.3(`Directory.Packages.props:14,57`)。 + +**跨线程限制** + +- 合成器服务端对象只能"under compositor lock"访问,也就是只能在渲染回调里访问(`ServerCompositor.cs:314-319`)。 +- `IRenderLoop.Wakeup()` 是 `internal`(`IRenderLoop.cs`),**非 UI 线程没有公开的方法唤醒空闲的渲染循环**,只能 `Dispatcher.UIThread.Post` 给 UI 线程,再提交一个批次。 + +### 3.1 四条路径对比 + +| | (a) `Control.Render` + `DrawGlyphRun` | (b) `ICustomDrawOperation` + Skia lease | (c) `CompositionCustomVisualHandler` | (d) `WriteableBitmap` CPU 光栅化 | +|---|---|---|---|---| +| 绘制发生在 | UI 线程录制,渲染线程回放 | UI 线程只录一个 op,渲染线程执行 `Render(ImmediateDrawingContext)` | **渲染线程** `OnRender` | 任意线程画进位图,UI 线程 `DrawImage` | +| 每帧需要 UI 线程吗 | 需要(`InvalidateVisual` → 重录) | 需要(`InvalidateVisual` → 新 op) | **活跃期不需要**(自轮询);只有从空闲唤醒时需要 | 需要(`InvalidateVisual`) | +| 局部重绘 | 整个控件都算脏 | 整个 op 的 Bounds 都算脏 | `Invalidate(Rect)` 加上 `RenderClipIntersectes` | 整个位图都算脏 | +| 文字 | `GlyphRun`,Skia 后端缓存 `SKTextBlob` | `SKTextBlob` / `DrawGlyphRun` 均可 | 均可 | Skia CPU 光栅 | +| 依赖的非稳定 API | 无 | `ISkiaSharpApiLeaseFeature` 是 `[Unstable]` | 只有用 lease 时才依赖 | 无 | +| 估算:200×60 网格、2× 缩放下每帧成本 [推断,需 spike] | UI 线程 50–150 µs 加脏行 GlyphRun 构建;渲染线程 0.3–1.5 ms | UI 线程约 10 µs;渲染线程同 (c) | UI 线程 0;渲染线程全量 0.3–1.5 ms,1–3 个脏行 0.05–0.3 ms | 光栅 3–10 ms,外加整图快照 24 MB 和 GPU 上传 2–5 ms | +| 实现复杂度 | 低(与 CodeView 同类) | 中 | 中高 | 中 | +| 主要风险 | 与 UI 线程抖动耦合:StreamMark 流式排版、GC、布局都会推迟终端帧 | 同 (a);没有 Skia 时 lease 为 null | 跨线程唤醒受限;需要自己管理 Size 和 DPI;API 文档不完整 | 带宽:每帧整图复制加上传 | + +### 3.2 (a) `Control.Render` + `DrawGlyphRun` [已核实 API] + +```csharp +// Avalonia.Base/Media/DrawingContext.cs:262, 442 +public abstract void DrawGlyphRun(IBrush? foreground, GlyphRun glyphRun); +public PushedState PushTextOptions(TextOptions textOptions); +// Avalonia.Base/Media/GlyphRun.cs:63-68 +public GlyphRun(GlyphTypeface glyphTypeface, double fontRenderingEmSize, ReadOnlyMemory characters, + IReadOnlyList glyphInfos, Point? baselineOrigin = null, int biDiLevel = 0) +// Media/TextFormatting/GlyphInfo.cs:8 +public readonly record struct GlyphInfo(ushort GlyphIndex, int GlyphCluster, double GlyphAdvance, Vector GlyphOffset = default) +``` + +**Skia 后端怎么画 GlyphRun**(`src/Skia/Avalonia.Skia/GlyphRunImpl.cs:22-131`): + +- 构造时取每个字形的 bounds(`SKFont.GetGlyphWidths`)。 +- 按 `TextOptions` 用 `TwoLevelCache` 缓存最多 3 种 `SKTextBlob`(`AllocatePositionedRun` + `SetPositions`)。 + +**做法** [推断] + +- 按"行 × 样式 run"缓存 `GlyphRun`,`GlyphInfo.GlyphAdvance` 固定为 cell 宽。 +- 每帧都要重新录制全部可见行,因为 Render 是全量替换,`ServerCompositionDrawListVisual.cs:36-39` 收到新的 render data 就 `InvalidateContent`。 +- 优点:只用稳定 API;Headless 绘制桩下也能运行;与 TCode 的 `CodeView`(`src/TCode.Desktop/Controls/CodeView.cs:69,313-370`,按行缓存 `TextLayout`)一脉相承,可以作为 MVP。 +- 缺点:终端帧率受制于 UI 线程。TCode 的 UI 线程同时承担 StreamMark 的流式 Markdown 渲染,Agent 大量输出时两者会争用。 + +### 3.3 (b) `ICustomDrawOperation` + `ISkiaSharpApiLeaseFeature` [已核实 API] + +```csharp +// Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs:8-31 +public interface ICustomDrawOperation : IEquatable, IDisposable +{ Rect Bounds { get; } bool HitTest(Point p); void Render(ImmediateDrawingContext context); } +// Skia/Avalonia.Skia/ISkiaSharpApiLeaseFeature.cs:8-28 +[Unstable] public interface ISkiaSharpApiLeaseFeature { ISkiaSharpApiLease Lease(); } +[Unstable] public interface ISkiaSharpApiLease : IDisposable +{ SKCanvas SkCanvas { get; } GRContext? GrContext { get; } SKSurface? SkSurface { get; } + double CurrentOpacity { get; } ISkiaSharpPlatformGraphicsApiLease? TryLeasePlatformGraphicsApi(); } +// Skia/Avalonia.Skia/DrawingContextImpl.cs:897-902 +public object? GetFeature(Type t) { if (t == typeof(ISkiaSharpApiLeaseFeature)) return new SkiaLeaseFeature(this); return null; } +``` + +**lease 的约束** + +- lease 期间如果调用其他 `ImmediateDrawingContext` 方法,会抛出 "The underlying graphics API is currently leased"(`DrawingContextImpl.cs:119-131,228-233`)。所以必须**先 Dispose lease,再调用 `DrawGlyphRun`**,二者不能交错。 +- Dispose 时会恢复 canvas 的矩阵(`:139-147`)。 +- GPU 模式下 `GRContext` 在整个 DrawingContext 生命周期内都被 `Monitor.Enter` 锁住(`:197-198`)。 + +**AOT** [推断,有强证据] + +- SkiaSharp 3.119.4 程序集带 `IsTrimmable` 元数据(在 nuget 缓存的 DLL 中能看到)。 +- TCode 已经引用 `StreamMark.Mermaid`(`src/TCode.Desktop/TCode.Desktop.csproj:24`,`PublishAot=true`),而它正是用 `ICustomDrawOperation` + `ISkiaSharpApiLeaseFeature` 画 SKPicture(`StreamMark/src/StreamMark.Mermaid/SkiaPictureDiagram.cs:90-130`)。在现有 AOT 0 警告的约束下,这条路已经走通。 + +**非 Skia 后端时的退化** + +- 唯一的非 Skia 情况是 Headless 的 `UseHeadlessDrawing=true`(默认值,见 `AvaloniaHeadlessPlatform.cs:115`),此时 `TryGetFeature` 返回 null。 +- 应对:要么什么都不画,要么回退到 (a)。 +- TCode 的测试用的是 `UseSkia()` + `UseHeadlessDrawing = false`(`tests/TCode.Desktop.Tests/HeadlessApp.cs:18-19`),lease 可用 [推断]。 + +### 3.4 (c) `CompositionCustomVisual` + `CompositionCustomVisualHandler`(推荐)[已核实 API] + +```csharp +// Avalonia.Base/Rendering/Composition/CompositionCustomVisualHandler.cs:10-111 @ 12.1.3(节选) +public abstract class CompositionCustomVisualHandler +{ + public virtual void OnMessage(object message) { } // 渲染线程:处理 UI 发来的消息 + public virtual void OnAnimationFrameUpdate() { } // 渲染线程:每个 tick 一次(需注册) + public abstract void OnRender(ImmediateDrawingContext drawingContext); + protected Vector EffectiveSize { get; } + protected TimeSpan CompositionNow { get; } // 合成器时钟,可驱动光标闪烁 + public virtual Rect GetRenderBounds(); + protected void Invalidate(); protected void Invalidate(Rect rc); // 局部脏区 + protected void RegisterForNextAnimationFrameUpdate(); + protected bool RenderClipIntersectes(Rect rc); // 原文拼写如此 +} +// Compositor.Factories.cs:38 +public CompositionCustomVisual CreateCustomVisual(CompositionCustomVisualHandler handler); +// CompositionCustomVisual.cs:21-29 —— UI 线程发消息,随下一个批次投递 +public void SendHandlerMessage(object message) { ... Compositor.RequestCompositionUpdate(OnCompositionUpdate); ... } +// ElementCompositionPreview.cs:21(类名 ElementComposition) +public static void SetElementChildVisual(Visual visual, CompositionVisual? compositionVisual); +``` + +**渲染线程上的 Skia lease**:`ServerCompositionCustomVisual.RenderCore` 用 `new ImmediateDrawingContext(ctx.Canvas, …)` 调用 `OnRender`(`ServerCustomCompositionVisual.cs:71-92`)。`CompositorDrawingContextProxy.GetFeature` 先 `Flush()`,再转交给底层的 Skia 上下文(`DrawingContextProxy.cs:293-297`),所以在 `OnRender` 里同样拿得到 `ISkiaSharpApiLeaseFeature`。 + +**线程模型与帧调度** [推断:设计;所用 API 已核实] + +1. UI 线程在 `OnAttachedToVisualTree` 里创建 visual,并在布局时同步 `visual.Size`(类型是 `Vector`,见 `composition-schema.xml:26`)。 +2. 核心线程每次发布新快照时,递增一个 `Volatile` 代数 `FrameGeneration`。 +3. handler 处于"活跃"状态时,每个 tick 在 `OnAnimationFrameUpdate` 里比较代数。有变化就 `Invalidate(dirtyRowsRect)`,然后再次 `RegisterForNextAnimationFrameUpdate()`。 +4. 连续 N 帧(例如 30 帧,约 250 ms)没有变化,就停止注册,渲染循环可以休眠。 +5. 从空闲唤醒的两种途径: + - 核心线程 `Dispatcher.UIThread.Post(() => visual.SendHandlerMessage(Wake))`; + - UI 线程在收到按键或 IME 事件时**预先**发送 `Wake`。这样按键回显的那一帧无需再绕一次 UI 线程。 +6. `OnRender` 取最新快照,只重建脏行的行缓存,绘制与裁剪区相交的行。 + +**风险** [推断] + +- `Invalidate(Rect)` 在默认的单脏矩形模式下会被合并成外接矩形。例如第 3 行和第 50 行同时变化,结果等于重画第 3 到 50 行。 +- `ImmediateDrawingContext` 没有 `PushTextOptions` / `PushRenderOptions`,文字 AA 模式只能用默认值,或者在 lease 里自己控制。 +- 官方文档提到的 `RequestNextFrameRendering()` 在 12.1.3 的 handler 中**不存在**(它属于 `OpenGlControlBase`)。文档的 Skia 示例还停留在 11.2 和 SkiaSharp 2.88,**以源码为准**。 + +### 3.5 (d) `WriteableBitmap` CPU 光栅化(foot 风格)[已核实 API] + +```csharp +// Avalonia.Base/Media/Imaging/WriteableBitmap.cs:24,67 +public WriteableBitmap(PixelSize size, Vector dpi, PixelFormat? format = null, AlphaFormat? alphaFormat = null); +public ILockedFramebuffer Lock(); +// Skia/Avalonia.Skia/WriteableBitmapImpl.cs:122-137,170-174,189-210 +public void Draw(DrawingContextImpl context, ...) { lock (_lock) { if (_image == null || !_imageValid) + { _image = GetSnapshot(); /* SKImage.FromPixels(...):整图拷贝 */ } context.Canvas.DrawImage(_image, ...); } } +// BitmapFramebuffer:构造时 Monitor.Enter(parent._lock);Dispose 时 _imageValid = false +``` + +**代价**:每次修改后,下一次绘制都要整图 `SKImage.FromPixels` 快照;在 GPU 后端还要再整图上传一次。`Lock()` 期间渲染线程的 `Draw` 也会被阻塞。 + +**估算** [推断]:3200×1900×4 B ≈ 24 MB/帧,60 fps 时约 1.5 GB/s 的拷贝加上传。 + +**缓解方法**: + +- 按行带分块,例如每 8 行一块 `WriteableBitmap`,只有脏块需要重新上传; +- 多线程按行光栅化,参考 foot; +- 在 Skia CPU 上直接用 `SKSurface.Create(info, framebuffer.Address, rowBytes)` 绘制。 + +**适用场景**:只适合"GPU 不可用"或"需要确定性像素输出"的场合。不推荐作为主路径。 + +### 3.6 其他可能但不推荐的路径 + +| 路径 | 不推荐的原因 | +|---|---| +| `OpenGlControlBase` | macOS 默认后端是 Metal,GL 互操作问题多 | +| `CompositionDrawingSurface.UpdateWithKeyedMutexAsync/…Semaphores…` 导入外部 GPU 图像 | 需要自己维护 D3D、Metal、Vulkan 三套渲染器(`CompositionDrawingSurface.cs:22-70`)。只有将来真的要做"Ghostty 式自有 GPU 渲染器"时才值得 | + +--- + +## 4. 字体与字形方案 + +### 4.1 等宽 cell 度量(设备像素对齐) + +**Avalonia 提供的度量 API** [已核实] + +- `GlyphTypeface.Metrics` 是 `FontMetrics`(设计单位):`DesignEmHeight`、`Ascent`(**负值**,在基线之上)、`Descent`(正值)、`LineGap`、`UnderlinePosition/Thickness`、`StrikethroughPosition/Thickness`、`IsFixedPitch`。其中 `LineSpacing = Descent - Ascent + LineGap`(`FontMetrics.cs:6-56`)。 +- 取 advance:`GlyphTypeface.CharacterToGlyphMap.GetGlyphs(...)` 加 `TryGetHorizontalGlyphAdvances(...)`(`CharacterToGlyphMap.cs:102`;`GlyphTypeface.cs:694`)。 + +**算法** [推断:照搬 Ghostty `Metrics.calc`,换算到 Avalonia 的符号约定] + +```csharp +// 示意代码:所有量先换算到"设备像素"再取整 +double px = fontSizePt * dpi / 72; // TCode 内部以 DIP 为准:px = fontSizeDip * renderScaling +double s = px / m.DesignEmHeight; +double faceW = MaxAdvance(gt, ' '..'~') * s; // Ghostty:ASCII 32..126 的最大 advance +double faceH = (m.Descent - m.Ascent + m.LineGap) * s; +int cellW = (int)Math.Round(faceW); // round(Ghostty),不用 ceil +int cellH = (int)Math.Round(faceH * lineHeightFactor); +double faceBaselineFromBottom = m.LineGap * s / 2 + m.Descent * s; +int baselineFromBottom = (int)Math.Round(faceBaselineFromBottom - (cellH - faceH) / 2); +int baselineY = cellH - baselineFromBottom; // 字形基线距 cell 顶部 +int underlineThick = Math.Max(1, (int)Math.Ceiling(m.UnderlineThickness * s)); +// 布局用 DIP:cellWDip = cellW / renderScaling(可以是分数),保证每条 cell 边界都落在整数设备像素上 +``` + +**适配要点** + +- 订阅 `TopLevel` 的缩放变化(RenderScaling 改变时),重新计算度量、清空行缓存和 sprite atlas。 +- 网格原点也要对齐到整数设备像素:在渲染线程用 lease 的 `SkCanvas.TotalMatrix`,或者从 UI 线程把 scaling 作为消息发给 handler。 + +### 4.2 逐字符字体回退 + +| 步骤 | Ghostty 的做法 | 在 Avalonia / TCode 中的实现 [推断] | +|---|---|---| +| 1 | `font-codepoint-map` 覆盖 | 配置项"码点范围 → 字体" | +| 2 | sprite face(程序化字符) | 先判断是否属于 §4.6 的范围 | +| 3 | 已加载字体,style 和 presentation 精确匹配 | 主字体 `CharacterToGlyphMap.TryGetGlyph`,再查已用过的回退字体 | +| 4 | 非 regular 时退回 regular | 同 | +| 5 | 系统发现,`size_adjustment = ic_width` | `FontManager.TryMatchCharacter(cp, style, weight, stretch, family, culture, out Typeface)`(`FontManager.cs:291-293`,Skia 实现最终调用 `SKFontManager.MatchCharacter`,见 `FontManagerImpl.cs:93`)。CJK 回退字体按"水"字的 advance 与 2×cellW 的比例调整 em 尺寸 | +| 6 | 最后用 U+FFFD | 同 | + +**缓存与异步** + +- 结果缓存为 `(codepoint, styleBits, presentation) → faceIndex`,即 Ghostty `SharedGrid.codepoints` 的 u64 键。 +- **系统字体查找放到后台**,参考 WezTerm:首帧先画占位方框,查到之后递增 `fontGeneration`,让含该码点的行缓存失效。这样渲染线程不会被 fontconfig 或 CoreText 的查询卡住。 +- 风险 [推断]:Avalonia 没有承诺 `FontManager` / `TextShaper` 在非 UI 线程上的线程安全(内部多用并发字典)。需要 spike 验证。如果不安全,就把字体解析和 shaping 放到专门的工作线程,渲染线程只消费结果。 + +### 4.3 宽字符与彩色 emoji + +- **宽度由核心决定**:wcwidth 表,或 mode 2027 grapheme 宽度。渲染器只负责把字形放进 1 格或 2 格。 +- **核心宽度表**:Avalonia 12.1 的 `Codepoint` 提供 `EastAsianWidthClass`、`IsEmoji`、`HasEmojiPresentation` 和 `GraphemeBreakClass`(`Codepoint.cs:64-96`)[已核实]。但终端需要与主流终端(Ghostty、xterm)一致的宽度语义,所以**建议自建三级查找表**,作为 `static ReadOnlySpan` 放进 RVA 数据段,对 AOT 友好。FFI 路线可以直接调用 `ghostty_unicode_codepoint_width/grapheme_width` [推断]。 +- **emoji**:遇到 VS16 或默认 emoji 呈现时,优先选彩色 emoji 字体(Apple Color Emoji / Segoe UI Emoji / Noto Color Emoji)。按 Ghostty 的约束,以 `cover` 方式居中放进 2 格,左右各留 2.5% 内边距(`SharedGrid.zig:416-432`)。Skia 会自动绘制 COLR、CBDT 和 sbix 格式的彩色字形 [推断]。 +- **宽字形溢出**:Nerd Font 图标这类宽于 1 格的字形,参考 Ghostty 的 `constraintWidth`:右邻格为空时允许占 2 格,否则按 `fit` 缩小(`cell.zig:250-291`)。 + +### 4.4 粗体、斜体与合成字形 + +- 优先使用字体族里真正的 Bold / Italic 字面,通过 `Typeface(family, style, weight)` 加 `FontManager.TryGetGlyphTypeface` 获取。 +- 没有对应字面时,用 `GlyphTypeface(IPlatformTypeface, FontSimulations)`。Skia 端的实现是 `SKFont.Embolden` 加 `skewX: -0.3f`,约 16.7°(`SkiaTypeface.cs:33-40`)[已核实]。 +- 作为对比,Ghostty 的斜切角度在 CoreText 上约为 15°,FreeType 上为 12°。 +- 可选"粗体用亮色"(bold-is-bright):把调色板 0–7 映射到 8–15。 + +### 4.5 连字与 shaping + +**API** [已核实] + +- `TextShaper.Current.ShapeText(ReadOnlyMemory, TextShaperOptions)` 返回 `ShapedBuffer`(`TextShaper.cs:43`)。 +- `TextShaperOptions` 的参数包括 `GlyphTypeface`、`FontRenderingEmSize`、`BidiLevel`、`Culture`、`IncrementalTabWidth`、`LetterSpacing` 和 `FontFeatures`(`TextShaperOptions.cs:32-61`)。 +- 注意 Avalonia 12 把 text shaper 从渲染后端中解耦了,必须调用 `UseHarfBuzz()`。TCode 已经调用了(`src/TCode.Desktop/SmokeMode.cs:56-57`)。 + +**策略** [推断,综合 Ghostty、kitty、WT 的做法] + +- **run 切分**:以下情况都要断开:样式变化(bg 除外)、face 变化、选区边界、**光标位置**(可配置)、spacer 格。 +- **强制网格对齐**:每个 cluster 的 x 设为 `col × cellW`,advance 只用于 cluster 内部的偏移(参见 Ghostty `harfbuzz.zig:131-300` 和 WT `AtlasEngine.cpp:1173`)。 +- **shaping 缓存**:键用 `hash(codepoints, 相对 cluster, faceIndex, featuresId)`,颜色不进键(参见 Ghostty `run.zig:262-287`)。 +- **快路径**:关闭连字,或者 run 中全是"不参与连字的字符"时,跳过 HarfBuzz,直接用 cmap 的 `GetGlyphs` 加固定 advance 构建 `GlyphInfo`。这是 ASCII 吞吐的关键。 +- **默认值**: + - Ghostty:默认开 `liga`;用户要关编程连字时配 `-calt`。 + - kitty:`disable_ligatures` 默认 `never`。 + - WezTerm:默认开 `kern/liga/clig`。 + - Alacritty:不支持连字。 + +### 4.6 程序化绘制的字符(保证像素对齐) + +**建议覆盖的范围**(取 Ghostty、WT、xterm.js、kitty 的并集): + +| 范围 | 内容 | 谁实现了 | +|---|---|---| +| U+2500–257F | Box Drawing | 全部 | +| U+2580–259F | Block Elements(包括 ░▒▓ 阴影) | 全部 | +| U+2800–28FF | Braille | Ghostty、xterm.js、WezTerm、foot、kitty | +| U+E0B0–E0BF(另加 E0D2、E0D4) | Powerline 几何类 | Ghostty、WT、xterm.js、WezTerm、kitty(foot 不支持) | +| U+1FB00–1FBEF | Symbols for Legacy Computing(sextant、wedge 等) | Ghostty、WT(到 1FB94)、xterm.js、kitty | +| U+1CD00–1CDE5 | Octants | Ghostty、foot、WezTerm、kitty | +| U+25E2–25E5、25F8–25FA、25FF | 几何三角 | Ghostty | +| U+EE00–EE0B | Fira Code 进度条和 spinner | xterm.js、WezTerm、kitty | +| U+F5D0–F60D | git 分支图(Kitty 扩展) | Ghostty、xterm.js、WezTerm、kitty | +| 非 Unicode | 下划线(single/double/curly/dotted/dashed)、删除线、上划线、光标(block/bar/underline/hollow) | Ghostty `special.zig` | + +**实现** [推断] + +- 用 SkiaSharp `SKPath` 和 `SKCanvas` 画到 **A8(Alpha8)** 位图。 +- 缓存键为 `(codepoint, cellW, cellH, thickness)`,打包进一张 sprite atlas(`SKImage`)。 +- 绘制时用 Alpha8 图像配合 paint 颜色着色(Skia 会把 alpha-only 图像按 paint color 着色),或者用 `SKCanvas.DrawAtlas` 带 colors 批量绘制。SkiaSharp 3.119.4 提供 `DrawAtlas`、`AllocateHorizontalRun`、`AllocatePositionedRun`(在程序集元数据中确认)。 + +**线宽与取整** [已核实:来源规则] + +- Ghostty:base = 下划线粗细(ceil 且 ≥1);light = base,heavy = 2 × base。 +- 分数坐标:`min = s - round((1-f)·s)`,`max = round(f·s)`,保证两半块拼接时无缝。 +- 盲文:点宽 = `min(w/4, h/8)`。 +- foot 的另一套规则:`0.04pt × 对角线 × dpi/72`,heavy = 3 × light。 +- 建议采用 Ghostty 规则,因为它与字体的下划线粗细一致。 + +### 4.7 下划线、删除线、上划线与超链接 + +- **位置和粗细**:来自 `FontMetrics.UnderlinePosition/Thickness` 和 `StrikethroughPosition/Thickness`(设计单位)。粗细 ceil 且 ≥1;位置 round。 + - Ghostty 的位置从 cell 顶部量:`top_to_baseline - underlinePosition`。 + - 字体没给度量时,用 Ghostty 的估算:下划线粗细 = 0.15 × ex height,删除线位于 `(ex + 粗细)/2`。 +- **样式**: + - double:上下各偏移一个粗细。 + - curly:Ghostty 用单周期贝塞尔曲线,振幅 `w/π`;kitty 用余弦曲线;foot 用三角波。 + - dotted:圆点。 + - dashed:每段 `w/3 + 1`。 + - 全部作为 sprite,按 cell 宽度平铺。 + - 下划线颜色来自 SGR 58。 +- **绘制顺序**:bg → 下划线 → 字形 → 删除线(Ghostty)。下划线压在字形之下,可以避开下行字母的视觉冲突。 +- **超链接(OSC 8)悬停**:Ghostty 的规则是,没有下划线的画单线,已有单下划线的改成双线。 + +### 4.8 光标、选区、最小对比度、暗淡与闪烁 + +| 特性 | 推荐做法 | 参照 | +|---|---|---| +| 光标形状 | block / bar(跨在两格之间,向左偏移 `(t+1)/2`)/ underline / hollow(失焦时);遵从 DECSCUSR | Ghostty `renderer/cursor.zig:35-67`;WT `_drawCursorBackground` | +| 光标下的文字 | block 光标所在格的文字改用 `cursor-text` 色,默认取背景色 | Ghostty `cell_text.v.glsl:134-141` | +| 光标颜色兜底 | 光标颜色与格子的对比度低于 1.5 时,改用反色 | Alacritty `display/content.rs:22,124-136` | +| 闪烁 | 每 600 ms 切换一次,有输出或输入时重置(节流 500 ms);失焦时不闪烁;由渲染线程的 `CompositionNow` 驱动 | Ghostty `Thread.zig:20`;`Termio.zig:714-725` | +| 选区 | **直接改格子颜色**,不用半透明叠加层:bg 用选区色,fg 按选区背景亮度自动取黑或白;没有配置选区色时 fg/bg 互换 | WT `AtlasEngine.cpp:333,410-484`;Ghostty `generic.zig:3112-3178` | +| 最小对比度 | 按 WCAG `(L1+0.05)/(L2+0.05)` 计算;不达标时 Ghostty 直接改成黑或白,xterm.js 每次调 10% 亮度迭代;按 `(fg,bg)` 对缓存;box、block、powerline 豁免;默认 1(关闭) | Ghostty `common.glsl:80-110`;xterm.js `Color.ts:296-384`;VS Code 取值范围 1–21 | +| faint(暗淡) | 前景 alpha 0.5,等价于在不透明背景上向背景色混合 50% | Ghostty `faint-opacity` 0.5;xterm.js `DIM_OPACITY = 0.5`;foot 用 `dim.amount 1.5` 混合 | +| 文字闪烁(SGR 5) | 可选实现:只有可见区域存在 blink 格时才启动 500 ms 定时器 | foot `terminal.c:511-512`;Ghostty 不渲染 | +| 背景 | 每行把相同 bg 合并成一次 `FillRectangle`;或者用 WT/Ghostty 的"每格 1 像素颜色位图 + 最近邻放大",一次 `DrawImage`(lease 里用 `SKSamplingOptions(SKFilterMode.Nearest)`)完成全部背景 | WT `shader_ps.hlsl:38-44`;`BackendD2D.cpp:188-207` | + +### 4.9 HiDPI、像素对齐与抗锯齿 + +- **对齐**:所有 cell 边界、基线、装饰线都放在整数设备像素上(§4.1)。如果用 `GlyphRun`,`BaselineOrigin` 也必须是整数设备像素。 +- **字形定位**:Avalonia Skia 的 `GlyphRunImpl` 默认 `SubpixelAntialias` + `Subpixel = true` + `BaselineSnap`(`GlyphRunImpl.cs:133-170`)。终端字形本来就按整数像素放置,"亚像素定位"带来的收益有限。 +- **LCD 子像素 AA**:要求背景不透明。TCode 如果用半透明或亚克力背景,就只能用灰度 AA。 + - Ghostty 一律用灰度 AA。 + - WT 默认 ClearType。 + - 建议默认灰度,不透明背景时可选 LCD [推断]。 +- **缩放变化**:重新光栅化 sprite,清空行缓存。Skia 自带的字形缓存会自动按新尺寸生成 [推断]。 + +--- + +## 5. 线程模型方案 + +### 5.1 三种模型对比 + +| 模型 | 代表 | 优点 | 缺点 | 放到 Avalonia 里 | +|---|---|---|---|---| +| **共享锁** | Ghostty(mutex + demand handoff)、Alacritty(FairMutex lease)、WT(公平 ticket lock) | 实现直接;渲染器能读到最新状态 | 必须解决锁饥饿;渲染侧要在锁内复制或 shaping | 渲染线程由**整个窗口共用**,在 `OnRender` 里等终端锁会拖慢全应用的帧。只有 try-lock 加"拿不到就沿用上一帧"才能接受 | +| **actor + 快照发布**(推荐) | contour(`RenderDoubleBuffer` try-lock 交换)、WezTerm(锁外解析产出 Action)、Ghostty RenderState 的思路 | 渲染线程**永不等锁**;核心状态单线程独占,没有数据竞争;libghostty-vt 本身也不是线程安全的,正好匹配 | 快照复制有开销,但只复制脏行;UI 发出的命令有一次线程跳转(约 10–50 µs) | 与 `CompositionCustomVisualHandler` 天然契合 | +| **单线程** | xterm.js(12 ms 时间片)、foot(epoll 单线程 + 渲染 worker) | 简单,没有锁 | UI 线程负担重;吞吐受限(xterm.js 只有 5–35 MB/s) | 不合适:TCode 的 UI 线程还要跑 Agent UI | + +### 5.2 推荐模型:每个终端两个线程,再加共用的渲染线程 [推断:设计] + +**读线程** + +- 阻塞读 PTY,从 4 块 × 64 KiB 的缓冲池取块。参数取自 Ghostty `Exec.zig:1306-1313`:buffer_count 小于 4 会有轻微变慢,64 KiB 既是一批解析量,也是一次持锁的工作量。 +- 池里没有空闲块时,读线程阻塞。PTY 内核缓冲随之填满,子进程被反压。**没有无界队列**,对比 xterm.js 是到 50 MB 才丢数据。 +- macOS 上 PTY 每次 read 只给约 1 KiB。读线程要循环读,直到 EAGAIN、满 64 KiB 或用完 3 ms 预算,参考 Ghostty `gather_budget_ns` 和 kitty `PTY_GATHER_MAX 64KiB`。这要求 fd 非阻塞,并用 `poll()`。`LibraryImport` 可以 AOT 安全地完成这些调用 [推断]。 + +**核心线程(actor)** + +- 独占 `ITerminalCore`,只等待一个信号,来源有两个:读线程送来的数据块,以及命令队列(resize、视口滚动、选区、按键编码、粘贴、搜索)。 +- 解析按批进行。每批之后检查渲染端设置的"想要帧"标志:设置了,并且 mode 2026 没有挂起,就**发布快照**。数据洪流期间,发布频率因此不超过 vsync。 +- 解析完当前所有输入、准备再次等待之前,只要状态是脏的,也发布一次。 +- effects(标题、铃声、OSC 52、通知、DA/DSR 应答)都在本线程同步产生。需要 UI 处理的投递给 UI 线程;应答直接写回 PTY。 +- **写 PTY**:由核心线程负责,或者另开一个写线程(参考 Ghostty `termio/Thread.zig`)。fd 用非阻塞,`EAGAIN` 时排队。UI 线程**从不**阻塞在 `write()` 上。 + +**渲染线程(Avalonia 共用)** + +- handler 在 `OnAnimationFrameUpdate` 里读 `Generation`,在 `OnRender` 里取最新快照。整个过程不加锁,只有 `Volatile` 读。 + +**UI 线程** + +负责输入、IME、选区手势、布局与 `Size`、AutomationPeer,以及从空闲状态唤醒渲染。 + +### 5.3 快照的数据结构与回收 + +```csharp +// 示意代码(推断:设计) +public sealed class FrameSnapshot +{ + public long Generation; + public int Cols, Rows; + public RowSnapshot[] RowsInView; // 未变化的行与上一快照共享同一对象,因此"只复制脏行" + public CursorState Cursor; // 位置、形状、是否可见、是否闪烁 + public TerminalColors Colors; // 调色板,已处理 reverse video + public TerminalModes Modes; // DECCKM、2004、1004、鼠标模式、kitty 键盘 flags,供 UI 线程编码按键 + public SelectionSpan? Selection; + public ScrollbarState Scrollbar; +} +public sealed class RowSnapshot +{ + public long RowId; public int Version; // RowId 跨滚动保持不变,相当于 Ghostty 的 Row.Id{serial,y} + public ulong[] Cells; // 快照自有的 8 B/格格式:码点 21、宽度 2、行内样式索引 16、grapheme 标志等 + public ResolvedStyle[] Styles; // 本行去重后的已解析样式:fg/bg RGB、flags、下划线样式/颜色、超链接 id + public GraphemeSideTable? Graphemes; // 稀疏表:col → 码点序列 + public bool SoftWrapped; +} +``` + +**回收**:用代际(epoch)方式。渲染线程开始使用某个快照时写入 `RenderEpoch`;核心线程回收 `RetiredAt < RenderEpoch` 的 RowSnapshot 和 FrameSnapshot,放回对象池。稳态下零分配。 + +**快照成本** [推断]: + +- 全脏时 200×60×8 B = 96 KB 的 memcpy,加上样式解析,量级是 10–30 µs。 +- 典型交互只有 1–3 行脏,量级是 1 µs。 + +**两种核心如何填充快照** [推断,FFI 部分基于已核实的 API]: + +| | 纯 C# 核心 | libghostty-vt | +|---|---|---| +| 脏信息来源 | 自有的 Row.dirty 和页脏位,参考 Ghostty | `ghostty_render_state_update(state, terminal)` 之后用 `row_iterator_next_dirty` | +| 取 cell | 直接复制 cell 和样式 | 每行一次 `ROW_DATA_CELLS_RAW` 拿整行 u64 视图(`render.h:446-460`)。位布局**不受 ABI 保护**,启动时从 `ghostty_type_json` 的清单解析位偏移。只在 style_id 变化时调用 `ROW_CELLS_DATA_STYLE`;grapheme 用 `GRAPHEMES_LEN/BUF` | +| 收尾 | — | `ghostty_render_state_clean` | +| 每帧 FFI 调用 | — | 约(脏行数 × 常数),不是(cell 数 × 常数)。`c-vt-render` 示例里逐 cell 迭代的写法在 .NET 里会有大约 4 万次 P/Invoke / 帧,必须避免 | + +### 5.4 帧调度与 mode 2026 [推断:设计;对照值已核实] + +**交互路径不加合并定时器。** 依据是 §2.9 的延迟数据:kitty 默认的 3/10 ms 延迟让平均延迟变为 23.8 ms,而调优后只有 10.7 ms。 + +- 按键时,UI 线程立刻 `SendHandlerMessage(Wake)`,渲染端进入"活跃轮询"。 +- 回显数据到达后,核心线程发布快照;下一个 vsync 渲染线程检测到代数变化,立即重画。 + +**洪流时**:渲染线程每个 tick 设置一次"想要帧",核心线程在批次之间响应。效果相当于 foot 的 upper 定时器,但不另设定时器。 + +**mode 2026**:持有期间核心线程不发布快照,超时 **1000 ms**(与 Ghostty、foot、xterm.js 一致)。各家超时对照:Alacritty(vte)150 ms 并限 2 MiB 缓冲;kitty 2000 ms;WT 最多等 100 ms;WezTerm 无超时。规范本身没有规定超时,只说 "won't be worse than having no synchronized output"。 + +**光标闪烁**:由渲染线程用 `CompositionNow` 计时,周期 600 ms(Ghostty)。有输出或输入时重置相位。失焦时停止闪烁,并让渲染循环进入休眠。 + +**空闲判定**:连续 30 帧无变化,就停止 `RegisterForNextAnimationFrameUpdate`。10 个空闲终端的 CPU 占用应接近 0(见基准测试计划 §9 场景 S10)。 + +### 5.5 Resize 与 reflow + +1. UI 线程只把新尺寸作为命令发给核心线程,并对连续的 resize 做合并:Ghostty 合并 25 ms,foot 在拖动期间先做截断,停 100 ms 后才真正 reflow。 +2. 核心线程执行 reflow,然后发布全脏快照,最后发送 `TIOCSWINSZ` 或 `ResizePseudoConsole`。 +3. reflow 期间暂停读取 PTY,参考 foot `terminal.c:264-272`。 + +libghostty-vt 的 reflow 会解压全部回滚页,大回滚下更需要合并(Ghostty `PageList.zig:1263-1267`)。 + +--- + +## 6. 输入、IME 与可访问性 + +### 6.1 KeyDown 与 TextInput 的分工 [API 已核实;策略为推断] + +`KeyEventArgs` 提供 `Key`(随键盘布局变化的虚拟键)、`PhysicalKey`(物理位置)、`KeySymbol`(该键会输出的字符)、`KeyModifiers` 和 `KeyDeviceType`(`KeyEventArgs.cs:34-75`)。实现 Kitty 键盘协议时,`PhysicalKey` 和 `KeySymbol` 都要用到。 + +| 输入 | 事件 | 处理方式 | +|---|---|---| +| 可打印字符,包括 AltGr、死键组合结果、IME 提交 | `TextInput` | 编码成 UTF-8 直接写入。Kitty 协议在 report-all-keys 模式下例外 | +| Ctrl + 字母或符号 | `KeyDown` | legacy 编码(0x01–0x1A,Ctrl+[ 为 ESC 等)或 Kitty CSI-u。**Win32 会丢弃 <0x20 的 WM_CHAR**,所以必须在 KeyDown 里编码 | +| Alt + 键 | `KeyDown` | 加 ESC 前缀。macOS 提供"Option 当 Alt"设置,可分左右(参考 Ghostty `macos-option-as-alt`) | +| 方向键、功能键、Home/End/PgUp/PgDn | `KeyDown` | 按 DECCKM / DECKPAM 模式编码 | +| 粘贴 | 命令 | 开启 bracketed paste(mode 2004)时包裹,并做安全检查(参考 libghostty `ghostty_paste_is_safe`) | +| 焦点进入/离开 | `GotFocus` / `LostFocus` | 开启 mode 1004 时上报 | + +**Win32 的关键细节** [已核实]: + +```csharp +// Avalonia/src/Windows/Avalonia.Win32/WindowImpl.AppWndProc.cs:261-275, 975-988 @ 12.1.3 +case WindowsMessage.WM_CHAR: + if (Imm32InputMethod.Current.IsComposing) break; // IME 组合期间不产生 TextInput + if (ToInt32(wParam) >= 32 && !_ignoreWmChar) { e = new RawTextInputEventArgs(...); } +... +if (message == WindowsMessage.WM_KEYDOWN) { + if (e is RawKeyEventArgs args && args.Key == Key.ImeProcessed) _ignoreWmChar = true; + else _ignoreWmChar = e.Handled; // KeyDown 被 Handled,随后的 WM_CHAR 就被吞掉 +} +``` + +由此得出的规则: + +1. KeyDown 里**只对自己编码了的键**设置 `Handled = true`,其余放行,让 TextInput 送达文本。 +2. `Key.ImeProcessed` 一律忽略。 +3. AltGr 在 Windows 上表现为 Ctrl+Alt。如果此时 `KeySymbol` 是可打印字符,就当作文本处理,不按 Ctrl+Alt 组合编码。 +4. 死键不处理,由平台在 TextInput 里送来组合后的字符。 + +### 6.2 IME(`TextInputMethodClient`)[API 已核实] + +```csharp +// Avalonia.Base/Input/TextInput/TextInputMethodClient.cs @ 12.1.3(节选) +public abstract class TextInputMethodClient { + public abstract Visual TextViewVisual { get; } + public abstract bool SupportsPreedit { get; } + public abstract bool SupportsSurroundingText { get; } + public abstract string SurroundingText { get; } + public abstract Rect CursorRectangle { get; } // 候选框定位 + public abstract TextSelection Selection { get; set; } + public virtual void SetPreeditText(string? preeditText, int? cursorPos); + protected virtual void RaiseCursorRectangleChanged(); // 另有 TextViewVisualChanged、SurroundingTextChanged、SelectionChanged、ResetRequested、InputPaneActivationRequested +} +// InputElement.cs:136-347:TextInputMethodClientRequested 路由事件,在事件参数里设置 e.Client +``` + +- 只有 `SupportsPreedit == true` 时,各平台才会把组合串交给客户端:Win32 见 `Imm32InputMethod.cs:275-285`,macOS 见 `AvaloniaNativeTextInputMethod.cs:141`;Linux 的 IBus/Fcitx(DBus)和 Wayland text-input-v3 也都支持。返回 false 时,Windows 上由 IMM 自己绘制组合窗口 [推断]。 +- 文档有出入:官方 Text Input 指南仍写着旧的 `ITextInputMethodClient` + `SetClient`,**以源码为准**。 + +**终端用的实现** [推断:设计] + +```csharp +sealed class TerminalImeClient(TerminalView view) : TextInputMethodClient +{ + public override Visual TextViewVisual => view; + public override bool SupportsPreedit => true; // 预编辑文字自己画在光标处 + public override bool SupportsSurroundingText => false; // 终端没有可编辑的上下文 + public override string SurroundingText => string.Empty; + public override Rect CursorRectangle => view.CursorCellRect(); // DIP,相对 TextViewVisual + public override TextSelection Selection { get => default; set { } } + public override void SetPreeditText(string? text, int? cursorPos) => view.SetPreedit(text, cursorPos); + public void OnCursorMoved() => RaiseCursorRectangleChanged(); // 光标格变化时触发,按帧节流 +} +// TerminalView 中:TextInputMethodClientRequested += (_, e) => e.Client = _ime; +// 提交的文本从 TextInput 事件到达 → 写入 PTY;预编辑期间不写 PTY +``` + +**预编辑的绘制**:参考 Ghostty。 + +- 从光标处开始,按 wcwidth 占格,覆盖在终端内容上方,加下划线。 +- 预编辑期间光标强制显示为 block(`renderer/cursor.zig:35-67`)。 +- 预编辑串保存在 UI 侧,以叠加层消息的形式发给 handler,不进入终端核心。 + +### 6.3 可访问性 [已核实现状;方案为推断] + +**Avalonia 12.1.3 现状** + +- 公开的 provider 只有 `IExpandCollapseProvider`、`IInvokeProvider`、`IRangeValueProvider`、`IScrollProvider`、`ISelectionProvider`、`ISelectionItemProvider`、`IToggleProvider`、`IValueProvider`、`IRootProvider`、`IEmbeddedRootProvider`(`src/Avalonia.Controls/Automation/Provider/`)。 +- **没有 `ITextProvider`**。`ITextProvider` / `ITextRangeProvider` 只是 Win32 互操作层的声明(`Avalonia.Win32.Automation/Interop/`)。 +- 连 `TextBoxAutomationPeer` 也只实现了 `IValueProvider`(`TextBoxAutomationPeer.cs:6`)。 +- **Live region 可用**:`AutomationProperties.LiveSetting`(`AutomationProperties.cs:239`)。在 Win32 上,peer 的 `Name` 变化且 LiveSetting 不为 Off 时,会触发 UIA `LiveRegionChanged`(`AutomationNode.cs:267-298`)。macOS 端会把 LiveSetting 透传出去(`AvnAutomationPeer.cs:65`)。Linux 端有 AT-SPI2 后端,其中包含 `AtSpiTextHandler`。 + +**参照系** + +- **xterm.js**:屏幕外放一个 `role=list` 的行 DOM,配合 `aria-live`,每次最多读 20 行。 +- **VS Code**:Accessible View(⌥F2 / Alt+F2)可以逐字、逐行浏览终端缓冲。依赖 shell integration 时,还能用 Go to Symbol 在命令之间跳转。 +- **Windows Terminal**:UIA 文本提供器,加 `UiaRenderer` 按渲染刷新率批量发送通知(spec #13666)。 +- **macOS Terminal**:VoiceOver 可以朗读窗口文字。 + +**方案(v1)** + +1. `TerminalAutomationPeer : ControlAutomationPeer` + - `ControlType = Document`,Name 设为标题。 + - 实现只读的 `IValueProvider`,Value 为视口文本(按需生成,限制长度)。 + - 实现 `IScrollProvider`,映射到回滚。 +2. **播报器**:一个隐藏子元素,`LiveSetting = Polite`。 + - 核心线程把"新完成的输出行"交给 UI 线程,UI 线程按 300–1000 ms 节流后更新 Name。超过 20 行时改为摘要提示,这是 xterm.js 的做法。 + - 用户自己输入的回显不播报,参考 xterm.js `_charsToConsume`。 +3. **可访问缓冲视图**:快捷键 Alt+F2,与 VS Code 一致。 + - 打开一个只读 `TextBox`,内容是回滚的纯文本。它自带平台文本无障碍,屏幕阅读器可以逐字、逐行浏览。 + - 有 shell integration(OSC 133)时,支持在命令之间跳转。 +4. 提供 `minimumContrastRatio` 设置,范围 1–21,默认 1。检测到屏幕阅读器时,自动启用播报。 +5. **长期**:要做完整的 UIA TextPattern 和 NSAccessibility 文本支持,就得向 Avalonia 贡献跨平台的 `ITextProvider`,或者写平台专用代码。见 §10 决策点 3。 + +--- + +## 7. 推荐架构 + +### 7.1 总体结构 + +``` + ┌──────────────── PTY(openpty / ConPTY)────────────────┐ + │ │ + [读线程] read()/poll,4×64 KiB 池(满即阻塞 = 背压;macOS 需 gather) [写] ← 核心线程(按键、粘贴、DA/DSR 应答) + │ Chunk + ▼ + [核心线程 = actor] 独占 ITerminalCore(ManagedCore 或 GhosttyVtCore) + · 按批(≤64 KiB)解析与执行 · 命令:resize / 视口滚动 / 选区 / 搜索 / 按键编码 + · effects → UI:标题 / 铃声 / OSC 52 / 通知 · mode 2026 hold(1 s 超时) + · 发布 FrameSnapshot:只复制脏行,未变行共享 → Volatile 交换 + Generation++ + │ ▲ Wake(只在从空闲唤醒时) + ▼ │ + [Avalonia 渲染线程] TerminalVisualHandler : CompositionCustomVisualHandler + · OnAnimationFrameUpdate:比较 Generation 或光标闪烁相位 → Invalidate(rect);空闲 30 帧后休眠 + · OnRender:RowCache(按 RowId+Version 和 fontGeneration)→ + ① 背景 / sprite / 下划线(Skia lease) ② 文字:ImmediateDrawingContext.DrawGlyphRun + ③ 删除线 / 光标 / 预编辑 / 选区(lease) + ▲ Size、缩放、主题、预编辑等消息(SendHandlerMessage) + [UI 线程] TerminalView : Control —— 布局、键盘/IME/鼠标、选区手势、AutomationPeer、可访问缓冲视图 +``` + +### 7.2 组件要点 + +**1. `ITerminalCore`**(两种实现共用一个接口)[推断:设计] + +```csharp +public interface ITerminalCore : IDisposable // 只允许核心线程调用 +{ + void Write(ReadOnlySpan data); + void Resize(int cols, int rows, int cellWidthPx, int cellHeightPx); + void ScrollViewport(in ViewportScroll request); + void UpdateSelection(in SelectionGesture gesture); string? GetSelectionText(); + int EncodeKey(in KeyInput key, Span destination); // legacy 或 Kitty 协议 + bool SynchronizedOutputHold { get; } // mode 2026 + void CaptureDirty(FrameSnapshotBuilder builder); // 只复制脏行,然后清除脏标记 + void CompactIdle(TimeSpan budget); // 冷页压实或压缩 +} +``` + +- **`ManagedCore`**: + - 页式存储,每页约 256–512 KB 的 `ulong` cell 加行头。页面放在 `NativeMemory.AlignedAlloc`,或者 pinned 的 `GC.AllocateUninitializedArray`,GC 不需要扫描。 + - 每页有自己的样式集(u16 id,带引用计数),grapheme 走旁路存储。 + - 行使用环形索引。 + - 冷页压实成"UTF-8 文本 + 样式游程"(WezTerm 式),或者用 `System.IO.Compression`(Brotli 或 Deflate 的 span API,AOT 安全)压缩。 +- **`GhosttyVtCore`**: + - 用 `[LibraryImport]` + `DisableRuntimeMarshalling` 声明调用,通过 NativeAOT `DirectPInvoke` 静态链接 `libghostty-vt.a`。zig 可以交叉编译到 6 个 RID [推断]。 + - 创建后立刻设置 `SCROLLBACK_MAX_BYTES`。 + - RENDER_HOLD 回调交给核心线程计时(1 s 超时)。 + - 空闲 250 ms 后调用 `ghostty_terminal_compress`。 + - 锁定一个 commit,并用一层薄适配器隔离 API 变化。 + - 生态现状: + - [摘要] 已有第三方 .NET 绑定,如 NuGet `LibGhostty.Net` 1.0.2、`DeBlasis.GhosttyVt`(deblasis/libghostty-vt-dotnet 有每日 CI 构建)。可以参考它们的构建脚本,但不建议直接依赖。 + - [已核实] 上游至今没有打版本 tag。2026-04 起 Ghostty 宣布迁出 GitHub,GitHub 上保留只读镜像,所以源码获取方式要预留调整空间。 + +**2. 解析快路径**(`ManagedCore`)[推断:设计;API 已核实存在于 .NET 10 参考程序集] + +```csharp +// Ground 状态:先找"非可打印 ASCII"的第一个位置(向量化),整段批量写入 +int run = input.IndexOfAnyExceptInRange((byte)0x20, (byte)0x7E); // -1 表示整段都是可打印 ASCII +if (run < 0) run = input.Length; +PrintAscii(input[..run]); // 有效列内:cells[x+i] = template | b[i](可以用 Vector256.Widen 分三级加宽到 ulong) +// 遇到 ≥0x80:Utf8 解码。.NET 没有 UTF-8→UTF-32 的批量 API, +// 逐个 Rune.DecodeFromUtf8(src, out Rune r, out int n)(返回 Done / NeedMoreData / InvalidData); +// 跨块截断时保留尾部字节(等价于 Utf8.ToUtf16 的 isFinalBlock:false) +// 遇到 ESC:CSI 快路径(ESC [ 参数 终止符)绕过通用状态机,参考 xterm.js 和 Ghostty +``` + +- Unicode 宽度:三级查找表,`static ReadOnlySpan` RVA 数据,≤0xFF 直接返回 1(参考 Ghostty)。 +- print 快路径的条件:非插入模式、wraparound 开启、目标格"简单"(没有 grapheme、没有超链接、不是宽字符)。参考 Ghostty `printSliceFast`。 + +**3. 渲染**:§3.4、§4。 + +**4. 输入、IME、可访问性**:§6。 + +### 7.3 数字目标 + +全部为 [推断:目标值]。"依据"一列是同类终端的已核实数据。 + +| 指标 | 目标 | 依据 / 说明 | +|---|---|---| +| 核心解析吞吐,ASCII,关闭渲染(2026 抑制) | ManagedCore ≥ 300 MB/s;GhosttyVtCore ≥ 500 MB/s | kitty 0.33:121.8 MB/s;Ghostty 的 SIMD ASCII 接近 memcpy | +| dense SGR(vtebench `dense_cells` 类) | ≥ 100 MB/s | kitty CSI:59.8 MB/s | +| Unicode / CJK | ≥ 100 MB/s | kitty Unicode:105 MB/s | +| 端到端 `cat`(开启渲染) | ≥ 关闭渲染时的 0.8 倍;150 MB ASCII ≤ 1 s | [摘要] Ghostty nightly 575 ms、Alacritty 1.2 s | +| 渲染线程每帧(200×60,2×) | 全量重画 p99 ≤ 2 ms;1–3 个脏行 ≤ 0.3 ms | 120 Hz 一帧 8.33 ms,并且要和应用其他内容共用 | +| UI 线程每帧 | ≤ 0.1 ms(只做输入和唤醒) | 每帧绘制不经过 UI 线程 | +| 快照发布 | 全脏 ≤ 50 µs;渲染线程等锁时间 = 0 | Ghostty 1.3:多数帧根本不持锁 | +| 输入延迟(自身附加) | KeyDown 到 Present 提交 p50 ≤ 1 ms,另加等待 vsync;p99 ≤ 1 帧 | — | +| 输入延迟(端到端 Typometer) | 平均值不超过同机 Alacritty 或 Ghostty + 2 ms | beuke:alacritty 平均 6.9 ms;xterm 5.3 ms | +| 回滚内存(每 1 万行 × 120 列) | 未压实 ≤ 10 MB;压实或压缩后 ≤ 3 MB | Ghostty 8 B/cell ≈ 9.7 MB;LZ4 后 10–30% | +| reflow | 1 万行 ≤ 15 ms;10 万行 ≤ 150 ms;在核心线程上执行,不阻塞 UI 和渲染 | 各家都是 O(回滚) 同步执行 | +| 空闲 CPU | 10 个空闲终端(光标闪烁中)合计 < 0.5% | 失焦时停止闪烁,渲染循环休眠 | +| GC | 解析和绘制热路径稳态零分配;洪流下 Gen0 ≤ 1 次/s | 对象池加代际回收 | + +--- + +## 8. 可选路线及取舍 + +### 8.1 渲染宿主路线 + +| 路线 | 渲染宿主 | 优点 | 缺点 | 定位 | +|---|---|---|---|---| +| **R1(推荐)** | (c) `CompositionCustomVisual`,文字用 `GlyphRun`,背景和 sprite 用 Skia lease | 帧与 UI 线程解耦;局部失效;延迟最低 | 复杂度最高;依赖 `[Unstable]` 的 lease;从空闲唤醒要绕 UI 线程;文档不全 | 正式版 | +| R2 | (b) `ICustomDrawOperation` | 绘制代码与 R1 共用一套 Painter;比 R1 简单 | 每帧经过 UI 线程 | 作为 R1 的回退开关 | +| R3(MVP) | (a) `Control.Render` + `GlyphRun` | 只用稳定 API;落地最快;与 CodeView 同构 | 帧率与 UI 线程耦合;整个控件重画 | 第一个可用版本,用来验证核心 | +| R4 | (d) CPU 光栅化到 `WriteableBitmap` | 像素输出确定;不依赖 GPU | 带宽开销大;HiDPI 下慢 | 只做软件兜底或截图 | +| R5 | 自有 GPU 渲染器(通过 `CompositionDrawingSurface` 导入 Metal/D3D/Vulkan 纹理) | 理论性能最高(Ghostty/WT 式实例化 quad) | 要写三套图形 API;AOT 和互操作都复杂 | 不建议 | + +**推进顺序** [推断]: + +1. 先写一套与宿主无关的 `TerminalPainter`。它的输入是快照加行缓存,输出是对 `ImmediateDrawingContext` 和 lease 的调用。 +2. 用 R3 跑通核心和渲染的正确性。 +3. 切换到 R1,并保留 R2 作为开关。 + +(a) 用的是 `DrawingContext`,(b)(c) 用的是 `ImmediateDrawingContext`。两者的 API 很接近,用一个薄接口就能统一。 + +### 8.2 次要取舍 + +| 议题 | 选项 | 建议 [推断] | +|---|---|---| +| 连字默认值 | 开(Ghostty、kitty、WezTerm)/ 关(Alacritty 不支持) | 默认**关**,保住 ASCII 快路径的吞吐;设置里提供"开启,并在光标处断开" | +| 文字抗锯齿 | 灰度(Ghostty)/ LCD(WT 默认 ClearType) | 默认灰度,与透明或亚克力背景兼容;背景不透明时可选 LCD | +| 背景绘制 | 每行 RLE `FillRectangle` / 每格 1 像素位图配最近邻放大(WT、Ghostty) | 先用 RLE(简单),基准测试显示是瓶颈时再换位图 | +| 回滚计量 | 行(多数终端)/ 字节(Ghostty) | 对外暴露行数,内部按页上限执行;默认 10,000 行(同 Alacritty) | +| 冷数据 | 不压缩 / 行压实(WezTerm)/ 通用压缩(Ghostty LZ4) | ManagedCore 先做行压实,可选 Brotli;GhosttyVtCore 用库自带的压缩 | +| 每个终端的线程数 | 读线程 + 核心线程(推荐)/ 合并成一个(Alacritty 式) | 分开:macOS 的 gather 和解析互不阻塞。终端很多时可以改成共享核心线程池(每个 actor 一个串行队列) | +| 程序化字形范围 | 最小集(2500–259F)/ 全集(§4.6) | 全集。只依赖路径绘制,工作量可控,可以参考 WT 每字符 4 条指令的编码,或 xterm.js 的路径定义 | +| SGR 5 闪烁 | 不渲染(Ghostty)/ 渲染(foot) | 可选实现,默认开,只在屏幕上有 blink 格时才计时 | + +--- + +## 9. 基准测试计划 + +### 9.1 工具与方法 + +- **微基准:BenchmarkDotNet**,`[MemoryDiagnoser]` 加 `[DisassemblyDiagnoser]`。 + - **JIT 和 NativeAOT 两种工具链都要跑**:产品发布的是 AOT 版本,而 AOT 没有动态 PGO。 + - AOT 的向量路径要单独核实。NativeAOT 默认指令集基线较低,`Vector256/512` 路径可能落到回退实现,需要按 RID 评估 `IlcInstructionSet` [推断]。 +- **数据集**(录制成字节文件,所有测试使用同一份): + - vtebench 的生成器:`dense_cells`、`light_cells`、`medium_cells`、`cursor_motion`、`scrolling*`、`unicode`、`sync_medium_cells`; + - Ghostty devlog 用过的 5.4 MB 日文文本; + - enwik8(WT 用过); + - 拼接的大源码文件(100 MB); + - `seq 1 10000000`; + - truecolor 渐变生成器; + - 用 `script` 或 asciinema 录制的 vim 滚动、htop/btop 60 s、emoji ZWJ 序列。 +- **端到端:在真实窗口内运行**。 + - 内置计数器用 `EventSource` 或 `EventCounters`(AOT 安全),记录:已解析字节、发布的快照数、渲染帧数、`OnRender` 耗时的 p50/p95/p99、快照耗时、空闲状态、GC 次数。 + - Avalonia 合成器自带 FPS 和帧时间叠加层(`Server/FpsCounter.cs`、`FrameTimeGraph.cs`),可以辅助观察。 +- **延迟**: + - Typometer:纯软件,可用于 macOS、Windows、X11,与 beuke.org 和 Zutty 的测法可比。 + - 内部分段时间戳:KeyDown → 写 PTY → 回显被解析 → 快照发布 → `OnRender` 结束。 + - 可选:240 fps 高速摄像抽查,参考 Chad Austin 和 vizenc 的方法。 +- **对照组**:同机运行 Ghostty、Alacritty、kitty、WezTerm,Windows 上加 WT,macOS 上加 Terminal.app 和 iTerm2。只比较相对值,不比较绝对值。 +- **Headless 回归**:用 TCode 现有的 `HeadlessApp`(`UseSkia` + `UseHeadlessDrawing=false`)做像素快照测试,覆盖 box drawing 拼接、光标形状、宽字符、emoji、下划线样式和 HiDPI 缩放。这种模式只测 CPU 侧的帧构建耗时,不代表 GPU 性能。 + +### 9.2 场景 + +| # | 场景 | 输入或命令 | 测量 | 通过线 | +|---|---|---|---|---| +| S1 | `cat` 大文件 | 100 MB ASCII 源码;150 MB 纯 ASCII | 核心吞吐(BDN,关闭渲染);端到端耗时(含渲染) | ≥ 300 MB/s;端到端 ≥ 0.8× | +| S2 | `seq` | `seq 1 10000000`(约 78 MB,行很短) | 每行开销:滚动、换行、整行清除 | ≥ 200 MB/s | +| S3 | 彩色密集 | vtebench `dense_cells`、`medium_cells`;truecolor 渐变 | SGR 解析和样式去重;页样式表溢出路径 | ≥ 100 MB/s,无分配 | +| S4 | 全屏刷新 | vim 滚动录制;htop/btop 运行 60 s;`sync_medium_cells` | 每帧脏行数、`OnRender` p99、mode 2026 下是否撕裂 | p99 ≤ 2 ms;无撕裂 | +| S5 | Unicode 和 emoji 密集 | 5.4 MB 日文文本;emoji ZWJ 序列和旗帜;组合字符 | 宽度表、grapheme 处理、回退字体首帧(不阻塞)、彩色字形 | ≥ 100 MB/s;回退查找不阻塞渲染 | +| S6 | 光标移动 | vtebench `cursor_motion`;`scrolling_*_region` | CSI 快路径;区域滚动 | 不低于 Alacritty 的同项结果 | +| S7 | resize 与 reflow | 回滚 1 万行和 10 万行,宽度在 80↔200 之间每 16 ms 切换一次,持续 5 s | reflow 耗时、UI 帧时间、PTY 暂停时长 | 1 万行 ≤ 15 ms,10 万行 ≤ 150 ms;UI 不掉帧 | +| S8 | 输入延迟 | Typometer:空闲时和后台有 `yes` 洪流时各测一次 | min/avg/max、自身附加分段 | 平均值 ≤ 对照组 + 2 ms | +| S9 | 内存 | 1 万、10 万行的纯文本、彩色、CJK 三类内容 | 托管堆、原生页、压实后的大小 | 每 1 万行 × 120 列 ≤ 10 MB,压实后 ≤ 3 MB | +| S10 | 空闲 | 10 个终端空闲,光标闪烁,持续 10 min | CPU、唤醒次数 | < 0.5% CPU | +| S11 | 背压 | `yes` 运行 30 s 后按 Ctrl-C(xterm.js 指南推荐的测法) | 内存是否有界;Ctrl-C 到提示符的时间 | 内存平稳;≤ 100 ms | +| S12 | 与 Agent UI 共存 | 终端洪流的同时,StreamMark 流式渲染长 Markdown | 两边的帧时间 | 终端帧率不受 UI 线程负载影响(R1 的核心卖点) | + +### 9.3 CI 门禁 + +- **每次 PR**:BenchmarkDotNet 的短作业(`--job short`)只跑 S1、S3、S5 的核心吞吐和快照成本,与基线 JSON 比较,回退超过 10% 即判失败(建议在固定硬件上运行)。 +- **每晚**:完整的 S1–S12。结果写入 `artifacts/bench/`,保留趋势。 + +--- + +## 10. 需要用户决策的点 + +1. **渲染宿主与非稳定 API**:选 R1(`CompositionCustomVisual` 渲染线程绘制)还是 R3(`Control.Render`,UI 线程驱动)? + - R1 帧与 UI 线程解耦,延迟和流畅度最好,但复杂度高,并依赖 `[Unstable]` 的 `ISkiaSharpApiLeaseFeature`。StreamMark 已经在用这个 API,Avalonia 小版本升级时可能需要跟进。 + - R3 简单、稳定,但 Agent 大量流式输出时,终端会和 UI 线程争用。 + - 建议:先做 R3 作为 MVP,再切换到 R1。 +2. **回滚的内存预算与压缩**: + - 默认回滚按行算(建议 1 万行)还是按字节算(Ghostty 默认 50 MB)? + - 是否在第一版就做冷页压实或压缩?压实后 1 万行约 1–3 MB,不压实约 10 MB。压实带来的代价是:搜索、reflow、视图回看时需要按需解压,代码复杂度也会上升。 + - 选 libghostty-vt 时,这项改为配置字节上限,再加压缩调度。 +3. **可访问性做到什么程度**:Avalonia 12.1.3 没有跨平台的 `ITextProvider`。 + - v1 方案:LiveSetting 播报 + 可访问缓冲视图(只读 TextBox)+ `IValueProvider`。 + - 完整方案:屏幕阅读器可以在终端里逐字导航(UIA TextPattern / NSAccessibility 文本)。这需要给 Avalonia 贡献补丁或写平台专用代码,工作量可能以周计。 + +另有一项与本路线相关,但应在核心选型报告中决策:纯 C# 核心还是 libghostty-vt。从本路线看,两种都能接到同一个快照接口上。差别在于: + +- libghostty-vt:吞吐、内存、压缩和键盘协议都更成熟,但 API 不稳定、需要原生构建,回滚默认值也要设置。 +- 纯 C# 核心:完全可控,但 VT 正确性和性能都要自己补。 + +--- + +## 11. 风险与待验证的 spike + +| # | 风险 | 验证方法 | +|---|---|---| +| 1 | `FontManager`、`TextShaper` 和 `GlyphRun.TryCreateImmutableGlyphRunReference` 在渲染线程上调用是否线程安全(Avalonia 没有承诺) | 在压力下从多线程调用,并比对结果;如果不安全,就把字体解析和 shaping 移到工作线程 | +| 2 | `CompositionCustomVisualHandler` 自轮询的开销,以及空闲休眠和唤醒的行为 | 跑 S10 和 S8 | +| 3 | 默认的单脏矩形合并下,局部重画的实际收益;开启 `UseRegionDirtyRectClipping` 的副作用 | 跑 S4,比较开关前后的结果 | +| 4 | GlyphRun 在各后端(Metal、ANGLE、GLX、Software)下的每帧成本 | 4 个后端 × 200×60 全量重画 | +| 5 | NativeAOT 下向量路径能否启用(指令集基线问题) | 跑 BDN 的 AOT 作业,并检查反汇编 | +| 6 | libghostty-vt 能否静态链接进 6 个 RID 的 AOT 产物,以及每帧的 FFI 成本 | 用 zig 交叉编译,配合 `DirectPInvoke`,再跑 S1 和 S4 | +| 7 | Headless 下 `CompositionCustomVisual` 能否参与 `CaptureRenderedFrame` | 写一个最小快照测试 | + +--- + +## 12. 来源列表 + +### 12.1 源码(本地浅克隆,只读) + +- **Ghostty** `@ b3dc3d7`: + - 页与 cell:`src/terminal/page.zig`、`PageList.zig`、`size.zig`;样式:`style.zig`、`ref_counted_set.zig`;`hyperlink.zig`;压缩与内存:`compress*.zig`、`mem.zig`。 + - 渲染状态与解析:`render.zig`、`stream.zig`、`Parser.zig`、`Terminal.zig`、`Screen.zig`、`modes.zig`;`src/simd/vt.zig`、`vt.cpp`、`index_of.zig`、`codepoint_width.zig`;`src/unicode/main.zig`。 + - IO 线程:`src/termio/Exec.zig`、`Termio.zig`、`Thread.zig`、`stream_handler.zig`。 + - 渲染器:`src/renderer/generic.zig`、`Thread.zig`、`State.zig`、`cell.zig`、`cursor.zig`、`size.zig`、`metal/shaders.zig`;着色器 `shaders/glsl/*.glsl`。 + - 字体:`src/font/Metrics.zig`、`Atlas.zig`、`SharedGrid.zig`、`SharedGridSet.zig`、`Collection.zig`、`CodepointResolver.zig`、`discovery.zig`、`shaper/run.zig`、`shaper/Cache.zig`、`shaper/harfbuzz.zig`、`face/coretext.zig`、`face/freetype.zig`、`sprite/Face.zig`、`sprite/draw/*.zig`。 + - 配置:`src/config/Config.zig`。 + - libghostty-vt:`include/ghostty/vt.h`、`include/ghostty/vt/{terminal,render,screen}.h`、`src/lib_vt.zig`、`example/c-vt-render/src/main.c`、`README.md`。 +- **Alacritty** `@ 5421470`:`alacritty_terminal/src/grid/{storage,mod,row,resize}.rs`、`term/{cell,mod}.rs`、`event_loop.rs`、`sync.rs`;`alacritty/src/display/{mod,damage,content}.rs`、`renderer/text/{atlas,glyph_cache,builtin_font,glsl3,gles2}.rs`、`config/scrolling.rs`、`event.rs`、`scheduler.rs`。 +- **vte** `@ abeae76`(0.15.0):`src/lib.rs:593-620`、`src/ansi.rs:36-42`。 +- **WezTerm** `@ e2a98e1`:`wezterm-cell/src/lib.rs`、`wezterm-surface/src/line/{line,storage,clusterline}.rs`、`cellcluster.rs`、`term/src/screen.rs`、`terminal.rs`、`terminalstate/performer.rs`、`wezterm-gui/src/termwindow/{mod.rs,render/*.rs}`、`glyphcache.rs`、`shapecache.rs`、`customglyph.rs`、`wezterm-font/src/{lib,shaper/harfbuzz}.rs`、`mux/src/{lib,localpane}.rs`、`config/src/config.rs`、`window/src/bitmaps/atlas.rs`。 +- **Windows Terminal** `@ 44b440b`: + - 缓冲区:`src/buffer/out/{Row.hpp,Row.cpp,textBuffer.hpp,textBuffer.cpp,TextAttribute.hpp,TextAttribute.cpp,TextColor.h}`。 + - AtlasEngine:`src/renderer/atlas/{README.md,AtlasEngine.cpp,AtlasEngine.r.cpp,BackendD3D.h,BackendD3D.cpp,BackendD2D.cpp,BuiltinGlyphs.h,BuiltinGlyphs.cpp,common.h,shader_ps.hlsl,dwrite_helpers.cpp}`。 + - 渲染线程:`src/renderer/base/{renderer.cpp,RenderEngineBase.cpp}`。 + - 解析与连接:`src/terminal/parser/stateMachine.cpp`、`src/types/utils.cpp`、`src/cascadia/TerminalConnection/ConptyConnection.cpp`、`src/cascadia/TerminalCore/Terminal.{hpp,cpp}`、`src/inc/til/ticket_lock.h`。 +- **xterm.js** `@ 991857a`:`src/common/buffer/{BufferLine,Constants,Buffer,BufferReflow}.ts`、`src/common/CircularList.ts`、`src/common/input/{WriteBuffer,TextDecoder}.ts`、`src/common/parser/EscapeSequenceParser.ts`、`src/common/InputHandler.ts`、`src/browser/services/RenderService.ts`、`RenderDebouncer.ts`、`src/browser/AccessibilityManager.ts`、`addons/addon-webgl/src/{WebglRenderer,GlyphRenderer,RectangleRenderer,RenderModel,TextureAtlas}.ts`、`customGlyphs/*`、`src/common/services/OptionsService.ts`。 +- **foot** `@ cb27717`:`terminal.h`、`terminal.c`、`grid.{h,c}`、`render.c`、`shm.c`、`box-drawing.c`、`composed.{h,c}`、`vt.c`、`csi.c`、`config.c`、`doc/benchmark.md`、`doc/foot.ini.5.scd`、`INSTALL.md`、`README.md`。 +- **kitty** `@ f426c08`:`kitty/{line.h,history.c,text-cache.c,line-buf.c,simd-string.c,simd-string-impl.h,child-monitor.c,vt-parser.c,screen.c,shaders.c,fonts.c,decorations.c}`、`kitty/shaders/cell.slang`、`kitty/options/definition.py`、`docs/performance.rst`、`tools/cmd/benchmark/main.go`。 +- **contour** `@ 8f3a4a4`:`src/vtbackend/grid/LineSoA.hpp`、`Line.hpp`、`CellProxy.hpp`、`render/RenderBuffer.{hpp,cpp}`、`Terminal.cpp`、`src/vtrasterizer/{TextureAtlas,TextRenderer,BoxDrawingRenderer}.*`。 +- **Avalonia** tag `12.1.3`(`8eeda4f`): + - Composition:`src/Avalonia.Base/Rendering/Composition/{CompositionCustomVisualHandler,CompositionCustomVisual,Compositor.Factories,ElementCompositionPreview,CompositionOptions,CompositionDrawingSurface}.cs`、`Server/{ServerCustomCompositionVisual,ServerCompositionTarget,ServerCompositionDrawListVisual,ServerCompositor,DrawingContextProxy}.cs`、`Drawing/{RenderDataDrawingContext,RenderDataStream.Replay}.cs`、`composition-schema.xml`。 + - 渲染循环与绘制:`src/Avalonia.Base/Rendering/{IRenderTimer,IRenderLoop,RenderLoop}.cs`、`SceneGraph/CustomDrawOperation.cs`;`src/Avalonia.Base/Media/{DrawingContext,ImmediateDrawingContext,GlyphRun,GlyphTypeface,FontMetrics,FontManager,TextOptions,FontSimulations,MediaContext.Clock,MediaContext.Compositor}.cs`、`Media/TextFormatting/{TextShaper,TextShaperOptions,ShapedBuffer,GlyphInfo}.cs`、`Media/TextFormatting/Unicode/Codepoint.cs`、`Media/Fonts/Tables/Cmap/CharacterToGlyphMap.cs`、`Media/Imaging/WriteableBitmap.cs`。 + - 输入:`src/Avalonia.Base/Input/{KeyEventArgs,InputElement}.cs`、`Input/TextInput/*.cs`。 + - Skia:`src/Skia/Avalonia.Skia/{ISkiaSharpApiLeaseFeature,DrawingContextImpl,GlyphRunImpl,WriteableBitmapImpl,SkiaTypeface,FontManagerImpl}.cs`。 + - 平台:`src/Windows/Avalonia.Win32/{WindowImpl.AppWndProc.cs,Input/Imm32InputMethod.cs,Win32PlatformOptions.cs}`、`src/Avalonia.Native/{AvaloniaNativeTextInputMethod,AvaloniaNativePlatformExtensions,AvnAutomationPeer}.cs`、`src/Avalonia.X11/X11Platform.cs`。 + - 可访问性:`src/Avalonia.Controls/Automation/{AutomationProperties.cs,Provider/*,Peers/*}`、`src/Windows/Avalonia.Win32.Automation/AutomationNode.cs`、`src/Avalonia.FreeDesktop.AtSpi/Handlers/*`。 + - Headless 与依赖版本:`src/Headless/Avalonia.Headless/{AvaloniaHeadlessPlatform,HeadlessPlatformRenderInterface}.cs`、`Directory.Packages.props`。 +- **TCode** `@ dfd3657`:`src/TCode.Desktop/Controls/CodeView.cs`、`src/TCode.Desktop/TCode.Desktop.csproj`、`src/TCode.Desktop/SmokeMode.cs`、`tests/TCode.Desktop.Tests/HeadlessApp.cs`。 +- **StreamMark** `@ 074f7ff`:`src/StreamMark.Mermaid/SkiaPictureDiagram.cs`、`src/StreamMark.Avalonia/Rendering/TextSegmentView.cs`、`src/StreamMark.Avalonia/Transcript/TranscriptPanel.cs`。 +- **.NET 10 参考程序集**(`Microsoft.NETCore.App.Ref/10.0.9` 的 XML 文档):`Utf8.{IsValid,ToUtf16,FromUtf16}`、`Ascii.{IsValid,ToUtf16}`、`SearchValues.Create(ReadOnlySpan)`、`MemoryExtensions.IndexOfAnyExcept(SearchValues)`、`IndexOfAnyExceptInRange`、`Rune.DecodeFromUtf8`、`Vector128.Widen/WidenLower`、`Vector512`。 +- **SkiaSharp 3.119.4**(nuget 缓存中 DLL 的元数据):`SKTextBlobBuilder.AllocateHorizontalRun/AllocatePositionedRun/AllocateRawPositionedRun`、`SKCanvas.DrawAtlas`、`IsTrimmable`。 + +### 12.2 网页(检索日期 2026-10-16) + +- vtebench:https://github.com/alacritty/vtebench +- Ghostty: + - Devlog 006(2024-02-12):https://mitchellh.com/writing/ghostty-devlog-006 + - "Everyone Should Know SIMD"(2026-07-22):https://mitchellh.com/writing/everyone-should-know-simd + - 1.3.0 release notes(2026-03-09):https://ghostty.org/docs/install/release-notes/1-3-0 + - libghostty-vt 文档:https://libghostty.tip.ghostty.org/ + - "Libghostty Is Coming"(2025-09-22):https://mitchellh.com/writing/libghostty-is-coming(路径按站点惯例推断,未逐字核对) + - "Ghostty Is Leaving GitHub"(2026-04-28):GitHub 上会保留只读镜像(据网页检索) +- 第三方横测:https://github.com/vizenc/terminal-benchmark +- [摘要] Ghostty 150 MB `cat` 转述:https://digg.com/ai/tfu92ilx +- kitty performance:https://sw.kovidgoyal.net/kitty/performance/ +- foot benchmark(2022-05-12):https://codeberg.org/dnkl/foot/raw/branch/master/doc/benchmark.md +- Windows Terminal: + - AtlasEngine PR #11623:https://github.com/microsoft/terminal/pull/11623 + - ConPTY PR #17510:https://github.com/microsoft/terminal/pull/17510 + - WT 1.22(2024-08-27):https://devblogs.microsoft.com/commandline/windows-terminal-preview-1-22-release + - WT 1.25(2026-03-05):https://devblogs.microsoft.com/commandline/windows-terminal-preview-1-25-release/ + - UIA 通知 spec:microsoft/terminal #13666(2022-11-03,作者 Carlos Zamora;具体 URL 未核实) +- xterm.js: + - flow control:https://xtermjs.org/docs/guides/flowcontrol/ + - screenReaderMode:https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/ +- 延迟测量: + - beuke.org(2024-03):https://beuke.org/terminal-latency/ + - Zutty(2021-01):https://tomscii.sig7.se/2021/01/Typing-latency-of-Zutty + - Zutty 吞吐(2020-12):https://tomscii.sig7.se/2020/12/A-totally-biased-comparison-of-Zutty + - Dan Luu(2017-07):https://danluu.com/term-latency/ + - Chad Austin(2024-02):https://chadaustin.me/2024/02/windows-terminal-latency/ +- Synchronized output 规范:https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md +- VS Code 无障碍:https://code.visualstudio.com/docs/configure/accessibility/accessibility +- macOS Terminal 与 VoiceOver:https://support.apple.com/guide/terminal/trml1020/mac +- Avalonia: + - custom rendering(2026-07-21):https://docs.avaloniaui.net/docs/graphics-animation/custom-rendering + - text input(2026-03-09):https://docs.avaloniaui.net/docs/input-interaction/text-input + - TextInputMethodClient API:https://docs.avaloniaui.net/api/avalonia/input/textinput/textinputmethodclient + - 12 breaking changes:https://docs.avaloniaui.net/docs/avalonia12-breaking-changes + - 12.0 博文(2026-04-07):https://avaloniaui.net/blog/avalonia-12 + - 12.1 博文(2026-07-08):https://avaloniaui.net/blog/release-12-1 + - accessibility:https://docs.avaloniaui.net/docs/app-development/accessibility +- .NET: + - Utf8:https://learn.microsoft.com/en-us/dotnet/api/system.text.unicode.utf8?view=net-10.0 + - Rune.DecodeFromUtf8:https://learn.microsoft.com/en-us/dotnet/api/system.text.rune.decodefromutf8?view=net-10.0 + - Ascii:https://learn.microsoft.com/en-us/dotnet/api/system.text.ascii?view=net-10.0 + - SearchValues:https://learn.microsoft.com/en-us/dotnet/api/system.buffers.searchvalues-1?view=net-10.0 + - Performance Improvements in .NET 10(2025-09-10):https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/ diff --git a/docs/terminal/research/05-platform-behavior-config.md b/docs/terminal/research/05-platform-behavior-config.md new file mode 100644 index 0000000..16c4568 --- /dev/null +++ b/docs/terminal/research/05-platform-behavior-config.md @@ -0,0 +1,1011 @@ +# 终端调研 05:与系统终端行为一致 + 用户配置 + +> 调研日期:2026-10-16。范围:默认 shell 的探测与启动、环境变量、shell 集成、键盘、鼠标与选择、粘贴安全、外观默认值、生命周期、可访问性,以及 `settings.json` 中 `terminal` 节的草案。 +> +> 标注:`[实]` = 已在源码、本机或官方文档中核实;`[推]` = 推断,实现前需要验证。源码引用的格式为 `前缀 路径:行号`,前缀与 commit 的对应关系见 §0。 + +## 0. 调研对象与版本 + +| 前缀 | 对象 | 版本 / commit | 说明 | +|---|---|---|---| +| VSC | microsoft/vscode | `4765bd3`(2026-10-16) | 终端相关目录的 sparse 克隆 | +| XT | xtermjs/xterm.js | `991857a`(2026-10-13) | VS Code 终端的内核 | +| WT | microsoft/terminal(Windows Terminal) | `44b440b`(2026-10-13) | | +| GH | ghostty-org/ghostty | `b3dc3d7`(2026-10-15) | | +| WZ | wezterm/wezterm | `e2a98e1`(2026-10-10) | | +| AL | alacritty/alacritty | `5421470`(2026-10-16) | | +| IT | gnachman/iTerm2 | `5794785`(v20261016-nightly,3.7) | | +| VTE | GNOME/vte | `a5c88ec`(2026-10-16) | GNOME Terminal 与 Ptyxis 的内核 | +| GT | GNOME/gnome-terminal | `1079728`(3.97.1,GTK4 版) | | +| PX | GNOME/ptyxis | `ecc97d3`(50.2) | Fedora / Ubuntu 新版的默认终端 | +| KO | KDE/konsole | `6ab4bb0`(2026-10-13) | | +| AV | AvaloniaUI/Avalonia | `8973430`(master,12.2.999) | TCode 当前用 12.1.3,最新稳定版 12.1.4 | +| LOGIN | apple-oss-distributions/system_cmds `login/login.c` | `408bba7453` | macOS 的 login(1) | +| local | 本机 macOS 26.6.2(25G83),Terminal.app 2.15(470.2) | | 只读命令:`ps`、`strings`、`plutil`、`cat /etc/*` | + +## 结论摘要 + +1. **默认 shell 取账户里登记的登录 shell。** 顺序为 `getpwuid()->pw_shell` → `$SHELL` → `/bin/sh`。系统终端(login(1)、VTE、Ptyxis)以及 WezTerm 和桌面启动的 Ghostty 都以 passwd 为准,因为 `chsh` 之后 `$SHELL` 可能过期。 +2. **按平台决定是否启动 login shell。** + - macOS 启动 **login shell**:Terminal.app、iTerm2、Ghostty、Alacritty 都借助 `/usr/bin/login`,VS Code 用 `-l` 参数。原因是从 Dock 启动的 GUI 程序只有 `PATH=/usr/bin:/bin:/usr/sbin:/sbin`,而 path_helper 和 Homebrew 的 PATH 只在 login shell 中设置。 + - Linux 启动**非 login 的交互式 shell**:GNOME Terminal、Ptyxis、Konsole、VS Code、Ghostty、Alacritty 都是这样,只有 WezTerm 例外。 + - Windows 跟随 Windows Terminal:默认用 PowerShell 7(按 WT 的规则挑选最佳安装),没有时用 Windows PowerShell。另外探测 cmd、WSL、Git Bash、VS 开发者命令行和 MSYS2/Cygwin。 +3. **环境变量。** + - 以 TCode 进程的原始环境为基础,不使用 Agent 的 PATH 快照。 + - 剔除 TCode 内部变量、启动器/桌面会话注入的变量、其他终端遗留的变量,以及 `SHLVL`。 + - 设置 `TERM=xterm-256color`(仅 Unix)、`COLORTERM=truecolor`、`TERM_PROGRAM=TCode` 和 `TERM_PROGRAM_VERSION`。 + - macOS 上 `LANG` 缺失时按**系统**区域生成(Terminal.app 和 iTerm2 的做法),而不是像 VS Code 那样按应用界面语言生成。按界面语言生成会让中文界面用户的命令输出也变成中文。 +4. **Shell 集成默认自动注入。** + - VS Code、Ghostty、kitty 默认注入;Windows Terminal、WezTerm、iTerm2 默认不注入。 + - 建议默认开启,注入方式:zsh 用 ZDOTDIR,bash 用 `--init-file` 模拟启动文件,fish 用 XDG_DATA_DIRS,pwsh 用 `-NoExit -Command`。 + - 协议用 OSC 133 + OSC 7,命令行文本用带 nonce 的 OSC 633;E。 + - 没有集成时,退化为 Windows Terminal 的"按回车即打标记"启发式。 +5. **键盘:哪些键归应用、哪些键交给 shell。** + - macOS:Cmd 组合键一律归应用(Terminal.app/iTerm2 的惯例),Ctrl 和 Option 交给 shell。 + - Windows/Linux:Ctrl+Shift 组合键归应用,单独的 Ctrl 和 Alt 交给 shell。 + - Windows:Ctrl+C 在有选区时复制,Ctrl+V 粘贴,这与 WT 新装时的默认一致。 + - **冲突:** TCode 的 ⌘K(命令面板)与 macOS 终端惯例的 ⌘K 清屏冲突。Win/Linux 上 TCode 的全局键 Ctrl+K/N/B 由窗口的 Tunnel 处理器拦截,会抢走 readline 的 kill-line、下一条历史、光标左移,需要用户决策(§4.6、§11)。 +6. **鼠标。** + - 右键:Windows 有选区时复制、否则粘贴;macOS 选中单词并弹出上下文菜单;Linux 弹出上下文菜单。 + - Linux 上选中的文本自动进入 PRIMARY,中键粘贴 PRIMARY。**Avalonia 只在 master 分支(预计 12.2)的 X11 后端上支持 PRIMARY**,TCode 当前用的 12.1.3 还没有。 +7. **粘贴。** + - 多行警告默认 `auto`:只在 bracketed paste 关闭且内容含换行时警告,WT、VS Code、Ghostty 都是这样。 + - 始终过滤 ESC 等控制字符,并把 LF 转成 CR;单行粘贴去掉末尾换行。 + - 拖放文件时按 shell 种类转义路径。 +8. **外观。** + - 字体、字号、光标闪烁跟随系统。macOS 用系统等宽字体 `.SF NS Mono`,回退 Menlo;Windows 用 Cascadia Mono 12pt;Linux 从 portal 读 `monospace-font-name`。 + - Avalonia 不提供光标闪烁频率和系统提示音 API,需要分平台 P/Invoke。 + - ANSI 调色板跟随 TCode 的明暗主题。 +9. **生命周期。** + - 关闭确认:Unix 上用 `tcgetpgrp(pty) ≠ shell` 判断是否有前台程序,再结合忽略列表决定是否确认。 + - shell 正常退出时关闭标签;非 0 退出时保留标签并显示退出码(对应 Terminal.app 的 "Close if the shell exited cleanly" 和 WT 的 `automatic`)。 + - 重启后恢复标签和 cwd,回滚内容可选恢复。 +10. **可访问性:** Avalonia 没有公开的 UIA Text pattern provider,只有 interop 定义。只能用 `LiveSetting` 播报新输出,再加一个 VS Code 那样的"可访问缓冲区"文本视图兜底。 + +--- + +## 1. 默认 shell 的探测与启动 + +### 1.1 macOS:系统终端怎么做 + +| 终端 | shell 从哪里来 | 启动命令 | login shell | 来源 | +|---|---|---|---|---| +| Terminal.app 2.15 | 目录服务。login(1) 按 `pw_shell` exec,并据此设置 `SHELL` | 进程树为 `Terminal → login -pf token → -zsh`。二进制中有 `-pf`、`-pfl`、`-pfq`、`-pfql` 和 `exec -la %@ %@` 等字符串,推测指定工作目录时用 `login -pfl /bin/bash -c "exec -la "` | 是(argv0 为 `-zsh`) | local `ps` / `strings` [实];参数含义 [推];LOGIN:954-955、1025-1053 [实] | +| Terminal.app 设置 "Shells open with" | "Default login shell" 或 "Command (complete path)" | 自定义命令同样经 login(1) 启动,因此仍是 login shell | 是 | https://support.apple.com/en-us/guide/terminal/trmlstrtup/2.15/mac/26 [实] | +| iTerm2 | OpenDirectory 的 `UserShell`(相当于 `dscl . read /Users/$USER UserShell`) | `/usr/bin/login -f[q]pl $USER /ShellLauncher --launch_shell`。ShellLauncher 以 argv0 `-` 调用 execvp;有 `~/.hushlogin` 时加 `q` | 是 | IT sources/Settings/Profiles/ITAddressBookMgr.m:782-811;sources/Tasks/shell_launcher.c:1-70;sources/System/iTermOpenDirectory.m:15-53 [实] | +| Ghostty | 从桌面启动时用 `getpwuid_r` 的 `pw_shell`;只在"像是从命令行启动"(`TERM_PROGRAM` 非空等)时才用 `$SHELL` | `/usr/bin/login [-q] -flp /bin/bash --noprofile --norc -c "exec -l "` | 是 | GH src/termio/Exec.zig:1883-1960;src/config/Config.zig:4817-4885 [实] | +| Alacritty | `$SHELL` → passwd | `/usr/bin/login -flp /bin/zsh -fc "exec -a - "`,有 `.hushlogin` 时用 `-qflp` | 是 | AL alacritty_terminal/src/tty/unix.rs:141-191 [实] | +| WezTerm | 启动时先 unset `SHELL`(`chsh` 后可能过期),再查 passwd | 不经过 login,只把 argv0 设为 `-` | 是 | WZ env-bootstrap/src/lib.rs:236-240;pty/src/cmdbuilder.rs:526-535 [实] | +| VS Code | `$SHELL` → `os.userInfo().shell` → `sh`;`/bin/false` 改为 `/bin/bash` | 默认 profile 中 zsh/bash/fish 带 `-l`;没有匹配 profile 时,zsh/bash 用 `--login` | 是 | VSC src/vs/base/node/shell.ts:29-62;src/vs/platform/terminal/common/terminalPlatformConfiguration.ts:287-311;src/vs/workbench/contrib/terminal/browser/terminalProfileResolverService.ts:238-270 [实] | + +**为什么 macOS 一定要启动 login shell** + +- **GUI 程序的环境很少。** 从 Dock 启动的程序(本机实测 VS Code 的进程环境)只有: + - `PATH=/usr/bin:/bin:/usr/sbin:/sbin`、`SHELL=/bin/zsh`; + - HOME、USER、LOGNAME、TMPDIR、SSH_AUTH_SOCK; + - `__CFBundleIdentifier`、`XPC_SERVICE_NAME`、`XPC_FLAGS`、`COMMAND_MODE`。 + + **没有 `LANG`,也没有 `TERM`** [实 local `ps -wwE`]。 +- **PATH 的补全只发生在 login shell 中。** + - `/etc/zprofile` 调用 `/usr/libexec/path_helper`。它把 `/etc/paths` 和 `/etc/paths.d/*` 排在前面,原有 PATH 中的条目移到末尾 [实 `man 8 path_helper` + 本机实测]。 + - zsh 只在 login shell 中读取 `/etc/zprofile` 和 `~/.zprofile`;Homebrew 推荐的 `eval "$(brew shellenv)"` 也写在 `~/.zprofile` 中。 +- **macOS 26 的 `/etc/zprofile` 还会补 LANG:** `if [ -z "$LANG" ]; then export LANG=C.UTF-8; fi` [实 local]。 +- **login(1) 的行为** [实 LOGIN 与 `man 1 login`]: + - `-p` 保留环境; + - 不带 `-l` 时 chdir 到 home(LOGIN:937-951); + - 按 `pw_shell` 设置 `SHELL`(LOGIN:954-955); + - PATH 只在未设置时才设为 `/usr/bin:/bin`(LOGIN:974-976); + - 检查 home 下的 `.hushlogin`(LOGIN:671-676),没有就打印 "Last login: …" 和 motd。 +- **Ghostty 选择 login(1) 而不是只改 argv0 的理由**(GH src/termio/Exec.zig:1883-1927): + - 只把 argv0 改成 `-zsh` 时,`getlogin()` 返回值不对,`SHELL` 也不会被设置; + - 用 `-l` 可以避免 login 切换到 home,再用 bash 的 `exec -l` 让目标 shell 成为 login shell; + - 用 bash 而不用 zsh 来 exec,是因为实测快约 2 倍。 +- **VS Code 的额外处理:** + - 启动时执行 `$SHELL -i -l -c` 解析一次 shell 环境(超时 10 秒,`application.shellEnvironmentResolutionTimeout`),终端从这个环境继承(VSC src/vs/platform/shell/node/shellEnv.ts:102-160)[实]; + - 因为 path_helper 会打乱顺序,另用 `VSCODE_PATH_PREFIX` 在启动文件执行后补回扩展要求的 PATH 前缀(VSC src/vs/platform/terminal/node/terminalEnvironment.ts:279-313)[实]。 + +### 1.2 Linux:系统终端怎么做 + +| 终端 | shell 从哪里来 | login shell | 来源 | +|---|---|---|---| +| VTE `vte_get_user_shell()` | 只用 `getpwuid(getuid())->pw_shell` | — | VTE src/vtegtk.cc:3226-3235 [实] | +| GNOME Terminal | 子进程环境中的 `$SHELL`(须可执行且非 setuid)→ `pw_shell` → 固定列表(bash/zsh/tcsh/ksh/csh/sh) | profile 键 `login-shell` 默认 `false`;为 true 时 argv0 前加 `-` | GT src/eggshell.cc:58-93;src/terminal-screen.cc:1849-1870 [实] | +| Ptyxis | ptyxis-agent 在**宿主机**上用 `getpwuid`。Flatpak 版通过 `flatpak-spawn --host` 把 agent 跑在宿主机上,注释原话是 "whatever our sandbox tells us is a lie" | `login-shell=false`;为 true 时追加 `-l`。因为要穿过容器和 Flatpak 层,无法控制 argv0 | PX agent/ptyxis-agent-impl.c:352-427;src/ptyxis-client.c:244-264、710-713;src/ptyxis-util.c:76-96 [实] | +| Konsole | profile 的 `Command` 取 `$SHELL`;运行时依次为 profile 指定的程序 → `pw_shell` → `$SHELL` → `/bin/sh` | 否 | KO src/profile/Profile.cpp:236-262;src/session/Session.cpp:509-535 [实] | +| Ghostty | 同 §1.1 | 否:`/bin/sh -c ""`,Flatpak 下加 `-l` | GH src/termio/Exec.zig:2009-2019 [实] | +| Alacritty | `$SHELL` → passwd | 否 | AL alacritty_terminal/src/tty/unix.rs:161-164 [实] | +| WezTerm | passwd | **是**(argv0 `-`),属于例外 | WZ pty/src/cmdbuilder.rs:526-535 [实] | +| VS Code | `$SHELL` → `userInfo()` | 否:Linux 的默认 profile 不带参数 | VSC terminalPlatformConfiguration.ts:341-362 [实] | + +**沙箱与打包格式** + +| 场景 | 系统终端/同类应用的做法 | 来源 | +|---|---|---| +| Flatpak | 用 `/.flatpak-info` 判断是否在 Flatpak 中。通过 `flatpak-spawn --host --env=K=V --directory= --forward-fd=N` 在宿主机上启动(需要 `talk-name=org.freedesktop.Flatpak`),环境变量要逐个显式传入。shell 用宿主机上的 `getent passwd` 获取(Ghostty、Ptyxis、Konsole 都是这样) | PX agent/ptyxis-run-context.c:1163-1216;GH src/os/passwd.zig:51-80;KO src/session/Session.cpp:467-487 [实] | +| VS Code Flatpak 版 | 让用户手动配置 profile:`/usr/bin/flatpak-spawn --host --env=TERM=xterm-256color bash` | https://github.com/flathub/com.visualstudio.code [实] | +| Snap | VS Code 剔除 `SNAP*`;WezTerm 剔除 `SNAP*` 和 `LD_LIBRARY_PATH`;Ghostty 过滤掉 snap 路径 | VSC src/vs/base/common/processes.ts:109-114;WZ env-bootstrap/src/lib.rs:14-51;GH src/apprt/gtk/class/surface.zig:1606-1636 [实] | +| AppImage | WezTerm 剔除 `APPIMAGE`、`APPDIR`、`OWD`、`ARGV0` | WZ env-bootstrap/src/lib.rs:14-51 [实] | + +**工作目录** + +- GNOME Terminal 新开窗口时用 home(GT src/terminal-screen.cc:1389-1392)。 +- 新标签继承当前目录时,GNOME Terminal 和 Ptyxis **只用 OSC 7** 的结果(`preserve-working-directory='safe'`,只在前台是 shell 时才继承);Konsole 先用 OSC 7,没有时读 `/proc//cwd`(GT src/terminal-screen.cc:1783-1801;PX src/ptyxis-client.c:723-748;KO src/session/Session.cpp:300-311)[实]。 + +### 1.3 Windows:系统终端与 VS Code 怎么做 + +**系统默认终端。** Windows 11 22H2 起,Windows Terminal 是系统默认的命令行宿主:所有命令行程序都在 WT 中打开(https://devblogs.microsoft.com/commandline/windows-terminal-is-now-the-default-in-windows-11/ ,2022-10-18)[实]。 + +**Windows Terminal 的默认 profile** + +- `defaults.json` 中写的是 Windows PowerShell `{61c54bbd-…}`(WT src/cascadia/TerminalSettingsModel/defaults.json:3)。 +- 但首次运行时,WT 会把 `defaultProfile` 改为生成的 "PowerShell",即最佳的 pwsh 安装;没有 pwsh 才回退 Windows PowerShell(CascadiaSettingsSerialization.cpp:274-295、1264-1289)[实]。 +- **结论:新装的 WT 在装有 pwsh 时默认用 pwsh。** + +**Windows Terminal 的动态 profile 生成器** + +生成顺序为 PowerShellCore → WSL → Azure → VisualStudio → SSH(CascadiaSettingsSerialization.cpp:222-229)[实]。 + +| 生成器 | 如何发现 | 命令行 | 起始目录 | 来源(TSM = src/cascadia/TerminalSettingsModel) | +|---|---|---|---|---| +| PowerShell 7 | `%ProgramFiles%\PowerShell\\pwsh.exe`(含 x86/ARM 目录);Store/MSIX 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\Microsoft.PowerShell[Preview]_8wekyb3d8bbwe\pwsh.exe`;`%USERPROFILE%\.dotnet\tools\pwsh.exe`;`%USERPROFILE%\scoop\shims\pwsh.exe`。**排名**:主版本高者优先;同版本时 Store > Scoop > dotnet > 传统安装 > WOW > Preview。第一名命名为 "PowerShell" | `"<路径>\pwsh.exe"` | `%USERPROFILE%` | TSM/PowershellCoreProfileGenerator.cpp:52-135、150-346 [实] | +| Windows PowerShell | 静态条目 | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` | `%USERPROFILE%` | TSM/defaults.json:38-56 [实] | +| cmd | 静态条目 | `%SystemRoot%\System32\cmd.exe` | `%USERPROFILE%` | TSM/defaults.json:57-75 [实] | +| WSL | 读注册表 `HKCU\…\Lxss\{guid}\DistributionName`,**不运行 wsl.exe**。跳过 `docker-desktop*`、`rancher-desktop*` 和 `Modern=1` 的发行版 | `wsl.exe -d ` | `~`(build ≥ 19041) | TSM/WslDistroGenerator.cpp:19-101、205-209 [实] | +| Azure Cloud Shell | 只在官方构建(ClientID 非 "0")中启用 | `connectionType` | — | src/cascadia/TerminalConnection/AzureConnection.cpp:73-76 [实] | +| VS Developer Command Prompt / PowerShell | 通过 COM 接口 `ISetupConfiguration2`(vswhere 的底层)发现;旧版本实例默认隐藏 | DevCmd:`cmd.exe /k "\Common7\Tools\VsDevCmd.bat" -startdir=none -arch=x64 -host_arch=x64`;DevShell:`pwsh/powershell -NoExit -Command "&{Import-Module …DevShell.dll; Enter-VsDevShell -SkipAutomaticLocation …}"` | VS 安装目录 | TSM/VsSetupConfiguration.cpp:13-30;VsDevCmdGenerator.cpp:39-67;VsDevShellGenerator.cpp:11-89 [实] | +| SSH | 在 OpenSSH 目录中找 ssh.exe;解析 `%ProgramData%\ssh\ssh_config` 和 `~\.ssh\config`,每个带 HostName 的 Host 生成一个 "SSH - " profile | `"" ` | 继承 | TSM/SshHostGenerator.cpp:13-167;src/features.xml:105-111 [实] | +| Git Bash | **没有内置生成器**。Git for Windows 自己安装 JSON fragment | — | — | [实](grep 无结果)/ fragment [推] | + +**VS Code 的探测逻辑** + +- **默认 shell:** 第一个找到的 PowerShell(VSC src/vs/base/node/shell.ts:65-70)。查找顺序(VSC src/vs/base/node/powershell.ts:250-305)[实]: + 1. `ProgramFiles\PowerShell\\pwsh.exe`; + 2. 另一位数的 Program Files; + 3. MSIX; + 4. `~\.dotnet\tools`; + 5. Preview(同样依次查 Program Files、MSIX、另一位数); + 6. scoop(`~\scoop\apps\pwsh\current`); + 7. Windows PowerShell。 + +| 自动探测的 profile | 路径 / 参数 | 来源(VSC src/vs/platform/terminal/node/terminalProfiles.ts) | +|---|---|---| +| PowerShell | 按上述顺序 | :96-101、338-345 | +| Windows PowerShell | `%windir%\System32\…\powershell.exe`;32 位进程运行在 64 位系统上时用 `Sysnative`(否则 PSReadLine 不可用) | :80-85、102-106 | +| Git Bash | 先找 PATH 上的 `git.exe`,取其上两级目录;再查 `ProgramW6432`、`ProgramFiles`、`ProgramFiles(X86)`、`LocalAppData\Program`。每个候选目录依次试 `Git\bin\bash.exe`、`Git\usr\bin\bash.exe`、`usr\bin\bash.exe`;最后查 scoop 的 `git` / `git-with-openssh`。参数 `--login -i` | :107-111、286-336 | +| Command Prompt | `System32\cmd.exe` | :112-116 | +| Cygwin | `%HOMEDRIVE%\cygwin64\bin\bash.exe`、`%HOMEDRIVE%\cygwin\bin\bash.exe`,参数 `--login`,**标记为 unsafe**(普通用户可以在 C:\ 下创建这些目录,首次使用前要确认) | :117-124 | +| MSYS2 | `%HOMEDRIVE%\msys64\usr\bin\bash.exe`,参数 `--login -i`,环境 `CHERE_INVOKING=1`(保持当前目录),unsafe | :125-134 | +| Cmder | `cmd.exe /K %CMDER_ROOT%\vendor\bin\vscode_init.cmd` | :135-142 | +| WSL | 要求 build ≥ 19041;运行 `wsl.exe -l -q`(强制 UTF-16LE,`WSL_UTF8=0`,超时 1 秒),跳过 `docker-desktop*`;参数 `-d ` | :87-89、347-389 | +| Unix 平台 | 读 `/etc/shells`,每行一个 profile | :401-440 | + +VS Code 还单独提供 **`terminal.integrated.agentHostProfile.*`**:AI Agent 工具使用的 shell 可以与交互终端分开配置(VSC terminalPlatformConfiguration.ts:171-192)[实]。这与 TCode 的现状一致:Agent 的 `ShellResolver` 在 Windows 上优先 Git Bash,交互终端则应跟随系统。 + +### 1.4 VS Code 在各平台的默认 shell 与启动参数(汇总) + +| 平台 | 可执行文件 | 参数 | 注入 shell 集成后的参数 | +|---|---|---|---| +| macOS | `$SHELL` | zsh/bash/fish 用 `-l` | zsh:`-il` 并设置 ZDOTDIR;bash:`--init-file <脚本>` 并设 `VSCODE_SHELL_LOGIN=1`(由脚本模拟 login);fish:`-l --init-command 'source …'`;pwsh:`-l -noexit -command '. …'` | +| Linux | `$SHELL` | 无 | zsh:`-i` 并设置 ZDOTDIR;bash:`--init-file`;fish:`--init-command`;pwsh:`-noexit -command` | +| Windows | 第一个 PowerShell | 无 | pwsh/powershell:`-noexit -command 'try { . "<脚本>" } catch {}'`;Git Bash:`--init-file` | + +来源:VSC src/vs/platform/terminal/node/terminalEnvironment.ts:52-277、327-368 [实]。如果用户自己写了无法识别的参数,就不注入(返回 `UnsupportedArgs`)。 + +### 1.5 TCode 建议默认 + +| 项 | 建议 | 依据 | +|---|---|---| +| Unix 上的 shell 来源 | `getpwuid(getuid())->pw_shell`(存在且可执行)→ `$SHELL` → macOS 用 `/bin/zsh`、Linux 用 `/bin/sh` | login(1)、VTE、Ptyxis、WezTerm、桌面启动的 Ghostty 都以 passwd 为准,`$SHELL` 可能在 chsh 后过期。**不要**沿用 Agent `ShellResolver` 中"只接受 bash/zsh"的限制,fish 和 nu 用户也要能直接用 | +| macOS 启动方式 | **方案 A(推荐):** `/usr/bin/login [-q] -flp /bin/bash --noprofile --norc -c 'exec -l '`,有 `~/.hushlogin` 时加 `-q`;cwd 由 TCode 在 fork 后自己 chdir(`-l` 让 login 不切到 home)。**方案 B:** 直接 exec shell,argv0 设为 `-zsh`(WezTerm 的做法) | A 与 Terminal.app、iTerm2、Ghostty、Alacritty 一致:有 utmp 记录,`getlogin()` 正确,会显示 "Last login"。B 省去 setuid 的 login,但这三点都和系统终端不同。见 §11 决策 2 | +| macOS 进程树 | 方案 A 下 PTY 的直接子进程是 root 所有的 `login`,shell 是孙进程。判断前台进程和 cwd 时用 `tcgetpgrp(master)` 和会话首进程,不要依赖直接子进程的 pid | [推];Ghostty 也提到 login 包装会吞掉退出码(GH src/Surface.zig:1277-1353) | +| Linux 启动方式 | 非 login 的交互式 shell;用户设置 `loginShell: true` 时把 argv0 设为 `-`(GNOME Terminal 的做法) | GNOME Terminal、Ptyxis、Konsole、VS Code、Ghostty、Alacritty 一致 | +| Windows 默认 | 最佳 pwsh(采用 WT 的排名规则)→ Windows PowerShell | WT 首次运行的默认值,与 VS Code 一致 | +| Windows 自动探测 | pwsh(各安装形态分别列出)、Windows PowerShell、cmd、Git Bash(VS Code 的路径表)、WSL(WT 的读注册表方式,不用启动 `wsl.exe`)、MSYS2/Cygwin(VS Code 的路径,标记 unsafe,首次使用前确认);VS 开发者命令行可以放到后续版本 | 结合 WT 与 VS Code。读注册表避免了 `wsl.exe -l` 唤醒 WSL 的开销 [推] | +| Agent 与交互终端分开 | `terminal.*` 只影响交互终端;Agent 继续用 `ShellResolver`(非交互、`-c`) | VS Code 的 `agentHostProfile` 也是这样分开的 | +| 起始目录 | 当前项目根目录;新标签/分屏继承当前标签的 cwd,来源依次为 OSC 7(shell 集成)→ Linux `/proc//cwd` → macOS `proc_pidinfo(PROC_PIDVNODEPATHINFO)` [推] → 项目根目录 | VS Code(工作区根目录,`splitCwd: inherited`)、GNOME Terminal、Konsole | +| 环境基础 | 使用 TCode **启动时的原始环境**,不用 `ExecutableLocator.ChildPath()` 生成的 PATH。在 macOS 上由 login shell 自己重建 PATH | 与 Terminal.app 一致;也避免了 VS Code 遇到的 path_helper 重排问题 | +| 跟随"系统终端的用户设置"(可选,以后做) | 读取 WT `settings.json` 的 `defaultProfile`、Terminal.app 偏好中的 Shell、GNOME Terminal 默认 profile 的 `custom-command` | 能更彻底地"与系统终端一致",但这些都是非公开格式,先不做 | + +--- + +## 2. 环境变量 + +### 2.1 各终端设置的变量 + +| 变量 | Terminal.app | iTerm2 | Windows Terminal | VTE / GNOME Terminal / Ptyxis | Konsole | VS Code | Ghostty | WezTerm | Alacritty | +|---|---|---|---|---|---|---|---|---|---| +| `TERM` | profile 的 "Declare terminal as",默认 `xterm-256color` [推] | 新 profile 为 `xterm-256color` | **不设** | `xterm-256color` | `xterm-256color` | Unix 上 `xterm-256color`(node-pty `name`),Windows 不设 | 找到资源目录时 `xterm-ghostty` 加 `TERMINFO=/terminfo`,否则 `xterm-256color` | `xterm-256color` | 有 terminfo 时 `alacritty`,否则 `xterm-256color` | +| `COLORTERM` | 有 `truecolor` 字符串 [推] | `truecolor` | 不设 | `truecolor`(VTE 强制设置,不允许覆盖) | `truecolor` | `truecolor` | `truecolor` | `truecolor` | `truecolor` | +| `TERM_PROGRAM` / `_VERSION` | `Apple_Terminal` / 版本 [推] | `iTerm.app` / 版本 | 不设 | 不设 | 不设(设 `KONSOLE_VERSION`) | `vscode` / 版本 | `ghostty` / 版本 | `WezTerm` / 版本 | 不设 | +| 会话 id | `TERM_SESSION_ID` | `ITERM_SESSION_ID`、`TERM_SESSION_ID`、`ITERM_PROFILE` | `WT_SESSION`、`WT_PROFILE_ID` | `GNOME_TERMINAL_SCREEN/SERVICE`;Ptyxis 设 `PTYXIS_PROFILE` | `KONSOLE_DBUS_*`、`SHELL_SESSION_ID` | — | `GHOSTTY_SURFACE_ID` | `WEZTERM_PANE` | `ALACRITTY_WINDOW_ID` | +| `LANG` / `LC_*` | "Set locale environment variables on startup":设为 `_.`,失败时设 `LC_CTYPE` [推] | 默认自动:按首选语言 × 国家地区猜测并验证,失败时设 `LC_CTYPE`;另设 `LC_TERMINAL=iTerm2` | 不设 | 不设 | `LANGUAGE=` 只在未设置时设 | `detectLocale: auto`:LANG 缺失或不是 UTF-8 时,**按 VS Code 界面语言**生成 | 桌面启动时移除 `LANGUAGE` | macOS 上 LANG 缺失时按 NSLocale 生成 | macOS 上 locale 无效时设 `LC_CTYPE` | +| 其他 | — | `COLORFGBG`、`TERMINFO_DIRS`、`TERM_FEATURES` | 把 `WSLENV` 前置 `WT_SESSION:WT_PROFILE_ID:` 和 profile 中的键 | VTE:`VTE_VERSION`、`PWD`、`TERMINFO_DIRS`;GNOME Terminal:`NCURSES_NO_UTF8_ACS=1` | `COLORFGBG`、`WINDOWID`、`PROFILEHOME` | `VSCODE_*`(shell 集成用) | `GHOSTTY_RESOURCES_DIR`、`GHOSTTY_BIN_DIR`(并加入 PATH)、`WINDOWID` | `WSLENV` 追加 `TERM:COLORTERM:TERM_PROGRAM:TERM_PROGRAM_VERSION` | `WINDOWID` | + +来源 [除标 推 的格子外均为实]: +- Terminal.app:local `strings` +- iTerm2:IT sources/PTYSession/PTYSession.m:3316-3443;sources/Locale/iTermLocaleGuesser.swift:87-140;ITAddressBookMgr.m:550 +- WT:src/cascadia/TerminalConnection/ConptyConnection.cpp:58-151 +- VTE:src/spawn.cc:252-293 +- GNOME Terminal:src/terminal-screen.cc:1951-1959 +- Ptyxis:src/ptyxis-client.c:667-673 +- Konsole:src/profile/Profile.cpp:71;src/session/Session.cpp:596-620 +- VS Code:src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts:62-71、98-108、110-180(第 16 行导入的 `language` 是界面语言);src/vs/platform/terminal/node/terminalProcess.ts:148-155 +- Ghostty:src/termio/Exec.zig:634-756 +- WezTerm:config/src/config.rs:1606-1622;env-bootstrap/src/lib.rs:118-158 +- Alacritty:alacritty_terminal/src/tty/mod.rs:100-150;alacritty/src/macos/locale.rs:13-40 + +### 2.2 各终端从继承环境中剔除的变量 + +| 终端 | 剔除内容 | 来源 | +|---|---|---| +| GNOME Terminal(最全) | **按名称:** COLORFGBG、COLORTERM、COLUMNS、LINES、DEFAULT_COLORS、DESKTOP_STARTUP_ID、XDG_ACTIVATION_TOKEN、GIO_LAUNCHED_DESKTOP_FILE(_PID)、INVOCATION_ID、JOURNAL_STREAM、NOTIFY_SOCKET、LISTEN_FDS/FDNAMES/PID、MAINPID、MANAGERPID、WATCHDOG_*、EXIT_CODE/STATUS、SERVICE_RESULT、PIDFILE、LC_TERMINAL、MLTERM、PWD、REMOTE_ADDR/PORT、**SHLVL**、STY、TERM、TERMCAP、TMUX、TMUX_PANE、VTE_VERSION、WINDOWID、WT_SESSION、WT_PROFILE、WCWIDTH_CJK_LEGACY、GJS_DEBUG_*,以及代理变量(之后按 GSettings 重新加入)。**按前缀:** GNOME_TERMINAL_、ALACRITTY_、CONTOUR_、FOOT_、GHOSTTY_、ITERM2_、KITTY_、KONSOLE_、MC_、MINTTY_、MOSH_、PUTTY_、RXVT_、**TERM_**、TERMINAL_、URXVT_、WEZTERM_、XTERM_、ZUTTY_ | GT src/terminal-client-utils.cc:211-305,在 src/terminal-screen.cc:1934-1945 应用 [实] | +| VTE 库本身 | 不剔除任何变量(VTE 的测试程序复制了 GNOME Terminal 的列表) | VTE src/spawn.cc;src/app/app.cc:261-320 [实] | +| Ptyxis | 非沙箱时整个环境原样传入;Flatpak 下只传白名单 | PX agent/ptyxis-session-container.c:107-124 [实] | +| Konsole | 不剔除 | [实](源码中没有 unsetenv) | +| VS Code | `ELECTRON_*`;`VSCODE_*`(PORTABLE、SHELL_LOGIN、ENV_REPLACE/APPEND/PREPEND 除外);`SNAP*`;`GDK_PIXBUF_*`。macOS 上把 `VSCODE_NODE_OPTIONS` 还原为 `NODE_OPTIONS` | VSC src/vs/base/common/processes.ts:104-126;terminalEnvironment.ts:278-295 [实] | +| iTerm2 | 只剔除 `SHLVL`、`NSZombieEnabled`、`MallocStackLogging`;`__CFBundleIdentifier` 和 `XPC_*` 保留;`TERM_SESSION_ID` 覆盖为新值 | IT sources/Tasks/PTYTask.m:648、678 [实] | +| Ghostty | 始终剔除 `VTE_VERSION`、`GHOSTTY_LOG`。macOS:剔除 `GHOSTTY_MAC_LAUNCH_SOURCE`,桌面启动时剔除 `LANGUAGE`,在 Xcode 中运行时剔除 `DYLD_*`/`__XPC_*`。GTK:剔除 `GDK_DEBUG`、`GDK_DISABLE`、`GSK_RENDERER`、`GIO_LAUNCHED_DESKTOP_FILE*`、`DBUS_STARTER_*`、`INVOCATION_ID`、`JOURNAL_STREAM`、`NOTIFY_SOCKET` | GH src/termio/Exec.zig:756;src/apprt/embedded.zig:1218-1238;src/apprt/gtk/class/surface.zig:1606-1636 [实] | +| WezTerm | `SHELL`、`WINDOWID`、`VTE_VERSION`、AppImage 和 Snap 相关变量 | WZ env-bootstrap/src/lib.rs:14-51、226-240 [实] | +| Alacritty | `XDG_ACTIVATION_TOKEN`、`DESKTOP_STARTUP_ID` | AL alacritty_terminal/src/tty/unix.rs:239-241 [实] | +| Windows Terminal | 不剔除,而是**从注册表重建**(`compatibility.reloadEnvironmentVariables` 默认 true):读 HKLM/HKCU Environment 和 Volatile Environment,Path 按"系统;用户"拼接。安装程序修改 PATH 后,新标签马上能看到 | WT src/cascadia/TerminalConnection/ConptyConnection.cpp:296-316;src/inc/til/env.h:237-270、446-476 [实] | + +### 2.3 TCode 建议 + +**设置的变量** + +| 变量 | 值 | 平台 | 依据 | +|---|---|---|---| +| `TERM` | `xterm-256color` | macOS、Linux。Windows 不设,WSL 会自己设 | 所有系统终端都用这个值。**不要自定义 TERM**:自定义值需要随应用分发 terminfo,以后接 SSH 时远端也没有对应条目(Ghostty 为此专门做了 `ssh-terminfo`) | +| `COLORTERM` | `truecolor` | 全部 | 全部终端 | +| `TERM_PROGRAM` / `TERM_PROGRAM_VERSION` | `TCode` / 版本号 | 全部 | VS Code、Ghostty、WezTerm、iTerm2;方便 CLI 工具识别宿主。**不要**设为 `Apple_Terminal`,否则会触发 `/etc/zshrc_Apple_Terminal` 的会话保存逻辑 | +| `WSLENV` | 追加 `TERM_PROGRAM:TERM_PROGRAM_VERSION:COLORTERM`,以及 profile `env` 中的键 | Windows | WT、WezTerm | +| `LANG` | 见下方"locale"一条 | macOS | Terminal.app、iTerm2 | +| 终端会话 id(可选) | `TCODE_TERMINAL_ID=` | 全部 | 类似 `WT_SESSION`,供以后 Agent 或 CLI 定位"当前终端"使用。**不确定是否需要**,可以先不加 | + +**locale(macOS)** + +- `setLocaleVariables: auto` 为默认:`LANG` 未设置或不是 UTF-8 时,按 `NSLocale` 的语言和地区生成 `xx_YY.UTF-8`,并检查 `/usr/share/locale/` 存在;不存在时只设 `LC_CTYPE=UTF-8`。 +- 这与 Terminal.app 和 iTerm2 一致。**不用 TCode 界面语言**生成,与 VS Code 的做法不同。 +- Linux 和 Windows 不改 locale。 + +**剔除的变量** + +1. **TCode 自身的变量:** 剔除 `TCODE_*`,只保留 `TCODE_HOME`,因为它是面向用户的配置。剔除的例子有 `TCODE_AGENTHOST_DIR`、`TCODE_FAKE_PROVIDER`、`TCODE_EXTERNAL_RUNTIMES`、`TCODE_DISABLE_*`、`TCODE_*_PATH`。同时**不要**注入 `TCODE_AGENT=1`:这个变量是 `BashTool` 给 Agent 命令设置的(src/TCode.Agent.Coding/Tools/Shell/BashTool.cs:204-207),交互终端不应带上。规则与 VS Code 剔除 `VSCODE_*` 相同。 +2. **启动方式注入的 .NET/IDE 变量** [推]:NativeAOT 程序没有宿主,不会自己往环境里加 `DOTNET_*`。环境中出现这些变量,只能是用户或启动它的工具设置的。因此**不要一刀切地剔除 `DOTNET_*`**:用户设置的 `DOTNET_ROOT`、`DOTNET_CLI_TELEMETRY_OPTOUT` 等应当保留。只剔除开发期由启动器注入的变量: + - `dotnet run` / `watch`:`DOTNET_LAUNCH_PROFILE`、`DOTNET_WATCH*`、`DOTNET_MODIFIABLE_ASSEMBLIES`; + - MSBuild:`MSBuild*`、`MSBUILD*`; + - 性能分析器:`DOTNET_STARTUP_HOOKS`、`CORECLR_*`、`COR_ENABLE_PROFILING`/`COR_PROFILER*`,否则任何 dotnet 子进程都会挂上分析器。 +3. **桌面会话与启动器的变量:** + - Linux:`DESKTOP_STARTUP_ID`、`XDG_ACTIVATION_TOKEN`、`GIO_LAUNCHED_DESKTOP_FILE(_PID)`、`INVOCATION_ID`、`JOURNAL_STREAM`、`NOTIFY_SOCKET`、`LISTEN_*`、`MANAGERPID`、`WATCHDOG_*`、`DBUS_STARTER_*`; + - Snap:`SNAP*`;AppImage:`APPIMAGE`、`APPDIR`、`OWD`、`ARGV0`; + - macOS:`__CFBundleIdentifier`、`XPC_SERVICE_NAME`、`XPC_FLAGS`。这几个描述的是 TCode 自己,不是 shell。iTerm2 保留它们,所以这里属于取舍 [推]。 +4. **其他终端遗留的变量**(TCode 从别的终端里启动时会出现):照搬 GNOME Terminal 的列表,并补上: + - 前缀 `ITERM_`(`ITERM_SESSION_ID`、`ITERM_PROFILE`)、`VSCODE_`、`WT_PROFILE_ID`、`LC_TERMINAL_VERSION`、`TERM_FEATURES`; + - 指向其他终端资源目录的 `TERMINFO`/`TERMINFO_DIRS` [推],例如 Ghostty 设置的 `TERMINFO`。 +5. **`SHLVL`、`PWD`、`OLDPWD`:** 剔除,这样新 shell 的 SHLVL 从 1 开始(GNOME Terminal、iTerm2 的做法)。 +6. **Windows 环境刷新:** 默认像 WT 一样从注册表重建环境(`reloadEnvironment: true`),用户安装软件修改 PATH 后,新终端不需要重启 TCode 就能生效。 + +--- + +## 3. Shell 集成 + +### 3.1 各终端的做法 + +| 终端 | 默认是否注入 | 注入方式 | 协议 | 来源 | +|---|---|---|---|---| +| VS Code | **自动**(`shellIntegration.enabled: true`) | **bash:** `--init-file <脚本>`,脚本自己 source `~/.bashrc`;login 时依次 source `/etc/profile`、`~/.bash_profile`/`~/.bash_login`/`~/.profile`。
**zsh:** 在 `${tmpdir}/${user}-${app}-zsh`(权限 0o1700)复制 `.zshrc`、`.zprofile`、`.zshenv`、`.zlogin`,设 `ZDOTDIR` 和 `USER_ZDOTDIR`;脚本中切回用户的 ZDOTDIR 并 source 用户文件,同时设 `HISTFILE=$USER_ZDOTDIR/.zsh_history`。
**fish:** `--init-command 'source …'`。
**pwsh:** `-noexit -command '. …'`。
Windows 支持 pwsh 和 Git Bash。用户参数无法识别时跳过 | OSC 633(A–I、E 命令行、P 属性、EnvJson…)、133、1337、7、9;9;`VSCODE_NONCE` 防止程序输出伪造命令行 | VSC src/vs/platform/terminal/node/terminalEnvironment.ts:52-368;src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh:30-68;shellIntegration-env.zsh、shellIntegration-rc.zsh;src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts:39-322 [实] | +| Ghostty | **自动**(`shell-integration = detect`) | **bash:** 改为 `bash --posix`,设 `ENV=/…/ghostty.bash`;脚本执行 `set +o posix` 后按 login/非 login 自己 source 启动文件。**跳过 macOS 的 `/bin/bash` 3.2**。
**zsh:** `ZDOTDIR=<资源目录>`,原值存入 `GHOSTTY_ZSH_ZDOTDIR`,由 `.zshenv` 还原(要求 zsh ≥ 5.1)。
**fish / elvish / nushell:** 前置 `XDG_DATA_DIRS`(nushell 另加 `--execute`)。
功能默认值:cursor、title、path 开;sudo、ssh-env、ssh-terminfo 关 | OSC 133 A/B/C/D/P(带 `aid=`、`cl=line`)、OSC 7、OSC 2 | GH src/termio/shell_integration.zig:137-948;src/shell-integration/bash/ghostty.bash:22-278;src/config/Config.zig:2915-2940、8899-8906 [实] | +| kitty | **自动**,所有功能默认开启 | zsh 用 ZDOTDIR;fish 用 XDG_DATA_DIRS;bash 用 POSIX 模式 + `ENV`;设 `KITTY_SHELL_INTEGRATION`,读取后由脚本 unset | OSC 133 等 | https://sw.kovidgoyal.net/kitty/shell-integration/ [实] | +| iTerm2 | **不注入**("Load shell integration automatically" 默认 NO);可以从菜单安装,或用 curl 安装脚本 | 开启后与 Ghostty 相同:zsh 用 ZDOTDIR(原值存入 `IT2_ORIG_ZDOTDIR`),bash 用 `--posix` + `ENV`,fish 用 XDG_DATA_DIRS;同样跳过 `/bin/bash` 3.2 | OSC 133、OSC 7、OSC 1337(SetUserVar、ShellIntegrationVersion…) | IT sources/ShellIntegration/ShellIntegrationInjection.swift:36-465;iTermProfilePreferences.m:1208;Resources/shell_integration/iterm2_shell_integration.zsh:37-129 [实] | +| WezTerm | **不注入**;`wezterm.sh` 需要用户 source,Fedora/Debian/Arch 的软件包会放到 `/etc/profile.d` | — | OSC 7、133、1337 SetUserVar | WZ docs/shell-integration.md:1-35;assets/shell-integration/wezterm.sh [实] | +| Windows Terminal | **不注入**;文档让用户自己修改 PROMPT/PS1。`autoMarkPrompts` 默认 true:在主屏幕按回车时,把当前行标为提示符 | 无 | 解析 OSC 133、7、9;9、9;12、633(只用 Completions)、1337 SetMark | WT src/cascadia/TerminalCore/Terminal.cpp:740-770;src/terminal/parser/OutputStateMachineEngine.hpp:209-232;https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration [实] | +| VTE / GNOME Terminal | 发行版把 `/etc/profile.d/vte.sh` 装好,但它只在 login shell 或发行版的 bashrc source profile.d 时才生效(Fedora 会,Debian/Ubuntu 的非 login bash 通常不会 [推]) | vte.sh:只对交互式 bash/zsh 生效,并且要求 `VTE_VERSION` ≥ 3405 | OSC 7、OSC 0、OSC 133 A/B/C/D、OSC 666 termprops;Ctrl+Shift+←/→ 在提示符之间跳转 | VTE src/vte.sh.in:16-176;src/vteseq.cc:1826-1854 [实] | +| Terminal.app | 系统自带,`TERM_PROGRAM=Apple_Terminal` 时触发:`/etc/zshrc` 第 74 行 source `/etc/zshrc_$TERM_PROGRAM` | `update_terminal_cwd`(在 precmd 中发 OSC 7);Resume Support:每个会话一份 `~/.zsh_sessions/$TERM_SESSION_ID.*` 历史与状态 | OSC 7;**提示符标记靠按键**:按回车、Ctrl-C、Ctrl-D 时自动给当前行打标记,不用 OSC 133 | local `/etc/zshrc_Apple_Terminal`:14-44、96-252;https://support.apple.com/guide/terminal/use-marks-and-bookmarks-trml135fbc26/2.15/mac/27 [实] | +| Konsole | 不注入 [推] | — | 解析 OSC 133 A/N/P、B、C、D、L;`SemanticHints` | KO src/Vt102Emulation.cpp:1194-1215 [实] | + +### 3.2 Shell 集成提供的功能 + +| 功能 | 需要的序列 | 已有的实现 | +|---|---|---| +| 命令边界与装饰(行首的成功/失败标记) | 133 A/B/C/D | VS Code `decorationsEnabled: both`、iTerm2 的蓝/红标记、WT 滚动条上的标记、Konsole semantic hints | +| 退出码 | 133;D; | 同上;Ghostty 的命令完成通知 | +| 当前目录 | OSC 7 / 633;P;Cwd / 9;9 | 新标签继承 cwd、标签标题、解析相对路径的链接 | +| 命令行文本 | 633;E(带 nonce) | VS Code 的命令历史与 quick fix | +| 跳到上一条/下一条命令 | 133 A | VS Code Ctrl/Cmd+↑/↓(VSC terminalActions.ts:872-925);Terminal.app Cmd+↑/↓(基于标记);iTerm2 Cmd+Shift+↑/↓;Ghostty macOS Cmd+↑/↓ 与 Cmd+Shift+↑/↓、其他平台 Ctrl+Shift+↑/↓;VTE Ctrl+Shift+←/→;WT `scrollToMark` 默认没有绑定键 | +| 选中/复制上一条命令的输出 | 133 C/D | VS Code copyLastCommandOutput、WT selectOutput、iTerm2 "Select Output of Last Command" | +| 按住 Alt/Option 单击移动光标 | 133(知道当前在输入区) | VS Code `altClickMovesCursor`、Ghostty `cursor-click-to-move`、Terminal.app 和 iTerm2 的 Option-click | +| 在提示符处关闭不需要确认 | 133 | Ghostty `confirm-close-surface`(GH src/Surface.zig:975-993) | +| 窗口不在前台时,长命令完成后通知 | 133 C/D 或 OSC 666 | Ptyxis(命令耗时 ≥ 250 ms 且窗口不活跃时)、Ghostty `notify-on-command-finish`(默认 never)、iTerm2 Cmd-Opt-A | +| 粘性滚动(固定显示当前命令行) | 633 | VS Code stickyScroll | + +### 3.3 风险与对策 + +| 风险 | 说明 | 对策 | +|---|---|---| +| 改变启动文件的读取方式 | ZDOTDIR 和 `--init-file` 都改变了 shell 读 rc 的路径;如果 `/etc/zshenv` 自己设置了 ZDOTDIR,zsh 注入会失效(GH src/shell-integration/README.md);HISTFILE 的默认位置会跟着 ZDOTDIR 变 | 照搬 VS Code 和 Ghostty 的还原逻辑,并显式设置 HISTFILE;设置页说明注入方式;每个 profile 都可以单独关闭 | +| macOS 的 `/bin/bash` 是 3.2 | `ENV` + `--posix` 的方式在 3.2 上不可用,Ghostty 和 iTerm2 都直接跳过 | bash 改用 VS Code 的 `--init-file` 方式并模拟 login 启动文件 [推:需要实测 3.2] | +| 用户自定义参数 | 无法判断是否还能注入 | 与 VS Code 一样,参数无法识别时跳过,并在界面上显示"未启用 shell 集成"的原因 | +| prompt 主题冲突 | 异步刷新的主题(p10k instant prompt、starship)可能吃掉标记;复杂的 `PROMPT_COMMAND` | kitty 把钩子挪到 `precmd_functions` 末尾;VS Code 文档单独说明了复杂 PROMPT_COMMAND 的情况 | +| 集成丢失 | `exec zsh`、tmux、ssh 之后就没有集成了 | 退化为 WT 的回车启发式;以后接 SSH 时可以考虑 Ghostty 的 ssh-env 做法 | +| 伪造序列 | 程序输出可以伪造 133/633,诱导用户"重新运行"恶意命令行 | 命令行文本只接受带 nonce 的 633;E(VS Code `VSCODE_NONCE`) | +| 环境变量泄漏 | 注入用的变量会被子进程继承 | 脚本读取后立即 unset(VS Code、Ghostty、kitty 都这样做) | +| 临时目录被篡改 | VS Code 把 zsh 文件复制到 /tmp,所以要设置 sticky 位 | 采用 Ghostty 的做法,让 ZDOTDIR 直接指向应用包内的只读资源目录,不复制到 /tmp | + +### 3.4 TCode 建议 + +- **默认开启自动注入**:zsh、bash、fish、pwsh,以及 Windows 上的 Git Bash。nushell 和 elvish 可以以后加。 + - 依据:TCode 定位类似 VS Code,命令导航、退出码、cwd 继承、关闭确认、以后让 Agent 读取命令输出,都依赖 shell 集成。 + - 代价:会改变 shell 的启动方式。见 §11 决策 3。 +- **注入方式:** + - zsh:ZDOTDIR 指向只读资源目录(Ghostty 的做法); + - bash:`--init-file` 模拟启动文件(VS Code 的做法,兼容 bash 3.2); + - fish:用 `XDG_DATA_DIRS` 加载 `vendor_conf.d`,不改参数; + - pwsh:`-NoExit -Command ". '