aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-22 15:12:02 +0200
committeruser <user@clank>2026-09-22 15:12:02 +0200
commit720aa48e249c0c962e8f73372ceded6092414a6e (patch)
treedb713dc86f1333dab0f509e633077384005562c3
parentsidebar: air inside it, the way panes have padding (tab_bar_padding) (diff)
downloadslosh-720aa48e249c0c962e8f73372ceded6092414a6e.tar.gz
slosh-720aa48e249c0c962e8f73372ceded6092414a6e.tar.xz
slosh-720aa48e249c0c962e8f73372ceded6092414a6e.zip
osc 5577: `busy`, and a sidebar that shows it (busy_mark)
A status is a line of text, and a line of text cannot say whether it is still true. "building 3/7" reads exactly the same while it builds and ten minutes after it stopped, which is the one thing somebody reading it from another tab wants to know -- and the only workaround available to a program was to write the answer into the words and keep rewriting them. So `busy`: printf '\033]5577;1;status;make test\033\\' printf '\033]5577;1;busy;1\033\\' ... printf '\033]5577;1;busy;0\033\\' A flag of its own rather than a field of `status`, because a status is the *whole* payload after the verb -- `status;a;b;c` has always been the text "a;b;c" -- so anything added to that line would change what every existing sender means, including the five pi extensions D1 promises byte compatibility to. Orthogonal, so a program can leave one line up and toggle the other; anything but 0/false/no/off is on; `clear` takes both; and a pane whose program has gone is never busy, whatever it last claimed, because a spinner turning under a dead tab is a lie you keep looking at. In a sidebar the row turns `tab_status_busy` -- the theme's accent, derived like the other status colours, so no theme needs a line -- and takes a spinner in the column `tab_bar_padding` was already holding blank, so nothing reflows when a program starts or stops working. `busy_mark` is the frames, separated by spaces: one frame is a static mark and costs nothing, several make it turn, and only then does the session keep a frame clock -- via the same `animating` flag an animated shader uses, so the cost lands only while such a row is actually composed, and `""` drops the mark entirely and leaves the colour to say it. The frame is a pure function of the clock, like every other pulse here, so two clients attached to one session show the same one. `panes` now reports `status` and `busy`, which is the scriptable half of the same fact: what a pane says about itself, without reading its screen.
-rw-r--r--.agents/skills/driving-slosh/SKILL.md11
-rw-r--r--config/config.kdl15
-rw-r--r--docs/config.md2
-rw-r--r--docs/panes.md11
-rw-r--r--docs/scripting.md13
-rw-r--r--docs/slosh.512
-rw-r--r--include/app_internal.h1
-rw-r--r--include/config.h17
-rw-r--r--include/slosh.h5
-rw-r--r--src/app.c7
-rw-r--r--src/app_draw.c72
-rw-r--r--src/config.c25
-rw-r--r--src/pane.c18
-rw-r--r--tests/test_osc5577.py65
-rw-r--r--tests/test_tab_bar.py132
-rw-r--r--tests/test_themes.py2
16 files changed, 404 insertions, 4 deletions
diff --git a/.agents/skills/driving-slosh/SKILL.md b/.agents/skills/driving-slosh/SKILL.md
index 3d14b3f..37bbb9b 100644
--- a/.agents/skills/driving-slosh/SKILL.md
+++ b/.agents/skills/driving-slosh/SKILL.md
@@ -211,6 +211,8 @@ sequence. No socket, no config, nothing to set up:
```bash
printf '\e]5577;1;status;building 3/7\e\\'
+printf '\e]5577;1;busy;1\e\\' # ...and it is still going
+printf '\e]5577;1;busy;0\e\\' # ...and now it is not
printf '\e]5577;1;buttons;approve:Approve;cancel:Cancel\e\\'
printf '\e]5577;1;purpose;task:build\e\\'
printf '\e]5577;1;clear\e\\'
@@ -221,6 +223,15 @@ sidebar (`tab_bar_side left/right`), as a row under your pane's tab, visible
from every other tab. Treat it as your progress report to somebody working
elsewhere: keep it current while you work ("building 3/7", "tests green"),
and `clear` it when you are done, so a stale line never outlives the work.
+
+**`busy` is the half the words cannot carry.** "building 3/7" reads the same
+whether it is building or gave up ten minutes ago, so say which: `busy;1` when
+you start something, `busy;0` when it lands. The sidebar draws a busy status in
+its own colour with a spinner beside it, which is what somebody in another tab
+is actually reading. It is a separate flag, so setting a new status does not
+change it; `clear` clears both, and so does your program exiting.
+Both are reported by `panes` as `status` and `busy`, so a script can see what
+every pane says about itself without reading any screens.
The buttons are real click targets, and a
click arrives **on your stdin** as:
diff --git a/config/config.kdl b/config/config.kdl
index 2ee0fb9..aac3292 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -280,6 +280,21 @@ bell_indicator true
// this file has no \u escape, so put the character itself here).
bell_mark "[!]"
+// A pane can also say that what its status describes is still *happening*
+// (`busy` over OSC 5577, see the scripting chapter). In a sidebar that status
+// then turns the theme's `tab_status_busy` colour and carries this spinner,
+// which is how "running" and "finished" tell themselves apart from another tab
+// without reading the words.
+//
+// The frames are separated by spaces. One frame is a static mark and costs
+// nothing; several make it turn, and only then does the session keep a frame
+// clock -- and only while such a row is actually on screen. "" draws no mark
+// at all and leaves the colour to say it. The mark sits in the column
+// `tab_bar_padding` was holding blank, so nothing moves when a program starts
+// or stops working; give it a gutter of its own with `tab_bar_padding 0 2`.
+busy_mark "⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏"
+busy_ms 120
+
// The one-row bar with tabs, the new-tab button and the pane count.
status_bar true
diff --git a/docs/config.md b/docs/config.md
index dba0dd2..a821a8d 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -134,7 +134,7 @@ whatever the file says.
| group | what is in it |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| geometry | `gap` `gap_aspect` `padding` `compact` `rounded` `title_align` `title_inset` `min_pane` `min_split` |
-| chrome | `status_bar` `status_line` `status_pad` `tab_bar_side` `tab_bar_index` `tab_bar_width` `tab_bar_padding` `tab_bar_pad` `tab_gap` `tab_bar_status` `tab_bar_status_lines` `tab_bar_chrome` `hints` `version_banner` `pane_buttons` `bell_indicator` `newtab_button` `newtab_pad` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark`) |
+| chrome | `status_bar` `status_line` `status_pad` `tab_bar_side` `tab_bar_index` `tab_bar_width` `tab_bar_padding` `tab_bar_pad` `tab_gap` `tab_bar_status` `tab_bar_status_lines` `tab_bar_chrome` `hints` `version_banner` `pane_buttons` `bell_indicator` `newtab_button` `newtab_pad` and the marks (`zoom_mark` `zoom_on_mark` `close_mark` `min_mark` `newtab_mark` `bell_mark` `busy_mark` `busy_ms`) |
| behaviour | `focus_follows_mouse` `ctrl_d_exits` `scroll_lines` `scrollback` `scrollback_bytes` `toast_ms` `splash_ms` `hover_delay_ms` `double_click_ms` `word_separators` `anim_ms` `modal_scrim` `dim_unfocused` `float_shadow` `keep_dead` `in_band_shaders` `multi_attach` `attach_indicator` `size_follows` `shell` `editor` |
| projects | `project_roots` `project_layout` (see [workspaces](workspaces.md)) |
| colour | `theme { }` |
diff --git a/docs/panes.md b/docs/panes.md
index 7018fc8..0af41ce 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -98,6 +98,17 @@ way: **the statuses give way before the tabs do**, because the list is how you
navigate and a status is only an annotation on it — a talkative pane must not
be able to cost you a tab you can click.
+**A pane can say its status is still happening**, and the sidebar says so back:
+`busy` over OSC 5577 turns that row `tab_status_busy` and puts a spinner
+(`busy_mark`) in the column the padding holds blank — so `make test` running and
+`make test` finished, which are the same words, stop looking the same from
+another tab. Nothing moves when it flips, since the mark takes a column that
+was already there; `tab_bar_padding 0 2` gives it a gutter of its own. One frame
+in `busy_mark` is a static mark and costs nothing, several make it turn, and the
+session only keeps a frame clock while such a row is actually on screen. The
+flag dies with the program: a status left behind by something that exited
+describes the past.
+
**`tab_bar_padding` is the air inside the strip**, between its frame and the
list: 1, 2 or 4 values in CSS order, the shape [`padding`](config.md) takes for
a pane. In cells rather than `padding`'s rows-times-`gap_aspect`, because the
diff --git a/docs/scripting.md b/docs/scripting.md
index 048a7fa..9971bc5 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -93,6 +93,8 @@ By printing an escape sequence. No plugin, no config:
```bash
printf '\033]5577;1;status;building 3/7\033\\'
+printf '\033]5577;1;busy;1\033\\' # ...and it is still going
+printf '\033]5577;1;busy;0\033\\' # ...and now it is not
printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'
# clicking [Approve] arrives on the program's stdin as:
# \033]5577;1;click;approve\033\\
@@ -108,6 +110,17 @@ the pane that said it. A status is not decoration on your own frame; it is how
a pane reports progress to somebody working elsewhere, which is a reason to
keep it current and `clear` it when the work is done.
+`busy` is the one thing a line of text cannot say about itself: `make test` and
+`make test` are the same words whether it is running or finished. It is a flag
+of its own rather than a field of `status`, because a status is the *whole*
+payload after the verb — `status;a;b;c` is the text `a;b;c` — so anything added
+to that line would change what every sender already means. In a sidebar a busy
+status gets the spinner (`busy_mark`) and `tab_status_busy`, which is how
+"working" and "done" tell themselves apart from another tab without reading the
+words. Anything but `0`, `false`, `no` or `off` turns it on; `clear` turns it
+off along with the text, and so does the program exiting — a status left behind
+by something that is gone describes the past, whatever it last claimed.
+
`purpose` is the other verb: `printf '\033]5577;1;purpose;logs\033\\'`. A purpose
declared by a layout or the control API wins and cannot be overwritten this way.
diff --git a/docs/slosh.5 b/docs/slosh.5
index 6d6acc2..9c88c1e 100644
--- a/docs/slosh.5
+++ b/docs/slosh.5
@@ -211,6 +211,18 @@ Whether a pane's bell shows at all (the mark, and the frame effect).
.BR zoom_mark ,\ zoom_on_mark ,\ close_mark ,\ min_mark ,\ newtab_mark ,\ bell_mark
The glyphs themselves, each one string.
.TP
+.BR busy_mark\ \fIframes\fR ,\ busy_ms\ \fIn\fR
+What a pane whose status says it is still working shows in a sidebar, and
+how long a frame of it is held.
+.B busy_mark
+is the spinner's frames separated by spaces; one frame is a static mark and
+costs nothing, several make it turn, and the session keeps a frame clock only
+while such a row is on screen. Empty draws no mark and leaves
+.B tab_status_busy
+to say it. The mark sits in the column
+.B tab_bar_padding
+holds blank, so nothing moves when a program starts or stops working.
+.TP
.BR newtab_button\ \fIbool\fR ,\ newtab_pad\ \fIn\fR
The new-tab button at the end of the tab strip, and the air between it and
the last tab \(em \fIn\fR blank rows in a sidebar, \fIn\fR blank columns in
diff --git a/include/app_internal.h b/include/app_internal.h
index 7948b13..1af9163 100644
--- a/include/app_internal.h
+++ b/include/app_internal.h
@@ -412,6 +412,7 @@ extern config_t CFG;
#define TAB_COUNT (CFG.tab_count)
#define TAB_STATUS_FG (CFG.tab_status_fg)
#define TAB_STATUS_BG (CFG.tab_status_bg)
+#define TAB_STATUS_BUSY (CFG.tab_status_busy)
#define TAB_STATUS_STRIPE (CFG.tab_status_stripe)
#define STATUS_C (CFG.status)
#define STATUS_STATE (CFG.status_state)
diff --git a/include/config.h b/include/config.h
index 47e48a7..40a5ace 100644
--- a/include/config.h
+++ b/include/config.h
@@ -235,6 +235,17 @@ 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[32];
+ /* What a pane that says it is *busy* looks like in the sidebar: the frames
+ * of a spinner, separated by spaces. One frame is a static mark and costs
+ * nothing; several make it turn, and only then does the session keep a
+ * frame clock -- and only while such a row is actually on screen. Empty
+ * draws no mark at all and leaves the colour to say it. */
+ char busy_mark[64];
+ /* Milliseconds a spinner frame is held. Its own knob rather than anim_ms,
+ * which is how often the session repaints while something animates: this is
+ * how fast the thing being repainted moves, and a spinner stepping at the
+ * repaint rate is a blur. */
+ uint16_t busy_ms;
/* Which dead panes stay, showing what they printed and offering to run
* again, and which just go.
*
@@ -530,6 +541,12 @@ typedef struct {
* none leaves the terminal's own background alone, which is what a
* translucent one wants. */
color_t tab_status_fg, tab_status_bg;
+ /* A status whose program says it is still happening. Brighter than the
+ * ambient one, because "running" and "finished" are the two states worth
+ * telling apart from another tab, and the difference between them should
+ * not be something you have to read the words to work out. Derived from the
+ * theme, like tab_status_fg. */
+ color_t tab_status_busy;
/* The other stripe. With wrapped statuses, where one pane's rows end and
* the next one's begin is no longer obvious from the shape -- both are
* just italic text down the same column -- so alternate panes sit on
diff --git a/include/slosh.h b/include/slosh.h
index beec697..b8c8849 100644
--- a/include/slosh.h
+++ b/include/slosh.h
@@ -313,6 +313,11 @@ const char *pane_name(const pane_t *p);
/* "" clears the name and gives the pane back to the program's title. */
void pane_set_name(pane_t *p, const char *name);
const char *pane_status(const pane_t *p);
+/* Whether the program says what its status describes is still happening
+ * (`busy` over OSC 5577). Never true of a pane whose program has gone: a
+ * status left behind by something that exited is a description of the past,
+ * whatever it last claimed. */
+bool pane_status_busy(const pane_t *p);
size_t pane_buttons(const pane_t *p, const pane_button_t **out);
void pane_click_button(pane_t *p, const char *id);
void pane_set_osc_handler(pane_t *p, pane_osc_fn fn, void *ud);
diff --git a/src/app.c b/src/app.c
index cefaa44..385c5f9 100644
--- a/src/app.c
+++ b/src/app.c
@@ -2155,6 +2155,13 @@ static void panes_cb(node_t *n, void *ud) {
json_int(j, "tab_id", (long long)pj->a->tabs[ti].id);
const char *t = pane_title(n->pane);
json_str(j, "title", t ? t : "", t ? strlen(t) : 0);
+ /* What the pane said about itself over OSC 5577, which is the one thing in
+ * here it chose rather than had chosen for it -- and whether it says that
+ * is still happening, which `alive` does not answer: a pane can be alive
+ * and idle all day. */
+ const char *st = pane_status(n->pane);
+ json_str(j, "status", st ? st : "", st ? strlen(st) : 0);
+ json_bool(j, "busy", pane_status_busy(n->pane));
json_obj_close(j);
}
diff --git a/src/app_draw.c b/src/app_draw.c
index cf3ff39..8c70771 100644
--- a/src/app_draw.c
+++ b/src/app_draw.c
@@ -2089,6 +2089,7 @@ struct tabstatus {
* of it could ever be shown. */
char text[256];
uint32_t pane;
+ bool busy; /* the program says this is still happening */
} row[16];
size_t n; /* rows kept */
size_t total; /* rows there were, so the cap can say "and more" */
@@ -2107,6 +2108,7 @@ static void tabstatus_cb(node_t *n, void *ud) {
if (ts->n >= sizeof ts->row / sizeof *ts->row) return;
snprintf(ts->row[ts->n].text, sizeof ts->row[0].text, "%s", text);
ts->row[ts->n].pane = n->id;
+ ts->row[ts->n].busy = pane_status_busy(n->pane);
ts->n++;
}
@@ -2139,6 +2141,46 @@ static void mark_cut(char *row, size_t cap, uint16_t width) {
if (len + 4 <= cap) memcpy(row + len, "\u2026", 4);
}
+/* One frame of `busy_mark`, by wall clock, into `out`; false when the mark is
+ * empty. `spins` says whether the mark has more than one frame in it, which is
+ * the only thing that costs the session a frame clock -- a static mark is a
+ * mark, and a mark does not need repainting.
+ *
+ * Time rather than a counter for the same reason the rest of this file derives
+ * rather than remembers: two sidebars drawn from one session (several clients
+ * attached) show the same frame, and a frame dropped under load is a frame
+ * skipped rather than a spinner that limps behind. */
+static bool busy_frame(char *out, size_t cap, bool *spins) {
+ const char *m = CFG.busy_mark;
+ *spins = false;
+ while (*m == ' ') m++;
+ if (!*m) return false;
+ size_t n = 0;
+ const char *starts[16];
+ size_t lens[16];
+ for (const char *p = m; *p && n < 16;) {
+ while (*p == ' ') p++;
+ if (!*p) break;
+ const char *e = p;
+ while (*e && *e != ' ') e++;
+ starts[n] = p;
+ lens[n] = (size_t)(e - p);
+ n++;
+ p = e;
+ }
+ if (!n) return false;
+ *spins = n > 1;
+ size_t i = 0;
+ if (n > 1) {
+ uint16_t step = CFG.busy_ms ? CFG.busy_ms : 120;
+ i = (size_t)((uint64_t)(now_ms_() / step) % n);
+ }
+ size_t len = lens[i] < cap - 1 ? lens[i] : cap - 1;
+ memcpy(out, starts[i], len);
+ out[len] = 0;
+ return true;
+}
+
#define STATUS_WRAP_MAX 8 /* rows one status may ever take */
#define STATUS_ROW_CAP 256
@@ -2377,6 +2419,21 @@ void draw_tab_sidebar(app_t *a, screen_t *s) {
color_t band = TAB_STATUS_BG;
if (lines > 1 && (j % 2)) band = TAB_STATUS_STRIPE;
+ /* A pane that says it is still working: its rows in the busy colour,
+ * and a spinner in the column the padding would otherwise leave blank.
+ * The mark goes on the first row only -- it belongs to the status, not
+ * to each line it wrapped onto -- and the colour carries the rest,
+ * which is also what a sidebar with no padding to spare falls back to.
+ *
+ * This is the one thing a status line cannot say about itself: the text
+ * of a finished `make test` and a running one is the same text. */
+ bool busy = ts.row[j].busy;
+ char spin[16] = "";
+ bool spins = false;
+ bool marked =
+ busy && CFG.tab_pad_left && busy_frame(spin, sizeof spin, &spins);
+ if (marked && spins) a->animating = true;
+
for (size_t k = 0; k < nrows[j] && y < limit; k++, y++) {
char text[STATUS_ROW_CAP + 8];
/* One leading space, which is the label's own -- a status starts in
@@ -2397,8 +2454,19 @@ void draw_tab_sidebar(app_t *a, screen_t *s) {
* would be left with none -- tab_status_fg/bg are there for themes
* that do. The hover brightening doubles as the affordance that the
* row is a door; the hint under the pointer says where it leads. */
- screen_text(s, cx, y, text, hot ? TAB_HOVER : TAB_STATUS_FG, band,
- hot ? ATTR_BOLD : ATTR_ITALIC);
+ screen_text(s, cx, y, text,
+ hot ? TAB_HOVER
+ : busy ? TAB_STATUS_BUSY
+ : TAB_STATUS_FG,
+ band, hot ? ATTR_BOLD : ATTR_ITALIC);
+ /* In the indent, against the text rather than against the frame, and
+ * on the first row only: the mark belongs to the status, not to each
+ * line it wrapped onto. It takes a column the padding was holding
+ * blank, so nothing moves when a program starts or stops working --
+ * a list that reflowed on every `make` would be worse than no mark. */
+ if (marked && k == 0)
+ screen_text(s, (uint16_t)(cx + CFG.tab_pad_left - 1), y, spin,
+ hot ? TAB_HOVER : TAB_STATUS_BUSY, band, 0);
char act[24];
snprintf(act, sizeof act, "find:%u", ts.row[j].pane);
hit_add(&s->hits, cx, y, cw, 1, act);
diff --git a/src/config.c b/src/config.c
index 81f23d5..44aee5b 100644
--- a/src/config.c
+++ b/src/config.c
@@ -696,6 +696,7 @@ static const struct {
{"tab_count", offsetof(config_t, tab_count)},
{"tab_status_fg", offsetof(config_t, tab_status_fg)},
{"tab_status_bg", offsetof(config_t, tab_status_bg)},
+ {"tab_status_busy", offsetof(config_t, tab_status_busy)},
{"tab_status_stripe", offsetof(config_t, tab_status_stripe)},
{"status", offsetof(config_t, status)},
{"status_state", offsetof(config_t, status_state)},
@@ -937,6 +938,14 @@ void config_defaults(config_t *c) {
c->newtab_pad = 1;
c->bell_indicator = true;
snprintf(c->bell_mark, sizeof c->bell_mark, "[!]");
+ /* Braille, because it is one cell wide in every terminal that has the
+ * glyphs and reads as motion rather than as a character that changed. A
+ * terminal without them gets a single frame of nothing useful, which is
+ * what `busy_mark "*"` (or "") is for. */
+ snprintf(c->busy_mark, sizeof c->busy_mark,
+ "\u280b \u2819 \u2839 \u2838 \u283c \u2834 \u2826 \u2827 "
+ "\u2807 \u280f");
+ c->busy_ms = 120;
c->keep_dead = KEEP_DEAD_COMMANDS;
/* Gentle: an unfocused pane is one you are still reading half the time.
* At 60 white text lands on #c3c3c3, which reads as "not this one" without
@@ -1126,6 +1135,10 @@ void config_defaults(config_t *c) {
* to. The slant already says what it is; this says it is worth reading. */
c->tab_status_fg = blend(dim, accent, 40);
c->tab_status_bg = (color_t){0}; /* none: the terminal's own background */
+ /* All the way to the accent: a running thing is the one item in a list of
+ * annotations that is still moving, and the spinner beside it is already
+ * saying so -- the colour is what makes it findable without looking. */
+ c->tab_status_busy = accent;
/* Barely off the background: the stripe has to say "different pane", not
* "selected". Only drawn when statuses can wrap -- see tab_bar_status_lines
* -- so an unwrapped sidebar keeps its transparent rows. */
@@ -1807,6 +1820,10 @@ char *config_render(const config_t *c) {
"columns on top\n",
c->newtab_pad);
cb_qstr(&b, "bell_mark", c->bell_mark, NULL);
+ cb_qstr(&b, "busy_mark", c->busy_mark,
+ "// a busy pane's spinner; one frame is a static mark");
+ cb_add(&b, "busy_ms %u // how long a spinner frame is held\n",
+ c->busy_ms);
cb_add(&b, "\n// ---- behaviour ----\n");
cb_add(&b, "focus_follows_mouse %s\n", yesno(c->focus_follows_mouse));
@@ -2211,6 +2228,8 @@ static const char *const KNOWN_TOP[] = {
"attach_indicator",
"bell_indicator",
"bell_mark",
+ "busy_mark",
+ "busy_ms",
"close_mark",
"compact",
"ctrl_d_exits",
@@ -2760,6 +2779,9 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
kdl_arg_bool(kdl_child(root, "bell_indicator"), 0, c->bell_indicator);
const char *bm = kdl_arg(kdl_child(root, "bell_mark"), 0, NULL);
if (bm) set_mark(c->bell_mark, sizeof c->bell_mark, bm);
+ const char *busym = kdl_arg(kdl_child(root, "busy_mark"), 0, NULL);
+ if (busym) snprintf(c->busy_mark, sizeof c->busy_mark, "%s", busym);
+ c->busy_ms = (uint16_t)kdl_arg_int(kdl_child(root, "busy_ms"), 0, c->busy_ms);
const kdl_node_t *mins = kdl_child(root, "min_split");
if (mins) {
@@ -2938,6 +2960,9 @@ static bool load_into(config_t *c, const char *path, int depth, char *err,
* different row at a glance, not enough to read as a highlight. */
if (!kdl_child(theme, "tab_status_stripe"))
c->tab_status_stripe = blend(c->default_bg, c->tab_hover, 12);
+ /* The busy one is the theme's own accent, which is what it uses for
+ * "this one" everywhere else. */
+ if (!kdl_child(theme, "tab_status_busy")) c->tab_status_busy = c->tab_hover;
}
/* After the theme, so the wash follows whatever scroll_bg the theme just
* chose -- a config that wrote its own `scrolled` chain keeps it, whether
diff --git a/src/pane.c b/src/pane.c
index 47db104..b9b3f44 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -99,6 +99,12 @@ struct pane {
osc_scan_t scan;
char status[256];
+ /* Whether what the status describes is still happening. Its own flag rather
+ * than a field of the status text, because the text is the *whole* payload
+ * after the verb (`status;a;b;c` is "a;b;c") and always has been -- adding a
+ * field to it would change what every existing sender means. Orthogonal, so
+ * a program can leave one line up and toggle the other. */
+ bool busy;
pane_button_t buttons[8];
size_t nbuttons;
pane_osc_fn osc_cb;
@@ -115,6 +121,7 @@ void pane_set_osc_handler(pane_t *p, pane_osc_fn fn, void *ud) {
}
const char *pane_status(const pane_t *p) { return p->status; }
+bool pane_status_busy(const pane_t *p) { return p->busy && p->alive; }
size_t pane_buttons(const pane_t *p, const pane_button_t **out) {
*out = p->buttons;
return p->nbuttons;
@@ -176,10 +183,20 @@ static void on_osc5577(const char *verb, const char *payload, void *ud) {
if (strcmp(verb, "status") == 0) {
snprintf(p->status, sizeof p->status, "%s", payload);
p->dirty = true;
+ } else if (strcmp(verb, "busy") == 0) {
+ /* "is this still happening?" -- the one thing a line of text cannot say
+ * about itself. Off for the spellings of no, on for everything else
+ * including an empty payload, because `busy` with nothing after it is a
+ * program saying it is busy. */
+ bool on = !(strcmp(payload, "0") == 0 || strcmp(payload, "false") == 0 ||
+ strcmp(payload, "no") == 0 || strcmp(payload, "off") == 0);
+ if (p->busy != on) p->dirty = true;
+ p->busy = on;
} else if (strcmp(verb, "buttons") == 0) {
parse_buttons(p, payload);
} else if (strcmp(verb, "clear") == 0) {
p->status[0] = 0;
+ p->busy = false;
p->nbuttons = 0;
p->dirty = true;
} else if (strcmp(verb, "hello") == 0) {
@@ -690,6 +707,7 @@ bool pane_restart(pane_t *p) {
* 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->busy = false;
p->nbuttons = 0;
osc_scan_reset(&p->scan);
p->exit_known = false;
diff --git a/tests/test_osc5577.py b/tests/test_osc5577.py
index 0a58b5b..a196d56 100644
--- a/tests/test_osc5577.py
+++ b/tests/test_osc5577.py
@@ -7,6 +7,7 @@ ones: a pane's own output is untrusted input.
"""
import sys
+import tempfile
import time
from harness import Session, check, report
@@ -158,6 +159,68 @@ def test_replace_and_clear():
)
+def test_busy_is_its_own_flag():
+ """`busy` says whether what the status describes is still happening -- the
+ one thing the text cannot say about itself. A flag rather than a field of
+ `status`, because a status is the whole payload after the verb and adding
+ to that line would change what every existing sender means."""
+ with Session(shell(), cols=50, rows=8) as s:
+ s.settle()
+ busy = lambda: s.panes()[0]["busy"]
+
+ s.raw(emit_cmd("1;status;make test"))
+ s.settle()
+ check("a status on its own is not busy", not busy(), str(s.panes()[0]))
+ check(
+ "...and the text arrived",
+ s.panes()[0]["status"] == "make test",
+ str(s.panes()[0]),
+ )
+
+ s.raw(emit_cmd("1;busy;1"))
+ s.settle()
+ check("busy;1 sets it", busy(), str(s.panes()[0]))
+ check(
+ "...without touching the text",
+ s.panes()[0]["status"] == "make test",
+ str(s.panes()[0]),
+ )
+
+ s.raw(emit_cmd("1;status;make install"))
+ s.settle()
+ check("a new status leaves the flag alone", busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;busy;0"))
+ s.settle()
+ check("busy;0 clears it", not busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;busy"))
+ s.settle()
+ check("busy with nothing after it means busy", busy(), str(s.panes()[0]))
+
+ s.raw(emit_cmd("1;clear"))
+ s.settle()
+ check(
+ "clear takes the flag with the text",
+ not busy() and not s.panes()[0]["status"],
+ str(s.panes()[0]),
+ )
+
+
+def test_a_status_left_by_a_dead_program_is_not_busy():
+ """It describes the past, whatever it last claimed -- and a spinner turning
+ forever under a tab whose program is gone is a lie you keep looking at."""
+ body = 'printf "\\033]5577;1;status;working\\033\\\\"; printf "\\033]5577;1;busy;1\\033\\\\"'
+ keep = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ keep.write('keep_dead "all"\n') # so there is still a pane to ask
+ keep.close()
+ with Session(["/bin/sh", "-c", body], cols=50, rows=8, config=keep.name) as s:
+ s.until(lambda snap: s.panes() and not s.panes()[0]["alive"])
+ p = s.panes()[0]
+ check("the program is gone", not p["alive"], str(p))
+ check("and nothing is still happening", not p["busy"], str(p))
+
+
def test_escaping():
with Session(
emit(
@@ -388,6 +451,8 @@ if __name__ == "__main__":
test_status_and_buttons()
test_click_reports()
test_replace_and_clear()
+ test_busy_is_its_own_flag()
+ test_a_status_left_by_a_dead_program_is_not_busy()
test_escaping()
test_hostile_input()
test_split_across_reads()
diff --git a/tests/test_tab_bar.py b/tests/test_tab_bar.py
index 2c35228..36af795 100644
--- a/tests/test_tab_bar.py
+++ b/tests/test_tab_bar.py
@@ -495,6 +495,134 @@ def test_status_rows_are_told_apart_by_the_slant_not_by_an_indent():
)
+def _busy(s, on=True):
+ s.raw('printf "\\e]5577;1;busy;%s\\x07"\\n' % ("1" if on else "0"))
+
+
+def test_a_busy_status_says_so():
+ """The text of a running `make test` and a finished one is the same text,
+ so the row says it instead: the busy colour, and a mark in the column the
+ padding holds blank."""
+ with Session(SH, cols=90, rows=20, config=LEFT) as s:
+ s.settle(30)
+ _status(s, "make test")
+ snap = s.snapshot()
+ idle = snap.style_at(CX + 1, Y0 + 1)
+ check("a plain status is ambient", idle["fg"] == "#5a6a8f", str(idle))
+ check(
+ "...and nothing sits in the indent",
+ snap.line(Y0 + 1)[CX] == " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+
+ _busy(s)
+ s.settle(30)
+ snap = s.snapshot()
+ hot = snap.style_at(CX + 1, Y0 + 1)
+ check("busy turns the row's own colour", hot["fg"] == "#7aa2f7", str(hot))
+ check(
+ "and a spinner lands in the indent",
+ snap.line(Y0 + 1)[CX] != " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+ check(
+ "...where it moves nothing: the text is where it was",
+ _row(snap, Y0 + 1).index("make test") == 1,
+ repr(_row(snap, Y0 + 1)),
+ )
+
+ _busy(s, False)
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "done puts it back",
+ snap.style_at(CX + 1, Y0 + 1)["fg"] == "#5a6a8f"
+ and snap.line(Y0 + 1)[CX] == " ",
+ repr(_row(snap, Y0 + 1)),
+ )
+
+
+def test_a_spinner_costs_a_frame_clock_and_a_mark_does_not():
+ """Several frames turn, one frame is a mark -- and only the turning one is
+ worth repainting the session for, only while it is on screen."""
+ one = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nbusy_mark "*"\n')
+ with Session(SH, cols=90, rows=20, config=one) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ check(
+ "the static mark is drawn",
+ s.snapshot().line(Y0 + 1)[CX] == "*",
+ repr(_row(s.snapshot(), Y0 + 1)),
+ )
+ check(
+ "and asks for no clock",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+
+ with Session(SH, cols=90, rows=20, config=LEFT) as s:
+ s.settle(30)
+ _status(s, "make test")
+ s.settle(30)
+ s.snapshot()
+ check(
+ "an idle sidebar wants no clock either",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+ _busy(s)
+ s.settle(30)
+ # The deadline is a property of the frame that was composed, like
+ # `animating` itself -- so ask for a frame before asking when the next
+ # one is wanted.
+ s.snapshot()
+ check(
+ "a spinner on screen asks for one",
+ s.api("deadline")["ms"] > 0,
+ str(s.api("deadline")),
+ )
+ _busy(s, False)
+ s.settle(30)
+ s.snapshot()
+ check(
+ "...and gives it back when the work stops",
+ s.api("deadline")["ms"] == -1,
+ str(s.api("deadline")),
+ )
+
+
+def test_no_busy_mark_leaves_the_colour_to_say_it():
+ quiet = _cfg(f'tab_bar_side "left"\ntab_bar_width {W}\nbusy_mark ""\n')
+ with Session(SH, cols=90, rows=20, config=quiet) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ snap = s.snapshot()
+ check("no mark", snap.line(Y0 + 1)[CX] == " ", repr(_row(snap, Y0 + 1)))
+ check(
+ "but the row still says it is running",
+ snap.style_at(CX + 1, Y0 + 1)["fg"] == "#7aa2f7",
+ str(snap.style_at(CX + 1, Y0 + 1)),
+ )
+
+
+def test_the_busy_colour_is_the_themes_own():
+ themed = _cfg(
+ f'tab_bar_side "left"\ntab_bar_width {W}\n'
+ 'theme { tab_status_busy "#ff00ff" }\n'
+ )
+ with Session(SH, cols=90, rows=20, config=themed) as s:
+ s.settle(30)
+ _status(s, "make test")
+ _busy(s)
+ s.settle(30)
+ st = s.snapshot().style_at(CX + 1, Y0 + 1)
+ check("named, it wins", st["fg"] == "#ff00ff", str(st))
+
+
def test_status_rows_take_their_own_theme_colours():
themed = _cfg(
f'tab_bar_side "left"\ntab_bar_width {W}\n'
@@ -1110,6 +1238,10 @@ if __name__ == "__main__":
test_a_theme_can_have_the_bare_tabs_back()
test_status_rows_sit_under_their_tab()
test_status_rows_are_told_apart_by_the_slant_not_by_an_indent()
+ test_a_busy_status_says_so()
+ test_a_spinner_costs_a_frame_clock_and_a_mark_does_not()
+ test_no_busy_mark_leaves_the_colour_to_say_it()
+ test_the_busy_colour_is_the_themes_own()
test_status_rows_take_their_own_theme_colours()
test_an_old_theme_gets_coherent_status_colours_anyway()
test_a_monochrome_theme_stays_monochrome()
diff --git a/tests/test_themes.py b/tests/test_themes.py
index 6710477..d9c67b0 100644
--- a/tests/test_themes.py
+++ b/tests/test_themes.py
@@ -26,7 +26,7 @@ THEMES = sorted(glob.glob(os.path.join(THEMEDIR, "*.kdl")))
# from colours the theme does define (so a theme gets them in its own family
# without a line), and the opt-in ones are unset on purpose, because a band
# behind a row takes a translucent terminal's own background away.
-DERIVED = {"tab_status_fg", "tab_status_stripe", "tab_idle_bg"}
+DERIVED = {"tab_status_fg", "tab_status_stripe", "tab_status_busy", "tab_idle_bg"}
OPT_IN = {"tab_status_bg", "attach_bg"}
# The scrollback search tints: one theme in contrib names them and the rest
# take the compiled-in pair, which reads the same on every palette here.