handmux 0.11.0 → 0.12.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/public/index.html CHANGED
@@ -42,8 +42,8 @@
42
42
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
43
43
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
44
44
  </style>
45
- <script type="module" crossorigin src="/assets/index-9fmzTpZ-.js"></script>
46
- <link rel="stylesheet" crossorigin href="/assets/index-CJZ28YE2.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CJZ28YE2.css"></noscript>
45
+ <script type="module" crossorigin src="/assets/index-DpMN2jCF.js"></script>
46
+ <link rel="stylesheet" crossorigin href="/assets/index-DJNPeU_h.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-DJNPeU_h.css"></noscript>
47
47
  </head>
48
48
  <body>
49
49
  <div id="boot-splash" aria-hidden="true">
package/src/httpApi.js CHANGED
@@ -417,7 +417,7 @@ export function createApiRouter({
417
417
  // all key off the same trimmed capture. See trimCapture.js.
418
418
  const raw = await commands.capturePane(req.query.pane, lines);
419
419
  const ansi = capTrailingBlankRows(raw);
420
- const { width, height, cursorX, cursorY, cursorVisible } = await commands.paneInfo(req.query.pane);
420
+ const { width, height, cursorX, cursorY, cursorVisible, altScreen, mouseAware } = await commands.paneInfo(req.query.pane);
421
421
  // The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
422
422
  // capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
423
423
  // less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
@@ -431,11 +431,13 @@ export function createApiRouter({
431
431
  // an unchanged screen returns 204 (empty) so an idle pane stops re-sending the whole capture.
432
432
  // The cursor is folded in so a bare left/right (which moves the cursor but not the text) still
433
433
  // yields a fresh frame — otherwise the move would 204 and the cursor would never visibly track.
434
+ // mouseAware is folded in too: toggling an app's mouse mode (e.g. `:set mouse=a` in vim) changes
435
+ // whether a swipe should wheel-scroll or just hint, and the client only re-reads it on a fresh frame.
434
436
  const hash = createHash('sha1')
435
- .update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${ansi}`)
437
+ .update(`${width}x${height}\n${cur.col},${cursorY},${cur.vis ? 1 : 0}\n${altScreen ? 1 : 0}${mouseAware ? 'm' : ''}\n${ansi}`)
436
438
  .digest('hex').slice(0, 16);
437
439
  if (req.query.since === hash) return res.status(204).end();
438
- const json = JSON.stringify({ ansi, width, height, hash, cur });
440
+ const json = JSON.stringify({ ansi, width, height, hash, cur, alt: altScreen, mouseAware });
439
441
  res.set('Content-Type', 'application/json');
440
442
  res.set('Vary', 'Accept-Encoding'); // both 200 branches vary on encoding (correct for any caching proxy)
441
443
  // Capture text is mostly SGR codes + spaces — gzip crushes it ~10x. (204s are empty, never gzipped.)
@@ -507,6 +509,29 @@ export function createApiRouter({
507
509
  } catch (e) { next(e); }
508
510
  });
509
511
 
512
+ // Swipe-to-scroll for full-screen (ALT-screen) apps: the client can't scroll the alt buffer itself
513
+ // (no scrollback), so it forwards the finger travel here and we inject wheel events the app scrolls on.
514
+ // SAFETY: we re-read the pane's mouse state at inject time and REFUSE unless it's actually reporting
515
+ // mouse — otherwise the raw escape bytes would print into the shell as literal `<65;…M` garbage. The
516
+ // client's cached mouseAware is only a UX hint; this check is the real guard (mouse mode can toggle
517
+ // between the poll and the gesture). Positioned at the pane centre so a split-aware app scrolls the
518
+ // region the finger is over; a lone full-screen app ignores the position.
519
+ r.post('/scroll', async (req, res, next) => {
520
+ const { pane, dir, lines } = req.body || {};
521
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
522
+ if (dir !== 'up' && dir !== 'down') return res.status(400).json({ error: 'bad dir' });
523
+ try {
524
+ const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
525
+ if (!altScreen || !mouseAware) return res.json({ ok: false, reason: 'no-mouse' });
526
+ await commands.sendWheel(pane, dir, lines, {
527
+ sgr: mouseSgr,
528
+ col: Math.max(1, Math.floor((width || 2) / 2)),
529
+ row: Math.max(1, Math.floor((height || 2) / 2)),
530
+ });
531
+ res.json({ ok: true });
532
+ } catch (e) { next(e); }
533
+ });
534
+
510
535
  // --- Capabilities probe ---------------------------------------------------------------------
511
536
  // Optional integrations are configured per-install (open-source installs ship without keys), so the
512
537
  // client asks what's actually available and hides controls that can't work — e.g. the mic when no
@@ -91,16 +91,46 @@ export async function capturePane(paneId, linesBack) {
91
91
  // visibility — it snapshots cells only — so we read them here for the client to re-place xterm's own
92
92
  // cursor onto Claude's input cell). cursor_x/cursor_y are 0-based, relative to the visible screen;
93
93
  // cursor_flag is DECTCEM visibility (1 while Claude accepts input, 0 while it's working / in a dialog).
94
+ // alternate_on = the pane is on the ALT screen (a full-screen app: vim/htop/less/a mouse-mode TUI). The
95
+ // alt buffer has no scrollback, so the phone can't swipe-scroll it. mouse_any_flag = the app has requested
96
+ // mouse reporting (any mode); mouse_sgr_flag = it negotiated the SGR (1006) encoding. Together they let the
97
+ // client translate a swipe into wheel events the app scrolls on (sendWheel) instead of a dead swipe.
94
98
  export async function paneInfo(paneId) {
95
99
  const out = await runTmux(['display-message', '-p', '-t', paneId,
96
- '#{pane_width}\t#{pane_height}\t#{cursor_x}\t#{cursor_y}\t#{cursor_flag}']);
97
- const [width, height, cx, cy, cflag] = out.trim().split('\t');
100
+ '#{pane_width}\t#{pane_height}\t#{cursor_x}\t#{cursor_y}\t#{cursor_flag}\t#{alternate_on}\t#{mouse_any_flag}\t#{mouse_sgr_flag}']);
101
+ const [width, height, cx, cy, cflag, alt, mAny, mSgr] = out.trim().split('\t');
98
102
  return {
99
103
  width: Number(width), height: Number(height),
100
104
  cursorX: Number(cx), cursorY: Number(cy), cursorVisible: cflag === '1',
105
+ altScreen: alt === '1', mouseAware: mAny === '1', mouseSgr: mSgr === '1',
101
106
  };
102
107
  }
103
108
 
109
+ // Inject mouse-wheel scroll events so a full-screen app on the ALT screen scrolls itself — exactly what a
110
+ // desktop wheel does. dir 'up' scrolls toward earlier content (SGR button 64), 'down' toward later (65);
111
+ // `count` notches go out in ONE send-keys as a repeated escape run. Only meaningful when the app requested
112
+ // mouse reporting — the CALLER MUST verify paneInfo().mouseAware first, or these escape bytes land in the
113
+ // shell as literal text. `sgr` picks the 1006 encoding the app negotiated (else the legacy X10 form);
114
+ // `col`/`row` are the 1-based pointer position the event reports at (the pane centre — irrelevant to a lone
115
+ // full-screen app, but lets a split-aware app scroll the region under the finger).
116
+ export async function sendWheel(paneId, dir, count, opts = {}) {
117
+ await runTmux(['send-keys', '-t', paneId, '-l', '--', wheelSeq(dir, count, opts)]);
118
+ }
119
+
120
+ // Pure: build the terminal byte run for `count` wheel notches (kept separate so the encoding is unit-
121
+ // tested without spawning tmux). `count` is clamped to 1..60 (one flick shouldn't inject hundreds).
122
+ export function wheelSeq(dir, count, { sgr = true, col = 1, row = 1 } = {}) {
123
+ const n = Math.min(Math.max(Math.trunc(Number(count)) || 1, 1), 60);
124
+ const btn = dir === 'up' ? 64 : 65;
125
+ const c = Math.max(1, Math.trunc(col) || 1);
126
+ const r = Math.max(1, Math.trunc(row) || 1);
127
+ const unit = sgr
128
+ ? `\x1b[<${btn};${c};${r}M`
129
+ // legacy X10: ESC [ M <btn+32> <col+32> <row+32>, each coordinate byte capped at 223.
130
+ : `\x1b[M${String.fromCharCode(btn + 32, Math.min(c, 223) + 32, Math.min(r, 223) + 32)}`;
131
+ return unit.repeat(n);
132
+ }
133
+
104
134
  // Resolve a pane to its tmux session name + window for routing a notification back to it. The hook
105
135
  // gives us only $TMUX_PANE; this turns "%263" into the session/window the phone navigates by.
106
136
  export async function paneLocation(paneId) {