terminal-pty-windows · diff
git:20261003.6479ce9 to git:20261016.4c2ca20
1 added, 1 removed. Audit A to A.
---
name: terminal-pty-windows
description: Use before changing or debugging clud's terminal/PTY path on Windows (ConPTY, the native ReadConsoleInputW reader, VT input, raw mode, escape sequences, mouse/paste reports, Ctrl+C, terminal restore). Covers what ConPTY does differently, the files to read, the invariants, and how to validate without a local Windows machine.
---
# Terminal / PTY on Windows
Shared facts (data flow, the three invariants, test tiers, act rules, CI
labels) are in
[docs/architecture/terminal-pty.md](../../../docs/architecture/terminal-pty.md).
Read that first. This page lists only what differs on Windows.
## What is different on Windows
- **Input does not come from a byte stream.** On an interactive console,
`console_input.rs` runs its own `ReadConsoleInputW` loop over
running-process's translator. Each translated KEY_EVENT becomes one
`extra_rx` chunk. The byte-stream stdin reader is not spawned.
- **VT input.** `console_setup::enable_console_vt_input` sets
`ENABLE_VIRTUAL_TERMINAL_INPUT` for the session. It is layered on *after*
the native reader has captured the original console mode
(`runner_execution.rs:326` starts the reader, then `runner_execution.rs:349`
takes the guard). Under VT input the console delivers terminal-originated
sequences one KEY_EVENT per character: SGR mouse reports, focus events,
bracketed-paste markers and DSR replies. The reader translates each event
on its own, so a sequence reaches the pump in pieces. This is why
invariant 1 (sequence atomicity, #1697) matters here.
- **Reader policies:**
- The reader pairs UTF-16 surrogates itself (#1351).
- Shift+Enter becomes `ESC CR`, because ConPTY rewrites LF to CR (#1369).
- Ctrl+V can expand to a saved clipboard-image path.
- Backspace `0x08` is normalized to `0x7f` (#1350).
- **Ctrl+C is a byte.** Raw mode clears `ENABLE_PROCESSED_INPUT`, so Ctrl+C
arrives as `0x03` on `extra_rx`, and the pump treats it as an interrupt.
No `CTRL_C_EVENT` fires.
- **ConPTY re-renders output.** On the ci-windows runner (#1709) we observed:
- ConPTY **forwards** the child's alternate screen (`?1049`), bracketed
paste (`?2004`) and cursor visibility (`?25`).
- It does **not forward** the child's mouse modes (`?1003`/`?1006`), its
scroll region, or cursor-key mode (`?1`).
- ConPTY itself emits `?1004h` and `?9001h` (win32-input-mode) at startup.
- **ConPTY consumes focus reports on input.** A terminal's `ESC[I` /
`ESC[O` that arrives in one write never reaches the child as bytes, on
either input path (observed on the x64 and arm runners, #1717). Cut across
writes with idle gaps, the outcome depends on timing (Windows timers tick at
~15.6 ms): either the held prefix is released as an Esc / Alt+[ key first
and the child reads the bytes unchanged, or the report assembles and is
consumed. Each report resolves on its own; the sweep accepts exactly those
two outcomes. Everything else in
`tests/integration/pty/input_corpus.rs` must reach the child byte-for-byte; the only
modelled transforms are LF→CR and this one (`expected_for`).
- **Cursor query at startup.** ConPTY sends `ESC[6n` and holds the child
until a reply comes back (#1310). clud answers only when stdin is not an
interactive console (`should_answer_cursor_queries`, `session.rs:1084`).
Otherwise the real terminal answers.
- **Piped stdout breaks ConPTY.** ConPTY stops relaying child output when
the spawning process's stdout is a pipe (#691). That is why CI runs the
`pty` harness inside a pseudo-terminal (`ci/run_bundle.py:206`).
- **Resizing** goes through `resize_pty` (`session.rs:34`), which resizes the
master directly, because `NativePtyProcess::resize_impl` is a no-op on
Windows.
## Files to read first
- - `crates/clud-bin/src/console_input.rs`: `spawn_console_input_reader`
+ - `crates/clud-bin/src/platform/windows/console_input.rs`: `spawn_console_input_reader`
(`:81`), surrogate pairing, and the Shift+Enter / Ctrl+V policies.
- `crates/clud-bin/src/console_setup.rs`: `ConsoleVtGuard` (`:110`) and
`enable_console_vt_input` (`:140`).
- `crates/clud-bin/src/runner_execution.rs:320-350`: reader and guard
ordering.
- `crates/clud-bin/src/session.rs`:
- `forward_user_input` (`:1252`)
- `BracketedPasteNormalizer::flush_due_in` (`session/bracketed_paste.rs`):
a held lone Esc is released after 5 ms, a partial report after 250 ms
(#1717)
- `should_answer_cursor_queries` (`:1084`)
- `RawTerminalGuard` (`:316`, `Drop` at `:602`)
- `crates/clud-bin/src/session/escape_gate.rs`: `EscapeSequenceGate`.
- `crates/clud-bin/src/session/child_modes.rs`: what gets undone on exit.
- `crates/clud-bin/src/session/interrupt.rs:26`: `interrupt_pty_process`
(daemon handoff, then tree kill, then PTY close).
- [windows-quirks.md](../../../docs/architecture/windows-quirks.md), sections
(c) VT-input RAII, (d) native terminal input, and (h) `kill_tree`.
- [session-lifecycle.md](../../../docs/architecture/session-lifecycle.md),
"The pump loop" and "Terminal restore".
## Invariants you must not break
1. **No partial escape sequence in a PTY write.** Every user-input write
goes through `forward_user_input`, so it passes the gate. Do not add a
side path that calls `write_impl` with keyboard bytes. Drag-drop chunks
carry no ESC, so they are safe.
2. **Keep the native reader's ordering.** It must capture the original
console mode before VT input is enabled. Dropping the guards restores VT
input first, then the reader's original mode.
3. **Each scanner resumes across chunks.** `extra_rx` and stdin each have
their own `InterruptScanner` (#1703).
4. **Do not stub a reply a real console will also send.** With an
interactive console, a clud `ESC[6n` stub would be a second, wrong reply
(#31).
5. **On restore, undo only what the child turned on.** Do not add a
blanket `?1049l`.
## How to validate a change
There is no local Windows, and `act` cannot run Windows.
1. Run the shared Linux checks first:
`ci-lint local-gate run` (the bosn PR plan plus a stamp; see CLAUDE.md). See terminal-pty.md
§ Validation for what it covers.
2. Add `ci-windows` only when the diff changes Windows platform
implementation or its native runtime contract: ConPTY/Win32 API
handling, console adapters, or Windows-specific branches. Identify the
changed implementation and what the native lane proves in the PR body.
Shared pump logic, stream parsers, helper extraction, and callers of
unchanged platform APIs stay on routine CI. Moving unchanged OS code
into helpers does not qualify. See [label selection](../../../docs/architecture/ci.md#when-to-add-native-ci-labels).
When warranted, it adds Windows x64 build, unit and integration lanes
alongside the routine Linux gate.
3. For a Windows platform implementation bug fix, push a **tests-only
commit first** so Windows records RED, then push the fix. Reproduce shared
business-logic regressions through the routine Linux gate instead.
4. Watch the run with
`clud tool run github/pr_merge_watch.py -- <PR> [--no-cancel]`. Read a
failing job with
`gh api --allow-escape-sequences repos/zackees/clud/actions/jobs/<job_id>/logs`.
Do not run `bash lint --windows` on the host. Agents must not run lint
outside act; rely on the clippy step in the CI Windows build.
Tests that need a real console call `require_pty_or_skip!`. They skip when
stdin is not a console, except under `CLUD_REQUIRE_PTY=1` (set by CI). To
feed real key events, inject KEY_EVENT records with `WriteConsoleInputW`, as
`crates/clud-bin/tests/integration/pty/shift_enter_dual_reader.rs:62` does.
## Known behaviours and gotchas
- **#1697.** `extra_rx_mouse_reports_split_per_character_reach_the_child_whole`
(`crates/clud-bin/tests/integration/pty/pty_pump.rs:745`) reproduced the user's exact
symptom on real ConPTY before the fix. The child read
`35;31;18M\u{1b}[<35;31;19M…`. Use it as the template for any
split-sequence regression.
- **#1704 / #1709.** `a_ctrl_c_interrupted_child_has_its_terminal_modes_undone`
(`tests/integration/pty/pty_pump.rs:378`) asserts only `?1049l`, `?2004l` and `?25h` on
Windows, because ConPTY did not forward the others. Do not "fix" this test
by asserting the POSIX set on Windows.
- **Windows-only compile error.** `#[cfg(windows)]` placed directly on an
`if` expression is rejected. Wrap the `if` in a block, or use `cfg!()`.
- **Ctrl+C interrupts.** `extra_rx_ctrl_c_byte_interrupts_pump` (`:831`) and
`extra_rx_kitty_csi_u_ctrl_c_interrupts_pump` (`:912`) cover the
interrupt path through `extra_rx`.
- **Git Bash / mintty without winpty** gives pipe stdio. clud then falls back
to subprocess mode (#1357; see windows-quirks.md).