Skip to content

Commit b68962b

Browse files
author
Your Name
committed
feat: tmux-compatible control mode (-C / -CC)
Add full control mode protocol for programmatic session control. External programs can now drive psmux over a structured text protocol using stdin/stdout instead of the TUI, enabling plugins, IDE integrations, custom dashboards, and automation tooling. Wire protocol: - %begin/%end/%error command response framing - 20+ async notification types (window, session, pane, output) - Octal escaping for non-printable bytes (tmux compatible) - Stable monotonic IDs (, @window, %pane) - -C (echo) and -CC (no-echo with ST terminator) modes New files: - src/control.rs: core module (formatting, escaping, emission) - docs/control-mode.md: plugin developer documentation - tests/test_control_mode.ps1: 18 integration tests Modified: - src/types.rs: ControlNotification enum, ControlClient, CtrlReq variants - src/server/connection.rs: CONTROL protocol handler, 40+ command dispatch - src/server/mod.rs: notification emission from hook events, output draining - src/main.rs: -C/-CC CLI flags, run_control_mode() with graceful shutdown - src/pane.rs: per-pane 64KB output ring buffer in reader thread - src/tree.rs: for_each_pane() traversal - src/popup.rs, src/window_ops.rs: output_ring plumbing All 502 Rust tests pass. All 18 PowerShell integration tests pass.
1 parent 6538a6e commit b68962b

15 files changed

Lines changed: 2594 additions & 13 deletions

docs/control-mode.md

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
# Control Mode
2+
3+
Control mode lets external programs drive psmux programmatically over a structured text protocol. Instead of rendering a TUI, psmux sends machine-readable notifications and accepts commands over stdin/stdout, making it the foundation for building plugins, IDE integrations, custom dashboards, session monitors, and any tooling that needs to interact with terminal sessions.
4+
5+
This is the same protocol that tmux uses for its control mode (`tmux -C` / `tmux -CC`), so existing knowledge and many client libraries transfer directly to psmux.
6+
7+
## Quick Start
8+
9+
```powershell
10+
# 1. Create a detached session
11+
psmux new-session -d -s work -x 120 -y 30
12+
13+
# 2. Attach in control mode (no-echo)
14+
psmux -CC
15+
```
16+
17+
psmux connects to the running session and enters a command/response loop. You type commands on stdin, and psmux responds on stdout with structured output.
18+
19+
```
20+
list-windows
21+
%begin 1700000000 1 1
22+
0: pwsh* (1 panes) [120x30]
23+
%end 1700000000 1 1
24+
```
25+
26+
To exit, close stdin (Ctrl+D / EOF) or send `kill-server`.
27+
28+
## Flags
29+
30+
| Flag | Mode | Behavior |
31+
|------|------|----------|
32+
| `-C` | Echo | Commands you send are echoed back to stdout before the response. Useful for debugging and interactive testing. |
33+
| `-CC` | No-echo | Commands are not echoed. This is the mode you want for programmatic use. In this mode, `%exit` is followed by an ST sequence (`ESC \`). |
34+
35+
## Session Targeting
36+
37+
By default, control mode connects to the session stored in `PSMUX_SESSION_NAME`. You can set it before launching:
38+
39+
```powershell
40+
$env:PSMUX_SESSION_NAME = "my-session"
41+
psmux -CC
42+
```
43+
44+
## Wire Protocol
45+
46+
### Command/Response Framing
47+
48+
Every command you send gets a response wrapped in `%begin` / `%end` (or `%error`) markers:
49+
50+
```
51+
<your command>
52+
%begin <timestamp> <command_number> <flags>
53+
<response lines>
54+
%end <timestamp> <command_number> <flags>
55+
```
56+
57+
| Field | Description |
58+
|-------|-------------|
59+
| `timestamp` | Unix epoch seconds when the command was processed |
60+
| `command_number` | Sequential counter (1, 2, 3, ...) for each command in the session |
61+
| `flags` | Reserved, always `1` |
62+
63+
The `%begin` and `%end` lines always share the same timestamp, command number, and flags. If a command fails, the closing frame is `%error` instead of `%end`:
64+
65+
```
66+
nonexistent-command
67+
%begin 1700000000 1 1
68+
unknown command: nonexistent-command
69+
%error 1700000000 1 1
70+
```
71+
72+
Command response blocks never interleave with each other. Notifications (described below) arrive between command blocks, never inside them.
73+
74+
### Notifications
75+
76+
Notifications are asynchronous lines that psmux sends whenever something happens in the session. They always start with `%` and arrive between command response blocks.
77+
78+
#### Window Notifications
79+
80+
| Notification | Meaning |
81+
|---|---|
82+
| `%window-add @<WID>` | A new window was created |
83+
| `%window-close @<WID>` | A window was destroyed |
84+
| `%window-renamed @<WID> <name>` | A window was renamed |
85+
| `%window-pane-changed @<WID> %<PID>` | The active pane in a window changed |
86+
| `%layout-change @<WID> <layout> <visible_layout> <flags>` | A window's pane layout changed (split, resize, etc.) |
87+
88+
#### Session Notifications
89+
90+
| Notification | Meaning |
91+
|---|---|
92+
| `%session-changed $<SID> <name>` | The attached session changed |
93+
| `%session-renamed <name>` | The current session was renamed |
94+
| `%session-window-changed $<SID> @<WID>` | The active window in a session changed |
95+
| `%sessions-changed` | A session was created or destroyed |
96+
97+
#### Pane Output
98+
99+
| Notification | Meaning |
100+
|---|---|
101+
| `%output %<PID> <escaped_data>` | A pane produced output |
102+
| `%pane-mode-changed %<PID>` | A pane entered or exited a special mode (e.g. copy mode) |
103+
104+
#### Flow Control
105+
106+
| Notification | Meaning |
107+
|---|---|
108+
| `%pause %<PID>` | Output for this pane has been paused (client is too far behind) |
109+
| `%continue %<PID>` | Output for this pane has resumed |
110+
111+
#### Client and Buffer
112+
113+
| Notification | Meaning |
114+
|---|---|
115+
| `%client-detached <client>` | A client disconnected from the session |
116+
| `%client-session-changed <client> $<SID> <name>` | Another client changed its attached session |
117+
| `%paste-buffer-changed <name>` | A paste buffer was modified |
118+
| `%paste-buffer-deleted <name>` | A paste buffer was deleted |
119+
| `%message <text>` | A status message was generated (e.g. from `display-message`) |
120+
121+
#### Exit
122+
123+
| Notification | Meaning |
124+
|---|---|
125+
| `%exit` | The control client is disconnecting. In `-CC` mode, followed by `ESC \` (ST sequence). |
126+
| `%exit <reason>` | Disconnecting with a reason (e.g. `too far behind`). |
127+
128+
### ID Formats
129+
130+
All IDs are stable, monotonically increasing integers that never get reused during a server's lifetime:
131+
132+
| Prefix | Entity | Example |
133+
|--------|--------|---------|
134+
| `$` | Session | `$0` |
135+
| `@` | Window | `@0`, `@1`, `@2` |
136+
| `%` | Pane | `%0`, `%1`, `%2` |
137+
138+
### Output Escaping
139+
140+
Data in `%output` notifications uses octal escaping for non-printable bytes:
141+
142+
| Byte | Encoding |
143+
|------|----------|
144+
| Printable ASCII (0x20 to 0x7E) | Passed through as-is |
145+
| Tab (0x09) | Passed through as-is |
146+
| Backslash (0x5C) | `\134` |
147+
| Carriage return (0x0D) | `\015` |
148+
| Line feed (0x0A) | `\012` |
149+
| Any other byte | `\NNN` (3-digit octal) |
150+
151+
Example: `hello\r\n` becomes `%output %0 hello\015\012`.
152+
153+
## Supported Commands
154+
155+
All standard psmux/tmux commands work in control mode. Here are the most useful ones for plugin development:
156+
157+
### Session and Window Management
158+
159+
```
160+
new-window # Create a new window
161+
new-window -n editor # Create a named window
162+
split-window -v # Split vertically
163+
split-window -h # Split horizontally
164+
kill-pane # Kill the active pane
165+
kill-window # Kill the active window
166+
select-window -t 1 # Switch to window 1
167+
select-pane -t %3 # Switch to pane %3
168+
rename-window new-name # Rename the active window
169+
rename-session new-name # Rename the session
170+
```
171+
172+
### Querying State
173+
174+
```
175+
list-windows # List all windows
176+
list-windows -F '#{window_id}' # Custom format
177+
list-panes # List panes in active window
178+
list-panes -a # List all panes across all windows
179+
list-sessions # List sessions
180+
list-clients # List connected clients
181+
display-message -p '#{pane_id}' # Print a format variable
182+
has-session -t my-session # Check if session exists (exit code)
183+
```
184+
185+
### Interacting with Panes
186+
187+
```
188+
send-keys -t %0 "echo hello" Enter # Send keystrokes to a pane
189+
send-keys -t %0 -l "literal text" # Send text literally (no key parsing)
190+
capture-pane -t %0 -p # Capture the visible content of a pane
191+
```
192+
193+
### Configuration and Hooks
194+
195+
```
196+
set-option -g status-style "bg=blue" # Set an option
197+
show-options -g # Show all global options
198+
set-hook -g after-new-window "display-message hi" # Set a hook
199+
bind-key M-x display-message "pressed!" # Bind a key
200+
```
201+
202+
### Server
203+
204+
```
205+
list-commands # List all available commands
206+
server-info # Server information
207+
kill-server # Shut down the server
208+
```
209+
210+
## Building a Plugin
211+
212+
### Minimal Python Example
213+
214+
```python
215+
import subprocess
216+
import threading
217+
218+
proc = subprocess.Popen(
219+
["psmux", "-CC"],
220+
stdin=subprocess.PIPE,
221+
stdout=subprocess.PIPE,
222+
stderr=subprocess.PIPE,
223+
text=True,
224+
env={**__import__("os").environ, "PSMUX_SESSION_NAME": "work"},
225+
)
226+
227+
def read_notifications():
228+
for line in proc.stdout:
229+
line = line.rstrip("\n")
230+
if line.startswith("%output"):
231+
parts = line.split(" ", 2)
232+
pane_id = parts[1]
233+
data = parts[2] if len(parts) > 2 else ""
234+
print(f"[{pane_id}] {data}")
235+
elif line.startswith("%window-add"):
236+
print(f"Window created: {line}")
237+
elif line.startswith("%begin"):
238+
pass # Start of command response
239+
elif line.startswith("%end"):
240+
pass # End of command response
241+
elif line.startswith("%error"):
242+
print(f"Command error: {line}")
243+
244+
reader = threading.Thread(target=read_notifications, daemon=True)
245+
reader.start()
246+
247+
# Send a command
248+
proc.stdin.write("list-windows\n")
249+
proc.stdin.flush()
250+
251+
# Create a new window
252+
proc.stdin.write("new-window -n build\n")
253+
proc.stdin.flush()
254+
255+
# Run a command in it
256+
proc.stdin.write('send-keys "cargo build" Enter\n')
257+
proc.stdin.flush()
258+
259+
import time
260+
time.sleep(5)
261+
proc.stdin.close()
262+
proc.wait()
263+
```
264+
265+
### Minimal PowerShell Example
266+
267+
```powershell
268+
$env:PSMUX_SESSION_NAME = "work"
269+
$psi = [System.Diagnostics.ProcessStartInfo]::new()
270+
$psi.FileName = (Get-Command psmux).Source
271+
$psi.Arguments = "-CC"
272+
$psi.RedirectStandardInput = $true
273+
$psi.RedirectStandardOutput = $true
274+
$psi.UseShellExecute = $false
275+
276+
$proc = [System.Diagnostics.Process]::Start($psi)
277+
278+
# Send a command
279+
$proc.StandardInput.WriteLine("list-windows")
280+
$proc.StandardInput.Flush()
281+
Start-Sleep -Seconds 1
282+
283+
# Read the response
284+
while ($proc.StandardOutput.Peek() -ge 0) {
285+
$line = $proc.StandardOutput.ReadLine()
286+
Write-Host $line
287+
}
288+
289+
$proc.StandardInput.Close()
290+
$proc.WaitForExit(5000)
291+
```
292+
293+
### Minimal Node.js Example
294+
295+
```javascript
296+
const { spawn } = require("child_process");
297+
298+
const proc = spawn("psmux", ["-CC"], {
299+
env: { ...process.env, PSMUX_SESSION_NAME: "work" },
300+
stdio: ["pipe", "pipe", "pipe"],
301+
});
302+
303+
proc.stdout.on("data", (chunk) => {
304+
for (const line of chunk.toString().split("\n")) {
305+
if (line.startsWith("%output")) {
306+
const [, paneId, ...rest] = line.split(" ");
307+
console.log(`[${paneId}] ${rest.join(" ")}`);
308+
} else if (line.startsWith("%begin")) {
309+
// Command response starting
310+
} else if (line.startsWith("%end")) {
311+
// Command response complete
312+
}
313+
}
314+
});
315+
316+
proc.stdin.write("list-windows\n");
317+
proc.stdin.write("new-window -n monitor\n");
318+
proc.stdin.write('send-keys "top" Enter\n');
319+
320+
setTimeout(() => {
321+
proc.stdin.end();
322+
}, 5000);
323+
```
324+
325+
## Parsing Tips
326+
327+
1. **Read line by line.** Every notification and framing marker is a single line terminated by `\n`.
328+
329+
2. **Track command state.** When you send a command, set a flag. Lines between `%begin` and `%end`/`%error` are the command's output. Everything outside those blocks is asynchronous notifications.
330+
331+
3. **Match begin/end pairs by command number.** The second field in `%begin` and `%end` lines is the command counter. Use it to correlate responses with requests.
332+
333+
4. **Buffer line parsing for `%output`.** Split on the first two spaces: `%output`, pane ID, then the rest is escaped output data.
334+
335+
5. **Decode octal escapes.** Replace `\NNN` sequences in output data with the corresponding byte value. `\134` is a literal backslash.
336+
337+
6. **Handle connection loss gracefully.** If the session dies or the server shuts down, stdout will close (EOF). Your reader loop should exit cleanly.
338+
339+
## Differences from tmux
340+
341+
psmux control mode is wire-compatible with tmux's protocol. A few features that exist in tmux but are not yet implemented in psmux:
342+
343+
| Feature | Status | Notes |
344+
|---------|--------|-------|
345+
| `refresh-client -f` flags | Planned | Per-client flags like `no-output`, `pause-after=N` |
346+
| `refresh-client -A` pane actions | Planned | Per-pane on/off/continue/pause |
347+
| `refresh-client -B` subscriptions | Planned | Filtered format variable monitoring |
348+
| `refresh-client -C WxH` | Planned | Client-side size override |
349+
| `%extended-output` | Planned | Output with age info for flow control |
350+
| `%subscription-changed` | Planned | Subscription value change events |
351+
| Unlinked window notifications | N/A | psmux uses one session per server |
352+
353+
The core protocol (framing, notifications, escaping, IDs, command dispatch) is fully compatible. Plugins targeting the basic tmux control mode protocol will work identically on psmux.
354+
355+
## Format Variables
356+
357+
Use `display-message -p` to query any format variable:
358+
359+
```
360+
display-message -p '#{session_name}: #{window_index} #{pane_id}'
361+
```
362+
363+
Common variables for control mode plugins:
364+
365+
| Variable | Example | Description |
366+
|----------|---------|-------------|
367+
| `#{session_name}` | `work` | Session name |
368+
| `#{session_id}` | `$0` | Session stable ID |
369+
| `#{window_id}` | `@0` | Window stable ID |
370+
| `#{window_index}` | `0` | Window index |
371+
| `#{window_name}` | `pwsh` | Window name |
372+
| `#{pane_id}` | `%0` | Pane stable ID |
373+
| `#{pane_index}` | `0` | Pane index within window |
374+
| `#{pane_pid}` | `12345` | Pane child process PID |
375+
| `#{pane_current_command}` | `pwsh` | Pane running command |
376+
| `#{pane_width}` | `120` | Pane width in columns |
377+
| `#{pane_height}` | `30` | Pane height in rows |
378+
| `#{cursor_x}` | `5` | Cursor column |
379+
| `#{cursor_y}` | `10` | Cursor row |

0 commit comments

Comments
 (0)