aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-17 14:13:44 +0200
committeruser <user@clank>2026-08-17 14:13:44 +0200
commite2eac8ef074d15cbff4844b956ddbe1031200dc8 (patch)
treebb7eef6e3f543e38ced30675b350e22e50b03001
parentsl0ppty: a wrapped line is one line when you copy it (diff)
downloadslosh-e2eac8ef074d15cbff4844b956ddbe1031200dc8.tar.gz
slosh-e2eac8ef074d15cbff4844b956ddbe1031200dc8.tar.xz
slosh-e2eac8ef074d15cbff4844b956ddbe1031200dc8.zip
sl0ppty: a dead pane says what it ran
``` │help: find a package to install `watch` with `cargo search cargo-watch` │ │ │ │[ran: cargo watch -x test] │ │[process exited: status 101] │ ╰ exited: status 101 ─────────────────────────────────[re-run]─[close]─╯ ``` `[re-run]` is one button on a pane whose program is gone, and nothing on the pane said what it would run. The command was never anywhere: not in the frame, not in the backlog, not in the exit note. Worst in a tab a project's layout built, where the command was never typed into that pane and so cannot be scrolled back to either -- which is now the common case, since that is what `open-workspace` does. So the death note gains a line above it. A pane running the session's shell has no command to name and gets the exit line alone, as before: `[ran: /bin/sh]` would be noise dressed as information. One note rather than two calls. `pane_note` puts a blank line either side of what it writes, so two of them would space the two halves apart as if they were about different things, when they are two facts about one run. Re-running keeps the previous run above it (D14), so the pair turns a pane you re-run into a log of what ran rather than a pile of identical epitaphs. Not put in the frame footer, which is the other place the question gets asked. It already carries the exit words and competes with the buttons for width, so a long command would arrive truncated in the one place that is always visible -- and the note is on screen anyway in the case that matters, because a pane whose program just exited is scrolled to the bottom. **Three existing tests were counting a string that now appears twice.** `DIES_CMD` is `echo mark-one; exit 3`, and they waited for `screen().count("mark-one") == 2` to mean "it ran twice" -- but the note contains the command, so every run now matches twice and the wait never finished. They ask `output_runs()` instead, which counts lines holding the marker that are not the `[ran:` line. Per line rather than by trimming the text, because every row still has the pane's frame border on the end of it. That is a fair correction and not a weakening: the assertion is still "the output appeared twice", which is what they meant. Verified: 56 checks in `tests/test_dead.py`, 1801 overall, all green. Three new: the command is named and sits *above* the exit line (asserted by row order, not by reading the two strings out of one screen); a pane with no command names none; and two runs produce two of each note. Then a real session, `command="cargo watch -x test"` with cargo-watch not installed, which is how this was noticed.
-rw-r--r--DESIGN.md2
-rw-r--r--docs/panes.md19
-rw-r--r--src/app.c17
-rw-r--r--tests/test_dead.py74
4 files changed, 96 insertions, 16 deletions
diff --git a/DESIGN.md b/DESIGN.md
index ac1b138..eaad861 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -156,7 +156,7 @@ unreadable.
| **D11** | **No terminfo/ncurses.** We are opinionated about the outer terminal: emit a known-good modern subset, probe with DA/XTGETTCAP where we must. |
| **D12** | **Vendor lib-vt source**, pinned by commit in `vendor/libghostty-vt.vendor.json`, with a patches file if we ever need one. We needed one: `vendor/patches/0001` stops a screen clear from freeing transmitted kitty images, which broke every program that uploads once and places per frame (see D17). Patches are applied to the committed tree and listed in the vendor json, so a build has them and a re-vendor knows what to re-apply. zig is a build-time dependency only, and doubles as our C compiler and static/musl cross-linker. |
| **D13** | **Shaders are a colour pass over cells, and *which* cells is the pass's, not the shader's.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so a content pass covers the contents and nothing else by paint order, not by a rule anyone has to remember. Frames are shaded by a pass of their own over a rect of their own (D19); no shader knows the difference, which is the point. Cells only: fg, bg and attrs may change, text and width may not, because rewriting text would desync selection and copy (which read the terminal, not the screen). Each cell is shaded independently, so positional effects are expressible and convolutions are not; that is the price of needing no second buffer. Shaders chain in order, each a complete pass. Attached per pane by internal policy only — not over the control API and not in-band, so a program cannot restyle itself. A cell whose colour is "terminal default" is materialised to a **configured** `default_fg`/`default_bg` first: we never learn the client's real default, and most text is default-coloured, so the alternative is a dimmer that visibly does nothing. Panes with no shader skip the pass entirely and still defer to the terminal. Which shader a pane gets from the session is a **table of states** — dragging, drop_hover, drop_target, dead, suspended, scrolled, unfocused — each naming a chain. A pane is usually in several at once, so exactly one wins: the first to match, in a fixed order running from the most deliberate to the most ambient. **Which states ship a default is a line, not a mood**: a pane that is *not live* gets one — dead, suspended, scrolled, where the cells are showing something other than a running program's present and nothing else would tell you. `unfocused` is a taste rather than a fact and gets one anyway, through a knob of its own (`dim_unfocused`) rather than through the table: the objection to shipping a taste was never the taste, it was that turning it off should not require knowing the states block exists, and a single number fixes that. Writing the chain by hand still wins over the knob, including writing an empty one. `dragging` keeps no default, because the pane in your hand keeping its colour is what makes it lift off the page. The corollary is that a collapsed header, which the pass never reaches because a header is chrome, has to carry those same three facts in words. Fixed rather than configured, because it is a ranking by urgency and not a preference. They do not stack, because two reasons to be grey compound into one muddy grey that reads as neither. `dead` is one of them because a pane whose program exits is now kept until it is dismissed (D14); it was excluded while there was no frame it could ever draw. All of it is **derived every frame, never attached and remembered**: focus and drags move through too many paths to keep an attachment in sync with, and a pane left grey by the one path that forgot to clear it is a bug that only appears in front of someone. Same rule as the rect and the collapsed flag. |
-| **D14** | **A pane that was given a command outlives it.** When such a program exits, the pane stays: it keeps everything it printed, writes one line into its own backlog saying how it ended (`[process exited: status 3]`, `[process exited: signal 9]`), says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. Which panes this applies to is the *command*, not the provenance: a pane told to run something is part of the session's shape and its death is news, while a shell you split off is finished when you type `exit` and a corpse to dismiss there is fussiness. A shell is a shell whether a layout made it or a keystroke did, which also means a session restored from `dump-layout` behaves like the one it was dumped from. `keep_dead` moves the line: `all` keeps shells too, `none` keeps nothing. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
+| **D14** | **A pane that was given a command outlives it.** When such a program exits, the pane stays: it keeps everything it printed, writes two lines into its own backlog saying what it ran and how it ended (`[ran: npm run dev]`, `[process exited: status 3]`) -- the command because `[re-run]` is one button, and a pane that does not say what it would run makes pressing it a guess, most of all in a tab a project's layout built where the command was never typed into that pane to be scrolled back to; a pane running the session's shell has none to name and gets the exit line alone, says the same thing in its frame and in the status line, and offers the only two verbs still meaningful — `[re-run]` and `[close]`. Re-running spawns the same argv in the same cwd, in the *same pane*: same id, same place in the tree, same terminal, so the run that ended stays above the new one in the scrollback and nothing that referred to that pane has to be told anything. The old behaviour reaped the pane before the next paint, which meant the last thing a failing command printed vanished together with the status that would have explained it, and a mistyped command in a fresh session closed the session. Which panes this applies to is the *command*, not the provenance: a pane told to run something is part of the session's shape and its death is news, while a shell you split off is finished when you type `exit` and a corpse to dismiss there is fussiness. A shell is a shell whether a layout made it or a keystroke did, which also means a session restored from `dump-layout` behaves like the one it was dumped from. `keep_dead` moves the line: `all` keeps shells too, `none` keeps nothing. Two consequences are load-bearing rather than incidental: the pty fd is **closed** the moment EOF is seen, because an EOF fd left in the poll set is readable forever and would spin a session at 100% CPU behind a pane that looks idle; and the exit status is collected with a **bounded** wait, never a blocking one, because a program that closes its terminal and keeps running must not be able to hang the session — losing that race costs the words "status 0" and nothing else. |
| **D15** | **Shaders may be loaded as native shared libraries**, from a directory beside the config. This is the one hole in D9 and it is argued for rather than drifted into (and D16 has since removed most of the *need* for it). A shader is called per cell, per pass, per frame — tens of thousands of times at up to 120Hz — so the subprocess model D9 prescribes cannot reach that path at all, and the measured alternative, interpreting a bytecode per cell, is 56ns against 8ns for the compiled function. What is loaded is also the narrowest possible thing: a pure function from one cell and its position to that cell's colours, unable to touch text, width, neighbours, panes or the session, because `shader_abi.h` is the only header it sees and nothing else is declared in it. On trust: the config file can already say `shell` and a layout can already say `command=`, so a file in the config directory can already run anything as you — a `.so` in the same directory is not a new boundary. It is refused unless it declares our ABI version *and* matching struct sizes, so a stale plugin fails at load instead of garbling colours. Nothing is ever unloaded: a `shade_fn` may be sitting in a live config or on a pane, so `dlclose()` would be a jump into an unmapped page — a reload therefore picks up newly *added* plugins only, and replacing one needs a new session. |
| **D16** | **A shader's `amount` may be an expression**, compiled to bytecode at config load. The scope is the argument: every built-in here is already `colour_op(amount)` with the amount a pure function of position, so the part worth handing to a config is the *number*, not the colour maths. That keeps mixing in C, gives an expression no way to produce an invalid cell (it produces one byte), and makes the result cacheable — a program that does not read the clock is evaluated once per cell into a map and reused until something it reads changes, so a config-written positional shader measures 5.2ns/cell against a compiled vignette's 6.1ns. Reading the clock costs 35ns/cell, per frame, which is the honest price of animation and is opt-in. The language is **total**: no loops, no recursion, no jumps — even `?:` evaluates both sides and selects, because nothing in it has an effect. So evaluation is O(program length), a config cannot spin, and there is nothing to sandbox; division by zero is 0 rather than a trap, because an expression that is wrong should look wrong rather than take the session with it. What a program reads is **derived by the compiler**, not declared, and that dependency set *is* the cache key — a stale map is unrepresentable rather than merely unlikely, which is the same rule the layout follows. An expression that does not compile drops that one shader with a warning: not the config, and not a half-strength version of what was asked for. |
| **D17** | **Images need pixels, and we were not carrying any.** Kitty graphics worked in the test suite and not in real life, because three separate things all quietly assumed a cell has no size. `pty_spawn`/`pty_resize` left `ws_xpixel`/`ws_ypixel` zero, so a program asking the tty how big a cell is got nothing and guessed. `ghostty_terminal_resize` was called with `0, 0` for the cell size, so lib-vt could not work out how many cells an image covers when the placement did not say — and a placement that covers zero cells is dropped, which is a picture that silently does not appear. And the vendored lib freed image *data* on `ED 2`, so a full-screen program lost everything it had uploaded. The fix runs the whole way through: the client reads its terminal's pixel size (`TIOCGWINSZ`, re-read on every resize because a window can move to another monitor), `MSG_HELLO`/`MSG_RESIZE` carry it, the app hands it to every pane in every tab, and each pane gives it to both its pty and its terminal. Where nobody knows, the default is **8x16 rather than 0**: a plausible cell means an image appears at roughly the right size, and zero means it does not appear at all. Tested end to end, including that a program can read real pixel dimensions out of its own pty. **Re-emission has to preserve what the program meant, not what it currently covers.** Two things fall out of that. A placement carries a sub-cell offset (`X=`/`Y=`, where the image starts *inside* its first cell); dropping it is invisible in a still picture and makes anything moving jump a whole cell at a time. And `c=`/`r=` mean *scale into this many cells*, so they are passed on only when the program asked for them — sending the count a natural-size image happens to cover makes it change size as it slides across a cell boundary, because that count goes up by one. Cropping follows the same split: a natural image is cropped by the screen pixels the pane has left (measured from the offset, not the cell edge), a scaled one proportionally, because a lost cell is worth `source/cells` pixels there. **And a program may prefer to *ask*.** `TIOCGWINSZ`'s pixel fields are the first way to find the cell size and XTWINOPS (`CSI 14/16/18 t`) is the second, which is what a program reaches for when the pty fields come back zeroed — as they do on the far side of an ssh hop. We answered none of it, so the query timed out and the program fell back to a guess; 10x20 against a real 9x22 cell is every image stretched by a fifth, and it looks like a bug in the program. `GHOSTTY_TERMINAL_OPT_SIZE` answers all three from the pane's own geometry (lib-vt encodes the reply). Mode 2048, the in-band report on resize, already worked: it is answered from the size handed to `ghostty_terminal_resize`. |
diff --git a/docs/panes.md b/docs/panes.md
index fd3bcee..162356b 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -87,16 +87,25 @@ project you have not opened yet has no pane for the finder to match.
Split off a shell, do something, type `exit`: it closes, like a terminal should.
But a pane that was *told to run something* — from a [layout](layouts.md) or the
[control API](scripting.md) — keeps what it printed when that something exits,
-with a line saying how it ended and two buttons:
+with two lines saying what ran and how it ended, and two buttons:
```
+ [ran: npm run dev]
+ [process exited: status 3]
╰ exited: status 3 ───────────────────────────────[re-run]─[close]─╯
```
-`[re-run]` (or `C-a r`) runs the same command again in the same pane, keeping
-the previous run above it in the scrollback. So a command that failed while you
-were looking elsewhere still has its error message when you get back, and a
-mistyped command in a fresh session no longer closes the session.
+**The command is written down because `[re-run]` is one button.** Without it,
+pressing it is a guess — most of all in a tab that a [project's
+layout](workspaces.md) built, where the command was never typed into that pane to
+be scrolled back to. A pane running the session's shell has no command to name
+and gets the exit line alone.
+
+`[re-run]` (or `C-a r`) runs the same command again in the same pane, keeping the
+previous run above it in the scrollback. So the two notes turn a pane you re-run
+into a log of what ran rather than a pile of identical epitaphs — and a command
+that failed while you were looking elsewhere still has its error message when you
+get back, while a mistyped command in a fresh session no longer closes it.
Which panes stay is one setting, `keep_dead`: `commands` (the default), `all`,
or `none`.
diff --git a/src/app.c b/src/app.c
index 86a8740..7fa6b9e 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1127,8 +1127,21 @@ bool app_pump_fd(app_t *a, int fd) {
if (was_alive && !pane_alive(f.found->pane)) {
char words[64];
exit_words(f.found->pane, words, sizeof words);
- char note[96];
- snprintf(note, sizeof note, "[process %s]", words);
+ /* What it ran, above how it ended. `[re-run]` is one button on a pane whose
+ * program is gone, and a pane that does not say what it would run makes
+ * pressing it a guess -- especially in a tab somebody else's layout built,
+ * where the command was never typed here in the first place. A pane running
+ * the session's shell has no command to name and gets the exit line alone.
+ *
+ * One note rather than two: `pane_note` puts a blank line either side of
+ * what it writes, so two calls would space the two halves apart as if they
+ * were about different things. */
+ const char *label = pane_label(f.found->pane);
+ char note[320];
+ if (label && *label)
+ snprintf(note, sizeof note, "[ran: %s]\r\n[process %s]", label, words);
+ else
+ snprintf(note, sizeof note, "[process %s]", words);
pane_note(f.found->pane, note, DEAD_C);
}
return true;
diff --git a/tests/test_dead.py b/tests/test_dead.py
index e42a965..6569013 100644
--- a/tests/test_dead.py
+++ b/tests/test_dead.py
@@ -63,6 +63,17 @@ def hit(snap, action):
return None
+def output_runs(snap, marker="mark-one"):
+ """How many times the command's *output* appears.
+
+ A dead pane now writes the command it ran into its own backlog, and
+ `echo mark-one` contains `mark-one` -- so a substring count over the screen
+ finds every run twice. The note is the line carrying `[ran:`; every other line
+ holding the marker is output. (Counted per line rather than by trimming the
+ text: each row still has the pane's frame border on the end of it.)"""
+ return sum(1 for l in snap.text if marker in l and "[ran:" not in l)
+
+
def test_it_stays_and_says_why():
with Session(SHELL, cols=60, rows=12, layout=commanded(DIES_CMD)) as s:
snap = s.until_text("[process exited")
@@ -80,6 +91,55 @@ def test_it_stays_and_says_why():
check("and no signal", pane["exit_signal"] == 0, str(pane))
+def test_it_says_what_it_ran():
+ """`[re-run]` is one button on a pane whose program is gone. Without the
+ command written down, pressing it is a guess -- and in a tab somebody else's
+ layout built, the command was never typed here to be scrolled back to."""
+ with Session(SHELL, cols=70, rows=12, layout=commanded(DIES_CMD)) as s:
+ snap = s.until_text("[process exited")
+ screen = snap.screen()
+ check("the backlog names the command", "[ran: " in screen, screen)
+ check("and it is the command that was given",
+ "echo mark-one; exit 3" in screen, screen)
+
+ # Above, not below: the exit line is the last word on the run.
+ lines = [i for i, l in enumerate(snap.text) if "[ran: " in l]
+ exits = [i for i, l in enumerate(snap.text) if "[process exited" in l]
+ check("it sits above how it ended", lines and exits and lines[0] < exits[0],
+ "%s vs %s" % (lines, exits))
+
+
+def test_a_pane_with_no_command_has_none_to_name():
+ """A shell that exited ran what the session runs. `[ran: /bin/sh]` would be
+ noise dressed as information."""
+ with Session(SHELL, cols=60, rows=10, config=cfg('keep_dead "all"\n')) as s:
+ s.settle()
+ s.raw(DONE)
+ snap = s.until_text("[process exited")
+ check("it still says it exited", "[process exited" in snap.screen(),
+ snap.screen())
+ check("and says nothing about a command", "[ran: " not in snap.screen(),
+ snap.screen())
+
+
+def test_each_run_says_what_it_was():
+ """Re-running keeps the previous run above (D14), so the two notes turn the
+ scrollback into a log of what ran rather than a pile of identical epitaphs."""
+ with Session(SHELL, cols=70, rows=16, layout=commanded(DIES_CMD)) as s:
+ s.until_text("[process exited")
+ s.api("rerun", id=0)
+ s.settle(120)
+ for _ in range(100):
+ snap = s.snapshot()
+ if snap.screen().count("[ran: ") >= 2:
+ break
+ s.settle(30)
+ check("both runs are named", snap.screen().count("[ran: ") == 2,
+ snap.screen())
+ check("and both endings are", snap.screen().count("[process exited") == 2,
+ snap.screen())
+
+
def test_a_clean_exit_is_not_dressed_up_as_a_failure():
with Session(SHELL, cols=60, rows=12, layout=commanded("true")) as s:
snap = s.until_text("[process exited")
@@ -137,9 +197,8 @@ def test_rerun_runs_it_again_on_top_of_what_it_left():
x, y = hit(snap, f"rerun:{pane['id']}")
s.click(x, y)
- snap = s.until(lambda sn: sn.screen().count("mark-one") == 2)
- check("the command ran again",
- snap.screen().count("mark-one") == 2, snap.screen())
+ snap = s.until(lambda sn: output_runs(sn) == 2)
+ check("the command ran again", output_runs(snap) == 2, snap.screen())
check("the run that ended is still above it",
"[process exited: status 3]" in snap.screen(), snap.screen())
check("the pane kept its id", s.pane()["id"] == pane["id"])
@@ -157,9 +216,8 @@ def test_rerun_over_the_control_api():
pane = s.pane()
reply = s.api("rerun", id=pane["id"])
check("the api runs it again", reply.get("ok") is True, str(reply))
- snap = s.until(lambda sn: sn.screen().count("mark-one") == 2)
- check("and it really ran", snap.screen().count("mark-one") == 2,
- snap.screen())
+ snap = s.until(lambda sn: output_runs(sn) == 2)
+ check("and it really ran", output_runs(snap) == 2, snap.screen())
reply = s.api("rerun", id=9999)
check("an unknown pane is refused", reply.get("ok") is False, str(reply))
@@ -169,8 +227,8 @@ def test_the_keyboard_can_do_it_too():
with Session(SHELL, cols=60, rows=20, layout=commanded(DIES_CMD)) as s:
s.until_text("[process exited")
s.key("r")
- snap = s.until(lambda sn: sn.screen().count("mark-one") == 2)
- check("C-a r runs it again", snap.screen().count("mark-one") == 2,
+ snap = s.until(lambda sn: output_runs(sn) == 2)
+ check("C-a r runs it again", output_runs(snap) == 2,
snap.screen())