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

17
package-lock.json generated
View File

@@ -7139,6 +7139,12 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"license": "MIT",
@@ -7146,6 +7152,15 @@
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"dev": true,
@@ -20347,6 +20362,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",

View File

@@ -153,6 +153,10 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
PASEO_DICTATION_ENABLED: "0",
PASEO_VOICE_MODE_ENABLED: "0",
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
NODE_ENV: 'development',
},
stdio: ['ignore', 'pipe', 'pipe'],

View File

@@ -168,9 +168,10 @@ export const openSettings = async (page: Page) => {
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
await expect(workingDirectoryLabel).toBeVisible();
const workingDirectorySelect = page.getByTestId('working-directory-select').first();
const workingDirectorySelect = page
.locator('[data-testid="working-directory-select"]:visible')
.first();
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
const input = page.getByRole('textbox', { name: '/path/to/project' });
const worktreePicker = page.getByTestId('worktree-attach-picker');
@@ -333,11 +334,9 @@ export const createAgent = async (page: Page, message: string) => {
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
const userBubble = page
.getByRole('button', { name: 'Copy message' })
.filter({ hasText: message })
.first();
await expect(userBubble).toBeVisible();
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
};
export interface AgentConfig {

View File

@@ -0,0 +1,370 @@
import { test, expect, type Page } from "./fixtures";
import {
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
function parseAgentFromUrl(url: string): { serverId: string; agentId: string } {
const pathname = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const modernMatch = pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
if (modernMatch) {
return {
serverId: decodeURIComponent(modernMatch[1]),
agentId: decodeURIComponent(modernMatch[2]),
};
}
const legacyMatch = pathname.match(/\/agent\/([^/]+)\/([^/?#]+)/);
if (legacyMatch) {
return {
serverId: decodeURIComponent(legacyMatch[1]),
agentId: decodeURIComponent(legacyMatch[2]),
};
}
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`);
}
async function openAgentFromSidebar(page: Page, serverId: string, agentId: string): Promise<void> {
await gotoHome(page);
const row = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect
.poll(
() => {
try {
const parsed = parseAgentFromUrl(page.url());
return `${parsed.serverId}:${parsed.agentId}`;
} catch {
return "";
}
},
{ timeout: 30000 }
)
.toBe(`${serverId}:${agentId}`);
}
async function openNewAgentDraft(page: Page): Promise<void> {
await gotoHome(page);
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click();
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await expect(
page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({
timeout: 30000,
});
}
async function openTerminalsPanel(page: Page): Promise<void> {
let header = page.locator('[data-testid="explorer-header"]:visible').first();
if (!(await header.isVisible().catch(() => false))) {
const toggle = page.getByRole("button", {
name: /open explorer|close explorer|toggle explorer/i,
});
if (await toggle.first().isVisible().catch(() => false)) {
await toggle.first().click();
}
}
header = page.locator('[data-testid="explorer-header"]:visible').first();
await expect(header).toBeVisible({ timeout: 30000 });
const terminalsTab = page.getByTestId("explorer-tab-terminals").first();
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
await terminalsTab.click();
await expect(page.getByTestId("terminals-header").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("terminal-surface").first()).toBeVisible({
timeout: 30000,
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
await expect(tabs.first()).toBeVisible({ timeout: 30000 });
await expect
.poll(async () => await tabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(2);
await tabs.last().click();
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function runTerminalCommandWithPreEnterEcho(
page: Page,
command: string,
expectedText: string
): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(command, { delay: 1 });
await expect(surface).toContainText(command, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(expectedText, {
timeout: 30000,
});
}
async function expectAnsiColorApplied(page: Page, marker: string): Promise<void> {
await expect
.poll(
async () =>
await page.evaluate((target) => {
const terminal = (window as any).__paseoTerminal;
if (!terminal?.buffer?.active?.getLine || !terminal?.buffer?.active?.getNullCell) {
return false;
}
const buffer = terminal.buffer.active;
const nullCell = buffer.getNullCell();
const lineCount = buffer.length ?? 0;
const cols = terminal.cols ?? 0;
for (let y = 0; y < lineCount; y += 1) {
const line = buffer.getLine(y);
if (!line) continue;
const lineText = line.translateToString(true);
const index = lineText.indexOf(target);
if (index === -1) continue;
for (let x = index; x < index + target.length && x < cols; x += 1) {
const cell = line.getCell(x, nullCell);
if (!cell) continue;
if (!cell.isFgDefault()) {
return true;
}
}
}
return false;
}, marker),
{ timeout: 30000 }
)
.toBe(true);
}
test("Terminals tab creates multiple terminals and streams command output", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminals-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal smoke");
await openTerminalsPanel(page);
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const preEnterEchoMarker = `typed-echo-${Date.now()}`;
await runTerminalCommandWithPreEnterEcho(
page,
`echo ${preEnterEchoMarker}`,
preEnterEchoMarker
);
const ansiMarker = `ansi-red-${Date.now()}`;
await runTerminalCommand(
page,
`printf '\\033[31m${ansiMarker}\\033[0m\\n'`,
ansiMarker
);
await expectAnsiColorApplied(page, ansiMarker);
const markerOne = `terminal-smoke-one-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerOne}`, markerOne);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
const markerTwo = `terminal-smoke-two-${Date.now()}`;
await runTerminalCommand(page, `echo ${markerTwo}`, markerTwo);
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent one");
const first = parseAgentFromUrl(page.url());
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Agent two");
const second = parseAgentFromUrl(page.url());
expect(first.serverId).toBe(second.serverId);
expect(first.agentId).not.toBe(second.agentId);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await page.getByTestId("terminals-create-button").first().click();
await selectNewestTerminalTab(page);
await openAgentFromSidebar(page, second.serverId, second.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
const sharedMarker = `shared-terminal-${Date.now()}`;
await runTerminalCommand(page, `echo ${sharedMarker}`, sharedMarker);
await openAgentFromSidebar(page, first.serverId, first.agentId);
await openTerminalsPanel(page);
await selectNewestTerminalTab(page);
await expect(page.getByTestId("terminal-surface").first()).toContainText(sharedMarker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;
return typeof terminal?.rows === "number" ? terminal.rows : 0;
});
}
async function setExplorerContentBottomPadding(page: Page, padding: number): Promise<void> {
await page.evaluate((nextPadding) => {
const container = document.querySelector<HTMLElement>(
'[data-testid="explorer-content-area"]'
);
if (!container) {
return;
}
container.style.boxSizing = "border-box";
container.style.paddingBottom = nextPadding + "px";
}, padding);
}
async function getTerminalScrollbackDistance(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (
window as {
__paseoTerminal?: {
buffer?: { active?: { baseY?: unknown; viewportY?: unknown } };
};
}
).__paseoTerminal;
const baseY = terminal?.buffer?.active?.baseY;
const viewportY = terminal?.buffer?.active?.viewportY;
if (typeof baseY !== "number" || typeof viewportY !== "number") {
return 0;
}
return Math.max(0, baseY - viewportY);
});
}
test("terminal viewport resizes and uses xterm scrollback", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-viewport-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Viewport and scrollback test");
await openTerminalsPanel(page);
const initialViewport = page.viewportSize();
if (!initialViewport) {
throw new Error("Expected a viewport size");
}
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThan(0);
const initialRows = await getTerminalRows(page);
await setExplorerContentBottomPadding(page, 220);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await setExplorerContentBottomPadding(page, 0);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const reducedHeight = Math.max(520, initialViewport.height - 220);
await page.setViewportSize({
width: initialViewport.width,
height: reducedHeight,
});
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeLessThan(initialRows);
await page.setViewportSize(initialViewport);
await expect
.poll(() => getTerminalRows(page), { timeout: 30000 })
.toBeGreaterThanOrEqual(initialRows);
const scrollbackMarker = `scrollback-${Date.now()}`;
await runTerminalCommand(
page,
`for i in $(seq 1 180); do echo ${scrollbackMarker}-$i; done`,
`${scrollbackMarker}-180`
);
const surface = page.getByTestId("terminal-surface").first();
await surface.hover();
await page.mouse.wheel(0, -3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeGreaterThan(0);
const distanceAfterScrollUp = await getTerminalScrollbackDistance(page);
await surface.hover();
await page.mouse.wheel(0, 3000);
await expect
.poll(() => getTerminalScrollbackDistance(page), { timeout: 30000 })
.toBeLessThan(distanceAfterScrollUp);
} finally {
await repo.cleanup();
}
});

View File

@@ -49,6 +49,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tauri-apps/api": "^2.9.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"expo": "^54.0.18",

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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",
},
}));

View File

@@ -616,7 +616,7 @@ function AgentScreenContent({
return;
}
// On native clients, daemon stream forwarding is focused-agent only, so switching
// agents can leave timeline gaps unless we explicitly request a snapshot.
// agents can leave timeline gaps unless we explicitly pull timeline catch-up.
const shouldSyncOnEntry = needsAuthoritativeSync || Platform.OS !== "web";
if (!shouldSyncOnEntry) {
return;

View File

@@ -1018,6 +1018,7 @@ export function DraftAgentScreen({
icon={<Folder size={16} color={theme.colors.foregroundMuted} />}
showLabel={false}
valueEllipsizeMode="middle"
testID="working-directory-select"
/>
</View>
{isDirectoryNotExists && (

View File

@@ -27,7 +27,7 @@ interface DesktopSidebarState {
fileExplorerOpen: boolean;
}
export type ExplorerTab = "changes" | "files";
export type ExplorerTab = "changes" | "files" | "terminals";
export type SortOption = "name" | "modified" | "size";
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;