aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-17 13:38:33 +0200
committeruser <user@clank>2026-08-17 13:38:33 +0200
commit7a91885c3b063c8d231daceb2b2e29e1f1a81aa6 (patch)
tree7210a201bc59bd0c871cbc82afc21de16d3959b5
parentsl0ppty: a --script pane is told no session rather than somebody else's (diff)
downloadslosh-7a91885c3b063c8d231daceb2b2e29e1f1a81aa6.tar.gz
slosh-7a91885c3b063c8d231daceb2b2e29e1f1a81aa6.tar.xz
slosh-7a91885c3b063c8d231daceb2b2e29e1f1a81aa6.zip
sl0ppty: `set-name target="pane"`, so a script can overrule a program's title
A pane's name already outranks the title its program sets -- `pane_title` prefers the name while there is one -- and that is the whole remedy for a program announcing something it stopped meaning: an agent still spinning the summary of a task it finished, a build script that titled itself once. The remedy was reachable by double-clicking the title on somebody's screen, and from a layout file, but not from the socket, while `set-purpose` next to it has taken `target:"pane"|"tab"` all along. `target` defaults to `tab`, which is what this verb has always meant. Two verbs whose defaults differ is a smaller wrong than a verb that quietly changes its subject under scripts already written against it. The refusals say `no such pane` or `no such tab`, so a mistyped target shows up in the reply rather than as a label that did not move. The gesture now calls the same function, so the two ways in cannot disagree about where a pane's name lives -- there is a test that a name set over the socket is the name the double-click editor offers to edit. Verified: eight checks in `tests/test_pane_title.py`. A pane named while its program keeps setting OSC 2 titles keeps the name; clearing it hands the label back to what the program calls itself *now*; `id: 0` is the focused pane and leaves the other one alone; `set-name` with no target still renames the tab and leaves the pane's title alone.
-rw-r--r--docs/panes.md6
-rw-r--r--docs/scripting.md2
-rw-r--r--src/app.c22
-rw-r--r--src/app.h3
-rw-r--r--src/cmd.c16
-rw-r--r--tests/test_pane_title.py97
6 files changed, 137 insertions, 9 deletions
diff --git a/docs/panes.md b/docs/panes.md
index d1e2876..70dafc7 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -46,6 +46,12 @@ nothing you click can disagree with what is on screen.
tab of its own. The tabs light up as destinations while you carry a pane and the
one under the pointer fills; the tab it already lives in does not offer itself. A
toast says where it went, since the pane lands somewhere you are not looking.
+- **Name a pane and it stops listening to its program.** `pane_title` prefers a
+ name while there is one, so a program that keeps announcing something stale —
+ an agent still spinning the summary of a task it finished — is overruled by
+ naming its pane, from the gesture below or from a script:
+ `sl0ppty cmd '{"cmd":"set-name","target":"pane","id":3,"name":"agent: gbos"}'`.
+ An empty name hands the label back.
- **Double-click a name to rename it in place** — a pane's title or a tab in the
strip. Enter keeps it, Escape abandons it, empty gives it back to the program.
A name is not a [purpose](layouts.md#purposes): the purpose is a separate label
diff --git a/docs/scripting.md b/docs/scripting.md
index 89dd835..8268c31 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -31,7 +31,9 @@ Panes and tabs are addressed by **id**, so a background tab is scriptable.
| `close` `rerun` | `id`, or 0 for the focused pane |
| `clear-shaders` | `id`, or 0 for the focused pane; answers `cleared:0\|1` — the way back from a pane that painted itself unreadable |
| `new-tab` `select-tab` `close-tab` `move-tab` `set-name` | tabs, by `id` or `index` |
+| `set-name target="pane"` | a pane's name, which outranks the title its program sets |
| `move-pane` | `id` of the pane, `tab` id to move it into (`0` for a tab of its own, with an optional `name`), `dir:"cols"\|"rows"`. The pane keeps running — same pty, same scrollback |
+| `set-name` | `target:"tab"` (the default, because that is what this verb has always meant) or `"pane"`, `id`, `name`. A pane accepts 0 for the focused one. A pane's name wins over the title the program sets, so this is how a program that keeps announcing something stale gets overruled; an empty `name` clears it and hands the label back. Refusals say `no such pane` or `no such tab`, so a mistyped target is visible in the reply |
| `set-purpose` | `target:"pane"\|"tab"`, `id` (or 0 for the focused pane, and the tab you are in), `purpose`. An empty `purpose` clears the slot *and* unlocks it, handing the label back to the program — a lock held over an empty string was a state nothing could get out of |
| `dump-layout` `apply-layout` | see [layouts](layouts.md). `dump-layout` takes `tab` (0 for every tab), `relative_to` to write every `cwd=` under that directory instead of absolute, and `suspend` (`as-is` `none` `commands` `all`); it answers `kdl` `panes` `suspended`, and an unknown `tab` is an error rather than an empty document |
| `workspaces` | the projects on disk and which of them are open: `roots` says whether any are configured at all, and each entry has `name` `path` `purpose` `layout` (a file path, or "") `mtime` `tab` (0 when closed) — see [workspaces](workspaces.md) |
diff --git a/src/app.c b/src/app.c
index a792ade..37bf04b 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1944,6 +1944,22 @@ static node_t *pane_by_id(app_t *a, uint32_t id) {
return b.found;
}
+/* Naming a pane, for the double-click gesture and for a script: 0 is the focused
+ * one, and an empty name clears it.
+ *
+ * This is the whole answer to a program that keeps setting a title it stopped
+ * meaning -- an agent still spinning the summary of a task it finished, say.
+ * `pane_title` prefers a name only while there is one, so what a person or a
+ * script wrote outranks the program's own until they hand it back. Naming does
+ * not select the pane's tab, unlike the verbs that rearrange things: a label is
+ * not a reason to move what somebody is looking at. */
+bool app_set_pane_name(app_t *a, uint32_t id, const char *name) {
+ node_t *n = id ? pane_by_id(a, id) : cur(a)->focus;
+ if (!n) return false;
+ pane_set_name(n->pane, name);
+ return true;
+}
+
bool app_minimize(app_t *a, uint32_t id) {
node_t *n = id ? pane_by_id(a, id) : cur(a)->focus;
if (!n || n->minimized) return false;
@@ -5355,11 +5371,7 @@ static void rename_end(app_t *a, bool keep) {
what = "purpose";
done = app_set_pane_purpose(a, a->rename_id, a->rename_buf, true);
} else if (a->renaming == RENAME_PANE) {
- node_t *n = pane_by_id(a, a->rename_id);
- if (n) {
- pane_set_name(n->pane, a->rename_buf);
- done = true;
- }
+ done = app_set_pane_name(a, a->rename_id, a->rename_buf);
} else {
done = app_set_tab_name(a, a->rename_id, a->rename_buf);
}
diff --git a/src/app.h b/src/app.h
index 4559a55..f8025ba 100644
--- a/src/app.h
+++ b/src/app.h
@@ -82,6 +82,9 @@ void app_cycle_tab(app_t *a, int delta);
size_t app_tab_count(const app_t *a);
bool app_close_tab(app_t *a, uint32_t id);
bool app_set_tab_name(app_t *a, uint32_t id, const char *name);
+/* A pane's name, which outranks the title its program sets. 0 is the focused
+ * pane; an empty name clears it and hands the label back to the program. */
+bool app_set_pane_name(app_t *a, uint32_t id, const char *name);
/* Put a tab at a different position in the strip, 0-based. The strip is the
* order, so this is what dragging one does; you stay in the tab you were in. */
bool app_move_tab(app_t *a, uint32_t id, size_t index);
diff --git a/src/cmd.c b/src/cmd.c
index 32d6f4b..58e22e9 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -229,10 +229,18 @@ static char *cmd_json(app_t *a, screen_t *s, input_parser_t *in,
: jerr("no such tab");
}
if (strcmp(cmd, "set-name") == 0) {
- return app_set_tab_name(a, (uint32_t)jv_geti(req, "id", 0),
- jv_gets(req, "name", ""))
- ? jok_int(NULL, 0)
- : jerr("no such tab");
+ /* `target`, exactly as `set-purpose` already has it, because a name is a
+ * label on either thing. The default stays `tab`: that is what this verb has
+ * always meant, and a verb quietly changing its subject would be worse than
+ * two verbs with different defaults. Naming a pane is how a stale title gets
+ * overruled, so the id may be 0 for the focused one. */
+ const char *target = jv_gets(req, "target", "tab");
+ uint32_t id = (uint32_t)jv_geti(req, "id", 0);
+ const char *name = jv_gets(req, "name", "");
+ bool pane = strcmp(target, "pane") == 0;
+ bool ok = pane ? app_set_pane_name(a, id, name)
+ : app_set_tab_name(a, id, name);
+ return ok ? jok_int(NULL, 0) : jerr(pane ? "no such pane" : "no such tab");
}
if (strcmp(cmd, "set-purpose") == 0) {
const char *target = jv_gets(req, "target", "pane");
diff --git a/tests/test_pane_title.py b/tests/test_pane_title.py
index d9a368f..d261e36 100644
--- a/tests/test_pane_title.py
+++ b/tests/test_pane_title.py
@@ -271,6 +271,99 @@ def test_a_typed_name_outranks_the_program():
titles(s) == ["prog-later"], str(titles(s)))
+def test_a_script_can_name_a_pane_too():
+ """The gesture is not the only way in. A program that keeps setting a title it
+ stopped meaning -- an agent still spinning the summary of a finished task -- is
+ overruled by naming the pane, and that has to be reachable from tooling, not
+ only from a double-click on somebody's screen."""
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ p = s.pane()
+ check("the program's title to start with", titles(s) == ["shell"],
+ str(titles(s)))
+
+ reply = s.api("set-name", target="pane", id=p["id"], name="agent: gbos")
+ s.settle(80)
+ check("the verb reports ok", reply.get("ok") is True, str(reply))
+ check("and the name is what the frame says", titles(s) == ["agent: gbos"],
+ str(titles(s)))
+
+ # The whole point: the program keeps shouting and is ignored.
+ s.raw(r"\e]2;\xe2\xa0\xb9 Fix toast overlap in frame tests\x07")
+ s.settle(120)
+ check("a program retitling itself cannot take the name back",
+ titles(s) == ["agent: gbos"], str(titles(s)))
+
+ s.api("set-name", target="pane", id=p["id"], name="")
+ s.settle(120)
+ check("clearing it hands the label back to the program",
+ titles(s) == ["\u2839 Fix toast overlap in frame tests"], str(titles(s)))
+
+
+def test_the_verb_and_the_gesture_are_one_store():
+ """A name set by a script is the name the editor offers to edit -- there is one
+ place a pane's name lives, so the two ways in cannot disagree about it."""
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ p = s.pane()
+ s.api("set-name", target="pane", id=p["id"], name="named")
+ s.settle(80)
+ dbl(s, col_of(s.snapshot(), p, "named"), p["y"])
+ s.settle(80)
+ row = s.snapshot().line(p["y"])
+ check("the editor opens seeded with the scripted name",
+ "named" in row and CURSOR in row, repr(row.strip()))
+ s.send(r"\e")
+ s.settle(40)
+
+
+def test_naming_addresses_the_focused_pane_by_default():
+ """0 is the focused pane, as it is for every other verb that takes an id."""
+ with Session(SH, cols=80, rows=30) as s:
+ s.settle()
+ s.key("-") # two panes, focus lands on the new one
+ s.settle(60)
+ panes = s.panes()
+ check("there are two panes to choose between", len(panes) == 2, str(panes))
+ focused = [q for q in panes if q["focused"]][0]
+ other = [q for q in panes if not q["focused"]][0]
+
+ s.api("set-name", target="pane", name="the focused one")
+ s.settle(80)
+ by_id = {q["id"]: q["title"] for q in s.panes()}
+ check("id 0 means the focused pane", by_id[focused["id"]] == "the focused one",
+ str(by_id))
+ check("and the other pane keeps the title it had",
+ by_id[other["id"]] == other["title"], str(by_id))
+
+
+def test_the_target_says_which_label_and_bad_ids_say_which_thing():
+ """`set-name` has always meant the tab, so that stays its default -- a verb
+ quietly changing its subject would be worse than two verbs whose defaults
+ differ. The refusals name the thing that was not found, so a mistyped target is
+ obvious from the reply rather than from a label that did not move."""
+ with Session(SH, cols=60, rows=14) as s:
+ s.settle()
+ p = s.pane()
+ tab = s.tabs()[0]
+
+ s.api("set-name", id=tab["id"], name="work")
+ s.settle(60)
+ check("no target still names the tab", s.tabs()[0]["name"] == "work",
+ str(s.tabs()[0]))
+ check("...and leaves the pane's title alone", titles(s) == ["shell"],
+ str(titles(s)))
+
+ check("a pane id that does not exist says so",
+ s.api("set-name", target="pane", id=4242, name="x")
+ .get("error") == "no such pane", str(s.api("set-name", target="pane", id=4242, name="x")))
+ check("and a tab id that does not exist says the other thing",
+ s.api("set-name", id=4242, name="x").get("error") == "no such tab",
+ str(s.api("set-name", id=4242, name="x")))
+ check("the pane kept its title through both refusals",
+ titles(s) == ["shell"] and s.pane()["id"] == p["id"], str(titles(s)))
+
+
def test_slow_clicks_are_not_a_double_click():
"""The control for the gesture: two clicks are not automatically a rename."""
with Session(SH, cols=60, rows=14) as s:
@@ -336,4 +429,8 @@ if __name__ == "__main__":
test_slow_clicks_are_not_a_double_click()
test_typing_a_name_does_not_reach_the_pane()
test_closing_the_pane_does_not_wedge_the_keyboard()
+ test_a_script_can_name_a_pane_too()
+ test_the_verb_and_the_gesture_are_one_store()
+ test_naming_addresses_the_focused_pane_by_default()
+ test_the_target_says_which_label_and_bad_ids_say_which_thing()
sys.exit(report())