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/README.md +14 -3
- package/README.zh-CN.md +16 -3
- package/package.json +5 -5
- package/public/assets/index-DJNPeU_h.css +32 -0
- package/public/assets/index-DpMN2jCF.js +159 -0
- package/public/index.html +2 -2
- package/src/httpApi.js +28 -3
- package/src/tmux/commands.js +32 -2
- package/public/assets/index-9fmzTpZ-.js +0 -159
- package/public/assets/index-CJZ28YE2.css +0 -32
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-
|
|
46
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
package/src/tmux/commands.js
CHANGED
|
@@ -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) {
|