aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 16:13:46 +0200
committeruser <user@clank>2026-08-15 16:13:46 +0200
commitee6288a7eadeb43c83a5ab78f6c65cffa4748a53 (patch)
tree8fc137687f47a7f20f0a2766bca17cd53e0400dc
parentmake coverage: a number, cheaply (diff)
downloadslosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.tar.gz
slosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.tar.xz
slosh-ee6288a7eadeb43c83a5ab78f6c65cffa4748a53.zip
sl0ppty: C-a e opens the config, in a pane, in your editor
The config watcher's whole point is the loop -- edit, save, watch the session change under you, edit again -- and the loop had a hole in the middle of it: you had to leave the session, or remember where the file lives, to start. So: one keystroke opens `$EDITOR <config>` in a new pane, down rather than across because a config file is lines and half the height beats half the width for reading them. `editor` in the config beats $EDITOR, and vi is the fallback, being the one editor a POSIX system must have -- a wrong guess you can see and change beats a pane that opens empty. The interesting part is what kind of pane it is. Panes given a command keep their corpse (D14), which is right for a dev server and wrong for this: quit the editor and you would be left dismissing a dead pane you opened thirty seconds ago. Rather than gaming that policy by giving the pane no command -- which would be a lie in the one field dump-layout and re-run read -- panes can now be *ephemeral*: a task rather than part of the session's shape. An ephemeral pane keeps no corpse and is not written into a dumped layout, so a restart does not reopen somebody's editor on a file they had finished with. That concept is the reusable half of this commit; the keybinding is the part you notice.
-rw-r--r--config/config.kdl10
-rw-r--r--src/app.c67
-rw-r--r--src/app.h3
-rw-r--r--src/cmd.c4
-rw-r--r--src/config.c9
-rw-r--r--src/config.h3
-rw-r--r--src/pane.c3
-rw-r--r--src/sl0ppty.h6
-rw-r--r--tests/test_edit_config.py130
9 files changed, 230 insertions, 5 deletions
diff --git a/config/config.kdl b/config/config.kdl
index 8008051..15ef8b0 100644
--- a/config/config.kdl
+++ b/config/config.kdl
@@ -472,6 +472,16 @@ theme {
// help detach quit
// literal-prefix none
+// What C-a e opens this file with. Empty means $EDITOR, and then vi -- the
+// one editor a POSIX system is required to have, so a wrong guess you can see
+// beats a pane that opens empty.
+//
+// The pane it opens is *ephemeral*: quit the editor and the pane goes, with
+// no corpse to dismiss and no trace in a dumped layout. You asked for an
+// editor, not for a record of having had one.
+//
+// editor "nvim"
+
keys {
// The leader. Every binding below is this key, then the key on the line.
// ctrl+a is a default and not a decision -- it is start-of-line to half
diff --git a/src/app.c b/src/app.c
index a7d50be..c3811d5 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1294,10 +1294,22 @@ static void replace_child(node_t *parent, node_t *old, node_t *new_) {
}
}
+/* The pane a split makes. NULL argv means "whatever this session runs", which
+ * is the ordinary case; a command makes it a task pane instead. */
+static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir,
+ bool before, const char *const *argv,
+ const char *label);
+
static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) {
- if (!leaf) return;
- node_t *fresh = leaf_new(a);
- if (!fresh) return;
+ split_node_with(a, leaf, dir, before, NULL, NULL);
+}
+
+static node_t *split_node_with(app_t *a, node_t *leaf, split_dir_t dir,
+ bool before, const char *const *argv,
+ const char *label) {
+ if (!leaf) return NULL;
+ node_t *fresh = argv ? leaf_new_ex(a, argv, NULL, false, label) : leaf_new(a);
+ if (!fresh) return NULL;
/* Growing an existing split in the same direction keeps the tree flat, so
* three vertical splits are three equal columns rather than 1/2 + 1/4 + 1/4. */
@@ -1332,6 +1344,7 @@ static void split_node(app_t *a, node_t *leaf, split_dir_t dir, bool before) {
cur(a)->focus = fresh;
layout(a);
+ return fresh;
}
/* Would splitting this pane produce two panes worth having?
@@ -1448,6 +1461,10 @@ struct reap {
* empty for a pane that is just a shell -- which is the whole distinction, and
* it happens to already be recorded. */
static bool keep_corpse(const pane_t *p) {
+ /* A pane opened to do one thing is done when that thing ends, whatever the
+ * policy says about commands: you asked for an editor, not for a record of
+ * having had one. */
+ if (pane_ephemeral(p)) return false;
switch (CFG.keep_dead) {
case KEEP_DEAD_ALL: return true;
case KEEP_DEAD_NONE: return false;
@@ -1690,6 +1707,40 @@ bool app_close_pane(app_t *a, uint32_t id) {
* 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. */
+/* Open the config in $EDITOR, in a pane of its own.
+ *
+ * The whole point is the loop: edit, save, watch the session change under you,
+ * edit again. Anything that makes you leave the session to do it -- another
+ * terminal, or remembering where the file lives -- is friction in the middle
+ * of the one workflow the config watcher exists for.
+ *
+ * The pane is ephemeral: quit the editor and it is gone. You asked for an
+ * editor, not for a record of having had one. */
+bool app_edit_config(app_t *a) {
+ node_t *n = a->ntabs ? cur(a)->focus : NULL;
+ if (!n) return false;
+
+ const char *editor = CFG.editor && *CFG.editor ? CFG.editor : getenv("EDITOR");
+ /* vi is the one editor a POSIX system is required to have. Better a wrong
+ * guess you can see and change than a pane that opens empty. */
+ if (!editor || !*editor) editor = "vi";
+
+ char cmd[1024];
+ snprintf(cmd, sizeof cmd, "%s '%s'", editor, config_default_path());
+ const char *argv[] = {"/bin/sh", "-c", cmd, NULL};
+
+ /* Down rather than across: a config file is lines, and half the width of a
+ * pane is a worse place to read them than half the height. */
+ if (!split_fits(n, SPLIT_ROWS)) {
+ app_toast(a, "no room to split down");
+ return false;
+ }
+ node_t *fresh = split_node_with(a, n, SPLIT_ROWS, false, argv, cmd);
+ if (!fresh) return false;
+ pane_set_ephemeral(fresh->pane, true);
+ return true;
+}
+
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;
@@ -4013,6 +4064,9 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
a->query[0] = 0;
a->sel = 0;
return true;
+ case ACT_EDIT_CONFIG:
+ app_edit_config(a);
+ return true;
case ACT_HELP:
a->help = !a->help;
return true;
@@ -4336,8 +4390,11 @@ static void dump_node(app_t *a, node_t *n, strbuf_t *b, int depth) {
char cwdbuf[4096];
sb_quoted(b, "cwd", live_cwd(n->pane, cwdbuf, sizeof cwdbuf));
/* The label is the command a layout gave it; a pane running the session's
- * default shell has none, and gets none back. */
- sb_quoted(b, "command", pane_label(n->pane));
+ * default shell has none, and gets none back. An ephemeral pane is a task
+ * that happened to be open when the dump was taken — restoring it would
+ * reopen somebody's editor on a file they finished with. */
+ if (!pane_ephemeral(n->pane))
+ sb_quoted(b, "command", pane_label(n->pane));
sb_quoted(b, "purpose", n->purpose);
if (pane_suspended(n->pane)) sb_add(b, " suspended=true");
if (n == cur(a)->focus) sb_add(b, " focus=true");
diff --git a/src/app.h b/src/app.h
index 6150147..0455732 100644
--- a/src/app.h
+++ b/src/app.h
@@ -114,6 +114,9 @@ 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);
+/* Open the config file in $EDITOR, in a pane of its own, so the edit/save/
+ * watch loop happens without leaving the session. The pane is ephemeral. */
+bool app_edit_config(app_t *a);
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 cddba75..e08b4f2 100644
--- a/src/cmd.c
+++ b/src/cmd.c
@@ -146,6 +146,10 @@ 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, "edit-config") == 0) {
+ return app_edit_config(a) ? jok_int("id", app_focused_pane_id(a))
+ : jerr("no room for another pane");
+ }
if (strcmp(cmd, "rerun") == 0) {
if (!app_rerun_pane(a, (uint32_t)jv_geti(req, "id", 0)))
return jerr("cannot run it again");
diff --git a/src/config.c b/src/config.c
index f4076b7..877b225 100644
--- a/src/config.c
+++ b/src/config.c
@@ -30,6 +30,7 @@ static const struct {
{"detach", ACT_DETACH}, {"quit", ACT_QUIT},
{"literal-prefix", ACT_LITERAL_PREFIX},
{"help", ACT_HELP},
+ {"edit-config", ACT_EDIT_CONFIG},
};
static const struct {
@@ -393,6 +394,7 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_SLASH, 0, ACT_HELP, false);
bind_add(c, GHOSTTY_KEY_R, 0, ACT_RERUN, false);
bind_add(c, GHOSTTY_KEY_Z, 0, ACT_ZOOM, false);
+ bind_add(c, GHOSTTY_KEY_E, 0, ACT_EDIT_CONFIG, false);
bind_add(c, GHOSTTY_KEY_M, 0, ACT_MINIMIZE, false);
bind_add(c, GHOSTTY_KEY_H, 0, ACT_FOCUS_LEFT, false);
bind_add(c, GHOSTTY_KEY_L, 0, ACT_FOCUS_RIGHT, false);
@@ -431,6 +433,7 @@ void config_free(config_t *c) {
free(c->exprs);
free(c->binds);
free(c->shell);
+ free(c->editor);
free(c->shader_dir);
memset(c, 0, sizeof *c);
}
@@ -478,6 +481,7 @@ static const struct {
{ACT_SCROLL_BOTTOM, "scroll", "back to the present"},
{ACT_HELP, "session", "this list"},
+ {ACT_EDIT_CONFIG, "session", "edit the config"},
{ACT_DETACH, "session", "detach, leave it running"},
{ACT_QUIT, "session", "quit the session"},
{ACT_LITERAL_PREFIX, "session", "send the prefix itself"},
@@ -709,6 +713,11 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
free(c->shell);
c->shell = strdup(sh);
}
+ const char *ed = kdl_arg(kdl_child(root, "editor"), 0, NULL);
+ if (ed) {
+ free(c->editor);
+ c->editor = strdup(ed);
+ }
/* Shader plugins, before any shader is named below: a `shaders` block may
* use what one of these adds, and a name is looked up as it is parsed. The
diff --git a/src/config.h b/src/config.h
index 882b8b0..33ece43 100644
--- a/src/config.h
+++ b/src/config.h
@@ -64,6 +64,7 @@ typedef enum {
ACT_DETACH,
ACT_QUIT,
ACT_HELP,
+ ACT_EDIT_CONFIG,
ACT_LITERAL_PREFIX,
ACT_SELECT_TAB_1, /* .. +8, so digits stay one entry each */
} action_t;
@@ -282,6 +283,8 @@ typedef struct {
/* what a new pane runs; NULL means $SHELL */
char *shell;
+ /* What `edit-config` opens the file with; NULL means $EDITOR, then vi. */
+ char *editor;
/* Where to look for shader plugins (`*.so`). NULL means the default: a
* `shaders` directory beside the config file. Read before the `shaders` and
diff --git a/src/pane.c b/src/pane.c
index 4306844..c2c1cc0 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -58,6 +58,7 @@ struct pane {
* a dead pane needs exactly what it was started with, and by then the pane
* is the only thing that still knows. */
bool suspended;
+ bool ephemeral;
char **argv;
char *cwd;
char label[128];
@@ -224,6 +225,8 @@ static void on_title_changed(GhosttyTerminal t, void *ud) {
}
bool pane_suspended(const pane_t *p) { return p->suspended; }
+void pane_set_ephemeral(pane_t *p, bool yes) { p->ephemeral = yes; }
+bool pane_ephemeral(const pane_t *p) { return p->ephemeral; }
const char *pane_label(const pane_t *p) { return p->label; }
/* Spawn what a suspended pane was created to run. */
diff --git a/src/sl0ppty.h b/src/sl0ppty.h
index e117908..38d5f18 100644
--- a/src/sl0ppty.h
+++ b/src/sl0ppty.h
@@ -119,6 +119,12 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
pane_t *pane_new_ex(const char *const argv[], uint16_t cols, uint16_t rows,
const char *cwd, bool suspended, const char *label);
bool pane_suspended(const pane_t *p);
+/* A pane opened to do one thing, rather than one the session is made of: an
+ * editor you popped open, not the dev server a layout declared. It runs a
+ * command like any other, but when that command ends it is finished — so it
+ * keeps no corpse and is not written into a dumped layout. */
+void pane_set_ephemeral(pane_t *p, bool yes);
+bool pane_ephemeral(const pane_t *p);
const char *pane_label(const pane_t *p);
/* The pid of the program in the pane, or -1. Used to ask the kernel where a
* shell has got to, which is the only honest answer to "what is this pane's
diff --git a/tests/test_edit_config.py b/tests/test_edit_config.py
new file mode 100644
index 0000000..516e5da
--- /dev/null
+++ b/tests/test_edit_config.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""C-a e opens the config in $EDITOR, in a pane of its own.
+
+The point is the loop: edit, save, watch the session change under you, edit
+again. Anything that makes you leave the session to do it — another terminal,
+or remembering where the file lives — is friction in the middle of the one
+workflow the config watcher exists for.
+
+The pane is *ephemeral*: quit the editor and it is gone. You asked for an
+editor, not for a record of having had one.
+"""
+import os
+import sys
+import tempfile
+
+from harness import Session, check, report
+
+SH = ["/bin/sh", "-c", 'printf "\\033]2;p\\007"; read x']
+
+# An "editor" that shows the file and stays up, and one that quits when told.
+SHOWS = "tail -f"
+QUITS = "sh -c 'read x; exit' --"
+
+
+def conf(text='shell "x"\n'):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def test_it_opens_the_config_in_a_pane():
+ c = conf('shell "a-marker-only-this-test-writes"\n')
+ with Session(SH, cols=70, rows=24, config=c,
+ env={"EDITOR": SHOWS}) as s:
+ s.settle()
+ check("one pane to start", len(s.panes()) == 1)
+ s.key("e")
+ snap = s.until_text("a-marker-only-this-test-writes")
+ check("a second pane opened", len(s.panes()) == 2, str(s.panes()))
+ check("with the config file in it",
+ "a-marker-only-this-test-writes" in snap.screen(), snap.screen())
+ os.unlink(c)
+
+
+def test_the_editor_pane_goes_when_the_editor_does():
+ c = conf()
+ with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": QUITS}) as s:
+ s.settle()
+ s.key("e")
+ s.settle(60)
+ check("the editor is up", len(s.panes()) == 2, str(s.panes()))
+ s.raw("q\\n")
+ s.until(lambda _: len(s.panes()) == 1)
+ check("quitting it closes the pane", len(s.panes()) == 1, str(s.panes()))
+ check("and leaves no corpse to dismiss",
+ "[process exited" not in s.snapshot().screen(),
+ s.snapshot().screen())
+ os.unlink(c)
+
+
+def test_the_config_can_name_the_editor():
+ """`editor` beats $EDITOR, for the person whose $EDITOR is something they
+ do not want a terminal pane to run."""
+ c = conf('editor "tail -f"\nshell "picked-by-the-config"\n')
+ with Session(SH, cols=70, rows=24, config=c,
+ env={"EDITOR": "definitely-not-an-editor"}) as s:
+ s.settle()
+ s.key("e")
+ snap = s.until_text("picked-by-the-config")
+ check("the config's editor is used",
+ "picked-by-the-config" in snap.screen(), snap.screen())
+ os.unlink(c)
+
+
+def test_it_is_not_written_into_a_dumped_layout():
+ """A dump is the session's shape. An editor someone had open while it was
+ taken is not part of that shape — restoring it would reopen their editor
+ on a file they had finished with."""
+ c = conf()
+ with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": SHOWS}) as s:
+ s.settle()
+ s.key("e")
+ s.settle(60)
+ check("the editor is up", len(s.panes()) == 2, str(s.panes()))
+ dumped = s.api("dump-layout")["kdl"]
+ check("the dump does not carry the editor command",
+ "tail -f" not in dumped, dumped)
+ os.unlink(c)
+
+
+def test_no_room_says_so_rather_than_splitting_anyway():
+ c = conf()
+ with Session(SH, cols=70, rows=10, config=c, env={"EDITOR": SHOWS}) as s:
+ s.settle()
+ s.key("e")
+ s.settle(40)
+ check("still one pane", len(s.panes()) == 1, str(s.panes()))
+ check("and it said why", "no room" in s.snapshot().screen(),
+ s.snapshot().screen())
+ os.unlink(c)
+
+
+def test_the_cheatsheet_lists_it():
+ c = conf()
+ with Session(SH, cols=92, rows=28, config=c) as s:
+ s.settle()
+ s.send(r"\x01?")
+ s.settle()
+ screen = s.snapshot().screen()
+ check("it is on the sheet", "edit the config" in screen, screen)
+ check("under a key", "e " in screen, screen)
+ os.unlink(c)
+
+
+def test_the_control_api_can_do_it_too():
+ c = conf('shell "from-the-api"\n')
+ with Session(SH, cols=70, rows=24, config=c, env={"EDITOR": SHOWS}) as s:
+ s.settle()
+ r = s.api("edit-config")
+ check("the api reports ok", r.get("ok") is True, str(r))
+ snap = s.until_text("from-the-api")
+ check("and opened it", "from-the-api" in snap.screen(), snap.screen())
+ os.unlink(c)
+
+
+for name, fn in sorted(list(globals().items())):
+ if name.startswith("test_"):
+ fn()
+sys.exit(report())