aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-14 21:32:40 +0200
committeruser <user@clank>2026-08-14 21:32:40 +0200
commitc5f4bb75403df3ca7e0bf6c30981c0cb4e54921e (patch)
tree44bddd3b1ba1ea0f4782b50d9c095c6b813f7b7b /src
parentsl0ppty: let a child's exit status survive long enough to be read (diff)
downloadslosh-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.
Diffstat (limited to 'src')
-rw-r--r--src/app.c179
-rw-r--r--src/app.h7
-rw-r--r--src/cmd.c5
-rw-r--r--src/config.c19
-rw-r--r--src/config.h20
-rw-r--r--src/pane.c112
-rw-r--r--src/sl0ppty.h17
7 files changed, 321 insertions, 38 deletions
diff --git a/src/app.c b/src/app.c
index 8902a74..ce95175 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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);
diff --git a/src/app.h b/src/app.h
index 7106305..33756e7 100644
--- a/src/app.h
+++ b/src/app.h
@@ -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);
diff --git a/src/cmd.c b/src/cmd.c
index eff366b..a3e0fdf 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -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;
diff --git a/src/pane.c b/src/pane.c
index 82e2fae..eac7f92 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -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. */