Deliver the keys that are not characters

An arrow key has never reached this machine. Voyager threw it away for want of
anywhere to put it, and a terminal sent ESC [ A, which arrived in the middle of
whatever was being read and made it unrecognisable - typing Up at the CosmOS
prompt put three bytes in the command line and got "I do not know".

So the console names them: one byte each, 0x80 upward, above ASCII so nothing
written before them can collide. Up, Down, Left, Right, Home, End and forward
Delete, with room above for the paging and function keys.

The console normalises, which is what it already does. Behind a window it turns
the key somebody pressed into a byte; on a terminal it turns the sequence into
the same byte. That is the act it has always performed on Return and Backspace,
one layer further along, and it is why a program need not know which of the two
it is talking to. What a key MEANS is not the console's business - that belongs
to whoever is reading, the same way what is on a disk belongs to the system and
what a drive is belongs to the machine.

Translated only when standard input really is a terminal. Nothing else sends
these sequences, a pipe holds exactly the bytes somebody put in it, and it keeps
the Escape-or-Up timing problem out of every test here: a test writes the key
values themselves. Line mode drops them, in both front ends, because line mode
delivers characters and a line somebody else has finished editing cannot be
moved about in.

Press.sbx says what it was handed, in hexadecimal and by name, and reads a line
before it reads keys so both halves of that rule are checked. Two recordings,
one fed as standard input and one as a keyboard, agreeing byte for byte; each
break fails exactly one of them. Three checks in terminal.sh type real escape
sequences at a pseudo-terminal, which is the only place they are ever read as
sequences: that they arrive as keys, that Escape alone is still Escape, and that
a character typed straight after an escape is held rather than swallowed.

Five recordings re-blessed for Press.sbx appearing on the shared disk, and the
whole of that diff is the file's own line and the counts above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-08-31 22:24:11 -04:00
co-authored by Claude Opus 5
parent 2a29cebc6b
commit b3726c950a
17 changed files with 646 additions and 35 deletions
+184 -28
View File
@@ -432,7 +432,13 @@ static int consoleGatherLine(void) {
// No room, or nothing a line is made of. A control byte that means something to a
// terminal means nothing here yet, and putting it in the line would only hand a
// program something it cannot use.
if (got < 0x20 || consoleLineLength >= CONSOLE_LINE_BYTES - 1) {
//
// THE CONSOLE'S OWN KEYS GO THE SAME WAY, from above rather than below: this is the
// gatherer, which is what line mode IS behind a window, and line mode delivers
// characters. An arrow key pressed while something else is collecting the line
// arrived too late to move anything.
if (got < 0x20 || (got >= CONSOLE_KEY_FIRST && got <= CONSOLE_KEY_LAST) ||
consoleLineLength >= CONSOLE_LINE_BYTES - 1) {
continue;
}
consoleLine[consoleLineLength++] = (unsigned char)got;
@@ -444,6 +450,168 @@ void consoleSetInputHook(int (*hook)(int mayWait)) {
inputHook = hook;
}
// ---- Reading standard input, in one place ----
//
// The blocking read and the poll behind the status port each reached for read() themselves,
// which was fine while one byte from the host was one byte for the program. It stopped
// being fine the moment a key could arrive as several: a sequence half taken by one path
// and half by the other is not a key, it is two pieces of rubbish. So there is one way in
// now, and the translation below sits on top of it.
//
// Answers a byte, CONSOLE_NOTHING_YET when nothing is waiting and it was told not to wait,
// or CONSOLE_GONE at the end of input. Deliberately the same three answers a front end's
// hook gives, so that everything above this treats a terminal and a window alike.
static int consoleFromInput(int mayWait) {
if (!mayWait) {
struct pollfd waiting = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
if (poll(&waiting, 1, 0) <= 0 || (waiting.revents & (POLLIN | POLLHUP)) == 0) {
return CONSOLE_NOTHING_YET;
}
}
unsigned char byte;
for (;;) {
const ssize_t got = read(STDIN_FILENO, &byte, 1);
if (got == 1) {
return byte;
}
if (got == 0) {
return CONSOLE_GONE;
}
if (errno != EINTR) {
return CONSOLE_GONE;
}
// Interrupted before anything arrived, so ask again.
}
}
// ---- What a terminal sends for a key that is not a character ----
//
// ESC [ A and its neighbours. A window hands over the key somebody pressed; a terminal
// hands over the sequence it was taught to send decades ago, and something has to turn one
// into the other. It happens here because the console is already the thing that turns a
// window's Enter key into a newline, and because doing this once in C is a great deal
// better than doing it in every program that wants an arrow key.
//
// ONLY WHEN THERE IS A TERMINAL. Nothing else sends these: a file or a pipe holds exactly
// the bytes somebody put in it, and translating there would mean an escape byte followed by
// a bracket could never be read back as what it is. It also keeps the timing problem below
// out of every test this machine has, which is the larger gain - a test writes the key
// values themselves and no terminal is involved in reading them.
//
// THE TIMING PROBLEM, which only the terminal case has: pressing Escape and pressing Up
// both begin with 0x1B, and the difference is that Up is followed immediately by more. So
// after an escape the console waits a moment to see whether anything is. A keyboard
// delivers a whole sequence in one go, so the wait is only ever spent when somebody really
// did press Escape, and it is far shorter than the gap between two keystrokes.
#define CONSOLE_ESCAPE_WAIT_MS 30
static int consoleTerminalKnown = 0; // isatty has been asked.
static int consoleHasTerminal = 0; // and this is what it said.
static int consoleTranslatingKeys(void) {
if (!consoleTerminalKnown) {
consoleHasTerminal = isatty(STDIN_FILENO);
consoleTerminalKnown = 1;
}
return consoleHasTerminal;
}
// A byte taken while deciding what an escape was, which turned out not to belong to it.
// One is enough: it is only ever the byte immediately after an escape that can be both
// part of a sequence and an ordinary character.
static int consoleHeldByte = -1;
// The next byte of a sequence, or below zero if the terminal has stopped talking. The wait
// is what tells a sequence from a keypress, so this is the one read in the console that
// times out rather than blocking.
static int consoleSequenceByte(void) {
struct pollfd waiting = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
if (poll(&waiting, 1, CONSOLE_ESCAPE_WAIT_MS) <= 0 || (waiting.revents & POLLIN) == 0) {
return -1;
}
return consoleFromInput(1);
}
// An escape has just been read. Answers the key it began, 0x1B if it was the Escape key
// itself, or below zero for a sequence this console has no key for.
static int consoleKeyFromEscape(void) {
const int intro = consoleSequenceByte();
if (intro < 0) {
// Nothing followed it, so somebody pressed Escape.
return 0x1B;
}
if (intro != '[' && intro != 'O') {
// An escape and then something else, close enough together to look like one thing.
// It was two: the escape is delivered now and the other byte waits its turn rather
// than being dropped, because it is an ordinary character somebody typed.
consoleHeldByte = intro;
return 0x1B;
}
// ESC [ 3 ~ carries a number and ESC [ A does not, and both end in a byte that says
// which kind it was. So the digits are collected and the ending decides.
int number = 0;
int final = consoleSequenceByte();
while (final >= '0' && final <= '9') {
number = number * 10 + (final - '0');
final = consoleSequenceByte();
}
switch (final) {
case 'A': return CONSOLE_KEY_UP;
case 'B': return CONSOLE_KEY_DOWN;
case 'C': return CONSOLE_KEY_RIGHT;
case 'D': return CONSOLE_KEY_LEFT;
// Terminals disagree about Home and End more than about anything else here, so both
// spellings of each are taken: the lettered one, and the numbered one that the
// terminals which do not use letters send instead.
case 'H': return CONSOLE_KEY_HOME;
case 'F': return CONSOLE_KEY_END;
case '~':
switch (number) {
case 1: case 7: return CONSOLE_KEY_HOME;
case 4: case 8: return CONSOLE_KEY_END;
case 3: return CONSOLE_KEY_DELETE;
default: break;
}
break;
default: break;
}
// A sequence this console has no key for. THE WHOLE OF IT GOES rather than the bytes
// being handed on, because it is a control sequence and not text: a program given the
// tail of one would put a bracket and a letter into whatever it was reading, which is
// the exact fault this whole translation exists to end.
return -1;
}
// One key from standard input: a byte as it arrived, or one of the console's own key values
// where a terminal sent a sequence meaning one.
static int consoleKeyFromInput(int mayWait) {
for (;;) {
int got;
if (consoleHeldByte >= 0) {
got = consoleHeldByte;
consoleHeldByte = -1;
} else {
got = consoleFromInput(mayWait);
}
if (got < 0) {
return got;
}
if (got == 0x1B && consoleTranslatingKeys()) {
got = consoleKeyFromEscape();
}
// Nothing to hand over: either that sequence meant nothing here, or it meant a key
// and line mode does not deliver keys. Both are the same answer to a caller - there
// is still no byte - so a blocking read asks again and a poll says so and leaves.
if (got < 0 || (!consoleKeyMode && got >= CONSOLE_KEY_FIRST && got <= CONSOLE_KEY_LAST)) {
if (!mayWait) {
return CONSOLE_NOTHING_YET;
}
continue;
}
return got;
}
}
uint8_t consoleReadByte(void) {
// Taking the byte answers whatever the console was asking about, so the line comes
// down here as well as when the CPU acknowledges it. Otherwise a program that reads
@@ -485,25 +653,15 @@ uint8_t consoleReadByte(void) {
consoleWaited();
}
}
unsigned char byte;
for (;;) {
ssize_t got = read(STDIN_FILENO, &byte, 1);
if (got == 1) {
return byte;
}
if (got == 0) {
// End of input. Still 0xFF, which is what getchar's EOF became when this was
// the only answer available, so nothing written against the old behaviour
// changes. The ENDED bit is the new way to know it was not a real byte.
consoleEnded = 1;
return 0xFF;
}
if (errno != EINTR) {
consoleEnded = 1;
return 0xFF;
}
// Interrupted before anything arrived, so ask again.
const int got = consoleKeyFromInput(1);
if (got >= 0) {
return (uint8_t)got;
}
// End of input. Still 0xFF, which is what getchar's EOF became when this was the only
// answer available, so nothing written against the old behaviour changes. The ENDED bit
// is the new way to know it was not a real byte.
consoleEnded = 1;
return 0xFF;
}
// Asking the host whether anything is waiting, and TAKING IT IF THERE IS. The byte goes
@@ -551,16 +709,14 @@ static void consoleFetch(void) {
}
return;
}
struct pollfd waiting = { .fd = STDIN_FILENO, .events = POLLIN, .revents = 0 };
if (poll(&waiting, 1, 0) <= 0 || (waiting.revents & (POLLIN | POLLHUP)) == 0) {
return;
}
unsigned char byte;
ssize_t got = read(STDIN_FILENO, &byte, 1);
if (got == 1) {
consolePushback = byte;
} else if (got == 0) {
const int got = consoleKeyFromInput(0);
if (got >= 0) {
consolePushback = got;
} else if (got == CONSOLE_GONE) {
consoleEnded = 1;
} else {
// Nothing waiting, which is the ordinary answer and not news.
return;
}
// A read that failed for any other reason is left alone: the next attempt asks again,
// and an interrupted poll is not news.