@jx0/jmux 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/src/main.ts ADDED
@@ -0,0 +1,405 @@
1
+ import { $ } from "bun";
2
+ import { TmuxPty } from "./tmux-pty";
3
+ import { ScreenBridge } from "./screen-bridge";
4
+ import { Renderer } from "./renderer";
5
+ import { InputRouter } from "./input-router";
6
+ import { Sidebar } from "./sidebar";
7
+ import { TmuxControl, type ControlEvent } from "./tmux-control";
8
+ import type { SessionInfo } from "./types";
9
+ import { resolve, dirname } from "path";
10
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
11
+ import { homedir } from "os";
12
+
13
+ // --- CLI commands (run and exit before TUI) ---
14
+
15
+ if (process.argv.includes("--install-agent-hooks")) {
16
+ installAgentHooks();
17
+ process.exit(0);
18
+ }
19
+
20
+ function installAgentHooks(): void {
21
+ const claudeDir = resolve(homedir(), ".claude");
22
+ const settingsPath = resolve(claudeDir, "settings.json");
23
+
24
+ // Ensure .claude directory exists
25
+ if (!existsSync(claudeDir)) {
26
+ mkdirSync(claudeDir, { recursive: true });
27
+ }
28
+
29
+ // Read existing settings or start fresh
30
+ let settings: Record<string, any> = {};
31
+ if (existsSync(settingsPath)) {
32
+ try {
33
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
34
+ } catch {
35
+ console.error("Error: could not parse ~/.claude/settings.json");
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ // Check if hook already exists
41
+ const stopHooks = settings.hooks?.Stop;
42
+ if (stopHooks) {
43
+ const alreadyInstalled = stopHooks.some((entry: any) =>
44
+ entry.hooks?.some((h: any) => h.command?.includes("@jmux-attention")),
45
+ );
46
+ if (alreadyInstalled) {
47
+ console.log("jmux agent hooks are already installed.");
48
+ return;
49
+ }
50
+ }
51
+
52
+ // Add the hook
53
+ if (!settings.hooks) settings.hooks = {};
54
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
55
+
56
+ settings.hooks.Stop.push({
57
+ hooks: [
58
+ {
59
+ type: "command",
60
+ command: "tmux set-option @jmux-attention 1 2>/dev/null || true",
61
+ timeout: 5,
62
+ },
63
+ ],
64
+ });
65
+
66
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
67
+ console.log("Installed jmux agent hooks in ~/.claude/settings.json");
68
+ console.log("");
69
+ console.log("When Claude Code finishes a response, your jmux sidebar");
70
+ console.log("will show an orange ! on that session.");
71
+ }
72
+
73
+ // --- TUI startup ---
74
+
75
+ const SIDEBAR_WIDTH = 24;
76
+ const BORDER_WIDTH = 1;
77
+ const SIDEBAR_TOTAL = SIDEBAR_WIDTH + BORDER_WIDTH;
78
+
79
+ // Resolve paths relative to source
80
+ const jmuxDir = resolve(dirname(import.meta.dir));
81
+ const configFile = resolve(jmuxDir, "config", "tmux.conf");
82
+
83
+ // Parse args: jmux [session] [--socket name]
84
+ let sessionName: string | undefined;
85
+ let socketName: string | undefined;
86
+ for (let i = 2; i < process.argv.length; i++) {
87
+ if (process.argv[i] === "--socket" || process.argv[i] === "-L") {
88
+ socketName = process.argv[++i];
89
+ } else if (!sessionName && !process.argv[i].startsWith("-")) {
90
+ sessionName = process.argv[i];
91
+ }
92
+ }
93
+ const cols = process.stdout.columns || 80;
94
+ const rows = process.stdout.rows || 24;
95
+ const sidebarVisible = cols >= 80;
96
+ const mainCols = sidebarVisible ? cols - SIDEBAR_TOTAL : cols;
97
+
98
+ // Enter alternate screen, raw mode, enable mouse tracking
99
+ process.stdout.write("\x1b[?1049h");
100
+ process.stdout.write("\x1b[?1000h"); // mouse button tracking
101
+ process.stdout.write("\x1b[?1006h"); // SGR extended mouse mode
102
+ if (process.stdin.setRawMode) {
103
+ process.stdin.setRawMode(true);
104
+ }
105
+ process.stdin.resume();
106
+
107
+ // Core components
108
+ const pty = new TmuxPty({ sessionName, socketName, configFile, jmuxDir, cols: mainCols, rows });
109
+ const bridge = new ScreenBridge(mainCols, rows);
110
+ const renderer = new Renderer();
111
+ const sidebar = new Sidebar(SIDEBAR_WIDTH, rows);
112
+ const control = new TmuxControl();
113
+
114
+ let currentSessionId: string | null = null;
115
+ let ptyClientName: string | null = null;
116
+ let sidebarShown = sidebarVisible;
117
+ let currentSessions: SessionInfo[] = [];
118
+ const lastViewedTimestamps = new Map<string, number>();
119
+
120
+ function switchByOffset(offset: number): void {
121
+ const ids = sidebar.getDisplayOrderIds();
122
+ if (ids.length === 0) return;
123
+ const currentIdx = ids.indexOf(currentSessionId ?? "");
124
+ const base = currentIdx >= 0 ? currentIdx : 0;
125
+ const newIdx = (base + offset + ids.length) % ids.length;
126
+ switchSession(ids[newIdx]);
127
+ }
128
+
129
+ // --- Session data helpers ---
130
+
131
+ async function fetchSessions(): Promise<void> {
132
+ try {
133
+ const lines = await control.sendCommand(
134
+ "list-sessions -F '#{session_id}:#{session_name}:#{session_activity}:#{session_attached}:#{session_windows}'",
135
+ );
136
+ const sessions: SessionInfo[] = lines
137
+ .filter((l) => l.length > 0)
138
+ .map((line) => {
139
+ const [id, name, activity, attached, windows] = line.split(":");
140
+ return {
141
+ id,
142
+ name,
143
+ activity: parseInt(activity, 10) || 0,
144
+ attached: attached === "1",
145
+ attention: false,
146
+ windowCount: parseInt(windows, 10) || 1,
147
+ };
148
+ });
149
+ currentSessions = sessions;
150
+
151
+ // Mark sessions with activity since last viewed
152
+ for (const session of sessions) {
153
+ const lastViewed = lastViewedTimestamps.get(session.id) ?? 0;
154
+ if (session.activity > lastViewed && session.id !== currentSessionId) {
155
+ sidebar.setActivity(session.id, true);
156
+ }
157
+ }
158
+
159
+ sidebar.updateSessions(sessions);
160
+ renderFrame();
161
+
162
+ // Fire-and-forget git branch lookup (async, updates sidebar when done)
163
+ lookupSessionDetails(sessions);
164
+ } catch {
165
+ // tmux server may be shutting down
166
+ }
167
+ }
168
+
169
+ async function resolveClientName(): Promise<void> {
170
+ try {
171
+ const lines = await control.sendCommand(
172
+ "list-clients -F '#{client_name}:#{client_pid}:#{client_session}'",
173
+ );
174
+ const pid = pty.pid.toString();
175
+ for (const line of lines) {
176
+ const parts = line.split(":");
177
+ if (parts[1] === pid) {
178
+ ptyClientName = parts[0];
179
+ // Set the initial active session
180
+ const clientSessionName = parts[2];
181
+ if (clientSessionName) {
182
+ const match = currentSessions.find((s) => s.name === clientSessionName);
183
+ if (match) {
184
+ currentSessionId = match.id;
185
+ sidebar.setActiveSession(match.id);
186
+ }
187
+ }
188
+ return;
189
+ }
190
+ }
191
+ } catch {
192
+ // Retry on next session switch
193
+ }
194
+ }
195
+
196
+ async function switchSession(sessionId: string): Promise<void> {
197
+ if (!ptyClientName) await resolveClientName();
198
+ if (!ptyClientName) return;
199
+
200
+ try {
201
+ await control.sendCommand(
202
+ `switch-client -c ${ptyClientName} -t '${sessionId}'`,
203
+ );
204
+ lastViewedTimestamps.set(sessionId, Math.floor(Date.now() / 1000));
205
+ sidebar.setActivity(sessionId, false);
206
+ currentSessionId = sessionId;
207
+ sidebar.setActiveSession(sessionId);
208
+ renderFrame();
209
+
210
+ // Clear attention flag if set
211
+ try {
212
+ await control.sendCommand(
213
+ `set-option -t '${sessionId}' -u @jmux-attention`,
214
+ );
215
+ } catch {
216
+ // Option may not be set
217
+ }
218
+ } catch {
219
+ // Session may have been killed
220
+ }
221
+ }
222
+
223
+ // --- Rendering ---
224
+
225
+ let renderTimer: ReturnType<typeof setTimeout> | null = null;
226
+
227
+ function renderFrame(): void {
228
+ const grid = bridge.getGrid();
229
+ const cursor = bridge.getCursor();
230
+ renderer.render(grid, cursor, sidebarShown ? sidebar.getGrid() : null);
231
+ }
232
+
233
+ function scheduleRender(): void {
234
+ if (renderTimer !== null) return;
235
+ renderTimer = setTimeout(() => {
236
+ renderTimer = null;
237
+ renderFrame();
238
+ }, 16); // ~60fps cap
239
+ }
240
+
241
+ // --- Input Router ---
242
+
243
+ const inputRouter = new InputRouter(
244
+ {
245
+ sidebarCols: SIDEBAR_WIDTH,
246
+ onPtyData: (data) => pty.write(data),
247
+ onSidebarClick: (row) => {
248
+ const session = sidebar.getSessionByRow(row);
249
+ if (session) switchSession(session.id);
250
+ },
251
+ onSessionPrev: () => switchByOffset(-1),
252
+ onSessionNext: () => switchByOffset(1),
253
+ },
254
+ sidebarShown,
255
+ );
256
+
257
+ // --- PTY output pipeline ---
258
+
259
+ let writesPending = 0;
260
+
261
+ pty.onData((data: string) => {
262
+ writesPending++;
263
+ bridge.write(data).then(() => {
264
+ writesPending--;
265
+ if (writesPending === 0) {
266
+ scheduleRender();
267
+ }
268
+ });
269
+ });
270
+
271
+ // --- Stdin ---
272
+
273
+ process.stdin.on("data", (data: Buffer) => {
274
+ inputRouter.handleInput(data.toString());
275
+ });
276
+
277
+ // --- Resize ---
278
+
279
+ process.on("SIGWINCH", () => {
280
+ const newCols = process.stdout.columns || 80;
281
+ const newRows = process.stdout.rows || 24;
282
+ const newSidebarVisible = newCols >= 80;
283
+ const newMainCols = newSidebarVisible ? newCols - SIDEBAR_TOTAL : newCols;
284
+
285
+ sidebarShown = newSidebarVisible;
286
+ inputRouter.setSidebarVisible(newSidebarVisible);
287
+ pty.resize(newMainCols, newRows);
288
+ bridge.resize(newMainCols, newRows);
289
+ sidebar.resize(SIDEBAR_WIDTH, newRows);
290
+ });
291
+
292
+ // --- Control mode events ---
293
+
294
+ control.onEvent((event: ControlEvent) => {
295
+ switch (event.type) {
296
+ case "sessions-changed":
297
+ fetchSessions();
298
+ break;
299
+ case "session-changed":
300
+ currentSessionId = event.args;
301
+ sidebar.setActiveSession(event.args);
302
+ renderFrame();
303
+ break;
304
+ case "client-session-changed":
305
+ // A client (possibly our PTY client) switched sessions — re-resolve
306
+ resolveClientName().then(() => renderFrame());
307
+ break;
308
+ case "subscription-changed":
309
+ if (event.name === "attention") {
310
+ const pairs = event.value.trim().split(/\s+/);
311
+ for (const pair of pairs) {
312
+ const eqIdx = pair.indexOf("=");
313
+ if (eqIdx === -1) continue;
314
+ const id = pair.slice(0, eqIdx);
315
+ const val = pair.slice(eqIdx + 1);
316
+ if (val === "1") {
317
+ sidebar.setActivity(id, false);
318
+ }
319
+ }
320
+ fetchSessions();
321
+ }
322
+ break;
323
+ }
324
+ });
325
+
326
+ // --- Git branch lookup ---
327
+
328
+ async function lookupSessionDetails(sessions: SessionInfo[]): Promise<void> {
329
+ const home = process.env.HOME || "";
330
+ for (const session of sessions) {
331
+ try {
332
+ // Use control mode connection — respects -L socket and -f config
333
+ const lines = await control.sendCommand(
334
+ `display-message -t '${session.id}' -p '#{pane_current_path}'`,
335
+ );
336
+ const cwd = (lines[0] || "").trim();
337
+ if (!cwd) continue;
338
+ session.directory = cwd.startsWith(home)
339
+ ? "~" + cwd.slice(home.length)
340
+ : cwd;
341
+ const branch = await $`git -C ${cwd} branch --show-current`
342
+ .text()
343
+ .catch(() => "");
344
+ session.gitBranch = branch.trim() || undefined;
345
+ } catch {
346
+ // Session may not exist or no git repo
347
+ }
348
+ }
349
+ sidebar.updateSessions(sessions);
350
+ renderFrame();
351
+ }
352
+
353
+ // --- Startup sequence ---
354
+
355
+ async function start(): Promise<void> {
356
+ // Wait for first PTY data (tmux is ready) using a one-shot flag
357
+ await new Promise<void>((resolve) => {
358
+ let resolved = false;
359
+ pty.onData(function firstData() {
360
+ if (!resolved) {
361
+ resolved = true;
362
+ resolve();
363
+ }
364
+ });
365
+ });
366
+
367
+ // Start control mode
368
+ await control.start({ socketName, configFile });
369
+
370
+ // Prefix is C-a — hardcoded since we ship our own tmux.conf
371
+
372
+ // Fetch initial sessions, then resolve client name (needs sessions list)
373
+ await fetchSessions();
374
+ await resolveClientName();
375
+ renderFrame();
376
+
377
+ // Subscribe to @jmux-attention across all sessions
378
+ await control.registerSubscription(
379
+ "attention",
380
+ 1,
381
+ "#{S:#{session_id}=#{@jmux-attention} }",
382
+ );
383
+ }
384
+
385
+ // --- Cleanup ---
386
+
387
+ function cleanup(): void {
388
+ control.close().catch(() => {});
389
+ process.stdout.write("\x1b[?1000l"); // disable mouse button tracking
390
+ process.stdout.write("\x1b[?1006l"); // disable SGR mouse mode
391
+ process.stdout.write("\x1b[?25h");
392
+ process.stdout.write("\x1b[?1049l");
393
+ if (process.stdin.setRawMode) {
394
+ process.stdin.setRawMode(false);
395
+ }
396
+ process.exit(0);
397
+ }
398
+
399
+ pty.onExit(() => cleanup());
400
+ process.on("SIGINT", () => cleanup());
401
+ process.on("SIGTERM", () => cleanup());
402
+
403
+ // --- Go ---
404
+
405
+ start().catch(() => cleanup());
@@ -0,0 +1,132 @@
1
+ import type { Cell, CellGrid, CursorPosition } from "./types";
2
+ import { ColorMode } from "./types";
3
+ import { createGrid, DEFAULT_CELL } from "./cell-grid";
4
+
5
+ export const BORDER_CHAR = "\u2502"; // │
6
+
7
+ export function sgrForCell(cell: Cell): string {
8
+ const parts: string[] = ["0"]; // always reset first
9
+
10
+ if (cell.bold) parts.push("1");
11
+ if (cell.dim) parts.push("2");
12
+ if (cell.italic) parts.push("3");
13
+ if (cell.underline) parts.push("4");
14
+
15
+ // Foreground
16
+ if (cell.fgMode === ColorMode.Palette) {
17
+ if (cell.fg < 8) {
18
+ parts.push(`${30 + cell.fg}`);
19
+ } else if (cell.fg < 16) {
20
+ parts.push(`${90 + cell.fg - 8}`);
21
+ } else {
22
+ parts.push(`38;5;${cell.fg}`);
23
+ }
24
+ } else if (cell.fgMode === ColorMode.RGB) {
25
+ const r = (cell.fg >> 16) & 0xff;
26
+ const g = (cell.fg >> 8) & 0xff;
27
+ const b = cell.fg & 0xff;
28
+ parts.push(`38;2;${r};${g};${b}`);
29
+ }
30
+
31
+ // Background
32
+ if (cell.bgMode === ColorMode.Palette) {
33
+ if (cell.bg < 8) {
34
+ parts.push(`${40 + cell.bg}`);
35
+ } else if (cell.bg < 16) {
36
+ parts.push(`${100 + cell.bg - 8}`);
37
+ } else {
38
+ parts.push(`48;5;${cell.bg}`);
39
+ }
40
+ } else if (cell.bgMode === ColorMode.RGB) {
41
+ const r = (cell.bg >> 16) & 0xff;
42
+ const g = (cell.bg >> 8) & 0xff;
43
+ const b = cell.bg & 0xff;
44
+ parts.push(`48;2;${r};${g};${b}`);
45
+ }
46
+
47
+ return `\x1b[${parts.join(";")}m`;
48
+ }
49
+
50
+ export function compositeGrids(
51
+ main: CellGrid,
52
+ sidebar: CellGrid | null,
53
+ ): CellGrid {
54
+ if (!sidebar) return main;
55
+
56
+ const totalCols = sidebar.cols + 1 + main.cols;
57
+ const rows = main.rows;
58
+ const grid = createGrid(totalCols, rows);
59
+
60
+ for (let y = 0; y < rows; y++) {
61
+ // Copy sidebar cells
62
+ for (let x = 0; x < sidebar.cols && x < sidebar.cells[y]?.length; x++) {
63
+ grid.cells[y][x] = { ...sidebar.cells[y][x] };
64
+ }
65
+ // Border column
66
+ const borderCol = sidebar.cols;
67
+ grid.cells[y][borderCol] = {
68
+ ...DEFAULT_CELL,
69
+ char: BORDER_CHAR,
70
+ dim: true,
71
+ };
72
+ // Copy main cells
73
+ for (let x = 0; x < main.cols; x++) {
74
+ grid.cells[y][borderCol + 1 + x] = { ...main.cells[y][x] };
75
+ }
76
+ }
77
+
78
+ return grid;
79
+ }
80
+
81
+ function cellsEqual(a: Cell, b: Cell): boolean {
82
+ return (
83
+ a.fg === b.fg &&
84
+ a.bg === b.bg &&
85
+ a.fgMode === b.fgMode &&
86
+ a.bgMode === b.bgMode &&
87
+ a.bold === b.bold &&
88
+ a.dim === b.dim &&
89
+ a.italic === b.italic &&
90
+ a.underline === b.underline
91
+ );
92
+ }
93
+
94
+ export class Renderer {
95
+ private prevAttrs: Cell | null = null;
96
+
97
+ render(
98
+ main: CellGrid,
99
+ cursor: CursorPosition,
100
+ sidebar: CellGrid | null,
101
+ ): void {
102
+ const grid = compositeGrids(main, sidebar);
103
+ const cursorOffset = sidebar ? sidebar.cols + 1 : 0;
104
+ const buf: string[] = [];
105
+
106
+ for (let y = 0; y < grid.rows; y++) {
107
+ // Move to start of row (1-indexed)
108
+ buf.push(`\x1b[${y + 1};1H`);
109
+ this.prevAttrs = null;
110
+
111
+ for (let x = 0; x < grid.cols; x++) {
112
+ const cell = grid.cells[y][x];
113
+
114
+ // Emit SGR only when attributes change
115
+ if (!this.prevAttrs || !cellsEqual(this.prevAttrs, cell)) {
116
+ buf.push(sgrForCell(cell));
117
+ this.prevAttrs = cell;
118
+ }
119
+
120
+ buf.push(cell.char);
121
+ }
122
+ }
123
+
124
+ // Reset attributes, position cursor
125
+ buf.push("\x1b[0m");
126
+ buf.push(
127
+ `\x1b[${cursor.y + 1};${cursor.x + cursorOffset + 1}H`,
128
+ );
129
+
130
+ process.stdout.write(buf.join(""));
131
+ }
132
+ }
@@ -0,0 +1,70 @@
1
+ import { Terminal } from "@xterm/headless";
2
+ import type { Cell, CellGrid, CursorPosition } from "./types";
3
+ import { ColorMode } from "./types";
4
+ import { createGrid, DEFAULT_CELL } from "./cell-grid";
5
+
6
+ export class ScreenBridge {
7
+ private terminal: Terminal;
8
+
9
+ constructor(cols: number, rows: number) {
10
+ this.terminal = new Terminal({
11
+ cols,
12
+ rows,
13
+ scrollback: 0,
14
+ allowProposedApi: true,
15
+ });
16
+ }
17
+
18
+ write(data: string): Promise<void> {
19
+ return new Promise((resolve) => {
20
+ this.terminal.write(data, resolve);
21
+ });
22
+ }
23
+
24
+ getGrid(): CellGrid {
25
+ const cols = this.terminal.cols;
26
+ const rows = this.terminal.rows;
27
+ const grid = createGrid(cols, rows);
28
+ const buffer = this.terminal.buffer.active;
29
+
30
+ for (let y = 0; y < rows; y++) {
31
+ const line = buffer.getLine(y);
32
+ if (!line) continue;
33
+ for (let x = 0; x < cols; x++) {
34
+ const xtermCell = line.getCell(x);
35
+ if (!xtermCell) continue;
36
+
37
+ const cell = grid.cells[y][x];
38
+ const chars = xtermCell.getChars();
39
+ cell.char = chars || " ";
40
+ cell.fg = xtermCell.getFgColor();
41
+ cell.bg = xtermCell.getBgColor();
42
+ cell.fgMode = xtermCell.isFgRGB()
43
+ ? ColorMode.RGB
44
+ : xtermCell.isFgPalette()
45
+ ? ColorMode.Palette
46
+ : ColorMode.Default;
47
+ cell.bgMode = xtermCell.isBgRGB()
48
+ ? ColorMode.RGB
49
+ : xtermCell.isBgPalette()
50
+ ? ColorMode.Palette
51
+ : ColorMode.Default;
52
+ cell.bold = xtermCell.isBold() !== 0;
53
+ cell.italic = xtermCell.isItalic() !== 0;
54
+ cell.underline = xtermCell.isUnderline() !== 0;
55
+ cell.dim = xtermCell.isDim() !== 0;
56
+ }
57
+ }
58
+
59
+ return grid;
60
+ }
61
+
62
+ getCursor(): CursorPosition {
63
+ const buffer = this.terminal.buffer.active;
64
+ return { x: buffer.cursorX, y: buffer.cursorY };
65
+ }
66
+
67
+ resize(cols: number, rows: number): void {
68
+ this.terminal.resize(cols, rows);
69
+ }
70
+ }