@jx0/jmux 0.3.3 → 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.3",
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,6 +66,12 @@ export class InputRouter {
65
66
  const mouse = parseSgrMouse(data);
66
67
  if (mouse && this.sidebarVisible) {
67
68
  if (mouse.x <= this.opts.sidebarCols) {
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
+ }
68
75
  // Click in sidebar region (ignore drags — button bit 5 = motion)
69
76
  if (!mouse.release && (mouse.button & 32) === 0) {
70
77
  this.opts.onSidebarClick(mouse.y - 1); // 0-indexed row
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.3";
15
+ const VERSION = "0.3.4";
16
16
 
17
17
  const HELP = `jmux — a persistent session sidebar for tmux
18
18
 
@@ -255,6 +255,7 @@ async function switchSession(sessionId: string): Promise<void> {
255
255
  sidebar.setActivity(sessionId, false);
256
256
  currentSessionId = sessionId;
257
257
  sidebar.setActiveSession(sessionId);
258
+ sidebar.scrollToActive();
258
259
  renderFrame();
259
260
 
260
261
  // Clear attention flag if set
@@ -298,6 +299,10 @@ const inputRouter = new InputRouter(
298
299
  const session = sidebar.getSessionByRow(row);
299
300
  if (session) switchSession(session.id);
300
301
  },
302
+ onSidebarScroll: (delta) => {
303
+ sidebar.scrollBy(delta);
304
+ scheduleRender();
305
+ },
301
306
  onSessionPrev: () => switchByOffset(-1),
302
307
  onSessionNext: () => switchByOffset(1),
303
308
  },
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
  }