diff --git a/KEYS.md b/KEYS.md index 88052a8..183af0d 100644 --- a/KEYS.md +++ b/KEYS.md @@ -21,7 +21,7 @@ Syntax highlighting only. No completion, no LSP, no snippets, no AI. | `Tab` | indent the selected lines | **selection only** | | `Shift+Tab` | unindent the selected lines | **selection only** | | `Ctrl+Q` | visual block (was `Ctrl+V`) | normal, visual | -| `Ctrl+Shift+Q` | **quit, with a confirmation** | normal, insert, **and inside the REPL** | +| `Ctrl+Shift+Q` | **quit, with a confirmation** | normal, insert, **and inside the REPL** — except on a terminal with no CSI-u, where **normal mode is not covered**; see below | | **Shift+arrows** | **select, like any other editor** | normal, insert | | `Delete` / `Backspace` | delete the selection and keep typing | selection | | Arrows | movement | everywhere | @@ -227,8 +227,24 @@ is live). It exists for the same reason `Ctrl+Shift+Z` does: in the legacy encoding Shift is dropped from a control chord, so `Ctrl+Q` and `Ctrl+Shift+Q` are the same byte. With CSI-u they are different keys — which is what lets this coexist with -`Ctrl+Q` for visual block. **On a terminal that speaks no CSI-u it will not -fire**; `:qa!` still works there. +`Ctrl+Q` for visual block. + +**On a terminal that speaks no CSI-u the chord arrives as plain `Ctrl+Q`**, so +on WSL insert-mode and terminal-mode `Ctrl+Q` are bound to the same prompt. The +binding is gated on **being on WSL**, not on the protocol — there is no runtime +way to ask whether CSI-u was negotiated — so a WSL session whose terminal *does* +speak CSI-u gets it too, and bare `Ctrl+Q` quits from insert there as well even +though `Ctrl+Shift+Q` arrives as its own key. From insert, which is where +this editor keeps you, you press the documented chord and it works. Two costs: +plain `Ctrl+Q` then quits from insert as well (Cancel is still the default) and +stops being vanilla vim's literal-insert. + +**Normal mode is not covered there.** `Ctrl+Q` in normal mode is visual block +and is the only route to one — `Ctrl+V` is paste — so binding it to the prompt +would leave no way to ask for a block at all, while quitting still has `:qa!`. +The trade is deliberate: from normal mode on such a terminal, `Ctrl+Shift+Q` +gives you a visual block, so press `i` first or use `:qa!`. None of this applies +off WSL. See [docs/wsl.md](docs/wsl.md). | Command | Does | |---|---| diff --git a/TODO.md b/TODO.md index b58e55d..c868692 100644 --- a/TODO.md +++ b/TODO.md @@ -39,7 +39,10 @@ Landed as a stack of PRs, one per checkpoint, each based on the previous. version parse that survives `-dev` strings, and correct nvim install hints per platform. (`drill.sh`'s only `apt install` line is for fzf, which has no version gate and is not stale — nothing to do there.) - *Gate: `install.sh` into a temp `DRILL_HOME`.* + *Gate: package-manager detection on this box; `--yes` installs and + re-probes; a non-tty run prints the command and runs nothing; the + version parse survives `0.10-dev` and a missing `NVIM v` prefix; and + the apt hint is withheld when apt's candidate is below 0.9.* - [x] **2 — `wsl/02-clipboard`** · `nvimrc.lua`: native provider first, a `clip.exe` + `powershell Get-Clipboard` shim only when no *usable* provider is found — "usable" being the load-bearing word, since an @@ -47,7 +50,10 @@ Landed as a stack of PRs, one per checkpoint, each based on the previous. anything, and an empty clipboard is not the same as an absent provider; and resolve `python3` past the `/mnt/.../WindowsApps` Store alias that WSL's PATH interop otherwise hands us. - *Gate: `suite_config.sh` clipboard cases green; copy/paste both directions by hand.* + *Gate: `suite_config.sh` clipboard cases green; copy/paste both + directions by hand; the shim engages with the providers hidden and + round-trips with no stray `\r`; and a stub `python3` planted under + `/mnt` is walked past, with a healthy PATH left untouched.* - [x] **3 — `wsl/03-timer`** · `drill.sh`: `run()` reported success for a process that merely launched, so a player that exits non-zero silenced the bell fallback too — check the exit code, add a timeout. Extend the @@ -57,8 +63,11 @@ Landed as a stack of PRs, one per checkpoint, each based on the previous. Scope grew once the suite could run here: **`pgrep -f "$TAG"` matches any process that merely *mentions* the tag**, so `t` was stopping strangers — including the test shell that holds the tag in its own `-c` string, which - is why 30 of 36 timer cases failed on Linux before any of this. Confirm - each candidate is an interpreter with `ps -o comm=` instead. + is why 30 of 36 timer cases failed on Linux before any of this. An + executable check and an end-anchor were both tried and both still + accepted `python3 train.py --tag drill-timer`; identity finally comes + from a marker inside the timer's own source, which is `argv[2]` of the + running python and which nothing else carries. *Gate: `suite_timer.sh` 36/36 (was 6/36); sound and notification both fire.* - [x] **4 — `wsl/04-demo-tests`** · `demo.sh` `bc` → `awk`; `tests/bin/timeout` delegates to real GNU `timeout` when one exists instead of shadowing it; @@ -66,29 +75,44 @@ Landed as a stack of PRs, one per checkpoint, each based on the previous. counts failing *suites* rather than summing exit codes — it called 30 broken timer cases "2 FAILING CASE(S)", small enough to read as a flake. - Also: `demo.sh --check` failed its `Ctrl+Shift+Q` case on every Linux box - and told you to record with `--keys socket`, which is the mode it was - already in. `--remote-send` collapses `` to ``, whose - literal-insert then eats the cancelling `c` — the exact Shift-drop that - made CSI-u necessary. Socket mode cannot ask that question, so it skips - it and says why. + Also, and recorded here because the wrong version of it shipped first: + `demo.sh --check` appeared to fail its `Ctrl+Shift+Q` case, and I + diagnosed it as `--remote-send` collapsing `` to ``. **That + was wrong.** `--remote-send` speaks nvim's key *notation*, not terminal + bytes, so no encoding happens and CSI-u never enters into it — measured + under a real pty, socket mode reports `ok Ctrl+Shift+Q reaches its mapping`. + What I had actually measured was a screenless nvim, where `confirm()` + cannot draw and returns instantly. The skip built on that reasoning is + reverted; the check runs unconditionally, as it always did. *Gate: full `./tests/run.sh` 556/556; `./demo.sh --check` clean.* - [x] **5 — `wsl/05-ci-docs`** · `.github/workflows/tests.yml` running the suite on `ubuntu-latest` under `xvfb` with `xclip`, so the clipboard cases are real; `docs/wsl.md`; requirement lines in `README.md`, `KEYS.md`, `docs/testing.md`. - *Gate: CI green on the PR itself.* - -### Still to be measured by a human - -`Ctrl+Shift+Q` only exists when the terminal negotiates CSI-u. Windows Terminal -≥1.22 does; older builds use win32-input-mode and the chord never fires, leaving -`:qa!` as the only way out. Nothing automated can settle this: `suite_quit.sh` -synthesises `ESC[113;6u` straight onto the pty, so it passes on a terminal where -the chord could never arrive, and `demo.sh --check` cannot send CSI-u over -`--remote-send` at all. **Press it in Windows Terminal.** If the confirmation -appears, your build speaks CSI-u; if nothing happens, update Windows Terminal or -use `:qa!`. Written up in [docs/wsl.md](docs/wsl.md). + *Gate: CI green on the PR itself — and green for the right reason: + the log must show the pinned nvim sha256 verified, `ALL SUITES + PASSED`, and no `no clipboard provider` warning, which is what proves + `xvfb` + `xclip` made the register cases real rather than skipped.* + +### Measured by a human, and then fixed + +`Ctrl+Shift+Q` did not work on WSL. Nothing automated could have told us: +`suite_quit.sh` writes `ESC[113;6u` straight onto the pty, so it passes on a +terminal where the chord could never arrive, and `demo.sh --check` reaches the +mapping over `--remote-send`, which consumes nvim's key *notation* and never +exercises a terminal encoding at all. Neither one presses a key. It took a +human. + +Windows Terminal 1.24 is new enough to speak CSI-u and does not negotiate it +with nvim, so the chord arrives as the legacy `0x11` — plain `` — and the +`` mapping is never reached. On WSL the config now also binds `` in +insert and terminal mode, which is what actually arrives. + +**Normal mode is deliberately not covered.** There `` is visual block and +is the only route to one, since `` is paste — so binding it to the prompt +would leave no way to ask for a block, while quitting still has `:qa!`. From +normal mode on such a terminal the chord gives you a block; press `i` first. +Off WSL none of this applies. Written up in [docs/wsl.md](docs/wsl.md). ### Not in scope, deliberately diff --git a/docs/wsl.md b/docs/wsl.md index da3ee40..dfefca4 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -81,22 +81,51 @@ anything. ## `Ctrl+Shift+Q` -`Ctrl+Shift+Q` only exists as a distinct key when the terminal negotiates -CSI-u. In the legacy encoding Shift is dropped from a control chord, so -`Ctrl+Q` and `Ctrl+Shift+Q` are the same byte and nothing downstream can tell -them apart. - -**Measured: Windows Terminal 1.24 does not negotiate CSI-u with nvim**, so the -chord arrives as plain ``. Updating Windows Terminal does not fix that — -1.24 is already new enough to speak the protocol and simply does not. The WSL -`` binding that makes the chord work lands with the quit checkpoint later -in this stack; until then the way out is `:qa!`. - -**Nothing automated can check this.** `suite_quit.sh` writes `ESC[113;6u` -straight onto the pty, so it passes on a terminal where the chord could never -arrive, and `demo.sh --check` reaches the mapping over `--remote-send`, which -speaks nvim key notation rather than terminal bytes — neither one is pressing a -real key. That is why this needed a human. +Press it. It works — but not by the route it uses on a Mac, and that is worth +knowing if you ever wonder why `Ctrl+Q` also quits here. + +`Ctrl+Shift+Q` only exists as a distinct key when the terminal negotiates CSI-u. +In the legacy encoding Shift is dropped from a control chord, so `Ctrl+Q` and +`Ctrl+Shift+Q` are the same byte — `0x11` — and nothing downstream can tell them +apart. **Windows Terminal 1.24 does not negotiate CSI-u with nvim**, so the +chord arrives as plain `` and the `` mapping is never reached. + +So on WSL, `nvimrc.lua` also binds what actually arrives: + +| | | +|---|---| +| insert mode, `Ctrl+Q` (and therefore `Ctrl+Shift+Q`) | the quit prompt | +| terminal mode — i.e. inside the REPL | the quit prompt, so you can leave without pressing `Ctrl+E` first | +| normal mode, `Ctrl+Q` | still visual block, untouched | + +Two consequences. `Ctrl+Q` on its own quits from insert *and from inside the +interpreter* too — the prompt defaults to **Cancel**, so a slip costs one +keystroke. And insert-mode +`Ctrl+Q` is no longer vanilla vim's literal-insert; in a Python scratchpad where +`Ctrl+V` is already paste, that was close to unreachable anyway. + +Gated on **WSL**, not on the protocol — there is no runtime signal for whether +CSI-u was negotiated (`vim.g.termfeatures` is `nil` even in a real TUI on 0.12), +so the binding is installed on every WSL session. If yours *does* speak CSI-u, +`Ctrl+Shift+Q` reaches its own mapping as normal and you additionally get bare +`Ctrl+Q` quitting from insert, losing literal-insert there. Off WSL none of it +applies and `Ctrl+Q` keeps every meaning it had. + +**Normal mode is the gap.** `Ctrl+Q` in normal mode is visual block, and it is +the only way to reach one — `Ctrl+V` there is paste. So on a terminal without +CSI-u, pressing `Ctrl+Shift+Q` from normal mode gives you a visual block, not +the prompt. That is a deliberate trade: quitting from normal mode still has +`:qa!`, while visual block would have nothing left. If you are in normal mode +and want the prompt, press `i` first, or use `:qa!`. + +**Nothing automated can check the CSI-u half.** `suite_quit.sh` writes +`ESC[113;6u` straight onto the pty, so it passes on a terminal where the chord +could never arrive; `demo.sh --check` cannot answer it either, and says so +rather than guessing — `--remote-send` consumes nvim's key *notation*, so it +delivers `` to the mapping and never exercises the terminal encoding at +all. (An earlier version of this page said it collapsed the chord. It does not; +that claim was measured against a screenless nvim and is retracted.) Which is +why this needed a human to press it. ## Keep `DRILL_HOME` off `/mnt/c` diff --git a/nvimrc.lua b/nvimrc.lua index c464bcd..ecf8b96 100644 --- a/nvimrc.lua +++ b/nvimrc.lua @@ -1258,3 +1258,47 @@ local function quit_drill() end map({ "n", "i", "t" }, "", quit_drill, S) + +-- ...and the same chord again, for terminals that cannot spell it. +-- +-- Everything above assumes CSI-u. Windows Terminal 1.24 is new enough to speak +-- it and does not negotiate it with nvim, so Ctrl+Shift+Q arrives as the plain +-- legacy 0x11 -- -- and the mapping above is simply never reached. The +-- documented way out of the editor does not exist on WSL, which leaves `:qa!`: +-- the one thing this whole section was written to stop being necessary. +-- +-- So bind what actually ARRIVES. This is not a second, different key to learn: +-- on such a terminal the user presses Ctrl+Shift+Q, exactly as documented, and +-- is what nvim is handed. +-- +-- INSERT AND TERMINAL, never normal, and gated on WSL. Each of those three +-- choices costs something: +-- +-- * normal mode keeps as visual block (CONFLICT 2). It is the ONLY +-- route to a block here, since in normal mode is paste -- so binding +-- the prompt there would leave no way to ask for one, while quitting still +-- has :qa!. From normal mode on such a terminal the chord gives you a +-- block; press i first. +-- +-- * terminal mode IS included, deliberately: is bound in n/i/t so +-- you can quit from inside the interpreter without pressing first, +-- and a fallback that skipped it would take that away on the one platform +-- that needs a fallback at all. It is not a free key there either -- +-- readline in emacs mode binds ^Q to quoted-insert -- but ^V is bound to +-- the same command, so quoted-insert survives. A fair trade, not a free +-- one. (drill.sh's `stty -ixon` is what frees ^Q from XON in the first +-- place.) +-- +-- * in insert, vanilla is literal-insert, the twin of . In a +-- Python scratchpad where is already paste that is close to +-- unreachable, and a quit prompt defaulting to Cancel is the better use of +-- the key -- but it IS a real vim behaviour, so nowhere but WSL loses it. +-- +-- The gate is IS_WSL, NOT the protocol: there is no runtime signal for whether +-- CSI-u was negotiated, so a WSL session whose terminal DOES speak it gets this +-- binding too, and bare quits from insert there as well. Off WSL -- +-- macOS, and Linux desktops with or without CSI-u -- the block is skipped and +-- nothing changes. +if IS_WSL then + map({ "i", "t" }, "", quit_drill, S) +end diff --git a/tests/suite_options.sh b/tests/suite_options.sh index 7c73089..6896c4f 100755 --- a/tests/suite_options.sh +++ b/tests/suite_options.sh @@ -131,6 +131,25 @@ end ok("quit_did_not_steal_ctrl_q", vim.fn.maparg("", "n"):lower() == "", vim.fn.maparg("", "n")) +-- The WSL quit fallback, which nothing else here reaches: on a terminal with no +-- CSI-u the chord arrives as plain , so the config binds it in insert AND +-- terminal mode, and deliberately NOT in normal, where it is the only route to +-- a visual block. Asserted from whichever side this machine is on -- a WSL box +-- proves the bindings exist, and CI (ubuntu-latest, no "microsoft" in +-- /proc/version) proves they do not leak onto a normal Linux desktop. Between +-- the two runs both halves are covered; on one machine only one half can be. +local on_wsl = vim.env.WSL_DISTRO_NAME ~= nil +if not on_wsl and vim.fn.filereadable("/proc/version") == 1 then + local first = vim.fn.readfile("/proc/version", "", 1)[1] or "" + on_wsl = first:lower():find("microsoft") ~= nil +end +for _, m in ipairs({ "i", "t" }) do + local bound = vim.fn.maparg("", m, false, true) + local has = type(bound) == "table" and bound.callback ~= nil + ok("wsl_quit_fallback_" .. m, has == on_wsl, + ("on_wsl=%s bound=%s"):format(tostring(on_wsl), tostring(has))) +end + -- Ctrl+C must stay unmapped in normal and terminal so SIGINT still lands ok("ctrlc_free_in_normal", vim.fn.maparg("", "n") == "", vim.fn.maparg("", "n")) ok("ctrlc_free_in_terminal", vim.fn.maparg("", "t") == "", vim.fn.maparg("", "t"))