diff options
| author | Peter Geissler <peter@haxx.in> | 2026-08-28 14:41:42 +0200 |
|---|---|---|
| committer | Peter Geissler <peter@haxx.in> | 2026-08-28 14:41:42 +0200 |
| commit | 2fbd36c1cef6ea6c9fcf9c36afb5209089288257 (patch) | |
| tree | c95a97a4277306eddc7daeb67dddac6daa1e943d | |
| parent | windows build: re-stamp version.h when the version changes (diff) | |
| download | slosh-2fbd36c1cef6ea6c9fcf9c36afb5209089288257.tar.gz slosh-2fbd36c1cef6ea6c9fcf9c36afb5209089288257.tar.xz slosh-2fbd36c1cef6ea6c9fcf9c36afb5209089288257.zip | |
ctrl_d_exits: Ctrl-D at a prompt, where the tty cannot do it
Windows has no VEOF. The console driver does not know the character, so
^D arrives at cmd.exe and PowerShell as an ordinary keystroke and they
ignore it -- it even echoes, which is how you find out: the line reads
`echo hello^D`. The one key that closes a pane on every other platform
does nothing at all, and closing a pane becomes a word you have to type.
That you can type `exit` instead is the tell: the intent was never
ambiguous, only unspoken. So slosh speaks it. What goes to the pane is
the word, not a signal and not a close from outside -- the shell runs
its own cleanup, returns its own status, and keep_dead decides what is
left behind, all three of which reaching past it would skip. It is also
what the user would have typed, so there is nothing new to explain.
The knob is what makes it honest, because a wrong guess costs a pane.
It fires only with the shell itself in the foreground (a program that
is running may want the EOF for itself -- `cat` reading to end is the
whole job), only on the primary screen (in an editor or a pager ^D is
half a page down, and the alternate screen is the pane stating that
rather than us matching process names), and only on an empty line
(POSIX sends EOF there; what it does not do is discard what you typed).
The line is an estimate. The shell's editor is on the far side of a
pty, so pane_send_key counts what it forwarded since the last Enter.
Editing it does not model -- a recalled history entry, ^U, completion
-- leaves the count too high, which forgets to exit. That is the
direction to be wrong in; the other one throws away a half-typed
command.
Default true on Windows, false on POSIX, where the line discipline
already does this and does it properly: ^D there is also EOF for `cat`
and every REPL, none of which is ours to intercept. A knob rather than
an #ifdef so the guards are the same code everywhere and can be tested
where the test suite runs -- test_ctrl_d.py turns it on and drives all
four decisions, plus the case that does exit.
Telling trapped from forwarded needs a program that reports what it
received: on POSIX a forwarded ^D also ends an idle shell, and looking
for the word `exit` cannot separate them either, because bash prints
`exit` itself on EOF.
Verified on the Windows box as well, where it is the default: ^D at an
idle cmd.exe prompt takes the pane (and with it the session, having no
panes left), `more` in the foreground keeps its own EOF, and a
half-typed line survives with the line still on screen.
| -rw-r--r-- | config/config.kdl | 21 | ||||
| -rw-r--r-- | docs/config.md | 2 | ||||
| -rw-r--r-- | docs/slosh.5 | 10 | ||||
| -rw-r--r-- | docs/windows.md | 11 | ||||
| -rw-r--r-- | include/config.h | 24 | ||||
| -rw-r--r-- | include/slosh.h | 5 | ||||
| -rw-r--r-- | src/app.c | 35 | ||||
| -rw-r--r-- | src/config.c | 15 | ||||
| -rw-r--r-- | src/pane.c | 27 | ||||
| -rw-r--r-- | tests/test_ctrl_d.py | 192 |
10 files changed, 341 insertions, 1 deletions
diff --git a/config/config.kdl b/config/config.kdl index f7a79e3..5f7abaa 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -289,6 +289,27 @@ status_pad 4 // expands a collapsed pane just because the pointer crossed its header. focus_follows_mouse true +// Ctrl-D at an idle shell prompt exits that shell. +// +// On POSIX the tty does this: ^D on an empty line is EOF, the shell reads it +// and goes. Windows has no such character -- the console driver does not know +// it, so cmd.exe and PowerShell ignore the one key that closes a pane +// everywhere else. Typing `exit` works, which is the tell: the intent was +// never ambiguous, only unspoken. So slosh sends exactly that word, and the +// pane ends the way it would have anyway -- the shell's own cleanup, its own +// exit status, and keep_dead above deciding what is left behind. +// +// Default: true on Windows, false everywhere else, where the real thing is +// already there and better than an imitation. Turn it on if you want the key +// to mean the same on both. +// +// It refuses unless all three hold, because a wrong guess costs a pane: +// nothing running but the shell (a program in the foreground may want the EOF +// itself), the primary screen (in an editor or a pager ^D is half a page +// down), and an empty line (POSIX sends EOF there; it does not throw away +// what you had typed). +ctrl_d_exits false + // Whether the program running in a pane may set that pane's shader chains, by // printing an escape sequence: // diff --git a/docs/config.md b/docs/config.md index c954de2..86cfc79 100644 --- a/docs/config.md +++ b/docs/config.md @@ -113,7 +113,7 @@ theme { frame_focus "#00ff88" } // ...but that one colour is mine |---|---| | geometry | `gap` `gap_aspect` `padding` `compact` `rounded` `title_align` `title_inset` `min_pane` `min_split` | | chrome | `status_bar` `status_line` `status_pad` `hints` `version_banner` `pane_buttons` `bell_indicator` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) | -| behaviour | `focus_follows_mouse` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `splash_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `float_shadow` `keep_dead` `in_band_shaders` `shell` `editor` `shader_dir` | +| behaviour | `focus_follows_mouse` `ctrl_d_exits` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `splash_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `float_shadow` `keep_dead` `in_band_shaders` `shell` `editor` `shader_dir` | | projects | `project_roots` `project_layout` (see [workspaces](workspaces.md)) | | colour | `theme { }` | | effects | `shaders { }`, `states { }` (see [shaders](shaders.md) and [chrome](chrome.md)) | diff --git a/docs/slosh.5 b/docs/slosh.5 index ec862b5..59465b2 100644 --- a/docs/slosh.5 +++ b/docs/slosh.5 @@ -147,6 +147,16 @@ The glyphs themselves, each one string. Hovering focuses \(em never at the cost of what you were doing: not during a prefix, a drag, or a modal. .TP +.BI ctrl_d_exits\ bool +Ctrl-D at an idle shell prompt sends +.B exit +to that shell, standing in for the EOF the tty would deliver on POSIX. +On by default on Windows, which has no VEOF and so ignores the key +entirely; off elsewhere, where the line discipline already does it. +Only with the shell itself in the foreground, on the primary screen, +and on an empty line \(em in an editor or a pager Ctrl-D is half a page +down, and a line with something on it is not an EOF. +.TP .BI scroll_lines\ n Rows per wheel notch. .TP diff --git a/docs/windows.md b/docs/windows.md index 861a3cd..4d01a0d 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -126,6 +126,17 @@ the session on the one program that misbehaves. Whoever finishes last frees. `tcgetpgrp()` would have named. Windows offers the image name rather than a full argv without reading another process's memory, so pane titles are correspondingly shorter. +- **No `VEOF`, so Ctrl-D is made to mean something.** The console driver has + no EOF character: `^D` reaches cmd.exe and PowerShell as an ordinary + keystroke and they ignore it, so the key that closes a pane on every other + platform does nothing at all. `ctrl_d_exits` (on by default here, off + elsewhere) sends the shell the word `exit` instead — which is what you would + have typed, and what makes the pane end through the shell's own cleanup and + exit status rather than by being closed from outside. It stays out of the + way unless all three hold: the shell itself in the foreground, the primary + screen, and an empty line. A program that is running may want the EOF + itself; in an editor or a pager `^D` is half a page down; and a line with + something on it is not an EOF anywhere. - **No editor is guaranteed.** `C-a e` prefers whichever console editor is actually installed — `nvim`, `vim`, `vi`, `nano`, `micro`, `hx`, `edit` — because the obvious fallback is the wrong shape: `notepad` is a GUI program, diff --git a/include/config.h b/include/config.h index 00add4e..b7d5023 100644 --- a/include/config.h +++ b/include/config.h @@ -306,6 +306,30 @@ typedef struct { uint16_t status_pad; bool focus_follows_mouse; + /* Whether Ctrl-D at an idle shell prompt exits that shell, the way the tty + * line discipline would do it on POSIX. + * + * Windows has no VEOF: the console driver does not know the character, so + * cmd.exe and PowerShell simply ignore it and the one keystroke that closes + * a pane everywhere else does nothing. Typing `exit` works, which is the + * tell -- the intent was never ambiguous, only unspoken. So slosh speaks it, + * and that is exactly what it sends: the pane exits through the same door as + * a typed `exit`, with the shell's own cleanup and exit status, and + * `keep_dead` decides what is left behind. Closing the pane from outside + * would skip all three. + * + * Off on POSIX, where the line discipline already does this properly and + * an emulation could only get in the way -- ^D there is also EOF for `cat` + * and every REPL, and none of that is ours to intercept. On for anyone who + * wants the same key to mean the same thing on both. + * + * Narrow on purpose, because a wrong guess costs a pane. It fires only with + * the shell itself in the foreground (no child process to be sending EOF + * to), on the primary screen (in an editor or a pager ^D is half a page + * down), and on an empty line -- where POSIX would send EOF rather than + * discard what you typed. */ + bool ctrl_d_exits; + /* Whether the program running in a pane may set that pane's shader chains * over OSC 5577. Off, because a program restyling the session it happens to * be running in is a hazard and not a feature (D13) -- and on for anyone diff --git a/include/slosh.h b/include/slosh.h index 3e293b3..e1ab08b 100644 --- a/include/slosh.h +++ b/include/slosh.h @@ -248,6 +248,11 @@ void pane_scroll_edge(pane_t *p, bool top); bool pane_scrolled(const pane_t *p); void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total); bool pane_alt_screen(const pane_t *p); +/* Whether the line being typed looks empty, counted from the keys forwarded + * since the last Enter. An estimate: the shell's line editor is on the far + * side of the pty and cannot be asked. Only ctrl_d_exits reads it, and only + * to refuse -- see the field in pane.c for which way it errs. */ +bool pane_line_empty(const pane_t *p); bool pane_wants_mouse(const pane_t *p); const char *pane_title(const pane_t *p); /* A BEL arrived and the pane has not been looked at since. */ @@ -1372,6 +1372,40 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { return run_action(a, config_lookup(&CFG, ev->key, ev->mods)); } +/* Ctrl-D where the line discipline cannot do it: see `ctrl_d_exits` in + * config.h for why this exists and why it is this narrow. + * + * What it sends is the word, not a signal. `exit` is what the user would have + * typed and what every shell slosh can start already understands, so the pane + * ends the way it ends on POSIX -- the shell's own cleanup, its own exit + * status, and `keep_dead` deciding whether the corpse stays. Reaching past the + * shell to close the pane would skip all three, and would also be a second + * meaning for a key that already has one. + * + * Returns true when the keystroke was spent here and must not be forwarded. */ +static bool ctrl_d_exit(app_t *a, const input_event_t *ev) { + if (!CFG.ctrl_d_exits || ev->action == KEY_RELEASE) return false; + if (ev->key != GHOSTTY_KEY_D) return false; + if ((ev->mods & (MOD_CTRL | MOD_ALT | MOD_SUPER | MOD_SHIFT)) != MOD_CTRL) + return false; + + pane_t *p = cur(a)->focus->pane; + /* An editor or a pager reads ^D as half a page down, and both live on the + * alternate screen -- which is the one thing here the pane can state rather + * than have guessed about it. */ + if (pane_alt_screen(p)) return false; + /* A program in the foreground is a program that may want the EOF itself. + * NULL is the shell at its own prompt, with nothing running under it. */ + char fg[1024]; + if (pane_foreground(p, fg, sizeof fg)) return false; + /* On a line with something on it, POSIX sends EOF and the shell ignores it; + * what it does not do is throw the line away. Neither do we. */ + if (!pane_line_empty(p)) return false; + + pane_write(p, "exit\r", 5); + return true; +} + /* What an action does, given the action rather than the key that asked for it * -- because the command palette asks for actions by name, and a palette that * had to synthesise a keystroke to run one would be inventing input. */ @@ -1672,6 +1706,7 @@ void app_event(app_t *a, const input_event_t *ev) { pane_start(cur(a)->focus->pane); break; } + if (ctrl_d_exit(a, ev)) break; pane_send_key(cur(a)->focus->pane, ev); break; case EV_MOUSE: { diff --git a/src/config.c b/src/config.c index 8ca9fcf..ae22278 100644 --- a/src/config.c +++ b/src/config.c @@ -919,6 +919,14 @@ void config_defaults(config_t *c) { c->status_pad = 4; c->focus_follows_mouse = true; c->in_band_shaders = false; + /* Windows has no VEOF for the line discipline to act on, so ^D there is a + * key that does nothing until we make it mean something. POSIX already has + * it, and better than we could. */ +#ifdef _WIN32 + c->ctrl_d_exits = true; +#else + c->ctrl_d_exits = false; +#endif c->default_fg = rgb(0xff, 0xff, 0xff); c->default_bg = rgb(0x00, 0x00, 0x00); @@ -1601,6 +1609,10 @@ char *config_render(const config_t *c) { cb_add(&b, "\n// ---- behaviour ----\n"); cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse)); cb_add(&b, "in_band_shaders %s\n", yesno(c->in_band_shaders)); + cb_add(&b, + "ctrl_d_exits %s // ^D at an idle prompt exits the shell; " + "default on Windows only\n", + yesno(c->ctrl_d_exits)); cb_add(&b, "scroll_lines %u\n", c->scroll_lines); cb_add(&b, "scrollback %zu // lines of history per pane; 0 for none\n", @@ -1838,6 +1850,7 @@ static const char *const KNOWN_TOP[] = { "bell_mark", "close_mark", "compact", + "ctrl_d_exits", "dim_unfocused", "double_click_ms", "word_separators", @@ -2082,6 +2095,8 @@ static bool load_into(config_t *c, const char *path, int depth, char *err, 0, c->focus_follows_mouse); c->in_band_shaders = kdl_arg_bool(kdl_child(root, "in_band_shaders"), 0, c->in_band_shaders); + c->ctrl_d_exits = + kdl_arg_bool(kdl_child(root, "ctrl_d_exits"), 0, c->ctrl_d_exits); const char *align = kdl_arg(kdl_child(root, "title_align"), 0, NULL); if (align) { @@ -65,6 +65,16 @@ struct pane { bool ephemeral; char **argv; char *cwd; + /* Roughly how much is sitting on the line being typed, counted from the + * keys we forwarded since the last Enter. The tty knows this exactly and we + * cannot ask it -- the shell's line editor is on the far side of a pty -- + * so it is an estimate, and the one caller treats it as one: ctrl_d_exits + * acts only on zero, which is the case a miscount would have to invent + * rather than merely misjudge. Line editing we do not model (a recalled + * history entry, ^U, tab completion) leaves it too high, which forgets to + * exit; that is the direction to be wrong in, because the other one throws + * away a half-typed command. */ + uint16_t line_len; /* A command line, not a word: a layout can name an interpreter by its full * path (Homebrew's python3 resolves to 124 characters before its arguments * even start), and a truncated label is not merely cosmetic -- it is what @@ -721,6 +731,21 @@ void pane_send_key(pane_t *p, const input_event_t *ev) { /* Typing snaps back to the live view: reading scrollback and then typing * into a screen that is not the one you are looking at is a trap. */ if (pane_scrolled(p)) pane_scroll_edge(p, false); + + /* Track the line as it is typed. Only presses: a repeat is another + * character, a release is not. */ + if (ev->action != KEY_RELEASE) { + if (ev->key == GHOSTTY_KEY_ENTER) + p->line_len = 0; + else if (ev->key == GHOSTTY_KEY_BACKSPACE) { + if (p->line_len) p->line_len--; + } else if (ev->text_len && (uint8_t)ev->text[0] >= 0x20 && + p->line_len < UINT16_MAX) + /* Text, not keystrokes: a chord that produces no character (^L, an + * arrow) is not on the line, and the 0x20 floor keeps the control + * codes some encodings emit as text out of the count. */ + p->line_len++; + } ghostty_key_encoder_setopt_from_terminal(p->kenc, p->term); ghostty_key_event_set_key(p->kev, (GhosttyKey)ev->key); ghostty_key_event_set_mods(p->kev, (GhosttyMods)ev->mods); @@ -1174,6 +1199,8 @@ void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total) { *total = (uint32_t)(sb.total > sb.len ? sb.total - sb.len : 0); } +bool pane_line_empty(const pane_t *p) { return p && p->line_len == 0; } + bool pane_alt_screen(const pane_t *p) { GhosttyTerminalScreen screen = GHOSTTY_TERMINAL_SCREEN_PRIMARY; ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, &screen); diff --git a/tests/test_ctrl_d.py b/tests/test_ctrl_d.py new file mode 100644 index 0000000..c95ddab --- /dev/null +++ b/tests/test_ctrl_d.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""`ctrl_d_exits`: Ctrl-D at an idle prompt, where the tty cannot do it. + +Windows has no VEOF, so ^D there is a key that does nothing. The knob makes +slosh send `exit` on the user's behalf -- but only where that is unambiguously +what ^D meant, because every other case costs a pane. Those refusals are the +interesting half, and most of what is below. + +Off by default on POSIX, so the tests that want it say so. The guards are the +same code on every platform, which is why it is a knob and not an `#ifdef` +nobody outside Windows could ever run. + +Telling trapped from forwarded needs care here. On POSIX a *forwarded* ^D also +ends an idle shell -- that is the line discipline doing its job -- so "did the +pane go" cannot tell the two apart, and neither can looking for the word +`exit`, which bash prints itself on EOF. PROBE settles it: a program that +reads one line and shows exactly what arrived. `exit` is what slosh types; +an empty line is EOF reaching the program untouched. +""" + +import os +import sys +import tempfile + +from harness import Session, check, report + +SH = ["/bin/sh"] +# Reads one line, then says what it got. The sleep keeps the pane up long +# enough to be read: the answer is on screen, not in an exit status. +PROBE = ["/bin/sh", "-c", 'read x; echo "GOT:[$x]"; sleep 5'] +# The same, from the alternate screen, switching back so the answer is visible. +PROBE_ALT = [ + "/bin/sh", + "-c", + 'printf "\\033[?1049h"; read x; printf "\\033[?1049l"; echo "GOT:[$x]"; sleep 5', +] + + +def conf(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +ON = conf("ctrl_d_exits true\n") +OFF = conf("ctrl_d_exits false\n") +# keep_dead all: the corpse stays, so its exit status can be read. +CORPSE = conf("ctrl_d_exits true\nkeep_dead all\n") + + +def got(s): + """What the probe program actually received.""" + snap = s.until_text("GOT:") + for line in snap.screen().splitlines(): + if "GOT:[" in line: + return line.split("GOT:[", 1)[1].split("]", 1)[0] + return None + + +def gone(s): + return len(s.panes()) == 0 + + +def test_ctrl_d_at_an_idle_prompt_ends_the_shell(): + with Session(SH, cols=60, rows=10, config=ON) as s: + s.settle(80) + check("the pane is alive to begin with", len(s.panes()) == 1, str(s.panes())) + s.send("\\x04") + snap = s.until(lambda _: gone(s)) + check("^D closed it", gone(s), snap.screen()) + + +def test_what_arrives_is_the_word_exit(): + with Session(PROBE, cols=60, rows=10, config=ON) as s: + s.settle(80) + s.send("\\x04") + check("the pane was sent `exit`", got(s) == "exit", repr(got(s))) + + +def test_the_shell_exits_rather_than_being_reached_past(): + """The shell ends itself: status 0, no signal. + + Closing the pane from outside would leave no status at all, and killing the + process would leave a signal. Both are visible here, which makes this the + test that says *how* it was done rather than only that it was.""" + with Session(SH, cols=60, rows=10, config=CORPSE) as s: + s.settle(80) + s.send("\\x04") + s.until(lambda _: not s.pane(0)["alive"]) + pane = s.pane(0) + check("the pane is dead but kept", not pane["alive"], str(pane)) + check("it exited, and was not signalled", pane["exit_signal"] == 0, str(pane)) + check("with the shell's own status", pane["exit_code"] == 0, str(pane)) + + +def test_off_by_default(): + with Session(PROBE, cols=60, rows=10) as s: + s.settle(80) + s.send("\\x04") + check("the raw ^D is forwarded, and arrives as EOF", got(s) == "", repr(got(s))) + + +def test_the_knob_turns_it_off(): + with Session(PROBE, cols=60, rows=10, config=OFF) as s: + s.settle(80) + s.send("\\x04") + check("off means off", got(s) == "", repr(got(s))) + + +def test_a_half_typed_line_is_not_thrown_away(): + """POSIX sends EOF on a line with something on it and the shell ignores it. + What it does not do is discard the line, and neither do we -- this is the + guard that stops a mistyped ^D costing the command you were composing.""" + with Session(SH, cols=60, rows=10, config=ON) as s: + s.settle(80) + s.send("echo hello") + s.settle(60) + s.send("\\x04") + s.settle(120) + check("the pane survived", len(s.panes()) == 1, str(s.panes())) + screen = s.snapshot().screen() + check("and the line is still there", "echo hello" in screen, screen) + + +def test_backspacing_the_line_away_makes_it_empty_again(): + """The count comes back down, so a typo you corrected does not wedge ^D + off for the rest of the line.""" + with Session(SH, cols=60, rows=10, config=ON) as s: + s.settle(80) + s.send("ab") + s.settle(60) + s.send("\\x7f\\x7f") # two backspaces + s.settle(60) + s.send("\\x04") + snap = s.until(lambda _: gone(s)) + check("an emptied line exits again", gone(s), snap.screen()) + + +def test_enter_clears_the_line(): + """Having run a command must leave the next prompt exitable.""" + with Session(SH, cols=60, rows=10, config=ON) as s: + s.settle(80) + s.send("echo one\\r") + s.until_text("one") + s.send("\\x04") + snap = s.until(lambda _: gone(s)) + check("^D works at the next prompt", gone(s), snap.screen()) + + +def test_a_program_in_the_foreground_keeps_its_own_eof(): + """`cat` wants the EOF itself: reading stdin to the end is the whole job. + Swallowing ^D here would break the thing this is imitating.""" + with Session(SH, cols=60, rows=10, config=ON) as s: + s.settle(80) + s.send("cat\\r") + s.settle(120) + s.send("hi\\r") + s.until_text("hi") + s.send("\\x04") # ends cat, not the shell + s.settle(150) + check( + "the pane is still here: ^D went to cat", + len(s.panes()) == 1, + str(s.panes()), + ) + s.send("echo back\\r") + snap = s.until_text("back") + check( + "and the shell has its prompt again", "back" in snap.screen(), snap.screen() + ) + + +def test_the_alternate_screen_keeps_ctrl_d(): + """In an editor or a pager ^D is half a page down. The alternate screen is + the pane stating so, rather than us guessing from a process name. + + Same program and same knob as test_what_arrives_is_the_word_exit, differing + only by the screen it reads from -- so an empty answer here is the screen + guard and nothing else.""" + with Session(PROBE_ALT, cols=60, rows=10, config=ON) as s: + s.settle(120) + s.send("\\x04") + check("^D was forwarded, not spent", got(s) == "", repr(got(s))) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +for path in (ON, OFF, CORPSE): + os.unlink(path) +sys.exit(report()) |
