mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
chore: commit remaining local changes
This commit is contained in:
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
370
packages/app/e2e/terminal-pane.spec.ts
Normal file
370
packages/app/e2e/terminal-pane.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
405
packages/app/src/components/terminal-emulator.tsx
Normal file
405
packages/app/src/components/terminal-emulator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
742
packages/app/src/components/terminal-pane.tsx
Normal file
742
packages/app/src/components/terminal-pane.tsx
Normal 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",
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user