Working scrollback within tmux
This commit is contained in:
@@ -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
@@ -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
@@ -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 Pear’s 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);
|
||||
|
||||
Reference in New Issue
Block a user