diff options
| author | user <user@clank> | 2026-08-14 21:32:40 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-14 21:32:40 +0200 |
| commit | c5f4bb75403df3ca7e0bf6c30981c0cb4e54921e (patch) | |
| tree | 44bddd3b1ba1ea0f4782b50d9c095c6b813f7b7b | |
| parent | sl0ppty: let a child's exit status survive long enough to be read (diff) | |
| download | slosh-c5f4bb75403df3ca7e0bf6c30981c0cb4e54921e.tar.gz slosh-c5f4bb75403df3ca7e0bf6c30981c0cb4e54921e.tar.xz slosh-c5f4bb75403df3ca7e0bf6c30981c0cb4e54921e.zip | |
sl0ppty: a pane outlives the program that was in it
A pane whose program exited was reaped before the next paint. The pane went,
and with it everything the program had printed -- including, always, the one
line that would have explained why it stopped. Close the last one and the
session went too, so a mistyped command in a fresh session closed the
terminal you were typing in.
Now the pane stays. It keeps its contents, writes one line into its own
backlog saying how it ended -- `[process exited: status 3]`, `signal 9` --
says the same thing in its frame and in the status line, and offers the two
verbs that still mean anything:
╰ exited: status 3 ──────────────────────────────[re-run]─[close]─╯
`[re-run]` runs the same argv in the same cwd in the *same pane*: same id,
same node in the tree, same terminal. Nothing that referred to that pane has
to be told anything, and the run that ended stays above the new one in the
scrollback, which is what makes comparing two runs possible at all. C-a r
and {"cmd":"rerun"} do the same thing; a live pane is refused rather than
restarted, because "run it again" is not a request to kill something.
Those buttons are the OSC 5577 row, not a second mechanism. A dead pane's
own buttons are inert -- a click would write a report into a closed pty --
so the row is given over rather than added to. `close` is listed last, so it
is drawn rightmost and is the one that survives a narrow frame: being unable
to dismiss a dead pane is a trap, being unable to re-run one is an
inconvenience.
`dead` is a shader state now, which D13 said it could never be: it named a
frame that could not be drawn, and now there is one. Dead panes drain to
grey by default, so a wall of them reads at a glance. A collapsed header
says "exited" in words as well, because the shader pass does not reach one
and a flattened tab draws nothing else of a pane.
Two things about this are load-bearing rather than incidental:
- the pty fd is closed the moment EOF is seen. A pane that outlives its
program stays in the poll set, and an fd at EOF is readable forever: the
session would spin a core behind a pane that looks idle.
- the exit status is collected with a bounded wait, never a blocking one.
EOF means the program already closed its fds, so it is on its way out and
a few hundred microseconds wins that race. A blocking waitpid would hang
the whole session on one program that closes its terminal and keeps
running; losing the race costs the words "status 0" and nothing else.
`keep_dead false` restores the old behaviour for a one-shot layout that
wants it. `panes` reports alive/exit_code/exit_signal, so a script can find
the pane whose command failed and rerun it.
| -rw-r--r-- | DESIGN.md | 3 | ||||
| -rw-r--r-- | HANDOVER.md | 52 | ||||
| -rw-r--r-- | README.md | 38 | ||||
| -rw-r--r-- | config/config.kdl | 38 | ||||
| -rw-r--r-- | contrib/themes/amber.kdl | 3 | ||||
| -rw-r--r-- | contrib/themes/mono.kdl | 3 | ||||
| -rw-r--r-- | contrib/themes/paper.kdl | 3 | ||||
| -rw-r--r-- | contrib/themes/phosphor.kdl | 3 | ||||
| -rw-r--r-- | contrib/themes/sl0p.kdl | 3 | ||||
| -rw-r--r-- | contrib/themes/slate.kdl | 3 | ||||
| -rw-r--r-- | src/app.c | 179 | ||||
| -rw-r--r-- | src/app.h | 7 | ||||
| -rw-r--r-- | src/cmd.c | 5 | ||||
| -rw-r--r-- | src/config.c | 19 | ||||
| -rw-r--r-- | src/config.h | 20 | ||||
| -rw-r--r-- | src/pane.c | 112 | ||||
| -rw-r--r-- | src/sl0ppty.h | 17 | ||||
| -rw-r--r-- | tests/test_dead.py | 251 |
18 files changed, 696 insertions, 63 deletions
@@ -155,7 +155,8 @@ unreadable. | **D10** | **One attached client** in the MVP. The protocol allows N (read-only observers are what sl0p.foo actually wants); the multi-user *rendering* path is not built, because it is where the fork's phantom-client and `MY FOCUS AND:` bugs live. | | **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. 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 pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. 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, 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. 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. There is no `dead` state: a pane whose program exits is reaped before the next paint, so it could never 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. | +| **D13** | **Shaders are a colour pass over pane *contents*, never our chrome.** A shader is a pure function from (cell, position, parameters) to that cell's colours, run between `pane_compose()` and the split guide — so "contents, not chrome" is the paint order, not a rule anyone has to remember. 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. 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 outlives its program.** When the program in a pane 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. `keep_dead false` restores it. 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. | ### D6 — responsive layout as a pure function diff --git a/HANDOVER.md b/HANDOVER.md index c715781..03d8a8d 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -55,6 +55,22 @@ can be provably even and still look wrong. Three attempts at the frame buttons established this; they are plain ASCII now. If someone reports spacing, dump the actual cells before touching any arithmetic. +**The test harness is not a session, and the difference hid a real bug.** The +server set `signal(SIGCHLD, SIG_IGN)`, which does not merely skip a handler — +it tells the kernel to discard exit statuses, so `waitpid` can never report +one. The headless driver never set it, so every test agreed that a dead pane +knew why it died, and the first real session showed `[process exited]` with no +status at all. Found by looking, in a zellij pane, thirty seconds after the +suite went green. If something is right in tests and wrong in front of you, +suspect what `server.c` does that `headless.c` does not. + +**A dead pane's fd is closed at EOF, deliberately.** Panes used to be reaped +before the next paint, so nothing had to think about it. Now that they stay, +an EOF fd left in the poll set is *readable forever*: the session would spin a +core behind a pane that looks idle. `collect_cb` skips `fd < 0` for the same +reason, and that is why `sl0ppty run cmd` still terminates — the settle loop +ends when no pane has an fd left. + **`settle N` in the test harness is a sleep in disguise.** `send` and `snapshot` are already synchronous with the app, so a settle before asserting on *our own chrome* waits for nothing. It is only needed to pump a pane's pty. @@ -80,17 +96,43 @@ what makes "contents, not chrome" the paint order rather than a rule. The control API in `cmd.c` is the same vocabulary the headless test driver speaks, so a script written against one works against the other. +## Dead panes (D14), and what it changed + +A pane now outlives its program: it keeps its contents, writes +`[process exited: status 3]` into its own backlog, and offers `[re-run]` and +`[close]` in its bottom frame row. Three things about it are worth knowing +before changing anything near it. + +- **The dead row and the OSC 5577 row are the same row**, by choice. A dead + pane's own buttons are inert — a click would write into a closed pty — so + `draw_pane_status` swaps the list rather than adding a second mechanism. + `close` is listed last so it is drawn rightmost and therefore survives a + narrow frame; the thing that drops off is `re-run`. +- **Re-running is the same pane.** Same id, same node, same terminal, so + nothing that referred to it needs telling and the previous run stays above + in the scrollback. That is the whole point; do not be tempted to close and + reopen. +- **The exit status is collected with a bounded wait, never a blocking one.** + EOF means the program closed its fds, which for an exit already happened, so + a few hundred microseconds wins the race with the kernel's exit path. A + blocking `waitpid` would hang the entire session on one program that closes + its terminal and keeps running. Losing the race costs the words "status 0". + ## Things left on the table - **A `reload` keybinding.** The config watcher made it less pressing. - **Tab-level shaders**, and the bigger one: **OSC 5577 → shader**, letting a pane colour itself by reporting its own state. That is the idea most likely to make this feel unlike other multiplexers. -- **Dead panes are not observable.** `app_reap` closes a pane before the next - paint, so there is no `dead` pane state to colour. Making one means keeping - dead panes until dismissed — a feature about lifetimes, not colour. -- **Collapsed headers do not reach the shader pass**, so a `suspended` or - `scrolled` colour is invisible on one. +- **Collapsed headers do not reach the shader pass**, so a `suspended`, + `dead` or `scrolled` colour is invisible on one. This is now the most + visible gap: a tab that has flattened into a list is exactly where you would + want a dead pane to stand out, and it is the one place it does not. +- **A dead pane cannot be re-run from the finder or the minimised bar** — only + from its own frame, `C-a r`, or `{"cmd":"rerun"}`. Fine while a dead pane is + something you are looking at; less fine once you have six of them put away. +- **Nothing re-runs a whole tab**, which is the obvious next want once one + pane can be re-run. - **Corner crossings**: found for both nestings now, but if more turn up missing, ask *how the layout was built* — the split order decides the tree shape, and that is where any remaining blind spot will be. @@ -49,7 +49,8 @@ sl0ppty cmd '{"cmd":"panes"}' | `C-a f` | find a pane by title, purpose or tab | | `C-a H J K L` or shift+arrows | move the boundary (the pane grows or shrinks accordingly) | | `C-a PgUp` `C-a PgDn` `C-a Home` `C-a End` | scrollback (the wheel does it too) | -| `C-a x` / `C-a d` / `C-a q` | close pane / detach / quit | +| `C-a x` / `C-a r` | close pane / re-run a dead pane's command | +| `C-a d` / `C-a q` | detach / quit | | `C-a C-a` | send a literal `C-a` | The mouse works throughout. **Click a pane's border to split toward it** — the @@ -91,6 +92,24 @@ behind take the space back. Click its name to bring it back. Once a tab is too small and has flattened into a list, being minimised means nothing: every pane is a row down there already. +**A pane outlives its program.** When what was running in a pane exits, the +pane stays where it is with everything it printed still on screen, a line in +its own backlog saying how it ended — `[process exited: status 3]`, +`[process exited: signal 9]` — and two buttons in its bottom border: + +``` +╰ exited: status 3 ───────────────────────────────[re-run]─[close]─╯ +``` + +**`[re-run]` runs the same command again in the same pane** — same id, same +place in the layout, same scrollback, so the run that ended stays above the +new one and you can compare them. `[close]` is what takes the pane away, and +with the last pane, the session. So a mistyped command no longer closes your +terminal, and a command that failed while you were looking elsewhere still has +its error message when you get back. Dead panes drain to grey, so a wall of +them reads at a glance. `keep_dead false` restores the old behaviour, where a +pane vanishes the moment its program exits. + **Select to copy.** Drag over text and release: it is on your clipboard, sent to your terminal as OSC 52 so it works over ssh. Middle click pastes it back, the way a primary selection behaves everywhere else. A program in a pane can @@ -106,14 +125,16 @@ false` turns it off; `bell_mark` chooses the character. **Shaders** are colour passes over a pane's *contents* — never the frame, the title or the tab strip, so chrome stays legible over a pane that has been dimmed underneath it. A `states { }` block says what a pane looks like while -it is in a state — being dragged, somewhere a drag could land, suspended, -scrolled back, or simply not focused. A pane is usually in several at once, so -exactly one wins, the most deliberate rather than the most ambient: +it is in a state — being dragged, somewhere a drag could land, dead, +suspended, scrolled back, or simply not focused. A pane is usually in several +at once, so exactly one wins, the most deliberate rather than the most +ambient: ```kdl states { dragging { } // untouched, in your hand drop_target { grayscale amount=200; dim amount=140 } + dead { grayscale amount=200; dim amount=90 } // its program is over suspended { grayscale amount=180; dim amount=90 } scrolled { tint amount=30 color="#ffcc88" } // you are in the past unfocused { dim amount=90 } @@ -148,8 +169,8 @@ a gap can be dragged. `hints false` turns it off. A **strip along the top** carries the tabs, the pane count and the prefix indicator; a **line along the bottom** says what you are looking at — session, tab, the focused pane's purpose or title, which of the tab's panes it is -(`pane 2/5`), and whether it is scrolled back, on an alternate screen, or not -started yet. Either can be turned off +(`pane 2/5`), and whether it is scrolled back, on an alternate screen, not +started yet, or over (`exited: status 3`). Either can be turned off (`status_bar`, `status_line`), and the panes get the row back. Every colour the compositor draws has its own name in `theme` — the split @@ -234,10 +255,11 @@ $ sl0ppty -s work cmd '{"cmd":"panes"}' {"ok":true,"panes":[{"id":1,"x":2,"y":2,...,"purpose":"agent:main"}]} ``` -`panes tabs snapshot send raw resize split focus close new-tab close-tab +`panes tabs snapshot send raw resize split focus close rerun new-tab close-tab select-tab set-name set-purpose apply-layout reload alive quit`. Panes are addressed by id, so a background tab is scriptable, and a detached session answers exactly as a live -one does. +one does. `panes` reports `alive`, `exit_code` and `exit_signal`, so a script +can find the pane whose command failed and `rerun` it. ## How it is built diff --git a/config/config.kdl b/config/config.kdl index db272a4..ef3c4f2 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -77,6 +77,18 @@ zoom_on_mark "*" close_mark "x" min_mark "_" +// A pane whose program exits stays where it is, keeping everything it +// printed, with a line in its own backlog saying why it ended and two buttons +// in its frame: [re-run] to run the same command again in the same pane and +// on top of the same scrollback, and [close] to let it go. `sl0ppty` closes +// with the last pane, so a session no longer disappears the instant the shell +// in it exits. +// +// false is the old behaviour: the pane is closed on the next paint, and with +// the last pane the session. That suits a one-shot layout, and costs you the +// last thing a failing command printed. +keep_dead true + bell_indicator true // What to draw. A short string rather than a fixed glyph, because the obvious @@ -161,6 +173,7 @@ double_click_ms 400 // dragging the pane you have hold of // drop_hover the one under the pointer, where it would land // drop_target the others, all of them somewhere it could go +// dead its program exited; waiting to be re-run or closed // suspended laid out, never started (its label is its contents) // scrolled looking at scrollback rather than at the present // unfocused the weakest thing that can be true of a pane @@ -174,13 +187,13 @@ double_click_ms 400 // Naming a state replaces its default outright — including with nothing, which // is how you turn one off: states { drop_target { } } // -// There is deliberately no `dead`: a pane whose program exits is reaped before -// the next frame is painted, so a shader for it could never draw. -// -// Only drop_hover and drop_target have defaults, shown here. +// Only drop_hover, drop_target and dead have defaults, shown here. `dead` is +// gentler than the drag greying on purpose: a dead pane is something you are +// still meant to read. states { drop_hover { grayscale amount=200; dim amount=140 } drop_target { grayscale amount=200; dim amount=140 } + dead { grayscale amount=200; dim amount=90 } // dragging { } // suspended { grayscale amount=180; dim amount=90 } // scrolled { tint amount=30 color="#ffcc88" } @@ -281,6 +294,18 @@ theme { // ── the line along the bottom ─────────────────────────────────────────── status "#45454a" // session, tab, pane, and the pane index status_state "#ffffff" // scrolled / alt screen / not started / exited + hint "#ffffff" // what the pointer is on, in the middle + + // ── marks on a pane's frame ───────────────────────────────────────────── + bell "#ff5fd7" // a pane rang and has not been looked at + dead "#ff875f" // its program exited: the line in its backlog, + // and the words in its frame and the status line + pane_button "#45454a" // the frame's own close/zoom/minimise marks + pane_button_hover "#ff5fd7" + + // ── the row of minimised panes ────────────────────────────────────────── + minbar "#45454a" + minbar_hover "#ff5fd7" // ── the pane finder ───────────────────────────────────────────────────── finder_fg "#ffffff" @@ -307,7 +332,7 @@ theme { // Actions: split-cols split-rows close-pane focus-left focus-right focus-up // focus-down focus-next resize-left resize-right resize-up resize-down // scroll-up scroll-down scroll-page-up scroll-page-down scroll-top -// scroll-bottom zoom minimize new-tab next-tab prev-tab select-tab-1..9 finder detach quit +// scroll-bottom zoom minimize rerun new-tab next-tab prev-tab select-tab-1..9 finder detach quit // literal-prefix none keys { @@ -316,6 +341,9 @@ keys { bind "\\" "split-cols" bind "-" "split-rows" bind "x" "close-pane" + // Run a dead pane's command again, in the same pane and on top of the + // same scrollback. Refused on a pane that is still running. + bind "r" "rerun" bind "z" "zoom" bind "m" "minimize" diff --git a/contrib/themes/amber.kdl b/contrib/themes/amber.kdl index e2dabf1..b4d6849 100644 --- a/contrib/themes/amber.kdl +++ b/contrib/themes/amber.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#1a1000" finder_sel_bg "#ffb000" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#1a1000" rename_bg "#ffb000" toast_fg "#1a1000" toast_bg "#ffb000" bell "#ffb000" + dead "#ffd48a" pane_button "#6b4a00" pane_button_hover "#ffb000" minbar "#6b4a00" diff --git a/contrib/themes/mono.kdl b/contrib/themes/mono.kdl index bedf6d0..2a478d5 100644 --- a/contrib/themes/mono.kdl +++ b/contrib/themes/mono.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#101010" finder_sel_bg "#e4e4e4" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#101010" rename_bg "#e4e4e4" toast_fg "#101010" toast_bg "#e4e4e4" bell "#e4e4e4" + dead "#9e9e9e" pane_button "#585858" pane_button_hover "#e4e4e4" minbar "#585858" diff --git a/contrib/themes/paper.kdl b/contrib/themes/paper.kdl index 902f343..e24fb44 100644 --- a/contrib/themes/paper.kdl +++ b/contrib/themes/paper.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#ffffff" finder_sel_bg "#0057b8" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#ffffff" rename_bg "#0057b8" toast_fg "#ffffff" toast_bg "#0057b8" bell "#0057b8" + dead "#b3261e" pane_button "#9aa0a6" pane_button_hover "#0057b8" minbar "#9aa0a6" diff --git a/contrib/themes/phosphor.kdl b/contrib/themes/phosphor.kdl index a0727a9..d27529b 100644 --- a/contrib/themes/phosphor.kdl +++ b/contrib/themes/phosphor.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#04140a" finder_sel_bg "#33ff66" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#04140a" rename_bg "#33ff66" toast_fg "#04140a" toast_bg "#33ff66" bell "#33ff66" + dead "#d9ff8c" pane_button "#1f5b32" pane_button_hover "#33ff66" minbar "#1f5b32" diff --git a/contrib/themes/sl0p.kdl b/contrib/themes/sl0p.kdl index 693754b..19c9dee 100644 --- a/contrib/themes/sl0p.kdl +++ b/contrib/themes/sl0p.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#141418" finder_sel_bg "#ff5fd7" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#141418" rename_bg "#ff5fd7" toast_fg "#141418" toast_bg "#ff5fd7" bell "#ff5fd7" + dead "#ff875f" pane_button "#45454a" pane_button_hover "#ff5fd7" minbar "#45454a" diff --git a/contrib/themes/slate.kdl b/contrib/themes/slate.kdl index 57d837b..eccf8af 100644 --- a/contrib/themes/slate.kdl +++ b/contrib/themes/slate.kdl @@ -53,12 +53,13 @@ theme { finder_sel_fg "#11131c" finder_sel_bg "#7aa2f7" - // rename editor, bell, frame buttons, the minimised bar + // rename editor, bell, dead panes, frame buttons, the minimised bar rename_fg "#11131c" rename_bg "#7aa2f7" toast_fg "#11131c" toast_bg "#7aa2f7" bell "#7aa2f7" + dead "#f7768e" pane_button "#3b4261" pane_button_hover "#7aa2f7" minbar "#3b4261" @@ -98,6 +98,7 @@ bool app_reload_config(char *err, size_t errcap) { #define MINBAR (CFG.minbar) #define MINBAR_HOVER (CFG.minbar_hover) #define HINT_C (CFG.hint) +#define DEAD_C (CFG.dead) static const color_t NO_COLOR = {0}; @@ -281,6 +282,18 @@ static bool ptr_on(const app_t *a, uint16_t x, uint16_t y, uint16_t w, a->ptr_x < x + w && a->ptr_y >= y && a->ptr_y < y + h; } +/* "exited", "exited: status 3", "exited: signal 9" — the same words wherever + * the fact is reported, so the status line, the pane's own frame and the line + * left in its backlog cannot describe one death three ways. */ +static void exit_words(const pane_t *p, char *out, size_t cap) { + int code = 0; + bool sig = false; + if (!pane_exit(p, &code, &sig) || (!sig && code == 0)) + snprintf(out, cap, "exited"); + else + snprintf(out, cap, "exited: %s %d", sig ? "signal" : "status", code); +} + typedef void (*leaf_fn_fwd)(node_t *, void *); static void walk_all(app_t *a, leaf_fn_fwd fn, void *ud); @@ -384,6 +397,7 @@ static const char *hint_for(app_t *a, const char *action) { if (strncmp(action, "zoom:", 5) == 0) return app_pane_zoomed(a, id) ? "back to the layout" : "fill the tab"; if (strncmp(action, "close:", 6) == 0) return "close this pane"; + if (strncmp(action, "rerun:", 6) == 0) return "run the command again"; if (strncmp(action, "scrollbottom:", 13) == 0) return "back to the bottom"; if (strncmp(action, "panetitle:", 10) == 0) return "double-click to rename \u00b7 drag to move"; @@ -505,12 +519,14 @@ static void draw_status_line(app_t *a, screen_t *s) { char ind[64] = {0}; if (f) { - if (app_pane_zoomed(a, f->id)) { - snprintf(ind, sizeof ind, "zoomed"); - } else if (pane_suspended(f->pane)) { + /* Ordered by what you would rather be told. A pane whose program is gone + * is over, and that outranks every arrangement it happens to be in. */ + if (pane_suspended(f->pane)) { snprintf(ind, sizeof ind, "not started"); } else if (!pane_alive(f->pane)) { - snprintf(ind, sizeof ind, "exited"); + exit_words(f->pane, ind, sizeof ind); + } else if (app_pane_zoomed(a, f->id)) { + snprintf(ind, sizeof ind, "zoomed"); } else if (pane_scrolled(f->pane)) { uint32_t above = 0, total = 0; pane_scroll_pos(f->pane, &above, &total); @@ -786,9 +802,13 @@ struct collect { int *out; size_t n, max; }; +/* Only panes that can still say something. A suspended pane has no pty yet + * and a dead one no longer has one; handing either to poll() would be an fd + * that is never readable, or — the expensive mistake — one at EOF that is + * readable forever. */ static void collect_cb(node_t *n, void *ud) { struct collect *c = ud; - if (c->n < c->max) c->out[c->n++] = pane_fd(n->pane); + if (pane_fd(n->pane) >= 0 && c->n < c->max) c->out[c->n++] = pane_fd(n->pane); } size_t app_fds(app_t *a, int *out, size_t max) { @@ -801,7 +821,18 @@ bool app_pump_fd(app_t *a, int fd) { struct find_fd f = {fd, NULL}; walk_all(a, find_fd_cb, &f); if (!f.found) return false; + /* Death is observed here, once, by the only call that can see the edge: + * pane_pump() is where EOF arrives, and the note has to be written on the + * transition or every later frame would write it again. */ + bool was_alive = pane_alive(f.found->pane); pane_pump(f.found->pane); + 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); + pane_note(f.found->pane, note, DEAD_C); + } return true; } @@ -1324,7 +1355,13 @@ static void reap_cb(node_t *n, void *ud) { if (!r->dead && !pane_alive(n->pane)) r->dead = n; } +/* Closing a pane whose program exited is a *policy*, not bookkeeping — which + * is the whole of this feature. By default a dead pane stays: it keeps what + * it printed, says why it is over, and offers to run the command again. Under + * `keep_dead false` this reverts to the old behaviour and the pane goes. */ void app_reap(app_t *a) { + ensure_config(); + if (CFG.keep_dead) return; for (;;) { struct reap r = {a, NULL}; walk_all(a, reap_cb, &r); @@ -1500,6 +1537,28 @@ bool app_close_pane(app_t *a, uint32_t id) { return true; } +/* Run a dead pane's command again, in the pane it died in. + * + * The pane is the same pane throughout — same id, same place in the tree, + * same terminal — so nothing that referred to it has to be told anything, and + * the run that ended stays above the new one in its scrollback. A suspended + * pane is started rather than restarted, because "run the thing this pane is + * for" is the same request either way. */ +bool app_rerun_pane(app_t *a, uint32_t id) { + node_t *n = id ? pane_by_id(a, id) : (a->ntabs ? cur(a)->focus : NULL); + if (!n) return false; + bool ok = pane_suspended(n->pane) ? pane_start(n->pane) + : pane_restart(n->pane); + if (!ok) { + app_toast(a, "cannot run it again"); + return false; + } + /* The new pty is born at the pane's current size: a dead pane is still laid + * out and still resized, pane_resize() just had no pty to tell. */ + cur(a)->focus = n; + return true; +} + uint32_t app_focused_pane_id(app_t *a) { return a->ntabs && cur(a)->focus ? cur(a)->focus->id : 0; } @@ -1798,15 +1857,52 @@ static void focus_next(app_t *a) { /* ---- drawing ------------------------------------------------------------ */ -/* OSC 5577: a pane's own status line and buttons, drawn in its bottom frame - * row. Buttons are budgeted from the right *before* the status text gets any +/* A pane's own status line and buttons, drawn in its bottom frame row. + * + * Two things end up here, because they are the same thing: what a *live* pane + * asked for over OSC 5577, and what a *dead* one is offered instead. A dead + * pane's own buttons are inert — clicking one would write a click report into + * a pty that is closed — so the row is given over to the two verbs that do + * still mean something: run it again, or let it go. Same row, same shape, + * same budgeting, so a dead pane is not a second kind of frame. + * + * Buttons are budgeted from the right *before* the status text gets any * columns, and each registers its hit as it is painted — the same rule the - * split button follows, for the same reason. */ -static void draw_pane_status(screen_t *s, node_t *leaf, color_t fg, + * frame's own buttons follow, for the same reason. Rightmost is last in the + * list, so what drops off a narrow frame is what is listed first: `close` + * outlives `re-run`, because being unable to dismiss a dead pane is a trap + * and being unable to re-run one is an inconvenience. */ +struct row_btn { + char label[40]; + char action[48]; +}; + +static void draw_pane_status(app_t *a, screen_t *s, node_t *leaf, color_t fg, bool focused) { - const pane_button_t *btns = NULL; - size_t nbtn = pane_buttons(leaf->pane, &btns); - const char *status = pane_status(leaf->pane); + struct row_btn row[8]; + size_t nbtn = 0; + char status[256] = {0}; + bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane); + + if (dead) { + snprintf(row[nbtn].label, sizeof row[0].label, "re-run"); + snprintf(row[nbtn].action, sizeof row[0].action, "rerun:%u", leaf->id); + nbtn++; + snprintf(row[nbtn].label, sizeof row[0].label, "close"); + snprintf(row[nbtn].action, sizeof row[0].action, "close:%u", leaf->id); + nbtn++; + exit_words(leaf->pane, status, sizeof status); + } else { + const pane_button_t *btns = NULL; + size_t n = pane_buttons(leaf->pane, &btns); + for (size_t i = 0; i < n && nbtn < sizeof row / sizeof *row; i++) { + snprintf(row[nbtn].label, sizeof row[0].label, "%s", btns[i].label); + snprintf(row[nbtn].action, sizeof row[0].action, "btn:%u:%s", leaf->id, + btns[i].id); + nbtn++; + } + snprintf(status, sizeof status, "%s", pane_status(leaf->pane)); + } if (!nbtn && !*status) return; rect_t r = leaf->rect; @@ -1817,16 +1913,16 @@ static void draw_pane_status(screen_t *s, node_t *leaf, color_t fg, /* right to left, so a button that does not fit is simply not drawn */ uint16_t x = right; for (size_t i = nbtn; i-- > 0;) { - uint16_t w = (uint16_t)(strlen(btns[i].label) + 2); /* [label] */ + uint16_t w = (uint16_t)(cells(row[i].label) + 2); /* [label] */ if (x < left + w + 1) break; x = (uint16_t)(x - w - 1); char label[80]; - snprintf(label, sizeof label, "[%s]", btns[i].label); - uint16_t drawn = screen_text(s, x, y, label, focused ? BTN_FG : fg, - focused ? BTN_BG : BTN_BG_IDLE, 0); - char action[48]; - snprintf(action, sizeof action, "btn:%u:%s", leaf->id, btns[i].id); - hit_add(&s->hits, x, y, drawn, 1, action); + snprintf(label, sizeof label, "[%s]", row[i].label); + bool hot = ptr_on(a, x, y, w, 1); + uint16_t drawn = screen_text(s, x, y, label, focused || hot ? BTN_FG : fg, + focused || hot ? BTN_BG : BTN_BG_IDLE, + hot ? ATTR_BOLD : 0); + hit_add(&s->hits, x, y, drawn, 1, row[i].action); } if (*status && x > left + 1) { @@ -1837,8 +1933,9 @@ static void draw_pane_status(screen_t *s, node_t *leaf, color_t fg, len = room; buf[len] = 0; } - screen_text(s, left, y, buf, focused ? TITLE_FOCUS : TITLE_IDLE, - NO_COLOR, 0); + screen_text(s, left, y, buf, + dead ? DEAD_C : (focused ? TITLE_FOCUS : TITLE_IDLE), NO_COLOR, + dead ? ATTR_BOLD : 0); } } @@ -2036,7 +2133,7 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) { hit_add(&s->hits, r.x, y1, r.w, 1, action); } - draw_pane_status(s, leaf, fg, focused); + draw_pane_status(a, s, leaf, fg, focused); /* Scroll position, when there is one: compact, right-aligned, and clickable * to get back to the bottom. Budgeted after the button and before the title, @@ -2140,6 +2237,17 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { const char *title = pane_title(leaf->pane); const char *status = pane_status(leaf->pane); + /* A dead pane says so here too, in place of whatever its program last asked + * us to show — which is stale by definition. This row is the only thing a + * flattened tab draws of a pane, and "it is over" is the one fact you + * cannot afford to have to open it to discover. The shader pass does not + * reach a header, so the words have to carry it. */ + bool dead = !pane_alive(leaf->pane) && !pane_suspended(leaf->pane); + char words[64]; + if (dead) { + exit_words(leaf->pane, words, sizeof words); + status = words; + } char line[256]; /* A space on each side, always. A title welded to the rule beside it reads * as one longer word, and the rule is not part of the name. */ @@ -2160,7 +2268,7 @@ static void draw_collapsed(app_t *a, screen_t *s, node_t *n) { /* Foreground only. A filled bar is louder than the thing it is telling you, * and this row has to sit in a list of its own kind without shouting. */ color_t rule = hot ? HEADER_HOVER : HEADER; - color_t label = hot ? HEADER_HOVER_TITLE : HEADER; + color_t label = hot ? HEADER_HOVER_TITLE : (dead ? DEAD_C : HEADER); /* A collapsed pane draws the top edge of a pane, corners and all. It is not * decoration: this row *is* a pane, closed — so a stack of them reads as a @@ -2237,6 +2345,7 @@ static pane_state_t pane_state(app_t *a, node_t *n) { if (n->id == a->drag.target) return PSTATE_DROP_HOVER; return PSTATE_DROP_TARGET; } + if (!pane_alive(n->pane) && !pane_suspended(n->pane)) return PSTATE_DEAD; if (pane_suspended(n->pane)) return PSTATE_SUSPENDED; if (pane_scrolled(n->pane)) return PSTATE_SCROLLED; if (n != cur(a)->focus) return PSTATE_UNFOCUSED; @@ -3183,6 +3292,8 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) { app_toggle_zoom(a, n->id); } else if (strncmp(action, "close:", 6) == 0) { close_leaf(a, n); + } else if (strncmp(action, "rerun:", 6) == 0) { + app_rerun_pane(a, n->id); } else if (strncmp(action, "scrollbottom:", 13) == 0) { if (ev->maction == MOUSE_PRESS) pane_scroll_edge(n->pane, false); } else if (strncmp(action, "pane:", 5) == 0) { @@ -3258,6 +3369,17 @@ static bool prefix_command(app_t *a, const input_event_t *ev) { case ACT_CLOSE_PANE: if (cur(a)->focus) close_leaf(a, cur(a)->focus); return true; + case ACT_RERUN: + /* The keyboard's half of a dead pane's [re-run] button. Refused on a + * live pane rather than restarting it: "run it again" would mean + * killing something that is still working, which is not what anybody + * pressing it is asking for. */ + if (cur(a)->focus && pane_alive(cur(a)->focus->pane) && + !pane_suspended(cur(a)->focus->pane)) + app_toast(a, "still running"); + else + app_rerun_pane(a, 0); + return true; case ACT_FOCUS_LEFT: focus_dir(a, -1, 0); return true; case ACT_FOCUS_RIGHT: focus_dir(a, 1, 0); return true; case ACT_FOCUS_UP: focus_dir(a, 0, -1); return true; @@ -3489,6 +3611,17 @@ static void panes_cb(node_t *n, void *ud) { json_bool(j, "focused", n == cur(pj->a)->focus); json_bool(j, "hidden", n->hidden); json_bool(j, "suspended", pane_suspended(n->pane)); + /* A dead pane is still a pane, so tooling has to be able to tell: `alive` + * false with a status is one whose program is over and which is waiting to + * be re-run or closed. `exit_code` is -1 when there is no status to give. */ + json_bool(j, "alive", pane_alive(n->pane)); + { + int code = 0; + bool sig = false; + bool known = pane_exit(n->pane, &code, &sig); + json_int(j, "exit_code", known && !sig ? code : -1); + json_int(j, "exit_signal", known && sig ? code : 0); + } json_str(j, "purpose", n->purpose, strlen(n->purpose)); json_bool(j, "purpose_declared", n->purpose_locked); json_int(j, "tab", (long long)tab_of(pj->a, n) + 1); @@ -47,7 +47,9 @@ void app_resize(app_t *a, uint16_t cols, uint16_t rows); bool app_pump_fd(app_t *a, int fd); /* Fill `out` with every live pane fd; returns the count. */ size_t app_fds(app_t *a, int *out, size_t max); -/* Reap panes whose process exited; collapses the tree. */ +/* Close panes whose process exited, collapsing the tree — only under + * `keep_dead false`. By default a dead pane stays until it is dismissed, so + * this does nothing and the pane offers [re-run] and [close] in its frame. */ void app_reap(app_t *a); void app_compose(app_t *a, screen_t *s); @@ -100,6 +102,9 @@ bool app_pane_zoomed(app_t *a, uint32_t id); bool app_focus_pane(app_t *a, uint32_t id); bool app_split_pane(app_t *a, uint32_t id, bool rows); bool app_close_pane(app_t *a, uint32_t id); +/* Run a dead (or not-yet-started) pane's command again, in the same pane and + * on top of the same scrollback. 0 means the focused one. */ +bool app_rerun_pane(app_t *a, uint32_t id); uint32_t app_focused_pane_id(app_t *a); uint32_t app_current_tab_id(app_t *a); @@ -140,6 +140,11 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in, return jerr("no such pane"); return jok_int("id", app_focused_pane_id(a)); } + if (strcmp(cmd, "rerun") == 0) { + if (!app_rerun_pane(a, (uint32_t)jv_geti(req, "id", 0))) + return jerr("cannot run it again"); + return jok_int("id", app_focused_pane_id(a)); + } if (strcmp(cmd, "close") == 0) { if (!app_close_pane(a, (uint32_t)jv_geti(req, "id", 0))) return jerr("no such pane"); diff --git a/src/config.c b/src/config.c index 6ce7a7f..6b10d36 100644 --- a/src/config.c +++ b/src/config.c @@ -14,7 +14,7 @@ static const struct { action_t action; } ACTIONS[] = { {"split-cols", ACT_SPLIT_COLS}, {"split-rows", ACT_SPLIT_ROWS}, - {"close-pane", ACT_CLOSE_PANE}, + {"close-pane", ACT_CLOSE_PANE}, {"rerun", ACT_RERUN}, {"zoom", ACT_ZOOM}, {"minimize", ACT_MINIMIZE}, {"focus-left", ACT_FOCUS_LEFT}, {"focus-right", ACT_FOCUS_RIGHT}, {"focus-up", ACT_FOCUS_UP}, @@ -109,8 +109,8 @@ static bool parse_color(const char *text, color_t *out) { } static const char *const PSTATE_NAMES[PSTATE_COUNT] = { - "dragging", "drop_hover", "drop_target", "suspended", "scrolled", - "unfocused", + "dragging", "drop_hover", "drop_target", "dead", "suspended", + "scrolled", "unfocused", }; const char *pane_state_name(pane_state_t s) { @@ -179,6 +179,7 @@ void config_defaults(config_t *c) { snprintf(c->min_mark, sizeof c->min_mark, "_"); c->bell_indicator = true; snprintf(c->bell_mark, sizeof c->bell_mark, "\u2022"); + c->keep_dead = true; c->min_pane_cols = 24; c->min_pane_rows = 6; c->min_split_cols = 32; @@ -211,6 +212,14 @@ void config_defaults(config_t *c) { shader_make(&c->state_shaders[PSTATE_DROP_HOVER][1], "dim", (color_t){0}, 140); c->state_n[PSTATE_DROP_HOVER] = 2; + /* The other state with an opinion, and for the same reason the drag states + * have one: what is on those cells is no longer live, and colour is the + * only thing that says so without reading a word. Gentler than the drag + * greying, because a dead pane is something you still want to read. */ + shader_make(&c->state_shaders[PSTATE_DEAD][0], "grayscale", (color_t){0}, + 200); + shader_make(&c->state_shaders[PSTATE_DEAD][1], "dim", (color_t){0}, 90); + c->state_n[PSTATE_DEAD] = 2; const color_t accent = rgb(0xff, 0x5f, 0xd7); const color_t ink = rgb(0x14, 0x14, 0x18); @@ -257,6 +266,7 @@ void config_defaults(config_t *c) { c->finder_sel_bg = accent; c->bell = accent; + c->dead = rgb(0xff, 0x87, 0x5f); c->hint = bright; c->minbar = dim; c->minbar_hover = accent; @@ -275,6 +285,7 @@ void config_defaults(config_t *c) { bind_add(c, GHOSTTY_KEY_BACKSLASH, 0, ACT_SPLIT_COLS); bind_add(c, GHOSTTY_KEY_MINUS, 0, ACT_SPLIT_ROWS); bind_add(c, GHOSTTY_KEY_X, 0, ACT_CLOSE_PANE); + bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN); bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM); bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE); bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT); @@ -388,6 +399,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { if (cm) snprintf(c->close_mark, sizeof c->close_mark, "%s", cm); const char *mm = kdl_arg(kdl_child(root, "min_mark"), 0, NULL); if (mm) snprintf(c->min_mark, sizeof c->min_mark, "%s", mm); + c->keep_dead = kdl_arg_bool(kdl_child(root, "keep_dead"), 0, c->keep_dead); c->bell_indicator = kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator); const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL); @@ -485,6 +497,7 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) { {"finder_sel_fg", &c->finder_sel_fg}, {"finder_sel_bg", &c->finder_sel_bg}, {"bell", &c->bell}, + {"dead", &c->dead}, {"hint", &c->hint}, {"minbar", &c->minbar}, {"minbar_hover", &c->minbar_hover}, diff --git a/src/config.h b/src/config.h index 76a9b85..a854fe8 100644 --- a/src/config.h +++ b/src/config.h @@ -15,13 +15,15 @@ #include "sl0ppty.h" /* Pane states a shader can be hung off, in the order they are tested. Only - * states a pane can actually be seen in: a pane whose program exits is reaped - * before the next paint, so there is deliberately no "dead" here — it would - * name a shader that could never draw a frame. */ + * states a pane can actually be seen in — which `dead` now is: a pane whose + * program exits is kept until it is dismissed, so there is a frame to draw. + * It ranks just under the drag states, above every ambient one: nothing about + * a pane is more worth knowing than that it is over. */ typedef enum { PSTATE_DRAGGING, /* the pane you have hold of */ PSTATE_DROP_HOVER, /* the one under the pointer, where it would land */ PSTATE_DROP_TARGET, /* the others, all of them somewhere it could go */ + PSTATE_DEAD, /* its program exited; waiting to be re-run or closed */ PSTATE_SUSPENDED, /* laid out, never started */ PSTATE_SCROLLED, /* looking at scrollback rather than the present */ PSTATE_UNFOCUSED, @@ -36,6 +38,7 @@ typedef enum { ACT_SPLIT_COLS, ACT_SPLIT_ROWS, ACT_CLOSE_PANE, + ACT_RERUN, ACT_ZOOM, ACT_MINIMIZE, ACT_FOCUS_LEFT, @@ -100,6 +103,13 @@ typedef struct { * terminals while chrome here is booked as one — so the default is narrow * and anyone who knows their terminal can say otherwise. */ char bell_mark[16]; + /* A pane whose program exits stays, showing what it printed and offering to + * run it again, until it is closed. Off restores the old behaviour: the + * pane vanishes on the next paint, and with the last pane so does the + * session. That is a real choice — a one-shot layout wants it — but it is + * not the default, because a pane that disappears takes its own error + * message with it. */ + bool keep_dead; uint16_t min_pane_cols, min_pane_rows; /* The smallest pane a split is allowed to *produce*. min_pane is the point * below which the layout gives up and collapses a pane; this is the point @@ -194,6 +204,10 @@ typedef struct { /* the bell mark in a pane's titlebar */ color_t bell; + /* what a dead pane says: the line in its backlog, and the word in the + * status line and its own frame */ + color_t dead; + /* the frame's own buttons */ color_t pane_button, pane_button_hover; @@ -8,6 +8,8 @@ #include <ghostty/vt.h> #include <stdlib.h> #include <string.h> +#include <sys/wait.h> +#include <time.h> #include <unistd.h> #include "input.h" @@ -25,6 +27,13 @@ struct pane { GhosttyMouseEvent mev; uint16_t cols, rows_n; bool alive; + /* How the program ended. `exit_known` is separate from `!alive` because the + * two really are different: EOF on the master says the pane is over, and + * the wait that says *why* can lose a race with the kernel finishing the + * exit. Better to say "exited" than to invent a status. */ + bool exit_known; + bool exit_signaled; + int exit_code; bool dirty; bool bell; /* rang, and not yet looked at */ char title[256]; @@ -37,7 +46,11 @@ struct pane { /* OSC 5577 state: what this pane asked us to draw in its frame */ /* A suspended pane is real, sized and laid out, but has not run anything: * a root with twelve projects must not become twelve running servers. It - * starts on the first keystroke it is given. */ + * starts on the first keystroke it is given. + * + * argv and cwd are kept for every pane, not only a suspended one: re-running + * a dead pane needs exactly what it was started with, and by then the pane + * is the only thing that still knows. */ bool suspended; char **argv; char *cwd; @@ -235,14 +248,12 @@ pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows, if (!p) return NULL; snprintf(p->label, sizeof p->label, "%s", label ? label : ""); if (suspended) { - /* pane_new already spawned; a suspended pane must not have. Close it and - * keep the arguments for later, rather than duplicating the constructor. */ + /* pane_new already spawned; a suspended pane must not have. Close it — the + * command it was built from is already kept, because every pane keeps it. */ pty_close(&p->pty); p->pty.fd = -1; p->suspended = true; p->alive = true; - p->argv = argv_dup(argv); - p->cwd = cwd ? strdup(cwd) : NULL; } return p; } @@ -299,6 +310,9 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, ghostty_terminal_set(p->term, GHOSTTY_TERMINAL_OPT_MODE_DEFAULT, &mc); } + p->argv = argv_dup(argv); + p->cwd = cwd ? strdup(cwd) : NULL; + if (pty_spawn(&p->pty, argv, cols, rows, cwd) != 0) goto fail; p->alive = true; p->dirty = true; @@ -351,6 +365,90 @@ void pane_set_name(pane_t *p, const char *name) { p->dirty = true; } +/* The program is gone: collect its status, and give the pty back. + * + * Two things have to happen here and nowhere else. The status has to be + * waited for *before* pty_close(), which sends SIGHUP to a pid that may by + * then have been recycled if something else reaped it first. And the fd has + * to be closed, because a pane that outlives its program stays in the tree + * now, and an EOF fd left in the poll set is readable forever — a session + * spinning at 100% CPU behind a pane that looks idle. + * + * The wait is bounded and short on purpose. EOF on the master means every + * slave fd is closed, which for a program that exited happened *during* its + * exit — so it is already on its way to being a zombie and the only race is + * with the tail of the kernel's exit path. A few hundred microseconds covers + * it; a blocking wait would instead hang the whole session on the one program + * that closes its terminal and keeps running. Losing the race costs the word + * "status 0" and nothing else. */ +static void pane_died(pane_t *p) { + if (!p->alive) return; + p->alive = false; + p->dirty = true; + + for (int i = 0; p->pty.pid > 0 && i < 5; i++) { + int st = 0; + pid_t r = waitpid(p->pty.pid, &st, WNOHANG); + if (r == p->pty.pid) { + p->exit_known = true; + p->exit_signaled = WIFSIGNALED(st); + p->exit_code = WIFSIGNALED(st) ? WTERMSIG(st) + : (WIFEXITED(st) ? WEXITSTATUS(st) : 0); + p->pty.pid = -1; /* reaped: nothing may signal this number again */ + break; + } + if (r < 0) break; /* somebody else's child, or already reaped */ + nanosleep(&(struct timespec){0, 200000}, NULL); + } + pty_close(&p->pty); +} + +bool pane_exit(const pane_t *p, int *code, bool *signaled) { + if (p->alive || !p->exit_known) return false; + if (code) *code = p->exit_code; + if (signaled) *signaled = p->exit_signaled; + return true; +} + +bool pane_restart(pane_t *p) { + if (p->alive || !p->argv) return false; + /* Whatever the last run asked us to draw in its frame died with it: a + * status line and buttons from a program that is not there describe + * nothing, and the new run gets to say its own. */ + p->status[0] = 0; + p->nbuttons = 0; + osc_scan_reset(&p->scan); + p->exit_known = false; + p->exit_signaled = false; + p->exit_code = 0; + + if (pty_spawn(&p->pty, (const char *const *)p->argv, p->cols, p->rows_n, + p->cwd) != 0) + return false; + /* The terminal is deliberately not cleared: the run that ended, and the + * line saying it ended, stay above this one in the scrollback. That is the + * whole reason a dead pane was kept. */ + p->suspended = false; + p->alive = true; + p->dirty = true; + return true; +} + +void pane_note(pane_t *p, const char *text, color_t fg) { + if (!text || !*text) return; + char sgr[32] = "\x1b[2m"; /* no colour given: dim, which every terminal has */ + if (fg.set) + snprintf(sgr, sizeof sgr, "\x1b[38;2;%u;%u;%um", fg.r, fg.g, fg.b); + char buf[320]; + /* Leading CRLF because the cursor is wherever the program left it, which is + * usually mid-line; trailing so anything after it starts clean. */ + int n = snprintf(buf, sizeof buf, "\r\n%s%s\x1b[0m\r\n", sgr, text); + if (n <= 0) return; + if ((size_t)n >= sizeof buf) n = (int)strlen(buf); + ghostty_terminal_vt_write(p->term, (const uint8_t *)buf, (size_t)n); + p->dirty = true; +} + ssize_t pane_pump(pane_t *p) { if (p->pty.fd < 0) return 0; uint8_t buf[65536]; @@ -368,12 +466,12 @@ ssize_t pane_pump(pane_t *p) { continue; } if (n == 0) { - p->alive = false; + pane_died(p); return 0; } if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; - p->alive = false; + pane_died(p); return -1; } return total; diff --git a/src/sl0ppty.h b/src/sl0ppty.h index 9c6e8d1..2b7d905 100644 --- a/src/sl0ppty.h +++ b/src/sl0ppty.h @@ -138,8 +138,23 @@ bool pane_start(pane_t *p); void pane_free(pane_t *p); int pane_fd(const pane_t *p); bool pane_alive(const pane_t *p); +/* How the program ended, for a pane that is no longer alive. `code` is the + * exit status, or the signal number when `signaled`. False while it is still + * running, and also when the status could not be collected — a pane can be + * known to be gone without it being known why. */ +bool pane_exit(const pane_t *p, int *code, bool *signaled); +/* Run the pane's command again, in the same pty-less pane: same argv, same + * cwd, same terminal, so the previous run stays above in the scrollback. + * False if the pane is still alive or the spawn failed. */ +bool pane_restart(pane_t *p); +/* Write a line into the pane's own terminal — the backlog, not the pty (which + * by the time this is wanted is closed). It scrolls with the output it + * followed, which is the point: it is a thing that happened to this pane. */ +void pane_note(pane_t *p, const char *text, color_t fg); /* Read available pty output into the terminal. Returns bytes read, 0 on EOF, - * -1 on error (EAGAIN is reported as 0 bytes with alive still true). */ + * -1 on error (EAGAIN is reported as 0 bytes with alive still true). A pane + * that reaches EOF stops being alive here and closes its pty, but keeps its + * terminal: a dead pane is still a pane with contents you can read. */ ssize_t pane_pump(pane_t *p); void pane_write(pane_t *p, const void *buf, size_t len); /* Re-encode a decoded event against this pane's own negotiated modes. */ diff --git a/tests/test_dead.py b/tests/test_dead.py new file mode 100644 index 0000000..86d3285 --- /dev/null +++ b/tests/test_dead.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""A pane whose program exits stays until it is dismissed. + +The old behaviour reaped it before the next paint, which meant the last thing +a failing command printed vanished along with the exit status that would have +explained it. Now the pane keeps its contents, says why it is over in its own +backlog and in its frame, and offers the only two verbs left that mean +anything: run it again, or close it. + +Buttons are found through the hit list rather than by column, because where +they land is the frame's business and not this test's. +""" +import sys +import tempfile + +from harness import Session, check, report + +# Exits with a status, having printed something worth keeping. +DIES = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; echo mark-one; exit 3'] +# Alive until told otherwise: it waits for a line, then exits. A `cat` in raw +# mode would not do — raw mode is exactly where EOT stops meaning end-of-file. +LIVES = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x'] +# The same, having painted a known non-default colour into its own content. +GREEN = ["/bin/sh", "-c", + 'printf "\\033]2;p\\007"; printf "\\033[38;2;0;255;0mBLOCK\\033[0m\\n"; ' + 'read x'] +DONE = "q\\n" # the line those two are waiting for + + +def cfg(text): + f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) + f.write(text) + f.close() + return f.name + + +def hit(snap, action): + """(x, y) of the first cell registering an action, or None.""" + for e in snap.hits: + if e["action"] == action: + return e["x"], e["y"] + return None + + +def test_it_stays_and_says_why(): + with Session(DIES, cols=60, rows=12) as s: + snap = s.until_text("[process exited") + pane = s.pane() + + check("the pane is still there", len(s.panes()) == 1, str(s.panes())) + check("and the session with it", s.alive()) + check("what it printed is still on screen", "mark-one" in snap.screen()) + check("the backlog says it exited", + "[process exited: status 3]" in snap.screen(), snap.screen()) + check("the frame says so too", "exited: status 3" in snap.screen()) + + check("json calls it dead", pane["alive"] is False, str(pane)) + check("with the status", pane["exit_code"] == 3, str(pane)) + check("and no signal", pane["exit_signal"] == 0, str(pane)) + + +def test_a_clean_exit_is_not_dressed_up_as_a_failure(): + with Session(["/bin/sh", "-c", "true"], cols=60, rows=12) as s: + snap = s.until_text("[process exited") + check("no status number for 0", + "[process exited]" in snap.screen(), snap.screen()) + check("and the code is reported as 0", s.pane()["exit_code"] == 0) + + +def test_a_signal_is_named_as_one(): + with Session(["/bin/sh", "-c", "kill -9 $$"], cols=60, rows=12) as s: + snap = s.until_text("[process exited") + check("the backlog says signal", + "[process exited: signal 9]" in snap.screen(), snap.screen()) + check("and so does json", s.pane()["exit_signal"] == 9, str(s.pane())) + + +def test_the_frame_offers_the_two_verbs_that_are_left(): + with Session(DIES, cols=60, rows=12) as s: + snap = s.until_text("[process exited") + pane = s.pane() + + check("a re-run button", "[re-run]" in snap.screen(), snap.screen()) + check("a close button", "[close]" in snap.screen(), snap.screen()) + + rerun = hit(snap, f"rerun:{pane['id']}") + close = hit(snap, f"close:{pane['id']}") + check("re-run is clickable", rerun is not None, str(snap.hits)) + check("close is clickable", close is not None, str(snap.hits)) + # Rightmost is the one that must survive a narrow frame. + check("close sits to the right of re-run", close[0] > rerun[0], + f"{rerun} {close}") + + check("the button is drawn where its hit is", + "[re-run]" in snap.line(rerun[1]), snap.line(rerun[1])) + + +def test_a_live_pane_keeps_its_own_buttons(): + """The dead row replaces the pane's OSC buttons; it must not pre-empt them.""" + argv = ["/bin/sh", "-c", + 'printf "\\033]5577;1;buttons;go:Go\\033\\\\"; stty raw -echo; cat'] + with Session(argv, cols=60, rows=12) as s: + snap = s.until_text("[Go]") + check("a live pane's own button is drawn", "[Go]" in snap.screen()) + check("and no dead buttons are", "[re-run]" not in snap.screen(), + snap.screen()) + + +def test_rerun_runs_it_again_on_top_of_what_it_left(): + # Tall enough that both runs are still on screen rather than in the + # scrollback: what is being asserted is that the first one was kept. + with Session(DIES, cols=60, rows=20) as s: + snap = s.until_text("[process exited") + pane = s.pane() + 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()) + 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"]) + + snap = s.until(lambda sn: sn.screen().count("[process exited") == 2) + check("and dying again says so again", + snap.screen().count("[process exited: status 3]") == 2, + snap.screen()) + check("with the buttons back", "[re-run]" in snap.screen()) + + +def test_rerun_over_the_control_api(): + with Session(DIES, cols=60, rows=20) as s: + s.until_text("[process exited") + 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()) + + reply = s.api("rerun", id=9999) + check("an unknown pane is refused", reply.get("ok") is False, str(reply)) + + +def test_the_keyboard_can_do_it_too(): + with Session(DIES, cols=60, rows=20) 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.screen()) + + +def test_a_live_pane_is_not_restarted_under_you(): + """"Run it again" on something still running would mean killing it.""" + with Session(LIVES, cols=60, rows=12) as s: + s.until_text("p") + s.key("r") + s.settle() + snap = s.snapshot() + check("it says no rather than doing it", "still running" in snap.screen(), + snap.screen()) + check("and the pane is untouched", s.pane()["alive"] is True) + + +def test_closing_it_is_what_takes_it_away(): + with Session(LIVES, cols=80, rows=16) as s: + s.until_text("p") + s.key("\\\\") # split, so closing one leaves a session behind + s.settle() + panes = s.panes() + check("two panes", len(panes) == 2, str(panes)) + + # End the focused one's program and let it die. + s.raw(DONE) + snap = s.until(lambda sn: "[process exited" in sn.screen()) + check("still two panes", len(s.panes()) == 2, str(s.panes())) + + dead = [p for p in s.panes() if not p["alive"]][0] + x, y = hit(snap, f"close:{dead['id']}") + s.click(x, y) + s.settle() + check("closing leaves one", len(s.panes()) == 1, str(s.panes())) + check("and it is the live one", s.panes()[0]["alive"] is True) + + +def test_keep_dead_false_is_the_old_behaviour(): + with Session(LIVES, cols=80, rows=16, + config=cfg("keep_dead false\n")) as s: + s.until_text("p") + s.key("\\\\") + s.settle() + check("two panes", len(s.panes()) == 2, str(s.panes())) + s.raw(DONE) + snap = s.until(lambda sn: len(s.panes()) == 1) + check("the dead pane is gone", len(s.panes()) == 1, str(s.panes())) + check("and left no notice", "[process exited" not in snap.screen(), + snap.screen()) + + +def test_dying_recolours_what_the_pane_left_behind(): + """`dead` is a shader state now, which it could not be while panes were + reaped before the next paint. Asserted as a change, not as a colour: which + grey is the config's business.""" + with Session(GREEN, cols=60, rows=12) as s: + snap = s.until_text("BLOCK") + x, y = snap.find("BLOCK") + live = snap.style_at(x, y) + check("a live pane keeps the colour it printed", + live and live.get("fg") == "#00ff00", str(live)) + + s.raw(DONE) + snap = s.until(lambda sn: "[process exited" in sn.screen()) + x, y = snap.find("BLOCK") + dead = snap.style_at(x, y) + check("a dead one does not", + dead and dead.get("fg") != "#00ff00", str(dead)) + + +def test_a_flattened_tab_still_says_which_pane_is_over(): + """A collapsed header is all a flattened tab draws of a pane, and the + shader pass does not reach one — so the words have to carry it.""" + with Session(LIVES, cols=100, rows=20) as s: + s.until_text("p") + s.key("\\\\") + s.settle() + s.raw(DONE) # the focused (right-hand) one dies + s.until(lambda sn: "[process exited" in sn.screen()) + s.key("h") # focus the live one, so the dead one + s.settle() # is the pane that collapses + s.resize(46, 14) # no room for two: the tab flattens + s.settle() + + snap = s.snapshot() + header = [l for l in snap.text if "· exited" in l] + check("the header says it exited", header, snap.screen()) + + +def test_the_status_line_names_the_state(): + with Session(DIES, cols=60, rows=12) as s: + snap = s.until_text("[process exited") + check("the focused dead pane is called exited", + "exited: status 3" in snap.line(snap.rows - 2), + snap.line(snap.rows - 2)) + + +for name, fn in sorted(list(globals().items())): + if name.startswith("test_"): + fn() +sys.exit(report()) |
