diff options
| author | user <user@clank> | 2026-08-17 13:38:33 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-17 13:38:33 +0200 |
| commit | 7a91885c3b063c8d231daceb2b2e29e1f1a81aa6 (patch) | |
| tree | 7210a201bc59bd0c871cbc82afc21de16d3959b5 | |
| parent | sl0ppty: a --script pane is told no session rather than somebody else's (diff) | |
| download | slosh-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.md | 6 | ||||
| -rw-r--r-- | docs/scripting.md | 2 | ||||
| -rw-r--r-- | src/app.c | 22 | ||||
| -rw-r--r-- | src/app.h | 3 | ||||
| -rw-r--r-- | src/cmd.c | 16 | ||||
| -rw-r--r-- | tests/test_pane_title.py | 97 |
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) | @@ -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); } @@ -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); @@ -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()) |
