Compare commits

...

15 Commits

Author SHA1 Message Date
Mohamed Boudra
36ac228bef chore(release): cut 0.1.6 2026-02-16 08:29:45 +07:00
Mohamed Boudra
12eade8f98 fix(release): avoid double-bumping workspace versions 2026-02-16 08:29:35 +07:00
Mohamed Boudra
b105409844 chore(release): cut 0.1.5 2026-02-16 08:27:54 +07:00
Mohamed Boudra
22c46a9c73 chore(release): add tag-driven mobile workflow and release docs 2026-02-16 08:27:39 +07:00
Mohamed Boudra
037a6a15a4 feat: stream worktree setup progress and terminal subscriptions 2026-02-16 08:09:27 +07:00
Mohamed Boudra
b980abb7af Remove temporary terminal debug instrumentation 2026-02-15 19:10:40 +07:00
Mohamed Boudra
0103b4da96 Harden terminal streaming and worktree setup flow 2026-02-15 19:05:27 +07:00
Mohamed Boudra
75dc802a3a chore(app): add terminal input/output debug logging 2026-02-15 15:43:07 +07:00
Mohamed Boudra
adb511d2be refactor(app): extract terminal runtime out of react lifecycle 2026-02-15 15:40:18 +07:00
Mohamed Boudra
9244f1fbd6 feat: terminal reattach, explorer tab memory, worktree terminals 2026-02-15 14:05:29 +07:00
Mohamed Boudra
1f72ce63cd config: add default worktree terminal 2026-02-15 09:37:36 +07:00
Mohamed Boudra
41d1fa6d97 feat: update sidebar agent workflow and host filter UI 2026-02-15 08:59:17 +07:00
Mohamed Boudra
9da5b26db0 feat(app): add global shortcut help dialog 2026-02-14 20:18:28 +07:00
Mohamed Boudra
756f9a7972 refactor(app): centralize keyboard actions and shortcuts 2026-02-14 19:53:19 +07:00
Mohamed Boudra
86b476e309 fix(app): remove terminal debug instrumentation 2026-02-14 18:09:07 +07:00
106 changed files with 10785 additions and 2719 deletions

View File

@@ -109,7 +109,7 @@ Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package
- `production` -> app name `Paseo`, package `sh.paseo`
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
EAS profiles live in `packages/app/eas.json` as `development` and `production`.
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
`development` uses Android `debug`.
@@ -136,6 +136,28 @@ npm run android:production
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
### Cloud build + submit (EAS Workflows)
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
That workflow does:
- Build iOS with the `production` profile
- Build Android with the `production` profile
- Submit each build with the `production` submit profile
Useful commands:
```bash
# List recent mobile workflow runs
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
# Inspect one run (jobs, status, outputs)
cd packages/app && npx eas workflow:view <run-id>
# Stream logs for all steps in one failed job
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
```
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
@@ -160,15 +182,22 @@ npm run release:patch
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
npm run release:check
npm run release:publish
npm run release:push # pushes HEAD and current version tag (triggers desktop release)
npm run release:push # pushes HEAD and current version tag (triggers desktop + EAS mobile workflows)
```
Notes:
- `version:all:*` uses `npm version` with workspace support and runs the root `version` lifecycle script to sync internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
- All workspaces share one version by design. Keep versions synchronized and release together.
- After each release, update the website Mac download CTA URL to the new version tag in `packages/website/src/routes/index.tsx`.
Release completion checklist:
- `npm run release:patch` completes successfully.
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
## Orchestrator Mode
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.

View File

@@ -71,7 +71,7 @@ PASEO_SPEECH_E2E_DOWNLOAD=1 PASEO_SPEECH_E2E_MODEL_SET=parakeet-pocket \
See [paseo.sh/docs](https://paseo.sh/docs) for full documentation.
## Desktop releases
## Releases
Desktop app binaries are built and attached to a GitHub Release when you push a version tag (for example `v0.1.0` or `desktop-v0.1.0`).
@@ -86,7 +86,23 @@ For the full package release flow, use:
npm run release:patch
```
This triggers the `Desktop Release` workflow (`.github/workflows/desktop-release.yml`).
`npm run release:patch` bumps all workspace versions together, publishes npm packages (`@getpaseo/relay`, `@getpaseo/server`, `@getpaseo/cli`), and pushes the matching `v*` tag.
The tag triggers:
- GitHub `Desktop Release` workflow (`.github/workflows/desktop-release.yml`)
- Expo EAS mobile workflow (`packages/app/.eas/workflows/release-mobile.yml`) to build + submit Android/iOS
Useful monitoring commands after a release push:
```bash
# Desktop (GitHub Actions)
gh run list --workflow "Desktop Release" --limit 10
gh run watch <run-id>
# Mobile (EAS Workflows)
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
cd packages/app && npx eas workflow:view <run-id>
```
## License

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.4",
"version": "0.1.6",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20335,7 +20335,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -20343,7 +20343,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.4",
"@getpaseo/server": "0.1.6",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -20446,11 +20446,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"@getpaseo/relay": "0.1.6",
"@getpaseo/server": "0.1.6",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -20500,14 +20500,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.4",
"version": "0.1.6",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -20523,12 +20523,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/relay": "0.1.6",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -20879,7 +20879,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.4",
"version": "0.1.6",
"private": true,
"workspaces": [
"packages/server",
@@ -35,9 +35,9 @@
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
"release:prepare": "npm install --workspaces --include-workspace-root",
"version:all:patch": "npm version patch --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --workspaces --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",

View File

@@ -0,0 +1,38 @@
name: Release Mobile
on:
push:
tags:
- "v*"
workflow_dispatch: {}
jobs:
build_ios:
name: Build iOS
type: build
params:
platform: ios
profile: production
build_android:
name: Build Android
type: build
params:
platform: android
profile: production
submit_ios:
name: Submit iOS
needs: [build_ios]
type: submit
params:
build_id: ${{ needs.build_ios.outputs.build_id }}
profile: production
submit_android:
name: Submit Android
needs: [build_android]
type: submit
params:
build_id: ${{ needs.build_android.outputs.build_id }}
profile: production

View File

@@ -112,6 +112,6 @@ export default {
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
},
},
owner: "moboudra",
owner: "getpaseo",
},
};

View File

@@ -0,0 +1,17 @@
import { test, expect } from "./fixtures";
import { gotoHome } from "./helpers/app";
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
await gotoHome(page);
await page.getByTestId("menu-button").first().focus();
await page.keyboard.press("Shift+/");
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(content).toBeVisible({ timeout: 10000 });
await expect(content).toContainText("Show keyboard shortcuts");
await expect(content).toContainText("Toggle left sidebar");
});

View File

@@ -14,7 +14,7 @@ test('sidebar toggle shows tooltip on the right', async ({ page }) => {
const tooltip = page.getByTestId('menu-button-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText('Toggle sidebar');
await expect(tooltip).toContainText(/⌘B|Ctrl\+B/);
await expect(tooltip).toContainText(/⌘B|Ctrl\+\./);
await page.waitForTimeout(250);
await expect(tooltip).toBeVisible();

View File

@@ -95,6 +95,33 @@ async function openTerminalsPanel(page: Page): Promise<void> {
});
}
async function openFilesPanel(page: Page): Promise<void> {
const filesTab = page.getByTestId("explorer-tab-files").first();
await expect(filesTab).toBeVisible({ timeout: 30000 });
await filesTab.click();
await expect(page.getByTestId("files-pane-header").first()).toBeVisible({
timeout: 30000,
});
}
async function getDesktopAgentSidebarOpen(page: Page): Promise<boolean | null> {
return await page.evaluate(() => {
const raw = localStorage.getItem("panel-state");
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as {
state?: { desktop?: { agentListOpen?: boolean } };
};
const value = parsed?.state?.desktop?.agentListOpen;
return typeof value === "boolean" ? value : null;
} catch {
return null;
}
});
}
async function selectNewestTerminalTab(page: Page): Promise<void> {
const tabs = page.locator('[data-testid^="terminal-tab-"]');
@@ -105,6 +132,16 @@ async function selectNewestTerminalTab(page: Page): Promise<void> {
await tabs.last().click();
}
async function getFirstTerminalTabTestId(page: Page): Promise<string> {
const firstTab = page.locator('[data-testid^="terminal-tab-"]').first();
await expect(firstTab).toBeVisible({ timeout: 30000 });
const value = await firstTab.getAttribute("data-testid");
if (!value) {
throw new Error("Expected terminal tab test id");
}
return value;
}
async function runTerminalCommand(page: Page, command: string, expectedText: string): Promise<void> {
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
@@ -213,6 +250,151 @@ test("Terminals tab creates multiple terminals and streams command output", asyn
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
await runTerminalCommand(
page,
"for i in $(seq 1 12000); do echo reattach-$i; done",
"reattach-12000"
);
for (let attempt = 0; attempt < 4; attempt += 1) {
await openFilesPanel(page);
await openTerminalsPanel(page);
await expect(page.getByText("Terminal stream ended. Reconnecting…")).toHaveCount(0, {
timeout: 30000,
});
await expect(page.getByTestId("terminal-attach-loading")).toHaveCount(0, {
timeout: 30000,
});
}
const marker = `reattach-health-${Date.now()}`;
await runTerminalCommand(page, `echo ${marker}`, marker);
} finally {
await repo.cleanup();
}
});
test("terminal keeps prompt echo visible after enter and backspace churn", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-echo-churn-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
for (let iteration = 0; iteration < 40; iteration += 1) {
await page.keyboard.press("Enter");
}
const markerAfterEnters = `echo-visible-${Date.now()}`;
await page.keyboard.type(`echo ${markerAfterEnters}`, { delay: 0 });
await expect(surface).toContainText(`echo ${markerAfterEnters}`, {
timeout: 30000,
});
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterEnters, {
timeout: 30000,
});
const longSuffix = "x".repeat(120);
await page.keyboard.type(`echo ${longSuffix}`, { delay: 0 });
for (let iteration = 0; iteration < longSuffix.length; iteration += 1) {
await page.keyboard.press("Backspace");
}
const markerAfterBackspace = `echo-backspace-${Date.now()}`;
await page.keyboard.type(markerAfterBackspace, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(markerAfterBackspace, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal remains interactive after alternate-screen enter/exit", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-alt-screen-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "hello");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type(
"printf '\\033[?1049h\\033[2J\\033[HALT\\033[?1049l\\n'",
{ delay: 0 }
);
await page.keyboard.press("Enter");
const marker = `post-alt-screen-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 0 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, {
timeout: 30000,
});
} finally {
await repo.cleanup();
}
});
test("terminal tab is removed when shell exits", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-exit-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal exit flow");
await openTerminalsPanel(page);
const exitedTabTestId = await getFirstTerminalTabTestId(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("exit", { delay: 1 });
await page.keyboard.press("Enter");
await expect(page.getByTestId(exitedTabTestId)).toHaveCount(0, {
timeout: 30000,
});
await expect(page.locator('[data-testid^="terminal-tab-"]').first()).toBeVisible({
timeout: 30000,
});
const nextTabTestId = await getFirstTerminalTabTestId(page);
expect(nextTabTestId).not.toBe(exitedTabTestId);
} finally {
await repo.cleanup();
}
});
test("terminals are shared by agents on the same cwd", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-share-");
@@ -256,6 +438,75 @@ test("terminals are shared by agents on the same cwd", async ({ page }) => {
}
});
test("terminal captures escape and ctrl+c key input", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-keys-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal key combo capture");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await page.keyboard.type("cat -v", { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText("cat -v", { timeout: 30000 });
await page.keyboard.press("Escape");
await expect(surface).toContainText("^[", { timeout: 30000 });
await page.keyboard.press("Control+C");
await expect(surface).toContainText("^C", { timeout: 30000 });
await page.keyboard.press("Control+B");
await expect(surface).toContainText("^B", { timeout: 30000 });
const marker = `terminal-key-capture-${Date.now()}`;
await page.keyboard.type(`echo ${marker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expect(surface).toContainText(marker, { timeout: 30000 });
} finally {
await repo.cleanup();
}
});
test("Cmd+B toggles sidebar even when terminal is focused", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-cmd-b-");
try {
await openNewAgentDraft(page);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, "Terminal Cmd+B");
await openTerminalsPanel(page);
const surface = page.getByTestId("terminal-surface").first();
await expect(surface).toBeVisible({ timeout: 30000 });
await surface.click({ force: true });
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(false);
await page.keyboard.press("Meta+B");
await expect
.poll(async () => await getDesktopAgentSidebarOpen(page), { timeout: 30000 })
.toBe(true);
} finally {
await repo.cleanup();
}
});
async function getTerminalRows(page: Page): Promise<number> {
return await page.evaluate(() => {
const terminal = (window as { __paseoTerminal?: { rows?: unknown } }).__paseoTerminal;

View File

@@ -30,6 +30,10 @@
}
},
"submit": {
"production": {}
"production": {
"ios": {
"ascAppId": "6758887924"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.4",
"version": "0.1.6",
"private": true,
"scripts": {
"start": "expo start",
@@ -30,7 +30,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.4",
"@getpaseo/server": "0.1.6",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -36,7 +36,8 @@ import {
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
@@ -144,8 +145,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
? mobileView === "agent-list"
: desktopAgentListOpen
: false;
const openGestureEnabled =
chromeEnabled && isMobile && mobileView === "agent";
useGlobalKeyboardNav({
useKeyboardShortcuts({
enabled: chromeEnabled,
isMobile,
toggleAgentList,
@@ -169,7 +172,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
const openGesture = useMemo(
() =>
Gesture.Pan()
.enabled(chromeEnabled && isMobile && !isOpen)
.enabled(openGestureEnabled)
.manualActivation(true)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
@@ -225,9 +228,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
isGesturing.value = false;
}),
[
chromeEnabled,
isMobile,
isOpen,
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
@@ -255,6 +256,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
<DownloadToast />
<CommandCenter />
<KeyboardShortcutsDialog />
</View>
);

View File

@@ -28,7 +28,7 @@ import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { focusWithRetries } from "@/utils/web-focus";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
@@ -74,8 +74,12 @@ export function AgentInputArea({
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const isScreenFocused = useIsFocused();
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
const messageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.messageInputActionRequest
);
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.clearMessageInputActionRequest
);
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
@@ -108,7 +112,7 @@ export function AgentInputArea({
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
@@ -400,21 +404,36 @@ export function AgentInputArea({
saveDraftInput(agentId, { text: userInput, images: selectedImages });
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
// When switching agents from the command center, auto-focus the input on web.
// Keyboard-dispatched message-input actions are routed through store requests.
useEffect(() => {
if (Platform.OS !== "web") return;
if (!isScreenFocused) return;
if (!focusChatInputRequest) return;
if (!messageInputActionRequest) return;
const currentKey = `${serverId}:${agentId}`;
if (focusChatInputRequest.agentKey !== currentKey) {
if (messageInputActionRequest.agentKey !== currentKey) {
return;
}
if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) {
if (
lastHandledMessageInputActionRequestIdRef.current ===
messageInputActionRequest.id
) {
return;
}
lastHandledMessageInputActionRequestIdRef.current =
messageInputActionRequest.id;
if (messageInputActionRequest.kind !== "focus") {
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind);
clearMessageInputActionRequest(messageInputActionRequest.id);
return;
}
if (Platform.OS !== "web") {
messageInputRef.current?.focus();
clearMessageInputActionRequest(messageInputActionRequest.id);
return;
}
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
return focusWithRetries({
focus: () => messageInputRef.current?.focus(),
@@ -424,14 +443,16 @@ export function AgentInputArea({
typeof document !== "undefined" ? document.activeElement : null;
return Boolean(el) && active === el;
},
onSuccess: () => clearFocusChatInputRequest(),
onTimeout: () => clearFocusChatInputRequest(),
onSuccess: () =>
clearMessageInputActionRequest(messageInputActionRequest.id),
onTimeout: () =>
clearMessageInputActionRequest(messageInputActionRequest.id),
});
}, [
agentId,
clearFocusChatInputRequest,
focusChatInputRequest,
clearMessageInputActionRequest,
isScreenFocused,
messageInputActionRequest,
serverId,
]);
@@ -590,7 +611,7 @@ export function AgentInputArea({
]
);
const cancelButton = isAgentRunning && !hasSendableContent ? (
const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleCancelAgent}
@@ -734,6 +755,7 @@ export function AgentInputArea({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}
/>
</View>

View File

@@ -103,7 +103,7 @@ export function AgentStreamView({
const streamItemCountRef = useRef(0);
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
@@ -154,7 +154,12 @@ export function AgentStreamView({
requestFilePreview(agentId, normalized.file);
}
setExplorerTab("files");
setExplorerTabForCheckout({
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
tab: "files",
});
openFileExplorer();
},
[
@@ -163,7 +168,7 @@ export function AgentStreamView({
requestDirectoryListing,
requestFilePreview,
selectExplorerEntry,
setExplorerTab,
setExplorerTabForCheckout,
openFileExplorer,
]
);

View File

@@ -48,9 +48,13 @@ export function CommandCenter() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
<View
testID="command-center-panel"
style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<TextInput
testID="command-center-input"
ref={inputRef}
value={query}
onChangeText={setQuery}

View File

@@ -2,7 +2,6 @@ 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,
@@ -19,44 +18,31 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
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;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
function isTerminalDebugEnabled(): boolean {
const explicit = (
globalThis as {
__PASEO_TERMINAL_DEBUG?: unknown;
}
).__PASEO_TERMINAL_DEBUG;
if (typeof explicit === "boolean") {
return explicit;
function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet";
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
return 0;
}
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);
return Math.max(0, rawHeight - inset);
}
interface ExplorerSidebarProps {
serverId: string;
agentId: string;
cwd: string;
isGit: boolean;
}
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile =
@@ -66,13 +52,13 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const explorerTab = usePanelStore((state) => state.explorerTab);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
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);
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
useEffect(() => {
bottomInset.value = insets.bottom;
@@ -112,68 +98,13 @@ 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 enableSidebarCloseGesture = isMobile && isOpen;
const handleTabPress = useCallback(
(tab: ExplorerTab) => {
setExplorerTab(tab);
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
},
[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);
}
[cwd, isGit, serverId, setExplorerTabForCheckout]
);
// Swipe gesture to close (swipe right on mobile)
@@ -181,18 +112,47 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(isMobile && isOpen)
// Only activate on rightward swipe (positive X), fail on leftward or vertical
// This allows ScrollViews using waitFor to scroll left normally
.activeOffsetX(15)
.failOffsetX(-10)
.failOffsetY([-10, 10])
.enabled(enableSidebarCloseGesture)
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional rightward movement.
if (deltaX >= 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.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)
@@ -200,30 +160,11 @@ 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)();
@@ -233,25 +174,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
})
.onFinalize(() => {
isGesturing.value = false;
if (terminalDebugEnabled) {
runOnJS(logCloseGesture)("finalize");
}
}),
[
isMobile,
isOpen,
explorerTab,
enableSidebarCloseGesture,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
handleClose,
logCloseGesture,
isGesturing,
closeGestureRef,
closeGestureLastLogX,
terminalDebugEnabled,
closeTouchStartX,
closeTouchStartY,
]
);
@@ -295,7 +230,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
@@ -333,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
serverId={serverId}
agentId={agentId}
cwd={cwd}
isGit={isGit}
isMobile={isMobile}
/>
</Animated.View>
@@ -365,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
serverId={serverId}
agentId={agentId}
cwd={cwd}
isGit={isGit}
isMobile={false}
/>
</Animated.View>
@@ -378,6 +315,7 @@ interface SidebarContentProps {
serverId: string;
agentId: string;
cwd: string;
isGit: boolean;
isMobile: boolean;
}
@@ -388,19 +326,12 @@ function SidebarContent({
serverId,
agentId,
cwd,
isGit,
isMobile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
const hasResolvedCheckoutStatus = status !== null;
// Switch to Files tab if Changes tab is hidden and user was on it
useEffect(() => {
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
onTabPress("files");
}
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
const resolvedTab: ExplorerTab =
!isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
@@ -410,13 +341,13 @@ function SidebarContent({
{isGit && (
<Pressable
testID="explorer-tab-changes"
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
resolvedTab === "changes" && styles.tabTextActive,
]}
>
Changes
@@ -425,13 +356,13 @@ function SidebarContent({
)}
<Pressable
testID="explorer-tab-files"
style={[styles.tab, activeTab === "files" && styles.tabActive]}
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
activeTab === "files" && styles.tabTextActive,
resolvedTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -439,13 +370,13 @@ function SidebarContent({
</Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
activeTab === "terminals" && styles.tabTextActive,
resolvedTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
@@ -463,13 +394,13 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{activeTab === "changes" && (
{resolvedTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{activeTab === "files" && (
{resolvedTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
{activeTab === "terminals" && (
{resolvedTab === "terminals" && (
<TerminalPane serverId={serverId} cwd={cwd} />
)}
</View>

View File

@@ -11,7 +11,11 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
const { theme } = useUnistyles();
return (

View File

@@ -1,48 +1,95 @@
import type { ReactNode } from "react";
import { Text } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
function MobileMenuIcon({ color }: { color: string }) {
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/>
</View>
);
}
export function SidebarMenuToggle({
style,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={16} color={menuIconColor} />
)}
</HeaderToggleButton>
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
return (
<ScreenHeader
left={
<>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -69,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));

View File

@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets

View File

@@ -0,0 +1,96 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsTauri } from "@/constants/layout";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
const isMac = getShortcutOs() === "mac";
const isTauri = getIsTauri();
const sections = useMemo(
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
[isMac, isTauri]
);
return (
<AdaptiveModalSheet
title="Keyboard shortcuts"
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"
snapPoints={["70%", "92%"]}
>
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (
<View key={section.title} style={styles.section}>
<Text style={styles.sectionTitle}>{section.title}</Text>
<View style={styles.rows}>
{section.rows.map((row) => (
<View key={row.id} style={styles.row}>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{row.label}</Text>
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
</View>
<Shortcut keys={row.keys} style={styles.rowShortcut} />
</View>
))}
</View>
</View>
))}
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
content: {
gap: theme.spacing[4],
},
section: {
gap: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
rows: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.surface2,
},
rowText: {
flex: 1,
minWidth: 0,
},
rowLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
rowNote: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
rowShortcut: {
alignSelf: "flex-start",
},
}));

View File

@@ -30,13 +30,13 @@ import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export interface ImageAttachment {
uri: string;
@@ -75,6 +75,8 @@ export interface MessageInputProps {
isAgentRunning?: boolean;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void;
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean;
}
@@ -82,6 +84,7 @@ export interface MessageInputProps {
export interface MessageInputRef {
focus: () => void;
blur: () => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void;
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
@@ -131,6 +134,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning = false,
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
},
ref
@@ -138,8 +142,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const { theme } = useUnistyles();
const toast = useToast();
const voice = useVoiceOptional();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
@@ -153,6 +155,40 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
blur: () => {
textInputRef.current?.blur?.();
},
runKeyboardAction: (action) => {
if (action === "focus") {
textInputRef.current?.focus();
return;
}
if (action === "voice-toggle") {
handleToggleRealtimeVoiceShortcut();
return;
}
if (action === "voice-mute-toggle") {
if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute();
}
return;
}
if (action === "dictation-cancel") {
if (isDictatingRef.current) {
cancelDictation();
}
return;
}
if (action === "dictation-toggle") {
if (isDictatingRef.current) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
}
},
getNativeElement: () => {
if (!IS_WEB) return null;
const current = textInputRef.current as
@@ -295,121 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
await startDictation();
}, [dictationUnavailableMessage, startDictation, toast]);
// Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation
useEffect(() => {
if (!IS_WEB) return;
const toggleRealtimeVoice = () => {
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return;
}
if (voice.isVoiceSwitching) {
return;
}
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
const tasks: Promise<unknown>[] = [];
if (isAgentRunning && client) {
tasks.push(client.cancelAgent(voiceAgentId));
}
tasks.push(voice.stopVoice());
void Promise.allSettled(tasks).then((results) => {
results.forEach((result) => {
if (result.status === "rejected") {
console.error(
"[MessageInput] Failed to stop realtime voice",
result.reason
);
}
});
});
return;
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message && message.trim().length > 0) {
toast.error(message);
}
});
};
const resolveNativeInput = (): unknown => {
const current = textInputRef.current as any;
if (!current) return null;
if (typeof current.getNativeRef === "function") {
return current.getNativeRef();
}
return current;
};
function handleKeyDown(event: KeyboardEvent) {
const nativeInput = resolveNativeInput();
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
return;
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d";
if (isMod && event.shiftKey && isKeyD && !event.repeat) {
event.preventDefault();
toggleRealtimeVoice();
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat
) {
event.preventDefault();
voice?.toggleMute();
return;
}
const dictating = isDictatingRef.current;
// Cmd+D: start dictation or submit if already dictating
if (isMod && isKeyD) {
event.preventDefault();
if (dictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
return;
}
// Escape: cancel dictation
if (event.key === "Escape" && dictating) {
event.preventDefault();
cancelDictation();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
cancelDictation,
client,
confirmDictation,
disabled,
isAgentRunning,
isConnected,
isRealtimeVoiceForCurrentAgent,
isScreenFocused,
startDictationIfAvailable,
toast,
voiceAgentId,
voiceServerId,
voice,
]);
// Animate overlay
useEffect(() => {
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
@@ -636,60 +557,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
const key = event.nativeEvent.key.toLowerCase();
// Cmd+B or Ctrl+B: toggle sidebar
if ((metaKey || ctrlKey) && key === "b") {
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+E or Ctrl+E: toggle explorer sidebar
if ((metaKey || ctrlKey) && key === "e") {
event.preventDefault();
toggleFileExplorer();
return;
}
// Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode
if ((metaKey || ctrlKey) && shiftKey && key === "d") {
event.preventDefault();
handleToggleRealtimeVoiceShortcut();
return;
}
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
if ((metaKey || ctrlKey) && key === "d") {
event.preventDefault();
if (isDictating) {
sendAfterTranscriptRef.current = true;
confirmDictation();
} else {
void startDictationIfAvailable();
}
return;
}
// Escape: cancel dictation
if (event.nativeEvent.key === "Escape" && isDictating) {
event.preventDefault();
cancelDictation();
return;
}
if (
isRealtimeVoiceForCurrentAgent &&
!metaKey &&
!ctrlKey &&
!shiftKey &&
event.nativeEvent.key === " "
) {
event.preventDefault();
voice?.toggleMute();
return;
}
if (event.nativeEvent.key !== "Enter") return;
// Shift+Enter: add newline (default behavior, don't intercept)
@@ -712,9 +579,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const canPressLoadingButton =
isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled ||
(!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: isAgentRunning
? "Send and interrupt"
: "Send message";
return (
<View style={styles.container}>
<View style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
@@ -860,7 +737,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
</TooltipContent>
</Tooltip>
{rightContent}
{shouldShowSendButton && isAgentRunning && onQueue && (
{hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
@@ -884,22 +761,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleSendMessage}
disabled={
isSubmitDisabled ||
isSubmitLoading ||
disabled
<TooltipTrigger
onPress={
canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage
}
accessibilityLabel={isAgentRunning ? "Send and interrupt" : "Send message"}
accessibilityRole="button"
style={[
styles.sendButton,
(isSubmitDisabled ||
isSubmitLoading ||
disabled) &&
styles.buttonDisabled,
]}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
style={[
styles.sendButton,
isSendButtonDisabled && styles.buttonDisabled,
]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />

File diff suppressed because it is too large Load Diff

View File

@@ -16,16 +16,25 @@ import { SidebarAgentList } from "./sidebar-agent-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarAgentsGrouped } from "@/hooks/use-sidebar-agents-grouped";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
import { useTauriDragHandlers } from "@/utils/tauri-window";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { Combobox } from "@/components/ui/combobox";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import { formatConnectionStatus } from "@/utils/daemons";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDraftRoute,
buildHostAgentsRoute,
buildHostSettingsRoute,
mapPathnameToServer,
@@ -85,14 +94,23 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
// Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const [selectedProjectKeys, setSelectedProjectKeys] = useState<string[]>([]);
const {
sections,
checkoutByAgentKey,
entries,
projectOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId });
} = useSidebarAgentsGrouped({
isOpen,
serverId: activeServerId,
selectedProjectKeys,
});
useEffect(() => {
setSelectedProjectKeys([]);
}, [activeServerId]);
const {
translateX,
backdropOpacity,
@@ -102,7 +120,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
isGesturing,
closeGestureRef,
} = useSidebarAnimation();
const trafficLightPadding = useTrafficLightPadding();
const dragHandlers = useTauriDragHandlers();
// Track user-initiated refresh to avoid showing spinner on background revalidation
@@ -120,11 +137,12 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}
}, [isRevalidating, isManualRefresh]);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys
);
const sidebarShortcutAgentKeys = useMemo(() => {
return deriveSidebarShortcutAgentKeys(sections, collapsedProjectKeys, 9);
}, [collapsedProjectKeys, sections]);
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
}, [entries]);
useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
@@ -135,11 +153,34 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent]);
const handleCreateAgentClean = useCallback(() => {
if (!activeServerId) {
let targetServerId = activeServerId;
let targetWorkingDir: string | null = null;
const selectedAgent = resolveSelectedAgentForNewAgent({
pathname,
selectedAgentId,
});
if (selectedAgent) {
targetServerId = selectedAgent.serverId;
const agent = useSessionStore
.getState()
.sessions[selectedAgent.serverId]
?.agents?.get(selectedAgent.agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
if (!targetServerId) {
return;
}
router.push(buildHostAgentDraftRoute(activeServerId) as any);
}, [activeServerId]);
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
}, [activeServerId, pathname, selectedAgentId]);
// Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => {
@@ -196,6 +237,27 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
windowWidth,
]);
const listFooterComponent = useMemo(() => {
if (!hasMoreEntries) {
return null;
}
return (
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
{({ hovered }) => (
<Text
style={[
styles.listViewMoreButtonText,
hovered && styles.listViewMoreButtonTextHovered,
]}
>
View more
</Text>
)}
</Pressable>
);
}, [handleViewMore, hasMoreEntries]);
const handleHostSelect = useCallback(
(nextServerId: string) => {
if (!nextServerId) {
@@ -297,9 +359,36 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</>
)}
</Pressable>
</View>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
@@ -314,10 +403,47 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="All agents"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<Users
size={20}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings
size={20}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
@@ -325,54 +451,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.footerButton}
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
All agents
</Text>
</>
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettingsMobile}
>
{({ hovered }) => (
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
</View>
</Animated.View>
</GestureDetector>
@@ -387,11 +465,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
return (
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
{/* Header: New Agent button - top padding area is draggable on Tauri */}
<View
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
{...dragHandlers}
>
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
@@ -401,13 +475,38 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
{({ hovered }) => (
<>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</>
)}
</Pressable>
</View>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
entries={entries}
projectOptions={projectOptions}
selectedProjectKeys={selectedProjectKeys}
onSelectedProjectKeysChange={setSelectedProjectKeys}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={styles.hostTrigger}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
@@ -422,47 +521,24 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
</Text>
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
{/* Middle: scrollable agent list */}
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarAgentList
sections={sections}
checkoutByAgentKey={checkoutByAgentKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
selectedAgentId={selectedAgentId}
/>
)}
{/* Footer */}
<View style={styles.sidebarFooter}>
<Pressable
style={styles.footerButton}
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Users size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} />
<Text style={[styles.footerButtonText, hovered && styles.footerButtonTextHovered]}>
All agents
</Text>
</>
)}
</Pressable>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="All agents"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<Users
size={20}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -478,6 +554,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
</View>
);
@@ -510,9 +597,12 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
},
sidebarHeader: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
@@ -520,7 +610,7 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "flex-start",
gap: theme.spacing[2],
},
newAgentButton: {
@@ -543,12 +633,18 @@ const styles = StyleSheet.create((theme) => ({
hostTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
justifyContent: "flex-start",
gap: theme.spacing[2],
minWidth: 0,
maxWidth: "55%",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
hostTriggerHovered: {
borderColor: theme.colors.borderAccent,
},
hostStatusDot: {
width: 8,
@@ -558,6 +654,8 @@ const styles = StyleSheet.create((theme) => ({
hostTriggerText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
sidebarFooter: {
flexDirection: "row",
@@ -568,28 +666,43 @@ const styles = StyleSheet.create((theme) => ({
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerHostSlot: {
flexGrow: 0,
flexShrink: 1,
minWidth: 0,
marginRight: theme.spacing[2],
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
footerButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
flexShrink: 0,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
footerButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
listViewMoreButton: {
marginTop: theme.spacing[2],
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[2],
},
listViewMoreButtonText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
footerButtonTextHovered: {
listViewMoreButtonTextHovered: {
color: theme.colors.foreground,
},
hostPickerList: {

View File

@@ -1,78 +1,60 @@
"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";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
interface TerminalEmulatorProps {
dom?: DOMProps;
streamKey: string;
outputText: string;
initialOutputText: string;
outputChunkText: string;
outputChunkSequence: number;
testId?: string;
backgroundColor?: string;
foregroundColor?: string;
cursorColor?: string;
onInput?: (data: string) => Promise<void> | void;
onResize?: (rows: number, cols: number) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
}
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}`);
interface Window {}
}
export default function TerminalEmulator({
streamKey,
outputText,
initialOutputText,
outputChunkText,
outputChunkSequence,
testId = "terminal-surface",
backgroundColor = "#0b0b0b",
foregroundColor = "#e6e6e6",
cursorColor = "#e6e6e6",
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
}: 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]);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
useEffect(() => {
const host = hostRef.current;
@@ -81,302 +63,94 @@ export default function TerminalEmulator({
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 runtime = new TerminalEmulatorRuntime();
runtimeRef.current = runtime;
runtime.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
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);
runtime.setPendingModifiers({ pendingModifiers });
runtime.mount({
root,
host,
initialOutputText,
theme: {
backgroundColor,
foregroundColor,
cursorColor,
},
});
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;
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
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) {
runtimeRef.current?.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
},
});
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
useEffect(() => {
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (outputChunkSequence <= 0) {
return;
}
const previous = renderedOutputRef.current;
if (outputText === previous) {
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
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);
}
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]);
renderedOutputRef.current = outputText;
}, [outputText]);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
}
runtimeRef.current?.focus();
}, [focusRequestToken]);
return (
<div
ref={rootRef}
data-testid={testId}
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
position: "relative",
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor,
@@ -384,8 +158,7 @@ export default function TerminalEmulator({
overscrollBehavior: "none",
}}
onPointerDown={() => {
logTerminalDebug("root pointerdown", { streamKey });
terminalRef.current?.focus();
runtimeRef.current?.focus();
}}
>
<div

File diff suppressed because it is too large Load Diff

View File

@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog
? setupLog
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View

View File

@@ -36,6 +36,7 @@ export interface ComboboxProps {
value: string;
onSelect: (id: string) => void;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
@@ -45,6 +46,7 @@ export interface ComboboxProps {
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
@@ -158,6 +160,7 @@ export function Combobox({
value,
onSelect,
onSearchQueryChange,
searchable = true,
placeholder = "Search...",
searchPlaceholder,
emptyText = "No options match your search.",
@@ -167,6 +170,7 @@ export function Combobox({
title = "Select",
open,
onOpenChange,
desktopPlacement = "top-start",
anchorRef,
children,
}: ComboboxProps): ReactElement {
@@ -245,7 +249,7 @@ export function Combobox({
);
const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? "top-start" : "bottom-start",
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
middleware,
sameScrollView: false,
elements: {
@@ -261,7 +265,7 @@ export function Combobox({
}
const raf = requestAnimationFrame(() => update());
return () => cancelAnimationFrame(raf);
}, [isMobile, update, isOpen]);
}, [desktopPlacement, isMobile, update, isOpen]);
useEffect(() => {
if (!isMobile) return;
@@ -293,7 +297,7 @@ export function Combobox({
[]
);
const normalizedSearch = searchQuery.trim().toLowerCase();
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
@@ -308,6 +312,7 @@ export function Combobox({
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
searchable &&
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
@@ -463,7 +468,7 @@ export function Combobox({
const content = children ?? (
<>
{searchInput}
{searchable ? searchInput : null}
{optionsList}
</>
);
@@ -537,7 +542,7 @@ export function Combobox({
</ScrollView>
) : (
<>
{searchInput}
{searchable ? searchInput : null}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
@@ -581,7 +586,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderRadius: 0,
...(IS_WEB
? {}
: {

View File

@@ -605,6 +605,10 @@ export function SessionProvider({
console.log("[Session] Agent update:", agent.id, agent.status);
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
return prev;
}
const next = new Map(prev);
next.set(agent.id, agent);
return next;

View File

@@ -57,7 +57,7 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
const pendingPermissions = new Map();
const agentLastActivity = new Map();
for (const snapshot of agentsList) {
for (const { agent: snapshot } of agentsList.entries) {
const agent = normalizeAgentSnapshot(snapshot, serverId);
agents.set(agent.id, agent);
agentLastActivity.set(agent.id, agent.lastActivityAt);

View File

@@ -56,7 +56,9 @@ export function useAllAgentsList(options?: {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgents();
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
});
},
enabled: canFetch,
staleTime: ALL_AGENTS_STALE_TIME,
@@ -76,11 +78,12 @@ export function useAllAgentsList(options?: {
if (!serverId) {
return [];
}
const data = agentsQuery.data ?? [];
const data = agentsQuery.data?.entries ?? [];
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const list: AggregatedAgent[] = [];
for (const snapshot of data) {
for (const entry of data) {
const snapshot = entry.agent;
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const live = liveAgents?.get(snapshot.id);
list.push(

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -126,9 +126,11 @@ export type CommandCenterItem =
export function useCommandCenter() {
const pathname = usePathname();
const { agents } = useAggregatedAgents();
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput);
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const requestMessageInputAction = useKeyboardShortcutsStore(
(s) => s.requestMessageInputAction
);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -220,13 +222,16 @@ export function useCommandCenter() {
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
requestFocusChatInput(agentKey(agent));
requestMessageInputAction({
agentKey: agentKey(agent),
kind: "focus",
});
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
},
[pathname, requestFocusChatInput, setOpen]
[pathname, requestMessageInputAction, setOpen]
);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
@@ -267,7 +272,10 @@ export function useCommandCenter() {
isFocused,
onTimeout: () => {
if (agentKeyFromPathname) {
requestFocusChatInput(agentKeyFromPathname);
requestMessageInputAction({
agentKey: agentKeyFromPathname,
kind: "focus",
});
}
},
});
@@ -283,7 +291,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [agentKeyFromPathname, open, requestFocusChatInput]);
}, [agentKeyFromPathname, open, requestMessageInputAction]);
useEffect(() => {
if (!open) return;

View File

@@ -1,316 +0,0 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useSessionStore } from "@/stores/session-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
export function useGlobalKeyboardNav({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const isEditableTarget = (event: KeyboardEvent): boolean => {
const target = event.target;
if (!(target instanceof Element)) return false;
if ((target as HTMLElement).isContentEditable) return true;
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return false;
};
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const n = Number(code.slice("Digit".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
if (code.startsWith("Numpad")) {
const n = Number(code.slice("Numpad".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
};
const navigateToSidebarShortcut = (digit: number) => {
const state = useKeyboardNavStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
};
const navigateToNewAgent = () => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
if (selectedAgentId) {
const separatorIndex = selectedAgentId.indexOf(":");
if (separatorIndex > 0) {
const serverId = selectedAgentId.slice(0, separatorIndex);
const agentId = selectedAgentId.slice(separatorIndex + 1);
targetServerId = serverId;
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
const lowerKey = key.toLowerCase();
if (key === "Alt" && !event.shiftKey) {
useKeyboardNavStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
}
// If shift is pressed while a modifier is held, hide the badges
if (key === "Shift") {
const state = useKeyboardNavStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const isMod = event.metaKey || event.ctrlKey;
const isKeyN = event.code === "KeyN" || lowerKey === "n";
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
// Note: intentionally works even when focus is inside an input/textarea.
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd/Ctrl+N: new agent (Tauri only)
// Note: intentionally works even when focus is inside an input/textarea.
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
event.preventDefault();
navigateToNewAgent();
return;
}
// Cmd+B: toggle sidebar
if (
isMod &&
(event.code === "KeyB" || lowerKey === "b")
) {
// The MessageInput already handles Cmd+B inside editable fields. If we also
// handle it globally, it can double-toggle and look like it "doesn't work".
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
isMod &&
(event.code === "Period" || key === ".")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+E: toggle explorer sidebar (only when an agent is selected)
if (
selectedAgentId &&
toggleFileExplorer &&
isMod &&
(event.code === "KeyE" || lowerKey === "e")
) {
// Same double-toggle issue as Cmd+B when focus is inside a text input.
if (isEditableTarget(event)) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Ctrl+`: toggle explorer sidebar (VS Code muscle memory)
// Note: intentionally works even when focus is inside an input/textarea.
if (
selectedAgentId &&
toggleFileExplorer &&
event.ctrlKey &&
!event.metaKey &&
(event.code === "Backquote" || key === "`")
) {
// Ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
event.preventDefault();
toggleFileExplorer();
return;
}
// Cmd+K: command center
if (isMod && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
if (!s.commandCenterOpen) {
const target =
event.target instanceof Element ? (event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}
// Number switching: ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
const digit = parseShortcutDigit(event);
if (!digit) {
return;
}
// Alt/Option+number: always (web + Tauri)
if (event.altKey) {
event.preventDefault();
navigateToSidebarShortcut(digit);
return;
}
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && isMod) {
event.preventDefault();
navigateToSidebarShortcut(digit);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}

View File

@@ -0,0 +1,295 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveSelectedAgentForNewAgent,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostAgentDetailRoute,
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
} from "@/keyboard/actions";
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
function resolveSelectedOrRouteAgentKey(input: {
selectedAgentId?: string;
pathname: string;
}): string | null {
const DRAFT_AGENT_ID = "__new_agent__";
if (input.selectedAgentId) {
return input.selectedAgentId;
}
const route = parseHostAgentRouteFromPathname(input.pathname);
if (!route) {
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
if (!draftRoute) {
return null;
}
return `${draftRoute.serverId}:${DRAFT_AGENT_ID}`;
}
return `${route.serverId}:${route.agentId}`;
}
export function useKeyboardShortcuts({
enabled,
isMobile,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const isMac = getShortcutOs() === "mac";
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
return true;
};
const navigateToSidebarShortcut = (digit: number): boolean => {
const state = useKeyboardShortcutsStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return false;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return false;
}
const { serverId, agentId } = parsed;
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return true;
};
const navigateToNewAgent = (): boolean => {
let targetServerId = parseServerIdFromPathname(pathname);
let targetWorkingDir: string | null = null;
const selectedAgent = resolveSelectedAgentForNewAgent({
pathname,
selectedAgentId,
});
if (selectedAgent) {
targetServerId = selectedAgent.serverId;
const agent = useSessionStore
.getState()
.sessions[selectedAgent.serverId]
?.agents?.get(selectedAgent.agentId);
const cwd = agent?.cwd?.trim();
if (cwd) {
const checkout =
queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null;
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout);
}
}
if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
targetServerId = sessionServerIds[0] ?? null;
}
if (!targetServerId) {
return false;
}
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any);
return true;
};
const requestMessageInputAction = (
kind: MessageInputKeyboardActionKind
): boolean => {
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
if (!agentKey) {
return false;
}
useKeyboardShortcutsStore.getState().requestMessageInputAction({
agentKey,
kind,
});
return true;
};
const handleAction = (input: {
action: string;
payload: KeyboardShortcutPayload;
event: KeyboardEvent;
}): boolean => {
switch (input.action) {
case "agent.new":
return navigateToNewAgent();
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "sidebar.toggle.right":
if (!selectedAgentId || !toggleFileExplorer) {
return false;
}
toggleFileExplorer();
return true;
case "sidebar.navigate.shortcut":
if (!input.payload || typeof input.payload !== "object" || !("digit" in input.payload)) {
return false;
}
return navigateToSidebarShortcut(input.payload.digit);
case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
const target =
input.event.target instanceof Element ? (input.event.target as Element) : null;
const targetEl =
target?.closest?.("textarea, input, [contenteditable='true']") ??
(target instanceof HTMLElement ? target : null);
const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null;
setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null
);
}
store.setCommandCenterOpen(!store.commandCenterOpen);
return true;
}
case "shortcuts.dialog.toggle": {
const store = useKeyboardShortcutsStore.getState();
store.setShortcutsDialogOpen(!store.shortcutsDialogOpen);
return true;
}
case "message-input.action":
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
return false;
}
return requestMessageInputAction(input.payload.kind);
default:
return false;
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control") && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(true);
}
if (key === "Shift") {
const state = useKeyboardShortcutsStore.getState();
if (state.altDown || state.cmdOrCtrlDown) {
state.resetModifiers();
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const match = resolveKeyboardShortcut({
event,
context: {
isMac,
isTauri,
focusScope,
commandCenterOpen: store.commandCenterOpen,
hasSelectedAgent: Boolean(selectedAgentId && toggleFileExplorer),
},
});
if (!match) {
return;
}
const handled = handleAction({
action: match.action,
payload: match.payload,
event,
});
if (!handled) {
return;
}
if (match.preventDefault) {
event.preventDefault();
}
if (match.stopPropagation) {
event.stopPropagation();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardShortcutsStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardShortcutsStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [
enabled,
isMobile,
pathname,
resetModifiers,
router,
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
]);
}

View File

@@ -1,53 +1,129 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore, type Agent } from "@/stores/session-store";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
useSectionOrderStore,
sortProjectsByStoredOrder,
} from "@/stores/section-order-store";
import type { FetchAgentsGroupedByProjectResponseMessage } from "@server/shared/messages";
deriveSidebarStateBucket,
isSidebarActiveAgent,
} from "@/utils/sidebar-agent-state";
import type { ProjectPlacementPayload } from "@server/shared/messages";
const SIDEBAR_GROUPS_STALE_TIME = 15_000;
const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000;
const MAX_AGENTS_PER_PROJECT = 5;
const SIDEBAR_AGENTS_STALE_TIME = 15_000;
const SIDEBAR_AGENTS_REFETCH_INTERVAL = 10_000;
const SIDEBAR_DONE_FILL_TARGET = 50;
type SidebarGroupsPayload =
FetchAgentsGroupedByProjectResponseMessage["payload"];
export type SidebarCheckoutLite =
SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"];
type MutableSidebarGroup = {
export interface SidebarProjectOption {
projectKey: string;
projectName: string;
agents: AggregatedAgent[];
};
activeCount: number;
totalCount: number;
serverId: string;
workingDir: string;
}
export interface SidebarSectionData {
key: string;
projectKey: string;
title: string;
agents: AggregatedAgent[];
firstAgentServerId?: string;
firstAgentId?: string;
workingDir?: string;
export interface SidebarAgentListEntry {
agent: AggregatedAgent & { createdAt: Date };
project: ProjectPlacementPayload;
}
export interface SidebarAgentsGroupedResult {
sections: SidebarSectionData[];
checkoutByAgentKey: Map<string, SidebarCheckoutLite>;
entries: SidebarAgentListEntry[];
projectOptions: SidebarProjectOption[];
hasMoreEntries: boolean;
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
refreshAll: () => void;
}
function compareByLastActivityDesc(
left: SidebarAgentListEntry,
right: SidebarAgentListEntry
): number {
return right.agent.lastActivityAt.getTime() - left.agent.lastActivityAt.getTime();
}
function compareByTitleAsc(
left: SidebarAgentListEntry,
right: SidebarAgentListEntry
): number {
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase();
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase();
const titleCmp = leftTitle.localeCompare(rightTitle, undefined, {
numeric: true,
sensitivity: "base",
});
if (titleCmp !== 0) {
return titleCmp;
}
// Deterministic tie-breaker so running rows stay stable while status updates stream.
return left.agent.id.localeCompare(right.agent.id, undefined, {
numeric: true,
sensitivity: "base",
});
}
function applySidebarDefaultOrdering(
entries: SidebarAgentListEntry[]
): { entries: SidebarAgentListEntry[]; hasMore: boolean } {
const needsInput: SidebarAgentListEntry[] = [];
const failed: SidebarAgentListEntry[] = [];
const running: SidebarAgentListEntry[] = [];
const attention: SidebarAgentListEntry[] = [];
const done: SidebarAgentListEntry[] = [];
for (const entry of entries) {
const bucket = deriveSidebarStateBucket({
status: entry.agent.status,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
});
if (bucket === "needs_input") {
needsInput.push(entry);
continue;
}
if (bucket === "failed") {
failed.push(entry);
continue;
}
if (bucket === "running") {
running.push(entry);
continue;
}
if (bucket === "attention") {
attention.push(entry);
continue;
}
done.push(entry);
}
needsInput.sort(compareByLastActivityDesc);
failed.sort(compareByLastActivityDesc);
running.sort(compareByTitleAsc);
attention.sort(compareByLastActivityDesc);
done.sort(compareByLastActivityDesc);
const active = [...needsInput, ...failed, ...running, ...attention];
if (active.length >= SIDEBAR_DONE_FILL_TARGET) {
return { entries: active, hasMore: done.length > 0 };
}
const remainingDoneSlots = SIDEBAR_DONE_FILL_TARGET - active.length;
const shownDone = done.slice(0, remainingDoneSlots);
return {
entries: [...active, ...shownDone],
hasMore: done.length > shownDone.length,
};
}
function toAggregatedAgent(params: {
source: Agent | ReturnType<typeof normalizeAgentSnapshot>;
serverId: string;
serverLabel: string;
}): AggregatedAgent {
}): AggregatedAgent & { createdAt: Date } {
const source = params.source;
return {
id: source.id,
@@ -55,6 +131,7 @@ function toAggregatedAgent(params: {
serverLabel: params.serverLabel,
title: source.title ?? null,
status: source.status,
createdAt: source.createdAt,
lastActivityAt: source.lastActivityAt,
cwd: source.cwd,
provider: source.provider,
@@ -69,6 +146,7 @@ function toAggregatedAgent(params: {
export function useSidebarAgentsGrouped(options?: {
isOpen?: boolean;
serverId?: string | null;
selectedProjectKeys?: string[];
}): SidebarAgentsGroupedResult {
const { connectionStates } = useDaemonConnections();
const queryClient = useQueryClient();
@@ -79,6 +157,15 @@ export function useSidebarAgentsGrouped(options?: {
? value.trim()
: null;
}, [options?.serverId]);
const selectedProjectKeys = useMemo(
() =>
new Set(
(options?.selectedProjectKeys ?? [])
.map((item) => item.trim())
.filter((item) => item.length > 0)
),
[options?.selectedProjectKeys]
);
const session = useSessionStore((state) =>
serverId ? state.sessions[serverId] : undefined
@@ -88,189 +175,160 @@ export function useSidebarAgentsGrouped(options?: {
const isConnected = session?.connection.isConnected ?? false;
const canFetch = Boolean(serverId && client && isConnected);
const groupedQuery = useQuery({
queryKey: ["sidebarAgentsGrouped", serverId] as const,
const agentsQuery = useQuery({
queryKey: ["sidebarAgentsList", serverId] as const,
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.fetchAgentsGroupedByProject({
return await client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "updated_at", direction: "desc" },
],
});
},
enabled: canFetch,
staleTime: SIDEBAR_GROUPS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false,
staleTime: SIDEBAR_AGENTS_STALE_TIME,
refetchInterval: isOpen ? SIDEBAR_AGENTS_REFETCH_INTERVAL : false,
refetchIntervalInBackground: isOpen,
refetchOnMount: "always" as const,
});
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => {
const { entries, projectOptions, hasAnyData, hasMoreEntries } = useMemo(() => {
if (!serverId) {
return {
sections: [] as SidebarSectionData[],
checkoutByAgentKey: new Map<string, SidebarCheckoutLite>(),
entries: [] as SidebarAgentListEntry[],
projectOptions: [] as SidebarProjectOption[],
hasAnyData: false,
hasMoreEntries: false,
};
}
const groupsByKey = new Map<string, MutableSidebarGroup>();
const checkoutLookup = new Map<string, SidebarCheckoutLite>();
const seenAgentKeys = new Set<string>();
const payload = groupedQuery.data as SidebarGroupsPayload | undefined;
const groupedFetchReady = groupedQuery.isFetched;
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
const seenAgentIds = new Set<string>();
const byProject = new Map<string, SidebarProjectOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
if (payload) {
for (const group of payload.groups) {
const existing: MutableSidebarGroup =
groupsByKey.get(group.projectKey) ??
{
projectKey: group.projectKey,
projectName: group.projectName,
agents: [],
};
for (const entry of group.agents) {
const normalized = normalizeAgentSnapshot(entry.agent, serverId);
const live = liveAgents?.get(entry.agent.id);
const nextAgent = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
if (nextAgent.archivedAt) {
continue;
}
const agentKey = `${serverId}:${entry.agent.id}`;
seenAgentKeys.add(agentKey);
checkoutLookup.set(
agentKey,
live?.projectPlacement?.checkout ?? entry.checkout
);
existing.agents.push(nextAgent);
}
groupsByKey.set(group.projectKey, existing);
const pushEntry = (entry: SidebarAgentListEntry): void => {
if (entry.agent.archivedAt) {
return;
}
const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`;
if (seenAgentIds.has(dedupeKey)) {
return;
}
seenAgentIds.add(dedupeKey);
mergedEntries.push(entry);
const existing = byProject.get(entry.project.projectKey);
const isActive = isSidebarActiveAgent({
status: entry.agent.status,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
});
if (existing) {
existing.totalCount += 1;
if (isActive) {
existing.activeCount += 1;
}
return;
}
byProject.set(entry.project.projectKey, {
projectKey: entry.project.projectKey,
projectName: entry.project.projectName,
activeCount: isActive ? 1 : 0,
totalCount: 1,
serverId,
workingDir: entry.project.checkout.cwd,
});
};
const fetchedEntries = agentsQuery.data?.entries ?? [];
for (const fetchedEntry of fetchedEntries) {
const normalized = normalizeAgentSnapshot(fetchedEntry.agent, serverId);
const live = liveAgents?.get(fetchedEntry.agent.id);
const project = live?.projectPlacement ?? fetchedEntry.project;
if (!project) {
continue;
}
const agent = toAggregatedAgent({
source: live ?? normalized,
serverId,
serverLabel,
});
pushEntry({ agent, project });
}
if (groupedFetchReady && liveAgents) {
if (liveAgents) {
for (const live of liveAgents.values()) {
if (live.archivedAt || live.labels.ui !== "true") {
continue;
}
if (!live.projectPlacement) {
// Ignore fetchAgents-hydrated snapshots for sidebar placement.
// Sidebar should derive placement from grouped RPC or project-enriched agent_update.
continue;
}
const agentKey = `${serverId}:${live.id}`;
if (seenAgentKeys.has(agentKey)) {
continue;
}
const livePlacement = live.projectPlacement;
const projectKey = livePlacement.projectKey;
const existing: MutableSidebarGroup =
groupsByKey.get(projectKey) ??
{
projectKey,
projectName: livePlacement.projectName,
agents: [],
};
existing.agents.push(
toAggregatedAgent({
source: live,
serverId,
serverLabel,
})
);
checkoutLookup.set(agentKey, livePlacement.checkout);
groupsByKey.set(projectKey, existing);
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
});
pushEntry({ agent, project: live.projectPlacement });
}
}
const sortedGroups = Array.from(groupsByKey.values())
.map((group) => {
const agents = [...group.agents].sort(
(left, right) =>
right.lastActivityAt.getTime() - left.lastActivityAt.getTime()
);
return {
...group,
agents: agents.slice(0, MAX_AGENTS_PER_PROJECT),
};
})
.filter((group) => group.agents.length > 0)
.sort((left, right) => {
const leftRecent = left.agents[0]?.lastActivityAt.getTime() ?? 0;
const rightRecent = right.agents[0]?.lastActivityAt.getTime() ?? 0;
return rightRecent - leftRecent;
});
const filteredEntries =
selectedProjectKeys.size > 0
? mergedEntries.filter((entry) =>
selectedProjectKeys.has(entry.project.projectKey)
)
: mergedEntries;
const orderedGroups = sortProjectsByStoredOrder(sortedGroups, projectOrder);
const nextSections = orderedGroups.map((group) => {
const firstAgent = group.agents[0];
return {
key: `project:${group.projectKey}`,
projectKey: group.projectKey,
title: group.projectName,
agents: group.agents,
firstAgentServerId: firstAgent?.serverId,
firstAgentId: firstAgent?.id,
workingDir: firstAgent?.cwd,
};
const ordered = applySidebarDefaultOrdering(filteredEntries);
const options = Array.from(byProject.values()).sort((left, right) => {
if (left.activeCount !== right.activeCount) {
return right.activeCount - left.activeCount;
}
return left.projectName.localeCompare(right.projectName);
});
return {
sections: nextSections,
checkoutByAgentKey: checkoutLookup,
hasAnyData: nextSections.length > 0,
entries: ordered.entries,
projectOptions: options,
hasAnyData: ordered.entries.length > 0,
hasMoreEntries: ordered.hasMore,
};
}, [
agentsQuery.data?.entries,
connectionStates,
groupedQuery.data,
groupedQuery.isFetched,
liveAgents,
projectOrder,
selectedProjectKeys,
serverId,
]);
useEffect(() => {
const currentKeys = sections.map((section) => section.projectKey);
const storedKeys = new Set(projectOrder);
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
if (newKeys.length > 0) {
setProjectOrder([...projectOrder, ...newKeys]);
}
}, [sections, projectOrder, setProjectOrder]);
const refreshAll = useCallback(() => {
if (!serverId) {
return;
}
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsGrouped", serverId],
queryKey: ["sidebarAgentsList", serverId],
});
}, [queryClient, serverId]);
const isFetching =
canFetch && (groupedQuery.isPending || groupedQuery.isFetching);
canFetch && (agentsQuery.isPending || agentsQuery.isFetching);
const isInitialLoad = isFetching && !hasAnyData;
const isRevalidating = isFetching && hasAnyData;
return {
sections,
checkoutByAgentKey,
entries,
projectOptions,
hasMoreEntries,
isLoading: isFetching,
isInitialLoad,
isRevalidating,
refreshAll,
};
}

View File

@@ -0,0 +1,27 @@
export type KeyboardFocusScope =
| "terminal"
| "message-input"
| "command-center"
| "editable"
| "other";
export type MessageInputKeyboardActionKind =
| "focus"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
| "voice-mute-toggle";
export type KeyboardActionId =
| "agent.new"
| "sidebar.toggle.left"
| "sidebar.toggle.right"
| "sidebar.navigate.shortcut"
| "command-center.toggle"
| "shortcuts.dialog.toggle"
| "message-input.action";
export type KeyboardShortcutPayload =
| { digit: number }
| { kind: MessageInputKeyboardActionKind }
| null;

View File

@@ -0,0 +1,42 @@
import type { KeyboardFocusScope } from "@/keyboard/actions";
export function resolveKeyboardFocusScope(input: {
target: EventTarget | null;
commandCenterOpen: boolean;
}): KeyboardFocusScope {
const { target, commandCenterOpen } = input;
if (!(target instanceof Element)) {
return commandCenterOpen ? "command-center" : "other";
}
if (
target.closest("[data-testid='terminal-surface']") ||
target.closest(".xterm")
) {
return "terminal";
}
if (
commandCenterOpen &&
(target.closest("[data-testid='command-center-panel']") ||
target.closest("[data-testid='command-center-input']"))
) {
return "command-center";
}
if (target.closest("[data-testid='message-input-root']")) {
return "message-input";
}
const editable = target as HTMLElement;
if (editable.isContentEditable) {
return commandCenterOpen ? "command-center" : "editable";
}
const tag = target.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") {
return commandCenterOpen ? "command-center" : "editable";
}
return commandCenterOpen ? "command-center" : "other";
}

View File

@@ -0,0 +1,233 @@
import { describe, expect, it } from "vitest";
import {
buildKeyboardShortcutHelpSections,
resolveKeyboardShortcut,
type KeyboardShortcutContext,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: "",
code: "",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
repeat: false,
...overrides,
} as KeyboardEvent;
}
function shortcutContext(
overrides: Partial<KeyboardShortcutContext> = {}
): KeyboardShortcutContext {
return {
isMac: false,
isTauri: false,
focusScope: "other",
commandCenterOpen: false,
hasSelectedAgent: true,
...overrides,
};
}
describe("keyboard-shortcuts", () => {
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "?",
code: "Slash",
shiftKey: true,
}),
context: shortcutContext({ focusScope: "other" }),
});
expect(match?.action).toBe("shortcuts.dialog.toggle");
});
it("does not match question-mark shortcut inside editable scopes", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "?",
code: "Slash",
shiftKey: true,
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match).toBeNull();
});
it("matches Cmd+B sidebar toggle on macOS", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
metaKey: true,
}),
context: shortcutContext({ isMac: true }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("does not bind Ctrl+B on non-mac", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "b",
code: "KeyB",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match).toBeNull();
});
it("keeps Mod+. as sidebar toggle fallback", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: ".",
code: "Period",
ctrlKey: true,
}),
context: shortcutContext({ isMac: false }),
});
expect(match?.action).toBe("sidebar.toggle.left");
});
it("routes Mod+D to message-input action outside terminal", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "message-input" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "dictation-toggle" });
});
it("does not route message-input actions when terminal is focused", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "d",
code: "KeyD",
metaKey: true,
}),
context: shortcutContext({ isMac: true, focusScope: "terminal" }),
});
expect(match).toBeNull();
});
it("keeps space typing available in message input", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: " ",
code: "Space",
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match).toBeNull();
});
it("routes space to voice mute toggle outside editable scopes", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: " ",
code: "Space",
}),
context: shortcutContext({ focusScope: "other" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "voice-mute-toggle" });
});
it("lets Escape continue to local handlers while routing dictation cancel", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "Escape",
code: "Escape",
}),
context: shortcutContext({ focusScope: "message-input" }),
});
expect(match?.action).toBe("message-input.action");
expect(match?.payload).toEqual({ kind: "dictation-cancel" });
expect(match?.preventDefault).toBe(false);
expect(match?.stopPropagation).toBe(false);
});
it("parses Alt+digit sidebar shortcut payload", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "2",
code: "Digit2",
altKey: true,
}),
context: shortcutContext(),
});
expect(match?.action).toBe("sidebar.navigate.shortcut");
expect(match?.payload).toEqual({ digit: 2 });
});
});
describe("keyboard-shortcut help sections", () => {
function findRow(
sections: ReturnType<typeof buildKeyboardShortcutHelpSections>,
id: string
) {
for (const section of sections) {
const row = section.rows.find((candidate) => candidate.id === id);
if (row) {
return row;
}
}
return null;
}
it("uses non-tauri defaults for new-agent and quick-open", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: true,
isTauri: false,
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
"alt",
"1-9",
]);
});
it("switches to tauri bindings in help rows", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: true,
isTauri: true,
});
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
expect(findRow(sections, "quick-open-agent")?.keys).toEqual([
"mod",
"1-9",
]);
});
it("uses mod+period as non-mac left sidebar shortcut", () => {
const sections = buildKeyboardShortcutHelpSections({
isMac: false,
isTauri: false,
});
expect(findRow(sections, "toggle-left-sidebar")?.keys).toEqual([
"mod",
".",
]);
});
});

View File

@@ -0,0 +1,423 @@
import type { ShortcutKey } from "@/utils/format-shortcut";
import type {
KeyboardActionId,
KeyboardFocusScope,
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
export type KeyboardShortcutContext = {
isMac: boolean;
isTauri: boolean;
focusScope: KeyboardFocusScope;
commandCenterOpen: boolean;
hasSelectedAgent: boolean;
};
export type KeyboardShortcutMatch = {
action: KeyboardActionId;
payload: KeyboardShortcutPayload;
preventDefault: boolean;
stopPropagation: boolean;
};
export type KeyboardShortcutHelpRow = {
id: string;
label: string;
keys: ShortcutKey[];
note?: string;
};
export type KeyboardShortcutHelpSection = {
id: "global" | "agent-input";
title: string;
rows: KeyboardShortcutHelpRow[];
};
type KeyboardShortcutPlatformContext = {
isMac: boolean;
isTauri: boolean;
};
type KeyboardShortcutHelpEntry = {
id: string;
section: KeyboardShortcutHelpSection["id"];
label: string;
keys: ShortcutKey[];
note?: string;
when?: (context: KeyboardShortcutPlatformContext) => boolean;
};
type KeyboardShortcutBinding = {
id: string;
action: KeyboardActionId;
matches: (event: KeyboardEvent) => boolean;
when: (context: KeyboardShortcutContext) => boolean;
payload?: (event: KeyboardEvent) => KeyboardShortcutPayload;
preventDefault?: boolean;
stopPropagation?: boolean;
help?: KeyboardShortcutHelpEntry;
};
const SHORTCUT_HELP_SECTION_TITLES: Record<
KeyboardShortcutHelpSection["id"],
string
> = {
global: "Global",
"agent-input": "Agent Input",
};
function isMod(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey;
}
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const value = Number(code.slice("Digit".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
if (code.startsWith("Numpad")) {
const value = Number(code.slice("Numpad".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
}
function hasDigit(event: KeyboardEvent): boolean {
return parseDigit(event) !== null;
}
function isQuestionMarkShortcut(event: KeyboardEvent): boolean {
return (
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.shiftKey &&
!event.repeat &&
(event.key === "?" || event.code === "Slash")
);
}
function withMessageInputAction(
kind: MessageInputKeyboardActionKind
): (event: KeyboardEvent) => KeyboardShortcutPayload {
return () => ({ kind });
}
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
{
id: "agent-new-mod-alt-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: () => true,
help: {
id: "new-agent",
section: "global",
label: "Create new agent",
keys: ["mod", "alt", "N"],
when: (context) => !context.isTauri,
},
},
{
id: "agent-new-tauri-mod-n",
action: "agent.new",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
when: (context) => context.isTauri,
help: {
id: "new-agent",
section: "global",
label: "Create new agent",
keys: ["mod", "N"],
when: (context) => context.isTauri,
},
},
{
id: "command-center-toggle",
action: "command-center.toggle",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyK" || event.key.toLowerCase() === "k"),
when: () => true,
help: {
id: "toggle-command-center",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
},
{
id: "shortcuts-dialog-toggle-question-mark",
action: "shortcuts.dialog.toggle",
matches: isQuestionMarkShortcut,
when: (context) => context.focusScope === "other",
help: {
id: "show-shortcuts",
section: "global",
label: "Show keyboard shortcuts",
keys: ["?"],
note: "Available when focus is not in a text field or terminal.",
},
},
{
id: "sidebar-toggle-left-mac-cmd-b",
action: "sidebar.toggle.left",
matches: (event) =>
event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyB" || event.key.toLowerCase() === "b"),
when: (context) => context.isMac,
help: {
id: "toggle-left-sidebar",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "B"],
when: (context) => context.isMac,
},
},
{
id: "sidebar-toggle-left-mod-period",
action: "sidebar.toggle.left",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Period" || event.key === "."),
when: (context) => !context.commandCenterOpen,
help: {
id: "toggle-left-sidebar",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "."],
when: (context) => !context.isMac,
},
},
{
id: "sidebar-toggle-right-mod-e",
action: "sidebar.toggle.right",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyE" || event.key.toLowerCase() === "e"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
help: {
id: "toggle-right-sidebar",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
},
{
id: "sidebar-toggle-right-ctrl-backquote",
action: "sidebar.toggle.right",
matches: (event) =>
event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Backquote" || event.key === "`"),
when: (context) => context.hasSelectedAgent && !context.commandCenterOpen,
},
{
id: "message-input-voice-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d") &&
!event.repeat,
payload: withMessageInputAction("voice-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
help: {
id: "voice-toggle",
section: "agent-input",
label: "Toggle voice mode",
keys: ["mod", "shift", "D"],
},
},
{
id: "message-input-dictation-toggle",
action: "message-input.action",
matches: (event) =>
isMod(event) &&
!event.shiftKey &&
!event.altKey &&
(event.code === "KeyD" || event.key.toLowerCase() === "d"),
payload: withMessageInputAction("dictation-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
help: {
id: "dictation-toggle",
section: "agent-input",
label: "Start/stop dictation",
keys: ["mod", "D"],
},
},
{
id: "message-input-dictation-cancel",
action: "message-input.action",
matches: (event) => event.key === "Escape",
payload: withMessageInputAction("dictation-cancel"),
when: (context) =>
!context.commandCenterOpen && context.focusScope !== "terminal",
preventDefault: false,
stopPropagation: false,
help: {
id: "dictation-cancel",
section: "agent-input",
label: "Cancel dictation",
keys: ["Esc"],
},
},
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",
matches: (event) =>
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
(event.code === "Space" || event.key === " ") &&
!event.repeat,
payload: withMessageInputAction("voice-mute-toggle"),
when: (context) =>
!context.commandCenterOpen && context.focusScope === "other",
help: {
id: "voice-mute-toggle",
section: "agent-input",
label: "Mute/unmute voice mode",
keys: ["Space"],
},
},
{
id: "sidebar-shortcut-alt-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => event.altKey && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => !context.commandCenterOpen,
help: {
id: "quick-open-agent",
section: "global",
label: "Open sidebar agent shortcut",
keys: ["alt", "1-9"],
when: (context) => !context.isTauri,
},
},
{
id: "sidebar-shortcut-tauri-mod-digit",
action: "sidebar.navigate.shortcut",
matches: (event) => isMod(event) && hasDigit(event),
payload: (event) => {
const digit = parseDigit(event);
return digit ? { digit } : null;
},
when: (context) => context.isTauri && !context.commandCenterOpen,
help: {
id: "quick-open-agent",
section: "global",
label: "Open sidebar agent shortcut",
keys: ["mod", "1-9"],
when: (context) => context.isTauri,
},
},
];
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
}): KeyboardShortcutMatch | null {
const { event, context } = input;
for (const binding of SHORTCUT_BINDINGS) {
if (!binding.matches(event)) {
continue;
}
if (!binding.when(context)) {
continue;
}
const payload = binding.payload?.(event) ?? null;
return {
action: binding.action,
payload,
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
return null;
}
export function buildKeyboardShortcutHelpSections(
input: KeyboardShortcutPlatformContext
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
["global", []],
["agent-input", []],
]);
for (const binding of SHORTCUT_BINDINGS) {
const help = binding.help;
if (!help) {
continue;
}
if (help.when && !help.when(input)) {
continue;
}
const rowKey = `${help.section}:${help.id}`;
if (seenRows.has(rowKey)) {
continue;
}
seenRows.add(rowKey);
const rows = rowsBySection.get(help.section);
if (!rows) {
continue;
}
rows.push({
id: help.id,
label: help.label,
keys: help.keys,
...(help.note ? { note: help.note } : {}),
});
}
const sectionOrder: KeyboardShortcutHelpSection["id"][] = [
"global",
"agent-input",
];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
if (rows.length === 0) {
return [];
}
return [
{
id: sectionId,
title: SHORTCUT_HELP_SECTION_TITLES[sectionId],
rows,
},
];
});
}

View File

@@ -13,6 +13,7 @@ import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
@@ -59,7 +60,11 @@ import { extractAgentModel } from "@/utils/extract-agent-model";
import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
useCheckoutStatusQuery,
} from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useToast } from "@/contexts/toast-context";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
@@ -67,6 +72,7 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import type { FetchAgentsEntry } from "@server/client/daemon-client";
import {
DropdownMenu,
DropdownMenuContent,
@@ -75,6 +81,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -210,6 +217,8 @@ function AgentScreenContent({
const toast = useToast();
const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient();
const resolvedAgentId = agentId;
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -229,22 +238,100 @@ function AgentScreenContent({
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout);
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout
);
// Derive isExplorerOpen from the unified panel state
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const openExplorerWithDefaultTab = useCallback(() => {
// Generic explorer toggles should land on Changes by default.
setExplorerTab("changes");
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
// Checkout status for header subtitle + git fallback when cached project placement is absent
const checkoutStatusQuery = useCheckoutStatusQuery({
serverId,
cwd: agent?.cwd ?? "",
});
const checkout = checkoutStatusQuery.status;
const resolveCachedCheckoutIsGit = useCallback(
(params: {
agentId?: string | null;
cwd?: string | null;
projectPlacementIsGit?: boolean;
checkoutStatusIsGit?: boolean;
}): boolean | null => {
if (typeof params.projectPlacementIsGit === "boolean") {
return params.projectPlacementIsGit;
}
const agentId = params.agentId?.trim();
if (agentId) {
const sidebarAgents = queryClient.getQueryData<{
entries: FetchAgentsEntry[];
}>(["sidebarAgentsList", serverId]);
const sidebarIsGit = sidebarAgents?.entries.find(
(entry) => entry.agent.id === agentId
)?.project?.checkout?.isGit;
if (typeof sidebarIsGit === "boolean") {
return sidebarIsGit;
}
}
const cwd = params.cwd?.trim();
if (!cwd) {
return null;
}
const cachedCheckout = queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, cwd)
);
if (typeof cachedCheckout?.isGit === "boolean") {
return cachedCheckout.isGit;
}
if (typeof params.checkoutStatusIsGit === "boolean") {
return params.checkoutStatusIsGit;
}
return null;
},
[queryClient, serverId]
);
const resolveCurrentExplorerCheckout = useCallback((): ExplorerCheckoutContext | null => {
if (!resolvedAgentId) {
return null;
}
const currentAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents?.get(resolvedAgentId);
const cwd = currentAgent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: currentAgent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
});
if (!cwd || typeof isGit !== "boolean") {
return null;
}
return { serverId, cwd, isGit };
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
const openExplorerForActiveCheckout = useCallback(() => {
const checkoutContext = resolveCurrentExplorerCheckout();
if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext);
}
openFileExplorer();
}, [openFileExplorer, setExplorerTab]);
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
const handleToggleExplorer = useCallback(() => {
if (isExplorerOpen) {
toggleFileExplorer();
return;
}
openExplorerWithDefaultTab();
}, [isExplorerOpen, openExplorerWithDefaultTab, toggleFileExplorer]);
openExplorerForActiveCheckout();
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]);
const {
translateX: explorerTranslateX,
@@ -254,6 +341,10 @@ function AgentScreenContent({
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
const handleOpenExplorerFromGesture = useCallback(() => {
openExplorerForActiveCheckout();
animateExplorerToOpen();
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
useEffect(() => {
if (Platform.OS !== "web") {
@@ -293,8 +384,7 @@ function AgentScreenContent({
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
animateExplorerToOpen();
runOnJS(openExplorerWithDefaultTab)();
runOnJS(handleOpenExplorerFromGesture)();
} else {
animateExplorerToClose();
}
@@ -308,9 +398,8 @@ function AgentScreenContent({
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToOpen,
animateExplorerToClose,
openExplorerWithDefaultTab,
handleOpenExplorerFromGesture,
isExplorerGesturing,
]
);
@@ -330,14 +419,43 @@ function AgentScreenContent({
return () => handler.remove();
}, [isExplorerOpen, closeToAgent]);
const resolvedAgentId = agentId;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
const cwd = agent?.cwd?.trim();
const isGit = resolveCachedCheckoutIsGit({
agentId: resolvedAgentId,
cwd,
projectPlacementIsGit: agent?.projectPlacement?.checkout?.isGit,
checkoutStatusIsGit: checkout?.isGit,
});
if (!cwd || typeof isGit !== "boolean") {
return null;
}
return { serverId, cwd, isGit };
}, [
agent?.cwd,
agent?.projectPlacement?.checkout?.isGit,
resolveCachedCheckoutIsGit,
resolvedAgentId,
checkout?.isGit,
serverId,
]);
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
useEffect(() => {
setActiveExplorerCheckout(activeExplorerCheckout);
}, [activeExplorerCheckout, setActiveExplorerCheckout]);
useEffect(() => {
if (!activeExplorerCheckout) {
return;
}
activateExplorerTabForCheckout(activeExplorerCheckout);
}, [activateExplorerTabForCheckout, activeExplorerCheckout]);
useEffect(() => {
return () => {
setActiveExplorerCheckout(null);
};
}, [setActiveExplorerCheckout]);
// Select only the specific stream tail - use stable empty array to avoid infinite loop
const streamItemsRaw = useSessionStore((state) =>
@@ -480,12 +598,7 @@ function AgentScreenContent({
};
}, [showConnectedNotice]);
// Checkout status for header subtitle
const checkoutStatusQuery = useCheckoutStatusQuery({
serverId,
cwd: agent?.cwd ?? "",
});
const checkout = checkoutStatusQuery.status;
const isGitCheckout = activeExplorerCheckout?.isGit ?? false;
useEffect(() => {
if (!resolvedAgentId) {
@@ -1105,7 +1218,12 @@ function AgentScreenContent({
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
</View>
);
@@ -1122,7 +1240,12 @@ function AgentScreenContent({
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
<ExplorerSidebar
serverId={serverId}
agentId={resolvedAgentId}
cwd={effectiveAgent.cwd}
isGit={isGitCheckout}
/>
)}
</>
);

View File

@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { Folder, GitBranch } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
@@ -31,7 +31,6 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -125,9 +124,6 @@ export function DraftAgentScreen({
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const params = useLocalSearchParams<DraftAgentParams>();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -226,11 +222,6 @@ export function DraftAgentScreen({
: undefined;
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const SidebarIcon = isMobile ? Menu : PanelLeft;
const sidebarIconColor = !isMobile && isSidebarOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
const [baseBranch, setBaseBranch] = useState("");
@@ -977,20 +968,7 @@ export function DraftAgentScreen({
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
]}
>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isSidebarOpen }}
>
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>

View File

@@ -85,7 +85,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
letterSpacing: 0.4,
textTransform: "uppercase",
marginBottom: theme.spacing[2],
},
input: {
@@ -180,8 +179,19 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
hostCardPressed: {
opacity: 0.85,
hostSettingsButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: "transparent",
backgroundColor: "transparent",
marginLeft: theme.spacing[2],
},
hostSettingsButtonActive: {
backgroundColor: theme.colors.surface3,
},
advancedTrigger: {
flexDirection: "row",
@@ -646,7 +656,7 @@ export default function SettingsScreen() {
connectionStatus={connectionStatus}
activeConnection={activeConnection}
lastError={lastConnectionError}
onPress={handleEditDaemon}
onOpenSettings={handleEditDaemon}
/>
);
})
@@ -885,8 +895,6 @@ function HostDetailModal({
}: HostDetailModalProps) {
const { theme } = useUnistyles();
const [draftLabel, setDraftLabel] = useState("");
const [isDraftLabelDirty, setIsDraftLabelDirty] = useState(false);
const activeServerIdRef = useRef<string | null>(null);
const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null);
const [isRemovingConnection, setIsRemovingConnection] = useState(false);
@@ -1033,28 +1041,18 @@ function HostDetailModal({
const handleDraftLabelChange = useCallback((nextValue: string) => {
setDraftLabel(nextValue);
setIsDraftLabelDirty(true);
}, []);
useEffect(() => {
if (!visible || !host) return;
const hostChanged = activeServerIdRef.current !== host.serverId;
if (hostChanged) {
setDraftLabel(host.label ?? "");
setIsDraftLabelDirty(false);
activeServerIdRef.current = host.serverId;
return;
}
if (!isDraftLabelDirty) {
setDraftLabel(host.label ?? "");
}
}, [visible, host?.serverId, host?.label, isDraftLabelDirty]);
// Initialize once per modal open / host switch; keep user edits fully local while typing.
setDraftLabel(host.label ?? "");
}, [visible, host?.serverId]);
useEffect(() => {
if (!visible) {
activeServerIdRef.current = null;
setIsRestarting(false);
setIsDraftLabelDirty(false);
setDraftLabel("");
}
}, [visible]);
@@ -1304,7 +1302,7 @@ interface DaemonCardProps {
connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null;
lastError: string | null;
onPress: (daemon: HostProfile) => void;
onOpenSettings: (daemon: HostProfile) => void;
}
function DaemonCard({
@@ -1312,7 +1310,7 @@ function DaemonCard({
connectionStatus,
activeConnection,
lastError,
onPress,
onOpenSettings,
}: DaemonCardProps) {
const { theme } = useUnistyles();
const statusLabel = formatConnectionStatus(connectionStatus);
@@ -1347,12 +1345,9 @@ function DaemonCard({
})();
return (
<Pressable
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
onPress={() => onPress(daemon)}
<View
style={styles.hostCard}
testID={`daemon-card-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`${daemon.label}, ${statusLabel}`}
>
<View style={styles.hostCardContent}>
<View style={styles.hostHeaderRow}>
@@ -1375,10 +1370,28 @@ function DaemonCard({
) : null}
</View>
) : null}
<Pressable
style={({ pressed, hovered }) => [
styles.hostSettingsButton,
(pressed || hovered) && styles.hostSettingsButtonActive,
]}
onPress={() => onOpenSettings(daemon)}
testID={`daemon-card-settings-${daemon.serverId}`}
accessibilityRole="button"
accessibilityLabel={`Open settings for ${daemon.label}`}
>
{({ pressed, hovered }) => (
<Settings
size={16}
color={pressed || hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
</View>
{connectionError ? <Text style={styles.hostError}>{connectionError}</Text> : null}
</View>
</Pressable>
</View>
);
}

View File

@@ -0,0 +1,34 @@
export type ExplorerTab = "changes" | "files" | "terminals";
export function isExplorerTab(value: unknown): value is ExplorerTab {
return value === "changes" || value === "files" || value === "terminals";
}
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
const trimmedServerId = serverId.trim();
const trimmedCwd = cwd.trim();
if (!trimmedServerId || !trimmedCwd) {
return null;
}
return `${trimmedServerId}::${trimmedCwd}`;
}
export function coerceExplorerTabForCheckout(tab: ExplorerTab, isGit: boolean): ExplorerTab {
if (!isGit && tab === "changes") {
return "files";
}
return tab;
}
export function resolveExplorerTabForCheckout(params: {
serverId: string;
cwd: string;
isGit: boolean;
explorerTabByCheckout: Record<string, ExplorerTab>;
}): ExplorerTab {
const key = buildExplorerCheckoutKey(params.serverId, params.cwd);
const stored = key ? params.explorerTabByCheckout[key] : null;
const defaultTab: ExplorerTab = params.isGit ? "changes" : "files";
const nextTab = stored && isExplorerTab(stored) ? stored : defaultTab;
return coerceExplorerTabForCheckout(nextTab, params.isGit);
}

View File

@@ -1,47 +0,0 @@
import { create } from "zustand";
type FocusChatInputRequest = {
id: number;
agentKey: string;
};
interface KeyboardNavState {
commandCenterOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
/** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void;
clearFocusChatInputRequest: () => void;
setCommandCenterOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
}
export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
commandCenterOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
focusChatInputRequest: null,
requestFocusChatInput: (agentKey) => {
const prev = get().focusChatInputRequest;
const id = (prev?.id ?? 0) + 1;
set({ focusChatInputRequest: { id, agentKey } });
},
clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }),
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
}));

View File

@@ -0,0 +1,62 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type MessageInputActionRequest = {
id: number;
agentKey: string;
kind: MessageInputKeyboardActionKind;
};
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
shortcutsDialogOpen: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[];
messageInputActionRequest: MessageInputActionRequest | null;
setCommandCenterOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutAgentKeys: (keys: string[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
agentKey: string;
kind: MessageInputKeyboardActionKind;
}) => void;
clearMessageInputActionRequest: (id: number) => void;
}
export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
(set, get) => ({
commandCenterOpen: false,
shortcutsDialogOpen: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [],
messageInputActionRequest: null,
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const previous = get().messageInputActionRequest;
const id = (previous?.id ?? 0) + 1;
set({ messageInputActionRequest: { id, agentKey, kind } });
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
if (!current || current.id !== id) {
return;
}
set({ messageInputActionRequest: null });
},
})
);

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
buildExplorerCheckoutKey,
resolveExplorerTabForCheckout,
} from "@/stores/explorer-tab-memory";
describe("panel-store explorer tab resolution", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
it("defaults to changes for git checkouts", () => {
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {},
})
).toBe("changes");
});
it("defaults to files for non-git checkouts", () => {
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: false,
explorerTabByCheckout: {},
})
).toBe("files");
});
it("restores a stored files tab for git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {
[key]: "files",
},
})
).toBe("files");
});
it("restores a stored terminals tab for git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: true,
explorerTabByCheckout: {
[key]: "terminals",
},
})
).toBe("terminals");
});
it("coerces stored changes to files for non-git checkouts", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
serverId,
cwd,
isGit: false,
explorerTabByCheckout: {
[key]: "changes",
},
})
).toBe("files");
});
});

View File

@@ -2,6 +2,14 @@ import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import {
buildExplorerCheckoutKey,
coerceExplorerTabForCheckout,
isExplorerTab,
resolveExplorerTabForCheckout,
type ExplorerTab,
} from "./explorer-tab-memory";
export type { ExplorerTab } from "./explorer-tab-memory";
/**
* Mobile panel state machine.
@@ -27,8 +35,12 @@ interface DesktopSidebarState {
fileExplorerOpen: boolean;
}
export type ExplorerTab = "changes" | "files" | "terminals";
export type SortOption = "name" | "modified" | "size";
export interface ExplorerCheckoutContext {
serverId: string;
cwd: string;
isGit: boolean;
}
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 640 : 400;
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
@@ -48,6 +60,8 @@ interface PanelState {
// File explorer settings (shared between mobile/desktop)
explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>;
activeExplorerCheckout: ExplorerCheckoutContext | null;
explorerWidth: number;
explorerSortOption: SortOption;
explorerFilesSplitRatio: number;
@@ -61,6 +75,9 @@ interface PanelState {
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void;
activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void;
setActiveExplorerCheckout: (checkout: ExplorerCheckoutContext | null) => void;
setExplorerWidth: (width: number) => void;
setExplorerSortOption: (option: SortOption) => void;
setExplorerFilesSplitRatio: (ratio: number) => void;
@@ -81,6 +98,18 @@ function clampExplorerFilesSplitRatio(ratio: number): number {
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
}
function resolveExplorerTabFromActiveCheckout(state: PanelState): ExplorerTab | null {
if (!state.activeExplorerCheckout) {
return null;
}
return resolveExplorerTabForCheckout({
serverId: state.activeExplorerCheckout.serverId,
cwd: state.activeExplorerCheckout.cwd,
isGit: state.activeExplorerCheckout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
});
}
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
export const usePanelStore = create<PanelState>()(
@@ -97,6 +126,8 @@ export const usePanelStore = create<PanelState>()(
// File explorer defaults
explorerTab: "changes",
explorerTabByCheckout: {},
activeExplorerCheckout: null,
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
explorerSortOption: "name",
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
@@ -108,10 +139,14 @@ export const usePanelStore = create<PanelState>()(
})),
openFileExplorer: () =>
set((state) => ({
mobileView: "file-explorer",
desktop: { ...state.desktop, fileExplorerOpen: true },
})),
set((state) => {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
return {
mobileView: "file-explorer",
desktop: { ...state.desktop, fileExplorerOpen: true },
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
};
}),
closeToAgent: () =>
set((state) => ({
@@ -142,17 +177,63 @@ export const usePanelStore = create<PanelState>()(
toggleFileExplorer: () =>
set((state) => {
// Mobile: toggle between agent and file-explorer
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
return {
mobileView: newMobileView,
const willOpenMobile = state.mobileView !== "file-explorer";
const willOpenDesktop = !state.desktop.fileExplorerOpen;
const nextState: Partial<PanelState> = {
mobileView: willOpenMobile ? "file-explorer" : "agent",
desktop: {
...state.desktop,
fileExplorerOpen: !state.desktop.fileExplorerOpen,
fileExplorerOpen: willOpenDesktop,
},
};
if (willOpenMobile || willOpenDesktop) {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
if (resolvedTab) {
nextState.explorerTab = resolvedTab;
}
}
return nextState;
}),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
set((state) => {
const resolvedTab = coerceExplorerTabForCheckout(tab, isGit);
const key = buildExplorerCheckoutKey(serverId, cwd);
const nextState: Partial<PanelState> = { explorerTab: resolvedTab };
if (key) {
const current = state.explorerTabByCheckout[key];
if (current !== resolvedTab) {
nextState.explorerTabByCheckout = {
...state.explorerTabByCheckout,
[key]: resolvedTab,
};
}
}
return nextState;
}),
activateExplorerTabForCheckout: (checkout) =>
set((state) => ({
activeExplorerCheckout: checkout,
explorerTab: resolveExplorerTabForCheckout({
serverId: checkout.serverId,
cwd: checkout.cwd,
isGit: checkout.isGit,
explorerTabByCheckout: state.explorerTabByCheckout,
}),
})),
setActiveExplorerCheckout: (checkout) =>
set((state) => {
const current = state.activeExplorerCheckout;
if (
current?.serverId === checkout?.serverId &&
current?.cwd === checkout?.cwd &&
current?.isGit === checkout?.isGit
) {
return state;
}
return { activeExplorerCheckout: checkout };
}),
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
setExplorerFilesSplitRatio: (ratio) =>
@@ -164,7 +245,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 3,
version: 4,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -197,12 +278,29 @@ export const usePanelStore = create<PanelState>()(
}
}
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
state.explorerTabByCheckout = {};
} else {
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) {
if (!isExplorerTab(value)) {
continue;
}
next[key] = value;
}
state.explorerTabByCheckout = next;
}
state.activeExplorerCheckout = null;
return state as PanelState;
},
partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
@@ -234,10 +332,14 @@ export function usePanelState(isMobile: boolean) {
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
@@ -262,10 +364,14 @@ export function usePanelState(isMobile: boolean) {
toggleFileExplorer: store.toggleFileExplorer,
// Explorer settings
explorerTab: store.explorerTab,
explorerTabByCheckout: store.explorerTabByCheckout,
explorerWidth: store.explorerWidth,
explorerSortOption: store.explorerSortOption,
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
setExplorerTab: store.setExplorerTab,
setExplorerTabForCheckout: store.setExplorerTabForCheckout,
activateExplorerTabForCheckout: store.activateExplorerTabForCheckout,
setActiveExplorerCheckout: store.setActiveExplorerCheckout,
setExplorerWidth: store.setExplorerWidth,
setExplorerSortOption: store.setExplorerSortOption,
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,

View File

@@ -0,0 +1,77 @@
type TerminalDebugGlobal = {
__PASEO_TERMINAL_DEBUG?: boolean;
};
type TerminalDebugLogInput = {
scope: string;
event: string;
details?: Record<string, unknown>;
};
function resolveGlobalDebugFlag(): boolean | null {
if (typeof globalThis === "undefined") {
return null;
}
const value = (globalThis as TerminalDebugGlobal).__PASEO_TERMINAL_DEBUG;
if (typeof value === "boolean") {
return value;
}
return null;
}
export function isTerminalDebugEnabled(): boolean {
const globalFlag = resolveGlobalDebugFlag();
if (globalFlag !== null) {
return globalFlag;
}
return process.env.NODE_ENV === "development";
}
export function terminalDebugLog(input: TerminalDebugLogInput): void {
if (!isTerminalDebugEnabled()) {
return;
}
const payload = input.details
? { ...input.details, ts: Date.now() }
: { ts: Date.now() };
console.log(`[terminal][${input.scope}] ${input.event}`, payload);
}
function escapeControlBytes(input: { text: string }): string {
let output = "";
for (const char of input.text) {
const code = char.charCodeAt(0);
if (code === 10) {
output += "\\n";
continue;
}
if (code === 13) {
output += "\\r";
continue;
}
if (code === 9) {
output += "\\t";
continue;
}
if (code < 32 || code === 127) {
output += `\\x${code.toString(16).padStart(2, "0")}`;
continue;
}
output += char;
}
return output;
}
export function summarizeTerminalText(input: {
text: string;
maxChars?: number;
}): string {
const maxChars = input.maxChars ?? 80;
const escaped = escapeControlBytes({ text: input.text });
if (escaped.length <= maxChars) {
return escaped;
}
return `${escaped.slice(0, maxChars)}`;
}

View File

@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
type StubTerminal = {
write: (text: string, callback?: () => void) => void;
reset: () => void;
focus: () => void;
};
function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & {
resetCalls: number;
};
writeCallbacks: Array<() => void>;
writeTexts: string[];
} {
const runtime = new TerminalEmulatorRuntime();
const writeCallbacks: Array<() => void> = [];
const writeTexts: string[] = [];
let resetCalls = 0;
const terminal: StubTerminal & { resetCalls: number } = {
write: (text: string, callback?: () => void) => {
writeTexts.push(text);
if (callback) {
writeCallbacks.push(callback);
}
},
reset: () => {
resetCalls += 1;
terminal.resetCalls = resetCalls;
},
focus: () => {},
resetCalls,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
return {
runtime,
terminal,
writeCallbacks,
writeTexts,
};
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
(globalThis as { window?: { __paseoTerminal?: unknown } }).window = {
__paseoTerminal: undefined,
};
});
afterEach(() => {
(globalThis as { window?: unknown }).window = originalWindow;
vi.useRealTimers();
});
it("processes write and clear operations in strict order", () => {
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
const committed: string[] = [];
runtime.write({
text: "first",
onCommitted: () => {
committed.push("first");
},
});
runtime.clear({
onCommitted: () => {
committed.push("clear");
},
});
runtime.write({
text: "second",
onCommitted: () => {
committed.push("second");
},
});
expect(writeTexts).toEqual(["first"]);
expect(terminal.resetCalls).toBe(0);
expect(committed).toEqual([]);
writeCallbacks[0]?.();
expect(committed).toEqual(["first", "clear"]);
expect(terminal.resetCalls).toBe(1);
expect(writeTexts).toEqual(["first", "second"]);
writeCallbacks[1]?.();
expect(committed).toEqual(["first", "clear", "second"]);
});
it("falls back to timeout commit when xterm write callback does not fire", () => {
vi.useFakeTimers();
const { runtime } = createRuntimeWithTerminal();
const onCommitted = vi.fn();
runtime.write({
text: "stuck",
onCommitted,
});
expect(onCommitted).not.toHaveBeenCalled();
vi.advanceTimersByTime(5_000);
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it("ignores stale duplicate write callbacks from a previous operation", () => {
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
const committed: string[] = [];
runtime.write({
text: "first",
onCommitted: () => {
committed.push("first");
},
});
runtime.write({
text: "second",
onCommitted: () => {
committed.push("second");
},
});
writeCallbacks[0]?.();
expect(committed).toEqual(["first"]);
writeCallbacks[0]?.();
expect(committed).toEqual(["first"]);
writeCallbacks[1]?.();
expect(committed).toEqual(["first", "second"]);
});
it("commits pending output operations during unmount to avoid deadlock", () => {
const { runtime } = createRuntimeWithTerminal();
const onCommittedA = vi.fn();
const onCommittedB = vi.fn();
runtime.write({
text: "a",
onCommitted: onCommittedA,
});
runtime.write({
text: "b",
onCommitted: onCommittedB,
});
runtime.unmount();
expect(onCommittedA).toHaveBeenCalledTimes(1);
expect(onCommittedB).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,695 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalEmulatorRuntimeTheme = {
backgroundColor: string;
foregroundColor: string;
cursorColor: string;
};
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialOutputText: string;
theme: TerminalEmulatorRuntimeTheme;
};
export type TerminalEmulatorRuntimeCallbacks = {
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
};
type TerminalEmulatorRuntimeDisposables = {
disposeInput: () => void;
disconnectResizeObserver: () => void;
removeWindowResize: () => void;
removeVisualViewportResize: () => void;
clearFitInterval: () => void;
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeTouchListeners: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
disposeTerminal: () => void;
};
type TerminalOutputOperation = {
type: "write" | "clear";
text: string;
onCommitted?: () => void;
};
declare global {
interface Window {
__paseoTerminal?: Terminal;
}
}
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
export class TerminalEmulatorRuntime {
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
ctrl: false,
shift: false,
alt: false,
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void {
this.callbacks = input.callbacks;
}
setPendingModifiers(input: { pendingModifiers: PendingTerminalModifiers }): void {
this.pendingModifiers = input.pendingModifiers;
}
mount(input: TerminalEmulatorRuntimeMountInput): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:start",
details: {
initialOutputLength: input.initialOutputText.length,
},
});
this.unmount();
input.host.innerHTML = "";
this.lastSize = null;
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: input.theme.backgroundColor,
foreground: input.theme.foregroundColor,
cursor: input.theme.cursorColor,
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(input.host);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
});
const restoreViewportStyles = this.applyViewportTouchStyles({
host: input.host,
});
this.terminal = terminal;
this.fitAddon = fitAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => {
const currentTerminal = this.terminal;
const currentFitAddon = this.fitAddon;
if (!currentTerminal || !currentFitAddon) {
return;
}
try {
currentFitAddon.fit();
} catch {
return;
}
const nextRows = currentTerminal.rows;
const nextCols = currentTerminal.cols;
const previous = this.lastSize;
if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) {
return;
}
this.lastSize = { rows: nextRows, cols: nextCols };
terminalDebugLog({
scope: "emulator-runtime",
event: "resize:emit",
details: {
rows: nextRows,
cols: nextCols,
force,
},
});
this.callbacks.onResize?.({
rows: nextRows,
cols: nextCols,
});
};
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
terminalDebugLog({
scope: "emulator-runtime",
event: "input:onData",
details: {
length: data.length,
preview: summarizeTerminalText({ text: data, maxChars: 64 }),
},
});
this.callbacks.onInput?.(data);
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.isComposing) {
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
}
if (
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
pendingModifiers: this.pendingModifiers,
})
) {
return true;
}
const modifiers = mergeTerminalModifiers({
pendingModifiers: this.pendingModifiers,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
});
this.callbacks.onTerminalKey?.({
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
terminalDebugLog({
scope: "emulator-runtime",
event: "key:intercepted",
details: {
key: normalizedKey,
ctrl: modifiers.ctrl,
shift: modifiers.shift,
alt: modifiers.alt,
meta: modifiers.meta,
},
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
}
event.preventDefault();
event.stopPropagation();
return false;
});
const removeTouchListeners = this.setupTouchScrollHandlers({
root: input.root,
host: input.host,
terminal,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize(false);
});
resizeObserver.observe(input.root);
resizeObserver.observe(input.host);
const windowResizeHandler = () => fitAndEmitResize(false);
window.addEventListener("resize", windowResizeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize(false);
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
const fitInterval = window.setInterval(() => {
fitAndEmitResize(false);
}, 250);
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
window.setTimeout(() => {
fitAndEmitResize(true);
}, delayMs)
);
const fontSet = document.fonts;
const fontReadyHandler = () => {
fitAndEmitResize(true);
};
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
void fontSet?.ready
.then(() => {
fitAndEmitResize(true);
})
.catch(() => {
// no-op
});
window.setTimeout(() => {
fitAndEmitResize(true);
}, 0);
if (input.initialOutputText.length > 0) {
terminal.write(input.initialOutputText);
terminalDebugLog({
scope: "emulator-runtime",
event: "output:initial-write",
details: {
length: input.initialOutputText.length,
preview: summarizeTerminalText({
text: input.initialOutputText,
maxChars: 96,
}),
},
});
}
this.processOutputQueue();
const disposables: TerminalEmulatorRuntimeDisposables = {
disposeInput: () => {
inputDisposable.dispose();
},
disconnectResizeObserver: () => {
resizeObserver.disconnect();
},
removeWindowResize: () => {
window.removeEventListener("resize", windowResizeHandler);
},
removeVisualViewportResize: () => {
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
},
clearFitInterval: () => {
window.clearInterval(fitInterval);
},
clearFitTimeouts: () => {
for (const handle of fitTimeouts) {
window.clearTimeout(handle);
}
},
removeFontListeners: () => {
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
},
removeTouchListeners,
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
fitAddon.dispose();
},
disposeTerminal: () => {
terminal.dispose();
},
};
this.cleanup = () => {
disposables.disposeInput();
disposables.disconnectResizeObserver();
disposables.removeWindowResize();
disposables.removeVisualViewportResize();
disposables.clearFitInterval();
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.disposeFitAddon();
disposables.disposeTerminal();
disposables.restoreDocumentStyles();
disposables.restoreViewportStyles();
};
}
write(input: { text: string; onCommitted?: () => void }): void {
if (input.text.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
text: input.text,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:enqueue",
details: {
chunkLength: input.text.length,
queueLength: this.outputOperations.length,
preview: summarizeTerminalText({ text: input.text, maxChars: 64 }),
},
});
this.processOutputQueue();
}
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
text: "",
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
terminalDebugLog({
scope: "emulator-runtime",
event: "output:clear",
});
this.processOutputQueue();
}
focus(): void {
this.terminal?.focus();
}
unmount(): void {
terminalDebugLog({
scope: "emulator-runtime",
event: "mount:unmount",
details: {
pendingWriteOperations: this.outputOperations.length,
},
});
this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation;
this.inFlightOutputOperation = null;
if (inFlightOperation?.onCommitted) {
inFlightOperation.onCommitted();
}
const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length);
for (const operation of pendingOperations) {
operation.onCommitted?.();
}
this.cleanup?.();
this.cleanup = null;
if (window.__paseoTerminal === this.terminal) {
window.__paseoTerminal = undefined;
}
this.terminal = null;
this.fitAddon = null;
this.lastSize = null;
}
private processOutputQueue(): void {
if (this.inFlightOutputOperation) {
return;
}
const terminal = this.terminal;
if (!terminal) {
return;
}
const operation = this.outputOperations.shift();
if (!operation) {
return;
}
this.inFlightOutputOperation = operation;
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
if (this.inFlightOutputOperation !== expectedOperation) {
return;
}
this.inFlightOutputOperation = null;
this.clearInFlightOutputTimeout();
expectedOperation.onCommitted?.();
this.processOutputQueue();
};
if (operation.type === "clear") {
terminal.reset();
finalizeOperation(operation);
return;
}
const text = operation.text;
terminalDebugLog({
scope: "emulator-runtime",
event: "output:flush",
details: {
length: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(text, () => {
finalizeOperation(operation);
});
} catch {
finalizeOperation(operation);
}
}
private clearInFlightOutputTimeout(): void {
if (!this.inFlightOutputOperationTimeout) {
return;
}
clearTimeout(this.inFlightOutputOperationTimeout);
this.inFlightOutputOperationTimeout = null;
}
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
const documentElement = document.documentElement;
const body = document.body;
const rootContainer = input.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 ?? "";
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%";
}
return () => {
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;
}
};
}
private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void {
const viewportElement = input.host.querySelector<HTMLElement>(".xterm-viewport");
const screenElement = input.host.querySelector<HTMLElement>(".xterm-screen");
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") ?? "";
const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
if (viewportElement) {
viewportElement.style.overscrollBehavior = "none";
viewportElement.style.touchAction = "pan-y";
viewportElement.style.overflowY = "auto";
viewportElement.style.overflowX = "hidden";
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
if (screenElement) {
screenElement.style.pointerEvents = "none";
}
return () => {
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
);
}
if (screenElement) {
screenElement.style.pointerEvents = previousScreenPointerEvents;
}
};
}
private setupTouchScrollHandlers(input: {
root: HTMLDivElement;
host: HTMLDivElement;
terminal: Terminal;
}): () => void {
let touchScrollRemainderPx = 0;
const measuredLineHeight =
input.host.querySelector<HTMLElement>(".xterm-rows > div")?.getBoundingClientRect()
.height ?? 0;
const touchScrollLineHeightPx =
measuredLineHeight > 0
? measuredLineHeight
: DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX;
const activeTouch = {
identifier: -1,
startX: 0,
startY: 0,
lastX: 0,
lastY: 0,
mode: null as "vertical" | "horizontal" | null,
};
const touchStartHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
const touch = event.touches[0];
if (!touch) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
return;
}
activeTouch.identifier = touch.identifier;
activeTouch.startX = touch.clientX;
activeTouch.startY = touch.clientY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
activeTouch.mode = null;
touchScrollRemainderPx = 0;
};
const touchMoveHandler = (event: TouchEvent) => {
if (event.touches.length !== 1) {
return;
}
const touch = Array.from(event.touches).find(
(candidate) => candidate.identifier === activeTouch.identifier
);
if (!touch) {
return;
}
const totalDeltaX = touch.clientX - activeTouch.startX;
const totalDeltaY = touch.clientY - activeTouch.startY;
if (activeTouch.mode === null) {
const absX = Math.abs(totalDeltaX);
const absY = Math.abs(totalDeltaY);
if (absX > 8 || absY > 8) {
activeTouch.mode = absY >= absX ? "vertical" : "horizontal";
}
}
const deltaY = touch.clientY - activeTouch.lastY;
activeTouch.lastX = touch.clientX;
activeTouch.lastY = touch.clientY;
if (activeTouch.mode !== "vertical") {
return;
}
touchScrollRemainderPx += deltaY;
const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx);
if (lineDelta !== 0) {
input.terminal.scrollLines(-lineDelta);
touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx;
}
event.preventDefault();
};
const touchEndHandler = (event: TouchEvent) => {
const activeTouchEnded = Array.from(event.changedTouches).some(
(touch) => touch.identifier === activeTouch.identifier
);
if (activeTouchEnded || event.touches.length === 0) {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
}
};
const touchCancelHandler = () => {
touchScrollRemainderPx = 0;
activeTouch.identifier = -1;
activeTouch.mode = null;
};
input.root.addEventListener("touchstart", touchStartHandler, { passive: true });
input.root.addEventListener("touchmove", touchMoveHandler, { passive: false });
input.root.addEventListener("touchend", touchEndHandler, { passive: true });
input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true });
return () => {
input.root.removeEventListener("touchstart", touchStartHandler);
input.root.removeEventListener("touchmove", touchMoveHandler);
input.root.removeEventListener("touchend", touchEndHandler);
input.root.removeEventListener("touchcancel", touchCancelHandler);
};
}
}

View File

@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from "vitest";
import { TerminalOutputDeliveryQueue } from "./terminal-output-delivery-queue";
describe("terminal-output-delivery-queue", () => {
it("retries in-flight delivery when consume is missing", () => {
vi.useFakeTimers();
const delivered: Array<{ sequence: number; text: string }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
deliveryTimeoutMs: 100,
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
vi.advanceTimersByTime(100);
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 1, text: "a" },
{ sequence: 2, text: "b" },
]);
vi.useRealTimers();
});
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "c" });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "bc" },
]);
});
it("ignores stale consume acknowledgements", () => {
const delivered = vi.fn();
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
queue.enqueue({ sequence: 1, text: "x" });
queue.consume({ sequence: 99 });
queue.enqueue({ sequence: 2, text: "y" });
expect(delivered).toHaveBeenCalledTimes(1);
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x" });
queue.consume({ sequence: 1 });
expect(delivered).toHaveBeenCalledTimes(2);
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y" });
});
it("resets in-flight and pending chunks", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "hello" });
queue.enqueue({ sequence: 2, text: " world" });
queue.reset();
queue.enqueue({ sequence: 3, text: "next" });
expect(delivered).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 3, text: "next" },
]);
});
it("preserves empty chunk payloads for authoritative clears", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "abc" });
queue.consume({ sequence: 1 });
queue.enqueue({ sequence: 2, text: "" });
expect(delivered).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
]);
});
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
const delivered: Array<{ sequence: number; text: string }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a" });
queue.enqueue({ sequence: 2, text: "b" });
queue.enqueue({ sequence: 3, text: "" });
queue.enqueue({ sequence: 4, text: "c" });
expect(delivered).toEqual([{ sequence: 1, text: "a" }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
]);
queue.consume({ sequence: 3 });
expect(delivered).toEqual([
{ sequence: 1, text: "a" },
{ sequence: 3, text: "" },
{ sequence: 4, text: "c" },
]);
});
});

View File

@@ -0,0 +1,151 @@
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
};
export type TerminalOutputDeliveryQueueOptions = {
onDeliver: (chunk: TerminalOutputDeliveryChunk) => void;
deliveryTimeoutMs?: number;
};
const DEFAULT_DELIVERY_TIMEOUT_MS = 8_000;
export class TerminalOutputDeliveryQueue {
private readonly pendingChunks: TerminalOutputDeliveryChunk[] = [];
private inFlightChunk: TerminalOutputDeliveryChunk | null = null;
private inFlightTimeout: ReturnType<typeof setTimeout> | null = null;
private lastSeenSequence = 0;
private readonly deliveryTimeoutMs: number;
constructor(private readonly options: TerminalOutputDeliveryQueueOptions) {
this.deliveryTimeoutMs =
options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
}
enqueue(chunk: TerminalOutputDeliveryChunk): void {
if (chunk.sequence <= 0) {
return;
}
if (chunk.sequence <= this.lastSeenSequence) {
return;
}
this.lastSeenSequence = chunk.sequence;
if (chunk.text.length === 0) {
this.pendingChunks.length = 0;
this.pendingChunks.push(chunk);
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:clear",
details: {
sequence: chunk.sequence,
},
});
this.tryDeliver();
return;
}
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
if (lastPendingChunk && lastPendingChunk.text.length > 0) {
lastPendingChunk.sequence = chunk.sequence;
lastPendingChunk.text += chunk.text;
} else {
this.pendingChunks.push(chunk);
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "enqueue:text",
details: {
sequence: chunk.sequence,
pendingCount: this.pendingChunks.length,
textLength: chunk.text.length,
preview: summarizeTerminalText({ text: chunk.text, maxChars: 80 }),
},
});
this.tryDeliver();
}
consume(input: { sequence: number }): void {
if (this.inFlightChunk?.sequence !== input.sequence) {
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "consume",
details: {
sequence: input.sequence,
},
});
this.clearInFlightTimeout();
this.inFlightChunk = null;
this.tryDeliver();
}
reset(): void {
this.clearInFlightTimeout();
this.pendingChunks.length = 0;
this.inFlightChunk = null;
this.lastSeenSequence = 0;
}
private tryDeliver(): void {
if (this.inFlightChunk) {
return;
}
const nextChunk = this.pendingChunks.shift();
if (!nextChunk) {
return;
}
this.inFlightChunk = nextChunk;
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:start",
details: {
sequence: nextChunk.sequence,
pendingCount: this.pendingChunks.length,
},
});
this.deliverInFlightChunk();
}
private deliverInFlightChunk(): void {
const chunk = this.inFlightChunk;
if (!chunk) {
return;
}
this.clearInFlightTimeout();
this.inFlightTimeout = setTimeout(() => {
if (!this.inFlightChunk) {
return;
}
terminalDebugLog({
scope: "output-delivery-queue",
event: "deliver:timeout-retry",
details: {
sequence: this.inFlightChunk.sequence,
pendingCount: this.pendingChunks.length,
timeoutMs: this.deliveryTimeoutMs,
},
});
this.deliverInFlightChunk();
}, this.deliveryTimeoutMs);
this.options.onDeliver({
sequence: chunk.sequence,
text: chunk.text,
});
}
private clearInFlightTimeout(): void {
if (!this.inFlightTimeout) {
return;
}
clearTimeout(this.inFlightTimeout);
this.inFlightTimeout = null;
}
}

View File

@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import { TerminalOutputPump } from "./terminal-output-pump";
describe("terminal-output-pump", () => {
it("batches selected-terminal chunk bursts into ordered flushes", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "a" });
pump.append({ terminalId: "term-1", text: "b" });
pump.append({ terminalId: "term-1", text: "c" });
expect(chunks).toEqual([]);
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
]);
vi.useRealTimers();
});
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "hello" });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
pump.append({ terminalId: "term-2", text: "world" });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
pump.setSelectedTerminal({ terminalId: "term-2" });
pump.append({ terminalId: "term-2", text: "!" });
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "hello" },
{ sequence: 2, text: "!" },
]);
vi.useRealTimers();
});
it("resets selected output when clearing selected terminal", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "abc" });
vi.runOnlyPendingTimers();
pump.clearTerminal({ terminalId: "term-1" });
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
expect(chunks).toEqual([
{ sequence: 1, text: "abc" },
{ sequence: 2, text: "" },
]);
vi.useRealTimers();
});
it("prunes orphaned terminal buffers", () => {
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: () => {},
});
pump.append({ terminalId: "a", text: "one" });
pump.append({ terminalId: "b", text: "two" });
pump.prune({ terminalIds: ["b"] });
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
expect(pump.readSnapshot({ terminalId: "b" })).toBe("two");
});
});

View File

@@ -0,0 +1,183 @@
import {
appendTerminalOutputBuffer,
createTerminalOutputBuffer,
readTerminalOutputBuffer,
type TerminalOutputBuffer,
} from "@/utils/terminal-output-buffer";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalOutputChunk = {
sequence: number;
text: string;
};
export type TerminalOutputPumpOptions = {
maxOutputChars: number;
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void;
};
export type TerminalOutputPumpSetSelectedInput = {
terminalId: string | null;
};
export type TerminalOutputPumpAppendInput = {
terminalId: string;
text: string;
};
export type TerminalOutputPumpReadInput = {
terminalId: string | null;
};
export type TerminalOutputPumpClearInput = {
terminalId: string;
};
export type TerminalOutputPumpPruneInput = {
terminalIds: string[];
};
export class TerminalOutputPump {
private readonly buffersByTerminalId = new Map<string, TerminalOutputBuffer>();
private selectedTerminalId: string | null = null;
private selectedChunkSequence = 0;
private selectedChunkAccumulator = "";
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: TerminalOutputPumpOptions) {}
setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void {
if (this.selectedTerminalId === input.terminalId) {
return;
}
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedTerminalId = input.terminalId;
}
append(input: TerminalOutputPumpAppendInput): void {
if (input.text.length === 0) {
return;
}
let buffer = this.buffersByTerminalId.get(input.terminalId);
if (!buffer) {
buffer = createTerminalOutputBuffer();
this.buffersByTerminalId.set(input.terminalId, buffer);
}
appendTerminalOutputBuffer({
buffer,
text: input.text,
maxChars: this.options.maxOutputChars,
});
if (this.selectedTerminalId !== input.terminalId) {
return;
}
this.selectedChunkAccumulator += input.text;
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:accumulate",
details: {
terminalId: input.terminalId,
appendedLength: input.text.length,
accumulatorLength: this.selectedChunkAccumulator.length,
preview: summarizeTerminalText({ text: input.text, maxChars: 80 }),
},
});
this.scheduleSelectedChunkFlush();
}
clearTerminal(input: TerminalOutputPumpClearInput): void {
this.buffersByTerminalId.delete(input.terminalId);
if (this.selectedTerminalId === input.terminalId) {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.emitSelectedChunk({ text: "" });
}
}
prune(input: TerminalOutputPumpPruneInput): void {
const terminalIdSet = new Set(input.terminalIds);
for (const terminalId of Array.from(this.buffersByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
this.buffersByTerminalId.delete(terminalId);
}
}
}
readSnapshot(input: TerminalOutputPumpReadInput): string {
if (!input.terminalId) {
return "";
}
const buffer = this.buffersByTerminalId.get(input.terminalId);
if (!buffer) {
return "";
}
return readTerminalOutputBuffer({ buffer });
}
dispose(): void {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedTerminalId = null;
this.buffersByTerminalId.clear();
}
private scheduleSelectedChunkFlush(): void {
if (this.selectedChunkFlushTimer) {
return;
}
this.selectedChunkFlushTimer = setTimeout(() => {
this.selectedChunkFlushTimer = null;
this.flushSelectedChunkAccumulator();
}, 0);
}
private flushSelectedChunkAccumulator(): void {
if (this.selectedChunkAccumulator.length === 0) {
return;
}
const text = this.selectedChunkAccumulator;
this.selectedChunkAccumulator = "";
terminalDebugLog({
scope: "output-pump",
event: "selected-terminal:flush",
details: {
terminalId: this.selectedTerminalId,
textLength: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.emitSelectedChunk({ text });
}
private emitSelectedChunk(input: { text: string }): void {
this.selectedChunkSequence += 1;
this.options.onSelectedOutputChunk({
sequence: this.selectedChunkSequence,
text: input.text,
});
}
private clearSelectedChunkFlushTimer(): void {
if (!this.selectedChunkFlushTimer) {
return;
}
clearTimeout(this.selectedChunkFlushTimer);
this.selectedChunkFlushTimer = null;
}
}

View File

@@ -0,0 +1,485 @@
import { describe, expect, it } from "vitest";
import {
TerminalStreamController,
type TerminalStreamControllerAttachPayload,
type TerminalStreamControllerChunk,
type TerminalStreamControllerClient,
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
public attachCalls: Array<{
terminalId: string;
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
};
}> = [];
public detachCalls: number[] = [];
public nextAttachResponses: TerminalStreamControllerAttachPayload[] = [];
async attachTerminalStream(
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
}
): Promise<TerminalStreamControllerAttachPayload> {
this.attachCalls.push({ terminalId, options });
const response = this.nextAttachResponses.shift();
if (!response) {
throw new Error("Missing fake attach response");
}
return response;
}
async detachTerminalStream(streamId: number): Promise<void> {
this.detachCalls.push(streamId);
}
onTerminalStreamData(
streamId: number,
handler: (chunk: TerminalStreamControllerChunk) => void
): () => void {
const pendingChunks = this.pendingChunksByStreamId.get(streamId);
if (pendingChunks && pendingChunks.length > 0) {
for (const chunk of pendingChunks) {
handler(chunk);
}
this.pendingChunksByStreamId.delete(streamId);
}
const subscribers = this.streamSubscribers.get(streamId) ?? new Set();
subscribers.add(handler);
this.streamSubscribers.set(streamId, subscribers);
return () => {
const current = this.streamSubscribers.get(streamId);
current?.delete(handler);
if (current && current.size === 0) {
this.streamSubscribers.delete(streamId);
}
};
}
emitChunk(input: {
streamId: number;
offset?: number;
endOffset: number;
replay?: boolean;
data: string;
}): void {
const subscribers = this.streamSubscribers.get(input.streamId);
if (!subscribers || subscribers.size === 0) {
return;
}
const bytes = new TextEncoder().encode(input.data);
const chunk: TerminalStreamControllerChunk = {
offset: input.offset ?? input.endOffset - bytes.byteLength,
endOffset: input.endOffset,
replay: input.replay,
data: bytes,
};
for (const subscriber of subscribers) {
subscriber(chunk);
}
}
bufferChunk(input: {
streamId: number;
offset?: number;
endOffset: number;
replay?: boolean;
data: string;
}): void {
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
const bytes = new TextEncoder().encode(input.data);
chunks.push({
offset: input.offset ?? input.endOffset - bytes.byteLength,
endOffset: input.endOffset,
replay: input.replay,
data: bytes,
});
this.pendingChunksByStreamId.set(input.streamId, chunks);
}
}
function createControllerHarness(input?: {
client?: FakeTerminalStreamClient;
}): {
client: FakeTerminalStreamClient;
chunks: Array<{ terminalId: string; text: string }>;
statuses: TerminalStreamControllerStatus[];
resets: string[];
controller: TerminalStreamController;
} {
const client = input?.client ?? new FakeTerminalStreamClient();
const chunks: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const resets: string[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onChunk: (chunk) => {
chunks.push(chunk);
},
onStatusChange: (status) => {
statuses.push(status);
},
onReset: ({ terminalId }) => {
resets.push(terminalId);
},
waitForDelay: async () => {},
});
return {
client,
chunks,
statuses,
resets,
controller,
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
await Promise.resolve();
}
describe("terminal-stream-controller", () => {
it("streams burst chunks in order without dropping intermediate chunks", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 7,
currentOffset: 0,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 7,
endOffset: 1,
data: "a",
});
harness.client.emitChunk({
streamId: 7,
endOffset: 2,
data: "b",
});
harness.client.emitChunk({
streamId: 7,
endOffset: 3,
data: "c",
});
expect(harness.chunks).toEqual([
{ terminalId: "term-1", text: "a" },
{ terminalId: "term-1", text: "b" },
{ terminalId: "term-1", text: "c" },
]);
});
it("retries retryable attach failures and then attaches", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: null,
currentOffset: 0,
reset: false,
error: "network disconnected",
});
harness.client.nextAttachResponses.push({
streamId: 9,
currentOffset: 5,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(9);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
streamId: 9,
isAttaching: false,
error: null,
});
});
it("handles stream exit by reconnecting on the same terminal", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 3,
currentOffset: 0,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 4,
currentOffset: 2,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 3,
endOffset: 2,
data: "hi",
});
harness.controller.handleStreamExit({
terminalId: "term-1",
streamId: 3,
});
await flushAsyncWork();
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(4);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
streamId: 4,
isAttaching: false,
error: null,
});
});
it("emits reset callback when attach indicates output reset", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 12,
currentOffset: 0,
reset: true,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-reset" });
await flushAsyncWork();
expect(harness.resets).toEqual(["term-reset"]);
expect(harness.controller.getActiveStreamId()).toBe(12);
});
it("delivers buffered replay chunks flushed synchronously during subscribe", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 17,
replayedFrom: 0,
currentOffset: 10,
reset: false,
error: null,
});
harness.client.bufferChunk({
streamId: 17,
offset: 0,
endOffset: 10,
replay: true,
data: "buffered-replay",
});
harness.controller.setTerminal({ terminalId: "term-buffered" });
await flushAsyncWork();
expect(harness.chunks).toEqual([
{ terminalId: "term-buffered", text: "buffered-replay" },
]);
});
it("clears stale selected output when bootstrap replay starts before current offset", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 61,
replayedFrom: 0,
currentOffset: 20,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-bootstrap-reset" });
await flushAsyncWork();
expect(harness.resets).toEqual(["term-bootstrap-reset"]);
});
it("reattaches and replays from last contiguous offset when chunk offsets gap", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 31,
currentOffset: 0,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 32,
currentOffset: 8,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-gap" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 31,
offset: 0,
endOffset: 2,
data: "ok",
});
harness.client.emitChunk({
streamId: 31,
offset: 4,
endOffset: 8,
data: "miss",
});
await flushAsyncWork();
expect(harness.chunks).toEqual([{ terminalId: "term-gap", text: "ok" }]);
expect(harness.client.detachCalls).toContain(31);
expect(harness.client.attachCalls.length).toBe(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(32);
});
it("does not treat replay range before currentOffset as a live gap", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 41,
replayedFrom: 50,
currentOffset: 100,
reset: false,
error: null,
});
harness.client.bufferChunk({
streamId: 41,
offset: 80,
endOffset: 100,
data: "replay-tail",
});
harness.controller.setTerminal({ terminalId: "term-replay" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 41,
offset: 100,
endOffset: 102,
data: "ok",
});
expect(harness.client.detachCalls).toEqual([]);
expect(harness.client.attachCalls).toHaveLength(1);
expect(harness.controller.getActiveStreamId()).toBe(41);
expect(harness.chunks).toEqual([
{ terminalId: "term-replay", text: "replay-tail" },
{ terminalId: "term-replay", text: "ok" },
]);
});
it("accepts clamped replay start after reconnect without entering a reconnect loop", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 71,
replayedFrom: 205,
currentOffset: 205,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 72,
replayedFrom: 396,
currentOffset: 978,
reset: true,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-clamped-replay" });
await flushAsyncWork();
harness.client.emitChunk({
streamId: 71,
offset: 205,
endOffset: 299,
data: "first",
});
harness.client.emitChunk({
streamId: 71,
offset: 396,
endOffset: 493,
data: "gap",
});
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 299,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(72);
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 396,
endOffset: 493,
data: "replay-1",
});
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 493,
endOffset: 590,
data: "replay-2",
});
harness.client.emitChunk({
streamId: 72,
replay: true,
offset: 687,
endOffset: 784,
data: "replay-3",
});
harness.client.emitChunk({
streamId: 72,
replay: false,
offset: 978,
endOffset: 1075,
data: "live",
});
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.detachCalls.filter((streamId) => streamId === 71)).toHaveLength(1);
expect(harness.chunks.at(-1)).toEqual({
terminalId: "term-clamped-replay",
text: "live",
});
});
});

View File

@@ -0,0 +1,618 @@
import {
getTerminalAttachRetryDelayMs,
getTerminalResumeOffset,
isTerminalAttachRetryableError,
updateTerminalResumeOffset,
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug";
export type TerminalStreamControllerAttachPayload = {
streamId: number | null;
replayedFrom?: number;
currentOffset: number;
reset: boolean;
error?: string | null;
};
export type TerminalStreamControllerChunk = {
offset: number;
endOffset: number;
replay?: boolean;
data: Uint8Array;
};
export type TerminalStreamControllerClient = {
attachTerminalStream: (
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
}
) => Promise<TerminalStreamControllerAttachPayload>;
detachTerminalStream: (streamId: number) => Promise<unknown>;
onTerminalStreamData: (
streamId: number,
handler: (chunk: TerminalStreamControllerChunk) => void
) => () => void;
};
export type TerminalStreamControllerSize = {
rows: number;
cols: number;
};
export type TerminalStreamControllerStatus = {
terminalId: string | null;
streamId: number | null;
isAttaching: boolean;
error: string | null;
};
export type TerminalStreamControllerOptions = {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onChunk: (input: { terminalId: string; text: string }) => void;
onReset?: (input: { terminalId: string }) => void;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
maxAttachAttempts?: number;
attachTimeoutMs?: number;
reconnectErrorMessage?: string;
withTimeout?: <T>(input: {
promise: Promise<T>;
timeoutMs: number;
timeoutMessage: string;
}) => Promise<T>;
waitForDelay?: (input: { durationMs: number }) => Promise<void>;
isRetryableError?: (input: { message: string }) => boolean;
getRetryDelayMs?: (input: { attempt: number }) => number;
};
type TerminalStreamControllerActiveStream = {
terminalId: string;
streamId: number;
decoder: TextDecoder;
nextExpectedOffset: number | null;
catchUpEndOffset: number | null;
unsubscribe: () => void;
};
const DEFAULT_ATTACH_MAX_ATTEMPTS = 4;
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
export class TerminalStreamController {
private readonly resumeOffsetByTerminalId = new Map<string, number>();
private selectedTerminalId: string | null = null;
private activeStream: TerminalStreamControllerActiveStream | null = null;
private attachGeneration = 0;
private isDisposed = false;
private status: TerminalStreamControllerStatus = {
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
};
constructor(private readonly options: TerminalStreamControllerOptions) {}
getActiveStreamId(): number | null {
return this.activeStream?.streamId ?? null;
}
setTerminal(input: { terminalId: string | null }): void {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "terminal:set",
details: {
previousTerminalId: this.selectedTerminalId,
nextTerminalId: input.terminalId,
},
});
const nextTerminalId = input.terminalId;
const previousTerminalId = this.selectedTerminalId;
const isSameTerminal = previousTerminalId === nextTerminalId;
const hasActiveStreamForSelection =
isSameTerminal &&
this.activeStream?.terminalId === nextTerminalId &&
typeof this.activeStream.streamId === "number";
if (hasActiveStreamForSelection) {
return;
}
this.selectedTerminalId = nextTerminalId;
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: true });
if (!nextTerminalId) {
this.updateStatus({
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
});
return;
}
this.updateStatus({
terminalId: nextTerminalId,
streamId: null,
isAttaching: true,
error: null,
});
void this.attachTerminal({
terminalId: nextTerminalId,
generation,
});
}
handleStreamExit(input: { terminalId: string; streamId: number }): void {
if (this.isDisposed) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:exit",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
},
});
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
if (activeStream.terminalId !== input.terminalId || activeStream.streamId !== input.streamId) {
return;
}
if (this.selectedTerminalId !== input.terminalId) {
return;
}
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: false });
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: true,
error:
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
}
pruneResumeOffsets(input: { terminalIds: string[] }): void {
const terminalIdSet = new Set(input.terminalIds);
for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
this.resumeOffsetByTerminalId.delete(terminalId);
}
}
}
dispose(): void {
if (this.isDisposed) {
return;
}
this.isDisposed = true;
this.attachGeneration += 1;
this.selectedTerminalId = null;
void this.detachActiveStream({ shouldDetach: true });
this.resumeOffsetByTerminalId.clear();
this.updateStatus({
terminalId: null,
streamId: null,
isAttaching: false,
error: null,
});
}
private async attachTerminal(input: {
terminalId: string;
generation: number;
}): Promise<void> {
const {
maxAttachAttempts = DEFAULT_ATTACH_MAX_ATTEMPTS,
attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS,
withTimeout = withPromiseTimeout,
waitForDelay = waitForDuration,
isRetryableError = isTerminalAttachRetryableError,
getRetryDelayMs = getTerminalAttachRetryDelayMs,
} = this.options;
let lastErrorMessage = "Unable to attach terminal stream";
for (let attempt = 0; attempt < maxAttachAttempts; attempt += 1) {
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:attempt",
details: {
terminalId: input.terminalId,
generation: input.generation,
attempt,
maxAttachAttempts,
},
});
try {
const preferredSize = this.options.getPreferredSize();
const resumeOffset = getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
});
const attachPayload = await withTimeout({
promise: this.options.client.attachTerminalStream(input.terminalId, {
...(resumeOffset !== undefined ? { resumeOffset } : {}),
...(preferredSize
? { rows: preferredSize.rows, cols: preferredSize.cols }
: {}),
}),
timeoutMs: attachTimeoutMs,
timeoutMessage: "Timed out attaching terminal stream",
});
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
if (typeof attachPayload.streamId === "number") {
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
}
return;
}
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:response-error",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
continue;
}
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
return;
}
if (attachPayload.reset) {
this.resumeOffsetByTerminalId.delete(input.terminalId);
this.options.onReset?.({ terminalId: input.terminalId });
}
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: attachPayload.currentOffset,
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
});
const decoder = new TextDecoder();
const streamId = attachPayload.streamId;
const replayedFromOffset =
typeof attachPayload.replayedFrom === "number"
? Math.max(0, Math.floor(attachPayload.replayedFrom))
: null;
const currentOffset = Math.max(
0,
Math.floor(attachPayload.currentOffset)
);
const shouldResetForReplayBootstrap =
typeof resumeOffset !== "number" &&
typeof replayedFromOffset === "number" &&
replayedFromOffset < currentOffset;
if (shouldResetForReplayBootstrap) {
this.options.onReset?.({ terminalId: input.terminalId });
}
const startExpectedOffset =
replayedFromOffset ??
(typeof resumeOffset === "number"
? Math.max(0, Math.floor(resumeOffset))
: currentOffset);
const catchUpEndOffset = currentOffset;
const activeStream: TerminalStreamControllerActiveStream = {
terminalId: input.terminalId,
streamId,
decoder,
nextExpectedOffset: startExpectedOffset,
catchUpEndOffset,
unsubscribe: () => {},
};
this.activeStream = activeStream;
const unsubscribe = this.options.client.onTerminalStreamData(streamId, (chunk) => {
this.handleChunk({
terminalId: input.terminalId,
streamId,
chunk,
decoder,
});
});
if (this.activeStream === activeStream) {
activeStream.unsubscribe = unsubscribe;
} else {
unsubscribe();
}
terminalDebugLog({
scope: "stream-controller",
event: "attach:success",
details: {
terminalId: input.terminalId,
streamId,
replayedFrom: attachPayload.replayedFrom ?? null,
requestedResumeOffset: resumeOffset ?? null,
currentOffset: attachPayload.currentOffset,
reset: attachPayload.reset,
bootstrapReset: shouldResetForReplayBootstrap,
},
});
this.updateStatus({
terminalId: input.terminalId,
streamId,
isAttaching: false,
error: null,
});
return;
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
terminalDebugLog({
scope: "stream-controller",
event: "attach:exception",
details: {
terminalId: input.terminalId,
attempt,
error: lastErrorMessage,
},
});
const hasRemainingAttempts = attempt < maxAttachAttempts - 1;
if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) {
await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) });
continue;
}
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
return;
}
}
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: false,
error: lastErrorMessage,
});
}
private handleChunk(input: {
terminalId: string;
streamId: number;
chunk: TerminalStreamControllerChunk;
decoder: TextDecoder;
}): void {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
return;
}
const chunkOffset = Number.isFinite(input.chunk.offset)
? Math.max(0, Math.floor(input.chunk.offset))
: 0;
const chunkEndOffset = Number.isFinite(input.chunk.endOffset)
? Math.max(0, Math.floor(input.chunk.endOffset))
: chunkOffset;
if (chunkEndOffset < chunkOffset) {
return;
}
const expectedOffset = activeStream.nextExpectedOffset;
if (typeof expectedOffset === "number") {
if (chunkEndOffset <= expectedOffset) {
return;
}
if (chunkOffset !== expectedOffset) {
const catchUpEndOffset = activeStream.catchUpEndOffset;
const canSkipReplayGap =
chunkOffset > expectedOffset &&
typeof catchUpEndOffset === "number" &&
expectedOffset < catchUpEndOffset &&
chunkOffset <= catchUpEndOffset;
if (canSkipReplayGap) {
activeStream.nextExpectedOffset = chunkOffset;
} else {
this.recoverFromStreamGap({
terminalId: input.terminalId,
streamId: input.streamId,
expectedOffset,
observedOffset: chunkOffset,
});
return;
}
}
}
if (
typeof activeStream.catchUpEndOffset === "number" &&
chunkEndOffset >= activeStream.catchUpEndOffset
) {
activeStream.catchUpEndOffset = null;
}
activeStream.nextExpectedOffset = chunkEndOffset;
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: chunkEndOffset,
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
});
const text = input.decoder.decode(input.chunk.data, { stream: true });
if (text.length === 0) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:chunk",
details: {
terminalId: input.terminalId,
streamId: input.streamId,
offset: chunkOffset,
endOffset: chunkEndOffset,
replay: Boolean(input.chunk.replay),
byteLength: input.chunk.data.byteLength,
textLength: text.length,
preview: summarizeTerminalText({ text, maxChars: 96 }),
},
});
this.options.onChunk({
terminalId: input.terminalId,
text,
});
}
private recoverFromStreamGap(input: {
terminalId: string;
streamId: number;
expectedOffset: number;
observedOffset: number;
}): void {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
if (activeStream.streamId !== input.streamId || activeStream.terminalId !== input.terminalId) {
return;
}
if (this.selectedTerminalId !== input.terminalId) {
return;
}
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: input.expectedOffset,
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
});
this.attachGeneration += 1;
const generation = this.attachGeneration;
void this.detachActiveStream({ shouldDetach: true });
this.updateStatus({
terminalId: input.terminalId,
streamId: null,
isAttaching: true,
error:
this.options.reconnectErrorMessage ?? DEFAULT_RECONNECT_ERROR_MESSAGE,
});
void this.attachTerminal({
terminalId: input.terminalId,
generation,
});
}
private async detachActiveStream(input: { shouldDetach: boolean }): Promise<void> {
const activeStream = this.activeStream;
if (!activeStream) {
return;
}
terminalDebugLog({
scope: "stream-controller",
event: "stream:detach",
details: {
terminalId: activeStream.terminalId,
streamId: activeStream.streamId,
shouldDetach: input.shouldDetach,
},
});
this.activeStream = null;
try {
const tail = activeStream.decoder.decode();
if (tail.length > 0) {
this.options.onChunk({
terminalId: activeStream.terminalId,
text: tail,
});
}
} catch {
// no-op
}
try {
activeStream.unsubscribe();
} catch {
// no-op
}
if (!input.shouldDetach) {
return;
}
try {
await this.options.client.detachTerminalStream(activeStream.streamId);
} catch {
// no-op
}
}
private isAttachGenerationCurrent(input: {
generation: number;
terminalId: string;
}): boolean {
if (this.isDisposed) {
return false;
}
return (
this.attachGeneration === input.generation &&
this.selectedTerminalId === input.terminalId
);
}
private updateStatus(status: TerminalStreamControllerStatus): void {
this.status = status;
terminalDebugLog({
scope: "stream-controller",
event: "status:update",
details: {
terminalId: status.terminalId,
streamId: status.streamId,
isAttaching: status.isAttaching,
error: status.error,
},
});
this.options.onStatusChange?.(status);
}
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname,
} from "./host-routes";
describe("parseHostAgentDraftRouteFromPathname", () => {
it("parses draft route server id", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({
serverId: "local",
});
});
it("parses encoded server id", () => {
expect(
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent")
).toEqual({
serverId: "team host",
});
});
it("does not match agent detail routes", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent/abc123")).toBeNull();
});
});
describe("parseHostAgentRouteFromPathname", () => {
it("continues parsing detail routes", () => {
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({
serverId: "local",
agentId: "abc123",
});
});
});

View File

@@ -12,6 +12,14 @@ function encodeSegment(value: string): string {
return encodeURIComponent(value);
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function parseServerIdFromPathname(pathname: string): string | null {
const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/);
if (!match) {
@@ -21,11 +29,7 @@ export function parseServerIdFromPathname(pathname: string): string | null {
if (!raw) {
return null;
}
try {
return trimNonEmpty(decodeURIComponent(raw));
} catch {
return trimNonEmpty(raw);
}
return trimNonEmpty(decodeSegment(raw));
}
export function parseHostAgentRouteFromPathname(
@@ -41,16 +45,8 @@ export function parseHostAgentRouteFromPathname(
return null;
}
const decode = (value: string) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const serverId = trimNonEmpty(decode(encodedServerId));
const agentId = trimNonEmpty(decode(encodedAgentId));
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
const agentId = trimNonEmpty(decodeSegment(encodedAgentId));
if (!serverId || !agentId) {
return null;
}
@@ -58,6 +54,24 @@ export function parseHostAgentRouteFromPathname(
return { serverId, agentId };
}
export function parseHostAgentDraftRouteFromPathname(
pathname: string
): { serverId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/);
if (!match) {
return null;
}
const encodedServerId = match[1];
if (!encodedServerId) {
return null;
}
const serverId = trimNonEmpty(decodeSegment(encodedServerId));
if (!serverId) {
return null;
}
return { serverId };
}
export function buildHostAgentDraftRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
@@ -115,4 +129,3 @@ export function mapPathnameToServer(
}
return `${base}/agent`;
}

View File

@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import {
buildNewAgentRoute,
parseAgentKey,
resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent,
} from "./new-agent-routing";
describe("buildNewAgentRoute", () => {
@@ -35,3 +37,60 @@ describe("resolveNewAgentWorkingDir", () => {
);
});
});
describe("parseAgentKey", () => {
it("parses server and agent ids from combined key", () => {
expect(parseAgentKey("srv-1:agent-9")).toEqual({
serverId: "srv-1",
agentId: "agent-9",
});
});
it("uses the last separator to preserve server ids with colons", () => {
expect(parseAgentKey("localhost:6767:agent-9")).toEqual({
serverId: "localhost:6767",
agentId: "agent-9",
});
});
it("returns null for malformed keys", () => {
expect(parseAgentKey("")).toBeNull();
expect(parseAgentKey("only-server")).toBeNull();
expect(parseAgentKey(":agent-1")).toBeNull();
expect(parseAgentKey("srv-1:")).toBeNull();
});
});
describe("resolveSelectedAgentForNewAgent", () => {
it("prefers the agent in the current route", () => {
expect(
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/agent/agent-2",
selectedAgentId: "srv-9:agent-9",
})
).toEqual({
serverId: "srv-1",
agentId: "agent-2",
});
});
it("falls back to selected agent key when route has no agent", () => {
expect(
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/settings",
selectedAgentId: "srv-1:agent-7",
})
).toEqual({
serverId: "srv-1",
agentId: "agent-7",
});
});
it("returns null when neither route nor selection has an agent", () => {
expect(
resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/settings",
})
).toBeNull();
});
});

View File

@@ -1,5 +1,36 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
import {
buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes";
export function parseAgentKey(
key: string | null | undefined
): { serverId: string; agentId: string } | null {
if (!key) {
return null;
}
const sep = key.lastIndexOf(":");
if (sep <= 0 || sep >= key.length - 1) {
return null;
}
const serverId = key.slice(0, sep).trim();
const agentId = key.slice(sep + 1).trim();
if (!serverId || !agentId) {
return null;
}
return { serverId, agentId };
}
export function resolveSelectedAgentForNewAgent(input: {
pathname: string;
selectedAgentId?: string;
}): { serverId: string; agentId: string } | null {
return (
parseHostAgentRouteFromPathname(input.pathname) ??
parseAgentKey(input.selectedAgentId)
);
}
export function resolveNewAgentWorkingDir(
cwd: string,

View File

@@ -0,0 +1,44 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
export type SidebarAttentionReason =
| "finished"
| "error"
| "permission"
| null
| undefined;
export type SidebarStateBucket =
| "needs_input"
| "failed"
| "running"
| "attention"
| "done";
export function deriveSidebarStateBucket(input: {
status: AgentLifecycleStatus;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): SidebarStateBucket {
if (input.requiresAttention && input.attentionReason === "permission") {
return "needs_input";
}
if (input.status === "error" || input.attentionReason === "error") {
return "failed";
}
if (input.status === "running") {
return "running";
}
if (input.requiresAttention) {
// Unread/attention-needed completed agents are active in sidebar logic.
return "attention";
}
return "done";
}
export function isSidebarActiveAgent(input: {
status: AgentLifecycleStatus;
requiresAttention?: boolean;
attentionReason?: SidebarAttentionReason;
}): boolean {
return deriveSidebarStateBucket(input) !== "done";
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
getTerminalResumeOffset,
getTerminalAttachRetryDelayMs,
isTerminalAttachRetryableError,
updateTerminalResumeOffset,
withPromiseTimeout,
} from "./terminal-attach";
describe("terminal-attach", () => {
it("computes bounded exponential retry delays", () => {
expect(getTerminalAttachRetryDelayMs({ attempt: 0 })).toBe(250);
expect(getTerminalAttachRetryDelayMs({ attempt: 1 })).toBe(500);
expect(getTerminalAttachRetryDelayMs({ attempt: 2 })).toBe(1_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 3 })).toBe(2_000);
expect(getTerminalAttachRetryDelayMs({ attempt: 8 })).toBe(2_000);
});
it("matches retryable attach errors", () => {
expect(
isTerminalAttachRetryableError({ message: "Terminal not found while attaching" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "Network disconnected during attach" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "stream ended before ack" })
).toBe(true);
expect(
isTerminalAttachRetryableError({ message: "permission denied" })
).toBe(false);
});
it("reads and updates resume offsets monotonically", () => {
const offsets = new Map<string, number>();
const terminalId = "term-1";
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBeUndefined();
updateTerminalResumeOffset({
terminalId,
offset: 8,
resumeOffsetByTerminalId: offsets,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBe(8);
// Stale offsets must not move resume backwards.
updateTerminalResumeOffset({
terminalId,
offset: 3,
resumeOffsetByTerminalId: offsets,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetByTerminalId: offsets,
})
).toBe(8);
});
it("resolves before timeout when promise completes", async () => {
await expect(
withPromiseTimeout({
promise: Promise.resolve("ok"),
timeoutMs: 50,
timeoutMessage: "timed out",
})
).resolves.toBe("ok");
});
it("rejects when timeout wins", async () => {
await expect(
withPromiseTimeout({
promise: new Promise<string>(() => {}),
timeoutMs: 10,
timeoutMessage: "timed out",
})
).rejects.toThrow("timed out");
});
});

View File

@@ -0,0 +1,83 @@
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
"terminal not found",
"timed out",
"timeout",
"connection",
"network",
"disconnected",
"stream ended",
] as const;
export function getTerminalResumeOffset(input: {
terminalId: string;
resumeOffsetByTerminalId: Map<string, number>;
}): number | undefined {
const offset = input.resumeOffsetByTerminalId.get(input.terminalId);
if (typeof offset !== "number" || !Number.isFinite(offset)) {
return undefined;
}
const normalizedOffset = Math.max(0, Math.floor(offset));
return normalizedOffset;
}
export function updateTerminalResumeOffset(input: {
terminalId: string;
offset: number;
resumeOffsetByTerminalId: Map<string, number>;
}): void {
if (!Number.isFinite(input.offset)) {
return;
}
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousOffset =
getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetByTerminalId: input.resumeOffsetByTerminalId,
}) ?? -1;
if (normalizedOffset <= previousOffset) {
return;
}
input.resumeOffsetByTerminalId.set(input.terminalId, normalizedOffset);
}
export function getTerminalAttachRetryDelayMs(input: { attempt: number }): number {
const clampedAttempt = Math.max(0, input.attempt);
const exponentialDelay = 250 * (2 ** clampedAttempt);
return Math.min(2_000, exponentialDelay);
}
export function isTerminalAttachRetryableError(input: { message: string }): boolean {
const normalized = input.message.toLowerCase();
return TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS.some((pattern) =>
normalized.includes(pattern)
);
}
export async function waitForDuration(input: { durationMs: number }): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, Math.max(0, input.durationMs));
});
}
export async function withPromiseTimeout<T>(input: {
promise: Promise<T>;
timeoutMs: number;
timeoutMessage: string;
}): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(input.timeoutMessage));
}, Math.max(0, input.timeoutMs));
});
try {
return await Promise.race([input.promise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
hasPendingTerminalModifiers,
isTerminalModifierDomKey,
mapTerminalDataToKey,
mergeTerminalModifiers,
normalizeDomTerminalKey,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
shouldInterceptDomTerminalKey,
} from "./terminal-keys";
describe("terminal key helpers", () => {
it("normalizes supported DOM keys", () => {
expect(normalizeDomTerminalKey("Esc")).toBe("Escape");
expect(normalizeDomTerminalKey(" ")).toBe(" ");
expect(normalizeDomTerminalKey("ArrowUp")).toBe("ArrowUp");
expect(normalizeDomTerminalKey("F12")).toBe("F12");
});
it("filters unsupported and composing DOM keys", () => {
expect(normalizeDomTerminalKey("Dead")).toBeNull();
expect(normalizeDomTerminalKey("Unidentified")).toBeNull();
expect(normalizeDomTerminalKey("MediaPlayPause")).toBeNull();
});
it("detects modifier DOM keys", () => {
expect(isTerminalModifierDomKey("Control")).toBe(true);
expect(isTerminalModifierDomKey("Shift")).toBe(true);
expect(isTerminalModifierDomKey("a")).toBe(false);
});
it("lowercases printable transport keys", () => {
expect(normalizeTerminalTransportKey("C")).toBe("c");
expect(normalizeTerminalTransportKey("Escape")).toBe("Escape");
});
it("merges pending modifiers with native key modifiers", () => {
expect(
mergeTerminalModifiers({
pendingModifiers: { ctrl: true, shift: false, alt: true },
ctrlKey: false,
shiftKey: true,
altKey: false,
metaKey: false,
})
).toEqual({
ctrl: true,
shift: true,
alt: true,
meta: false,
});
});
it("intercepts special keys and modifier combos", () => {
expect(
shouldInterceptDomTerminalKey({
key: "Escape",
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: true,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
).toBe(true);
expect(
shouldInterceptDomTerminalKey({
key: "c",
ctrlKey: false,
altKey: false,
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
).toBe(false);
});
it("detects pending modifier state", () => {
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(
false
);
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(
true
);
});
it("maps onData bytes to terminal keys for modifier fallback", () => {
expect(mapTerminalDataToKey("c")).toBe("c");
expect(mapTerminalDataToKey("\r")).toBe("Enter");
expect(mapTerminalDataToKey("\t")).toBe("Tab");
expect(mapTerminalDataToKey("\x7f")).toBe("Backspace");
expect(mapTerminalDataToKey("\x1b")).toBe("Escape");
expect(mapTerminalDataToKey("\x03")).toBeNull();
expect(mapTerminalDataToKey("")).toBeNull();
});
it("clears pending modifiers when fallback input cannot map to a key", () => {
expect(
resolvePendingModifierDataInput({
data: "hello",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
).toEqual({
mode: "raw",
clearPendingModifiers: true,
});
});
it("maps pending modifier fallback to key transport when possible", () => {
expect(
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: true, shift: false, alt: false },
})
).toEqual({
mode: "key",
key: "c",
clearPendingModifiers: true,
});
});
it("keeps raw mode unchanged when no pending modifiers exist", () => {
expect(
resolvePendingModifierDataInput({
data: "c",
pendingModifiers: { ctrl: false, shift: false, alt: false },
})
).toEqual({
mode: "raw",
clearPendingModifiers: false,
});
});
});

View File

@@ -0,0 +1,194 @@
export type PendingTerminalModifiers = {
ctrl: boolean;
shift: boolean;
alt: boolean;
};
const MODIFIER_DOM_KEYS = new Set([
"Control",
"Shift",
"Alt",
"Meta",
"AltGraph",
"OS",
]);
const DOM_KEY_ALIASES: Record<string, string> = {
Esc: "Escape",
Left: "ArrowLeft",
Right: "ArrowRight",
Up: "ArrowUp",
Down: "ArrowDown",
Del: "Delete",
Spacebar: " ",
Space: " ",
};
const SUPPORTED_SPECIAL_KEYS = new Set([
"Enter",
"Tab",
"Backspace",
"Escape",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"Insert",
"Delete",
"PageUp",
"PageDown",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
]);
export function isTerminalModifierDomKey(rawKey: string): boolean {
return MODIFIER_DOM_KEYS.has(rawKey);
}
export function normalizeDomTerminalKey(rawKey: string): string | null {
if (!rawKey) {
return null;
}
const key = DOM_KEY_ALIASES[rawKey] ?? rawKey;
if (
key === "Unidentified" ||
key === "Dead" ||
key === "Compose" ||
key === "Process"
) {
return null;
}
if (key.length === 1) {
return key;
}
if (SUPPORTED_SPECIAL_KEYS.has(key)) {
return key;
}
return null;
}
export function normalizeTerminalTransportKey(key: string): string {
if (key.length === 1) {
return key.toLowerCase();
}
return key;
}
export function hasPendingTerminalModifiers(
modifiers: PendingTerminalModifiers
): boolean {
return modifiers.ctrl || modifiers.shift || modifiers.alt;
}
export function shouldInterceptDomTerminalKey(args: {
key: string;
ctrlKey: boolean;
altKey: boolean;
pendingModifiers: PendingTerminalModifiers;
}): boolean {
return (
args.key.length > 1 ||
args.ctrlKey ||
args.altKey ||
hasPendingTerminalModifiers(args.pendingModifiers)
);
}
export function mergeTerminalModifiers(args: {
pendingModifiers: PendingTerminalModifiers;
ctrlKey: boolean;
shiftKey: boolean;
altKey: boolean;
metaKey: boolean;
}): {
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
} {
const { pendingModifiers, ctrlKey, shiftKey, altKey, metaKey } = args;
return {
ctrl: ctrlKey || pendingModifiers.ctrl,
shift: shiftKey || pendingModifiers.shift,
alt: altKey || pendingModifiers.alt,
meta: metaKey,
};
}
export function mapTerminalDataToKey(data: string): string | null {
if (!data || data.length !== 1) {
return null;
}
if (data === "\r" || data === "\n") {
return "Enter";
}
if (data === "\t") {
return "Tab";
}
if (data === "\x7f" || data === "\b") {
return "Backspace";
}
if (data === "\x1b") {
return "Escape";
}
const code = data.charCodeAt(0);
// Only map printable ASCII for modifier fallback; keep control bytes raw.
if (code >= 0x20 && code <= 0x7e) {
return data;
}
return null;
}
export function resolvePendingModifierDataInput(args: {
data: string;
pendingModifiers: PendingTerminalModifiers;
}):
| {
mode: "key";
key: string;
clearPendingModifiers: true;
}
| {
mode: "raw";
clearPendingModifiers: boolean;
} {
if (!hasPendingTerminalModifiers(args.pendingModifiers)) {
return {
mode: "raw",
clearPendingModifiers: false,
};
}
const mappedKey = mapTerminalDataToKey(args.data);
if (!mappedKey) {
return {
mode: "raw",
clearPendingModifiers: true,
};
}
return {
mode: "key",
key: mappedKey,
clearPendingModifiers: true,
};
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import {
appendTerminalOutputBuffer,
createTerminalOutputBuffer,
readTerminalOutputBuffer,
} from "./terminal-output-buffer";
describe("terminal-output-buffer", () => {
it("keeps appended text within max chars without rebuilding from scratch", () => {
const buffer = createTerminalOutputBuffer();
appendTerminalOutputBuffer({ buffer, text: "abc", maxChars: 5 });
appendTerminalOutputBuffer({ buffer, text: "de", maxChars: 5 });
expect(readTerminalOutputBuffer({ buffer })).toBe("abcde");
appendTerminalOutputBuffer({ buffer, text: "f", maxChars: 5 });
expect(readTerminalOutputBuffer({ buffer })).toBe("bcdef");
appendTerminalOutputBuffer({ buffer, text: "gh", maxChars: 5 });
expect(readTerminalOutputBuffer({ buffer })).toBe("defgh");
});
it("ignores empty appends and preserves current content", () => {
const buffer = createTerminalOutputBuffer();
appendTerminalOutputBuffer({ buffer, text: "hello", maxChars: 10 });
appendTerminalOutputBuffer({ buffer, text: "", maxChars: 10 });
expect(readTerminalOutputBuffer({ buffer })).toBe("hello");
});
it("handles large overflow by trimming entire leading segments", () => {
const buffer = createTerminalOutputBuffer();
appendTerminalOutputBuffer({ buffer, text: "12345", maxChars: 8 });
appendTerminalOutputBuffer({ buffer, text: "6789", maxChars: 8 });
appendTerminalOutputBuffer({ buffer, text: "ABCDEF", maxChars: 8 });
expect(readTerminalOutputBuffer({ buffer })).toBe("89ABCDEF");
});
});

View File

@@ -0,0 +1,95 @@
export interface TerminalOutputBuffer {
segments: string[];
startIndex: number;
totalChars: number;
}
const COMPACT_SEGMENT_THRESHOLD = 256;
export function createTerminalOutputBuffer(): TerminalOutputBuffer {
return {
segments: [],
startIndex: 0,
totalChars: 0,
};
}
function normalizeMaxChars(input: { maxChars: number }): number {
if (!Number.isFinite(input.maxChars)) {
return 0;
}
return Math.max(0, Math.floor(input.maxChars));
}
function compactTerminalOutputBuffer(input: { buffer: TerminalOutputBuffer }): void {
const { buffer } = input;
if (buffer.startIndex <= COMPACT_SEGMENT_THRESHOLD) {
return;
}
buffer.segments = buffer.segments.slice(buffer.startIndex);
buffer.startIndex = 0;
}
function trimTerminalOutputBufferToMax(input: {
buffer: TerminalOutputBuffer;
maxChars: number;
}): void {
const { buffer } = input;
const maxChars = normalizeMaxChars({ maxChars: input.maxChars });
while (buffer.totalChars > maxChars) {
const leadingSegment = buffer.segments[buffer.startIndex];
if (!leadingSegment) {
buffer.segments = [];
buffer.startIndex = 0;
buffer.totalChars = 0;
return;
}
const overflowChars = buffer.totalChars - maxChars;
if (leadingSegment.length <= overflowChars) {
buffer.startIndex += 1;
buffer.totalChars -= leadingSegment.length;
continue;
}
buffer.segments[buffer.startIndex] = leadingSegment.slice(overflowChars);
buffer.totalChars -= overflowChars;
break;
}
compactTerminalOutputBuffer({ buffer });
}
export function appendTerminalOutputBuffer(input: {
buffer: TerminalOutputBuffer;
text: string;
maxChars: number;
}): void {
if (!input.text) {
return;
}
input.buffer.segments.push(input.text);
input.buffer.totalChars += input.text.length;
trimTerminalOutputBufferToMax({
buffer: input.buffer,
maxChars: input.maxChars,
});
}
export function readTerminalOutputBuffer(input: {
buffer: TerminalOutputBuffer;
}): string {
const { buffer } = input;
if (buffer.totalChars <= 0) {
return "";
}
if (buffer.startIndex === 0) {
return buffer.segments.join("");
}
return buffer.segments.slice(buffer.startIndex).join("");
}

View File

@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
});
});
it("builds display model from worktree setup detail", () => {
const display = buildToolCallDisplayModel({
name: "paseo_worktree_setup",
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
branchName: "feature-branch",
log: "==> [1/1] Running: npm install\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
status: "running",
exitCode: null,
},
],
},
});
expect(display).toEqual({
displayName: "Worktree Setup",
summary: "feature-branch",
});
});
it("does not derive command summary from unknown raw detail", () => {
const display = buildToolCallDisplayModel({
name: "exec_command",

View File

@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
edit: Pencil,
write: Pencil,
search: Search,
worktree_setup: SquareTerminal,
unknown: Wrench,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.4",
"version": "0.1.6",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -22,8 +22,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.4",
"@getpaseo/server": "0.1.4",
"@getpaseo/relay": "0.1.6",
"@getpaseo/server": "0.1.6",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -111,7 +111,10 @@ export async function runLsCommand(
}
try {
let agents = await client.fetchAgents()
const fetchPayload = await client.fetchAgents({
filter: options.all ? { includeArchived: true } : undefined,
})
let agents = fetchPayload.entries.map((entry) => entry.agent)
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {

View File

@@ -53,7 +53,8 @@ export async function runStopCommand(
}
try {
let agents = await client.fetchAgents()
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
let agents = fetchPayload.entries.map((entry) => entry.agent)
const stoppedIds: string[] = []
if (options.all) {

View File

@@ -111,7 +111,8 @@ export async function runStatusCommand(
const client = await tryConnectToDaemon({ host, timeout: 1500 })
if (client) {
try {
const agents = await client.fetchAgents()
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
runningAgents = agents.filter(a => a.status === 'running').length
idleAgents = agents.filter(a => a.status === 'idle').length
} catch {

View File

@@ -57,7 +57,8 @@ export async function runLsCommand(options: PermitLsOptions, _command: Command):
}
try {
const agents = await client.fetchAgents()
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
await client.close()
// Collect all pending permissions from all agents

View File

@@ -76,7 +76,8 @@ export async function runLsCommand(
}
try {
const agents = await client.fetchAgents()
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({})

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.4",
"version": "0.1.6",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.4",
"version": "0.1.6",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.4",
"version": "0.1.6",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -53,7 +53,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@getpaseo/relay": "0.1.4",
"@getpaseo/relay": "0.1.6",
"@ai-sdk/openai": "2.0.52",
"@deepgram/sdk": "^3.4.0",
"@lezer/common": "^1.5.0",

View File

@@ -28,7 +28,8 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).not.toHaveBeenCalled();
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
manager.subscribe({
streamId: 7,
@@ -39,7 +40,18 @@ describe("TerminalStreamManager", () => {
expect(seen).toEqual(["hello"]);
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
});
test("acks buffered chunks even while no subscriber is attached", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.receiveChunk({
chunk: createChunk({ streamId: 2, offset: 0, data: "abc" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 2, offset: 3 });
});
test("does not ack when every handler throws", () => {
@@ -77,6 +89,9 @@ describe("TerminalStreamManager", () => {
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 2, data: "C" }),
});
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 1 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(3, { streamId: 5, offset: 3 });
const seen: string[] = [];
manager.subscribe({
@@ -87,8 +102,7 @@ describe("TerminalStreamManager", () => {
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenNthCalledWith(1, { streamId: 5, offset: 2 });
expect(sendAck).toHaveBeenNthCalledWith(2, { streamId: 5, offset: 3 });
expect(sendAck).toHaveBeenCalledTimes(3);
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {

View File

@@ -79,6 +79,10 @@ export class TerminalStreamManager {
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
return;
}

View File

@@ -455,7 +455,7 @@ describe("DaemonClient", () => {
expect(request.message.requestId.length).toBeGreaterThan(0);
});
test("fetches project-grouped agents via RPC", async () => {
test("fetches agents via RPC with filters, sort, and pagination", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -471,29 +471,49 @@ describe("DaemonClient", () => {
mock.triggerOpen();
await connectPromise;
const promise = client.fetchAgentsGroupedByProject({
const promise = client.fetchAgents({
filter: { labels: { ui: "true" } },
sort: [
{ key: "status_priority", direction: "asc" },
{ key: "created_at", direction: "desc" },
],
page: { limit: 25, cursor: "cursor-1" },
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "fetch_agents_grouped_by_project_request";
type: "fetch_agents_request";
requestId: string;
filter?: { labels?: Record<string, string> };
sort?: Array<{
key: "status_priority" | "created_at" | "updated_at" | "title";
direction: "asc" | "desc";
}>;
page?: { limit: number; cursor?: string };
};
};
expect(request.message.type).toBe("fetch_agents_grouped_by_project_request");
expect(request.message.type).toBe("fetch_agents_request");
expect(request.message.sort).toEqual([
{ key: "status_priority", direction: "asc" },
{ key: "created_at", direction: "desc" },
]);
expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" });
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "fetch_agents_grouped_by_project_response",
type: "fetch_agents_response",
payload: {
requestId: request.message.requestId,
groups: [],
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
},
},
})
@@ -501,7 +521,12 @@ describe("DaemonClient", () => {
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
groups: [],
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: "cursor-1",
hasMore: false,
},
});
});
@@ -728,6 +753,48 @@ describe("DaemonClient", () => {
unsubscribe();
});
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamData(11, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const payload = new TextEncoder().encode("ls\r\n");
const frame = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 11,
offset: 0,
payload,
});
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
mock.triggerMessage(frameAsString);
expect(seen).toEqual(["ls\r\n"]);
expect(mock.sent).toHaveLength(1);
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame?.streamId).toBe(11);
expect(ackFrame?.offset).toBe(4);
});
test("acks buffered terminal chunks when handler is attached", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1169,4 +1236,89 @@ describe("DaemonClient", () => {
expect(received).toHaveLength(0);
expect(logger.warn).toHaveBeenCalled();
});
test("sends subscribe/unsubscribe terminals messages", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
client.subscribeTerminals({ cwd: "/tmp/project" });
client.unsubscribeTerminals({ cwd: "/tmp/project" });
expect(mock.sent).toHaveLength(2);
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: "session",
message: {
type: "subscribe_terminals_request",
cwd: "/tmp/project",
},
});
expect(JSON.parse(String(mock.sent[1]))).toEqual({
type: "session",
message: {
type: "unsubscribe_terminals_request",
cwd: "/tmp/project",
},
});
});
test("dispatches terminals_changed events to typed listeners", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const received: Array<{ cwd: string; names: string[] }> = [];
const unsubscribe = client.on("terminals_changed", (message) => {
received.push({
cwd: message.payload.cwd,
names: message.payload.terminals.map((terminal) => terminal.name),
});
});
mock.triggerMessage(
wrapSessionMessage({
type: "terminals_changed",
payload: {
cwd: "/tmp/project",
terminals: [
{
id: "term-1",
name: "Dev Server",
},
],
},
})
);
unsubscribe();
expect(received).toEqual([
{
cwd: "/tmp/project",
names: ["Dev Server"],
},
]);
});
});

View File

@@ -244,10 +244,19 @@ type AgentRefreshedStatusPayload = z.infer<
type RestartRequestedStatusPayload = z.infer<
typeof RestartRequestedStatusPayloadSchema
>;
type FetchAgentsGroupedByProjectPayload = Extract<
type FetchAgentsPayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_grouped_by_project_response" }
{ type: "fetch_agents_response" }
>["payload"];
type FetchAgentsRequest = Extract<
SessionInboundMessage,
{ type: "fetch_agents_request" }
>;
export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId"> & {
requestId?: string;
};
export type FetchAgentsEntry = FetchAgentsPayload["entries"][number];
export type FetchAgentsPageInfo = FetchAgentsPayload["pageInfo"];
export type WaitForFinishResult = {
status: "idle" | "error" | "permission" | "timeout";
@@ -347,6 +356,7 @@ export class DaemonClient {
string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>();
private terminalDirectorySubscriptions = new Set<string>();
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
private relayClientId: string | null = null;
@@ -465,6 +475,7 @@ export class DaemonClient {
this.updateConnectionState({ status: "connected" });
this.resubscribeAgentUpdates();
this.resubscribeCheckoutDiffSubscriptions();
this.resubscribeTerminalDirectorySubscriptions();
this.flushPendingSendQueue();
this.resolveConnect();
}),
@@ -976,15 +987,14 @@ export class DaemonClient {
// Agent RPCs (requestId-correlated)
// ============================================================================
async fetchAgents(options?: {
filter?: { labels?: Record<string, string> };
requestId?: string;
}): Promise<AgentSnapshotPayload[]> {
async fetchAgents(options?: FetchAgentsOptions): Promise<FetchAgentsPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: resolvedRequestId,
...(options?.filter ? { filter: options.filter } : {}),
...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}),
});
return this.sendRequest({
requestId: resolvedRequestId,
@@ -998,33 +1008,6 @@ export class DaemonClient {
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload.agents;
},
});
}
async fetchAgentsGroupedByProject(options?: {
filter?: { labels?: Record<string, string> };
requestId?: string;
}): Promise<FetchAgentsGroupedByProjectPayload> {
const resolvedRequestId = this.createRequestId(options?.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agents_grouped_by_project_request",
requestId: resolvedRequestId,
...(options?.filter ? { filter: options.filter } : {}),
});
return this.sendRequest({
requestId: resolvedRequestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "fetch_agents_grouped_by_project_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
return null;
}
return msg.payload;
},
});
@@ -1112,6 +1095,18 @@ export class DaemonClient {
}
}
private resubscribeTerminalDirectorySubscriptions(): void {
if (this.terminalDirectorySubscriptions.size === 0) {
return;
}
for (const cwd of this.terminalDirectorySubscriptions) {
this.sendSessionMessage({
type: "subscribe_terminals_request",
cwd,
});
}
}
// ============================================================================
// Agent Lifecycle
// ============================================================================
@@ -2333,6 +2328,28 @@ export class DaemonClient {
// Terminals
// ============================================================================
subscribeTerminals(input: { cwd: string }): void {
this.terminalDirectorySubscriptions.add(input.cwd);
if (!this.transport || this.connectionState.status !== "connected") {
return;
}
this.sendSessionMessage({
type: "subscribe_terminals_request",
cwd: input.cwd,
});
}
unsubscribeTerminals(input: { cwd: string }): void {
this.terminalDirectorySubscriptions.delete(input.cwd);
if (!this.transport || this.connectionState.status !== "connected") {
return;
}
this.sendSessionMessage({
type: "unsubscribe_terminals_request",
cwd: input.cwd,
});
}
async listTerminals(
cwd: string,
requestId?: string

View File

@@ -46,14 +46,24 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import { expandUserPath } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
}
@@ -313,12 +323,13 @@ export async function createAgentManagementMcpServer(
};
let resolvedCwd = expandUserPath(cwd);
let worktreeConfig: WorktreeConfig | undefined;
if (worktreeName) {
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -326,6 +337,7 @@ export async function createAgentManagementMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
const provider: AgentProvider = agentType ?? "claude";
@@ -337,6 +349,27 @@ export async function createAgentManagementMcpServer(
title: normalizedTitle ?? undefined,
});
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: options.terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
@@ -350,7 +383,9 @@ export async function createAgentManagementMcpServer(
});
try {
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
@@ -575,7 +610,9 @@ export async function createAgentManagementMcpServer(
}
try {
agentManager.recordUserMessage(agentId, prompt);
agentManager.recordUserMessage(agentId, prompt, {
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId },

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -405,6 +405,66 @@ describe("AgentManager", () => {
expect(result.rows[result.rows.length - 1]?.seq).toBe(3);
});
test("emits live timeline updates without recording canonical timeline rows", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-timeline-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000120",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const streamEvents: Array<{
seq?: number;
epoch?: string;
eventType?: string;
itemType?: string;
}> = [];
manager.subscribe(
(event) => {
if (event.type !== "agent_stream") {
return;
}
streamEvents.push({
seq: event.seq,
epoch: event.epoch,
eventType: event.event.type,
itemType: event.event.type === "timeline" ? event.event.item.type : undefined,
});
},
{ agentId: snapshot.id, replayState: false }
);
await manager.emitLiveTimelineItem(snapshot.id, {
type: "assistant_message",
text: "live-only update",
});
expect(streamEvents).toHaveLength(1);
expect(streamEvents[0]).toMatchObject({
eventType: "timeline",
itemType: "assistant_message",
});
expect(streamEvents[0]?.seq).toBeUndefined();
expect(streamEvents[0]?.epoch).toBeUndefined();
expect(manager.getTimeline(snapshot.id)).toEqual([]);
const fetched = manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
});
expect(fetched.rows).toEqual([]);
});
test("fetchTimeline returns full timeline with reset when cursor seq falls behind retention window", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-gap-"));
const storagePath = join(workdir, "agents");
@@ -588,6 +648,74 @@ describe("AgentManager", () => {
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
});
test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000120",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
try {
manager.recordUserMessage(snapshot.id, "hello");
const afterMessage = manager.getAgent(snapshot.id);
expect(afterMessage).toBeDefined();
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
const stream = manager.streamAgent(snapshot.id, "hello");
const afterRunStart = manager.getAgent(snapshot.id);
expect(afterRunStart).toBeDefined();
expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt);
await stream.return(undefined);
} finally {
nowSpy.mockRestore();
}
});
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000121",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const lifecycleUpdates: string[] = [];
manager.subscribe((event) => {
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
return;
}
lifecycleUpdates.push(event.agent.lifecycle);
});
lifecycleUpdates.length = 0;
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
expect(lifecycleUpdates).toEqual([]);
});
test("runAgent assembles finalText from trailing assistant chunks", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");

View File

@@ -307,6 +307,15 @@ export class AgentManager {
this.onAgentAttention = callback;
}
private touchUpdatedAt(agent: ManagedAgent): Date {
const nowMs = Date.now();
const previousMs = agent.updatedAt.getTime();
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
const next = new Date(nextMs);
agent.updatedAt = next;
return next;
}
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
const targetAgentId =
options?.agentId == null
@@ -847,7 +856,7 @@ export class AgentManager {
recordUserMessage(
agentId: string,
text: string,
options?: { messageId?: string }
options?: { messageId?: string; emitState?: boolean }
): void {
const agent = this.requireAgent(agentId);
const item: AgentTimelineItem = {
@@ -855,8 +864,8 @@ export class AgentManager {
text,
messageId: options?.messageId,
};
agent.updatedAt = new Date();
agent.lastUserMessageAt = agent.updatedAt;
const updatedAt = this.touchUpdatedAt(agent);
agent.lastUserMessageAt = updatedAt;
const row = this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
@@ -866,12 +875,14 @@ export class AgentManager {
seq: row.seq,
epoch: this.ensureTimelineState(agent).epoch,
});
this.emitState(agent);
if (options?.emitState !== false) {
this.emitState(agent);
}
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
agent.updatedAt = new Date();
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
@@ -884,6 +895,19 @@ export class AgentManager {
await this.persistSnapshot(agent);
}
async emitLiveTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
});
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
@@ -953,6 +977,9 @@ export class AgentManager {
agent.pendingRun = streamForwarder;
agent.lifecycle = "running";
// Bump updatedAt when lifecycle changes so downstream consumers can
// deterministically order idle->running transitions.
this.touchUpdatedAt(agent);
self.emitState(agent);
return streamForwarder;
@@ -1524,7 +1551,7 @@ export class AgentManager {
): void {
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
agent.updatedAt = new Date();
this.touchUpdatedAt(agent);
}
let timelineRow: AgentTimelineRow | null = null;

View File

@@ -23,6 +23,39 @@ type McpClient = {
close: () => Promise<void>;
};
async function withTimeout<T>(
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for path: ${options.targetPath}`
);
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
@@ -53,10 +86,13 @@ function getStructuredContent(result: McpToolResult): StructuredContent | null {
return null;
}
async function waitForAgentCompletion(client: McpClient, agentId: string): Promise<void> {
const waitResult = (await client.callTool({
async function waitForAgentCompletion(options: {
client: McpClient;
agentId: string;
}): Promise<void> {
const waitResult = (await options.client.callTool({
name: "wait_for_agent",
args: { agentId },
args: { agentId: options.agentId },
})) as McpToolResult;
const payload = getStructuredContent(waitResult);
if (!payload) {
@@ -127,7 +163,7 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion(client, agentId!);
await waitForAgentCompletion({ client, agentId: agentId! });
if (existsSync(filePath)) {
const contents = await readFile(filePath, "utf8");
@@ -148,4 +184,115 @@ describe("agent MCP end-to-end (offline)", () => {
},
30_000
);
test(
"create_agent with worktree is async and boots terminals only after setup success",
async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
const port = await getAvailablePort();
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
allowedHosts: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
};
const daemon = await createPaseoDaemon(daemonConfig, pino({ level: "silent" }));
await daemon.start();
const transport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${port}/mcp/agents`)
);
const client = (await experimental_createMCPClient({ transport })) as McpClient;
let agentId: string | null = null;
try {
const { execSync } = await import("node:child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
await writeFile(path.join(repoRoot, "file.txt"), "hello\n", "utf8");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
await writeFile(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
},
}),
"utf8"
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree config'", {
cwd: repoRoot,
stdio: "pipe",
});
const result = (await withTimeout({
promise: client.callTool({
name: "create_agent",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
agentType: "claude",
initialMode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
baseBranch: "main",
background: true,
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
})) as McpToolResult;
const payload = getStructuredContent(result);
agentId = (payload?.agentId as string | undefined) ?? null;
expect(agentId).toBeTruthy();
const worktreePath = (payload?.cwd as string | undefined) ?? "";
expect(worktreePath).toContain(`${path.sep}worktrees${path.sep}`);
expect(existsSync(path.join(worktreePath, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(worktreePath, "dev-terminal.txt"))).toBe(false);
await writeFile(path.join(worktreePath, "allow-setup"), "ok\n", "utf8");
await waitForPathExists({
targetPath: path.join(worktreePath, "setup-done.txt"),
timeoutMs: 15000,
});
await waitForPathExists({
targetPath: path.join(worktreePath, "dev-terminal.txt"),
timeoutMs: 15000,
});
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
await rm(repoRoot, { recursive: true, force: true });
}
},
60_000
);
});

View File

@@ -134,6 +134,21 @@ export type ToolCallDetail =
type: "search";
query: string;
}
| {
type: "worktree_setup";
worktreePath: string;
branchName: string;
log: string;
commands: Array<{
index: number;
command: string;
cwd: string;
status: "running" | "completed" | "failed";
exitCode: number | null;
durationMs?: number;
}>;
truncated?: boolean;
}
| {
type: "unknown";
input: unknown | null;

View File

@@ -28,7 +28,11 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import { createWorktree } from "../../utils/worktree.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./timeline-append.js";
import { type WorktreeConfig } from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
import type {
@@ -36,10 +40,16 @@ import type {
VoiceSpeakHandler,
} from "../voice-types.js";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "../worktree-bootstrap.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
* ID of the agent that is connecting to this MCP server.
@@ -289,6 +299,7 @@ export async function createAgentMcpServer(
const {
agentManager,
agentStorage,
terminalManager,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -469,6 +480,7 @@ export async function createAgentMcpServer(
let resolvedCwd: string;
let resolvedMode: string | undefined;
let worktreeConfig: WorktreeConfig | undefined;
if (callerAgentId) {
const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args);
@@ -514,7 +526,7 @@ export async function createAgentMcpServer(
if (!baseBranch) {
throw new Error("baseBranch is required when creating a worktree");
}
const worktree = await createWorktree({
const worktree = await createAgentWorktree({
branchName: worktreeName,
cwd: resolvedCwd,
baseBranch,
@@ -522,6 +534,7 @@ export async function createAgentMcpServer(
paseoHome: options.paseoHome,
});
resolvedCwd = worktree.worktreePath;
worktreeConfig = worktree;
}
resolvedMode = initialMode;
@@ -542,6 +555,27 @@ export async function createAgentMcpServer(
childAgentDefaultLabels ? { labels: childAgentDefaultLabels } : undefined
);
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: terminalManager ?? null,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager,
agentId: snapshot.id,
item,
}),
logger: childLogger,
});
}
const trimmedPrompt = initialPrompt.trim();
scheduleAgentMetadataGeneration({
agentManager,
@@ -554,7 +588,9 @@ export async function createAgentMcpServer(
});
try {
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId: snapshot.id },
@@ -781,7 +817,9 @@ export async function createAgentMcpServer(
}
try {
agentManager.recordUserMessage(agentId, prompt);
agentManager.recordUserMessage(agentId, prompt, {
emitState: false,
});
} catch (error) {
childLogger.error(
{ err: error, agentId },

View File

@@ -0,0 +1,38 @@
import type { AgentManager } from "./agent-manager.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AppendTimelineItemIfAgentKnownOptions {
agentManager: AgentManager;
agentId: string;
item: AgentTimelineItem;
}
export async function appendTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
): Promise<boolean> {
try {
await options.agentManager.appendTimelineItem(options.agentId, options.item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
export async function emitLiveTimelineItemIfAgentKnown(
options: AppendTimelineItemIfAgentKnownOptions
): Promise<boolean> {
try {
await options.agentManager.emitLiveTimelineItem(options.agentId, options.item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}

View File

@@ -306,6 +306,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) => wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
@@ -329,6 +330,7 @@ export async function createPaseoDaemon(
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -448,6 +450,7 @@ export async function createPaseoDaemon(
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,

View File

@@ -16,18 +16,16 @@ function tmpCwd(): string {
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string
options: { promise: Promise<T>; timeoutMs: number; label: string }
): Promise<T> {
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Timed out after ${timeoutMs}ms (${label})`));
}, timeoutMs);
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
}, options.timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
return await Promise.race([options.promise, timeout]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
@@ -92,6 +90,21 @@ async function waitForTimelineToolCall(
);
}
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number; label: string }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}`
);
}
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -352,8 +365,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -367,9 +380,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-test",
},
}),
2500,
"createAgent should not block on setup"
);
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
@@ -384,9 +397,10 @@ describe("daemon E2E", () => {
);
expect(completed.callId).toBeTruthy();
expect(completed.detail.type).toBe("unknown");
if (completed.detail.type === "unknown") {
expect(completed.detail.output).toBeTruthy();
expect(completed.detail.type).toBe("worktree_setup");
if (completed.detail.type === "worktree_setup") {
expect(completed.detail.commands.length).toBeGreaterThan(0);
expect(completed.detail.log.length).toBeGreaterThan(0);
}
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true);
@@ -396,6 +410,107 @@ describe("daemon E2E", () => {
60000
);
test(
"bootstraps configured worktree terminals after setup succeeds",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const setupCommand =
'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Dev Server",
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
{
command: 'echo "lint-watch" > lint-terminal.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", {
cwd: repoRoot,
stdio: "pipe",
});
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup + Terminals Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "async-setup-terminals-test",
worktreeSlug: "async-setup-terminals-test",
},
}),
timeoutMs: 2500,
label: "createAgent should not block on setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false);
expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false);
writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n");
await waitForTimelineToolCall(
collector.messages,
agent.id,
(item) => item.name === "paseo_worktree_setup" && item.status === "completed",
20000
);
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "dev terminal marker",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "lint-terminal.txt"),
timeoutMs: 15000,
label: "lint terminal marker",
});
const list = await ctx.client.listTerminals(agent.cwd);
expect(list.error).toBeUndefined();
expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true);
expect(list.terminals.length).toBeGreaterThanOrEqual(2);
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
test(
"reports failures via timeline tool_call without deleting the created worktree",
async () => {
@@ -421,7 +536,17 @@ describe("daemon E2E", () => {
'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7';
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({ worktree: { setup: [setupCommand] } })
JSON.stringify({
worktree: {
setup: [setupCommand],
terminals: [
{
name: "Should Not Start",
command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null',
},
],
},
})
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", {
@@ -429,8 +554,8 @@ describe("daemon E2E", () => {
stdio: "pipe",
});
const agent = await withTimeout(
ctx.client.createAgent({
const agent = await withTimeout({
promise: ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
@@ -444,9 +569,9 @@ describe("daemon E2E", () => {
worktreeSlug: "async-setup-failure-test",
},
}),
2500,
"createAgent should not block on failing setup"
);
timeoutMs: 2500,
label: "createAgent should not block on failing setup",
});
expect(agent.cwd).toContain(path.join(".paseo", "worktrees"));
expect(existsSync(agent.cwd)).toBe(true);
@@ -469,11 +594,14 @@ describe("daemon E2E", () => {
);
expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true);
expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false);
const output = failed.detail.type === "unknown" ? failed.detail.output as any : undefined;
const commands = output?.commands as any[] | undefined;
expect(Array.isArray(commands)).toBe(true);
expect(commands?.[0]?.exitCode).toBe(7);
expect(failed.detail.type).toBe("worktree_setup");
if (failed.detail.type === "worktree_setup") {
expect(Array.isArray(failed.detail.commands)).toBe(true);
expect(failed.detail.commands[0]?.exitCode).toBe(7);
expect(failed.detail.log).toContain("Exit 7");
}
await ctx.client.deleteAgent(agent.id);
rmSync(repoRoot, { recursive: true, force: true });

View File

@@ -97,6 +97,42 @@ const shouldRun = !process.env.CI;
30000
);
test(
"emits terminals_changed for subscribed cwd when terminals are created",
async () => {
const cwd = tmpCwd();
await ctx.client.listTerminals(cwd);
const snapshots: Array<{ cwd: string; names: string[] }> = [];
const unsubscribe = ctx.client.on("terminals_changed", (message) => {
if (message.type !== "terminals_changed") {
return;
}
snapshots.push({
cwd: message.payload.cwd,
names: message.payload.terminals.map((terminal) => terminal.name),
});
});
ctx.client.subscribeTerminals({ cwd });
await ctx.client.createTerminal(cwd, "Dev Server");
await waitForCondition(
() =>
snapshots.some(
(snapshot) =>
snapshot.cwd === cwd && snapshot.names.includes("Dev Server")
),
10000
);
ctx.client.unsubscribeTerminals({ cwd });
unsubscribe();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"subscribes to terminal and receives state",
async () => {
@@ -317,6 +353,44 @@ const shouldRun = !process.env.CI;
30000
);
test(
"emits terminal_stream_exit and removes terminal when shell exits",
async () => {
const cwd = tmpCwd();
const list = await ctx.client.listTerminals(cwd);
const terminalId = list.terminals[0].id;
const attach = await ctx.client.attachTerminalStream(terminalId, { rows: 24, cols: 80 });
expect(attach.error).toBeNull();
const streamId = attach.streamId!;
let sawExit = false;
const unsubscribeExit = ctx.client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit") {
return;
}
if (
message.payload.terminalId === terminalId &&
message.payload.streamId === streamId
) {
sawExit = true;
}
});
ctx.client.sendTerminalStreamKey(streamId, { key: "d", ctrl: true });
await waitForCondition(() => sawExit, 10000);
const next = await ctx.client.listTerminals(cwd);
expect(next.terminals).toHaveLength(1);
expect(next.terminals[0].id).not.toBe(terminalId);
unsubscribeExit();
rmSync(cwd, { recursive: true, force: true });
},
30000
);
test(
"replays detached terminal output from resume offset (scrollback continuity)",
async () => {

View File

@@ -15,6 +15,8 @@ import {
type FileDownloadTokenRequest,
type GitSetupOptions,
type ListTerminalsRequest,
type SubscribeTerminalsRequest,
type UnsubscribeTerminalsRequest,
type CreateTerminalRequest,
type SubscribeTerminalRequest,
type UnsubscribeTerminalRequest,
@@ -27,7 +29,11 @@ import {
type ProjectCheckoutLitePayload,
type ProjectPlacementPayload,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type {
TerminalManager,
TerminalsChangedEvent,
} from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import {
BinaryMuxChannel,
TerminalBinaryFlags,
@@ -67,6 +73,10 @@ import type {
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import { toAgentPayload } from "./agent/agent-projections.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import { projectTimelineRows, type TimelineProjectionMode } from "./agent/timeline-projection.js";
import {
StructuredAgentResponseError,
@@ -82,7 +92,6 @@ import type {
AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
AgentTimelineItem,
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
@@ -100,18 +109,18 @@ import {
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
createWorktree,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
slugify,
validateBranchSlug,
listPaseoWorktrees,
deletePaseoWorktree,
isPaseoOwnedWorktreeCwd,
resolvePaseoWorktreeRootForCwd,
} from "../utils/worktree.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
getCheckoutStatus,
@@ -148,8 +157,6 @@ const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
let restartRequested = false;
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const RESTART_EXIT_DELAY_MS = 250;
const PROJECT_PLACEMENT_CACHE_TTL_MS = 10_000;
const MAX_AGENTS_PER_PROJECT = 5;
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
@@ -209,19 +216,22 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
return `remote:${cleanedHost}/${cleanedPath}`;
}
function deriveProjectGroupingKey(cwd: string, remoteUrl: string | null): string {
const remoteKey = deriveRemoteProjectKey(remoteUrl);
function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) {
return remoteKey;
}
const worktreeMarker = ".paseo/worktrees/";
const idx = cwd.indexOf(worktreeMarker);
const idx = options.cwd.indexOf(worktreeMarker);
if (idx !== -1) {
return cwd.slice(0, idx).replace(/\/$/, "");
return options.cwd.slice(0, idx).replace(/\/$/, "");
}
return cwd;
return options.cwd;
}
function deriveProjectGroupingName(projectKey: string): string {
@@ -280,6 +290,30 @@ type TerminalStreamPendingChunk = {
replay: boolean;
};
type FetchAgentsRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_agents_request" }
>;
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
type FetchAgentsResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
>["payload"];
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type FetchAgentsCursor = {
sort: FetchAgentsRequestSort[];
values: Record<string, string | number | null>;
id: string;
};
class SessionRequestError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = "SessionRequestError";
}
}
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
@@ -524,10 +558,6 @@ export class Session {
filter?: { labels?: Record<string, string>; agentId?: string };
}
| null = null;
private readonly projectPlacementCache = new Map<
string,
{ expiresAt: number; promise: Promise<ProjectPlacementPayload> }
>();
private clientActivity: {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
@@ -537,7 +567,10 @@ export class Session {
} | null = null;
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
private readonly terminalManager: TerminalManager | null;
private readonly subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalSubscriptions: Map<string, () => void> = new Map();
private terminalExitSubscriptions: Map<string, () => void> = new Map();
private readonly terminalStreams = new Map<
number,
{
@@ -604,6 +637,11 @@ export class Session {
this.agentStorage = agentStorage;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
if (this.terminalManager) {
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged(
(event) => this.handleTerminalsChanged(event)
);
}
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim();
this.localSpeechModelsDir =
@@ -1103,7 +1141,10 @@ export class Session {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
.then((status) => this.toProjectCheckoutLite(cwd, status))
.catch(() => this.buildFallbackProjectCheckout(cwd));
const projectKey = deriveProjectGroupingKey(cwd, checkout.remoteUrl);
const projectKey = deriveProjectGroupingKey({
cwd,
remoteUrl: checkout.remoteUrl,
});
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
@@ -1111,21 +1152,6 @@ export class Session {
};
}
private getProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
const now = Date.now();
const cached = this.projectPlacementCache.get(cwd);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
const promise = this.buildProjectPlacement(cwd);
this.projectPlacementCache.set(cwd, {
expiresAt: now + PROJECT_PLACEMENT_CACHE_TTL_MS,
promise,
});
return promise;
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
try {
const subscription = this.agentUpdatesSubscription;
@@ -1137,7 +1163,7 @@ export class Session {
const matches = this.matchesAgentFilter(payload, subscription.filter);
if (matches) {
const project = await this.getProjectPlacement(payload.cwd);
const project = await this.buildProjectPlacement(payload.cwd);
this.emit({
type: "agent_update",
payload: { kind: "upsert", agent: payload, project },
@@ -1173,11 +1199,7 @@ export class Session {
break;
case "fetch_agents_request":
await this.handleFetchAgents(msg.requestId, msg.filter);
break;
case "fetch_agents_grouped_by_project_request":
await this.handleFetchAgentsGroupedByProject(msg.requestId, msg.filter);
await this.handleFetchAgents(msg);
break;
case "fetch_agent_request":
@@ -1432,6 +1454,14 @@ export class Session {
this.handleRegisterPushToken(msg.token);
break;
case "subscribe_terminals_request":
this.handleSubscribeTerminalsRequest(msg);
break;
case "unsubscribe_terminals_request":
this.handleUnsubscribeTerminalsRequest(msg);
break;
case "list_terminals_request":
await this.handleListTerminalsRequest(msg);
break;
@@ -2304,7 +2334,10 @@ export class Session {
const prompt = this.buildAgentPrompt(text, images);
try {
this.agentManager.recordUserMessage(agentId, text, { messageId });
this.agentManager.recordUserMessage(agentId, text, {
messageId,
emitState: false,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
@@ -2397,7 +2430,24 @@ export class Session {
}
if (worktreeConfig) {
void this.runAsyncWorktreeSetup(snapshot.id, worktreeConfig);
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeConfig,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
this.sessionLogger.info(
@@ -2626,12 +2676,11 @@ export class Session {
}' for branch ${targetBranch}`
);
const createdWorktree = await createWorktree({
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch: normalized.baseBranch!,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
runSetup: false,
paseoHome: this.paseoHome,
});
cwd = createdWorktree.worktreePath;
@@ -2655,108 +2704,6 @@ export class Session {
};
}
private async runAsyncWorktreeSetup(
agentId: string,
worktree: WorktreeConfig
): Promise<void> {
const callId = uuidv4();
let results: WorktreeSetupCommandResult[] = [];
try {
const started = await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "running",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: null,
},
error: null,
});
if (!started) {
return;
}
results = await runWorktreeSetupCommands({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
cleanupOnFailure: false,
});
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "completed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: null,
});
} catch (error: any) {
if (error instanceof WorktreeSetupError) {
results = error.results;
}
const message = error instanceof Error ? error.message : String(error);
await this.safeAppendTimelineItem(agentId, {
type: "tool_call",
name: "paseo_worktree_setup",
callId,
status: "failed",
detail: {
type: "unknown",
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
cwd: result.cwd,
exitCode: result.exitCode,
output: `${result.stdout ?? ""}${result.stderr ? `\n${result.stderr}` : ""}`.trim(),
})),
},
},
error: { message },
});
}
}
private async safeAppendTimelineItem(
agentId: string,
item: AgentTimelineItem
): Promise<boolean> {
try {
await this.agentManager.appendTimelineItem(agentId, item);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Unknown agent")) {
return false;
}
throw error;
}
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
): Promise<void> {
@@ -4876,102 +4823,320 @@ export class Session {
return this.buildStoredAgentPayload(record);
}
private async handleFetchAgents(
requestId: string,
filter?: { labels?: Record<string, string> }
): Promise<void> {
try {
const agents = await this.listAgentPayloads(filter);
this.emit({
type: "fetch_agents_response",
payload: { requestId, agents },
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request");
this.emit({
type: "fetch_agents_response",
payload: { requestId, agents: [] },
});
}
}
private async listAgentsGroupedByProjectPayload(filter?: {
labels?: Record<string, string>;
}): Promise<Array<{
projectKey: string;
projectName: string;
agents: Array<{
agent: AgentSnapshotPayload;
checkout: ProjectCheckoutLitePayload;
}>;
}>> {
const agents = await this.listAgentPayloads(filter);
const visibleAgents = agents
.filter((agent) => !agent.archivedAt)
.sort(
(left, right) =>
Date.parse(right.updatedAt || "") - Date.parse(left.updatedAt || "")
);
const grouped = new Map<
string,
{
projectKey: string;
projectName: string;
agents: Array<{
agent: AgentSnapshotPayload;
checkout: ProjectCheckoutLitePayload;
}>;
}
>();
// Warm project placement status for all visible roots up front to avoid serial N+1 latency.
for (const agent of visibleAgents) {
void this.getProjectPlacement(agent.cwd);
private normalizeFetchAgentsSort(
sort: FetchAgentsRequestSort[] | undefined
): FetchAgentsRequestSort[] {
const fallback: FetchAgentsRequestSort[] = [
{ key: "updated_at", direction: "desc" },
];
if (!sort || sort.length === 0) {
return fallback;
}
for (const agent of visibleAgents) {
const project = await this.getProjectPlacement(agent.cwd);
const projectKey = project.projectKey;
let group = grouped.get(projectKey);
if (!group) {
group = {
projectKey,
projectName: project.projectName,
agents: [],
};
grouped.set(projectKey, group);
}
if (group.agents.length >= MAX_AGENTS_PER_PROJECT) {
const deduped: FetchAgentsRequestSort[] = [];
const seen = new Set<string>();
for (const entry of sort) {
if (seen.has(entry.key)) {
continue;
}
group.agents.push({ agent, checkout: project.checkout });
seen.add(entry.key);
deduped.push(entry);
}
return Array.from(grouped.values());
return deduped.length > 0 ? deduped : fallback;
}
private async handleFetchAgentsGroupedByProject(
requestId: string,
filter?: { labels?: Record<string, string> }
private getStatusPriority(agent: AgentSnapshotPayload): number {
const requiresAttention = agent.requiresAttention ?? false;
const attentionReason = agent.attentionReason ?? null;
if (requiresAttention && attentionReason === "permission") {
return 0;
}
if (agent.status === "error" || attentionReason === "error") {
return 1;
}
if (agent.status === "running") {
return 2;
}
if (agent.status === "initializing") {
return 3;
}
return 4;
}
private getFetchAgentsSortValue(
entry: FetchAgentsResponseEntry,
key: FetchAgentsRequestSort["key"]
): string | number | null {
switch (key) {
case "status_priority":
return this.getStatusPriority(entry.agent);
case "created_at":
return Date.parse(entry.agent.createdAt);
case "updated_at":
return Date.parse(entry.agent.updatedAt);
case "title":
return entry.agent.title?.toLocaleLowerCase() ?? "";
}
}
private compareSortValues(
left: string | number | null,
right: string | number | null
): number {
if (left === right) {
return 0;
}
if (left === null) {
return -1;
}
if (right === null) {
return 1;
}
if (typeof left === "number" && typeof right === "number") {
return left < right ? -1 : 1;
}
return String(left).localeCompare(String(right));
}
private compareFetchAgentsEntries(
left: FetchAgentsResponseEntry,
right: FetchAgentsResponseEntry,
sort: FetchAgentsRequestSort[]
): number {
for (const spec of sort) {
const leftValue = this.getFetchAgentsSortValue(left, spec.key);
const rightValue = this.getFetchAgentsSortValue(right, spec.key);
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return left.agent.id.localeCompare(right.agent.id);
}
private encodeFetchAgentsCursor(
entry: FetchAgentsResponseEntry,
sort: FetchAgentsRequestSort[]
): string {
const values: Record<string, string | number | null> = {};
for (const spec of sort) {
values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key);
}
return Buffer.from(
JSON.stringify({
sort,
values,
id: entry.agent.id,
}),
"utf8"
).toString("base64url");
}
private decodeFetchAgentsCursor(
cursor: string,
sort: FetchAgentsRequestSort[]
): FetchAgentsCursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
} catch {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
if (!parsed || typeof parsed !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const payload = parsed as {
sort?: unknown;
values?: unknown;
id?: unknown;
};
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
if (!payload.values || typeof payload.values !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const cursorSort: FetchAgentsRequestSort[] = [];
for (const item of payload.sort) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { key?: unknown }).key !== "string" ||
typeof (item as { direction?: unknown }).direction !== "string"
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const key = (item as { key: string }).key;
const direction = (item as { direction: string }).direction;
if (
(key !== "status_priority" &&
key !== "created_at" &&
key !== "updated_at" &&
key !== "title") ||
(direction !== "asc" && direction !== "desc")
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
cursorSort.push({ key, direction });
}
if (
cursorSort.length !== sort.length ||
cursorSort.some(
(entry, index) =>
entry.key !== sort[index]?.key ||
entry.direction !== sort[index]?.direction
)
) {
throw new SessionRequestError(
"invalid_cursor",
"fetch_agents cursor does not match current sort"
);
}
return {
sort: cursorSort,
values: payload.values as Record<string, string | number | null>,
id: payload.id,
};
}
private compareEntryWithCursor(
entry: FetchAgentsResponseEntry,
cursor: FetchAgentsCursor,
sort: FetchAgentsRequestSort[]
): number {
for (const spec of sort) {
const leftValue = this.getFetchAgentsSortValue(entry, spec.key);
const rightValue =
cursor.values[spec.key] !== undefined ? cursor.values[spec.key] ?? null : null;
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return entry.agent.id.localeCompare(cursor.id);
}
private async listFetchAgentsEntries(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>
): Promise<{
entries: FetchAgentsResponseEntry[];
pageInfo: FetchAgentsResponsePageInfo;
}> {
const filter = request.filter;
const sort = this.normalizeFetchAgentsSort(request.sort);
const includeArchived = filter?.includeArchived ?? false;
let agents = await this.listAgentPayloads({
labels: filter?.labels,
});
if (!includeArchived) {
agents = agents.filter((agent) => !agent.archivedAt);
}
if (filter?.statuses && filter.statuses.length > 0) {
const statuses = new Set(filter.statuses);
agents = agents.filter((agent) => statuses.has(agent.status));
}
if (typeof filter?.requiresAttention === "boolean") {
agents = agents.filter(
(agent) =>
(agent.requiresAttention ?? false) === filter.requiresAttention
);
}
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload> => {
const existing = placementByCwd.get(cwd);
if (existing) {
return existing;
}
const placementPromise = this.buildProjectPlacement(cwd);
placementByCwd.set(cwd, placementPromise);
return placementPromise;
};
let entries = await Promise.all(
agents.map(async (agent) => ({
agent,
project: await getPlacement(agent.cwd),
}))
);
if (filter?.projectKeys && filter.projectKeys.length > 0) {
const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0));
entries = entries.filter((entry) => projectKeys.has(entry.project.projectKey));
}
entries.sort((left, right) =>
this.compareFetchAgentsEntries(left, right, sort)
);
const cursorToken = request.page?.cursor;
if (cursorToken) {
const cursor = this.decodeFetchAgentsCursor(cursorToken, sort);
entries = entries.filter(
(entry) => this.compareEntryWithCursor(entry, cursor, sort) > 0
);
}
const limit = request.page?.limit ?? entries.length;
const pagedEntries = entries.slice(0, limit);
const hasMore = entries.length > limit;
const nextCursor =
hasMore && pagedEntries.length > 0
? this.encodeFetchAgentsCursor(
pagedEntries[pagedEntries.length - 1],
sort
)
: null;
return {
entries: pagedEntries,
pageInfo: {
nextCursor,
prevCursor: request.page?.cursor ?? null,
hasMore,
},
};
}
private async handleFetchAgents(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>
): Promise<void> {
try {
const groups = await this.listAgentsGroupedByProjectPayload(filter);
const payload = await this.listFetchAgentsEntries(request);
this.emit({
type: "fetch_agents_grouped_by_project_response",
payload: { requestId, groups },
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
...payload,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error },
"Failed to handle fetch_agents_grouped_by_project_request"
);
const code =
error instanceof SessionRequestError ? error.code : "fetch_agents_failed";
const message =
error instanceof Error ? error.message : "Failed to fetch agents";
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request");
this.emit({
type: "fetch_agents_grouped_by_project_response",
payload: { requestId, groups: [] },
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
@@ -5111,7 +5276,10 @@ export class Session {
await this.interruptAgentIfRunning(agentId);
try {
this.agentManager.recordUserMessage(agentId, msg.text, { messageId: msg.messageId });
this.agentManager.recordUserMessage(agentId, msg.text, {
messageId: msg.messageId,
emitState: false,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
@@ -5958,10 +6126,20 @@ export class Session {
this.isVoiceMode = false;
// Unsubscribe from all terminals
if (this.unsubscribeTerminalsChanged) {
this.unsubscribeTerminalsChanged();
this.unsubscribeTerminalsChanged = null;
}
this.subscribedTerminalDirectories.clear();
for (const unsubscribe of this.terminalSubscriptions.values()) {
unsubscribe();
}
this.terminalSubscriptions.clear();
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
unsubscribeExit();
}
this.terminalExitSubscriptions.clear();
this.detachAllTerminalStreams({ emitExit: false });
for (const target of this.checkoutDiffTargets.values()) {
@@ -5975,6 +6153,118 @@ export class Session {
// Terminal Handlers
// ============================================================================
private ensureTerminalExitSubscription(terminal: TerminalSession): void {
if (this.terminalExitSubscriptions.has(terminal.id)) {
return;
}
const unsubscribeExit = terminal.onExit(() => {
this.handleTerminalExited(terminal.id);
});
this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit);
}
private handleTerminalExited(terminalId: string): void {
const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId);
if (unsubscribeExit) {
unsubscribeExit();
this.terminalExitSubscriptions.delete(terminalId);
}
const unsubscribe = this.terminalSubscriptions.get(terminalId);
if (unsubscribe) {
try {
unsubscribe();
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId },
"Failed to unsubscribe terminal after process exit"
);
}
this.terminalSubscriptions.delete(terminalId);
}
const streamId = this.terminalStreamByTerminalId.get(terminalId);
if (typeof streamId === "number") {
this.detachTerminalStream(streamId, { emitExit: true });
}
}
private emitTerminalsChangedSnapshot(input: {
cwd: string;
terminals: Array<{ id: string; name: string }>;
}): void {
this.emit({
type: "terminals_changed",
payload: {
cwd: input.cwd,
terminals: input.terminals,
},
});
}
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
if (!this.subscribedTerminalDirectories.has(event.cwd)) {
return;
}
this.emitTerminalsChangedSnapshot({
cwd: event.cwd,
terminals: event.terminals.map((terminal) => ({
id: terminal.id,
name: terminal.name,
})),
});
}
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
this.subscribedTerminalDirectories.add(msg.cwd);
void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
}
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
this.subscribedTerminalDirectories.delete(msg.cwd);
}
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> {
if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) {
return;
}
const hadDirectoryBeforeSubscribe = this.terminalManager
.listDirectories()
.includes(cwd);
try {
const terminals = await this.terminalManager.getTerminals(cwd);
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal);
}
// New directories auto-create Terminal 1, which already emits through
// terminal-manager change listeners.
if (!hadDirectoryBeforeSubscribe) {
return;
}
if (!this.subscribedTerminalDirectories.has(cwd)) {
return;
}
this.emitTerminalsChangedSnapshot({
cwd,
terminals: terminals.map((terminal) => ({
id: terminal.id,
name: terminal.name,
})),
});
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to emit initial terminal snapshot"
);
}
}
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
@@ -5990,6 +6280,9 @@ export class Session {
try {
const terminals = await this.terminalManager.getTerminals(msg.cwd);
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal);
}
this.emit({
type: "list_terminals_response",
payload: {
@@ -6029,6 +6322,7 @@ export class Session {
cwd: msg.cwd,
name: msg.name,
});
this.ensureTerminalExitSubscription(session);
this.emit({
type: "create_terminal_response",
payload: {
@@ -6077,6 +6371,7 @@ export class Session {
});
return;
}
this.ensureTerminalExitSubscription(session);
// Unsubscribe from previous subscription if any
const existing = this.terminalSubscriptions.get(msg.terminalId);
@@ -6128,6 +6423,7 @@ export class Session {
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
return;
}
this.ensureTerminalExitSubscription(session);
session.send(msg.message);
}

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
} from "./worktree-bootstrap.js";
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
let repoDir: string;
let paseoHome: string;
beforeEach(() => {
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-bootstrap-test-")));
repoDir = join(tempDir, "repo");
paseoHome = join(tempDir, "paseo-home");
execSync(`mkdir -p ${repoDir}`);
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
execSync("echo 'hello' > file.txt", { cwd: repoDir, stdio: "pipe" });
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("streams running setup updates live and persists only a final setup timeline row", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "line-one"; echo "line-two" 1>&2', 'echo "line-three"'],
},
})
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktree = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
worktreeSlug: "feature-streaming-setup",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
const live: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
worktree,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async (item: AgentTimelineItem) => {
live.push(item);
return true;
},
});
const liveSetupItems = live.filter(
(item) =>
item.type === "tool_call" &&
item.name === "paseo_worktree_setup" &&
item.status === "running"
);
expect(liveSetupItems.length).toBeGreaterThan(0);
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup"
);
expect(persistedSetupItems).toHaveLength(1);
expect(persistedSetupItems[0]?.type).toBe("tool_call");
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
expect(persistedSetupItems[0].detail.type).toBe("worktree_setup");
if (persistedSetupItems[0].detail.type === "worktree_setup") {
expect(persistedSetupItems[0].detail.log).toContain(
"==> [1/2] Running: echo \"line-one\"; echo \"line-two\" 1>&2"
);
expect(persistedSetupItems[0].detail.log).toContain("line-one");
expect(persistedSetupItems[0].detail.log).toContain("line-two");
expect(persistedSetupItems[0].detail.log).toContain(
"==> [2/2] Running: echo \"line-three\""
);
expect(persistedSetupItems[0].detail.log).toContain("line-three");
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[1\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.log).toMatch(/<== \[2\/2\] Exit 0 in \d+\.\d{2}s/);
expect(persistedSetupItems[0].detail.commands).toHaveLength(2);
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2',
status: "completed",
exitCode: 0,
});
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2,
command: 'echo "line-three"',
status: "completed",
exitCode: 0,
});
expect(
typeof persistedSetupItems[0].detail.commands[0]?.durationMs === "number"
).toBe(true);
expect(
typeof persistedSetupItems[0].detail.commands[1]?.durationMs === "number"
).toBe(true);
}
}
const liveCallIds = new Set(
liveSetupItems
.filter((item): item is Extract<AgentTimelineItem, { type: "tool_call" }> => item.type === "tool_call")
.map((item) => item.callId)
);
expect(liveCallIds.size).toBe(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(liveCallIds.has(persistedSetupItems[0].callId)).toBe(true);
}
});
it("does not fail setup when live timeline emission throws", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: ['echo "ok"'],
},
})
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktree = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-live-failure",
baseBranch: "main",
worktreeSlug: "feature-live-failure",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await expect(
runAsyncWorktreeBootstrap({
agentId: "agent-live-failure",
worktree,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => {
throw new Error("live emit failed");
},
})
).resolves.toBeUndefined();
const persistedSetupItems = persisted.filter(
(item) => item.type === "tool_call" && item.name === "paseo_worktree_setup"
);
expect(persistedSetupItems).toHaveLength(1);
if (persistedSetupItems[0]?.type === "tool_call") {
expect(persistedSetupItems[0].status).toBe("completed");
}
});
it("truncates each command output to 64kb in the middle", async () => {
const largeOutputCommand =
"node -e \"process.stdout.write('prefix-'); process.stdout.write('x'.repeat(70000)); process.stdout.write('-suffix')\"";
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [largeOutputCommand],
},
})
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add large output setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktree = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-large-output",
baseBranch: "main",
worktreeSlug: "feature-large-output",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-large-output",
worktree,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup"
);
expect(persistedSetupItem).toBeDefined();
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.truncated).toBe(true);
expect(persistedSetupItem.detail.log).toContain("prefix-");
expect(persistedSetupItem.detail.log).toContain("-suffix");
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
});
});

View File

@@ -0,0 +1,537 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
createWorktree,
getWorktreeTerminalSpecs,
runWorktreeSetupCommands,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
} from "../utils/worktree.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
command: string;
status: "started" | "failed";
terminalId: string | null;
error: string | null;
}
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
worktree: WorktreeConfig;
terminalManager: TerminalManager | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
}
export interface CreateAgentWorktreeOptions {
cwd: string;
branchName: string;
baseBranch: string;
worktreeSlug: string;
paseoHome?: string;
}
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
type MiddleTruncationAccumulator = {
totalBytes: number;
head: string;
tail: string;
truncated: boolean;
};
function byteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
function sliceFirstBytes(text: string, maxBytes: number): string {
if (maxBytes <= 0 || text.length === 0) {
return "";
}
const bytes = Buffer.from(text, "utf8");
if (bytes.length <= maxBytes) {
return text;
}
return bytes.subarray(0, maxBytes).toString("utf8");
}
function sliceLastBytes(text: string, maxBytes: number): string {
if (maxBytes <= 0 || text.length === 0) {
return "";
}
const bytes = Buffer.from(text, "utf8");
if (bytes.length <= maxBytes) {
return text;
}
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
}
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
return {
totalBytes: 0,
head: "",
tail: "",
truncated: false,
};
}
function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: number } {
const markerBytes = byteLength(WORKTREE_SETUP_TRUNCATION_MARKER);
const availableBytes = Math.max(0, maxBytes - markerBytes);
const headBytes = Math.floor(availableBytes / 2);
const tailBytes = availableBytes - headBytes;
return { headBytes, tailBytes };
}
function appendToMiddleTruncationAccumulator(
accumulator: MiddleTruncationAccumulator,
chunk: string
): void {
if (!chunk) {
return;
}
accumulator.totalBytes += byteLength(chunk);
if (!accumulator.truncated) {
const combined = `${accumulator.head}${chunk}`;
if (byteLength(combined) <= MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES) {
accumulator.head = combined;
return;
}
const { headBytes, tailBytes } = getHeadTailBudgets(
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES
);
accumulator.head = sliceFirstBytes(combined, headBytes);
accumulator.tail = sliceLastBytes(combined, tailBytes);
accumulator.truncated = true;
return;
}
const { tailBytes } = getHeadTailBudgets(MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES);
accumulator.tail = sliceLastBytes(`${accumulator.tail}${chunk}`, tailBytes);
}
function truncateTextInMiddle(
text: string,
maxBytes: number
): { text: string; truncated: boolean } {
if (maxBytes <= 0 || !text) {
return { text: "", truncated: text.length > 0 };
}
if (byteLength(text) <= maxBytes) {
return { text, truncated: false };
}
const { headBytes, tailBytes } = getHeadTailBudgets(maxBytes);
return {
text: `${sliceFirstBytes(text, headBytes)}${WORKTREE_SETUP_TRUNCATION_MARKER}${sliceLastBytes(text, tailBytes)}`,
truncated: true,
};
}
function renderMiddleTruncationAccumulator(
accumulator: MiddleTruncationAccumulator
): { text: string; truncated: boolean } {
if (!accumulator.truncated) {
return { text: accumulator.head, truncated: false };
}
return {
text: `${accumulator.head}${WORKTREE_SETUP_TRUNCATION_MARKER}${accumulator.tail}`,
truncated: true,
};
}
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions
): Promise<WorktreeConfig> {
return createWorktree({
branchName: options.branchName,
cwd: options.cwd,
baseBranch: options.baseBranch,
worktreeSlug: options.worktreeSlug,
runSetup: false,
paseoHome: options.paseoHome,
});
}
function formatDurationMs(durationMs: number): string {
return `${(durationMs / 1000).toFixed(2)}s`;
}
function commandStatusFromResult(
result: WorktreeSetupCommandResult
): "running" | "completed" | "failed" {
if (result.exitCode === null) {
return "running";
}
return result.exitCode === 0 ? "completed" : "failed";
}
function buildWorktreeSetupLog(input: {
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
}): { log: string; truncated: boolean } {
const { results, outputAccumulatorsByIndex } = input;
if (results.length === 0) {
return {
log: "",
truncated: false,
};
}
const lines: string[] = [];
let anyTruncated = false;
const total = results.length;
for (const [index, result] of results.entries()) {
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
const accumulator = outputAccumulatorsByIndex?.get(index + 1);
const output = accumulator
? renderMiddleTruncationAccumulator(accumulator)
: truncateTextInMiddle(
`${result.stdout ?? ""}${result.stderr ?? ""}`,
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES
);
if (output.text.length > 0) {
lines.push(output.text.replace(/\n$/, ""));
}
if (output.truncated) {
anyTruncated = true;
}
if (result.exitCode !== null) {
lines.push(
`<== [${index + 1}/${total}] Exit ${result.exitCode} in ${formatDurationMs(result.durationMs)}`
);
}
}
return {
log: lines.join("\n"),
truncated: anyTruncated,
};
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
errorMessage: string | null;
}): AgentTimelineItem {
const commands = input.results.map((result, index) => ({
index: index + 1,
command: result.command,
cwd: result.cwd,
status: commandStatusFromResult(result),
exitCode: result.exitCode,
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
}));
const renderedLog = buildWorktreeSetupLog({
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
const detail = {
type: "worktree_setup" as const,
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
commands,
...(renderedLog.truncated ? { truncated: true } : {}),
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "running",
detail,
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "completed",
detail,
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_setup",
callId: input.callId,
status: "failed",
detail,
error: { message: input.errorMessage ?? "Worktree setup failed" },
};
}
function buildTerminalTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeBootstrapTerminalResult[];
errorMessage: string | null;
}): AgentTimelineItem {
const detailInput = {
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
};
const detailOutput = {
worktreePath: input.worktree.worktreePath,
terminals: input.results,
};
if (input.status === "running") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "running",
detail: {
type: "unknown",
input: detailInput,
output: null,
},
error: null,
};
}
if (input.status === "completed") {
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "completed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: null,
};
}
return {
type: "tool_call",
name: "paseo_worktree_terminals",
callId: input.callId,
status: "failed",
detail: {
type: "unknown",
input: detailInput,
output: detailOutput,
},
error: { message: input.errorMessage ?? "Worktree terminal bootstrap failed" },
};
}
async function runWorktreeTerminalBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const terminalSpecs = getWorktreeTerminalSpecs(options.worktree.worktreePath);
if (terminalSpecs.length === 0) {
return;
}
const callId = uuidv4();
const started = await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "running",
worktree: options.worktree,
results: [],
errorMessage: null,
})
);
if (!started) {
return;
}
if (!options.terminalManager) {
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "failed",
worktree: options.worktree,
results: [],
errorMessage: "Terminal manager not available",
})
);
return;
}
const results: WorktreeBootstrapTerminalResult[] = [];
for (const spec of terminalSpecs) {
try {
const terminal = await options.terminalManager.createTerminal({
cwd: options.worktree.worktreePath,
name: spec.name,
});
terminal.send({
type: "input",
data: `${spec.command}\r`,
});
results.push({
name: terminal.name ?? spec.name ?? null,
command: spec.command,
status: "started",
terminalId: terminal.id,
error: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.logger?.warn(
{ agentId: options.agentId, command: spec.command, err: error },
"Failed to bootstrap worktree terminal"
);
results.push({
name: spec.name ?? null,
command: spec.command,
status: "failed",
terminalId: null,
error: message,
});
}
}
await options.appendTimelineItem(
buildTerminalTimelineItem({
callId,
status: "completed",
worktree: options.worktree,
results,
errorMessage: null,
})
);
}
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions
): Promise<void> {
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
let liveEmitQueue = Promise.resolve();
const queueLiveRunningEmit = () => {
if (!emitLiveTimelineItem) {
return;
}
const runningResults = Array.from(runningResultsByIndex.entries())
.sort((a, b) => a[0] - b[0])
.map(([, result]) => result);
liveEmitQueue = liveEmitQueue.then(async () => {
try {
await emitLiveTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "running",
worktree: options.worktree,
results: runningResults,
outputAccumulatorsByIndex,
errorMessage: null,
})
);
} catch (error) {
options.logger?.warn(
{ err: error, agentId: options.agentId },
"Failed to emit live worktree setup timeline update"
);
}
});
};
try {
setupResults = await runWorktreeSetupCommands({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
cleanupOnFailure: false,
onEvent: (event) => {
const existing = runningResultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
command: event.command,
cwd: event.cwd,
stdout: "",
stderr: "",
exitCode: null,
durationMs: 0,
};
if (event.type === "output") {
const outputAccumulator =
outputAccumulatorsByIndex.get(event.index) ??
createMiddleTruncationAccumulator();
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
runningResultsByIndex.set(event.index, {
...baseResult,
// Keep the timeline command model lightweight; output is carried in
// outputAccumulatorsByIndex.
stdout: baseResult.stdout,
stderr: baseResult.stderr,
});
queueLiveRunningEmit();
return;
}
if (event.type === "command_completed") {
runningResultsByIndex.set(event.index, {
...baseResult,
stdout: event.stdout,
stderr: event.stderr,
exitCode: event.exitCode,
durationMs: event.durationMs,
});
queueLiveRunningEmit();
return;
}
runningResultsByIndex.set(event.index, baseResult);
queueLiveRunningEmit();
},
});
await liveEmitQueue;
const completed = await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "completed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
errorMessage: null,
})
);
if (!completed) {
return;
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
await liveEmitQueue;
const message = error instanceof Error ? error.message : String(error);
await options.appendTimelineItem(
buildSetupTimelineItem({
callId: setupCallId,
status: "failed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
errorMessage: message,
})
);
return;
}
await runWorktreeTerminalBootstrap(options);
}

View File

@@ -3,6 +3,7 @@ import {
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "./binary-mux.js";
@@ -40,4 +41,10 @@ describe("binary mux frame codec", () => {
const tampered = encoded.slice(0, encoded.byteLength - 1);
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
});
it("converts UTF-8 string payloads to bytes", () => {
const bytes = asUint8Array("hello");
expect(bytes).not.toBeNull();
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
});
});

View File

@@ -37,6 +37,19 @@ export interface BinaryMuxFrame {
}
export function asUint8Array(data: unknown): Uint8Array | null {
if (typeof data === "string") {
if (typeof TextEncoder !== "undefined") {
return new TextEncoder().encode(data);
}
if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(data, "utf8"));
}
const out = new Uint8Array(data.length);
for (let i = 0; i < data.length; i += 1) {
out[i] = data.charCodeAt(i) & 0xff;
}
return out;
}
if (data instanceof Uint8Array) {
return data;
}

View File

@@ -188,6 +188,23 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
type: z.literal("search"),
query: z.string(),
}),
z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
})
),
truncated: z.boolean().optional(),
}),
z.object({
type: z.literal("unknown"),
input: UnknownValueSchema,
@@ -458,16 +475,24 @@ export const FetchAgentsRequestMessageSchema = z.object({
filter: z
.object({
labels: z.record(z.string()).optional(),
projectKeys: z.array(z.string()).optional(),
statuses: z.array(AgentStatusSchema).optional(),
includeArchived: z.boolean().optional(),
requiresAttention: z.boolean().optional(),
})
.optional(),
});
export const FetchAgentsGroupedByProjectRequestMessageSchema = z.object({
type: z.literal("fetch_agents_grouped_by_project_request"),
requestId: z.string(),
filter: z
sort: z
.array(
z.object({
key: z.enum(["status_priority", "created_at", "updated_at", "title"]),
direction: z.enum(["asc", "desc"]),
})
)
.optional(),
page: z
.object({
labels: z.record(z.string()).optional(),
limit: z.number().int().positive().max(1000),
cursor: z.string().min(1).optional(),
})
.optional(),
});
@@ -940,6 +965,16 @@ export const ListTerminalsRequestSchema = z.object({
requestId: z.string(),
});
export const SubscribeTerminalsRequestSchema = z.object({
type: z.literal("subscribe_terminals_request"),
cwd: z.string(),
});
export const UnsubscribeTerminalsRequestSchema = z.object({
type: z.literal("unsubscribe_terminals_request"),
cwd: z.string(),
});
export const CreateTerminalRequestSchema = z.object({
type: z.literal("create_terminal_request"),
cwd: z.string(),
@@ -1002,7 +1037,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
AbortRequestMessageSchema,
AudioPlayedMessageSchema,
FetchAgentsRequestMessageSchema,
FetchAgentsGroupedByProjectRequestMessageSchema,
FetchAgentRequestMessageSchema,
SubscribeAgentUpdatesMessageSchema,
UnsubscribeAgentUpdatesMessageSchema,
@@ -1053,6 +1087,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ExecuteCommandRequestSchema,
RegisterPushTokenMessageSchema,
ListTerminalsRequestSchema,
SubscribeTerminalsRequestSchema,
UnsubscribeTerminalsRequestSchema,
CreateTerminalRequestSchema,
SubscribeTerminalRequestSchema,
UnsubscribeTerminalRequestSchema,
@@ -1399,26 +1435,17 @@ export const FetchAgentsResponseMessageSchema = z.object({
type: z.literal("fetch_agents_response"),
payload: z.object({
requestId: z.string(),
agents: z.array(AgentSnapshotPayloadSchema),
}),
});
const ProjectGroupedAgentEntryPayloadSchema = z.object({
agent: AgentSnapshotPayloadSchema,
checkout: ProjectCheckoutLitePayloadSchema,
});
const ProjectGroupPayloadSchema = z.object({
projectKey: z.string(),
projectName: z.string(),
agents: z.array(ProjectGroupedAgentEntryPayloadSchema),
});
export const FetchAgentsGroupedByProjectResponseMessageSchema = z.object({
type: z.literal("fetch_agents_grouped_by_project_response"),
payload: z.object({
requestId: z.string(),
groups: z.array(ProjectGroupPayloadSchema),
entries: z.array(
z.object({
agent: AgentSnapshotPayloadSchema,
project: ProjectPlacementPayloadSchema,
})
),
pageInfo: z.object({
nextCursor: z.string().nullable(),
prevCursor: z.string().nullable(),
hasMore: z.boolean(),
}),
}),
});
@@ -1887,6 +1914,14 @@ export const ListTerminalsResponseSchema = z.object({
}),
});
export const TerminalsChangedSchema = z.object({
type: z.literal("terminals_changed"),
payload: z.object({
cwd: z.string(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
}),
});
export const CreateTerminalResponseSchema = z.object({
type: z.literal("create_terminal_response"),
payload: z.object({
@@ -1972,7 +2007,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentStreamMessageSchema,
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
FetchAgentsGroupedByProjectResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
@@ -2009,6 +2043,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListCommandsResponseSchema,
ExecuteCommandResponseSchema,
ListTerminalsResponseSchema,
TerminalsChangedSchema,
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
TerminalOutputSchema,
@@ -2042,9 +2077,6 @@ export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSche
export type FetchAgentsResponseMessage = z.infer<
typeof FetchAgentsResponseMessageSchema
>;
export type FetchAgentsGroupedByProjectResponseMessage = z.infer<
typeof FetchAgentsGroupedByProjectResponseMessageSchema
>;
export type FetchAgentResponseMessage = z.infer<
typeof FetchAgentResponseMessageSchema
>;
@@ -2079,9 +2111,6 @@ export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
// Type exports for inbound message types
export type VoiceAudioChunkMessage = z.infer<typeof VoiceAudioChunkMessageSchema>;
export type FetchAgentsRequestMessage = z.infer<typeof FetchAgentsRequestMessageSchema>;
export type FetchAgentsGroupedByProjectRequestMessage = z.infer<
typeof FetchAgentsGroupedByProjectRequestMessageSchema
>;
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
export type WaitForFinishRequest = z.infer<typeof WaitForFinishRequestSchema>;
@@ -2158,6 +2187,9 @@ export type RegisterPushTokenMessage = z.infer<typeof RegisterPushTokenMessageSc
// Terminal message types
export type ListTerminalsRequest = z.infer<typeof ListTerminalsRequestSchema>;
export type ListTerminalsResponse = z.infer<typeof ListTerminalsResponseSchema>;
export type SubscribeTerminalsRequest = z.infer<typeof SubscribeTerminalsRequestSchema>;
export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsRequestSchema>;
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;

View File

@@ -59,6 +59,34 @@ describe("shared tool-call display mapping", () => {
});
});
it("builds display model for worktree setup detail", () => {
const display = buildToolCallDisplayModel({
name: "paseo_worktree_setup",
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
branchName: "feature-branch",
log: "==> [1/1] Running: npm install\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
status: "running",
exitCode: null,
},
],
},
});
expect(display).toEqual({
displayName: "Worktree Setup",
summary: "feature-branch",
});
});
it("provides errorText for failed calls", () => {
const display = buildToolCallDisplayModel({
name: "shell",

View File

@@ -83,6 +83,10 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
displayName = "Search";
summary = input.detail.query;
break;
case "worktree_setup":
displayName = "Worktree Setup";
summary = input.detail.branchName;
break;
case "unknown":
break;
}

View File

@@ -1,6 +1,21 @@
import { describe, it, expect, afterEach } from "vitest";
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
async function waitForCondition(
predicate: () => boolean,
timeoutMs: number,
intervalMs = 25
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
describe("TerminalManager", () => {
let manager: TerminalManager;
@@ -136,6 +151,21 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
});
it("auto-removes terminal when shell exits", async () => {
manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp");
const exitedId = terminals[0].id;
terminals[0].send({ type: "input", data: "\u0004" });
await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000);
expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(1);
expect(remaining[0].id).not.toBe(exitedId);
});
});
describe("listDirectories", () => {
@@ -161,12 +191,62 @@ describe("TerminalManager", () => {
manager = createTerminalManager();
const tmpTerminals = await manager.getTerminals("/tmp");
const homeTerminals = await manager.getTerminals("/home");
const tmpId = tmpTerminals[0].id;
const homeId = homeTerminals[0].id;
manager.killAll();
expect(manager.listDirectories()).toEqual([]);
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
expect(manager.getTerminal(tmpId)).toBeUndefined();
expect(manager.getTerminal(homeId)).toBeUndefined();
});
});
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalNames: input.terminals.map((terminal) => terminal.name),
});
});
await manager.getTerminals("/tmp");
await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1"],
});
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalNames: ["Terminal 1", "Dev Server"],
});
unsubscribe();
});
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
terminalCount: input.terminals.length,
});
});
const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id);
expect(snapshots).toContainEqual({
cwd: "/tmp",
terminalCount: 0,
});
unsubscribe();
});
});
});

Some files were not shown because too many files have changed in this diff Show More