@ogulcancelik/pi-tmux 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/index.ts +436 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Can Celik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pi-tmux
|
|
2
|
+
|
|
3
|
+
Tmux pane management for [pi](https://github.com/badlogic/pi-mono). Run dev servers, watchers, and long-running processes in named panes without blocking the agent.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@ogulcancelik/pi-tmux
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add manually to `~/.pi/agent/settings.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"packages": ["npm:@ogulcancelik/pi-tmux"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
Gives the agent a `tmux` tool with five actions:
|
|
22
|
+
|
|
23
|
+
| Action | Description |
|
|
24
|
+
|--------|-------------|
|
|
25
|
+
| **run** | Start a command in a named pane |
|
|
26
|
+
| **read** | Capture output from a named pane |
|
|
27
|
+
| **send** | Send keys (`C-c`, `Enter`, etc.) or literal text to a pane |
|
|
28
|
+
| **stop** | Kill a named pane |
|
|
29
|
+
| **list** | List all managed panes |
|
|
30
|
+
|
|
31
|
+
The agent uses `tmux run` for long-running processes (dev servers, file watchers, builds) and `bash` for short-lived commands. This keeps the agent unblocked while background processes run.
|
|
32
|
+
|
|
33
|
+
## Layout
|
|
34
|
+
|
|
35
|
+
Pi runs on the left. The first worker pane splits to the right. Additional panes stack vertically below existing ones. You can override with `position: "right"` or `position: "bottom"`.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
- Panes are tagged with `@pi_name` tmux user options for reliable discovery
|
|
40
|
+
- ANSI escape sequences are stripped from captured output
|
|
41
|
+
- Dead panes are automatically replaced on `run`
|
|
42
|
+
- The tool self-disables when pi is not running inside tmux
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- [pi](https://github.com/badlogic/pi-mono) v0.40+
|
|
47
|
+
- [tmux](https://github.com/tmux/tmux) — pi must be running inside a tmux session
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux extension — gives the agent named panes for long-running processes.
|
|
3
|
+
*
|
|
4
|
+
* Actions:
|
|
5
|
+
* run — create a named pane (split right) and run a command in it
|
|
6
|
+
* read — capture output from a named pane
|
|
7
|
+
* send — send keys to a named pane (C-c, Enter, q, etc.)
|
|
8
|
+
* stop — kill a named pane
|
|
9
|
+
* list — list all managed panes
|
|
10
|
+
*
|
|
11
|
+
* Panes are tagged with @pi_name tmux user options for discovery.
|
|
12
|
+
* The tool is disabled when not running inside tmux.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
18
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
19
|
+
import { Type } from "@sinclair/typebox";
|
|
20
|
+
|
|
21
|
+
interface PaneInfo {
|
|
22
|
+
name: string;
|
|
23
|
+
paneId: string;
|
|
24
|
+
alive: boolean;
|
|
25
|
+
command: string;
|
|
26
|
+
pid: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Strip ANSI escapes, OSC sequences, and tmux/wezterm wrapping
|
|
30
|
+
function stripAnsi(text: string): string {
|
|
31
|
+
return (
|
|
32
|
+
text
|
|
33
|
+
// OSC sequences (e.g. \x1b]...\x07 or \x1b]...\x1b\\)
|
|
34
|
+
.replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "")
|
|
35
|
+
// tmux passthrough (\x1bPtmux;...\x1b\\)
|
|
36
|
+
.replace(/\x1bPtmux;.*?\x1b\\/g, "")
|
|
37
|
+
// CSI sequences (\x1b[...letter)
|
|
38
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
39
|
+
// Remaining bare escapes
|
|
40
|
+
.replace(/\x1b[^[\]P]/g, "")
|
|
41
|
+
// Carriage returns (terminal rewrite lines)
|
|
42
|
+
.replace(/\r/g, "")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function (pi: ExtensionAPI) {
|
|
47
|
+
const inTmux = !!process.env.TMUX;
|
|
48
|
+
let myPaneId: string | null = null;
|
|
49
|
+
let myWindowId: string | null = null;
|
|
50
|
+
|
|
51
|
+
// Discover our own pane/window/session on startup
|
|
52
|
+
pi.on("session_start", async () => {
|
|
53
|
+
if (!inTmux) {
|
|
54
|
+
// Disable the tmux tool
|
|
55
|
+
const active = pi.getActiveTools();
|
|
56
|
+
pi.setActiveTools(active.filter((t) => t !== "tmux"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = await pi.exec("tmux", [
|
|
62
|
+
"display-message",
|
|
63
|
+
"-p",
|
|
64
|
+
"-t",
|
|
65
|
+
process.env.TMUX_PANE || "",
|
|
66
|
+
"#{pane_id}\t#{window_id}\t#{session_id}",
|
|
67
|
+
]);
|
|
68
|
+
if (result.code === 0) {
|
|
69
|
+
const [paneId, windowId] = result.stdout.trim().split("\t");
|
|
70
|
+
myPaneId = paneId || null;
|
|
71
|
+
myWindowId = windowId || null;
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// --- helpers ---
|
|
77
|
+
|
|
78
|
+
function requireWindowTarget(): string {
|
|
79
|
+
if (!myWindowId) throw new Error("Could not determine current tmux window.");
|
|
80
|
+
return myWindowId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function findPane(name: string): Promise<PaneInfo | null> {
|
|
84
|
+
const result = await pi.exec("tmux", [
|
|
85
|
+
"list-panes",
|
|
86
|
+
"-t",
|
|
87
|
+
requireWindowTarget(),
|
|
88
|
+
"-F",
|
|
89
|
+
"#{pane_id}\t#{@pi_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_dead}",
|
|
90
|
+
]);
|
|
91
|
+
if (result.code !== 0) return null;
|
|
92
|
+
|
|
93
|
+
for (const line of result.stdout.trim().split("\n")) {
|
|
94
|
+
const [paneId, paneName, command, pid, dead] = line.split("\t");
|
|
95
|
+
if (paneName === name) {
|
|
96
|
+
return { name: paneName, paneId, alive: dead !== "1", command, pid };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listAllPanes(): Promise<PaneInfo[]> {
|
|
103
|
+
const result = await pi.exec("tmux", [
|
|
104
|
+
"list-panes",
|
|
105
|
+
"-t",
|
|
106
|
+
requireWindowTarget(),
|
|
107
|
+
"-F",
|
|
108
|
+
"#{pane_id}\t#{@pi_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_dead}",
|
|
109
|
+
]);
|
|
110
|
+
if (result.code !== 0) return [];
|
|
111
|
+
|
|
112
|
+
const panes: PaneInfo[] = [];
|
|
113
|
+
for (const line of result.stdout.trim().split("\n")) {
|
|
114
|
+
if (!line.trim()) continue;
|
|
115
|
+
const [paneId, paneName, command, pid, dead] = line.split("\t");
|
|
116
|
+
// Skip pi's own pane
|
|
117
|
+
if (paneId === myPaneId) continue;
|
|
118
|
+
panes.push({
|
|
119
|
+
name: paneName?.trim() || "",
|
|
120
|
+
paneId,
|
|
121
|
+
alive: dead !== "1",
|
|
122
|
+
command,
|
|
123
|
+
pid,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return panes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function capturePane(paneId: string, lines: number): Promise<string> {
|
|
130
|
+
const result = await pi.exec("tmux", ["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
|
|
131
|
+
if (result.code !== 0) throw new Error(`capture-pane failed: ${result.stderr}`);
|
|
132
|
+
|
|
133
|
+
let output = stripAnsi(result.stdout);
|
|
134
|
+
// Trim trailing blank lines (tmux pads to pane height)
|
|
135
|
+
output = output.replace(/\n+$/, "\n");
|
|
136
|
+
return output;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- tool ---
|
|
140
|
+
|
|
141
|
+
pi.registerTool({
|
|
142
|
+
name: "tmux",
|
|
143
|
+
label: "tmux",
|
|
144
|
+
description:
|
|
145
|
+
"Manage tmux panes for long-running processes (dev servers, watchers, etc). " +
|
|
146
|
+
"Actions: run (start command in named pane), read (capture output), send (send keys like C-c), stop (kill pane), list (show panes).",
|
|
147
|
+
promptGuidelines: [
|
|
148
|
+
"Use `tmux` run for long-running processes (dev servers, watchers, builds) instead of `bash`.",
|
|
149
|
+
"Use `bash` only for short-lived commands that complete quickly.",
|
|
150
|
+
"Layout: pi runs on the left. Worker panes are created on the right, stacked vertically. First pane splits right from pi, additional panes automatically stack below existing ones.",
|
|
151
|
+
],
|
|
152
|
+
parameters: Type.Object({
|
|
153
|
+
action: StringEnum(["run", "read", "send", "stop", "list"] as const, {
|
|
154
|
+
description: "Action to perform",
|
|
155
|
+
}),
|
|
156
|
+
pane: Type.Optional(Type.String({ description: "Pane name (required for run/read/send/stop)" })),
|
|
157
|
+
command: Type.Optional(Type.String({ description: "Shell command to run (for run action)" })),
|
|
158
|
+
keys: Type.Optional(
|
|
159
|
+
Type.String({
|
|
160
|
+
description: "Keys to send, space-separated (for send action). Examples: C-c, Enter, q, y",
|
|
161
|
+
}),
|
|
162
|
+
),
|
|
163
|
+
text: Type.Optional(
|
|
164
|
+
Type.String({
|
|
165
|
+
description: "Literal text to type into the pane (for send action). Sent as-is, no key lookup.",
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
lines: Type.Optional(
|
|
169
|
+
Type.Number({ description: "Scrollback lines to capture (for read action, default: 20)" }),
|
|
170
|
+
),
|
|
171
|
+
restart: Type.Optional(
|
|
172
|
+
Type.Boolean({ description: "Kill existing pane before starting (for run action, default: false)" }),
|
|
173
|
+
),
|
|
174
|
+
cwd: Type.Optional(Type.String({ description: "Working directory (for run action)" })),
|
|
175
|
+
position: Type.Optional(
|
|
176
|
+
StringEnum(["right", "bottom"] as const, {
|
|
177
|
+
description: "Pane position (for run action, default: right)",
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
}),
|
|
181
|
+
|
|
182
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
183
|
+
if (!inTmux) {
|
|
184
|
+
throw new Error("Not running inside tmux.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { action } = params;
|
|
188
|
+
|
|
189
|
+
switch (action) {
|
|
190
|
+
case "run": {
|
|
191
|
+
const { pane, command, restart, cwd, position } = params;
|
|
192
|
+
if (!pane) throw new Error("'pane' is required for run");
|
|
193
|
+
if (!command) throw new Error("'command' is required for run");
|
|
194
|
+
|
|
195
|
+
const existing = await findPane(pane);
|
|
196
|
+
|
|
197
|
+
// Dead pane → always replace. Alive pane → error unless restart.
|
|
198
|
+
if (existing?.alive && !restart) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Pane '${pane}' already exists (running ${existing.command}). Use restart: true to replace it.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (existing) {
|
|
204
|
+
await pi.exec("tmux", ["kill-pane", "-t", existing.paneId]);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Layout: first pane splits right from pi, additional panes stack
|
|
208
|
+
// below existing panes (vertical stack on the right side).
|
|
209
|
+
// Explicit position overrides this behavior.
|
|
210
|
+
// Uses all panes (not just managed) for correct layout awareness.
|
|
211
|
+
const allOtherPanes = await listAllPanes();
|
|
212
|
+
let splitFlag: string;
|
|
213
|
+
let splitTarget: string | null = null;
|
|
214
|
+
|
|
215
|
+
if (position === "right") {
|
|
216
|
+
splitFlag = "-h";
|
|
217
|
+
} else if (position === "bottom") {
|
|
218
|
+
splitFlag = "-v";
|
|
219
|
+
} else if (allOtherPanes.length > 0) {
|
|
220
|
+
// Auto: stack below the last existing pane
|
|
221
|
+
splitFlag = "-v";
|
|
222
|
+
splitTarget = allOtherPanes[allOtherPanes.length - 1].paneId;
|
|
223
|
+
} else {
|
|
224
|
+
// Auto: first pane goes to the right of pi
|
|
225
|
+
splitFlag = "-h";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const splitArgs = ["split-window", "-d", splitFlag, "-P", "-F", "#{pane_id}"];
|
|
229
|
+
splitArgs.push("-t", splitTarget ?? myPaneId ?? requireWindowTarget());
|
|
230
|
+
if (cwd) splitArgs.push("-c", cwd);
|
|
231
|
+
|
|
232
|
+
const result = await pi.exec("tmux", splitArgs);
|
|
233
|
+
if (result.code !== 0) throw new Error(`split-window failed: ${result.stderr}`);
|
|
234
|
+
|
|
235
|
+
const newPaneId = result.stdout.trim();
|
|
236
|
+
|
|
237
|
+
// Tag with name
|
|
238
|
+
await pi.exec("tmux", ["set-option", "-p", "-t", newPaneId, "@pi_name", pane]);
|
|
239
|
+
|
|
240
|
+
// Send command (literal text + Enter)
|
|
241
|
+
await pi.exec("tmux", ["send-keys", "-l", "-t", newPaneId, command]);
|
|
242
|
+
await pi.exec("tmux", ["send-keys", "-t", newPaneId, "Enter"]);
|
|
243
|
+
|
|
244
|
+
// Wait briefly and capture initial output
|
|
245
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
246
|
+
const initialOutput = await capturePane(newPaneId, 20);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content: [
|
|
250
|
+
{
|
|
251
|
+
type: "text",
|
|
252
|
+
text: `Started '${command}' in pane '${pane}' (${newPaneId})\n\n${initialOutput}`,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
details: { action: "run", pane, paneId: newPaneId, command, position: position ?? "right" },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case "read": {
|
|
260
|
+
const { pane, lines } = params;
|
|
261
|
+
if (!pane) throw new Error("'pane' is required for read");
|
|
262
|
+
|
|
263
|
+
const existing = await findPane(pane);
|
|
264
|
+
if (!existing) throw new Error(`Pane '${pane}' not found. Use action 'list' to see managed panes.`);
|
|
265
|
+
|
|
266
|
+
const output = await capturePane(existing.paneId, lines ?? 20);
|
|
267
|
+
|
|
268
|
+
const truncation = truncateTail(output, {
|
|
269
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
270
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
let text = truncation.content;
|
|
274
|
+
if (truncation.truncated) {
|
|
275
|
+
text = `[Showing last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${text}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
content: [{ type: "text", text }],
|
|
280
|
+
details: { action: "read", pane, alive: existing.alive, command: existing.command },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case "send": {
|
|
285
|
+
const { pane, keys, text } = params;
|
|
286
|
+
if (!pane) throw new Error("'pane' is required for send");
|
|
287
|
+
if (!keys && !text) throw new Error("'keys' or 'text' is required for send");
|
|
288
|
+
|
|
289
|
+
const existing = await findPane(pane);
|
|
290
|
+
if (!existing) throw new Error(`Pane '${pane}' not found.`);
|
|
291
|
+
|
|
292
|
+
// Send literal text first (if provided)
|
|
293
|
+
if (text) {
|
|
294
|
+
await pi.exec("tmux", ["send-keys", "-l", "-t", existing.paneId, text]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Then send special keys (if provided)
|
|
298
|
+
if (keys) {
|
|
299
|
+
const keyArgs = keys.split(/\s+/).filter(Boolean);
|
|
300
|
+
await pi.exec("tmux", ["send-keys", "-t", existing.paneId, ...keyArgs]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const desc = [text && `"${text}"`, keys].filter(Boolean).join(" + ");
|
|
304
|
+
return {
|
|
305
|
+
content: [{ type: "text", text: `Sent ${desc} to pane '${pane}'` }],
|
|
306
|
+
details: { action: "send", pane, keys, text },
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case "stop": {
|
|
311
|
+
const { pane } = params;
|
|
312
|
+
if (!pane) throw new Error("'pane' is required for stop");
|
|
313
|
+
|
|
314
|
+
const existing = await findPane(pane);
|
|
315
|
+
if (!existing) throw new Error(`Pane '${pane}' not found.`);
|
|
316
|
+
|
|
317
|
+
if (existing.paneId === myPaneId) {
|
|
318
|
+
throw new Error("Refusing to kill the pane pi is running in.");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await pi.exec("tmux", ["kill-pane", "-t", existing.paneId]);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text", text: `Stopped pane '${pane}'` }],
|
|
325
|
+
details: { action: "stop", pane },
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
case "list": {
|
|
330
|
+
const panes = await listAllPanes();
|
|
331
|
+
|
|
332
|
+
if (panes.length === 0) {
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text: "No panes (besides pi)." }],
|
|
335
|
+
details: { action: "list", panes: [] },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const text = panes
|
|
340
|
+
.map((p) => {
|
|
341
|
+
const label = p.name || `[${p.command}]`;
|
|
342
|
+
const managed = p.name ? "" : " (unmanaged)";
|
|
343
|
+
return `${label}: ${p.alive ? "running" : "dead"} (${p.command}) [${p.paneId}]${managed}`;
|
|
344
|
+
})
|
|
345
|
+
.join("\n");
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
content: [{ type: "text", text }],
|
|
349
|
+
details: { action: "list", panes },
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
default:
|
|
354
|
+
throw new Error(`Unknown action: ${action}`);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// --- rendering ---
|
|
359
|
+
|
|
360
|
+
renderCall(args, theme) {
|
|
361
|
+
const action = args.action || "?";
|
|
362
|
+
let text = theme.fg("toolTitle", theme.bold("tmux "));
|
|
363
|
+
text += theme.fg("accent", action);
|
|
364
|
+
|
|
365
|
+
if (args.pane) text += theme.fg("muted", ` ${args.pane}`);
|
|
366
|
+
if (args.command) text += theme.fg("dim", ` › ${args.command}`);
|
|
367
|
+
if (args.text) text += theme.fg("dim", ` › "${args.text}"`);
|
|
368
|
+
if (args.keys) text += theme.fg("dim", ` › ${args.keys}`);
|
|
369
|
+
|
|
370
|
+
return new Text(text, 0, 0);
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
renderResult(result, { expanded }, theme) {
|
|
374
|
+
const details = result.details as Record<string, any> | undefined;
|
|
375
|
+
if (!details) {
|
|
376
|
+
const c = result.content?.[0];
|
|
377
|
+
return new Text(c?.type === "text" ? c.text : "", 0, 0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
switch (details.action) {
|
|
381
|
+
case "run": {
|
|
382
|
+
let t = theme.fg("success", `▶ ${details.pane}`);
|
|
383
|
+
t += theme.fg("dim", ` › ${details.command}`);
|
|
384
|
+
return new Text(t, 0, 0);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
case "read": {
|
|
388
|
+
const dot = details.alive ? theme.fg("success", "●") : theme.fg("error", "●");
|
|
389
|
+
let t = `${dot} ${theme.fg("accent", details.pane)}`;
|
|
390
|
+
|
|
391
|
+
if (expanded) {
|
|
392
|
+
const c = result.content?.[0];
|
|
393
|
+
if (c?.type === "text") {
|
|
394
|
+
const outputLines = c.text.split("\n").slice(0, 40);
|
|
395
|
+
t += "\n" + outputLines.map((l: string) => theme.fg("dim", l)).join("\n");
|
|
396
|
+
const total = c.text.split("\n").length;
|
|
397
|
+
if (total > 40) {
|
|
398
|
+
t += `\n${theme.fg("muted", `... (${total} total lines)`)}`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return new Text(t, 0, 0);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
case "send": {
|
|
406
|
+
const desc = [details.text && `"${details.text}"`, details.keys].filter(Boolean).join(" + ");
|
|
407
|
+
return new Text(theme.fg("accent", `⏎ ${details.pane} › ${desc}`), 0, 0);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case "stop": {
|
|
411
|
+
return new Text(theme.fg("warning", `■ ${details.pane}`), 0, 0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
case "list": {
|
|
415
|
+
const panes = details.panes as PaneInfo[];
|
|
416
|
+
if (!panes?.length) return new Text(theme.fg("dim", "no panes"), 0, 0);
|
|
417
|
+
|
|
418
|
+
const lines = panes.map((p) => {
|
|
419
|
+
const dot = p.alive ? theme.fg("success", "●") : theme.fg("error", "●");
|
|
420
|
+
const label = p.name
|
|
421
|
+
? theme.fg("accent", p.name)
|
|
422
|
+
: theme.fg("muted", `[${p.command}]`);
|
|
423
|
+
const extra = p.name ? "" : theme.fg("dim", " (unmanaged)");
|
|
424
|
+
return `${dot} ${label} ${theme.fg("dim", p.command)}${extra}`;
|
|
425
|
+
});
|
|
426
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
default: {
|
|
430
|
+
const c = result.content?.[0];
|
|
431
|
+
return new Text(c?.type === "text" ? c.text : "", 0, 0);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ogulcancelik/pi-tmux",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tmux pane management for pi. Run dev servers, watchers, and long-running processes in named panes without blocking the agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"tmux",
|
|
10
|
+
"terminal",
|
|
11
|
+
"pane-management",
|
|
12
|
+
"dev-server",
|
|
13
|
+
"long-running",
|
|
14
|
+
"coding-agent"
|
|
15
|
+
],
|
|
16
|
+
"author": "Can Celik",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ogulcancelik/pi-extensions.git",
|
|
21
|
+
"directory": "packages/pi-tmux"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/ogulcancelik/pi-extensions/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-tmux#readme",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./index.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.ts",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
]
|
|
40
|
+
}
|