Skip to content
Open
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
166 changes: 157 additions & 9 deletions app/tabs/sessions/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Dimensions,
AccessibilityInfo,
TouchableOpacity,
type LayoutChangeEvent,
} from "react-native";
import { WebView } from "react-native-webview";
import { ChevronDown } from "lucide-react-native";
Expand Down Expand Up @@ -96,6 +97,19 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
const wsManagerRef = useRef<NativeWebSocketManager | null>(null);
const terminalColsRef = useRef(80);
const terminalRowsRef = useRef(24);
// Pixel height of the visible terminal area as measured by RN layout.
// The WebView is shrunk by the TabBar/KeyboardBar/system-keyboard via the
// parent's marginBottom, but inside the WebView `100vh`/`window.innerHeight`
// is unreliable (WKWebView reports stale values after a frame resize). Pushing
// the exact laid-out height lets xterm compute the correct row count, so TUI
// apps (Claude Code, Codex, …) draw their bottom input row inside the visible
// area instead of behind the chrome.
const viewportHeightRef = useRef<number | null>(null);
// Debounces onLayout pushes during LayoutAnimation / keyboard slide so the
// pty isn't spammed with resize storms (each resize → SIGWINCH → TUI redraw).
const viewportDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const pendingDataRef = useRef<string[]>([]);
const dataFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
Expand Down Expand Up @@ -294,7 +308,20 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
.xterm-viewport {
width: 100% !important;
height: 100% !important;
-webkit-overflow-scrolling: touch;
overflow: hidden !important;
-webkit-overflow-scrolling: auto;
}

/* Disable native touch-scrolling of the embedded terminal at the source.
The terminal is scroll-driven exclusively by JS (synthetic WheelEvent →
viewport.scrollTop). Without this, iOS WKWebView / Android WebView's
native touch scroll of .xterm-viewport bubbles to the page when the
(alternate) buffer is at its top, scrolling the whole WebView instead of
the terminal. */
html, body, #terminal, .xterm, .xterm-viewport, .xterm-screen {
touch-action: none;
-webkit-touch-action: none;
-ms-touch-action: none;
}

.xterm {
Expand Down Expand Up @@ -408,7 +435,7 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
allowProposedApi: true,
disableStdin: true,
disableStdin: false,
cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}'
});

Expand All @@ -417,6 +444,15 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(

terminal.open(document.getElementById('terminal'));

// Bridge xterm-originated input (e.g. wheel events synthesized into SGR
// mouse sequences / arrow keys) back to the pty. Regular keyboard input
// goes through the RN IME and does not pass through this onData.
terminal.onData(function(data) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'input', data: data }));
}
});

fitAddon.fit();
terminal.write('\x1b[?25h');

Expand Down Expand Up @@ -679,32 +715,106 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
}
}

var lastViewportHeight = null;
function applyViewportHeight(px, force) {
var el = document.getElementById('terminal');
if (!el || !px || px <= 0) return;
if (!force && Math.abs(px - (lastViewportHeight || 0)) < 1) return;
lastViewportHeight = px;

el.style.height = px + 'px';
el.style.minHeight = '0px';

try {
fitAddon.fit();
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'resize',
data: { cols: terminal.cols, rows: terminal.rows }
}));
}
// If the user was scrolled near the bottom, keep them pinned there so
// the prompt/TUI input row stays visible after the resize.
try {
if (terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 1) {
terminal.scrollToBottom();
}
} catch(e2) {}
} catch(e) {}
}
window.setTerminalViewportHeight = function(px) {
applyViewportHeight(px, false);
}

// Re-fit using the last RN-measured viewport height (if known) instead of
// the possibly-stale 100vh, so RN-driven resizes (keyboard, orientation,
// chrome show/hide) keep the row count in sync with the visible area.
window.nativeFit = function() {
try { handleResize(); } catch(e) {}
if (lastViewportHeight) {
applyViewportHeight(lastViewportHeight, true);
} else {
try { handleResize(); } catch(e) {}
}
}

window.addEventListener('resize', handleResize);
window.addEventListener('resize', function() {
// Prefer the RN-measured height; fall back to the WebView's own viewport
// when RN hasn't measured yet (e.g. initial load before onLayout).
if (lastViewportHeight) {
applyViewportHeight(lastViewportHeight, true);
} else {
try { handleResize(); } catch(e) {}
}
});

window.addEventListener('orientationchange', function() {
setTimeout(handleResize, 100);
});

// Touch-scroll acceleration for iOS WebView
// Touch-scroll for both the normal scrollback and TUI alternate-screen
// buffers. Instead of calling terminal.scrollLines() (which is a no-op on
// the alt buffer that Claude Code / Codex run in), synthesize a wheel
// event on the xterm root element: xterm then routes it either to the
// scrollback (normal buffer) or to SGR mouse / arrow-key sequences the TUI
// understands (alternate buffer with/without mouse tracking).
// touchmove is non-passive so we can preventDefault and stop the native
// WebView/page from hijacking the swipe (especially up-swipe when the
// alt buffer is already at its top).
(function() {
var scrollTouchY = null;
var pendingLines = 0;
var lineH = terminal._core._renderService.dimensions.css.cell.height || ${baseFontSize * 1.2};
terminalElement.addEventListener('touchstart', function(e) {
if (e.touches.length === 1) scrollTouchY = e.touches[0].clientY;
}, { passive: true, capture: true });
terminalElement.addEventListener('touchmove', function(e) {
if (scrollTouchY === null || e.touches.length !== 1) return;
// While the user is text-selecting, leave the gesture alone so xterm's
// selection drag can track the finger.
if (typeof isCurrentlySelecting !== 'undefined' && isCurrentlySelecting) {
return;
}
// Claim the gesture so WKWebView / Android WebView do not scroll the
// whole page when the terminal content cannot scroll further.
try { e.preventDefault(); } catch(e3) {}
var dy = scrollTouchY - e.touches[0].clientY;
scrollTouchY = e.touches[0].clientY;
var lines = Math.trunc(dy / lineH);
if (lines !== 0) terminal.scrollLines(lines);
}, { passive: true, capture: true });
pendingLines += dy / lineH;
var whole = Math.trunc(pendingLines);
if (whole !== 0) {
pendingLines -= whole;
try {
terminal.element.dispatchEvent(new WheelEvent('wheel', {
deltaY: whole,
deltaMode: WheelEvent.DOM_DELTA_LINE,
cancelable: true
}));
} catch(e2) {}
}
}, { passive: false, capture: true });
terminalElement.addEventListener('touchend', function() {
scrollTouchY = null;
pendingLines = 0;
}, { passive: true, capture: true });
})();

Expand Down Expand Up @@ -819,6 +929,26 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
[],
);

const handleTerminalLayout = useCallback((event: LayoutChangeEvent) => {
const h = Math.round(event.nativeEvent.layout.height || 0);
if (h <= 0 || h === viewportHeightRef.current) {
return;
}
viewportHeightRef.current = h;
// Debounce so mid-animation frames don't each trigger a pty resize.
if (viewportDebounceTimerRef.current) {
clearTimeout(viewportDebounceTimerRef.current);
}
viewportDebounceTimerRef.current = setTimeout(() => {
viewportDebounceTimerRef.current = null;
try {
webViewRef.current?.injectJavaScript(
`window.setTerminalViewportHeight && window.setTerminalViewportHeight(${h}); true;`,
);
} catch (err) {}
}, 80);
}, []);

const handleWebViewMessage = useCallback((event: any) => {
try {
const message = JSON.parse(event.nativeEvent.data);
Expand All @@ -827,6 +957,13 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
case "terminalReady":
terminalColsRef.current = message.data.cols;
terminalRowsRef.current = message.data.rows;
// Re-apply the RN-measured viewport height now that the terminal
// exists — onLayout may have fired before the HTML finished loading.
if (viewportHeightRef.current) {
webViewRef.current?.injectJavaScript(
`window.setTerminalViewportHeight && window.setTerminalViewportHeight(${viewportHeightRef.current}); true;`,
);
}
wsManagerRef.current?.connect(message.data.cols, message.data.rows);
break;

Expand All @@ -850,6 +987,12 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
case "scrollState":
setShowScrollToBottomButton(!message.data.isAtBottom);
break;

case "input":
// Wheel/mouse input synthesized inside the WebView (xterm onData),
// forwarded to the pty so TUI apps can scroll their context.
wsManagerRef.current?.sendInput(message.data);
break;
}
} catch (error) {
console.error("[Terminal] Error parsing WebView message:", error);
Expand Down Expand Up @@ -979,6 +1122,10 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
clearTimeout(accessibilityTimerRef.current);
accessibilityTimerRef.current = null;
}
if (viewportDebounceTimerRef.current) {
clearTimeout(viewportDebounceTimerRef.current);
viewportDebounceTimerRef.current = null;
}
};
}, []);

Expand Down Expand Up @@ -1027,6 +1174,7 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(

return (
<View
onLayout={handleTerminalLayout}
style={{
flex: isVisible ? 1 : 0,
width: "100%",
Expand Down Expand Up @@ -1094,7 +1242,7 @@ const TerminalComponent = forwardRef<TerminalHandle, TerminalProps>(
`WebView HTTP error: ${nativeEvent.statusCode}`,
);
}}
scrollEnabled={true}
scrollEnabled={false}
overScrollMode="never"
bounces={false}
showsHorizontalScrollIndicator={false}
Expand Down