Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/renderer/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ function syncViewportScrollArea(term: Terminal): void {
} catch { /* viewport may not be ready */ }
}

/**
* True when the terminal is showing its normal (scrollback) buffer.
*
* Alt-screen TUIs (vim, less, htop, and Copilot CLI's full-screen UI with
* its own scrollbar) render into the alternate buffer, which has no
* scrollback and is fixed to the viewport size. Those apps own their own
* scrolling, so tmax's viewport scroll-sync workarounds below must stay out
* of their way — otherwise they fight the app's scrollbar (e.g. a stray
* scrollToBottom) instead of helping.
*/
function isNormalBuffer(term: Terminal): boolean {
return term.buffer.active.type === 'normal';
}

const WSL_PROMPT_DEBOUNCE_MS = 200;
const WSL_PROMPT_FALLBACK_MS = 5000;

Expand Down Expand Up @@ -1258,6 +1272,9 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
const computeScrolledAway = () => {
try {
const buf = term.buffer.active;
// Alt-screen has no scrollback — you can't be "scrolled away" from a
// live prompt that doesn't exist, so hide the jump-to-bottom arrow.
if (buf.type !== 'normal') return false;
if (buf.viewportY < buf.baseY) return true;
const vp = containerRef.current?.querySelector('.xterm-viewport') as HTMLElement | null;
if (vp && vp.scrollHeight - vp.clientHeight - vp.scrollTop > 2) return true;
Expand Down Expand Up @@ -1289,6 +1306,9 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
try {
const vp = viewportScrollEl;
if (!vp) return;
// Alt-screen apps own the viewport and have no scrollback; mapping
// DOM scrollTop back to a buffer line would fight the app's own UI.
if (!isNormalBuffer(term)) return;
const cellHeight =
(term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height?: number } } } } } })
._core?._renderService?.dimensions?.css?.cell?.height || 0;
Expand Down Expand Up @@ -1433,6 +1453,9 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
// means max-scroll always lands at the live prompt.
const wheelPreSyncHandler = (e: WheelEvent) => {
if (e.deltaY === 0 || e.shiftKey) return;
// Alt-screen apps own their scroll; resyncing the (nonexistent)
// scrollback area just fights the app's own scrollbar.
if (!isNormalBuffer(term)) return;
try {
const v: any = (term as any)?._core?.viewport;
if (!v) return;
Expand Down Expand Up @@ -1467,6 +1490,9 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
// but didn't — otherwise we'd thrash sync calls at scroll boundaries
// and on shift/horizontal wheels.
if (e.deltaY === 0 || e.shiftKey) return;
// In alt-screen there's no scrollback to recover into; a wheel that
// "does nothing" is the expected behavior, so don't resync.
if (!isNormalBuffer(term)) return;
const viewport = containerRef.current?.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) return;
const before = viewport.scrollTop;
Expand All @@ -1484,6 +1510,8 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
// would be) forces a sync. Useful when the auto-recovery hasn't yet
// kicked in - the user can manually refresh the scroll area.
const manualSyncHandler = (e: MouseEvent) => {
// No scrollback area to refresh while an alt-screen app is up.
if (!isNormalBuffer(term)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// Only fire if the dblclick was within ~16px of the right edge.
Expand All @@ -1500,6 +1528,9 @@ const TerminalPanel: React.FC<TerminalPanelProps> = ({ terminalId, floatTitleBar
// so it doesn't interfere with mid-scrolling.
const wheelClampHandler = (e: WheelEvent) => {
if (e.deltaY <= 0 || e.shiftKey) return;
// Never snap an alt-screen app to "bottom" — it has no scrollback and
// scrollToBottom() would yank the full-screen TUI's own view.
if (!isNormalBuffer(term)) return;
const viewport = containerRef.current?.querySelector('.xterm-viewport') as HTMLElement | null;
if (!viewport) return;
requestAnimationFrame(() => {
Expand Down
134 changes: 134 additions & 0 deletions tests/e2e/task-altscreen-scroll-gating.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Alt-screen scroll-gating: tmax's xterm 5.5 viewport scroll-sync
// workarounds (wheelPreSyncHandler, wheelRecoveryHandler, wheelClampHandler,
// manualSyncHandler, syncBufferToScrollbar, computeScrolledAway) are written
// for the NORMAL scrollback buffer. Full-screen TUIs (vim, less, htop, and
// Copilot CLI's full-screen scrollbar) run in the ALTERNATE buffer, which has
// no scrollback and where the app owns its own scrolling. The workarounds are
// now gated on `term.buffer.active.type === 'normal'` so they stay out of the
// app's way instead of fighting it.
//
// Test 1 proves wheelPreSyncHandler does NOT mutate the viewport cache while
// in alt-screen (on the un-gated code it would force-resync and overwrite the
// staged cache value). Test 2 proves the floating jump-to-bottom arrow hides
// in alt-screen and reappears after the app exits alt-screen.
import { test, expect, Page } from '@playwright/test';
import { launchTmax, getStoreState } from './fixtures/launch';

async function writeTerm(window: Page, id: string, data: string): Promise<void> {
await window.evaluate((args: { id: string; data: string }) => {
const entry = (window as any).__getTerminalEntry(args.id);
return new Promise<void>((resolve) => entry.terminal.write(args.data, () => resolve()));
}, { id, data });
}

test('wheel in alt-screen does NOT force-sync the viewport cache', async () => {
const { window, close } = await launchTmax();
try {
await window.waitForSelector('.terminal-panel', { timeout: 15_000 });
await window.waitForTimeout(2500);

const state = await getStoreState(window);
const terminalId = state.terminalIds[0];

// Seed normal-buffer scrollback so the viewport has real geometry, then
// scroll up off the bottom.
const filler: string[] = [];
for (let i = 0; i < 200; i++) filler.push(`base-line-${i.toString().padStart(4, '0')}`);
await writeTerm(window, terminalId, '\r\n' + filler.join('\r\n') + '\r\n');
await window.waitForTimeout(300);
await window.evaluate((id) => {
(window as any).__getTerminalEntry(id).terminal.scrollLines(-50);
}, terminalId);
await window.waitForTimeout(150);

// Enter alt-screen + enable mouse tracking (what a full-screen TUI does).
await writeTerm(window, terminalId, '\x1b[?1049h\x1b[?1000h\x1b[?1006h');
await window.waitForTimeout(150);

const result = await window.evaluate((id) => {
const entry = (window as any).__getTerminalEntry(id);
const term = entry.terminal;
const v: any = (term as any)._core.viewport;
const vp = (entry.container || document).querySelector('.xterm-viewport') as HTMLElement;

// Sanity: we are in the alternate buffer.
const bufType = term.buffer.active.type;

// Stage a cache lag the un-gated wheelPreSyncHandler would "fix":
// bufLen > _lastRecordedBufferLength triggers a force-resync that
// overwrites this sentinel. The gate must make it a no-op.
const altLen = term.buffer.active.length;
const sentinel = Math.max(0, altLen - 20);
v._lastRecordedBufferLength = sentinel;

const beforeViewportY = term.buffer.active.viewportY;

const ev = new WheelEvent('wheel', {
deltaY: 100000,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
bubbles: true,
cancelable: true,
});
vp.dispatchEvent(ev);

return { bufType, sentinel, afterRecorded: v._lastRecordedBufferLength, beforeViewportY };
}, terminalId);

// Let any rAF the un-gated path would schedule settle.
await window.waitForTimeout(200);

const after = await window.evaluate((id) => {
const term = (window as any).__getTerminalEntry(id).terminal;
const v: any = (term as any)._core.viewport;
return { recorded: v._lastRecordedBufferLength, viewportY: term.buffer.active.viewportY };
}, terminalId);

// We must actually be in alt-screen for the test to be meaningful.
expect(result.bufType).toBe('alternate');
// The gated wheelPreSyncHandler bailed: the staged cache value is intact.
// On the un-gated code this would be -1 or the recomputed buffer length.
expect(result.afterRecorded).toBe(result.sentinel);
expect(after.recorded).toBe(result.sentinel);
// wheelClampHandler did not yank the alt-screen view.
expect(after.viewportY).toBe(result.beforeViewportY);
} finally {
await close();
}
});

test('jump-to-bottom arrow hides in alt-screen and reappears on exit', async () => {
const { window, close } = await launchTmax();
try {
await window.waitForSelector('.terminal-panel', { timeout: 15_000 });
await window.waitForTimeout(2500);

const state = await getStoreState(window);
const terminalId = state.terminalIds[0];

const filler: string[] = [];
for (let i = 0; i < 200; i++) filler.push(`away-line-${i.toString().padStart(4, '0')}`);
await writeTerm(window, terminalId, '\r\n' + filler.join('\r\n') + '\r\n');
await window.waitForTimeout(300);

// Scroll up so the normal buffer is "scrolled away" → arrow appears.
await window.evaluate((id) => {
(window as any).__getTerminalEntry(id).terminal.scrollLines(-50);
}, terminalId);
// > 750ms scroll-away poll so React state settles regardless of events.
await window.waitForTimeout(900);
expect(await window.locator('.terminal-jump-to-bottom').count()).toBe(1);

// Enter alt-screen: the arrow must hide (no scrollback to be away from).
await writeTerm(window, terminalId, '\x1b[?1049h');
await window.waitForTimeout(900);
expect(await window.locator('.terminal-jump-to-bottom').count()).toBe(0);

// Exit alt-screen: normal-buffer scroll position is restored, so the
// arrow reappears within one poll cycle.
await writeTerm(window, terminalId, '\x1b[?1049l');
await window.waitForTimeout(900);
expect(await window.locator('.terminal-jump-to-bottom').count()).toBe(1);
} finally {
await close();
}
});
Loading