@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/LICENSE +21 -0
- package/README.md +219 -0
- package/bin/jmux +2 -0
- package/config/new-session.sh +69 -0
- package/config/tmux.conf +115 -0
- package/package.json +42 -0
- package/src/__tests__/cell-grid.test.ts +75 -0
- package/src/__tests__/input-router.test.ts +113 -0
- package/src/__tests__/renderer.test.ts +112 -0
- package/src/__tests__/screen-bridge.test.ts +61 -0
- package/src/__tests__/sidebar.test.ts +237 -0
- package/src/__tests__/tmux-control.test.ts +142 -0
- package/src/cell-grid.ts +63 -0
- package/src/input-router.ts +85 -0
- package/src/main.ts +405 -0
- package/src/renderer.ts +132 -0
- package/src/screen-bridge.ts +70 -0
- package/src/sidebar.ts +295 -0
- package/src/tmux-control.ts +223 -0
- package/src/tmux-pty.ts +80 -0
- package/src/types.ts +39 -0
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());
|
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|