diff --git a/package-lock.json b/package-lock.json index eb3f93015..a958c6b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index b3e21bf99..fba4a06a2 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -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'], diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 9fea7a5d7..938e2f51b 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -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 { diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts new file mode 100644 index 000000000..ad20cff1b --- /dev/null +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await page.evaluate((nextPadding) => { + const container = document.querySelector( + '[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 { + 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(); + } +}); diff --git a/packages/app/package.json b/packages/app/package.json index 68e886146..30226460d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 6357616c3..2ddb95a92 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -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): 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) => { + 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 @@ -278,6 +409,7 @@ function SidebarContent({ {isGit && ( onTabPress("changes")} > @@ -292,6 +424,7 @@ function SidebarContent({ )} onTabPress("files")} > @@ -304,6 +437,20 @@ function SidebarContent({ Files + onTabPress("terminals")} + > + + Terminals + + {isMobile && ( @@ -322,6 +469,9 @@ function SidebarContent({ {activeTab === "files" && ( )} + {activeTab === "terminals" && ( + + )} ); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx new file mode 100644 index 000000000..165870c43 --- /dev/null +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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; + onResize?: (rows: number, cols: number) => Promise | 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): 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(null); + const hostRef = useRef(null); + const terminalRef = useRef(null); + const renderedOutputRef = useRef(""); + const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null); + const onInputRef = useRef(onInput); + const onResizeRef = useRef(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(".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 ( +
{ + logTerminalDebug("root pointerdown", { streamKey }); + terminalRef.current?.focus(); + }} + > +
+
+ ); +} diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx new file mode 100644 index 000000000..83c4e2750 --- /dev/null +++ b/packages/app/src/components/terminal-pane.tsx @@ -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): 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 | 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>(new Map()); + const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); + + const [selectedTerminalId, setSelectedTerminalId] = useState(null); + const [outputByTerminalId, setOutputByTerminalId] = useState>( + () => new Map() + ); + const [activeStream, setActiveStream] = useState<{ + terminalId: string; + streamId: number; + } | null>(null); + const [isAttaching, setIsAttaching] = useState(false); + const [streamError, setStreamError] = useState(null); + const [modifiers, setModifiers] = useState({ + 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 ( + + Host is not connected + + ); + } + + 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 ( + + + + {terminals.map((terminal) => { + const isActive = terminal.id === selectedTerminalId; + return ( + setSelectedTerminalId(terminal.id)} + style={({ pressed, hovered }) => [ + styles.terminalTab, + isActive && styles.terminalTabActive, + (pressed || hovered) && styles.terminalTabHovered, + ]} + > + + {terminal.name} + + + ); + })} + + + [ + styles.headerIconButton, + (hovered || pressed) && styles.headerIconButtonHovered, + ]} + > + {isRefreshing ? ( + + ) : ( + + )} + + [ + styles.headerIconButton, + (hovered || pressed) && styles.headerIconButtonHovered, + ]} + > + {isCreating ? ( + + ) : ( + + )} + + + + + + {selectedTerminal ? ( + + + + + + ) : ( + + No terminal selected + + )} + + {isAttaching ? ( + + + Attaching terminal… + + ) : null} + + + {combinedError ? ( + + + {combinedError} + + + ) : null} + + {isMobile ? ( + + + + {(Object.keys(MODIFIER_LABELS) as Array).map((modifier) => ( + toggleModifier(modifier)} + style={({ hovered, pressed }) => [ + styles.keyButton, + modifiers[modifier] && styles.keyButtonActive, + (hovered || pressed) && styles.keyButtonHovered, + ]} + > + + {MODIFIER_LABELS[modifier]} + + + ))} + + {KEY_BUTTONS.map((button) => ( + sendVirtualKey(button.key)} + style={({ hovered, pressed }) => [ + styles.keyButton, + (hovered || pressed) && styles.keyButtonHovered, + ]} + > + {button.label} + + ))} + + + + ) : null} + + ); +} + +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", + }, +})); diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index d722d4e55..7a7a130cd 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -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; diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 6892d37ee..b9ba1dbd5 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1018,6 +1018,7 @@ export function DraftAgentScreen({ icon={} showLabel={false} valueEllipsizeMode="middle" + testID="working-directory-select" /> {isDirectoryNotExists && ( diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 01cde1d30..8aa46f501 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -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;