chore: commit remaining local changes

This commit is contained in:
Mohamed Boudra
2026-02-14 01:07:23 +07:00
parent b8f6115465
commit 0bbdefa7b8
11 changed files with 1701 additions and 11 deletions
@@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
runOnJS,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { X } from "lucide-react-native";
import {
usePanelStore,
@@ -20,9 +22,34 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400;
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log("[TerminalDebug][ExplorerSidebar] " + message, payload);
return;
}
console.log("[TerminalDebug][ExplorerSidebar] " + message);
}
interface ExplorerSidebarProps {
serverId: string;
agentId: string;
@@ -42,6 +69,14 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const terminalDebugEnabled = isTerminalDebugEnabled();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const closeGestureLastLogX = useSharedValue(0);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
useEffect(() => {
if (isMobile) {
@@ -77,6 +112,39 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
closeToAgent();
}, [closeToAgent]);
const logCloseGesture = useCallback(
(phase: string, payload?: Record<string, unknown>) => {
logTerminalDebug("close gesture " + phase, {
isMobile,
isOpen,
explorerTab,
...(payload ?? {}),
});
},
[explorerTab, isMobile, isOpen]
);
const logKeyboardInset = useCallback(
(rawHeight: number, shift: number, inset: number) => {
logTerminalDebug("keyboard inset", {
rawHeight: Math.round(rawHeight),
shift: Math.round(shift),
inset: Math.round(inset),
isMobile,
isOpen,
explorerTab,
});
},
[explorerTab, isMobile, isOpen]
);
useEffect(() => {
if (!terminalDebugEnabled) {
return;
}
logTerminalDebug("close gesture config", { isMobile, isOpen, explorerTab });
}, [explorerTab, isMobile, isOpen, terminalDebugEnabled]);
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTab(tab);
@@ -84,6 +152,30 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
[setExplorerTab]
);
useAnimatedReaction(
() => {
const rawHeight = Math.abs(keyboardHeight.value);
return {
rawHeight,
shift: Math.max(0, rawHeight - bottomInset.value),
inset: bottomInset.value,
};
},
(next, previous) => {
if (!terminalDebugEnabled) {
return;
}
if (
previous &&
Math.abs(previous.shift - next.shift) < 4 &&
Math.abs(previous.rawHeight - next.rawHeight) < 4
) {
return;
}
runOnJS(logKeyboardInset)(next.rawHeight, next.shift, next.inset);
}
);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
@@ -97,6 +189,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
.failOffsetY([-10, 10])
.onStart(() => {
isGesturing.value = true;
if (terminalDebugEnabled) {
closeGestureLastLogX.value = 0;
runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) });
}
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
@@ -104,11 +200,30 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
if (
terminalDebugEnabled &&
Math.abs(newTranslateX - closeGestureLastLogX.value) >= 80
) {
closeGestureLastLogX.value = newTranslateX;
runOnJS(logCloseGesture)("update", {
translationX: Math.round(event.translationX),
appliedTranslateX: Math.round(newTranslateX),
velocityX: Math.round(event.velocityX),
});
}
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("end", {
translationX: Math.round(event.translationX),
velocityX: Math.round(event.velocityX),
shouldClose,
});
}
if (shouldClose) {
animateToClose();
runOnJS(handleClose)();
@@ -118,18 +233,25 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
})
.onFinalize(() => {
isGesturing.value = false;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("finalize");
}
}),
[
isMobile,
isOpen,
explorerTab,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
logCloseGesture,
isGesturing,
closeGestureRef,
closeGestureLastLogX,
terminalDebugEnabled,
]
);
@@ -171,6 +293,14 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
});
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
@@ -190,8 +320,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
<Animated.View
style={[
styles.mobileSidebar,
{ width: windowWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
{ width: windowWidth, paddingTop: insets.top },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
pointerEvents="auto"
>
@@ -278,6 +409,7 @@ function SidebarContent({
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
testID="explorer-tab-changes"
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
@@ -292,6 +424,7 @@ function SidebarContent({
</Pressable>
)}
<Pressable
testID="explorer-tab-files"
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
@@ -304,6 +437,20 @@ function SidebarContent({
Files
</Text>
</Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
activeTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
</Text>
</Pressable>
</View>
<View style={styles.headerRightSection}>
{isMobile && (
@@ -322,6 +469,9 @@ function SidebarContent({
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{activeTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
)}
</View>
</View>
);
@@ -0,0 +1,405 @@
"use dom";
import { useEffect, useRef } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import type { DOMProps } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
outputText: string;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
onInput?: (data: string) => Promise<void> | void;
onResize?: (rows: number, cols: number) => Promise<void> | void;
}
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
}
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log(`[TerminalDebug][DOM] ${message}`, payload);
return;
}
console.log(`[TerminalDebug][DOM] ${message}`);
}
export default function TerminalEmulator({
streamKey,
outputText,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
onInput,
onResize,
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const renderedOutputRef = useRef("");
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
useEffect(() => {
onInputRef.current = onInput;
}, [onInput]);
useEffect(() => {
onResizeRef.current = onResize;
}, [onResize]);
useEffect(() => {
const host = hostRef.current;
const root = rootRef.current;
if (!host || !root) {
return;
}
logTerminalDebug("mount", {
streamKey,
hasOnInput: Boolean(onInputRef.current),
hasOnResize: Boolean(onResizeRef.current),
});
renderedOutputRef.current = "";
lastSizeRef.current = null;
host.innerHTML = "";
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
fontSize: 13,
lineHeight: 1.25,
scrollback: 10_000,
theme: {
background: backgroundColor,
foreground: foregroundColor,
cursor: cursorColor,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(host);
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = root.parentElement;
const previousDocumentElementOverflow = documentElement.style.overflow;
const previousDocumentElementWidth = documentElement.style.width;
const previousDocumentElementHeight = documentElement.style.height;
const previousBodyOverflow = body.style.overflow;
const previousBodyWidth = body.style.width;
const previousBodyHeight = body.style.height;
const previousBodyMargin = body.style.margin;
const previousBodyPadding = body.style.padding;
const previousRootOverflow = rootContainer?.style.overflow ?? "";
const previousRootWidth = rootContainer?.style.width ?? "";
const previousRootHeight = rootContainer?.style.height ?? "";
// Force document to follow WebView bounds; xterm viewport owns scrollback.
documentElement.style.overflow = "hidden";
documentElement.style.width = "100%";
documentElement.style.height = "100%";
body.style.overflow = "hidden";
body.style.width = "100%";
body.style.height = "100%";
body.style.margin = "0";
body.style.padding = "0";
if (rootContainer) {
rootContainer.style.overflow = "hidden";
rootContainer.style.width = "100%";
rootContainer.style.height = "100%";
}
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
const previousViewportOverflowX = viewportElement?.style.overflowX ?? "";
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "contain";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
terminalRef.current = terminal;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force = false) => {
const handler = onResizeRef.current;
if (!handler) {
return;
}
try {
fitAddon.fit();
} catch {
logTerminalDebug("fit failed");
return;
}
const rows = terminal.rows;
const cols = terminal.cols;
const previous = lastSizeRef.current;
if (!force && previous && previous.rows === rows && previous.cols === cols) {
return;
}
lastSizeRef.current = { rows, cols };
const rootRect = root.getBoundingClientRect();
logTerminalDebug("fit+resize", {
force,
rows,
cols,
rootWidth: Math.round(rootRect.width),
rootHeight: Math.round(rootRect.height),
});
void handler(rows, cols);
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
const handler = onInputRef.current;
if (!handler) {
return;
}
logTerminalDebug("input", {
length: data.length,
preview: data.slice(0, 20),
});
void handler(data);
});
let lastScrollLogTs = 0;
let lastWheelLogTs = 0;
let lastTouchMoveLogTs = 0;
const viewportScrollHandler = () => {
const now = Date.now();
if (now - lastScrollLogTs < 120) {
return;
}
lastScrollLogTs = now;
logTerminalDebug("viewport scroll", {
baseY: terminal.buffer.active.baseY,
viewportY: terminal.buffer.active.viewportY,
});
};
const viewportWheelHandler = (event: WheelEvent) => {
const now = Date.now();
if (now - lastWheelLogTs < 120) {
return;
}
lastWheelLogTs = now;
logTerminalDebug("viewport wheel", {
deltaY: event.deltaY,
deltaX: event.deltaX,
});
};
const viewportTouchStartHandler = (event: TouchEvent) => {
logTerminalDebug("viewport touchstart", {
touches: event.touches.length,
});
};
const viewportTouchMoveHandler = (event: TouchEvent) => {
const now = Date.now();
if (now - lastTouchMoveLogTs < 120) {
return;
}
lastTouchMoveLogTs = now;
logTerminalDebug("viewport touchmove", {
touches: event.touches.length,
});
};
viewportElement?.addEventListener("scroll", viewportScrollHandler, { passive: true });
viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true });
viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, {
passive: true,
});
viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, {
passive: true,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize();
});
resizeObserver.observe(root);
const windowResizeHandler = () => fitAndEmitResize();
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize();
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
// Safety net for keyboard/layout transitions that can skip callbacks.
const fitInterval = window.setInterval(() => {
fitAndEmitResize();
}, 250);
window.setTimeout(() => fitAndEmitResize(true), 0);
if (outputText.length > 0) {
terminal.write(outputText);
renderedOutputRef.current = outputText;
}
terminal.focus();
return () => {
inputDisposable.dispose();
resizeObserver.disconnect();
window.removeEventListener("resize", windowResizeHandler);
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
window.clearInterval(fitInterval);
viewportElement?.removeEventListener("scroll", viewportScrollHandler);
viewportElement?.removeEventListener("wheel", viewportWheelHandler);
viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler);
viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler);
fitAddon.dispose();
terminal.dispose();
documentElement.style.overflow = previousDocumentElementOverflow;
documentElement.style.width = previousDocumentElementWidth;
documentElement.style.height = previousDocumentElementHeight;
body.style.overflow = previousBodyOverflow;
body.style.width = previousBodyWidth;
body.style.height = previousBodyHeight;
body.style.margin = previousBodyMargin;
body.style.padding = previousBodyPadding;
if (rootContainer) {
rootContainer.style.overflow = previousRootOverflow;
rootContainer.style.width = previousRootWidth;
rootContainer.style.height = previousRootHeight;
}
if (viewportElement) {
viewportElement.style.overscrollBehavior = previousViewportOverscroll;
viewportElement.style.touchAction = previousViewportTouchAction;
viewportElement.style.overflowY = previousViewportOverflowY;
viewportElement.style.overflowX = previousViewportOverflowX;
viewportElement.style.pointerEvents = previousViewportPointerEvents;
viewportElement.style.setProperty(
"-webkit-overflow-scrolling",
previousViewportWebkitOverflowScrolling
);
}
terminalRef.current = null;
if (window.__paseoTerminal === terminal) {
window.__paseoTerminal = undefined;
}
logTerminalDebug("unmount", { streamKey });
renderedOutputRef.current = "";
lastSizeRef.current = null;
};
}, [backgroundColor, cursorColor, foregroundColor, streamKey]);
useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) {
return;
}
const previous = renderedOutputRef.current;
if (outputText === previous) {
return;
}
if (previous.length > 0 && outputText.startsWith(previous)) {
const suffix = outputText.slice(previous.length);
if (suffix.length > 0) {
terminal.write(suffix);
}
} else {
terminal.reset();
terminal.clear();
if (outputText.length > 0) {
terminal.write(outputText);
}
}
renderedOutputRef.current = outputText;
}, [outputText]);
return (
<div
ref={rootRef}
data-testid={testId}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
display: "flex",
minHeight: 0,
minWidth: 0,
backgroundColor,
overflow: "hidden",
overscrollBehavior: "none",
}}
onPointerDown={() => {
logTerminalDebug("root pointerdown", { streamKey });
terminalRef.current?.focus();
}}
>
<div
ref={hostRef}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
width: "100%",
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
}}
/>
</div>
);
}
@@ -0,0 +1,742 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActivityIndicator,
type LayoutChangeEvent,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { Plus, RefreshCw } from "lucide-react-native";
import { NativeViewGestureHandler } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSessionStore } from "@/stores/session-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
}
const MAX_OUTPUT_CHARS = 200_000;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
shift: "Shift",
alt: "Alt",
} as const;
const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [
{ id: "esc", label: "Esc", key: "Escape" },
{ id: "tab", label: "Tab", key: "Tab" },
{ id: "up", label: "↑", key: "ArrowUp" },
{ id: "down", label: "↓", key: "ArrowDown" },
{ id: "left", label: "←", key: "ArrowLeft" },
{ id: "right", label: "→", key: "ArrowRight" },
{ id: "enter", label: "Enter", key: "Enter" },
{ id: "backspace", label: "⌫", key: "Backspace" },
{ id: "c", label: "C", key: "c" },
];
type ModifierState = {
ctrl: boolean;
shift: boolean;
alt: boolean;
};
function terminalScopeKey(serverId: string, cwd: string): string {
return `${serverId}:${cwd}`;
}
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
}
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
return devFlag === true;
}
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
if (!isTerminalDebugEnabled()) {
return;
}
if (payload) {
console.log("[TerminalDebug][Pane] " + message, payload);
return;
}
console.log("[TerminalDebug][Pane] " + message);
}
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Optional when rendered inside the mobile explorer sidebar gesture context.
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Terminal pane can render outside explorer sidebar during isolated tests.
}
const queryClient = useQueryClient();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
const scopeKey = useMemo(() => terminalScopeKey(serverId, cwd), [serverId, cwd]);
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
() => new Map()
);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
} | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>({
ctrl: false,
shift: false,
alt: false,
});
const terminalsQuery = useQuery({
queryKey: ["terminals", serverId, cwd] as const,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.listTerminals(cwd);
},
staleTime: 5_000,
});
const terminals = terminalsQuery.data?.terminals ?? [];
const createTerminalMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
if (payload.terminal) {
selectedTerminalByScopeRef.current.set(scopeKey, payload.terminal.id);
setSelectedTerminalId(payload.terminal.id);
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
});
},
});
useEffect(() => {
setSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
lastReportedSizeRef.current = null;
}, [scopeKey]);
useEffect(() => {
if (selectedTerminalId) {
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
}
}, [scopeKey, selectedTerminalId]);
useEffect(() => {
if (terminals.length === 0) {
setSelectedTerminalId(null);
return;
}
const has = (id: string | null | undefined) =>
Boolean(id && terminals.some((terminal) => terminal.id === id));
if (has(selectedTerminalId)) {
return;
}
const stored = selectedTerminalByScopeRef.current.get(scopeKey);
if (has(stored)) {
setSelectedTerminalId(stored!);
return;
}
const fallback = terminals[0]?.id ?? null;
if (fallback) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
setSelectedTerminalId(fallback);
}
}, [scopeKey, terminals, selectedTerminalId]);
const appendOutput = useCallback((terminalId: string, text: string) => {
if (!text) {
return;
}
setOutputByTerminalId((previous) => {
const next = new Map(previous);
const existing = next.get(terminalId) ?? "";
const combined = `${existing}${text}`;
next.set(
terminalId,
combined.length > MAX_OUTPUT_CHARS
? combined.slice(combined.length - MAX_OUTPUT_CHARS)
: combined
);
return next;
});
}, []);
useEffect(() => {
let isCancelled = false;
let streamId: number | null = null;
let unsubscribe: (() => void) | null = null;
let decoder: TextDecoder | null = null;
const terminalId = selectedTerminalId;
if (!client || !isConnected || !terminalId) {
setActiveStream(null);
setIsAttaching(false);
return;
}
setIsAttaching(true);
setStreamError(null);
lastReportedSizeRef.current = null;
const attach = async () => {
try {
const attachPayload = await client.attachTerminalStream(terminalId);
if (isCancelled) {
if (typeof attachPayload.streamId === "number") {
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
}
return;
}
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
setStreamError(attachPayload.error ?? "Unable to attach terminal stream");
setActiveStream(null);
return;
}
streamId = attachPayload.streamId;
decoder = new TextDecoder();
setActiveStream({ terminalId, streamId });
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
if (isCancelled) {
return;
}
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
appendOutput(terminalId, text);
});
} catch (error) {
if (!isCancelled) {
setStreamError(
error instanceof Error ? error.message : "Unable to attach terminal stream"
);
setActiveStream(null);
}
} finally {
if (!isCancelled) {
setIsAttaching(false);
}
}
};
void attach();
return () => {
isCancelled = true;
if (decoder) {
appendOutput(terminalId, decoder.decode());
decoder = null;
}
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
if (streamId !== null) {
void client.detachTerminalStream(streamId).catch(() => {});
}
};
}, [appendOutput, client, isConnected, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId
? activeStream.streamId
: null;
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId]
);
const waitForCloseGesture = Boolean(isMobile && closeGestureRef?.current);
useEffect(() => {
logTerminalDebug("render state", {
scopeKey,
isMobile,
terminals: terminals.length,
selectedTerminalId,
activeStreamId,
isAttaching,
waitForCloseGesture,
});
}, [
activeStreamId,
isAttaching,
isMobile,
scopeKey,
selectedTerminalId,
terminals.length,
waitForCloseGesture,
]);
const handleOutputLayout = useCallback(
(event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
logTerminalDebug("output layout", {
width: Math.round(width),
height: Math.round(height),
selectedTerminalId,
});
},
[selectedTerminalId]
);
const handleTerminalLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
logTerminalDebug("terminal container layout", {
width: Math.round(width),
height: Math.round(height),
});
}, []);
const currentOutput = selectedTerminalId
? (outputByTerminalId.get(selectedTerminalId) ?? "")
: "";
const handleRefresh = useCallback(() => {
void terminalsQuery.refetch();
}, [terminalsQuery]);
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
const handleTerminalData = useCallback(
async (data: string) => {
if (!client || activeStreamId === null || data.length === 0) {
return;
}
logTerminalDebug("send input", {
length: data.length,
preview: data.slice(0, 24),
});
client.sendTerminalStreamInput(activeStreamId, data);
},
[client, activeStreamId]
);
const handleTerminalResize = useCallback(
async (rows: number, cols: number) => {
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const previous = lastReportedSizeRef.current;
if (
previous &&
previous.rows === normalizedRows &&
previous.cols === normalizedCols
) {
return;
}
logTerminalDebug("send resize", {
rows: normalizedRows,
cols: normalizedCols,
selectedTerminalId,
});
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
[client, selectedTerminalId]
);
const toggleModifier = useCallback((modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
}, []);
const sendVirtualKey = useCallback(
(key: string) => {
if (!client || activeStreamId === null) {
return;
}
client.sendTerminalStreamKey(activeStreamId, {
key: key.length === 1 ? key.toLowerCase() : key,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
});
logTerminalDebug("send virtual key", {
key,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
});
setModifiers({ ctrl: false, shift: false, alt: false });
},
[client, activeStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift]
);
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
<Text style={styles.stateText}>Host is not connected</Text>
</View>
);
}
const queryError =
terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending;
const isRefreshing = terminalsQuery.isFetching;
const createError =
createTerminalMutation.error instanceof Error
? createTerminalMutation.error.message
: null;
const combinedError = streamError ?? createError ?? queryError;
return (
<View style={styles.container}>
<View style={styles.header} testID="terminals-header">
<ScrollView
horizontal
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{terminals.map((terminal) => {
const isActive = terminal.id === selectedTerminalId;
return (
<Pressable
key={terminal.id}
testID={`terminal-tab-${terminal.id}`}
onPress={() => setSelectedTerminalId(terminal.id)}
style={({ pressed, hovered }) => [
styles.terminalTab,
isActive && styles.terminalTabActive,
(pressed || hovered) && styles.terminalTabHovered,
]}
>
<Text style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}>
{terminal.name}
</Text>
</Pressable>
);
})}
</ScrollView>
<View style={styles.headerActions}>
<Pressable
testID="terminals-refresh-button"
onPress={handleRefresh}
disabled={isRefreshing}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isRefreshing ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<RefreshCw size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<Pressable
testID="terminals-create-button"
onPress={handleCreateTerminal}
disabled={isCreating}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isCreating ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Plus size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
<View style={styles.outputContainer} onLayout={handleOutputLayout}>
{selectedTerminal ? (
<NativeViewGestureHandler
disallowInterruption={false}
waitFor={waitForCloseGesture ? closeGestureRef : undefined}
>
<View style={styles.terminalGestureContainer} onLayout={handleTerminalLayout}>
<TerminalEmulator
dom={{
style: { flex: 1 },
matchContents: false,
scrollEnabled: true,
nestedScrollEnabled: true,
overScrollMode: "never",
}}
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
outputText={currentOutput}
testId="terminal-surface"
backgroundColor={theme.colors.background}
foregroundColor={theme.colors.foreground}
cursorColor={theme.colors.foreground}
onInput={handleTerminalData}
onResize={handleTerminalResize}
/>
</View>
</NativeViewGestureHandler>
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching ? (
<View style={styles.attachOverlay}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={styles.attachOverlayText}>Attaching terminal…</Text>
</View>
) : null}
</View>
{combinedError ? (
<View style={styles.errorRow}>
<Text style={styles.statusError} numberOfLines={2}>
{combinedError}
</Text>
</View>
) : null}
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.keyboardRow}>
{(Object.keys(MODIFIER_LABELS) as Array<keyof ModifierState>).map((modifier) => (
<Pressable
key={modifier}
testID={`terminal-key-${modifier}`}
onPress={() => toggleModifier(modifier)}
style={({ hovered, pressed }) => [
styles.keyButton,
modifiers[modifier] && styles.keyButtonActive,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={[styles.keyButtonText, modifiers[modifier] && styles.keyButtonTextActive]}>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
))}
{KEY_BUTTONS.map((button) => (
<Pressable
key={button.id}
testID={`terminal-key-${button.id}`}
onPress={() => sendVirtualKey(button.key)}
style={({ hovered, pressed }) => [
styles.keyButton,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={styles.keyButtonText}>{button.label}</Text>
</Pressable>
))}
</View>
</ScrollView>
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
header: {
minHeight: 48,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
tabsScroll: {
flex: 1,
minWidth: 0,
},
tabsContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
},
terminalTab: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
terminalTabHovered: {
backgroundColor: theme.colors.surface2,
},
terminalTabActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surface2,
},
terminalTabText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
terminalTabTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
headerIconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
headerIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
outputContainer: {
flex: 1,
minHeight: 0,
position: "relative",
backgroundColor: theme.colors.background,
},
terminalGestureContainer: {
flex: 1,
minHeight: 0,
},
attachOverlay: {
position: "absolute",
top: theme.spacing[3],
right: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
attachOverlayText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
statusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.xs,
},
keyboardContainer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
keyboardRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[3],
},
keyButton: {
minWidth: 44,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
keyButtonHovered: {
backgroundColor: theme.colors.surface2,
},
keyButtonActive: {
borderColor: theme.colors.primary,
backgroundColor: theme.colors.surface2,
},
keyButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
keyButtonTextActive: {
color: theme.colors.foreground,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[4],
},
stateText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));