tty: the switch chord is a setting, because only the host knows the answer

Third guess at a chord, and the third one would have been wrong too: a display
manager that grabs Ctrl+Alt+F<n> matches loosely, so Ctrl+Alt+Shift+F2 never
reaches the guest either. Extra modifiers do not get past a loose grab.

The real problem is that this is not knowable from inside. A key the host
swallowed is indistinguishable from a key nobody pressed, so the guest cannot
probe for a free combination, and there is no combination that is free
everywhere:

  the HOST takes    Alt+F4, Alt+F2, Ctrl+Alt+F1..F6 (loosely matched),
                    Ctrl+Alt+digit (QEMU's own console tabs)
  APPLICATIONS want Alt+anything (Meta -- zsh binds Meta-digit as
                    digit-argument), Ctrl+letter, Ctrl+Shift+digit
                    (Ctrl+Shift+2 types ^@), bare F-keys
  the LAYOUT wants  AltGr+letter

So it is a setting. VT_SETCHORD/VT_GETCHORD, and in userspace:

    chvt -c ctrl+shift+F       Ctrl+Shift+F1..F6   (the new default)
    chvt -c ctrl+alt+shift+F
    chvt -c alt+1
    chvt -c off                no chord; chvt N is then the only way
    chvt                       report the active terminal and the chord

`off` is a legitimate setting rather than a failure mode. On a machine whose
host grabs everything worth binding, `chvt N` is the honest answer.

The default moved to Ctrl+Shift+F1..F6: display managers bind Ctrl+Alt+F<n>,
not Ctrl+Shift+F<n>, and Ctrl+Shift with a *function* key is not a character
on any layout -- unlike Ctrl+Shift+digit, which is.

Caught while writing it: VTC_RALT was 0x08 while kbd.h's RALT is (1<<7), since
(1<<3) is CAPSLOCK. A chord set from userspace with AltGr would simply never
have matched, and the only symptom would have been a key that does nothing.
A _Static_assert now holds the two headers together.

Verified: Ctrl+Alt+F2 leaves the screen byte-identical, Ctrl+Shift+F2 switches,
Ctrl+Shift+F1 returns to a pixel-identical tty1, and chvt -c round-trips
through alt+1 and off and back. schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-13 13:13:38 +02:00
commit 7c0f7c7d01
8 changed files with 256 additions and 60 deletions

View file

@ -18,8 +18,8 @@ its distribution.
| API | **SchAPI** | The native programming interface — *designed, not built* |
| Compositor | **Schcom** | Owns the display; v0 runs |
Six virtual terminals sit under all of it -- `/dev/tty1`..`tty6`,
Ctrl+Alt+Shift+F1..F6 --
Six virtual terminals sit under all of it -- `/dev/tty1`..`tty6`, `chvt N`
or a settable chord --
each with its own screen, line discipline and foreground process group. See
[terminals.md](terminals.md).

View file

@ -1,8 +1,9 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
# Virtual terminals
Six of them, `/dev/tty1` through `/dev/tty6`, reached with
**Ctrl+Alt+Shift+F1 .. F6** or with `chvt N`. Each one is an independent terminal: its own screen, its own
Six of them, `/dev/tty1` through `/dev/tty6`, reached with `chvt N` or with
a keyboard chord — **Ctrl+Shift+F1 .. F6** by default, and settable, for
reasons below. Each one is an independent terminal: its own screen, its own
cursor, its own line discipline, its own foreground process group and its own
session. Two shells on two terminals are two unrelated processes that happen to
share a keyboard and a screen.
@ -107,49 +108,67 @@ into the terminal instead of switching away from it.
What the chord *means* is decided in `console.c`, where the terminals are. The
keyboard driver names it and stops.
### Why this chord
### The chord is a setting, and that is the point
**Anything the kernel swallows is taken from every program that will ever run
here, permanently.** That is the whole difficulty, and it took two wrong
answers to see it. The obvious candidates are all spoken for on one side or
the other:
**There is no combination that is free for everyone on every host**, and a
guest cannot find out what its host takes: a key the host swallowed is
indistinguishable from a key nobody pressed. Three parties have a claim on
every candidate.
| | |
|---|---|
| **The host takes it** | |
| `Alt+F4` | the window manager closes the QEMU window |
| `Alt+F2` | GNOME and KDE open a run dialog |
| `Ctrl+Alt+F1..F6` | the display manager switches its **own** terminals |
| **Applications want it** | |
| `Alt+`anything | Meta. zsh's own line editor binds Meta-digit as `digit-argument`; so does every readline editor |
| **The host takes it, and the guest never sees it** | |
| `Alt+F4`, `Alt+F2` | window manager |
| `Ctrl+Alt+F1..F6` | display manager — and it matches **loosely**, so adding Shift does not get past it |
| `Ctrl+Alt+`digit | QEMU's own console tabs |
| **Applications want it, and lose it permanently if the kernel eats it** | |
| `Alt+`anything | Meta. zsh binds Meta-digit as `digit-argument`; so does every readline editor |
| `Ctrl+`letter | control characters |
| `Ctrl+Shift+`digit | types a character — `Ctrl+Shift+2` is `^@` |
| `F1..F6` alone | help, and whatever a full-screen program wants |
| **The layout wants it** | |
| `AltGr+`letter | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr *is* Ctrl+Alt |
| **The keyboard layout wants it** | |
| `AltGr+`letter | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr arrives as Ctrl+Alt |
`Ctrl+Alt+Shift+F1..F6` is in none of those sets. Display managers bind the
same chord *without* Shift, so adding it steps out of the host's way, and no
terminal application binds a triple-modified function key. Four keys is more
than one wants to press, which is right for something done rarely and on
purpose — `chvt N` covers everything else.
So the kernel ships a default and lets it be moved:
Which chords actually reach a guest was **measured, not assumed**: a temporary
```sh
chvt # report the active terminal and the current chord
chvt -c ctrl+shift+F # Ctrl+Shift+F1..F6 (the default)
chvt -c ctrl+alt+shift+F # if your host leaves that alone
chvt -c alt+1 # Alt+1..6
chvt -c off # no chord; chvt N is then the only way
```
`off` is a legitimate setting, not a failure mode. On a machine whose host
grabs everything worth binding, `chvt N` from a shell is the honest answer.
The default is `Ctrl+Shift+F1..F6` because display managers bind `Ctrl+Alt+F<n>`
rather than `Ctrl+Shift+F<n>`, and because `Ctrl+Shift` with a *function key*
is not a character on any layout — unlike `Ctrl+Shift+`digit, which is.
Which chords reach a guest at all was **measured, not assumed**: a temporary
probe in `kbdgetc` logged every modified keypress, and QEMU's `sendkey` fired
each candidate at it. Everything reached the guest, including `Ctrl+Alt+F2` —
so the host conflict is the host's display manager, not QEMU.
each candidate at it. Everything arrived, `Ctrl+Alt+F2` included — so that
conflict is the host's display manager, not QEMU, and no amount of extra
modifiers gets around a grab that matches loosely.
The modifier test is an **equality**, not a mask:
```c
if ((shift & (CTL | SHIFT | ALT | RALT)) == (CTL | ALT | SHIFT) && ...)
if (vt_chord_mods != 0 &&
(shift & (CTL | SHIFT | ALT | RALT)) == vt_chord_mods && ...)
```
A mask would fire on any superset, so a chord aimed at the host would switch
A mask fires on any superset, so a chord aimed at the host would switch
terminals behind your back. Right Alt is tracked as its own bit (`RALT`) for
the same reason; it used to share `ALT`, which is harmless for typing — neither
is used to look up a character — and wrong for chords.
`include/posix.h`'s `VTC_*` bits mirror `include/kbd.h`'s modifier bits, and a
`_Static_assert` in `kbd.c` holds them together. A mismatch would be silent:
a chord set from userspace would simply never match, and the only symptom
would be a key that does nothing.
### Taking the chord back
Three layers, in increasing order of how much a program is claiming: