@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/sidebar.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { CellGrid, SessionInfo } from "./types";
|
|
2
|
+
import { ColorMode } from "./types";
|
|
3
|
+
import { createGrid, writeString, type CellAttrs } from "./cell-grid";
|
|
4
|
+
|
|
5
|
+
const HEADER_ROWS = 2; // "jmux" header + separator
|
|
6
|
+
|
|
7
|
+
const DIM_ATTRS: CellAttrs = { dim: true };
|
|
8
|
+
const ACCENT_ATTRS: CellAttrs = {
|
|
9
|
+
fg: 2,
|
|
10
|
+
fgMode: ColorMode.Palette,
|
|
11
|
+
};
|
|
12
|
+
const ACTIVE_MARKER_ATTRS: CellAttrs = {
|
|
13
|
+
fg: 2,
|
|
14
|
+
fgMode: ColorMode.Palette,
|
|
15
|
+
bold: true,
|
|
16
|
+
};
|
|
17
|
+
const ACTIVITY_ATTRS: CellAttrs = {
|
|
18
|
+
fg: 2,
|
|
19
|
+
fgMode: ColorMode.Palette,
|
|
20
|
+
};
|
|
21
|
+
const ATTENTION_ATTRS: CellAttrs = {
|
|
22
|
+
fg: 3,
|
|
23
|
+
fgMode: ColorMode.Palette,
|
|
24
|
+
bold: true,
|
|
25
|
+
};
|
|
26
|
+
const ACTIVE_NAME_ATTRS: CellAttrs = {
|
|
27
|
+
fg: 15,
|
|
28
|
+
fgMode: ColorMode.Palette,
|
|
29
|
+
bold: true,
|
|
30
|
+
};
|
|
31
|
+
const INACTIVE_NAME_ATTRS: CellAttrs = {
|
|
32
|
+
fg: 7,
|
|
33
|
+
fgMode: ColorMode.Palette,
|
|
34
|
+
};
|
|
35
|
+
const GROUP_HEADER_ATTRS: CellAttrs = {
|
|
36
|
+
fg: 8,
|
|
37
|
+
fgMode: ColorMode.Palette,
|
|
38
|
+
bold: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// --- Grouping logic ---
|
|
42
|
+
|
|
43
|
+
interface SessionGroup {
|
|
44
|
+
label: string;
|
|
45
|
+
sessionIndices: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getParentLabel(dir: string): string | null {
|
|
49
|
+
const lastSlash = dir.lastIndexOf("/");
|
|
50
|
+
if (lastSlash <= 0) return null;
|
|
51
|
+
const parent = dir.slice(0, lastSlash);
|
|
52
|
+
const segments = parent.split("/").filter((s) => s.length > 0);
|
|
53
|
+
if (segments.length === 0) return null;
|
|
54
|
+
if (segments[0] === "~" && segments.length === 1) return null;
|
|
55
|
+
return segments.slice(-2).join("/");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type RenderItem =
|
|
59
|
+
| { type: "group-header"; label: string }
|
|
60
|
+
| { type: "session"; sessionIndex: number; grouped: boolean }
|
|
61
|
+
| { type: "spacer" };
|
|
62
|
+
|
|
63
|
+
function buildRenderPlan(sessions: SessionInfo[]): {
|
|
64
|
+
items: RenderItem[];
|
|
65
|
+
displayOrder: number[];
|
|
66
|
+
} {
|
|
67
|
+
const groupMap = new Map<string, number[]>();
|
|
68
|
+
const ungrouped: number[] = [];
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < sessions.length; i++) {
|
|
71
|
+
const dir = sessions[i].directory;
|
|
72
|
+
if (!dir) {
|
|
73
|
+
ungrouped.push(i);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const label = getParentLabel(dir);
|
|
77
|
+
if (!label) {
|
|
78
|
+
ungrouped.push(i);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const existing = groupMap.get(label);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.push(i);
|
|
84
|
+
} else {
|
|
85
|
+
groupMap.set(label, [i]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [label, indices] of groupMap) {
|
|
90
|
+
if (indices.length === 1) {
|
|
91
|
+
ungrouped.push(indices[0]);
|
|
92
|
+
groupMap.delete(label);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sortedGroups: SessionGroup[] = [...groupMap.entries()]
|
|
97
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
98
|
+
.map(([label, indices]) => ({
|
|
99
|
+
label,
|
|
100
|
+
sessionIndices: indices.sort((a, b) =>
|
|
101
|
+
sessions[a].name.localeCompare(sessions[b].name),
|
|
102
|
+
),
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
ungrouped.sort((a, b) => sessions[a].name.localeCompare(sessions[b].name));
|
|
106
|
+
|
|
107
|
+
const items: RenderItem[] = [];
|
|
108
|
+
const displayOrder: number[] = [];
|
|
109
|
+
|
|
110
|
+
for (const group of sortedGroups) {
|
|
111
|
+
items.push({ type: "group-header", label: group.label });
|
|
112
|
+
for (const idx of group.sessionIndices) {
|
|
113
|
+
items.push({ type: "session", sessionIndex: idx, grouped: true });
|
|
114
|
+
displayOrder.push(idx);
|
|
115
|
+
items.push({ type: "spacer" });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const idx of ungrouped) {
|
|
120
|
+
items.push({ type: "session", sessionIndex: idx, grouped: false });
|
|
121
|
+
displayOrder.push(idx);
|
|
122
|
+
items.push({ type: "spacer" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { items, displayOrder };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Sidebar class ---
|
|
129
|
+
|
|
130
|
+
export class Sidebar {
|
|
131
|
+
private width: number;
|
|
132
|
+
private height: number;
|
|
133
|
+
private sessions: SessionInfo[] = [];
|
|
134
|
+
private activeSessionId: string | null = null;
|
|
135
|
+
private displayOrder: number[] = [];
|
|
136
|
+
private rowToSessionIndex = new Map<number, number>();
|
|
137
|
+
private activitySet = new Set<string>();
|
|
138
|
+
|
|
139
|
+
constructor(width: number, height: number) {
|
|
140
|
+
this.width = width;
|
|
141
|
+
this.height = height;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
updateSessions(sessions: SessionInfo[]): void {
|
|
145
|
+
this.sessions = sessions;
|
|
146
|
+
const { displayOrder } = buildRenderPlan(sessions);
|
|
147
|
+
this.displayOrder = displayOrder;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setActiveSession(id: string): void {
|
|
151
|
+
this.activeSessionId = id;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
setActivity(sessionId: string, active: boolean): void {
|
|
155
|
+
if (active) {
|
|
156
|
+
this.activitySet.add(sessionId);
|
|
157
|
+
} else {
|
|
158
|
+
this.activitySet.delete(sessionId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getDisplayOrderIds(): string[] {
|
|
163
|
+
return this.displayOrder
|
|
164
|
+
.map((idx) => this.sessions[idx]?.id)
|
|
165
|
+
.filter(Boolean) as string[];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getSessionByRow(row: number): SessionInfo | null {
|
|
169
|
+
const sessionIdx = this.rowToSessionIndex.get(row);
|
|
170
|
+
if (sessionIdx === undefined) return null;
|
|
171
|
+
return this.sessions[sessionIdx] ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
resize(width: number, height: number): void {
|
|
175
|
+
this.width = width;
|
|
176
|
+
this.height = height;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getGrid(): CellGrid {
|
|
180
|
+
const grid = createGrid(this.width, this.height);
|
|
181
|
+
this.rowToSessionIndex.clear();
|
|
182
|
+
|
|
183
|
+
// Header
|
|
184
|
+
writeString(grid, 0, 1, "jmux", { ...ACCENT_ATTRS, bold: true });
|
|
185
|
+
writeString(grid, 1, 0, "\u2500".repeat(this.width), DIM_ATTRS);
|
|
186
|
+
|
|
187
|
+
const { items } = buildRenderPlan(this.sessions);
|
|
188
|
+
let row = HEADER_ROWS;
|
|
189
|
+
|
|
190
|
+
for (const item of items) {
|
|
191
|
+
if (row >= this.height) break;
|
|
192
|
+
|
|
193
|
+
if (item.type === "group-header") {
|
|
194
|
+
let label = item.label;
|
|
195
|
+
if (label.length > this.width - 2) {
|
|
196
|
+
label = label.slice(0, this.width - 3) + "\u2026";
|
|
197
|
+
}
|
|
198
|
+
writeString(grid, row, 1, label, GROUP_HEADER_ATTRS);
|
|
199
|
+
row++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (item.type === "spacer") {
|
|
204
|
+
row++;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sessionIdx = item.sessionIndex;
|
|
209
|
+
const session = this.sessions[sessionIdx];
|
|
210
|
+
if (!session) continue;
|
|
211
|
+
|
|
212
|
+
const nameRow = row;
|
|
213
|
+
const detailRow = row + 1;
|
|
214
|
+
const isActive = session.id === this.activeSessionId;
|
|
215
|
+
const hasActivity = this.activitySet.has(session.id);
|
|
216
|
+
|
|
217
|
+
// Map rows to session for click handling
|
|
218
|
+
this.rowToSessionIndex.set(nameRow, sessionIdx);
|
|
219
|
+
if (detailRow < this.height) {
|
|
220
|
+
this.rowToSessionIndex.set(detailRow, sessionIdx);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Active marker
|
|
224
|
+
if (isActive) {
|
|
225
|
+
writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
226
|
+
if (detailRow < this.height) {
|
|
227
|
+
writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Indicator
|
|
232
|
+
if (session.attention) {
|
|
233
|
+
writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
|
|
234
|
+
} else if (hasActivity) {
|
|
235
|
+
writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Window count right-aligned
|
|
239
|
+
const windowCountStr = `${session.windowCount}w`;
|
|
240
|
+
const windowCountCol = this.width - windowCountStr.length - 1;
|
|
241
|
+
|
|
242
|
+
// Session name
|
|
243
|
+
const nameStart = 3;
|
|
244
|
+
const nameMaxLen = windowCountCol - 1 - nameStart;
|
|
245
|
+
let displayName = session.name;
|
|
246
|
+
if (displayName.length > nameMaxLen) {
|
|
247
|
+
displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const nameAttrs: CellAttrs = isActive
|
|
251
|
+
? { ...ACTIVE_NAME_ATTRS }
|
|
252
|
+
: { ...INACTIVE_NAME_ATTRS };
|
|
253
|
+
writeString(grid, nameRow, nameStart, displayName, nameAttrs);
|
|
254
|
+
|
|
255
|
+
if (windowCountCol > nameStart) {
|
|
256
|
+
writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Detail line
|
|
260
|
+
if (detailRow < this.height) {
|
|
261
|
+
const detailStart = 3;
|
|
262
|
+
|
|
263
|
+
if (item.grouped) {
|
|
264
|
+
if (session.gitBranch) {
|
|
265
|
+
const branchCol = this.width - session.gitBranch.length - 1;
|
|
266
|
+
if (branchCol > detailStart) {
|
|
267
|
+
writeString(grid, detailRow, branchCol, session.gitBranch, DIM_ATTRS);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
let branchCols = 0;
|
|
272
|
+
if (session.gitBranch) {
|
|
273
|
+
const branchCol = this.width - session.gitBranch.length - 1;
|
|
274
|
+
if (branchCol > detailStart + 1) {
|
|
275
|
+
writeString(grid, detailRow, branchCol, session.gitBranch, DIM_ATTRS);
|
|
276
|
+
branchCols = session.gitBranch.length + 2;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (session.directory !== undefined) {
|
|
280
|
+
const dirMaxLen = this.width - detailStart - branchCols - 1;
|
|
281
|
+
let displayDir = session.directory;
|
|
282
|
+
if (displayDir.length > dirMaxLen) {
|
|
283
|
+
displayDir = displayDir.slice(0, dirMaxLen - 1) + "\u2026";
|
|
284
|
+
}
|
|
285
|
+
writeString(grid, detailRow, detailStart, displayDir, DIM_ATTRS);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
row += 2;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return grid;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
|
|
3
|
+
// --- Protocol Parser (unit-testable) ---
|
|
4
|
+
|
|
5
|
+
export type ControlEvent =
|
|
6
|
+
| { type: "sessions-changed" }
|
|
7
|
+
| { type: "session-changed"; args: string }
|
|
8
|
+
| { type: "window-renamed"; args: string }
|
|
9
|
+
| { type: "client-session-changed"; args: string }
|
|
10
|
+
| {
|
|
11
|
+
type: "subscription-changed";
|
|
12
|
+
name: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
type: "response";
|
|
17
|
+
commandNumber: number;
|
|
18
|
+
flags: number;
|
|
19
|
+
lines: string[];
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
type: "error";
|
|
23
|
+
commandNumber: number;
|
|
24
|
+
flags: number;
|
|
25
|
+
lines: string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type EventListener = (event: ControlEvent) => void;
|
|
29
|
+
|
|
30
|
+
export class ControlParser {
|
|
31
|
+
private buffer = "";
|
|
32
|
+
private listeners: EventListener[] = [];
|
|
33
|
+
private inBlock = false;
|
|
34
|
+
private blockCommandNumber = 0;
|
|
35
|
+
private blockFlags = 0;
|
|
36
|
+
private blockLines: string[] = [];
|
|
37
|
+
|
|
38
|
+
onEvent(listener: EventListener): void {
|
|
39
|
+
this.listeners.push(listener);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
feed(data: string): void {
|
|
43
|
+
this.buffer += data;
|
|
44
|
+
let newlineIdx: number;
|
|
45
|
+
while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
|
|
46
|
+
const line = this.buffer.slice(0, newlineIdx);
|
|
47
|
+
this.buffer = this.buffer.slice(newlineIdx + 1);
|
|
48
|
+
this.processLine(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private processLine(line: string): void {
|
|
53
|
+
if (this.inBlock) {
|
|
54
|
+
if (line.startsWith("%end ") || line.startsWith("%error ")) {
|
|
55
|
+
const isError = line.startsWith("%error ");
|
|
56
|
+
this.inBlock = false;
|
|
57
|
+
this.emit({
|
|
58
|
+
type: isError ? "error" : "response",
|
|
59
|
+
commandNumber: this.blockCommandNumber,
|
|
60
|
+
flags: this.blockFlags,
|
|
61
|
+
lines: this.blockLines,
|
|
62
|
+
});
|
|
63
|
+
this.blockLines = [];
|
|
64
|
+
} else {
|
|
65
|
+
this.blockLines.push(line);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (line.startsWith("%begin ")) {
|
|
71
|
+
const parts = line.split(" ");
|
|
72
|
+
this.blockCommandNumber = parseInt(parts[2], 10);
|
|
73
|
+
this.blockFlags = parseInt(parts[3], 10) || 0;
|
|
74
|
+
this.inBlock = true;
|
|
75
|
+
this.blockLines = [];
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (line === "%sessions-changed") {
|
|
80
|
+
this.emit({ type: "sessions-changed" });
|
|
81
|
+
} else if (line.startsWith("%session-changed ")) {
|
|
82
|
+
this.emit({
|
|
83
|
+
type: "session-changed",
|
|
84
|
+
args: line.slice("%session-changed ".length),
|
|
85
|
+
});
|
|
86
|
+
} else if (line.startsWith("%window-renamed ")) {
|
|
87
|
+
this.emit({
|
|
88
|
+
type: "window-renamed",
|
|
89
|
+
args: line.slice("%window-renamed ".length),
|
|
90
|
+
});
|
|
91
|
+
} else if (line.startsWith("%client-session-changed ")) {
|
|
92
|
+
this.emit({
|
|
93
|
+
type: "client-session-changed",
|
|
94
|
+
args: line.slice("%client-session-changed ".length),
|
|
95
|
+
});
|
|
96
|
+
} else if (line.startsWith("%subscription-changed ")) {
|
|
97
|
+
const rest = line.slice("%subscription-changed ".length);
|
|
98
|
+
const spaceIdx = rest.indexOf(" ");
|
|
99
|
+
if (spaceIdx === -1) {
|
|
100
|
+
this.emit({
|
|
101
|
+
type: "subscription-changed",
|
|
102
|
+
name: rest,
|
|
103
|
+
value: "",
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
this.emit({
|
|
107
|
+
type: "subscription-changed",
|
|
108
|
+
name: rest.slice(0, spaceIdx),
|
|
109
|
+
value: rest.slice(spaceIdx + 1),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Ignore unknown % lines (e.g. %output if not suppressed)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private emit(event: ControlEvent): void {
|
|
117
|
+
for (const listener of this.listeners) {
|
|
118
|
+
listener(event);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Control Client (subprocess management) ---
|
|
124
|
+
|
|
125
|
+
export class TmuxControl {
|
|
126
|
+
private proc: Subprocess | null = null;
|
|
127
|
+
private parser = new ControlParser();
|
|
128
|
+
// FIFO queue — tmux command numbers are global server counters,
|
|
129
|
+
// not sequential from 0. We match responses in order instead.
|
|
130
|
+
private pendingQueue: Array<{
|
|
131
|
+
resolve: (lines: string[]) => void;
|
|
132
|
+
reject: (err: Error) => void;
|
|
133
|
+
}> = [];
|
|
134
|
+
|
|
135
|
+
constructor() {
|
|
136
|
+
this.parser.onEvent((event) => {
|
|
137
|
+
if (event.type === "response" || event.type === "error") {
|
|
138
|
+
// flags=1 means this response is for a command sent by THIS client.
|
|
139
|
+
// flags=0 means it's from the initial attach or another client — skip it.
|
|
140
|
+
if (event.flags !== 1) return;
|
|
141
|
+
|
|
142
|
+
const pending = this.pendingQueue.shift();
|
|
143
|
+
if (pending) {
|
|
144
|
+
if (event.type === "error") {
|
|
145
|
+
pending.reject(new Error(event.lines.join("\n")));
|
|
146
|
+
} else {
|
|
147
|
+
pending.resolve(event.lines);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
onEvent(listener: EventListener): void {
|
|
155
|
+
this.parser.onEvent(listener);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async start(opts?: { socketName?: string; configFile?: string }): Promise<void> {
|
|
159
|
+
const args = ["tmux"];
|
|
160
|
+
if (opts?.configFile) args.push("-f", opts.configFile);
|
|
161
|
+
if (opts?.socketName) args.push("-L", opts.socketName);
|
|
162
|
+
args.push("-C", "attach");
|
|
163
|
+
this.proc = Bun.spawn(args, {
|
|
164
|
+
stdin: "pipe",
|
|
165
|
+
stdout: "pipe",
|
|
166
|
+
stderr: "ignore",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Read stdout in background
|
|
170
|
+
this.readOutput();
|
|
171
|
+
|
|
172
|
+
// Suppress %output notifications
|
|
173
|
+
await this.sendCommand("refresh-client -f no-output");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async readOutput(): Promise<void> {
|
|
177
|
+
if (!this.proc?.stdout) return;
|
|
178
|
+
const reader = this.proc.stdout.getReader();
|
|
179
|
+
const decoder = new TextDecoder();
|
|
180
|
+
try {
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
this.parser.feed(decoder.decode(value, { stream: true }));
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Process exited
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async sendCommand(cmd: string): Promise<string[]> {
|
|
192
|
+
if (!this.proc?.stdin) throw new Error("TmuxControl not started");
|
|
193
|
+
const promise = new Promise<string[]>((resolve, reject) => {
|
|
194
|
+
this.pendingQueue.push({ resolve, reject });
|
|
195
|
+
});
|
|
196
|
+
// Bun.spawn with stdin:"pipe" gives a FileSink, not a WritableStream
|
|
197
|
+
this.proc.stdin.write(cmd + "\n");
|
|
198
|
+
this.proc.stdin.flush();
|
|
199
|
+
return promise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async registerSubscription(
|
|
203
|
+
name: string,
|
|
204
|
+
interval: number,
|
|
205
|
+
format: string,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
await this.sendCommand(
|
|
208
|
+
`refresh-client -B "${name}:${interval}:${format}"`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async close(): Promise<void> {
|
|
213
|
+
if (this.proc?.stdin) {
|
|
214
|
+
try {
|
|
215
|
+
this.proc.stdin.end();
|
|
216
|
+
} catch {
|
|
217
|
+
// Already closed
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
this.proc?.kill();
|
|
221
|
+
this.proc = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/tmux-pty.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Terminal } from "bun-pty";
|
|
2
|
+
|
|
3
|
+
export interface TmuxPtyOptions {
|
|
4
|
+
sessionName?: string;
|
|
5
|
+
socketName?: string;
|
|
6
|
+
configFile?: string;
|
|
7
|
+
jmuxDir?: string;
|
|
8
|
+
cols: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class TmuxPty {
|
|
13
|
+
private pty: Terminal;
|
|
14
|
+
private _pid: number;
|
|
15
|
+
private dataListeners: Array<(data: string) => void> = [];
|
|
16
|
+
private exitListeners: Array<(code: number) => void> = [];
|
|
17
|
+
|
|
18
|
+
constructor(options: TmuxPtyOptions) {
|
|
19
|
+
const args: string[] = [];
|
|
20
|
+
if (options.configFile) {
|
|
21
|
+
args.push("-f", options.configFile);
|
|
22
|
+
}
|
|
23
|
+
if (options.socketName) {
|
|
24
|
+
args.push("-L", options.socketName);
|
|
25
|
+
}
|
|
26
|
+
args.push("new-session", "-A");
|
|
27
|
+
if (options.sessionName) {
|
|
28
|
+
args.push("-s", options.sessionName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.pty = new Terminal("tmux", args, {
|
|
32
|
+
name: "xterm-256color",
|
|
33
|
+
cols: options.cols,
|
|
34
|
+
rows: options.rows,
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
TERM: "xterm-256color",
|
|
38
|
+
JMUX_DIR: options.jmuxDir || "",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this._pid = this.pty.pid;
|
|
43
|
+
|
|
44
|
+
this.pty.onData((data: string) => {
|
|
45
|
+
for (const listener of this.dataListeners) {
|
|
46
|
+
listener(data);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.pty.onExit((event: { exitCode: number }) => {
|
|
51
|
+
for (const listener of this.exitListeners) {
|
|
52
|
+
listener(event.exitCode);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get pid(): number {
|
|
58
|
+
return this._pid;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onData(listener: (data: string) => void): void {
|
|
62
|
+
this.dataListeners.push(listener);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onExit(listener: (code: number) => void): void {
|
|
66
|
+
this.exitListeners.push(listener);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
write(data: string): void {
|
|
70
|
+
this.pty.write(data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resize(cols: number, rows: number): void {
|
|
74
|
+
this.pty.resize(cols, rows);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
kill(): void {
|
|
78
|
+
this.pty.kill();
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const enum ColorMode {
|
|
2
|
+
Default = 0,
|
|
3
|
+
Palette = 1,
|
|
4
|
+
RGB = 2,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Cell {
|
|
8
|
+
char: string;
|
|
9
|
+
fg: number;
|
|
10
|
+
bg: number;
|
|
11
|
+
fgMode: ColorMode;
|
|
12
|
+
bgMode: ColorMode;
|
|
13
|
+
bold: boolean;
|
|
14
|
+
italic: boolean;
|
|
15
|
+
underline: boolean;
|
|
16
|
+
dim: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CellGrid {
|
|
20
|
+
cols: number;
|
|
21
|
+
rows: number;
|
|
22
|
+
cells: Cell[][];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CursorPosition {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SessionInfo {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
attached: boolean;
|
|
34
|
+
activity: number;
|
|
35
|
+
gitBranch?: string;
|
|
36
|
+
attention: boolean;
|
|
37
|
+
windowCount: number;
|
|
38
|
+
directory?: string;
|
|
39
|
+
}
|