aboutsummaryrefslogtreecommitdiffstats
path: root/docs/scripting.md
blob: 2b484ef8871ff385fb9b3325aaff424346d8ffd9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# Scripting

Three things make slosh easy to drive from other programs: a socket that does
everything the keyboard does, an escape sequence a pane can use to draw its own
chrome, and a headless mode that is the whole program without a terminal.

## The control socket

One JSON object per line, over the session's socket. A detached session answers
exactly as an attached one does.

```bash
$ slosh -s work cmd '{"cmd":"new-tab","name":"api"}'
{"ok":true,"id":2}
$ slosh -s work cmd '{"cmd":"panes"}'
{"ok":true,"panes":[{"id":1,"title":"nvim","alive":true,...}]}
```

Panes and tabs are addressed by **id**, so a background tab is scriptable.

| verb | |
|---|---|
| `panes` `tabs` | what exists, with ids, rects, titles, purposes, state. A pane's `tab_id` is what `move-pane` and `select-tab` want; its `tab` is where that tab sits in the strip; its `pid` is the program in it |
| `snapshot` | the composited screen, as JSON, `format:"text"`, or `format:"bytes"`, the emitter's own output for this frame (a second call is the delta) |
| `capture` | one pane's visible text: `id` (0 = focused). Reads the pane's own terminal, so it works for a pane in a tab that is not on screen, and changes nothing — no `select-tab`, no focus, no effect on a selection |
| `deadline` | when the session wants its next frame, in ms, or -1 |
| `send` | bytes as if typed, decoded like input (`"data":"\\x01\\\\"`) |
| `raw` | bytes straight into a pane's pty: `id` (0 = focused). Naming the pane is the point — focusing one first moves the view of whoever is watching |
| `resize` | `cols` `rows`, and optionally `cell_w` `cell_h` |
| `split` | `dir:"cols"\|"rows"`, `id` for which pane to split |
| `focus` | `id` |
| `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` | tabs, by `id` or `index` |
| `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"`, `beside` a pane in that tab to land next to (default: whatever it has focused), `focus:false` to leave focus — and the view — where it is. The pane keeps running: same pty, same scrollback |
| `float` | bare: toggle a pane [floating](panes.md#floating-a-pane) (`id`, or 0 for the focused one). With any of `x` `y` `w` `h` it *places*: floats first when tiled, re-places when floating, never un-floats; omitted fields keep their value |
| `new-float` | a fresh floating shell over the current tab, centred, in the focused pane's directory; answers `id` |
| `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 |
| `dump-layout` `apply-layout` | see [layouts](layouts.md). `apply-layout` takes `focus:false` to build the tabs without going to them (a layout that names an `active` tab still wins); the panes it builds are sized either way. `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) |
| `open-workspace` | `name` or `path`, and `suspended`; answers `tab` `purpose` `path` `created` `tabs` `honoured`. Already open means focused, with `created:false` |
| `close-workspace` | `name` or `purpose`; answers `closed`, how many tabs went |
| `save-workspace` | write this tab as the project's layout: `tab` (0 for the current one), `path` for a tab that is not a workspace yet, `suspend`, and `force` to overwrite a layout the project already has; answers `path` `purpose` `panes` `suspended` `replaced` |
| `notify` | put a line in the session's status area |
| `graphics` | the kitty placements on screen, or the bytes sent for them. The bytes are rendered for *you*, not the client, so anything stateful in them (image transmissions, deletions) is re-sent to the client on its next frame |
| `clipboard` | what the session has copied |
| `reload` | re-read the config; answers `{"ok":true,"warning":...}` if it had a complaint |
| `splash` | replay the attach greeting; `fx` and `motion` pick the colour effect and the assembly by index, for a deterministic one |
| `edit-config` | open the config in a pane |
| `theme` | without `name`, what is installed and what is worn (`theme` `themes` `dirs`); with one, switch now, and `save:true` writes the `theme_name` line into the config. See [themes](config.md#themes) |
| `alive` | is it running, and how many panes and tabs |
| `quit` | end the session |

## Your own pane, not the focused one

A program in a pane is told which pane it is in: **`$SLOSH_PANE`**. Everything
else follows from having it.

```bash
$S '{"cmd":"capture","id":'$SLOSH_PANE'}'      # read yourself
$S '{"cmd":"panes"}' | jq --argjson me $SLOSH_PANE '.panes[] | select(.id==$me) | .tab_id'
```

**Do not use `focused` for this.** Exactly one pane in the session is focused,
it is in the tab currently on screen, and it belongs to whoever is looking —
which is not you. A tool that read focus as "where I live" put its panes in
whatever tab the human had wandered into, and in a session with several such
tools running it was reliably somebody else's. `focused` answers "what is the
human doing"; `$SLOSH_PANE` answers "who am I".

So background work is three calls, none of which move anybody's view:

```bash
my_tab=$($S '{"cmd":"panes"}' | jq --argjson me $SLOSH_PANE '.panes[]|select(.id==$me)|.tab_id')

# 1. build a pane that was *given* a command, without going to it
$S '{"cmd":"apply-layout","focus":false,"kdl":"layout { tab name=\"work\" { pane purpose=\"task:build\" command=\"make -j8\" } }"}'
id=$($S '{"cmd":"panes"}' | jq '.panes[]|select(.purpose=="task:build")|.id')

# 2. move it in beside yourself, still without taking focus
$S "{\"cmd\":\"move-pane\",\"id\":$id,\"tab\":$my_tab,\"beside\":$SLOSH_PANE,\"focus\":false}"

# 3. read it wherever it is
$S "{\"cmd\":\"capture\",\"id\":$id}"
```

If you cannot read the environment — a scrubbed env, a helper with no
controlling tty, a process several forks down — `panes` reports each pane's
**`pid`**, so walking your own parents until one of them matches finds your pane
without asking anybody about focus.

## Driving a project

Everything a program needs in a project it has never seen: open it, ask what is
in it, act on the purposes the project's own layout declared:

```bash
$ slosh -s work cmd '{"cmd":"open-workspace","name":"api"}'
{"ok":true,"tab":3,"purpose":"project:api.5c1f0a3b","path":"/home/you/dev/api","created":true,"tabs":1,"honoured":0}
$ slosh -s work cmd '{"cmd":"open-workspace","name":"api"}'
{"ok":true,"tab":3,"purpose":"project:api.5c1f0a3b","path":"/home/you/dev/api","created":false,"tabs":0,"honoured":0}
```

**The second call focuses what is there and says `created:false`.** Opening is
idempotent, so a script drives it in a loop without asking first. "Have I
opened this already" is the question a script gets wrong after a crash or a
re-attach.

Then read the tab it handed back:

```bash
$ slosh -s work cmd '{"cmd":"panes"}' \
    | jq -c '.panes[] | select(.tab_id == 3) | {id, purpose, suspended}'
{"id":7,"purpose":"agent:main","suspended":false}
{"id":8,"purpose":"service:web","suspended":true}
$ slosh -s work cmd '{"cmd":"rerun","id":8}'    # start the dev server
```

The project's own layout file decided that `service:web` is the dev server and
that it starts asleep; nothing in the session, the config or the calling program
had to know that.

`workspaces` reports each project's layout file `mtime`, so a tool that kept the
mtime it opened a workspace with can tell the file has moved on since, without
the session storing a byte on its behalf. Re-applying the changed layout is
deliberately not offered: the panes it would replace have processes in them.

## A pane can draw its own chrome

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\\
```

The status text appears in the pane's frame; the buttons are real targets in it.
A program that wants to be asked something can ask *in place* rather than
printing a prompt and hoping.

With the tab bar [on a side](panes.md#the-tab-bar), the status is also a row
under the pane's tab — visible from every other tab, and clicking it jumps to
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`, coloured `tab_status_spinner`) 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.

`shader` is the third, and the only one the session can refuse: it sets the shader
passes for the pane that asked, as a document in the config's own syntax, so an
entry's `where=` decides which rect it lands on and the reply counts what went
where. It needs
`in_band_shaders true` because a program restyling your session is a hazard
before it is a convenience. `shader;` with no rect named clears both of that
pane's chains, which is never refused; `clear-shaders` above is the same
thing from outside, for a program that will not do it itself. `shader-load;<path>`
hands over a `shaders { }` file instead of a chain and answers with how much of it
ran (`ok;1 chrome, 0 content`), so a script can apply a preset without knowing how
to read one.
See [shaders](shaders.md#prototyping-in-a-pane).

Anything the session sends *back* to a program ends its verb in `-reply`
(`hello-reply`, `shader-reply`) and no request verb may. A pane that echoes what
it is sent (`cat`, a shell with echo on, a REPL waiting for a line) would
otherwise be answered into a loop, which is exactly what `hello` used to do:
4 MB of hellos in a second and a half.

## ...or say what it is doing in somebody else's protocol

OSC 5577 only works for programs that know slosh exists. **OSC 7501, the
[program status protocol](https://www.superlogical.com/rex/docs/build/program-status),
is the same idea written down for everybody** — by the author of ghostty, and
implemented by it and by Rex — so a program that already speaks it needs to know
nothing about us:

```bash
status() {
  printf '\033]7501;state=%s:msg=%s\033\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
}
status working "Syncing photos"
rsync -a ~/Photos backup:/photos && status done "Photos synced" || status error "rsync failed"
```

The body is `key=value` pairs separated by `:`. `state` is the only required
key, and is one of `idle`, `working`, `done`, `blocked`, `error`, or `clear` to
remove the record. The rest: `msg` and `title` (base64 UTF-8), `app` (a stable
name like `cargo`), `progress` (0–100), `kind` (`permission`, `question` or
`auth`, with `blocked`), and `id` to report more than one thing at a time.

It lands in the same place 5577's `status` does — the pane's frame, a row in the
sidebar, `status` and `busy` in `panes` — because every reader of a pane wants
"what is this doing", not "which protocol said so". What the structure buys on
top of that is reported alongside: `program_state`, `program_kind`,
`program_app` and `progress`.

```bash
$ slosh cmd '{"cmd":"panes"}' | jq -c '.panes[] | select(.program_state == "blocked")'
{"id":4,...,"status":"EU West: Approve deploy to production?","busy":true,
 "program_state":"blocked","program_kind":"permission","program_app":"deploy","progress":-1}
```

The two protocols share that one slot, **last writer wins**: a 5577
`status`/`busy`/`clear` drops the 7501 records, because a `program_state`
describing text that is no longer on screen is worse than none. Neither
protocol replaces the other — 7501 is deliberately one-way (a program reports,
the terminal decides what to show), so `buttons`, `click` and `shader` have
nowhere to live in it; and 5577 has no idea what `done` or `blocked` mean.

**A program with several things going at once** gives each an `id`, which may be
a path: `build/test` is a child of `build`. Clearing a record clears everything
under it, a record with no `app` inherits one from its nearest ancestor that has
one, and when several records are true at the same time the pane shows the one
nearest to needing a person: `blocked`, then `error`, `done`, `working`, `idle`,
ties going to whichever was written last. So a deploy that is `working` at the
root while `eu-west` waits for approval shows the approval.

**Records die without anybody saying so**, because there is no heartbeat in this
protocol and a status nobody retracted is the failure mode it exists to fix. A
`working` or `blocked` record is dropped when the program exits or a new shell
prompt begins (`OSC 133 ; A`); `done` and `error` survive both, which is how a
result is still there when you come back to the pane; `idle` stays until it is
replaced; and a full reset (`ESC c`) removes all of them.

To find out whether any of this is listened to, ask: `printf
'\033]7501;?\033\\'` is answered with the same bytes, `\033]7501;?\033\\`, and
by nothing at all in a terminal that does not implement it. That reply *is* a
request — the one shape 5577 forbids — so it is rate-limited to one answer per
250 ms per pane: a program that asks once is answered at once, and a pane that
echoes our answer back into our own scanner stops there instead of trading
megabytes with us.

## Driving it from an agent

Everything above is what an agent needs, and none of it says which parts matter.
`.agents/skills/driving-slosh/SKILL.md` is the same socket written as
instructions: how a program in a pane finds out which session it is in
(`SLOSH_SESSION`, `SLOSH_BIN`), why work belongs in a pane that *was given a
command* rather than typed into somebody's shell, that `alive` and `exit_code` are
how you wait rather than reading the screen for a marker your own echo matches, and
that `purpose` is the handle to find things by because titles change underneath
you.

It follows the `.agents/skills/<name>/SKILL.md` convention, so an agent working in
a checkout picks it up without being told. To use it elsewhere, copy or symlink the
directory into wherever your agent looks for skills. `tests/test_skill.py` checks
every verb, variable and `panes` field it names against the program, because a
stale skill is worse than a missing one: an agent acts on it without a human
reading it first.

## Headless

`slosh --script` is the whole program without a terminal: commands on stdin,
answers on stdout. It is how the test suite works (drive these events, assert
this screen), which is also why the suite is 1,500-odd real end-to-end checks that
finish in about thirteen seconds.

```bash
$ printf '%s\n' '{"cmd":"split","dir":"cols"}' '{"cmd":"snapshot","format":"text"}' \
    | slosh --script --cols 80 --rows 24 -- /bin/sh
```

The bare-verb form (`snapshot text`, `send \x01\\`, `resize 100 30`) is a
human-friendly alias for the same code, so a script and a test cannot drift from
what the API does.