Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions tmux-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,19 @@ session summary.
| `#{pi_window_summary}` | Plain text, e.g. `api + 2 others · working 1 · done 2 · unread 1` |
| `#{pi_session_summary}` | The same summary across the whole session |

Each placeholder becomes a `#(…)` status job that reads the global option
`@pi_query` (the plugin's Node and query paths), so install paths never appear
inside your formats. Jobs refresh at your `status-interval`.
Placeholders become plain reads of tmux options (`@pi_window_state`,
`@pi_window_name`, `@pi_window_unread`, `@pi_window_count`,
`@pi_window_summary` on each window, and `@pi_session_summary` on each session),
so drawing a window label starts no process. When any placeholder is in use, the
plugin appends one hidden job, `#(#{@pi_query} env publish)`, to `status-right`.
It prints nothing. Each `status-interval` it reads every Pi pane once, works out
every window and session aggregate, and writes only the values that changed.
That is one Node process per attached client per tick, however many windows you
have. Values are at most one `status-interval` old.

`@pi_query` holds the plugin's Node and query paths, so install paths never
appear inside your formats. Formats expanded by an older version of the plugin
(one `#(…)` job per placeholder per window) are rewritten when it loads again.

In windows with no Pi, text placeholders are empty and counts are `0`, so they
work as tmux conditionals: `#{?#{pi_count},…,…}`. Placeholders can be nested inside
Expand Down Expand Up @@ -118,13 +128,14 @@ helper from your own script; see

## Troubleshooting

| Symptom | Likely cause and fix |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Literal `#{pi_…}` text in the status bar | Plugin ran before the format was defined, or the theme regenerated it. Load the plugin after your theme, then reload. |
| Placeholders render empty in Pi windows | Publisher not loaded, or still running pre-update code. Install it, then restart that Pi; `/tmux status` in Pi should list the pane. |
| `pane-focus-in[1079] is occupied` on load | Another plugin uses that hook slot. Nothing was changed; free the slot or remove the other hook. |
| Stopped working after switching Node versions | The Node path is captured at load. Reload tmux config with Node 24+ on tmux's `PATH`. |
| State is not cleared when you return | Ensure the plugin loaded (it enables `focus-events` and installs the hook). Check with `tmux show-hooks -g 'pane-focus-in[1079]'`. |
| Symptom | Likely cause and fix |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Literal `#{pi_…}` text in the status bar | Plugin ran before the format was defined, or the theme regenerated it. Load the plugin after your theme, then reload. |
| Placeholders never fill in | The publisher job runs from `status-right`. If a theme rebuilt `status-right` after the plugin loaded, load the plugin after the theme. |
| Placeholders render empty in Pi windows | Publisher not loaded, or still running pre-update code. Install it, then restart that Pi; `/tmux status` in Pi should list the pane. |
| `pane-focus-in[1079] is occupied` on load | Another plugin uses that hook slot. Nothing was changed; free the slot or remove the other hook. |
| Stopped working after switching Node versions | The Node path is captured at load. Reload tmux config with Node 24+ on tmux's `PATH`. |
| State is not cleared when you return | Ensure the plugin loaded (it enables `focus-events` and installs the hook). Check with `tmux show-hooks -g 'pane-focus-in[1079]'`. |

## Focus acknowledgement

Expand All @@ -141,7 +152,8 @@ only Pi-owned panes and leaves other hook slots alone. Reloading is idempotent.
bash /absolute/path/to/pinet/pinet.tmux uninstall
```

This removes only the hook the plugin installed, and `@pi_query`. `focus-events` stays enabled
This removes only the hook the plugin installed, `@pi_query`, and the
publisher job from `status-right`. `focus-events` stays enabled
because other plugins may rely on it.

3. Remove the plugin line, reload tmux config and run TPM's clean
Expand Down
36 changes: 31 additions & 5 deletions tmux-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ const OPTIONS = [
"window-status-format",
"window-status-current-format",
];
/** Placeholders read options written by the single status job, so window formats start no processes. */
export const PLACEHOLDERS = {
pi_state: "#{@pi_window_state}",
pi_name: "#{@pi_window_name}",
pi_unread: "#{?#{@pi_window_unread},#{@pi_window_unread},0}",
pi_count: "#{?#{@pi_window_count},#{@pi_window_count},0}",
pi_window_summary: "#{@pi_window_summary}",
pi_session_summary: "#{@pi_session_summary}",
} as const;
/** Pre-#1095 expansions: one Node job per placeholder per window per tick. Rewritten on load. */
const LEGACY_JOBS = {
pi_state: ["window", "state"],
pi_name: ["window", "name"],
pi_unread: ["window", "unread"],
Expand All @@ -24,17 +34,24 @@ export function shellQuote(text: string): string {
}
/** Holds the shell-quoted node/query paths; jobs reference it so paths never reach tmux's format parser. */
export const QUERY = "@pi_query";
/**
* The one job that refreshes every aggregate. It lives in status-right, outside the window list:
* tmux keys jobs by window inside the list, but by client here, so it runs once per client per tick.
* It prints nothing.
*/
export const PUBLISH_JOB = `#(#{${QUERY}} env publish)`;
export function expand(format: string): string {
let result = format;
for (const [placeholder, [scope, mode]] of Object.entries(PLACEHOLDERS)) {
for (const [placeholder, read] of Object.entries(PLACEHOLDERS)) {
const [scope, mode] = LEGACY_JOBS[placeholder as keyof typeof LEGACY_JOBS];
const target = scope === "window" ? "#{window_id}" : "#{session_id}";
result = result.replaceAll(
`#{${placeholder}}`,
`#(#{${QUERY}} env ${scope} '${target}' ${mode})`,
);
result = result
.replaceAll(`#{${placeholder}}`, read)
.replaceAll(`#(#{${QUERY}} env ${scope} '${target}' ${mode})`, read);
}
return result;
}
const READS = /#\{@pi_(?:window_[a-z]+|session_summary)\}/;
/** TPM adapter: only expand explicit placeholders; never choose/add a layout. */
export async function configure(
tmux: Tmux,
Expand All @@ -48,6 +65,9 @@ export async function configure(
if (saved && saved === current) await tmux.run(["set-hook", "-gu", HOOK]);
await tmux.run(["set-option", "-gu", SAVED_HOOK]);
await tmux.run(["set-option", "-gu", QUERY]);
const right = await tmux.run(["show-options", "-gqv", "status-right"]);
if (right.includes(PUBLISH_JOB))
await tmux.run(["set-option", "-g", "status-right", right.replaceAll(PUBLISH_JOB, "")]);
return;
}
if (current.trim() !== HOOK && (!saved || saved !== current))
Expand All @@ -57,9 +77,11 @@ export async function configure(
await tmux.run(["set-hook", "-g", HOOK, FOCUS_HOOK]);
await tmux.run(["set-option", "-g", SAVED_HOOK, await tmux.run(["show-hooks", "-g", HOOK])]);
await tmux.run(["set-option", "-g", QUERY, `${shellQuote(node)} ${shellQuote(query)}`]);
let used = false;
for (const option of OPTIONS) {
const original = await tmux.run(["show-options", "-gqv", option]);
const replacement = expand(original);
used ||= READS.test(replacement);
if (replacement !== original) await tmux.run(["set-option", "-g", option, replacement]);
}
// Preserve window-local overrides, including their themes, and expand only their explicit tokens.
Expand All @@ -68,10 +90,14 @@ export async function configure(
for (const option of OPTIONS.slice(2)) {
const original = await tmux.run(["show-options", "-wqv", "-t", window, option]);
const replacement = expand(original);
used ||= READS.test(replacement);
if (replacement !== original)
await tmux.run(["set-option", "-w", "-t", window, option, replacement]);
}
}
const right = await tmux.run(["show-options", "-gqv", "status-right"]);
if (used && !right.includes(PUBLISH_JOB))
await tmux.run(["set-option", "-g", "status-right", right + PUBLISH_JOB]);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
Expand Down
6 changes: 6 additions & 0 deletions tmux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,14 @@ your tmux configuration and the companion plugin.
```sh
node /path/to/installed/package/dist/status.js SOCKET window WINDOW_ID aggregate
node /path/to/installed/package/dist/status.js SOCKET session SESSION_ID aggregate
node /path/to/installed/package/dist/status.js SOCKET publish
```

`publish` prints nothing. It writes every window's and session's aggregate as
tmux options in one pass (the companion plugin runs it once per status tick).
Prefer reading those options to running a query per window: each query starts a
Node process.

Modes: `json` (validated pane records), `aggregate` (counts/name/state as JSON),
`summary` (plain text, no styles/icons), `state`, `name`, `unread`, `count`.
Arguments select socket and target; no implicit latest-client routing. The companion
Expand Down
52 changes: 34 additions & 18 deletions tmux/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "vitest";
import { configure, expand, HOOK, QUERY } from "../tmux-plugin/plugin.ts";
import { configure, expand, HOOK, PUBLISH_JOB, QUERY } from "../tmux-plugin/plugin.ts";
import { Tmux, processIdentity } from "./transport.ts";
const exec = promisify(execFile);

Expand Down Expand Up @@ -51,7 +51,12 @@ test(
await tmux.run(["set-hook", "-g", "pane-focus-in[123]", "display-message retained"]);
await configure(tmux, node, query);
assert.equal(await tmux.run(["show-options", "-gqv", "window-status-format"]), normal);
assert.equal(await tmux.run(["show-options", "-gqv", "status-right"]), expand(right));
// #1095: window formats read options; the only job is one publisher outside the window list.
assert.ok(!expand(local).includes("#("));
assert.equal(
await tmux.run(["show-options", "-gqv", "status-right"]),
expand(right) + PUBLISH_JOB,
);
assert.equal(
await tmux.run(["show-options", "-wqv", "-t", pane, "window-status-current-format"]),
expand(local),
Expand All @@ -67,6 +72,7 @@ test(
const hook = await tmux.run(["show-hooks", "-g", HOOK]);
assert.ok(hook.includes("@pi_unread"));
await configure(tmux, node, query, true);
assert.equal(await tmux.run(["show-options", "-gqv", "status-right"]), expand(right));
assert.equal((await tmux.run(["show-hooks", "-g", HOOK])).trim(), HOOK);
assert.equal(await tmux.run(["show-options", "-gqv", QUERY]), "");
assert.ok((await tmux.run(["show-hooks", "-g", "pane-focus-in[123]"])).includes("retained"));
Expand Down Expand Up @@ -129,28 +135,38 @@ test(
await symlink(process.execPath, node);
const query = fileURLToPath(new URL("./status.ts", import.meta.url));
await configure(tmux, node, query);
// Formats from a pre-#1095 load are upgraded in place on reload.
const legacy = `#I #(#{${QUERY}} env window '#{window_id}' count)`;
await tmux.run(["set-option", "-g", "window-status-format", legacy]);
await tmux.run(["set-option", "-g", "status-right", "#{pi_session_summary}"]);
await configure(tmux, node, query);
assert.equal(
await tmux.run(["show-options", "-gqv", "window-status-format"]),
`#I ${expand("#{pi_count}")}`,
);
const render = (placeholder: string, target = pane) =>
tmux.run(["display-message", "-p", "-t", target, expand(`#{${placeholder}}`)]);
assert.equal(await render("pi_count"), "0", "counts render 0 before the first publish");
// tmux format-expands a job's command before running it; reproduce that step with tmux itself.
const job = await tmux.run(["display-message", "-p", "-t", pane, PUBLISH_JOB.slice(2, -1)]);
const run = () =>
exec("sh", ["-c", job], { env: { ...process.env, TMUX: `${socket},0,-1` } });
assert.equal((await run()).stdout, "", "the publisher prints nothing into the status line");
for (const [placeholder, expected] of [
["pi_state", "working"],
["pi_name", "ship it"],
["pi_count", "1"],
["pi_unread", "0"],
["pi_window_summary", "ship it · working"],
["pi_session_summary", "ship it · working"],
]) {
// tmux format-expands a job's command before running it; reproduce that step with tmux itself.
const job = await tmux.run([
"display-message",
"-p",
"-t",
pane,
expand(`#{${placeholder}}`).slice(2, -1),
]);
assert.equal(
(
await exec("sh", ["-c", job], { env: { ...process.env, TMUX: `${socket},0,-1` } })
).stdout.trim(),
expected,
);
}
])
assert.equal(await render(placeholder), expected, placeholder);
assert.equal(await render("pi_state", second), "error", "each window gets its own aggregate");
await tmux.release(pane, record.owner);
await run();
assert.equal(await render("pi_count"), "0", "a released pane clears its window");
assert.equal(await render("pi_state"), "");
assert.equal(await render("pi_session_summary"), "");
// The actual TPM root entry runs native TS; neither node_modules nor dist is used by this path.
await exec("bash", [fileURLToPath(new URL("../pinet.tmux", import.meta.url))], {
env: { ...process.env, TMUX: `${socket},0,-1`, TMUX_PANE: pane },
Expand Down
11 changes: 9 additions & 2 deletions tmux/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@ import { summarize } from "./query.ts";
const [socketArgument, scope, target, mode = "summary"] = process.argv.slice(2);
const socket =
socketArgument === "env" ? process.env.TMUX?.match(/^(.*),\d+,-?\d+$/)?.[1] : socketArgument;
if (
if (socket && scope === "publish" && target === undefined) {
// The companion's status job: prints nothing, writes every aggregate as tmux options.
try {
await new Tmux(socket).rollup();
} catch {
process.exitCode = 1;
}
} else if (
!socket ||
(scope !== "session" && scope !== "window") ||
!target ||
!/^[$@%]\d+$/.test(target) ||
!["json", "summary", "state", "name", "unread", "count", "aggregate"].includes(mode)
) {
console.error(
"Usage: node status.ts SOCKET|env session|window TARGET [json|aggregate|summary|state|name|unread|count]",
"Usage: node status.ts SOCKET|env publish\n node status.ts SOCKET|env session|window TARGET [json|aggregate|summary|state|name|unread|count]",
);
process.exitCode = 2;
} else {
Expand Down
Loading
Loading