@jx0/jmux 0.3.2 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx0/jmux",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "The terminal workspace for agentic development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -235,4 +235,125 @@ describe("Sidebar", () => {
235
235
  }
236
236
  expect(found).toBe(true);
237
237
  });
238
+
239
+ test("scrolls to show active session when it overflows", () => {
240
+ // Height 10 = 2 header rows + 8 viewport rows
241
+ // Each session = 2 rows + 1 spacer = 3 rows, so 8 rows fits ~2.6 sessions
242
+ const sidebar = new Sidebar(SIDEBAR_WIDTH, 10);
243
+ sidebar.updateSessions(
244
+ makeSessions([
245
+ { name: "a" },
246
+ { name: "b" },
247
+ { name: "c" },
248
+ { name: "d" },
249
+ ]),
250
+ );
251
+ // Activate last session and scroll to it
252
+ sidebar.setActiveSession("$3");
253
+ sidebar.scrollToActive();
254
+ const grid = sidebar.getGrid();
255
+ // "d" should be visible somewhere in the grid
256
+ let found = false;
257
+ for (let r = 2; r < 10; r++) {
258
+ const text = Array.from(
259
+ { length: SIDEBAR_WIDTH },
260
+ (_, i) => grid.cells[r][i].char,
261
+ ).join("");
262
+ if (text.includes("d")) {
263
+ found = true;
264
+ break;
265
+ }
266
+ }
267
+ expect(found).toBe(true);
268
+ });
269
+
270
+ test("scrollBy moves viewport and clamps to bounds", () => {
271
+ const sidebar = new Sidebar(SIDEBAR_WIDTH, 10);
272
+ sidebar.updateSessions(
273
+ makeSessions([
274
+ { name: "a" },
275
+ { name: "b" },
276
+ { name: "c" },
277
+ { name: "d" },
278
+ ]),
279
+ );
280
+ // First session visible at start
281
+ let grid = sidebar.getGrid();
282
+ const row2 = Array.from(
283
+ { length: SIDEBAR_WIDTH },
284
+ (_, i) => grid.cells[2][i].char,
285
+ ).join("");
286
+ expect(row2).toContain("a");
287
+
288
+ // Scroll down
289
+ sidebar.scrollBy(3);
290
+ grid = sidebar.getGrid();
291
+ // "a" should no longer be on row 2
292
+ const row2After = Array.from(
293
+ { length: SIDEBAR_WIDTH },
294
+ (_, i) => grid.cells[2][i].char,
295
+ ).join("");
296
+ expect(row2After).not.toContain("a");
297
+
298
+ // Scroll way past the top — should clamp to 0
299
+ sidebar.scrollBy(-100);
300
+ grid = sidebar.getGrid();
301
+ const row2Reset = Array.from(
302
+ { length: SIDEBAR_WIDTH },
303
+ (_, i) => grid.cells[2][i].char,
304
+ ).join("");
305
+ expect(row2Reset).toContain("a");
306
+ });
307
+
308
+ test("shows scroll indicators when content overflows", () => {
309
+ const sidebar = new Sidebar(SIDEBAR_WIDTH, 10);
310
+ sidebar.updateSessions(
311
+ makeSessions([
312
+ { name: "a" },
313
+ { name: "b" },
314
+ { name: "c" },
315
+ { name: "d" },
316
+ ]),
317
+ );
318
+ // At top: should show down indicator but not up
319
+ let grid = sidebar.getGrid();
320
+ expect(grid.cells[2][SIDEBAR_WIDTH - 1].char).not.toBe("\u25b2");
321
+ expect(grid.cells[9][SIDEBAR_WIDTH - 1].char).toBe("\u25bc");
322
+
323
+ // Scroll to middle: should show both
324
+ sidebar.scrollBy(3);
325
+ grid = sidebar.getGrid();
326
+ expect(grid.cells[2][SIDEBAR_WIDTH - 1].char).toBe("\u25b2");
327
+ });
328
+
329
+ test("scrollToActive snaps back after manual scroll", () => {
330
+ const sidebar = new Sidebar(SIDEBAR_WIDTH, 10);
331
+ sidebar.updateSessions(
332
+ makeSessions([
333
+ { name: "a" },
334
+ { name: "b" },
335
+ { name: "c" },
336
+ { name: "d" },
337
+ ]),
338
+ );
339
+ sidebar.setActiveSession("$0"); // "a" is active
340
+ // Scroll away from active session
341
+ sidebar.scrollBy(6);
342
+ // Snap back
343
+ sidebar.scrollToActive();
344
+ const grid = sidebar.getGrid();
345
+ // "a" should be visible
346
+ let found = false;
347
+ for (let r = 2; r < 10; r++) {
348
+ const text = Array.from(
349
+ { length: SIDEBAR_WIDTH },
350
+ (_, i) => grid.cells[r][i].char,
351
+ ).join("");
352
+ if (text.includes("a")) {
353
+ found = true;
354
+ break;
355
+ }
356
+ }
357
+ expect(found).toBe(true);
358
+ });
238
359
  });
@@ -33,6 +33,7 @@ export interface InputRouterOptions {
33
33
  sidebarCols: number;
34
34
  onPtyData: (data: string) => void;
35
35
  onSidebarClick: (row: number) => void;
36
+ onSidebarScroll?: (delta: number) => void;
36
37
  onSessionPrev?: () => void;
37
38
  onSessionNext?: () => void;
38
39
  }
@@ -65,8 +66,14 @@ export class InputRouter {
65
66
  const mouse = parseSgrMouse(data);
66
67
  if (mouse && this.sidebarVisible) {
67
68
  if (mouse.x <= this.opts.sidebarCols) {
68
- // Click in sidebar region
69
- if (!mouse.release) {
69
+ // Wheel events: button 64 = up, 65 = down
70
+ if ((mouse.button & 64) !== 0) {
71
+ const delta = (mouse.button & 1) ? 3 : -3;
72
+ this.opts.onSidebarScroll?.(delta);
73
+ return;
74
+ }
75
+ // Click in sidebar region (ignore drags — button bit 5 = motion)
76
+ if (!mouse.release && (mouse.button & 32) === 0) {
70
77
  this.opts.onSidebarClick(mouse.y - 1); // 0-indexed row
71
78
  }
72
79
  return; // Consume sidebar mouse events
package/src/main.ts CHANGED
@@ -12,7 +12,7 @@ import { homedir } from "os";
12
12
 
13
13
  // --- CLI commands (run and exit before TUI) ---
14
14
 
15
- const VERSION = "0.3.1";
15
+ const VERSION = "0.3.4";
16
16
 
17
17
  const HELP = `jmux — a persistent session sidebar for tmux
18
18
 
@@ -143,6 +143,7 @@ const mainCols = sidebarVisible ? cols - SIDEBAR_TOTAL : cols;
143
143
  // Enter alternate screen, raw mode, enable mouse tracking
144
144
  process.stdout.write("\x1b[?1049h");
145
145
  process.stdout.write("\x1b[?1000h"); // mouse button tracking
146
+ process.stdout.write("\x1b[?1002h"); // mouse drag tracking
146
147
  process.stdout.write("\x1b[?1006h"); // SGR extended mouse mode
147
148
  if (process.stdin.setRawMode) {
148
149
  process.stdin.setRawMode(true);
@@ -254,6 +255,7 @@ async function switchSession(sessionId: string): Promise<void> {
254
255
  sidebar.setActivity(sessionId, false);
255
256
  currentSessionId = sessionId;
256
257
  sidebar.setActiveSession(sessionId);
258
+ sidebar.scrollToActive();
257
259
  renderFrame();
258
260
 
259
261
  // Clear attention flag if set
@@ -297,6 +299,10 @@ const inputRouter = new InputRouter(
297
299
  const session = sidebar.getSessionByRow(row);
298
300
  if (session) switchSession(session.id);
299
301
  },
302
+ onSidebarScroll: (delta) => {
303
+ sidebar.scrollBy(delta);
304
+ scheduleRender();
305
+ },
300
306
  onSessionPrev: () => switchByOffset(-1),
301
307
  onSessionNext: () => switchByOffset(1),
302
308
  },
@@ -307,7 +313,18 @@ const inputRouter = new InputRouter(
307
313
 
308
314
  let writesPending = 0;
309
315
 
316
+ // OSC 52 clipboard: \x1b]52;...;...\x07 or \x1b]52;...;...\x1b\\
317
+ const OSC52_RE = /\x1b\]52;[^;]*;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
318
+
310
319
  pty.onData((data: string) => {
320
+ // Pass OSC 52 clipboard sequences directly to the outer terminal
321
+ const osc52Matches = data.match(OSC52_RE);
322
+ if (osc52Matches) {
323
+ for (const seq of osc52Matches) {
324
+ process.stdout.write(seq);
325
+ }
326
+ }
327
+
311
328
  writesPending++;
312
329
  bridge.write(data).then(() => {
313
330
  writesPending--;
@@ -450,6 +467,7 @@ async function start(): Promise<void> {
450
467
  function cleanup(): void {
451
468
  control.close().catch(() => {});
452
469
  process.stdout.write("\x1b[?1000l"); // disable mouse button tracking
470
+ process.stdout.write("\x1b[?1002l"); // disable mouse drag tracking
453
471
  process.stdout.write("\x1b[?1006l"); // disable SGR mouse mode
454
472
  process.stdout.write("\x1b[?25h");
455
473
  process.stdout.write("\x1b[?1049l");
package/src/sidebar.ts CHANGED
@@ -145,6 +145,10 @@ function buildRenderPlan(sessions: SessionInfo[]): {
145
145
  return { items, displayOrder };
146
146
  }
147
147
 
148
+ function itemHeight(item: RenderItem): number {
149
+ return item.type === "session" ? 2 : 1;
150
+ }
151
+
148
152
  // --- Sidebar class ---
149
153
 
150
154
  export class Sidebar {
@@ -152,9 +156,11 @@ export class Sidebar {
152
156
  private height: number;
153
157
  private sessions: SessionInfo[] = [];
154
158
  private activeSessionId: string | null = null;
159
+ private items: RenderItem[] = [];
155
160
  private displayOrder: number[] = [];
156
161
  private rowToSessionIndex = new Map<number, number>();
157
162
  private activitySet = new Set<string>();
163
+ private scrollOffset = 0;
158
164
 
159
165
  constructor(width: number, height: number) {
160
166
  this.width = width;
@@ -163,8 +169,10 @@ export class Sidebar {
163
169
 
164
170
  updateSessions(sessions: SessionInfo[]): void {
165
171
  this.sessions = sessions;
166
- const { displayOrder } = buildRenderPlan(sessions);
172
+ const { items, displayOrder } = buildRenderPlan(sessions);
173
+ this.items = items;
167
174
  this.displayOrder = displayOrder;
175
+ this.clampScroll();
168
176
  }
169
177
 
170
178
  setActiveSession(id: string): void {
@@ -194,6 +202,41 @@ export class Sidebar {
194
202
  resize(width: number, height: number): void {
195
203
  this.width = width;
196
204
  this.height = height;
205
+ this.clampScroll();
206
+ }
207
+
208
+ scrollBy(delta: number): void {
209
+ this.scrollOffset += delta;
210
+ this.clampScroll();
211
+ }
212
+
213
+ scrollToActive(): void {
214
+ if (!this.activeSessionId) return;
215
+ const viewportHeight = this.height - HEADER_ROWS;
216
+ let vRow = 0;
217
+ for (const item of this.items) {
218
+ const h = itemHeight(item);
219
+ if (item.type === "session") {
220
+ const session = this.sessions[item.sessionIndex];
221
+ if (session?.id === this.activeSessionId) {
222
+ if (vRow < this.scrollOffset) {
223
+ this.scrollOffset = vRow;
224
+ } else if (vRow + h > this.scrollOffset + viewportHeight) {
225
+ this.scrollOffset = vRow + h - viewportHeight;
226
+ }
227
+ this.clampScroll();
228
+ return;
229
+ }
230
+ }
231
+ vRow += h;
232
+ }
233
+ }
234
+
235
+ private clampScroll(): void {
236
+ const totalRows = this.items.reduce((sum, item) => sum + itemHeight(item), 0);
237
+ const viewportHeight = this.height - HEADER_ROWS;
238
+ const maxOffset = Math.max(0, totalRows - viewportHeight);
239
+ this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset));
197
240
  }
198
241
 
199
242
  getGrid(): CellGrid {
@@ -204,152 +247,134 @@ export class Sidebar {
204
247
  writeString(grid, 0, 1, "jmux", { ...ACCENT_ATTRS, bold: true });
205
248
  writeString(grid, 1, 0, "\u2500".repeat(this.width), DIM_ATTRS);
206
249
 
207
- const { items } = buildRenderPlan(this.sessions);
208
- let row = HEADER_ROWS;
250
+ const viewportHeight = this.height - HEADER_ROWS;
251
+ let vRow = 0;
252
+ let totalRows = 0;
209
253
 
210
- for (const item of items) {
211
- if (row >= this.height) break;
254
+ for (const item of this.items) {
255
+ const h = itemHeight(item);
256
+ const screenRow = HEADER_ROWS + vRow - this.scrollOffset;
212
257
 
213
- if (item.type === "group-header") {
214
- let label = item.label;
215
- if (label.length > this.width - 2) {
216
- label = label.slice(0, this.width - 3) + "\u2026";
217
- }
218
- writeString(grid, row, 1, label, GROUP_HEADER_ATTRS);
219
- row++;
258
+ // Skip items entirely above viewport
259
+ if (screenRow + h <= HEADER_ROWS) {
260
+ vRow += h;
261
+ totalRows += h;
220
262
  continue;
221
263
  }
222
-
223
- if (item.type === "spacer") {
224
- row++;
264
+ // Track total rows even after viewport
265
+ if (screenRow >= this.height) {
266
+ vRow += h;
267
+ totalRows += h;
225
268
  continue;
226
269
  }
227
270
 
228
- const sessionIdx = item.sessionIndex;
229
- const session = this.sessions[sessionIdx];
230
- if (!session) continue;
231
-
232
- const nameRow = row;
233
- const isActive = session.id === this.activeSessionId;
234
- const hasActivity = this.activitySet.has(session.id);
235
-
236
- if (item.grouped) {
237
- // Grouped: two rows — name + window count, then subdirectory + branch
238
- const detailRow = row + 1;
239
-
240
- this.rowToSessionIndex.set(nameRow, sessionIdx);
241
- if (detailRow < this.height) {
242
- this.rowToSessionIndex.set(detailRow, sessionIdx);
243
- }
244
-
245
- if (isActive) {
246
- writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
247
- if (detailRow < this.height) {
248
- writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
249
- }
271
+ if (item.type === "group-header") {
272
+ let label = item.label;
273
+ if (label.length > this.width - 2) {
274
+ label = label.slice(0, this.width - 3) + "\u2026";
250
275
  }
276
+ writeString(grid, screenRow, 1, label, GROUP_HEADER_ATTRS);
277
+ } else if (item.type === "spacer") {
278
+ // nothing to render
279
+ } else {
280
+ this.renderSession(grid, screenRow, item);
281
+ }
251
282
 
252
- if (session.attention) {
253
- writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
254
- } else if (hasActivity) {
255
- writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
256
- }
283
+ vRow += h;
284
+ totalRows += h;
285
+ }
257
286
 
258
- const windowCountStr = `${session.windowCount}w`;
259
- const windowCountCol = this.width - windowCountStr.length - 1;
260
- const nameStart = 3;
261
- const nameMaxLen = windowCountCol - 1 - nameStart;
262
- let displayName = session.name;
263
- if (displayName.length > nameMaxLen) {
264
- displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
265
- }
287
+ // Scroll indicators
288
+ if (this.scrollOffset > 0) {
289
+ writeString(grid, HEADER_ROWS, this.width - 1, "\u25b2", DIM_ATTRS);
290
+ }
291
+ if (this.scrollOffset + viewportHeight < totalRows) {
292
+ writeString(grid, this.height - 1, this.width - 1, "\u25bc", DIM_ATTRS);
293
+ }
266
294
 
267
- const nameAttrs: CellAttrs = isActive
268
- ? { ...ACTIVE_NAME_ATTRS }
269
- : { ...INACTIVE_NAME_ATTRS };
270
- writeString(grid, nameRow, nameStart, displayName, nameAttrs);
295
+ return grid;
296
+ }
271
297
 
272
- if (windowCountCol > nameStart) {
273
- writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
274
- }
298
+ private renderSession(
299
+ grid: CellGrid,
300
+ nameRow: number,
301
+ item: Extract<RenderItem, { type: "session" }>,
302
+ ): void {
303
+ const sessionIdx = item.sessionIndex;
304
+ const session = this.sessions[sessionIdx];
305
+ if (!session) return;
306
+
307
+ const detailRow = nameRow + 1;
308
+ const isActive = session.id === this.activeSessionId;
309
+ const hasActivity = this.activitySet.has(session.id);
310
+
311
+ // Map rows to session for click handling
312
+ this.rowToSessionIndex.set(nameRow, sessionIdx);
313
+ if (detailRow < this.height) {
314
+ this.rowToSessionIndex.set(detailRow, sessionIdx);
315
+ }
275
316
 
276
- // Detail line: branch name
277
- if (detailRow < this.height && session.gitBranch) {
278
- const detailStart = 3;
279
- const maxLen = this.width - detailStart - 1;
280
- let branch = session.gitBranch;
281
- if (branch.length > maxLen) {
282
- branch = branch.slice(0, maxLen - 1) + "\u2026";
283
- }
284
- writeString(grid, detailRow, detailStart, branch, DIM_ATTRS);
285
- }
317
+ // Active marker
318
+ if (isActive) {
319
+ writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
320
+ writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
321
+ }
286
322
 
287
- row += 2;
288
- } else {
289
- // Ungrouped: two rows (name + detail)
290
- const detailRow = row + 1;
323
+ // Indicator
324
+ if (session.attention) {
325
+ writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
326
+ } else if (hasActivity) {
327
+ writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
328
+ }
291
329
 
292
- this.rowToSessionIndex.set(nameRow, sessionIdx);
293
- if (detailRow < this.height) {
294
- this.rowToSessionIndex.set(detailRow, sessionIdx);
295
- }
330
+ // Name row: name + window count
331
+ const windowCountStr = `${session.windowCount}w`;
332
+ const windowCountCol = this.width - windowCountStr.length - 1;
333
+ const nameStart = 3;
334
+ const nameMaxLen = windowCountCol - 1 - nameStart;
335
+ let displayName = session.name;
336
+ if (displayName.length > nameMaxLen) {
337
+ displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
338
+ }
296
339
 
297
- if (isActive) {
298
- writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
299
- if (detailRow < this.height) {
300
- writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
301
- }
302
- }
340
+ const nameAttrs: CellAttrs = isActive
341
+ ? { ...ACTIVE_NAME_ATTRS }
342
+ : { ...INACTIVE_NAME_ATTRS };
343
+ writeString(grid, nameRow, nameStart, displayName, nameAttrs);
303
344
 
304
- if (session.attention) {
305
- writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
306
- } else if (hasActivity) {
307
- writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
308
- }
345
+ if (windowCountCol > nameStart) {
346
+ writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
347
+ }
309
348
 
310
- const windowCountStr = `${session.windowCount}w`;
311
- const windowCountCol = this.width - windowCountStr.length - 1;
312
- const nameStart = 3;
313
- const nameMaxLen = windowCountCol - 1 - nameStart;
314
- let displayName = session.name;
315
- if (displayName.length > nameMaxLen) {
316
- displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
349
+ // Detail line
350
+ if (item.grouped) {
351
+ if (session.gitBranch) {
352
+ const detailStart = 3;
353
+ const maxLen = this.width - detailStart - 1;
354
+ let branch = session.gitBranch;
355
+ if (branch.length > maxLen) {
356
+ branch = branch.slice(0, maxLen - 1) + "\u2026";
317
357
  }
318
-
319
- const nameAttrs: CellAttrs = isActive
320
- ? { ...ACTIVE_NAME_ATTRS }
321
- : { ...INACTIVE_NAME_ATTRS };
322
- writeString(grid, nameRow, nameStart, displayName, nameAttrs);
323
-
324
- if (windowCountCol > nameStart) {
325
- writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
358
+ writeString(grid, detailRow, detailStart, branch, DIM_ATTRS);
359
+ }
360
+ } else {
361
+ const detailStart = 3;
362
+ let branchCols = 0;
363
+ if (session.gitBranch) {
364
+ const branchCol = this.width - session.gitBranch.length - 1;
365
+ if (branchCol > detailStart + 1) {
366
+ writeString(grid, detailRow, branchCol, session.gitBranch, DIM_ATTRS);
367
+ branchCols = session.gitBranch.length + 2;
326
368
  }
327
-
328
- // Detail line
329
- if (detailRow < this.height) {
330
- const detailStart = 3;
331
- let branchCols = 0;
332
- if (session.gitBranch) {
333
- const branchCol = this.width - session.gitBranch.length - 1;
334
- if (branchCol > detailStart + 1) {
335
- writeString(grid, detailRow, branchCol, session.gitBranch, DIM_ATTRS);
336
- branchCols = session.gitBranch.length + 2;
337
- }
338
- }
339
- if (session.directory !== undefined) {
340
- const dirMaxLen = this.width - detailStart - branchCols - 1;
341
- let displayDir = session.directory;
342
- if (displayDir.length > dirMaxLen) {
343
- displayDir = displayDir.slice(0, dirMaxLen - 1) + "\u2026";
344
- }
345
- writeString(grid, detailRow, detailStart, displayDir, DIM_ATTRS);
346
- }
369
+ }
370
+ if (session.directory !== undefined) {
371
+ const dirMaxLen = this.width - detailStart - branchCols - 1;
372
+ let displayDir = session.directory;
373
+ if (displayDir.length > dirMaxLen) {
374
+ displayDir = displayDir.slice(0, dirMaxLen - 1) + "\u2026";
347
375
  }
348
-
349
- row += 2;
376
+ writeString(grid, detailRow, detailStart, displayDir, DIM_ATTRS);
350
377
  }
351
378
  }
352
-
353
- return grid;
354
379
  }
355
380
  }