@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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { sgrForCell, compositeGrids, BORDER_CHAR } from "../renderer";
|
|
3
|
+
import { createGrid, writeString } from "../cell-grid";
|
|
4
|
+
import { ColorMode } from "../types";
|
|
5
|
+
import type { Cell } from "../types";
|
|
6
|
+
|
|
7
|
+
describe("sgrForCell", () => {
|
|
8
|
+
test("returns reset only for default cell", () => {
|
|
9
|
+
const cell: Cell = {
|
|
10
|
+
char: " ",
|
|
11
|
+
fg: 0, bg: 0,
|
|
12
|
+
fgMode: ColorMode.Default, bgMode: ColorMode.Default,
|
|
13
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
14
|
+
};
|
|
15
|
+
expect(sgrForCell(cell)).toBe("\x1b[0m");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("includes bold attribute", () => {
|
|
19
|
+
const cell: Cell = {
|
|
20
|
+
char: "x",
|
|
21
|
+
fg: 0, bg: 0,
|
|
22
|
+
fgMode: ColorMode.Default, bgMode: ColorMode.Default,
|
|
23
|
+
bold: true, italic: false, underline: false, dim: false,
|
|
24
|
+
};
|
|
25
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;1m");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("encodes standard ANSI foreground color 0-7", () => {
|
|
29
|
+
const cell: Cell = {
|
|
30
|
+
char: "x",
|
|
31
|
+
fg: 1, bg: 0,
|
|
32
|
+
fgMode: ColorMode.Palette, bgMode: ColorMode.Default,
|
|
33
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
34
|
+
};
|
|
35
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;31m");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("encodes bright ANSI foreground color 8-15", () => {
|
|
39
|
+
const cell: Cell = {
|
|
40
|
+
char: "x",
|
|
41
|
+
fg: 9, bg: 0,
|
|
42
|
+
fgMode: ColorMode.Palette, bgMode: ColorMode.Default,
|
|
43
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
44
|
+
};
|
|
45
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;91m");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("encodes 256-color foreground", () => {
|
|
49
|
+
const cell: Cell = {
|
|
50
|
+
char: "x",
|
|
51
|
+
fg: 200, bg: 0,
|
|
52
|
+
fgMode: ColorMode.Palette, bgMode: ColorMode.Default,
|
|
53
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
54
|
+
};
|
|
55
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;38;5;200m");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("encodes RGB foreground", () => {
|
|
59
|
+
const cell: Cell = {
|
|
60
|
+
char: "x",
|
|
61
|
+
fg: 0xFF8800, bg: 0,
|
|
62
|
+
fgMode: ColorMode.RGB, bgMode: ColorMode.Default,
|
|
63
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
64
|
+
};
|
|
65
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;38;2;255;136;0m");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("encodes background color", () => {
|
|
69
|
+
const cell: Cell = {
|
|
70
|
+
char: "x",
|
|
71
|
+
fg: 0, bg: 4,
|
|
72
|
+
fgMode: ColorMode.Default, bgMode: ColorMode.Palette,
|
|
73
|
+
bold: false, italic: false, underline: false, dim: false,
|
|
74
|
+
};
|
|
75
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;44m");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("encodes combined attributes and colors", () => {
|
|
79
|
+
const cell: Cell = {
|
|
80
|
+
char: "x",
|
|
81
|
+
fg: 2, bg: 0,
|
|
82
|
+
fgMode: ColorMode.Palette, bgMode: ColorMode.Default,
|
|
83
|
+
bold: true, italic: true, underline: false, dim: false,
|
|
84
|
+
};
|
|
85
|
+
expect(sgrForCell(cell)).toBe("\x1b[0;1;3;32m");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("compositeGrids", () => {
|
|
90
|
+
test("returns main grid only when no sidebar", () => {
|
|
91
|
+
const main = createGrid(10, 3);
|
|
92
|
+
writeString(main, 0, 0, "hello");
|
|
93
|
+
const result = compositeGrids(main, null);
|
|
94
|
+
expect(result.cols).toBe(10);
|
|
95
|
+
expect(result.cells[0][0].char).toBe("h");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("composites sidebar + border + main", () => {
|
|
99
|
+
const sidebar = createGrid(4, 2);
|
|
100
|
+
writeString(sidebar, 0, 0, "side");
|
|
101
|
+
const main = createGrid(6, 2);
|
|
102
|
+
writeString(main, 0, 0, "main!!");
|
|
103
|
+
const result = compositeGrids(main, sidebar);
|
|
104
|
+
// sidebar: 4 cols + border: 1 col + main: 6 cols = 11 cols
|
|
105
|
+
expect(result.cols).toBe(11);
|
|
106
|
+
expect(result.cells[0][0].char).toBe("s");
|
|
107
|
+
expect(result.cells[0][3].char).toBe("e");
|
|
108
|
+
expect(result.cells[0][4].char).toBe(BORDER_CHAR);
|
|
109
|
+
expect(result.cells[0][5].char).toBe("m");
|
|
110
|
+
expect(result.cells[0][10].char).toBe("!");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { ScreenBridge } from "../screen-bridge";
|
|
3
|
+
import { ColorMode } from "../types";
|
|
4
|
+
|
|
5
|
+
describe("ScreenBridge", () => {
|
|
6
|
+
test("returns empty grid with spaces before any writes", () => {
|
|
7
|
+
const bridge = new ScreenBridge(10, 5);
|
|
8
|
+
const grid = bridge.getGrid();
|
|
9
|
+
expect(grid.cols).toBe(10);
|
|
10
|
+
expect(grid.rows).toBe(5);
|
|
11
|
+
expect(grid.cells[0][0].char).toBe(" ");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("captures plain text written to terminal", async () => {
|
|
15
|
+
const bridge = new ScreenBridge(10, 3);
|
|
16
|
+
await bridge.write("Hello");
|
|
17
|
+
const grid = bridge.getGrid();
|
|
18
|
+
expect(grid.cells[0][0].char).toBe("H");
|
|
19
|
+
expect(grid.cells[0][1].char).toBe("e");
|
|
20
|
+
expect(grid.cells[0][2].char).toBe("l");
|
|
21
|
+
expect(grid.cells[0][3].char).toBe("l");
|
|
22
|
+
expect(grid.cells[0][4].char).toBe("o");
|
|
23
|
+
expect(grid.cells[0][5].char).toBe(" ");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("captures bold SGR attribute", async () => {
|
|
27
|
+
const bridge = new ScreenBridge(10, 3);
|
|
28
|
+
await bridge.write("\x1b[1mBold\x1b[0m");
|
|
29
|
+
const grid = bridge.getGrid();
|
|
30
|
+
expect(grid.cells[0][0].char).toBe("B");
|
|
31
|
+
expect(grid.cells[0][0].bold).toBe(true);
|
|
32
|
+
expect(grid.cells[0][3].char).toBe("d");
|
|
33
|
+
expect(grid.cells[0][3].bold).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("captures foreground palette color", async () => {
|
|
37
|
+
const bridge = new ScreenBridge(10, 3);
|
|
38
|
+
// SGR 31 = red foreground (ANSI color 1)
|
|
39
|
+
await bridge.write("\x1b[31mRed\x1b[0m");
|
|
40
|
+
const grid = bridge.getGrid();
|
|
41
|
+
expect(grid.cells[0][0].char).toBe("R");
|
|
42
|
+
expect(grid.cells[0][0].fg).toBe(1);
|
|
43
|
+
expect(grid.cells[0][0].fgMode).toBe(ColorMode.Palette);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("reports cursor position", async () => {
|
|
47
|
+
const bridge = new ScreenBridge(10, 3);
|
|
48
|
+
await bridge.write("Hi");
|
|
49
|
+
const cursor = bridge.getCursor();
|
|
50
|
+
expect(cursor.x).toBe(2);
|
|
51
|
+
expect(cursor.y).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("handles resize", async () => {
|
|
55
|
+
const bridge = new ScreenBridge(10, 3);
|
|
56
|
+
bridge.resize(20, 5);
|
|
57
|
+
const grid = bridge.getGrid();
|
|
58
|
+
expect(grid.cols).toBe(20);
|
|
59
|
+
expect(grid.rows).toBe(5);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { Sidebar } from "../sidebar";
|
|
3
|
+
import type { SessionInfo } from "../types";
|
|
4
|
+
|
|
5
|
+
const SIDEBAR_WIDTH = 24;
|
|
6
|
+
|
|
7
|
+
function makeSessions(
|
|
8
|
+
entries: Array<{ name: string; directory?: string; gitBranch?: string }>,
|
|
9
|
+
): SessionInfo[] {
|
|
10
|
+
return entries.map((e, i) => ({
|
|
11
|
+
id: `$${i}`,
|
|
12
|
+
name: e.name,
|
|
13
|
+
attached: i === 0,
|
|
14
|
+
activity: 0,
|
|
15
|
+
attention: false,
|
|
16
|
+
windowCount: 1,
|
|
17
|
+
directory: e.directory,
|
|
18
|
+
gitBranch: e.gitBranch,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("Sidebar", () => {
|
|
23
|
+
test("renders header row", () => {
|
|
24
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
25
|
+
sidebar.updateSessions(makeSessions([{ name: "main" }]));
|
|
26
|
+
const grid = sidebar.getGrid();
|
|
27
|
+
const headerText = Array.from(
|
|
28
|
+
{ length: 4 },
|
|
29
|
+
(_, i) => grid.cells[0][1 + i].char,
|
|
30
|
+
).join("");
|
|
31
|
+
expect(headerText).toBe("jmux");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("renders ungrouped sessions without a group header", () => {
|
|
35
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
36
|
+
sidebar.updateSessions(
|
|
37
|
+
makeSessions([
|
|
38
|
+
{ name: "alpha", directory: "~/one" },
|
|
39
|
+
{ name: "beta", directory: "~/two" },
|
|
40
|
+
]),
|
|
41
|
+
);
|
|
42
|
+
const grid = sidebar.getGrid();
|
|
43
|
+
// No shared parent → ungrouped, sessions start at row 2
|
|
44
|
+
const row2 = Array.from(
|
|
45
|
+
{ length: SIDEBAR_WIDTH },
|
|
46
|
+
(_, i) => grid.cells[2][i].char,
|
|
47
|
+
).join("");
|
|
48
|
+
expect(row2).toContain("alpha");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("groups sessions sharing a parent directory", () => {
|
|
52
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
53
|
+
sidebar.updateSessions(
|
|
54
|
+
makeSessions([
|
|
55
|
+
{ name: "api", directory: "~/Code/work/api" },
|
|
56
|
+
{ name: "frontend", directory: "~/Code/work/frontend" },
|
|
57
|
+
{ name: "scratch", directory: "/tmp" },
|
|
58
|
+
]),
|
|
59
|
+
);
|
|
60
|
+
const grid = sidebar.getGrid();
|
|
61
|
+
// Row 2: group header "Code/work"
|
|
62
|
+
const headerRow = Array.from(
|
|
63
|
+
{ length: SIDEBAR_WIDTH },
|
|
64
|
+
(_, i) => grid.cells[2][i].char,
|
|
65
|
+
).join("");
|
|
66
|
+
expect(headerRow).toContain("Code/work");
|
|
67
|
+
// Row 3: first session in group "api"
|
|
68
|
+
const apiRow = Array.from(
|
|
69
|
+
{ length: SIDEBAR_WIDTH },
|
|
70
|
+
(_, i) => grid.cells[3][i].char,
|
|
71
|
+
).join("");
|
|
72
|
+
expect(apiRow).toContain("api");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("solo sessions in a directory are ungrouped", () => {
|
|
76
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
77
|
+
sidebar.updateSessions(
|
|
78
|
+
makeSessions([
|
|
79
|
+
{ name: "only-one", directory: "~/Code/work/only-one" },
|
|
80
|
+
{ name: "other", directory: "~/somewhere/other" },
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
const grid = sidebar.getGrid();
|
|
84
|
+
// Neither has a sibling → no group headers, just sessions
|
|
85
|
+
const row2 = Array.from(
|
|
86
|
+
{ length: SIDEBAR_WIDTH },
|
|
87
|
+
(_, i) => grid.cells[2][i].char,
|
|
88
|
+
).join("");
|
|
89
|
+
// Should contain a session name, not a group header
|
|
90
|
+
expect(row2).toContain("only-one");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("grouped sessions show branch but not directory on detail line", () => {
|
|
94
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
95
|
+
sidebar.updateSessions(
|
|
96
|
+
makeSessions([
|
|
97
|
+
{
|
|
98
|
+
name: "api",
|
|
99
|
+
directory: "~/Code/work/api",
|
|
100
|
+
gitBranch: "main",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "web",
|
|
104
|
+
directory: "~/Code/work/web",
|
|
105
|
+
gitBranch: "feat/x",
|
|
106
|
+
},
|
|
107
|
+
]),
|
|
108
|
+
);
|
|
109
|
+
const grid = sidebar.getGrid();
|
|
110
|
+
// Find the detail row for "api" (row after "api" name row)
|
|
111
|
+
// Row 2: group header, Row 3: api name, Row 4: api detail
|
|
112
|
+
const detailRow = Array.from(
|
|
113
|
+
{ length: SIDEBAR_WIDTH },
|
|
114
|
+
(_, i) => grid.cells[4][i].char,
|
|
115
|
+
).join("");
|
|
116
|
+
expect(detailRow).toContain("main");
|
|
117
|
+
expect(detailRow).not.toContain("Code/work");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("ungrouped sessions show directory on detail line", () => {
|
|
121
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
122
|
+
sidebar.updateSessions(
|
|
123
|
+
makeSessions([
|
|
124
|
+
{ name: "solo", directory: "~/mydir", gitBranch: "dev" },
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
const grid = sidebar.getGrid();
|
|
128
|
+
// Row 2: session name, Row 3: detail
|
|
129
|
+
const detailRow = Array.from(
|
|
130
|
+
{ length: SIDEBAR_WIDTH },
|
|
131
|
+
(_, i) => grid.cells[3][i].char,
|
|
132
|
+
).join("");
|
|
133
|
+
expect(detailRow).toContain("~/mydir");
|
|
134
|
+
expect(detailRow).toContain("dev");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("highlights active session with green marker", () => {
|
|
138
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
139
|
+
sidebar.updateSessions(
|
|
140
|
+
makeSessions([{ name: "main" }, { name: "dev" }]),
|
|
141
|
+
);
|
|
142
|
+
sidebar.setActiveSession("$0");
|
|
143
|
+
const grid = sidebar.getGrid();
|
|
144
|
+
// Find the active session's name row and check for marker
|
|
145
|
+
let foundMarker = false;
|
|
146
|
+
for (let r = 2; r < 20; r++) {
|
|
147
|
+
if (grid.cells[r][0].char === "\u258e") {
|
|
148
|
+
foundMarker = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
expect(foundMarker).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("shows activity indicator", () => {
|
|
156
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
157
|
+
sidebar.updateSessions(makeSessions([{ name: "main" }]));
|
|
158
|
+
sidebar.setActivity("$0", true);
|
|
159
|
+
const grid = sidebar.getGrid();
|
|
160
|
+
let foundDot = false;
|
|
161
|
+
for (let r = 2; r < 20; r++) {
|
|
162
|
+
if (grid.cells[r][1].char === "\u25CF") {
|
|
163
|
+
foundDot = true;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
expect(foundDot).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("shows attention flag", () => {
|
|
171
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
172
|
+
const sessions = makeSessions([{ name: "main" }]);
|
|
173
|
+
sessions[0].attention = true;
|
|
174
|
+
sidebar.updateSessions(sessions);
|
|
175
|
+
const grid = sidebar.getGrid();
|
|
176
|
+
let foundBang = false;
|
|
177
|
+
for (let r = 2; r < 20; r++) {
|
|
178
|
+
if (grid.cells[r][1].char === "!") {
|
|
179
|
+
foundBang = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
expect(foundBang).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("getDisplayOrderIds returns sessions in grouped display order", () => {
|
|
187
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
188
|
+
sidebar.updateSessions(
|
|
189
|
+
makeSessions([
|
|
190
|
+
{ name: "c" },
|
|
191
|
+
{ name: "a" },
|
|
192
|
+
{ name: "b" },
|
|
193
|
+
]),
|
|
194
|
+
);
|
|
195
|
+
const ids = sidebar.getDisplayOrderIds();
|
|
196
|
+
// Ungrouped, sorted alphabetically by name
|
|
197
|
+
expect(ids).toEqual(["$1", "$2", "$0"]); // a, b, c
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("getSessionByRow returns correct session for click handling", () => {
|
|
201
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
202
|
+
sidebar.updateSessions(
|
|
203
|
+
makeSessions([
|
|
204
|
+
{ name: "api", directory: "~/Code/work/api" },
|
|
205
|
+
{ name: "web", directory: "~/Code/work/web" },
|
|
206
|
+
]),
|
|
207
|
+
);
|
|
208
|
+
sidebar.getGrid(); // must render to populate row map
|
|
209
|
+
|
|
210
|
+
// Row 2: group header → null
|
|
211
|
+
expect(sidebar.getSessionByRow(2)).toBeNull();
|
|
212
|
+
// Row 3: first session name row → api
|
|
213
|
+
expect(sidebar.getSessionByRow(3)?.name).toBe("api");
|
|
214
|
+
// Row 4: first session detail row → api
|
|
215
|
+
expect(sidebar.getSessionByRow(4)?.name).toBe("api");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("shows window count", () => {
|
|
219
|
+
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
220
|
+
const sessions = makeSessions([{ name: "main" }]);
|
|
221
|
+
sessions[0].windowCount = 5;
|
|
222
|
+
sidebar.updateSessions(sessions);
|
|
223
|
+
const grid = sidebar.getGrid();
|
|
224
|
+
let found = false;
|
|
225
|
+
for (let r = 2; r < 10; r++) {
|
|
226
|
+
const text = Array.from(
|
|
227
|
+
{ length: SIDEBAR_WIDTH },
|
|
228
|
+
(_, i) => grid.cells[r][i].char,
|
|
229
|
+
).join("");
|
|
230
|
+
if (text.includes("5w")) {
|
|
231
|
+
found = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
expect(found).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { ControlParser, type ControlEvent } from "../tmux-control";
|
|
3
|
+
|
|
4
|
+
describe("ControlParser", () => {
|
|
5
|
+
test("emits sessions-changed notification", () => {
|
|
6
|
+
const parser = new ControlParser();
|
|
7
|
+
const events: ControlEvent[] = [];
|
|
8
|
+
parser.onEvent((e) => events.push(e));
|
|
9
|
+
|
|
10
|
+
parser.feed("%sessions-changed\n");
|
|
11
|
+
|
|
12
|
+
expect(events.length).toBe(1);
|
|
13
|
+
expect(events[0].type).toBe("sessions-changed");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("emits session-changed notification", () => {
|
|
17
|
+
const parser = new ControlParser();
|
|
18
|
+
const events: ControlEvent[] = [];
|
|
19
|
+
parser.onEvent((e) => events.push(e));
|
|
20
|
+
|
|
21
|
+
parser.feed("%session-changed $3\n");
|
|
22
|
+
|
|
23
|
+
expect(events.length).toBe(1);
|
|
24
|
+
expect(events[0].type).toBe("session-changed");
|
|
25
|
+
if (events[0].type === "session-changed") {
|
|
26
|
+
expect(events[0].args).toBe("$3");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("collects response block between %begin and %end", () => {
|
|
31
|
+
const parser = new ControlParser();
|
|
32
|
+
const events: ControlEvent[] = [];
|
|
33
|
+
parser.onEvent((e) => events.push(e));
|
|
34
|
+
|
|
35
|
+
parser.feed("%begin 1234 721599 1\n");
|
|
36
|
+
parser.feed("line one\n");
|
|
37
|
+
parser.feed("line two\n");
|
|
38
|
+
parser.feed("%end 1234 721599 1\n");
|
|
39
|
+
|
|
40
|
+
expect(events.length).toBe(1);
|
|
41
|
+
expect(events[0].type).toBe("response");
|
|
42
|
+
if (events[0].type === "response") {
|
|
43
|
+
expect(events[0].commandNumber).toBe(721599);
|
|
44
|
+
expect(events[0].flags).toBe(1);
|
|
45
|
+
expect(events[0].lines).toEqual(["line one", "line two"]);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("emits error response on %error", () => {
|
|
50
|
+
const parser = new ControlParser();
|
|
51
|
+
const events: ControlEvent[] = [];
|
|
52
|
+
parser.onEvent((e) => events.push(e));
|
|
53
|
+
|
|
54
|
+
parser.feed("%begin 1234 2 1\n");
|
|
55
|
+
parser.feed("something bad\n");
|
|
56
|
+
parser.feed("%error 1234 2 1\n");
|
|
57
|
+
|
|
58
|
+
expect(events.length).toBe(1);
|
|
59
|
+
expect(events[0].type).toBe("error");
|
|
60
|
+
if (events[0].type === "error") {
|
|
61
|
+
expect(events[0].commandNumber).toBe(2);
|
|
62
|
+
expect(events[0].flags).toBe(1);
|
|
63
|
+
expect(events[0].lines).toEqual(["something bad"]);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("parses flags field from %begin/%end", () => {
|
|
68
|
+
const parser = new ControlParser();
|
|
69
|
+
const events: ControlEvent[] = [];
|
|
70
|
+
parser.onEvent((e) => events.push(e));
|
|
71
|
+
|
|
72
|
+
// flags=0 means response is NOT from this client (e.g. initial attach)
|
|
73
|
+
parser.feed("%begin 1234 100 0\n");
|
|
74
|
+
parser.feed("%end 1234 100 0\n");
|
|
75
|
+
|
|
76
|
+
// flags=1 means response IS from this client
|
|
77
|
+
parser.feed("%begin 1234 200 1\n");
|
|
78
|
+
parser.feed("data\n");
|
|
79
|
+
parser.feed("%end 1234 200 1\n");
|
|
80
|
+
|
|
81
|
+
expect(events.length).toBe(2);
|
|
82
|
+
if (events[0].type === "response") {
|
|
83
|
+
expect(events[0].flags).toBe(0);
|
|
84
|
+
}
|
|
85
|
+
if (events[1].type === "response") {
|
|
86
|
+
expect(events[1].flags).toBe(1);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("emits subscription-changed notification", () => {
|
|
91
|
+
const parser = new ControlParser();
|
|
92
|
+
const events: ControlEvent[] = [];
|
|
93
|
+
parser.onEvent((e) => events.push(e));
|
|
94
|
+
|
|
95
|
+
parser.feed("%subscription-changed attention main=1 dev=\n");
|
|
96
|
+
|
|
97
|
+
expect(events.length).toBe(1);
|
|
98
|
+
expect(events[0].type).toBe("subscription-changed");
|
|
99
|
+
if (events[0].type === "subscription-changed") {
|
|
100
|
+
expect(events[0].name).toBe("attention");
|
|
101
|
+
expect(events[0].value).toBe("main=1 dev=");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("handles partial lines across multiple feeds", () => {
|
|
106
|
+
const parser = new ControlParser();
|
|
107
|
+
const events: ControlEvent[] = [];
|
|
108
|
+
parser.onEvent((e) => events.push(e));
|
|
109
|
+
|
|
110
|
+
parser.feed("%sessions");
|
|
111
|
+
expect(events.length).toBe(0);
|
|
112
|
+
parser.feed("-changed\n");
|
|
113
|
+
expect(events.length).toBe(1);
|
|
114
|
+
expect(events[0].type).toBe("sessions-changed");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("handles multiple lines in single feed", () => {
|
|
118
|
+
const parser = new ControlParser();
|
|
119
|
+
const events: ControlEvent[] = [];
|
|
120
|
+
parser.onEvent((e) => events.push(e));
|
|
121
|
+
|
|
122
|
+
parser.feed("%sessions-changed\n%session-changed $1\n");
|
|
123
|
+
|
|
124
|
+
expect(events.length).toBe(2);
|
|
125
|
+
expect(events[0].type).toBe("sessions-changed");
|
|
126
|
+
expect(events[1].type).toBe("session-changed");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("emits window-renamed notification", () => {
|
|
130
|
+
const parser = new ControlParser();
|
|
131
|
+
const events: ControlEvent[] = [];
|
|
132
|
+
parser.onEvent((e) => events.push(e));
|
|
133
|
+
|
|
134
|
+
parser.feed("%window-renamed @0 bash\n");
|
|
135
|
+
|
|
136
|
+
expect(events.length).toBe(1);
|
|
137
|
+
expect(events[0].type).toBe("window-renamed");
|
|
138
|
+
if (events[0].type === "window-renamed") {
|
|
139
|
+
expect(events[0].args).toBe("@0 bash");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
package/src/cell-grid.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Cell, CellGrid } from "./types";
|
|
2
|
+
import { ColorMode } from "./types";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CELL: Readonly<Cell> = {
|
|
5
|
+
char: " ",
|
|
6
|
+
fg: 0,
|
|
7
|
+
bg: 0,
|
|
8
|
+
fgMode: ColorMode.Default,
|
|
9
|
+
bgMode: ColorMode.Default,
|
|
10
|
+
bold: false,
|
|
11
|
+
italic: false,
|
|
12
|
+
underline: false,
|
|
13
|
+
dim: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createGrid(cols: number, rows: number): CellGrid {
|
|
17
|
+
const cells: Cell[][] = [];
|
|
18
|
+
for (let r = 0; r < rows; r++) {
|
|
19
|
+
const row: Cell[] = [];
|
|
20
|
+
for (let c = 0; c < cols; c++) {
|
|
21
|
+
row.push({ ...DEFAULT_CELL });
|
|
22
|
+
}
|
|
23
|
+
cells.push(row);
|
|
24
|
+
}
|
|
25
|
+
return { cols, rows, cells };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CellAttrs {
|
|
29
|
+
fg?: number;
|
|
30
|
+
bg?: number;
|
|
31
|
+
fgMode?: ColorMode;
|
|
32
|
+
bgMode?: ColorMode;
|
|
33
|
+
bold?: boolean;
|
|
34
|
+
italic?: boolean;
|
|
35
|
+
underline?: boolean;
|
|
36
|
+
dim?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeString(
|
|
40
|
+
grid: CellGrid,
|
|
41
|
+
row: number,
|
|
42
|
+
col: number,
|
|
43
|
+
text: string,
|
|
44
|
+
attrs?: CellAttrs,
|
|
45
|
+
): void {
|
|
46
|
+
if (row < 0 || row >= grid.rows) return;
|
|
47
|
+
for (let i = 0; i < text.length; i++) {
|
|
48
|
+
const c = col + i;
|
|
49
|
+
if (c < 0 || c >= grid.cols) continue;
|
|
50
|
+
const cell = grid.cells[row][c];
|
|
51
|
+
cell.char = text[i];
|
|
52
|
+
if (attrs) {
|
|
53
|
+
if (attrs.fg !== undefined) cell.fg = attrs.fg;
|
|
54
|
+
if (attrs.bg !== undefined) cell.bg = attrs.bg;
|
|
55
|
+
if (attrs.fgMode !== undefined) cell.fgMode = attrs.fgMode;
|
|
56
|
+
if (attrs.bgMode !== undefined) cell.bgMode = attrs.bgMode;
|
|
57
|
+
if (attrs.bold !== undefined) cell.bold = attrs.bold;
|
|
58
|
+
if (attrs.italic !== undefined) cell.italic = attrs.italic;
|
|
59
|
+
if (attrs.underline !== undefined) cell.underline = attrs.underline;
|
|
60
|
+
if (attrs.dim !== undefined) cell.dim = attrs.dim;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface SgrMouseEvent {
|
|
2
|
+
button: number;
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
release: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
9
|
+
|
|
10
|
+
export function parseSgrMouse(seq: string): SgrMouseEvent | null {
|
|
11
|
+
const match = seq.match(SGR_MOUSE_RE);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
return {
|
|
14
|
+
button: parseInt(match[1], 10),
|
|
15
|
+
x: parseInt(match[2], 10),
|
|
16
|
+
y: parseInt(match[3], 10),
|
|
17
|
+
release: match[4] === "m",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function translateMouseX(
|
|
22
|
+
seq: string,
|
|
23
|
+
offset: number,
|
|
24
|
+
): string | null {
|
|
25
|
+
const match = seq.match(SGR_MOUSE_RE);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const newX = parseInt(match[2], 10) - offset;
|
|
28
|
+
if (newX <= 0) return null;
|
|
29
|
+
return `\x1b[<${match[1]};${newX};${match[3]}${match[4]}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InputRouterOptions {
|
|
33
|
+
sidebarCols: number;
|
|
34
|
+
onPtyData: (data: string) => void;
|
|
35
|
+
onSidebarClick: (row: number) => void;
|
|
36
|
+
onSessionPrev?: () => void;
|
|
37
|
+
onSessionNext?: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class InputRouter {
|
|
41
|
+
private opts: InputRouterOptions;
|
|
42
|
+
private sidebarVisible: boolean;
|
|
43
|
+
|
|
44
|
+
constructor(opts: InputRouterOptions, sidebarVisible: boolean) {
|
|
45
|
+
this.opts = opts;
|
|
46
|
+
this.sidebarVisible = sidebarVisible;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setSidebarVisible(visible: boolean): void {
|
|
50
|
+
this.sidebarVisible = visible;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handleInput(data: string): void {
|
|
54
|
+
// Always-active hotkeys: Ctrl-Shift-Up/Down for session switching
|
|
55
|
+
if (data === "\x1b[1;6A") {
|
|
56
|
+
this.opts.onSessionPrev?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (data === "\x1b[1;6B") {
|
|
60
|
+
this.opts.onSessionNext?.();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for SGR mouse events
|
|
65
|
+
const mouse = parseSgrMouse(data);
|
|
66
|
+
if (mouse && this.sidebarVisible) {
|
|
67
|
+
if (mouse.x <= this.opts.sidebarCols) {
|
|
68
|
+
// Click in sidebar region
|
|
69
|
+
if (!mouse.release) {
|
|
70
|
+
this.opts.onSidebarClick(mouse.y - 1); // 0-indexed row
|
|
71
|
+
}
|
|
72
|
+
return; // Consume sidebar mouse events
|
|
73
|
+
}
|
|
74
|
+
// Mouse in main area — translate X coordinate
|
|
75
|
+
const translated = translateMouseX(data, this.opts.sidebarCols + 1);
|
|
76
|
+
if (translated) {
|
|
77
|
+
this.opts.onPtyData(translated);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Default: pass through to PTY
|
|
83
|
+
this.opts.onPtyData(data);
|
|
84
|
+
}
|
|
85
|
+
}
|