diff options
| author | user <user@clank> | 2026-08-12 16:42:40 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 16:42:40 +0200 |
| commit | 889180b6b6b7a52144ffb14fc50d9531203677e6 (patch) | |
| tree | e41184df00ee6eb7a83112119346caa7bbd8c66b | |
| parent | sl0p: hot-reload status_bar_button_style (diff) | |
| download | sl0ppij-889180b6b6b7a52144ffb14fc50d9531203677e6.tar.gz sl0ppij-889180b6b6b7a52144ffb14fc50d9531203677e6.tar.xz sl0ppij-889180b6b6b7a52144ffb14fc50d9531203677e6.zip | |
sl0p: propose per-pane workspace hints
A design doc, not an implementation: lets a project ship a layout that also tells
an agent what each pane is for.
The load-bearing decision is the boundary -- zellij carries one opaque string per
pane and never interprets it, pi owns the whole vocabulary. That keeps the fork
change to a single field threaded along the existing status_bar path, and lets the
vocabulary change without rebuilding zellij.
Records two findings that were checked rather than assumed, since both would
otherwise be discovered the expensive way: pane names cannot carry metadata (there
is no stable name field at runtime, and title is clobbered by whatever the program
sets), and the runtime query surface already exists (list-panes --json), so the
work is only to get the field into it.
| -rw-r--r-- | sl0p/workspace-hints-proposal.md | 176 |
1 files changed, 176 insertions, 0 deletions
diff --git a/sl0p/workspace-hints-proposal.md b/sl0p/workspace-hints-proposal.md new file mode 100644 index 00000000..b81782ab --- /dev/null +++ b/sl0p/workspace-hints-proposal.md @@ -0,0 +1,176 @@ +# Proposal: workspace hints (per-pane `purpose`) + +**Goal.** Let a project ship a zellij layout that also tells an agent *what each +pane is for*, so "open this project" produces a terminal arrangement the agent can +navigate instead of guessing. + +**Status.** Proposal. Nothing implemented. + +## The boundary that keeps this small + +> zellij carries an **opaque string** per pane. It never interprets it. +> pi owns the entire vocabulary. + +zellij must not learn what `dev-server` means, must not enforce policy, must not +special-case a value. Every semantic lives in pi. Consequences: + +- the fork change is one string threaded through an existing path — no new enums, + no new subsystems, no protobuf enum variants (which we cannot regenerate anyway) +- the vocabulary can change daily without rebuilding zellij +- zellij cannot be blamed for, or asked to enforce, agent policy + +Anything in a proposal that requires zellij to *understand* a purpose is out of +scope by construction. + +## Why not just use pane names (the zero-work alternative) + +Tempting: `pane name="agent:server"` needs no fork change. It does not work, and +this was checked rather than assumed: + +- `list-panes --json` exposes **`title`**, and no separate stable `name` field +- `title` is **clobbered by the running program**. In a live session right now a + pane declared in a layout reports `title="pi ⚙ working…"` — written by a zsh + title hook, not by the layout + +So a name is a *UI string a program owns*, not a data channel. Metadata stored +there is silently destroyed the moment a shell sets its own title. Hence a +dedicated field. + +## The half that is load-bearing + +A layout is a **template** consumed at session creation. A hint is worthless +unless it survives into the running session and an agent can *query* it. + +Good news: that surface already exists and is already rich. + +```console +$ zellij action list-panes --json +[{ "id": 1, "title": "…", "tab_id": 0, "tab_name": "Tab #1", "is_focused": false, … }] +``` + +So the work is: get `purpose` into that JSON. No new query command is needed. + +## v1 scope + +1. **`purpose="…"` as a pane property** in layout KDL, for tiled *and* floating + panes. + + ```kdl + layout { + pane split_direction="vertical" { + pane name="editor" purpose="human-editor" + pane { + pane name="server" purpose="dev-server" command="npm" { args "run" "dev"; } + pane name="logs" purpose="logs" command="tail" { args "-f" "log/dev.log"; } + } + } + pane name="agent" purpose="agent-scratch" + } + ``` + +2. **`--purpose <STRING>` on `new-pane` / `run`**, so an agent can tag panes it + creates itself. Without this only layout-declared panes have semantics and an + agent cannot find the server pane it started five minutes ago. Same field, + trivial addition, closes the loop. + + ```bash + id=$(zellij run --no-focus --purpose dev-server --name server -- npm run dev) + ``` + +3. **Surface it in `list-panes --json`** as `"purpose": "dev-server"` (and `null` + when unset). + +4. **Round-trip it** in `dump-layout` and session serialization, so a resurrected + session keeps its semantics. A workspace that forgets what its panes were for + after a restart is not a workspace. + +That is the whole feature. + +## Template to clone + +The fork's `status_bar` pane attribute is the same shape — string, declared in KDL, +lives on the pane, round-trips through dump-layout and resurrection. Follow that +path exactly: + +| step | file | +|---|---| +| allowlist the property (tiled **and** floating) | `zellij-utils/src/kdl/kdl_layout_parser.rs` (`is_a_valid_pane_property`, `is_a_valid_floating_pane_property`) | +| parse + sanitize | same file (`kdl_get_string_property_or_child_value_with_error!`) | +| layout structs | `zellij-utils/src/input/layout.rs` (`TiledPaneLayout`, `FloatingPaneLayout`) | +| apply to the runtime pane | `zellij-server/src/tab/layout_applier.rs` | +| store on the pane | `zellij-server/src/panes/terminal_pane.rs`, `plugin_pane.rs` | +| expose to `list-panes` | `zellij-server/src/session_layout_metadata.rs`, `PaneInfo` in `zellij-utils/src/data.rs` | +| serialize / resurrect | `zellij-utils/src/session_serialization.rs` | +| CLI flag | `zellij-utils/src/cli.rs`, `zellij-utils/src/input/actions.rs`, `zellij-server/src/route.rs` | + +`grep -rln status_bar --include=*.rs zellij-utils/src zellij-server/src` lists the +full precedent if a step above is unclear. + +## Constraints + +- **No `protoc`.** New protobuf *fields* are fine if defaulted at both boundaries + (`plugin_api/`, `ipc/protobuf_conversion.rs`); new *enum variants* are + impossible. `purpose` must therefore be a plain `Option<String>`, never an enum. + If exposing it to plugins costs protobuf churn, **defer the plugin path** — CLI + JSON is what pi consumes. +- **Absent must be a total no-op.** A layout with no `purpose` anywhere must + produce byte-identical behaviour and an unchanged `dump-layout`, so existing + layouts and the snapshot suite are untouched. +- **Sanitize** like `sanitize_status_bar_text` does: bounded length, no control + characters. Reuse that function's home in `input/layout.rs` so KDL and CLI paths + share one sanitizer. +- **Keep the allowlist strict.** Unknown pane properties must still error; do not + relax `is_a_valid_pane_property` into "accept anything". + +## Design note: declared panes vs agent-spawned panes + +There is a trap in "the workspace declares a pane and the agent uses it". Using +someone else's pane means writing keystrokes into whatever is currently running in +it — which types `cargo build` into a vim session if the state ever drifts. + +Recommended split, and pi's convention should encode it: + +- **declared panes** are for things that *start themselves* (servers, `tail -f`, + `watch`) or that belong to the **human** (editor). The agent reads them + (`dump-screen -p`) and otherwise leaves them alone. +- **the agent's own work** goes in panes the agent *spawns and tags* + (`zellij run --no-focus --purpose …`), and closes when done. + +So `purpose` is mostly used two ways: "this is the human's, don't touch" and "this +is the log/server I should read", plus "this is mine, I made it". + +## Trust + +Layout files already execute arbitrary commands (`command="rm"`). A workspace file +committed to a repo is therefore **pre-existing arbitrary code execution**, and +sanitizing `purpose` does not change that one bit. + +The real rule is pi's, not zellij's: **do not auto-open a workspace file from a +repo you have not read.** Sanitization here only prevents a hostile `purpose` +string from corrupting terminal output or pi's parsing — it is not a trust +boundary. Say so plainly in the pi-side docs so nobody infers safety that is not +there. + +## Verification checklist + +Prove each empirically, not by inspection: + +1. layout with `purpose` → `list-panes --json` reports it for the right pane ids +2. floating pane with `purpose` → same +3. `zellij run --purpose X` → reported for the new pane +4. kill and resurrect the session → `purpose` still reported +5. `dump-layout` of a session built from a `purpose` layout → re-running that + output reproduces the purposes +6. an unknown property (`pane porpoise="x"`) → still a parse error +7. a layout with no `purpose` → unchanged `dump-layout`, full suite green + (`cargo test -p zellij-server -p zellij-utils --lib`) + +## Deliberately out of scope + +- **tab-level `purpose`** — plausibly wanted ("this tab is agent scratch"), but + pane-level answers the actual question first. Add later if it earns it. +- **a general `meta` key/value bag** — one string is enough until it demonstrably + is not. `command`/`args`/`cwd` already carry the "what runs here" data. +- **zellij enforcing anything** (read-only panes, agent permissions). It cannot: + the agent is just a process running CLI commands. Advisory only, forever. +- **a new query command** — `list-panes --json` already exists. |
