Working scrollback within tmux

This commit is contained in:
2026-04-25 06:31:10 +00:00
parent b6b935fabc
commit 81721b70ce
3 changed files with 144 additions and 14 deletions
+33 -12
View File
@@ -207,17 +207,39 @@ export function Terminal({ booterId }: Props) {
});
const fit = new FitAddon();
term.loadAddon(fit);
const basePath = appBasePath();
term.open(el);
let tmuxWheelScrollTimer: ReturnType<typeof setTimeout> | null = null;
let pendingTmuxWheelScroll: { direction: "up" | "down"; lines: number } | null = null;
const flushTmuxWheelScroll = () => {
if (tmuxWheelScrollTimer) {
clearTimeout(tmuxWheelScrollTimer);
tmuxWheelScrollTimer = null;
}
const pending = pendingTmuxWheelScroll;
pendingTmuxWheelScroll = null;
if (!pending) return;
void fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/scroll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pending),
}).catch(() => {
/* ignore */
});
};
const scheduleTmuxWheelScroll = (direction: "up" | "down", lines: number) => {
if (pendingTmuxWheelScroll && pendingTmuxWheelScroll.direction !== direction) {
flushTmuxWheelScroll();
}
pendingTmuxWheelScroll = {
direction,
lines: Math.min(200, (pendingTmuxWheelScroll?.lines ?? 0) + lines),
};
if (tmuxWheelScrollTimer) clearTimeout(tmuxWheelScrollTimer);
tmuxWheelScrollTimer = setTimeout(flushTmuxWheelScroll, 24);
};
term.attachCustomWheelEventHandler((ev) => {
if (ev.deltaY === 0) return true;
const active = term.buffer.active;
const hasXtermScrollback = active.baseY > 0;
if (!hasXtermScrollback) {
// xterm otherwise converts wheel into up/down keys when there is no scrollback.
ev.preventDefault();
ev.stopPropagation();
return false;
}
const mode = ev.deltaMode;
const rawLines =
mode === WheelEvent.DOM_DELTA_LINE
@@ -226,9 +248,8 @@ export function Terminal({ booterId }: Props) {
? ev.deltaY * Math.max(1, term.rows)
: ev.deltaY / WHEEL_PX_PER_LINE;
const whole = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines);
const lines = whole === 0 ? (rawLines > 0 ? 1 : -1) : whole;
term.scrollLines(lines);
// Prevent xterm from forwarding wheel as terminal input (which can trigger shell command history).
const lines = Math.min(200, Math.abs(whole === 0 ? (rawLines > 0 ? 1 : -1) : whole));
scheduleTmuxWheelScroll(rawLines < 0 ? "up" : "down", lines);
ev.preventDefault();
ev.stopPropagation();
return false;
@@ -237,7 +258,6 @@ export function Terminal({ booterId }: Props) {
term.focus();
});
const basePath = appBasePath();
let resizePostTimer: ReturnType<typeof setTimeout> | null = null;
let lastResizePosted = { cols: 0, rows: 0 };
const scheduleResizeNotify = () => {
@@ -630,6 +650,7 @@ export function Terminal({ booterId }: Props) {
outputMatchBufRef.current = "";
setKernelRebootBanner(null);
clearReconnect();
if (tmuxWheelScrollTimer) clearTimeout(tmuxWheelScrollTimer);
if (resizePostTimer) clearTimeout(resizePostTimer);
if (roRaf) cancelAnimationFrame(roRaf);
ro.disconnect();
+26 -1
View File
@@ -23,7 +23,7 @@ import {
useSessionHomeBareOsLayout,
} from "./barecloud-config";
import { pearHomeDir } from "./pm2-pear";
import { useTerminalTmuxPersistence } from "./tmux-pear";
import { scrollTmuxPearSession, useTerminalTmuxPersistence } from "./tmux-pear";
import { collectHostStats } from "./host-stats";
import { publishBooterEvent } from "./app-ws-events";
@@ -640,5 +640,30 @@ export async function handleHttpApi(
return true;
}
const scrollM = pathname.match(/^\/api\/terminal\/([^/]+)\/scroll\/?$/);
if (scrollM && method === "POST") {
const id = booterIdFromPathSegment(scrollM[1]!);
if (!getBooterById(id)) {
sendJson(res, 404, { error: "Unknown booter id" });
return true;
}
let body: { direction?: string; lines?: number };
try {
body = (await readJsonBody(req)) as { direction?: string; lines?: number };
} catch {
sendJson(res, 400, { error: "Invalid JSON" });
return true;
}
const direction = body?.direction === "up" || body?.direction === "down" ? body.direction : null;
const lines = Number(body?.lines);
if (!direction || !Number.isFinite(lines)) {
sendJson(res, 400, { error: "direction and lines are required" });
return true;
}
const ok = scrollTmuxPearSession(id, direction, lines);
sendJson(res, ok ? 200 : 409, { ok });
return true;
}
return false;
}
+85 -1
View File
@@ -98,12 +98,75 @@ function tmuxHistoryLimit(): number {
return Math.min(n, 200_000);
}
function configureTmuxServerMouse(): void {
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-g", "mouse", "on"], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
} catch {
/* ignore */
}
try {
execFileSync(
tmuxBin(),
[
...tmuxSocketArgs(),
"bind-key",
"-n",
"WheelUpPane",
"if-shell",
"-F",
"#{||:#{pane_in_mode},#{mouse_any_flag},#{alternate_on}}",
"send-keys -M",
"copy-mode -e; send-keys -X -N 3 scroll-up",
],
{
stdio: "ignore",
env: tmuxClientEnv(process.env),
},
);
} catch {
/* ignore */
}
try {
execFileSync(
tmuxBin(),
[
...tmuxSocketArgs(),
"bind-key",
"-n",
"WheelDownPane",
"if-shell",
"-F",
"#{||:#{pane_in_mode},#{mouse_any_flag},#{alternate_on}}",
"send-keys -M",
],
{
stdio: "ignore",
env: tmuxClientEnv(process.env),
},
);
} catch {
/* ignore */
}
}
/**
* Keep managed BareCloud tmux sessions close to tmux defaults while retaining resumable pane history.
* Browser/xterm owns scrolling; tmux keeps its normal mouse, alternate-screen, and terminal capability behavior.
* Browser/xterm owns the DOM viewport; tmux mouse mode lets wheel input drive tmux copy-mode/history.
*/
function configureTmuxSessionForWebTerminal(booterId: string): void {
configureTmuxServerMouse();
const target = tmuxExactSessionTarget(booterId);
try {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-t", target, "mouse", "on"], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
} catch {
/* ignore */
}
try {
execFileSync(
tmuxBin(),
@@ -131,6 +194,27 @@ export function hasTmuxPearSession(booterId: string): boolean {
}
}
export function scrollTmuxPearSession(booterId: string, direction: "up" | "down", lines: number): boolean {
if (!useTerminalTmuxPersistence()) return false;
const count = Math.max(1, Math.min(200, Math.floor(lines)));
const target = `${tmuxSessionName(booterId)}:`;
try {
if (direction === "up") {
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "copy-mode", "-e", "-t", target], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
}
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "send-keys", "-t", target, "-X", "-N", String(count), `scroll-${direction}`], {
stdio: "ignore",
env: tmuxClientEnv(process.env),
});
return true;
} catch {
return false;
}
}
/** Build `tmux new-session -e VAR=value` pairs from Pears per-booter env (tmux does not use the client `env` for the pane). */
function tmuxNewSessionEnvArgs(booterId: string, termEnv: string): string[] {
const managed = pearManagedEnv(booterId, termEnv);