Compare commits

..
120 changed files with 6713 additions and 6016 deletions
+1 -14
View File
@@ -4,7 +4,6 @@ on:
push:
tags:
- "v*"
- "android-v*"
workflow_dispatch:
inputs:
tag:
@@ -17,7 +16,7 @@ concurrency:
cancel-in-progress: false
env:
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
jobs:
publish-android-apk:
@@ -32,18 +31,6 @@ jobs:
fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release tag
shell: bash
run: |
set -euo pipefail
source_tag="${SOURCE_TAG}"
if [[ "$source_tag" =~ ^(android-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
release_tag="v${BASH_REMATCH[2]}"
else
release_tag="$source_tag"
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
- name: Setup Node
uses: actions/setup-node@v4
with:
-3
View File
@@ -132,7 +132,6 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -238,7 +237,6 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"
@@ -347,7 +345,6 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true
run: |
set -euo pipefail
publish_mode="never"
-31
View File
@@ -1,36 +1,5 @@
# Changelog
## 0.1.32 - 2026-03-23
### Added
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
- Added line numbers and word-wrap toggle to file previews.
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
- Expanded diff syntax highlighting to cover more languages.
- Added status bar tooltips for project and agent status.
### Improved
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
- Desktop key repeat now works correctly on macOS.
- Desktop notifications on macOS are more reliable.
- Daemon startup no longer blocks on model downloads.
- Better error messages from the daemon — RPC errors now include the actual underlying details.
### Fixed
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
- Fixed desktop window drag coordinates being applied when they shouldn't be.
## 0.1.30 - 2026-03-19
### Added
-74
View File
@@ -1,74 +0,0 @@
# Product
What Paseo is, who it's for, and where it's going.
## What is Paseo
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
## Core philosophy
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
## How it works
### Projects and workspaces
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
### Inside a workspace
A workspace is a flexible canvas:
- Launch multiple agents side by side in split panes
- Open terminals alongside agents
- Mix and match providers within the same workspace
### The daemon
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
This architecture means:
- The daemon can run on any machine: laptop, VM, remote server
- Multiple clients can connect simultaneously
- Agents keep running when you close the app
## Target user
Anyone who builds software:
- Care about owning their tools and their data
- Use multiple AI providers and want to switch freely
- Run agents on real tasks across real projects
- Want to work from multiple devices
## What compounds over time
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
## Strategic bets
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
## Current state (March 2026)
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
- Daily releases
- Community contributions starting (packaging, bug fixes)
- Key UX: split panes, keybinding customization, workspace model
+10 -16
View File
@@ -37,24 +37,18 @@ npm run release:finalize # Publish npm, promote draft to published
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
**NEVER use `workflow_dispatch` to retry release builds.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). This means build fixes committed to `main` won't be picked up — the old broken code at the tag gets built again.
To retry a failed workflow for an existing tag:
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
1. **Retry via `workflow_dispatch`** — all release workflows support `workflow_dispatch` with a `tag` input:
```bash
gh workflow run "Desktop Release" -f tag=v0.1.28 # all platforms
gh workflow run "Desktop Release" -f tag=v0.1.28 -f platform=macos # single platform
gh workflow run "Android APK Release" -f tag=v0.1.28
gh workflow run "Deploy App" # no tag input needed
```
2. **Platform-specific retry tags** (desktop only) — push a tag like `desktop-macos-v0.1.28` to rebuild just that platform against the release tag's code
```bash
# Desktop (all platforms)
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
# Desktop (single platform)
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
# Android APK
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
```
This ensures the checkout ref matches the actual code on `main` with the fix included.
If the fix requires a code change (e.g. a broken build script), commit the fix to `main` and use `workflow_dispatch` pointing at the existing tag — the workflow checks out the tag ref, but for build-tooling fixes you may need to point it at `main` or cherry-pick the fix onto the tag.
## Notes
+2 -150
View File
@@ -25,7 +25,6 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
@@ -12557,58 +12556,18 @@
"node": ">=10.0.0"
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-image": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.9.0.tgz",
"integrity": "sha512-oYWA8/QAr5/Emwl1xL7WCoOqeG3IZfpzEz/OVq1j4Oi9934TQmHiyubClikRf0D/jL3JNiNuz/Lsqx0kXQ02BA==",
"license": "MIT"
},
"node_modules/@xterm/addon-ligatures": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.10.0.tgz",
"integrity": "sha512-/Few8ZSHMib7sGjRJoc5l7bCtEB9XJfkNofvPpOcWADxKaUl8og8P172j67OoACSNJAXqeCLIuvj8WFCBkcTxg==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.1.0",
"font-ligatures": "^1.4.1"
},
"engines": {
"node": ">8.0.0"
}
},
"node_modules/@xterm/addon-search": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
@@ -15225,31 +15184,6 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/connect": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
@@ -21383,33 +21317,6 @@
}
}
},
"node_modules/font-finder": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/font-finder/-/font-finder-1.1.0.tgz",
"integrity": "sha512-wpCL2uIbi6GurJbU7ZlQ3nGd61Ho+dSU6U83/xJT5UPFfN35EeCW/rOtS+5k+IuEZu2SYmHzDIPL9eA5tSYRAw==",
"license": "MIT",
"dependencies": {
"get-system-fonts": "^2.0.0",
"promise-stream-reader": "^1.0.1"
},
"engines": {
"node": ">8.0.0"
}
},
"node_modules/font-ligatures": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/font-ligatures/-/font-ligatures-1.4.1.tgz",
"integrity": "sha512-7W6zlfyhvCqShZ5ReUWqmSd9vBaUudW0Hxis+tqUjtHhsPU+L3Grf8mcZAtCiXHTzorhwdRTId2WeH/88gdFkw==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.0.3",
"lru-cache": "^6.0.0",
"opentype.js": "^0.8.0"
},
"engines": {
"node": ">8.0.0"
}
},
"node_modules/fontfaceobserver": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz",
@@ -21805,15 +21712,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-system-fonts": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-system-fonts/-/get-system-fonts-2.0.2.tgz",
"integrity": "sha512-zzlgaYnHMIEgHRrfC7x0Qp0Ylhw/sHpM6MHXeVBTYIsvGf5GpbnClB+Q6rAPdn+0gd2oZZIo6Tj3EaWrt4VhDQ==",
"license": "MIT",
"engines": {
"node": ">8.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -24149,12 +24047,6 @@
"node": ">=10"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -25109,6 +25001,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
@@ -27684,18 +27577,6 @@
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/opentype.js": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.8.0.tgz",
"integrity": "sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg==",
"license": "MIT",
"dependencies": {
"tiny-inflate": "^1.0.2"
},
"bin": {
"ot": "bin/ot"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -28643,15 +28524,6 @@
"node": ">=10"
}
},
"node_modules/promise-stream-reader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz",
"integrity": "sha512-Tnxit5trUjBAqqZCGWwjyxhmgMN4hGrtpW3Oc/tRI4bpm/O2+ej72BB08l6JBnGQgVDGCLvHFGjGgQS6vzhwXg==",
"license": "MIT",
"engines": {
"node": ">8.0.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -32508,12 +32380,6 @@
"semver": "bin/semver"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -32681,16 +32547,6 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -34634,6 +34490,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
@@ -34862,13 +34719,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
+1 -2
View File
@@ -14,7 +14,7 @@
],
"scripts": {
"dev": "./scripts/dev.sh",
"dev:server": "npm run dev --workspace=@getpaseo/server",
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
@@ -59,7 +59,6 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
+1 -6
View File
@@ -6,7 +6,7 @@
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
@@ -44,13 +44,8 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"base64-js": "^1.5.1",
+1 -6
View File
@@ -264,9 +264,6 @@ function AppContainer({
const daemons = useHosts();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
@@ -277,8 +274,6 @@ function AppContainer({
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
});
const containerStyle = useMemo(
@@ -289,7 +284,7 @@ function AppContainer({
const content = (
<View style={containerStyle}>
<View style={rowStyle}>
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
<View style={flexStyle}>{children}</View>
</View>
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
@@ -120,7 +120,6 @@ export function AgentInputArea({
const toast = useToast();
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
const isDictationReady =
isConnected &&
(agentDirectoryStatus === "ready" ||
@@ -594,14 +593,12 @@ export function AgentInputArea({
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
{dictationCancelKeys ? (
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Interrupt</Text>
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
) : null;
const rightContent = (
@@ -628,9 +625,7 @@ export function AgentInputArea({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
</View>
</TooltipContent>
</Tooltip>
@@ -283,6 +283,55 @@ function ControlledStatusBar({
</>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
{canSelectModel ? (
<>
<Tooltip
@@ -374,55 +423,6 @@ function ControlledStatusBar({
/>
</>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modeAnchorRef}
collapsable={false}
disabled={disabled || !canSelectMode}
onPress={() => handleSelectorPress("mode")}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === "mode") && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getStatusSelectorHint("mode")}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={(id) => onSelectMode?.(id)}
searchable={comboboxModeOptions.length > SEARCH_THRESHOLD}
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
</>
) : (
<>
@@ -484,6 +484,49 @@ function ControlledStatusBar({
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{canSelectModel ? (
<View style={styles.sheetSection}>
<DropdownMenu
@@ -553,49 +596,6 @@ function ControlledStatusBar({
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "mode"}
onOpenChange={handleOpenChange("mode")}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id);
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
</AdaptiveModalSheet>
</>
)}
@@ -1,6 +1,5 @@
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
@@ -64,7 +63,6 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
@@ -90,7 +88,7 @@ export interface AgentStreamViewProps {
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
function AgentStreamView(
{
agentId,
@@ -138,11 +136,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -171,7 +164,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (normalized.file) {
if (onOpenWorkspaceFile) {
openWorkspaceFile({ filePath: normalized.file });
onOpenWorkspaceFile({ filePath: normalized.file });
return;
}
@@ -204,7 +197,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
resolvedServerId,
router,
setExplorerTabForCheckout,
openWorkspaceFile,
onOpenWorkspaceFile,
workspaceId,
],
);
@@ -664,9 +657,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
);
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator() {
const progress = useSharedValue(0);
@@ -178,7 +178,7 @@ export function CommandCenter() {
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
@@ -9,8 +9,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
@@ -19,7 +17,6 @@ interface GitActionsSplitButtonProps {
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
@@ -80,11 +77,6 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys
? <Shortcut chord={archiveShortcutKeys} />
: undefined
}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
@@ -21,7 +21,7 @@ export function KeyboardShortcutsDialog() {
return (
<AdaptiveModalSheet
title="Shortcuts"
title="Keyboard shortcuts"
visible={open}
onClose={() => setOpen(false)}
testID="keyboard-shortcuts-dialog"
+2 -2
View File
@@ -565,7 +565,7 @@ function MobileSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
</View>
</TooltipContent>
</Tooltip>
@@ -745,7 +745,7 @@ function DesktopSidebar({
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
<Shortcut keys={["⌘", "⇧", "O"]} />
</View>
</TooltipContent>
</Tooltip>
+6 -19
View File
@@ -33,7 +33,6 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import {
markScrollInvestigationEvent,
@@ -211,10 +210,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
markScrollInvestigationRender(investigationComponentId);
const toast = useToast();
const voice = useVoiceOptional();
const sendKeys = useShortcutKeys("message-input-send");
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const rootRef = useRef<View | null>(null);
const inputWrapperRef = useRef<View | null>(null);
@@ -1011,16 +1006,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
: "Mute voice"
: "Dictation"}
</Text>
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
<Shortcut
chord={
(isRealtimeVoiceForCurrentAgent
? voiceMuteToggleKeys
: dictationToggleKeys)!
}
style={styles.tooltipShortcut}
/>
) : null}
<Shortcut
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
style={styles.tooltipShortcut}
/>
</View>
</TooltipContent>
</Tooltip>
@@ -1043,9 +1032,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -1068,7 +1055,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -27,12 +27,12 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
Check,
ChevronDown,
ChevronRight,
CircleHelp,
Copy,
ExternalLink,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
@@ -48,6 +48,7 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
type SidebarStateBucket,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -72,19 +73,12 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -97,12 +91,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
const EMPTY_WORKSPACES = new Map();
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
@@ -128,7 +116,6 @@ interface ProjectHeaderRowProps {
onPress: () => void;
serverId: string | null;
canCreateWorktree: boolean;
isProjectActive?: boolean;
onWorkspacePress?: () => void;
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber?: number | null;
@@ -158,53 +145,22 @@ interface WorkspaceRowInnerProps {
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
}, []);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`${hint.state} pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
style={({ pressed }) => [
styles.workspacePrBadge,
pressed && styles.workspacePrBadgePressed,
]}
>
<GitPullRequest size={12} color={activeColor} />
<Text
style={[
styles.workspacePrBadgeText,
{ color: activeColor },
]}
numberOfLines={1}
>
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
</Text>
{isHovered && <ExternalLink size={10} color={activeColor} />}
</Pressable>
);
function resolveStatusDotColor(input: {
theme: ReturnType<typeof useUnistyles>["theme"];
bucket: SidebarStateBucket;
}) {
const { theme, bucket } = input;
return bucket === "needs_input"
? theme.colors.palette.amber[500]
: bucket === "failed"
? theme.colors.palette.red[500]
: bucket === "running"
? theme.colors.palette.blue[500]
: bucket === "attention"
? theme.colors.palette.green[500]
: theme.colors.border;
}
function WorkspaceStatusIndicator({
@@ -217,48 +173,38 @@ function WorkspaceStatusIndicator({
loading?: boolean;
}) {
const { theme } = useUnistyles();
const color = resolveStatusDotColor({ theme, bucket });
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done";
if (loading) {
if (isIdle) {
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
return (
<View style={styles.workspaceStatusDot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
<KindIcon size={14} color={theme.colors.foregroundMuted} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const KindIcon =
workspaceKind === "local_checkout"
? Monitor
: workspaceKind === "worktree"
? FolderGit2
: null;
if (!KindIcon) return null;
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} color={theme.colors.foregroundMuted} />
{dotColor ? (
<View
style={[
styles.statusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
{loading ? (
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
) : shouldShowSyncedLoader ? (
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
) : bucket === "needs_input" ? (
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
) : bucket === "attention" ? (
<Check size={14} color={theme.colors.palette.green[500]} />
) : (
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
)}
</View>
);
}
@@ -278,72 +224,29 @@ function ProjectLeadingVisual({
showChevron?: boolean;
isArchiving?: boolean;
}) {
const { theme } = useUnistyles();
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
const activeWorkspace = workspace;
const shouldShowWorkspaceStatus =
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
const shouldShowSyncedLoader = activeWorkspace
? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket })
: false;
if (showChevron && chevron !== null) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ProjectInlineChevron chevron={chevron} />
</View>
);
}
const projectIcon = iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
);
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return <View style={styles.projectLeadingVisualSlot}>{projectIcon}</View>;
}
if (isArchiving) {
return (
<View style={styles.projectLeadingVisualSlot}>
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.projectLeadingVisualSlot}>
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
</View>
);
}
const dotColor = getStatusDotColor({
theme,
bucket: activeWorkspace.statusBucket,
showDoneAsInactive: false,
});
return (
<View style={styles.projectLeadingVisualSlot}>
{projectIcon}
{dotColor ? (
<View
style={[
styles.statusDotOverlay,
{
backgroundColor: dotColor,
borderColor: theme.colors.surface0,
},
]}
{showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator
bucket={activeWorkspace.statusBucket}
workspaceKind={activeWorkspace.workspaceKind}
loading={isArchiving}
/>
) : null}
) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
</View>
);
}
@@ -364,17 +267,14 @@ function NewWorktreeButton({
visible,
loading = false,
testID,
showShortcutHint = false,
}: {
displayName: string;
onPress: () => void;
visible: boolean;
loading?: boolean;
testID: string;
showShortcutHint?: boolean;
}) {
const { theme } = useUnistyles();
const newWorktreeKeys = useShortcutKeys("new-worktree");
return (
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
@@ -409,13 +309,8 @@ function NewWorktreeButton({
}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
</View>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -649,7 +544,6 @@ function ProjectHeaderRow({
onPress,
serverId,
canCreateWorktree,
isProjectActive = false,
onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null,
@@ -700,17 +594,6 @@ function ProjectHeaderRow({
toast.error(error instanceof Error ? error.message : String(error));
},
});
useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
priority: 0,
handle: () => {
createWorktreeMutation.mutate();
return true;
},
});
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -753,7 +636,6 @@ function ProjectHeaderRow({
onPress={() => createWorktreeMutation.mutate()}
visible={isHovered || isMobileBreakpoint}
loading={createWorktreeMutation.isPending}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
@@ -829,16 +711,10 @@ function WorkspaceRowInner({
onArchive,
onCopyBranchName,
onCopyPath,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobile = Platform.OS !== "web";
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
});
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -873,99 +749,91 @@ function WorkspaceRowInner({
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
{archiveLabel ?? "Archive"}
Copy branch name
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
);
@@ -1035,7 +903,7 @@ function WorkspaceRowWithMenu({
void (async () => {
const confirmed = await confirmDialog({
title: "Archive worktree?",
message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
@@ -1123,23 +991,6 @@ function WorkspaceRowWithMenu({
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
if (isWorktree) {
handleArchiveWorktree();
} else {
handleArchiveWorkspace();
}
return true;
},
});
return (
<WorkspaceRowInner
workspace={workspace}
@@ -1159,7 +1010,6 @@ function WorkspaceRowWithMenu({
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
);
}
@@ -1332,7 +1182,6 @@ function FlattenedProjectRow({
drag,
isDragging,
dragHandleProps,
isProjectActive = false,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1347,7 +1196,6 @@ function FlattenedProjectRow({
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean;
}) {
if (project.projectKind === "non_git") {
return (
@@ -1378,7 +1226,6 @@ function FlattenedProjectRow({
onPress={onPress}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
shortcutNumber={shortcutNumber}
@@ -1480,13 +1327,6 @@ function ProjectBlock({
[activeWorkspaceSelection, collapsed, project, serverId],
);
const isProjectActive = useMemo(() => {
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
return false;
}
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
}, [serverId, activeWorkspaceSelection, project.workspaces]);
const renderWorkspaceRow = useCallback(
(
item: SidebarWorkspaceEntry,
@@ -1579,7 +1419,6 @@ function ProjectBlock({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={isProjectActive}
/>
) : (
<>
@@ -1593,7 +1432,6 @@ function ProjectBlock({
onPress={onToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
@@ -1962,7 +1800,12 @@ const styles = StyleSheet.create((theme) => ({
projectBlock: {
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
workspaceListContainer: {
marginLeft: theme.spacing[3],
paddingLeft: theme.spacing[2],
borderLeftWidth: StyleSheet.hairlineWidth,
borderLeftColor: theme.colors.surface1,
},
emptyText: {
color: theme.colors.foregroundMuted,
textAlign: "center",
@@ -1972,7 +1815,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: "row",
@@ -2018,7 +1861,6 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.sm,
},
projectLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
@@ -2041,7 +1883,6 @@ const styles = StyleSheet.create((theme) => ({
projectTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
@@ -2082,37 +1923,20 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
flexShrink: 0,
},
projectActionTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
projectActionTooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
projectActionTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
fontSize: theme.fontSize.xs,
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "center",
gap: theme.spacing[1],
},
workspaceRowMain: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
},
workspaceRowLeft: {
flexDirection: "row",
@@ -2152,22 +1976,17 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
width: 14,
height: 16,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
alignItems: "flex-start",
justifyContent: "center",
},
statusDotOverlay: {
position: "absolute",
right: 0,
bottom: 0,
workspaceStatusDotFill: {
width: 7,
height: 7,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
workspaceArchivingOverlay: {
...StyleSheet.absoluteFillObject,
@@ -2187,7 +2006,6 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
flex: 1,
@@ -2199,23 +2017,6 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchTextHovered: {
opacity: 1,
},
workspacePrBadgeRow: {
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadge: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacePrBadgePressed: {
opacity: 0.82,
},
workspacePrBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
+18 -137
View File
@@ -1,6 +1,5 @@
import {
Fragment,
memo,
useCallback,
useEffect,
useMemo,
@@ -10,7 +9,6 @@ import {
type ReactNode,
type SetStateAction,
} from "react";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
DndContext,
DragOverlay,
@@ -31,8 +29,6 @@ import { Platform, View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useTrafficLightPadding } from "@/utils/desktop-window";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -46,7 +42,6 @@ import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
@@ -67,7 +62,6 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -111,7 +105,6 @@ interface SplitContainerProps {
onResizeSplit: (groupId: string, sizes: number[]) => void;
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
focusModeEnabled?: boolean;
}
interface WorkspaceTabDragData {
@@ -156,81 +149,6 @@ interface SplitPaneViewProps
tabDropPreview: TabDropPreview | null;
}
interface MountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isPaneFocused: boolean;
paneId: string;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
const MountedTabSlot = memo(function MountedTabSlot({
tabDescriptor,
isVisible,
isPaneFocused,
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
buildPaneContentModel({
paneId,
isPaneFocused,
tab: tabDescriptor,
}),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
);
return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
</View>
);
});
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
const next = new Map<string, WorkspaceTabDescriptor>();
for (const tabDescriptor of tabDescriptors) {
const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
if (
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
continue;
}
next.set(tabDescriptor.tabId, tabDescriptor);
}
return next;
}, [tabDescriptors]);
useEffect(() => {
cacheRef.current = tabDescriptorMap;
}, [tabDescriptorMap]);
return tabDescriptorMap;
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
@@ -278,7 +196,6 @@ export function SplitContainer({
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
}: SplitContainerProps) {
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
@@ -297,17 +214,6 @@ export function SplitContainer({
const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]);
const effectiveRoot = useMemo(() => {
if (!focusModeEnabled) {
return layout.root;
}
const focusedPane = panesById.get(layout.focusedPaneId);
if (!focusedPane) {
return layout.root;
}
return { kind: "pane" as const, pane: focusedPane };
}, [focusModeEnabled, layout.root, layout.focusedPaneId, panesById]);
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current as WorkspaceTabDragData | undefined;
if (data?.kind !== "workspace-tab") {
@@ -519,7 +425,7 @@ export function SplitContainer({
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={effectiveRoot}
node={layout.root}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
@@ -808,9 +714,6 @@ function SplitPaneView({
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
const trafficLightPadding = useTrafficLightPadding();
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
@@ -820,18 +723,7 @@ function SplitPaneView({
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
const { mountedTabIds } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: paneTabIds,
cap: 3,
});
const mountedPaneTabIds = useMemo(
() => paneTabIds.filter((tabId) => mountedTabIds.has(tabId)),
[mountedTabIds, paneTabIds],
);
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
() =>
paneTabs.map((tab) => ({
@@ -842,6 +734,17 @@ function SplitPaneView({
})),
[activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
);
const paneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
paneId: pane.id,
isPaneFocused: isFocused,
tab: activeTabDescriptor,
})
: null,
[activeTabDescriptor, buildPaneContentModel, pane.id],
);
useEffect(() => {
if (Platform.OS !== "web") {
@@ -861,14 +764,14 @@ function SplitPaneView({
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
stableOnFocusPane(pane.id);
onFocusPane(pane.id);
};
const handlePaneFocusIn = (event: FocusEvent) => {
if (!shouldFocusPaneFromEventTarget(event.target)) {
return;
}
stableOnFocusPane(pane.id);
onFocusPane(pane.id);
};
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
@@ -878,17 +781,11 @@ function SplitPaneView({
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
};
}, [stableOnFocusPane, pane.id]);
}, [onFocusPane, pane.id]);
return (
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
]}
>
<View style={styles.paneTabs}>
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
@@ -924,24 +821,8 @@ function SplitPaneView({
</View>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0 ? (
mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
{paneContent ? (
<WorkspacePaneContent content={paneContent} />
) : (
(renderPaneEmptyState?.() ?? null)
)}
@@ -1,11 +1,9 @@
"use dom";
import { useEffect, useMemo, useRef, useState, type Ref } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { focusWithRetries } from "../utils/web-focus";
@@ -14,12 +12,6 @@ import {
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
@@ -71,14 +63,17 @@ function buildXtermThemeKey(theme: ITheme): string {
interface TerminalEmulatorProps {
dom?: DOMProps;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
initialOutputText: string;
initialOutputChunkSequence?: number;
outputChunkText: string;
outputChunkSequence: number;
outputChunkReplay?: boolean;
testId?: string;
xtermTheme?: ITheme;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
@@ -89,6 +84,7 @@ interface TerminalEmulatorProps {
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -127,8 +123,12 @@ function ensureTerminalScrollbarStyle(): void {
}
export default function TerminalEmulator({
ref,
streamKey,
initialOutputText,
initialOutputChunkSequence = 0,
outputChunkText,
outputChunkSequence,
outputChunkReplay = false,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
@@ -138,11 +138,11 @@ export default function TerminalEmulator({
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onOutputChunkConsumed,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -150,6 +150,7 @@ export default function TerminalEmulator({
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const appliedChunkSequenceRef = useRef(0);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
@@ -168,23 +169,6 @@ export default function TerminalEmulator({
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
useDOMImperativeHandle(
ref as Ref<DOMImperativeFactory>,
() =>
({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}) as unknown as DOMImperativeFactory,
[],
);
useEffect(() => {
mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme });
@@ -331,15 +315,17 @@ export default function TerminalEmulator({
runtime.mount({
root,
host,
initialSnapshot,
initialOutputText,
theme: mountedThemeRef.current,
});
appliedChunkSequenceRef.current = Math.max(0, Math.floor(initialOutputChunkSequence));
return () => {
runtime.unmount();
if (runtimeRef.current === runtime) {
runtimeRef.current = null;
}
appliedChunkSequenceRef.current = 0;
};
}, [streamKey]);
@@ -358,11 +344,45 @@ export default function TerminalEmulator({
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
const runtime = runtimeRef.current;
if (outputChunkSequence <= 0) {
return;
}
if (outputChunkSequence <= appliedChunkSequenceRef.current) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
if (!runtime) {
onOutputChunkConsumed?.(outputChunkSequence);
return;
}
appliedChunkSequenceRef.current = outputChunkSequence;
if (outputChunkText.length === 0) {
runtime.clear({
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
return;
}
runtime.write({
text: outputChunkText,
suppressInput: outputChunkReplay,
onCommitted: () => {
onOutputChunkConsumed?.(outputChunkSequence);
},
});
}, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true });
return focusWithRetries({
focus: () => {
runtimeRef.current?.focus();
@@ -597,9 +617,9 @@ export default function TerminalEmulator({
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 8,
paddingRight: 0,
}}
/>
+557 -72
View File
@@ -1,18 +1,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useAppVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
TerminalStreamController,
@@ -20,15 +24,19 @@ import {
} from "@/terminal/runtime/terminal-stream-controller";
import { usePanelStore } from "@/stores/panel-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps {
serverId: string;
cwd: string;
terminalId: string;
isPaneFocused: boolean;
selectedTerminalId: string | null;
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
hideHeader?: boolean;
manageTerminalDirectorySubscription?: boolean;
}
const MAX_OUTPUT_CHARS = 200_000;
const TERMINAL_TAB_MAX_WIDTH = 220;
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const MODIFIER_LABELS = {
@@ -71,6 +79,8 @@ type PendingTerminalInput =
};
};
type ListTerminalsPayload = ListTerminalsResponse["payload"];
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
@@ -81,14 +91,32 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
return (
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset="10%" stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
</View>
);
}
export function TerminalPane({
serverId,
cwd,
terminalId,
isPaneFocused,
selectedTerminalId,
onSelectedTerminalIdChange,
hideHeader = false,
manageTerminalDirectorySubscription = true,
}: TerminalPaneProps) {
const isScreenFocused = useIsFocused();
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -101,28 +129,119 @@ export function TerminalPane({
enabled: isMobile,
});
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
const workspaceTerminalSession = useMemo(
() =>
getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: MAX_OUTPUT_CHARS,
}),
[scopeKey],
);
const outputSession = workspaceTerminalSession.outputSession;
const subscribeOutputSession = useCallback(
(listener: () => void) => outputSession.subscribe(listener),
[outputSession],
);
const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]);
const outputState = useSyncExternalStore(
subscribeOutputSession,
getOutputSessionState,
getOutputSessionState,
);
const selectedOutputState = useMemo(() => {
if (outputState.selectedTerminalId === selectedTerminalId) {
return outputState;
}
return {
...outputState,
selectedTerminalId,
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
}, [outputSession, outputState, selectedTerminalId]);
const [activeStream, setActiveStream] = useState<{
terminalId: string;
streamId: number;
} | null>(null);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const updateSelectedTerminalId = useCallback(
(next: string | null) => {
onSelectedTerminalIdChange?.(next);
},
[onSelectedTerminalIdChange],
);
useEffect(() => {
terminalIdRef.current = terminalId;
}, [terminalId]);
selectedTerminalIdRef.current = selectedTerminalId;
}, [selectedTerminalId]);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
return;
}
clearTimeout(hoverOutTimeoutRef.current);
hoverOutTimeoutRef.current = null;
}, []);
const handleTerminalTabHoverIn = useCallback(
(terminalId: string) => {
clearHoverOutTimeout();
setHoveredTerminalId(terminalId);
},
[clearHoverOutTimeout],
);
const handleTerminalTabHoverOut = useCallback(
(terminalId: string) => {
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setHoveredTerminalId((current) => (current === terminalId ? null : current));
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
}, 50);
},
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverIn = useCallback(
(terminalId: string) => {
clearHoverOutTimeout();
setHoveredTerminalId(terminalId);
setHoveredCloseTerminalId(terminalId);
},
[clearHoverOutTimeout],
);
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
}, []);
useEffect(() => {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
@@ -132,26 +251,19 @@ export function TerminalPane({
}, []);
useEffect(() => {
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
if (isMobile || !isScreenFocused || !selectedTerminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
const nextFocusKey = `${scopeKey}:${terminalId}`;
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus();
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
lastReportedSizeRef.current = null;
requestTerminalReflow();
}
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -190,7 +302,7 @@ export function TerminalPane({
useFocusEffect(
useCallback(() => {
if (!terminalId) {
if (!selectedTerminalId) {
return;
}
// Navigation transitions can temporarily report stale dimensions.
@@ -206,9 +318,23 @@ export function TerminalPane({
clearTimeout(handle);
}
};
}, [requestTerminalReflow, terminalId]),
}, [requestTerminalReflow, selectedTerminalId]),
);
const terminalsQuery = useQuery({
queryKey: terminalsQueryKey,
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.listTerminals(cwd);
},
staleTime: 5_000,
});
const terminals = terminalsQuery.data?.terminals ?? [];
useEffect(() => {
if (!client || !isConnected || !isScreenFocused) {
return;
@@ -224,29 +350,135 @@ export function TerminalPane({
return;
}
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
if (terminalIdRef.current === exitedTerminalId) {
emulatorRef.current?.clear();
}
streamControllerRef.current?.handleTerminalExit({
streamControllerRef.current?.handleStreamExit({
terminalId: exitedTerminalId,
streamId: message.payload.streamId,
});
setModifiers({ ...EMPTY_MODIFIERS });
});
}, [client, isConnected, workspaceTerminalSession.snapshots]);
}, [client, isConnected]);
useEffect(() => {
if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
return;
}
const unsubscribe = client.on("terminals_changed", (message) => {
if (message.type !== "terminals_changed") {
return;
}
if (message.payload.cwd !== cwd) {
return;
}
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
});
client.subscribeTerminals({ cwd });
return () => {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [
client,
cwd,
isConnected,
manageTerminalDirectorySubscription,
queryClient,
terminalsQueryKey,
]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return await client.createTerminal(cwd);
},
onSuccess: (payload) => {
const createdTerminal = payload.terminal;
if (createdTerminal) {
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
const nextTerminals = upsertTerminalListEntry({
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
return {
cwd: current?.cwd ?? cwd,
terminals: nextTerminals,
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
};
});
updateSelectedTerminalId(createdTerminal.id);
requestTerminalFocus();
}
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
});
},
});
const killTerminalMutation = useMutation({
mutationFn: async (terminalId: string) => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.killTerminal(terminalId);
if (!payload.success) {
throw new Error("Unable to close terminal");
}
return payload;
},
onSuccess: (_, terminalId) => {
setHoveredTerminalId((current) => (current === terminalId ? null : current));
outputSession.clearTerminal({ terminalId });
if (selectedTerminalIdRef.current === terminalId) {
updateSelectedTerminalId(null);
setModifiers({ ...EMPTY_MODIFIERS });
}
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
});
void queryClient.refetchQueries({
queryKey: terminalsQueryKey,
type: "active",
});
},
});
useEffect(() => {
lastReportedSizeRef.current = null;
}, [scopeKey]);
useEffect(() => {
const terminalIds = terminals.map((terminal) => terminal.id);
outputSession.prune({ terminalIds });
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
}, [outputSession, terminals]);
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
setIsAttaching(status.isAttaching);
setStreamError(status.error);
if (status.terminalId && typeof status.streamId === "number") {
setActiveStream({
terminalId: status.terminalId,
streamId: status.streamId,
});
return;
}
setActiveStream(null);
}, []);
useEffect(() => {
streamControllerRef.current?.dispose();
streamControllerRef.current = null;
setActiveStream(null);
setIsAttaching(false);
setStreamError(null);
@@ -257,25 +489,19 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId, text }) => {
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
resumeOffsets: workspaceTerminalSession.resumeOffsets,
onChunk: ({ terminalId, text, replay }) => {
outputSession.append({ terminalId, text, replay });
},
onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
onReset: ({ terminalId }) => {
outputSession.clearTerminal({ terminalId });
},
onStatusChange: handleStreamControllerStatus,
});
streamControllerRef.current = controller;
controller.setTerminal({
terminalId: isScreenFocused ? terminalIdRef.current : null,
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
});
return () => {
@@ -289,16 +515,34 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isScreenFocused,
workspaceTerminalSession.snapshots,
outputSession,
workspaceTerminalSession.resumeOffsets,
]);
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isScreenFocused ? terminalId : null;
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
outputSession.setSelectedTerminal({
terminalId: nextSelectedTerminalId,
});
}, [isScreenFocused, terminalId]);
streamControllerRef.current?.setTerminal({
terminalId: nextSelectedTerminalId,
});
}, [isScreenFocused, outputSession, selectedTerminalId]);
const activeStreamId =
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
const getCurrentActiveStreamId = useCallback(() => {
return streamControllerRef.current?.getActiveStreamId() ?? null;
}, []);
const selectedTerminal = useMemo(
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
[terminals, selectedTerminalId],
);
const handleCreateTerminal = useCallback(() => {
createTerminalMutation.mutate();
}, [createTerminalMutation]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
@@ -314,13 +558,13 @@ export function TerminalPane({
return false;
}
const currentTerminalId = terminalIdRef.current;
if (!currentTerminalId) {
const terminalId = selectedTerminalIdRef.current;
if (!terminalId) {
return false;
}
if (entry.type === "data") {
client.sendTerminalInput(currentTerminalId, {
client.sendTerminalInput(terminalId, {
type: "input",
data: entry.data,
});
@@ -331,7 +575,7 @@ export function TerminalPane({
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(currentTerminalId, {
client.sendTerminalInput(terminalId, {
type: "input",
data: encoded,
});
@@ -364,10 +608,31 @@ export function TerminalPane({
}, [dispatchTerminalInputEntry]);
useEffect(() => {
if (!isAttaching && !streamError) {
flushPendingTerminalInput();
}
}, [flushPendingTerminalInput, isAttaching, streamError]);
flushPendingTerminalInput();
}, [activeStreamId, flushPendingTerminalInput]);
const handleCloseTerminal = useCallback(
async (terminalId: string) => {
if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
return;
}
const confirmed = await confirmDialog({
title: "Close terminal?",
message: "Any running process in this terminal will be stopped immediately.",
confirmLabel: "Close",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
killTerminalMutation.mutate(terminalId);
},
[killTerminalMutation],
);
const clearPendingModifiers = useCallback(() => {
setModifiers({ ...EMPTY_MODIFIERS });
@@ -381,7 +646,7 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
if (!client || !terminalIdRef.current) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
input: {
@@ -411,7 +676,7 @@ export function TerminalPane({
}
return true;
},
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
);
const handleTerminalData = useCallback(
@@ -445,7 +710,7 @@ export function TerminalPane({
}
}
if (!client || !terminalIdRef.current) {
if (!client || !selectedTerminalIdRef.current) {
enqueuePendingTerminalInput({
type: "data",
data,
@@ -464,6 +729,7 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
getCurrentActiveStreamId,
modifiers.alt,
modifiers.ctrl,
modifiers.shift,
@@ -472,10 +738,10 @@ export function TerminalPane({
],
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number }) => {
const handleTerminalResize = useCallback(
async (input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
@@ -485,12 +751,13 @@ export function TerminalPane({
return;
}
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
client.sendTerminalInput(terminalId, {
client.sendTerminalInput(selectedTerminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
[client, selectedTerminalId],
);
const handleTerminalKey = useCallback(
@@ -504,6 +771,13 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleOutputChunkConsumed = useCallback(
(sequence: number) => {
outputSession.consume({ sequence });
},
[outputSession],
);
const toggleModifier = useCallback(
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
@@ -542,13 +816,121 @@ export function TerminalPane({
);
}
const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
const isCreating = createTerminalMutation.isPending;
const createError =
createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
const closeError =
killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
const combinedError = streamError ?? closeError ?? createError ?? queryError;
return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
{!hideHeader ? (
<View style={styles.header} testID="terminals-header">
<ScrollView
horizontal
style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false}
>
{terminals.map((terminal) => {
const isActive = terminal.id === selectedTerminalId;
const isTabHovered = hoveredTerminalId === terminal.id;
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
const isClosingTerminal =
killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
const gradientId = `terminal-close-gradient-${terminal.id.replace(
/[^a-zA-Z0-9_-]/g,
"-",
)}`;
return (
<Pressable
key={terminal.id}
testID={`terminal-tab-${terminal.id}`}
onPress={() => updateSelectedTerminalId(terminal.id)}
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
style={({ pressed, hovered }) => [
styles.terminalTab,
isActive && styles.terminalTabActive,
shouldShowCloseButton && styles.terminalTabHovered,
(pressed || hovered) && styles.terminalTabHovered,
]}
>
<Text
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
numberOfLines={1}
ellipsizeMode="tail"
>
{terminal.name}
</Text>
<Pressable
testID={`terminal-close-${terminal.id}`}
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTerminal}
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
onPress={(event) => {
event.stopPropagation();
void handleCloseTerminal(terminal.id);
}}
style={({ hovered, pressed }) => [
styles.terminalTabCloseButton,
shouldShowCloseButton
? styles.terminalTabCloseButtonShown
: styles.terminalTabCloseButtonHidden,
]}
>
{({ hovered = false, pressed = false }) => {
const iconColor =
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<TerminalCloseGradient
color={theme.colors.surface2}
gradientId={gradientId}
/>
<View style={styles.terminalTabCloseIcon}>
{isClosingTerminal ? (
<ActivityIndicator size={12} color={iconColor} />
) : (
<X size={12} color={iconColor} />
)}
</View>
</>
);
}}
</Pressable>
</Pressable>
);
})}
</ScrollView>
<View style={styles.headerActions}>
<Pressable
testID="terminals-create-button"
onPress={handleCreateTerminal}
disabled={isCreating}
style={({ hovered, pressed }) => [
styles.headerIconButton,
(hovered || pressed) && styles.headerIconButtonHovered,
]}
>
{isCreating ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Plus size={16} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
</View>
) : null}
<View style={styles.outputContainer}>
{isScreenFocused ? (
{selectedTerminal && isScreenFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
dom={{
style: { flex: 1 },
matchContents: false,
@@ -559,11 +941,15 @@ export function TerminalPane({
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${terminalId}`}
streamKey={`${scopeKey}:${selectedTerminal.id}`}
initialOutputText={selectedOutputState.snapshotText}
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
outputChunkText={selectedOutputState.chunkText}
outputChunkSequence={selectedOutputState.chunkSequence}
outputChunkReplay={selectedOutputState.chunkReplay}
testId="terminal-surface"
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
return;
@@ -580,13 +966,18 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
onOutputChunkConsumed={handleOutputChunkConsumed}
pendingModifiers={modifiers}
focusRequestToken={focusRequestToken}
resizeRequestToken={resizeRequestToken}
/>
</View>
) : (
) : selectedTerminal ? (
<View style={styles.terminalGestureContainer} />
) : (
<View style={styles.centerState}>
<Text style={styles.stateText}>No terminal selected</Text>
</View>
)}
{isAttaching && isScreenFocused ? (
@@ -596,10 +987,10 @@ export function TerminalPane({
) : null}
</View>
{streamError ? (
{combinedError ? (
<View style={styles.errorRow}>
<Text style={styles.statusError} numberOfLines={2}>
{streamError}
{combinedError}
</Text>
</View>
) : null}
@@ -657,6 +1048,100 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
backgroundColor: theme.colors.surface0,
},
header: {
minHeight: 48,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
},
tabsScroll: {
flex: 1,
minWidth: 0,
},
tabsContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
},
terminalTab: {
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
justifyContent: "center",
maxWidth: TERMINAL_TAB_MAX_WIDTH,
minWidth: 96,
overflow: "hidden",
position: "relative",
},
terminalTabHovered: {
backgroundColor: theme.colors.surface2,
},
terminalTabActive: {
backgroundColor: theme.colors.surface2,
},
terminalTabText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
terminalTabTextActive: {
color: theme.colors.foreground,
},
terminalTabCloseButton: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: 32,
borderRadius: theme.borderRadius.sm,
alignItems: "flex-end",
justifyContent: "center",
paddingRight: theme.spacing[2],
},
terminalTabCloseButtonShown: {
opacity: 1,
},
terminalTabCloseButtonHidden: {
opacity: 0,
},
terminalTabCloseGradient: {
...StyleSheet.absoluteFillObject,
borderRadius: theme.borderRadius.sm,
overflow: "hidden",
zIndex: 0,
},
terminalTabCloseIcon: {
position: "relative",
zIndex: 1,
alignItems: "center",
justifyContent: "center",
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
headerIconButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
headerIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
outputContainer: {
flex: 1,
minHeight: 0,
@@ -24,7 +24,7 @@ import {
type ViewStyle,
type StyleProp,
} from "react-native";
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
@@ -226,22 +226,6 @@ export function DropdownMenuTrigger({
);
}
function getTransformOrigin(placement: Placement, alignment: Alignment): string {
const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center";
const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center";
return `${vertical} ${horizontal}`;
}
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(150);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
export function DropdownMenuContent({
children,
side = "bottom",
@@ -265,22 +249,9 @@ export function DropdownMenuContent({
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
// Keep Modal mounted during exit animation
useEffect(() => {
if (open) {
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
setClosing(true);
}
}, [open, modalVisible]);
const handleClose = useCallback(() => {
setOpen(false);
@@ -343,7 +314,6 @@ export function DropdownMenuContent({
// For fullWidth, x is simply the horizontal padding to center on screen
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
const handleContentLayout = useCallback(
@@ -354,7 +324,7 @@ export function DropdownMenuContent({
[],
);
if (!modalVisible) return null;
if (!open) return null;
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
@@ -367,7 +337,7 @@ export function DropdownMenuContent({
return (
<Modal
visible={modalVisible}
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
@@ -381,38 +351,30 @@ export function DropdownMenuContent({
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing ? (
<Animated.View
entering={contentEntering}
exiting={contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(setModalVisible)(false);
}
})}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
transformOrigin: getTransformOrigin(actualPlacement, align),
},
]}
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={{ flexGrow: 1 }}
>
{children}
</ScrollView>
</Animated.View>
) : null}
{children}
</ScrollView>
</Animated.View>
</View>
</Modal>
);
@@ -597,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
},
content: {
backgroundColor: theme.colors.surface1,
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
@@ -642,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({
borderColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemPressed: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelected: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelectedInteractive: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface1,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
+4 -34
View File
@@ -6,46 +6,22 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
chord,
style,
textStyle,
}: {
keys?: ShortcutKey[];
chord?: ShortcutKey[][];
keys: ShortcutKey[];
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement {
const displayChord = chord ?? (keys ? [keys] : []);
const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0];
if (!singleCombo) {
return <View style={style} />;
}
if (displayChord.length === 1) {
return (
<View style={[styles.badge, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
}
return (
<View style={[styles.sequence, style]}>
{displayChord.map(function (combo, index) {
return (
<View key={`${combo.join("+")}-${index}`} style={styles.badge}>
<Text style={[styles.text, textStyle]}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
})}
<View style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
badge: {
root: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
@@ -53,12 +29,6 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
sequence: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[1],
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
+46 -37
View File
@@ -49,6 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import {
shouldAutoReplayQueuedAgentMessage,
takeQueuedAgentMessageReplay,
type QueuedAgentReplaySource,
} from "@/contexts/session-queued-message-replay";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -272,7 +277,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
const sendAgentMessageRef = useRef<
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
((
agentId: string,
message: string,
images?: AttachmentMetadata[],
messageId?: string,
) => Promise<void>) | null
>(null);
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
@@ -349,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const applyAuthoritativeAgentSnapshot = useCallback(
(agent: Agent) => {
(agent: Agent, options?: { source?: QueuedAgentReplaySource }) => {
setAgents(serverId, (prev) => {
const current = prev.get(agent.id);
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
@@ -423,17 +433,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
const prevStatus = previousAgentStatusRef.current.get(agent.id);
if (prevStatus === "running" && agent.status !== "running") {
if (
shouldAutoReplayQueuedAgentMessage({
previousStatus: prevStatus,
nextStatus: agent.status,
source: options?.source ?? "live",
})
) {
const session = useSessionStore.getState().sessions[serverId];
const queue = session?.queuedMessages.get(agent.id);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id));
if (replay) {
if (sendAgentMessageRef.current) {
void sendAgentMessageRef.current(agent.id, next.text, next.images);
void sendAgentMessageRef.current(
agent.id,
replay.text,
replay.images,
replay.messageId,
);
}
setQueuedMessages(serverId, (prev) => {
const updated = new Map(prev);
updated.set(agent.id, rest);
updated.set(agent.id, replay.remainingQueue);
return updated;
});
}
@@ -516,31 +536,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const handleAppResumed = useCallback(
(awayMs: number) => {
scheduleAuthoritativeRevalidation();
if (Platform.OS !== "web") {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
if (agentId && cursor) {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
});
}
}
if (awayMs < HISTORY_STALE_AFTER_MS) {
return;
}
bumpHistorySyncGeneration(serverId);
},
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
[bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId],
);
// Client activity tracking (heartbeat, push token registration)
@@ -741,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}),
};
applyAuthoritativeAgentSnapshot(agent);
applyAuthoritativeAgentSnapshot(agent, { source: "live" });
},
[
applyAuthoritativeAgentSnapshot,
@@ -791,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (payload.agent) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot({
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
});
applyAuthoritativeAgentSnapshot(
{
...normalized,
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
},
{ source: "hydrate" },
);
}
// Call pure reducer
@@ -1488,11 +1492,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
]);
const sendAgentMessage = useCallback(
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
const messageId = generateMessageId();
async (
agentId: string,
message: string,
images?: AttachmentMetadata[],
messageId?: string,
) => {
const resolvedMessageId = messageId ?? generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: messageId,
id: resolvedMessageId,
text: message,
timestamp: new Date(),
};
@@ -1526,7 +1535,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
void client
.sendAgentMessage(agentId, message, {
messageId,
messageId: resolvedMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
.catch((error) => {
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
shouldAutoReplayQueuedAgentMessage,
takeQueuedAgentMessageReplay,
} from "./session-queued-message-replay";
describe("takeQueuedAgentMessageReplay", () => {
it("preserves the queued message id for idempotent replay", () => {
const replay = takeQueuedAgentMessageReplay([
{
id: "msg-1",
text: "resume this",
images: [
{
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachment:img-1",
fileName: "test.png",
byteSize: 12,
createdAt: 1,
},
],
},
{
id: "msg-2",
text: "second",
},
]);
expect(replay).toEqual({
messageId: "msg-1",
text: "resume this",
images: [
{
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachment:img-1",
fileName: "test.png",
byteSize: 12,
createdAt: 1,
},
],
remainingQueue: [{ id: "msg-2", text: "second" }],
});
});
it("returns null when the queue is empty", () => {
expect(takeQueuedAgentMessageReplay([])).toBeNull();
expect(takeQueuedAgentMessageReplay(undefined)).toBeNull();
});
it("only auto-replays queued messages for live running-to-idle transitions", () => {
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "running",
nextStatus: "idle",
source: "live",
}),
).toBe(true);
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "running",
nextStatus: "idle",
source: "hydrate",
}),
).toBe(false);
expect(
shouldAutoReplayQueuedAgentMessage({
previousStatus: "idle",
nextStatus: "idle",
source: "live",
}),
).toBe(false);
});
});
@@ -0,0 +1,40 @@
import type { AttachmentMetadata } from "@/attachments/types";
export interface QueuedAgentMessageReplayItem {
id: string;
text: string;
images?: AttachmentMetadata[];
}
export interface QueuedAgentMessageReplay {
messageId: string;
text: string;
images?: AttachmentMetadata[];
remainingQueue: QueuedAgentMessageReplayItem[];
}
export type QueuedAgentReplaySource = "hydrate" | "live";
export function shouldAutoReplayQueuedAgentMessage(input: {
previousStatus: string | undefined;
nextStatus: string;
source: QueuedAgentReplaySource;
}): boolean {
return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running";
}
export function takeQueuedAgentMessageReplay(
queue: readonly QueuedAgentMessageReplayItem[] | undefined,
): QueuedAgentMessageReplay | null {
if (!queue || queue.length === 0) {
return null;
}
const [next, ...remainingQueue] = queue;
return {
messageId: next.id,
text: next.text,
images: next.images,
remainingQueue,
};
}
-49
View File
@@ -1,49 +0,0 @@
import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>();
function notify(): void {
const next = getIsAppActivelyVisible();
if (next === current) {
return;
}
current = next;
for (const listener of listeners) {
listener();
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): boolean {
return current;
}
export function useAppVisible(): boolean {
useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify);
window.addEventListener("blur", notify);
}
return () => {
appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify);
}
};
}, []);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
@@ -3,7 +3,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
@@ -16,48 +15,6 @@ interface UseCheckoutPrStatusQueryOptions {
}
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
const number = Number.parseInt(match[1], 10);
return Number.isFinite(number) ? number : null;
} catch {
return null;
}
}
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
const status = payload.status;
if (!status?.url) {
return null;
}
const number = parsePullRequestNumber(status.url);
if (number === null) {
return null;
}
return {
url: status.url,
number,
state: status.isMerged || status.state === "merged"
? "merged"
: status.state === "open"
? "open"
: "closed",
};
}
export function useCheckoutPrStatusQuery({
serverId,
@@ -91,28 +48,3 @@ export function useCheckoutPrStatusQuery({
refresh: query.refetch,
};
}
export function useWorkspacePrHint({
serverId,
cwd,
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Daemon client not available");
}
return await client.checkoutPrStatus(cwd);
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
select: selectWorkspacePrHint,
});
return query.data ?? null;
}
+5 -6
View File
@@ -13,7 +13,7 @@ import {
} from "@/utils/command-center-focus-restore";
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -91,7 +91,7 @@ export type CommandCenterActionItem = {
title: string;
icon?: "plus" | "settings";
route?: Href;
shortcutKeys?: ShortcutKey[][];
shortcutKeys?: ShortcutKey[];
};
export type CommandCenterItem =
@@ -107,7 +107,7 @@ export type CommandCenterItem =
function resolveActionShortcutKeys(
actionId: string | undefined,
overrides: Record<string, string>,
): ShortcutKey[][] | undefined {
): ShortcutKey[] | undefined {
if (!actionId) return undefined;
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -115,9 +115,8 @@ function resolveActionShortcutKeys(
const bindingId = getBindingIdForAction(actionId, platform);
if (!bindingId) return undefined;
const override = overrides[bindingId];
if (override) return chordStringToShortcutKeys(override);
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : undefined;
if (override) return comboStringToShortcutKeys(override);
return getDefaultKeysForAction(actionId, platform) ?? undefined;
}
export function useCommandCenter() {
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { getIsDesktop } from "@/constants/layout";
@@ -17,11 +17,7 @@ import {
} from "@/keyboard/actions";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
type ChordState,
resolveKeyboardShortcut,
buildEffectiveBindings,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
@@ -33,27 +29,18 @@ export function useKeyboardShortcuts({
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
selectedAgentId?: string;
toggleFileExplorer?: () => void;
toggleBothSidebars?: () => void;
toggleFocusMode?: () => void;
}) {
const pathname = usePathname();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const chordStateRef = useRef<ChordState>({
candidateIndices: [],
step: 0,
timeoutId: null,
});
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
const activeServerId =
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
@@ -166,21 +153,6 @@ export function useKeyboardShortcuts({
id: "workspace.tab.new",
scope: "workspace",
});
case "worktree.archive":
return keyboardActionDispatcher.dispatch({
id: "worktree.archive",
scope: "sidebar",
});
case "worktree.new":
return keyboardActionDispatcher.dispatch({
id: "worktree.new",
scope: "sidebar",
});
case "workspace.terminal.new":
return keyboardActionDispatcher.dispatch({
id: "workspace.terminal.new",
scope: "workspace",
});
case "workspace.tab.close.current":
return keyboardActionDispatcher.dispatch({
id: "workspace.tab.close-current",
@@ -232,11 +204,6 @@ export function useKeyboardShortcuts({
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "sidebar.toggle.both":
if (toggleBothSidebars) {
toggleBothSidebars();
}
return true;
case "sidebar.toggle.right":
if (!toggleFileExplorer) {
return false;
@@ -252,11 +219,6 @@ export function useKeyboardShortcuts({
}
toggleFileExplorer();
return true;
case "view.toggle.focus":
if (toggleFocusMode) {
toggleFocusMode();
}
return true;
case "command-center.toggle": {
const store = useKeyboardShortcutsStore.getState();
if (!store.commandCenterOpen) {
@@ -294,11 +256,6 @@ export function useKeyboardShortcuts({
return;
}
const store = useKeyboardShortcutsStore.getState();
if (store.capturingShortcut) {
return;
}
const key = event.key ?? "";
if (key === "Alt" && !event.shiftKey) {
useKeyboardShortcutsStore.getState().setAltDown(true);
@@ -313,11 +270,12 @@ export function useKeyboardShortcuts({
}
}
const store = useKeyboardShortcutsStore.getState();
const focusScope = resolveKeyboardFocusScope({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const result = resolveKeyboardShortcut({
const match = resolveKeyboardShortcut({
event,
context: {
isMac,
@@ -330,41 +288,25 @@ export function useKeyboardShortcuts({
toggleFileExplorer,
}),
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) {
if (!match) {
return;
}
const handled = handleAction({
action: result.match.action,
payload: result.match.payload,
action: match.action,
payload: match.payload,
event,
});
if (!handled) {
return;
}
if (result.match.preventDefault) {
if (match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
if (match.stopPropagation) {
event.stopPropagation();
}
};
@@ -388,14 +330,6 @@ export function useKeyboardShortcuts({
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
if (chordStateRef.current.timeoutId !== null) {
clearTimeout(chordStateRef.current.timeoutId);
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
@@ -411,6 +345,5 @@ export function useKeyboardShortcuts({
selectedAgentId,
toggleAgentList,
toggleFileExplorer,
toggleFocusMode,
]);
}
+4 -5
View File
@@ -1,12 +1,12 @@
import { useMemo } from "react";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
@@ -18,10 +18,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
const override = overrides[bindingId];
if (override) {
return chordStringToShortcutKeys(override);
return comboStringToShortcutKeys(override);
}
const defaultKeys = getDefaultKeysForAction(actionId, platform);
return defaultKeys ? [defaultKeys] : null;
return getDefaultKeysForAction(actionId, platform);
}, [actionId, overrides, isMac, isDesktop]);
}
@@ -1,10 +0,0 @@
import { useCallback, useRef } from "react";
export function useStableEvent<TArgs extends unknown[], TResult>(
handler: (...args: TArgs) => TResult,
): (...args: TArgs) => TResult {
const handlerRef = useRef(handler);
handlerRef.current = handler;
return useCallback((...args: TArgs) => handlerRef.current(...args), []);
}
-7
View File
@@ -7,8 +7,6 @@ export type KeyboardFocusScope =
export type MessageInputKeyboardActionKind =
| "focus"
| "send"
| "queue"
| "dictation-toggle"
| "dictation-cancel"
| "voice-toggle"
@@ -35,13 +33,8 @@ export type KeyboardActionId =
| "workspace.navigate.relative"
| "sidebar.toggle.left"
| "sidebar.toggle.right"
| "sidebar.toggle.both"
| "command-center.toggle"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "worktree.new"
| "worktree.archive"
| "view.toggle.focus"
| "message-input.action";
export type KeyboardShortcutPayload =
@@ -20,10 +20,7 @@ export type KeyboardActionId =
| "workspace.pane.move-tab.right"
| "workspace.pane.move-tab.up"
| "workspace.pane.move-tab.down"
| "workspace.pane.close"
| "workspace.terminal.new"
| "worktree.new"
| "worktree.archive";
| "workspace.pane.close";
export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope }
@@ -45,10 +42,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "worktree.archive"; scope: KeyboardActionScope };
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
export type KeyboardActionHandler = {
handlerId: string;
@@ -1,11 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
@@ -34,30 +31,6 @@ function shortcutContext(
};
}
function initialChordState(): ChordState {
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function resolveShortcut(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
chordState?: ChordState;
onChordReset?: () => void;
bindings?: readonly ParsedShortcutBinding[];
}) {
return resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
chordState: input.chordState ?? initialChordState(),
onChordReset: input.onChordReset ?? (() => undefined),
...(input.bindings ? { bindings: input.bindings } : {}),
});
}
function expectShortcutResolution(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
@@ -66,33 +39,29 @@ function expectShortcutResolution(input: {
preventDefault?: boolean;
stopPropagation?: boolean;
}) {
const result = resolveShortcut({
event: input.event,
context: input.context,
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
});
expect(result.match?.action).toBe(input.action);
expect(match?.action).toBe(input.action);
if ("payload" in input) {
expect(result.match?.payload).toEqual(input.payload);
expect(match?.payload).toEqual(input.payload);
}
expect(result.match?.preventDefault).toBe(input.preventDefault ?? true);
expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true);
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
}
function expectNoShortcutResolution(input: {
event: Partial<KeyboardEvent>;
context?: Partial<KeyboardShortcutContext>;
}) {
const result = resolveShortcut({
event: input.event,
context: input.context,
const match = resolveKeyboardShortcut({
event: keyboardEvent(input.event),
context: shortcutContext(input.context),
});
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(false);
expect(result.nextChordState).toEqual(initialChordState());
expect(match).toBeNull();
}
type MatchingShortcutCase = {
@@ -369,66 +338,6 @@ describe("keyboard-shortcuts", () => {
it.each(nonMatchingCases)("$name", ({ event, context }) => {
expectNoShortcutResolution({ event, context });
});
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const chordBindingIndex = bindings.findIndex(
(binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
);
expect(chordBindingIndex).toBeGreaterThan(-1);
const firstResult = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
bindings,
});
expect(firstResult.match).toBeNull();
expect(firstResult.preventDefault).toBe(true);
expect(firstResult.nextChordState.step).toBe(1);
expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]);
const secondResult = resolveShortcut({
event: { key: "s", code: "KeyS" },
context: { isMac: false, isDesktop: true },
chordState: firstResult.nextChordState,
bindings,
});
expect(secondResult.match?.action).toBe("workspace.terminal.new");
expect(secondResult.match?.payload).toBeNull();
expect(secondResult.match?.preventDefault).toBe(true);
expect(secondResult.match?.stopPropagation).toBe(true);
expect(secondResult.preventDefault).toBe(false);
expect(secondResult.nextChordState).toEqual(initialChordState());
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();
const bindings = buildEffectiveBindings({
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
});
const onChordReset = vi.fn();
const result = resolveShortcut({
event: { key: "w", code: "KeyW", ctrlKey: true },
context: { isMac: false, isDesktop: true },
onChordReset,
bindings,
});
expect(result.match).toBeNull();
expect(result.preventDefault).toBe(true);
expect(result.nextChordState.timeoutId).not.toBeNull();
vi.advanceTimersByTime(1500);
expect(onChordReset).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
describe("keyboard-shortcut help sections", () => {
+84 -403
View File
@@ -5,7 +5,7 @@ import type {
KeyboardShortcutPayload,
MessageInputKeyboardActionKind,
} from "@/keyboard/actions";
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
export type { KeyCombo } from "@/keyboard/shortcut-string";
@@ -33,15 +33,8 @@ export type KeyboardShortcutHelpRow = {
note?: string;
};
export type ShortcutSectionId =
| "navigation"
| "tabs-panes"
| "projects"
| "panels"
| "agent-input";
export type KeyboardShortcutHelpSection = {
id: ShortcutSectionId;
id: "global" | "agent-input";
title: string;
rows: KeyboardShortcutHelpRow[];
};
@@ -76,7 +69,7 @@ type ShortcutPayloadDef =
interface ShortcutHelp {
id: string;
section: ShortcutSectionId;
section: "global" | "agent-input";
label: string;
keys: ShortcutKey[];
note?: string;
@@ -95,22 +88,13 @@ interface ShortcutBinding {
}
export interface ParsedShortcutBinding extends ShortcutBinding {
parsedChord: KeyCombo[];
}
export interface ChordState {
candidateIndices: number[];
step: number;
timeoutId: ReturnType<typeof setTimeout> | null;
parsedCombo: KeyCombo;
}
// --- Constants ---
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
navigation: "Navigation",
"tabs-panes": "Tabs & Panes",
projects: "Projects",
panels: "Panels",
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
global: "Global",
"agent-input": "Agent Input",
};
@@ -125,7 +109,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "new-agent",
section: "projects",
section: "global",
label: "Open project",
keys: ["mod", "shift", "O"],
},
@@ -137,64 +121,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "new-agent",
section: "projects",
section: "global",
label: "Open project",
keys: ["mod", "shift", "O"],
},
},
// --- New worktree ---
{
id: "worktree-new-cmd-o-mac",
action: "worktree.new",
combo: "Cmd+O",
when: { mac: true, commandCenter: false },
help: {
id: "new-worktree",
section: "projects",
label: "New worktree",
keys: ["mod", "O"],
},
},
{
id: "worktree-new-ctrl-o-non-mac",
action: "worktree.new",
combo: "Ctrl+O",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "new-worktree",
section: "projects",
label: "New worktree",
keys: ["mod", "O"],
},
},
// --- Archive worktree ---
{
id: "worktree-archive-cmd-shift-backspace-mac",
action: "worktree.archive",
combo: "Cmd+Shift+Backspace",
when: { mac: true, commandCenter: false },
help: {
id: "archive-worktree",
section: "projects",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
{
id: "worktree-archive-ctrl-shift-backspace-non-mac",
action: "worktree.archive",
combo: "Ctrl+Shift+Backspace",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "archive-worktree",
section: "projects",
label: "Archive worktree",
keys: ["mod", "shift", "Backspace"],
},
},
// --- Tab management ---
{
id: "workspace-tab-new-cmd-t-mac",
@@ -203,7 +135,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, commandCenter: false },
help: {
id: "workspace-tab-new",
section: "tabs-panes",
section: "global",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -215,7 +147,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-new",
section: "tabs-panes",
section: "global",
label: "New agent tab",
keys: ["mod", "T"],
},
@@ -227,7 +159,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, desktop: true, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["meta", "W"],
},
@@ -239,7 +171,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["ctrl", "W"],
},
@@ -251,7 +183,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { desktop: false, commandCenter: false },
help: {
id: "workspace-tab-close-current",
section: "tabs-panes",
section: "global",
label: "Close current tab",
keys: ["alt", "shift", "W"],
},
@@ -266,7 +198,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -279,7 +211,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["mod", "1-9"],
},
@@ -292,7 +224,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-jump-index",
section: "navigation",
section: "global",
label: "Jump to workspace",
keys: ["alt", "1-9"],
},
@@ -307,7 +239,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "global",
label: "Jump to tab",
keys: ["alt", "1-9"],
},
@@ -320,7 +252,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "index" },
help: {
id: "workspace-tab-jump-index",
section: "navigation",
section: "global",
label: "Jump to tab",
keys: ["alt", "shift", "1-9"],
},
@@ -335,7 +267,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -348,7 +280,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["mod", "["],
},
@@ -361,7 +293,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -374,7 +306,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["mod", "]"],
},
@@ -387,7 +319,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-prev",
section: "navigation",
section: "global",
label: "Previous workspace",
keys: ["alt", "["],
},
@@ -400,7 +332,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-next",
section: "navigation",
section: "global",
label: "Next workspace",
keys: ["alt", "]"],
},
@@ -415,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: -1 },
help: {
id: "workspace-tab-prev",
section: "navigation",
section: "global",
label: "Previous tab",
keys: ["alt", "shift", "["],
},
@@ -428,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
payload: { type: "delta", delta: 1 },
help: {
id: "workspace-tab-next",
section: "navigation",
section: "global",
label: "Next tab",
keys: ["alt", "shift", "]"],
},
@@ -439,10 +371,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-split-right-cmd-backslash",
action: "workspace.pane.split.right",
combo: "Cmd+\\",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-right",
section: "tabs-panes",
section: "global",
label: "Split pane right",
keys: ["mod", "\\"],
},
@@ -451,10 +383,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-split-down-cmd-shift-backslash",
action: "workspace.pane.split.down",
combo: "Cmd+Shift+\\",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-split-down",
section: "tabs-panes",
section: "global",
label: "Split pane down",
keys: ["mod", "shift", "\\"],
},
@@ -463,10 +395,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-left-cmd-shift-left",
action: "workspace.pane.focus.left",
combo: "Cmd+Shift+ArrowLeft",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-left",
section: "tabs-panes",
section: "global",
label: "Focus pane left",
keys: ["mod", "shift", "Left"],
},
@@ -475,10 +407,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-right-cmd-shift-right",
action: "workspace.pane.focus.right",
combo: "Cmd+Shift+ArrowRight",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-right",
section: "tabs-panes",
section: "global",
label: "Focus pane right",
keys: ["mod", "shift", "Right"],
},
@@ -487,10 +419,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-up-cmd-shift-up",
action: "workspace.pane.focus.up",
combo: "Cmd+Shift+ArrowUp",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-up",
section: "tabs-panes",
section: "global",
label: "Focus pane up",
keys: ["mod", "shift", "Up"],
},
@@ -499,10 +431,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-down-cmd-shift-down",
action: "workspace.pane.focus.down",
combo: "Cmd+Shift+ArrowDown",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-focus-down",
section: "tabs-panes",
section: "global",
label: "Focus pane down",
keys: ["mod", "shift", "Down"],
},
@@ -511,10 +443,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-left-cmd-shift-alt-left",
action: "workspace.pane.move-tab.left",
combo: "Cmd+Alt+Shift+ArrowLeft",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-left",
section: "tabs-panes",
section: "global",
label: "Move tab left",
keys: ["mod", "shift", "alt", "Left"],
},
@@ -523,10 +455,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-right-cmd-shift-alt-right",
action: "workspace.pane.move-tab.right",
combo: "Cmd+Alt+Shift+ArrowRight",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-right",
section: "tabs-panes",
section: "global",
label: "Move tab right",
keys: ["mod", "shift", "alt", "Right"],
},
@@ -535,10 +467,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-up-cmd-shift-alt-up",
action: "workspace.pane.move-tab.up",
combo: "Cmd+Alt+Shift+ArrowUp",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-up",
section: "tabs-panes",
section: "global",
label: "Move tab up",
keys: ["mod", "shift", "alt", "Up"],
},
@@ -547,10 +479,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-move-tab-down-cmd-shift-alt-down",
action: "workspace.pane.move-tab.down",
combo: "Cmd+Alt+Shift+ArrowDown",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-move-tab-down",
section: "tabs-panes",
section: "global",
label: "Move tab down",
keys: ["mod", "shift", "alt", "Down"],
},
@@ -559,41 +491,15 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-close-cmd-shift-w",
action: "workspace.pane.close",
combo: "Cmd+Shift+W",
when: { mac: true, commandCenter: false },
when: { mac: true, terminal: false, commandCenter: false },
help: {
id: "workspace-pane-close",
section: "tabs-panes",
section: "global",
label: "Close pane",
keys: ["mod", "shift", "W"],
},
},
// --- New terminal ---
{
id: "workspace-terminal-new-cmd-shift-t-mac",
action: "workspace.terminal.new",
combo: "Cmd+Shift+T",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-terminal-new",
section: "panels",
label: "New terminal",
keys: ["mod", "shift", "T"],
},
},
{
id: "workspace-terminal-new-ctrl-shift-t-non-mac",
action: "workspace.terminal.new",
combo: "Ctrl+Shift+T",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "workspace-terminal-new",
section: "panels",
label: "New terminal",
keys: ["mod", "shift", "T"],
},
},
// --- Command center ---
{
id: "command-center-toggle-cmd-k-mac",
@@ -602,7 +508,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-command-center",
section: "panels",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -614,7 +520,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, terminal: false },
help: {
id: "toggle-command-center",
section: "panels",
section: "global",
label: "Toggle command center",
keys: ["mod", "K"],
},
@@ -629,7 +535,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { focusScope: "other" },
help: {
id: "show-shortcuts",
section: "panels",
section: "global",
label: "Show keyboard shortcuts",
keys: ["?"],
note: "Available when focus is not in a text field or terminal.",
@@ -644,21 +550,27 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "B"],
},
},
{
id: "sidebar-toggle-left-cmd-period-mac",
action: "sidebar.toggle.left",
combo: "Cmd+.",
when: { mac: true, commandCenter: false },
},
{
id: "sidebar-toggle-left-ctrl-period-non-mac",
action: "sidebar.toggle.left",
combo: "Ctrl+B",
combo: "Ctrl+.",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-left-sidebar",
section: "panels",
section: "global",
label: "Toggle left sidebar",
keys: ["mod", "B"],
keys: ["mod", "."],
},
},
{
@@ -668,7 +580,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -680,7 +592,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
help: {
id: "toggle-right-sidebar",
section: "panels",
section: "global",
label: "Toggle right sidebar",
keys: ["mod", "E"],
},
@@ -692,58 +604,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
when: { hasSelectedAgent: true, commandCenter: false },
},
// --- Toggle both sidebars ---
{
id: "sidebar-toggle-both-cmd-period-mac",
action: "sidebar.toggle.both",
combo: "Cmd+.",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
label: "Toggle both sidebars",
keys: ["mod", "."],
},
},
{
id: "sidebar-toggle-both-ctrl-period-non-mac",
action: "sidebar.toggle.both",
combo: "Ctrl+.",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-both-sidebars",
section: "panels",
label: "Toggle both sidebars",
keys: ["mod", "."],
},
},
// --- Focus mode ---
{
id: "view-toggle-focus-cmd-shift-f-mac",
action: "view.toggle.focus",
combo: "Cmd+Shift+F",
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
help: {
id: "toggle-focus",
section: "panels",
label: "Toggle focus mode",
keys: ["mod", "shift", "F"],
},
},
{
id: "view-toggle-focus-ctrl-shift-f-non-mac",
action: "view.toggle.focus",
combo: "Ctrl+Shift+F",
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
section: "panels",
label: "Toggle focus mode",
keys: ["mod", "shift", "F"],
},
},
// --- Message input ---
{
id: "message-input-voice-toggle-cmd-shift-d-mac",
@@ -814,52 +674,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
keys: ["Esc"],
},
},
{
id: "message-input-send-enter",
action: "message-input.action",
combo: "Enter",
when: { focusScope: "message-input", commandCenter: false },
payload: { type: "message-input", kind: "send" },
preventDefault: false,
stopPropagation: false,
help: {
id: "message-input-send",
section: "agent-input",
label: "Send message",
keys: ["Enter"],
},
},
{
id: "message-input-queue-cmd-enter-mac",
action: "message-input.action",
combo: "Cmd+Enter",
when: { mac: true, focusScope: "message-input", commandCenter: false },
payload: { type: "message-input", kind: "queue" },
preventDefault: false,
stopPropagation: false,
help: {
id: "message-input-queue",
section: "agent-input",
label: "Queue message",
keys: ["mod", "Enter"],
},
},
{
id: "message-input-queue-ctrl-enter-non-mac",
action: "message-input.action",
combo: "Ctrl+Enter",
when: { mac: false, focusScope: "message-input", commandCenter: false },
payload: { type: "message-input", kind: "queue" },
preventDefault: false,
stopPropagation: false,
help: {
id: "message-input-queue",
section: "agent-input",
label: "Queue message",
keys: ["mod", "Enter"],
},
},
{
id: "message-input-voice-mute-toggle",
action: "message-input.action",
@@ -879,12 +693,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
// --- Parse bindings at module load ---
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
const parsedChord = parseChordString(binding.combo);
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
const parsedCombo = parseShortcutString(binding.combo);
if (binding.repeat === false) {
parsedCombo.repeat = false;
}
return { ...binding, parsedChord };
return { ...binding, parsedCombo };
}
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
@@ -898,17 +711,16 @@ export function buildEffectiveBindings(
if (override === undefined) {
return binding;
}
let parsedChord: KeyCombo[];
let parsedCombo: KeyCombo;
try {
parsedChord = parseChordString(override);
parsedCombo = parseShortcutString(override);
} catch {
return binding;
}
const lastCombo = parsedChord.at(-1);
if (binding.repeat === false && lastCombo) {
lastCombo.repeat = false;
if (binding.repeat === false) {
parsedCombo.repeat = false;
}
return { ...binding, combo: override, parsedChord };
return { ...binding, combo: override, parsedCombo };
});
}
@@ -985,27 +797,6 @@ function resolvePayload(
}
}
const CHORD_TIMEOUT_MS = 1500;
function clearChordTimeout(timeoutId: ReturnType<typeof setTimeout> | null): void {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
}
function createChordTimeout(onChordReset: () => void): ReturnType<typeof setTimeout> {
return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
}
function resetChordState(input: ChordState): ChordState {
clearChordTimeout(input.timeoutId);
return {
candidateIndices: [],
step: 0,
timeoutId: null,
};
}
function helpMatchesPlatform(
when: ShortcutWhen | undefined,
context: KeyboardShortcutPlatformContext,
@@ -1020,131 +811,24 @@ function helpMatchesPlatform(
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): {
match: KeyboardShortcutMatch | null;
nextChordState: ChordState;
preventDefault: boolean;
};
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
bindings?: readonly ParsedShortcutBinding[];
}): {
match: KeyboardShortcutMatch | null;
nextChordState: ChordState;
preventDefault: boolean;
} {
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
if (chordState.step === 0) {
const advancingCandidateIndices: number[] = [];
let singleComboMatch: KeyboardShortcutMatch | null = null;
for (const [index, binding] of bindings.entries()) {
const firstCombo = binding.parsedChord[0];
if (!firstCombo) {
continue;
}
if (!matchesCombo(firstCombo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
if (binding.parsedChord.length > 1) {
advancingCandidateIndices.push(index);
continue;
}
if (!singleComboMatch) {
singleComboMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
}
if (advancingCandidateIndices.length > 0) {
return {
match: null,
nextChordState: {
candidateIndices: advancingCandidateIndices,
step: 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: singleComboMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
}
const matchingCandidateIndices: number[] = [];
let completedMatch: KeyboardShortcutMatch | null = null;
for (const index of chordState.candidateIndices) {
const binding = bindings[index];
if (!binding) {
continue;
}
const combo = binding.parsedChord[chordState.step];
if (!combo) {
continue;
}
if (!matchesCombo(combo, event, context.isMac)) {
}): KeyboardShortcutMatch | null {
const { event, context, bindings = DEFAULT_BINDINGS } = input;
for (const binding of bindings) {
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
continue;
}
if (chordState.step + 1 === binding.parsedChord.length) {
completedMatch = {
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
break;
}
matchingCandidateIndices.push(index);
}
if (completedMatch) {
return {
match: completedMatch,
nextChordState: resetChordState(chordState),
preventDefault: false,
action: binding.action,
payload: resolvePayload(binding.payload, event),
preventDefault: binding.preventDefault ?? true,
stopPropagation: binding.stopPropagation ?? true,
};
}
if (matchingCandidateIndices.length > 0) {
clearChordTimeout(chordState.timeoutId);
return {
match: null,
nextChordState: {
candidateIndices: matchingCandidateIndices,
step: chordState.step + 1,
timeoutId: createChordTimeout(onChordReset),
},
preventDefault: true,
};
}
return {
match: null,
nextChordState: resetChordState(chordState),
preventDefault: false,
};
return null;
}
export function getBindingIdForAction(
@@ -1184,11 +868,8 @@ export function buildKeyboardShortcutHelpSections(
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
): KeyboardShortcutHelpSection[] {
const seenRows = new Set<string>();
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
["navigation", []],
["tabs-panes", []],
["projects", []],
["panels", []],
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
["global", []],
["agent-input", []],
]);
@@ -1218,7 +899,7 @@ export function buildKeyboardShortcutHelpSections(
});
}
const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"];
return sectionOrder.flatMap((sectionId) => {
const rows = rowsBySection.get(sectionId) ?? [];
@@ -36,8 +36,6 @@ KEY_MAP["`"] = { code: "Backquote", key: "`" };
KEY_MAP["/"] = { code: "Slash" };
KEY_MAP["?"] = { code: "Slash", key: "?" };
KEY_MAP["Space"] = { code: "Space", key: " " };
KEY_MAP["Enter"] = { code: "Enter", key: "Enter" };
KEY_MAP["Backspace"] = { code: "Backspace" };
KEY_MAP["Escape"] = { code: "Escape" };
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
@@ -103,10 +101,6 @@ export function parseShortcutString(s: string): KeyCombo {
return combo;
}
export function parseChordString(s: string): KeyCombo[] {
return s.split(" ").map(parseShortcutString);
}
export function keyComboToString(combo: KeyCombo): string {
const parts: string[] = [];
@@ -126,10 +120,6 @@ export function keyComboToString(combo: KeyCombo): string {
return parts.join("+");
}
export function chordToString(chord: KeyCombo[]): string {
return chord.map(keyComboToString).join(" ");
}
const MODIFIER_CODES = new Set([
"MetaLeft",
"MetaRight",
@@ -166,10 +156,6 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
return keys;
}
export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
return s.split(" ").map(comboStringToShortcutKeys);
}
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null;
+6 -8
View File
@@ -22,7 +22,6 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import {
@@ -104,13 +103,12 @@ function useAgentPanelDescriptor(
function AgentPanel() {
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
invariant(target.kind === "agent", "AgentPanel requires agent target");
function openWorkspaceFile(input: { filePath: string }) {
openFileInWorkspace(input.filePath);
}
const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile);
const handleOpenWorkspaceFile = useCallback(
(input: { filePath: string }) => {
openFileInWorkspace(input.filePath);
},
[openFileInWorkspace],
);
return (
<AgentPanelContent
serverId={serverId}
+10 -3
View File
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const { serverId, workspaceId, target, openTab } = usePaneContext();
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused) {
@@ -60,8 +60,15 @@ function TerminalPanel() {
<TerminalPane
serverId={serverId}
cwd={workspaceId}
terminalId={target.terminalId}
isPaneFocused={isPaneFocused}
selectedTerminalId={target.terminalId}
onSelectedTerminalIdChange={(terminalId) => {
if (!terminalId) {
return;
}
openTab({ kind: "terminal", terminalId });
}}
hideHeader
manageTerminalDirectorySubscription={false}
/>
);
}
+3 -3
View File
@@ -67,7 +67,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
type SettingsSectionId =
| "hosts"
| "appearance"
| "shortcuts"
| "keyboard-shortcuts"
| "diagnostics"
| "about"
| "permissions"
@@ -83,7 +83,7 @@ function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDe
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
];
@@ -514,7 +514,7 @@ function SettingsSectionContent({
return <HostsSection {...hostsProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "shortcuts":
case "keyboard-shortcuts":
return <KeyboardShortcutsSection />;
case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />;
@@ -10,31 +10,16 @@ import {
getBindingIdForAction,
type KeyboardShortcutHelpRow,
} from "@/keyboard/keyboard-shortcuts";
import {
chordStringToShortcutKeys,
comboStringToShortcutKeys,
keyboardEventToComboString,
} from "@/keyboard/shortcut-string";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
function ShortcutSequence({ chord }: { chord: string[] | null }) {
if (!chord || chord.length === 0) {
return <Text style={styles.capturingText}>Press shortcut...</Text>;
}
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
}
function ShortcutRow({
row,
bindingId,
overrideCombo,
isCapturing,
capturedCombos,
onRebind,
onDone,
onCancel,
onReset,
}: {
@@ -42,38 +27,29 @@ function ShortcutRow({
bindingId: string | null;
overrideCombo: string | undefined;
isCapturing: boolean;
capturedCombos: string[];
onRebind: () => void;
onDone: () => void;
onCancel: () => void;
onReset: () => void;
}) {
const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
return (
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
<Text style={styles.rowLabel}>{row.label}</Text>
<View style={styles.rowActions}>
{isCapturing ? (
<ShortcutSequence chord={capturedCombos} />
<Text style={styles.capturingText}>Press shortcut...</Text>
) : (
<Shortcut chord={displayChord} />
<Shortcut keys={displayKeys} />
)}
{bindingId !== null && (
<>
{isCapturing && capturedCombos.length > 0 ? (
<Button variant="ghost" size="sm" onPress={onDone}>
Done
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
</>
<Button
variant="ghost"
size="sm"
onPress={isCapturing ? onCancel : onRebind}
>
{isCapturing ? "Cancel" : "Rebind"}
</Button>
)}
{overrideCombo !== undefined && !isCapturing && (
<Button variant="ghost" size="sm" onPress={onReset}>
@@ -87,73 +63,51 @@ function ShortcutRow({
export function KeyboardShortcutsSection() {
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
useKeyboardShortcutOverrides();
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
const isMac = getShortcutOs() === "mac";
const isDesktop = getIsDesktop();
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
function cancelCapture() {
setCapturedCombos([]);
setCapturingBindingId(null);
setCapturingShortcut(false);
}
function startCapture(bindingId: string) {
setCapturedCombos([]);
setCapturingBindingId(bindingId);
setCapturingShortcut(true);
}
function saveCapture() {
if (capturingBindingId === null || capturedCombos.length === 0) {
return;
}
void setOverride(capturingBindingId, capturedCombos.join(" "));
cancelCapture();
}
useEffect(() => {
if (Platform.OS !== "web") return;
if (capturingBindingId === null) return;
function handleKeyDown(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
const activeBindingId = capturingBindingId;
function handleKeyDown(event: KeyboardEvent) {
const key = event.key ?? "";
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
if (key === "Escape") {
event.preventDefault();
event.stopPropagation();
setCapturingBindingId(null);
return;
}
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
return;
}
const comboString = keyboardEventToComboString(event);
if (comboString === null) {
return;
}
if (comboString === null) return;
setCapturedCombos((current) => [...current, comboString]);
event.preventDefault();
event.stopPropagation();
void setOverride(activeBindingId, comboString);
setCapturingBindingId(null);
}
window.addEventListener("keydown", handleKeyDown, true);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
};
}, [capturingBindingId]);
useEffect(() => {
return () => {
setCapturingShortcut(false);
};
}, [setCapturingShortcut]);
}, [capturingBindingId, setOverride]);
if (Platform.OS !== "web") {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
<View style={[settingsStyles.card, styles.mobileCard]}>
<Text style={styles.mobileText}>
Keyboard shortcuts are only available on desktop.
@@ -166,7 +120,7 @@ export function KeyboardShortcutsSection() {
return (
<View style={settingsStyles.section}>
<View style={styles.sectionHeader}>
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
{hasOverrides && (
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
Reset all
@@ -190,14 +144,8 @@ export function KeyboardShortcutsSection() {
bindingId={bindingId}
overrideCombo={overrideCombo}
isCapturing={capturingBindingId === bindingId}
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
onRebind={() => {
if (bindingId) {
startCapture(bindingId);
}
}}
onDone={saveCapture}
onCancel={cancelCapture}
onRebind={() => setCapturingBindingId(bindingId)}
onCancel={() => setCapturingBindingId(null)}
onReset={() => {
if (bindingId) void removeOverride(bindingId);
}}
@@ -1,64 +0,0 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
activeTabId: string | null;
allTabIds: string[];
cap: number;
}
interface UseMountedTabSetResult {
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) {
return false;
}
for (const value of left) {
if (!right.has(value)) {
return false;
}
}
return true;
}
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
const { activeTabId, allTabIds, cap } = input;
const allTabIdsKey = allTabIds.join("\u0000");
const availableTabIds = useMemo(() => new Set(allTabIds), [allTabIdsKey]);
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
const lruRef = useRef(
activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : [],
);
useLayoutEffect(() => {
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
if (activeTabId && availableTabIds.has(activeTabId)) {
const existingIndex = nextLru.indexOf(activeTabId);
if (existingIndex >= 0) {
nextLru.splice(existingIndex, 1);
}
nextLru.unshift(activeTabId);
}
if (nextLru.length > cap) {
nextLru.length = cap;
}
lruRef.current = nextLru;
setMountedTabIds((previousMountedTabIds) => {
const nextMountedTabIds = new Set(nextLru);
return setsEqual(previousMountedTabIds, nextMountedTabIds)
? previousMountedTabIds
: nextMountedTabIds;
});
}, [activeTabId, availableTabIds, cap]);
return { mountedTabIds };
}
@@ -20,7 +20,6 @@ import {
} from "@/components/ui/context-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
import {
@@ -309,10 +308,6 @@ export function WorkspaceDesktopTabsRow({
tabDropPreviewIndex = null,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
@@ -453,12 +448,10 @@ export function WorkspaceDesktopTabsRow({
>
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
{newAgentTabKeys ? (
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
@@ -474,13 +467,8 @@ export function WorkspaceDesktopTabsRow({
>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
{newTerminalTabKeys ? (
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -495,13 +483,8 @@ export function WorkspaceDesktopTabsRow({
>
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
{splitRightKeys ? (
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane right</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -516,13 +499,8 @@ export function WorkspaceDesktopTabsRow({
>
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.newTabTooltipRow}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
{splitDownKeys ? (
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
) : null}
</View>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>Split pane down</Text>
</TooltipContent>
</Tooltip>
</View>
@@ -74,7 +74,6 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import {
@@ -97,9 +96,7 @@ import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-sta
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
@@ -440,68 +437,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
);
});
interface MobileMountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isPaneFocused: boolean;
paneId: string | null;
buildPaneContentModel: (input: {
paneId: string | null;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
tabDescriptor,
isVisible,
isPaneFocused,
paneId,
buildPaneContentModel,
}: MobileMountedTabSlotProps) {
const content = useMemo(
() =>
buildPaneContentModel({
paneId,
isPaneFocused,
tab: tabDescriptor,
}),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
);
return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
</View>
);
});
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
const next = new Map<string, WorkspaceTabDescriptor>();
for (const tabDescriptor of tabDescriptors) {
const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
if (
cachedDescriptor &&
cachedDescriptor.key === tabDescriptor.key &&
cachedDescriptor.kind === tabDescriptor.kind &&
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
) {
next.set(tabDescriptor.tabId, cachedDescriptor);
continue;
}
next.set(tabDescriptor.tabId, tabDescriptor);
}
return next;
}, [tabDescriptors]);
useEffect(() => {
cacheRef.current = tabDescriptorMap;
}, [tabDescriptorMap]);
return tabDescriptorMap;
}
export function WorkspaceScreen({ serverId, workspaceId }: WorkspaceScreenProps) {
const isFocused = useIsFocused();
@@ -550,7 +485,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
const normalizedWorkspaceId =
@@ -675,10 +609,17 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}));
});
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit") {
return;
}
});
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
return () => {
unsubscribeChanged();
unsubscribeStreamExit();
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
};
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
@@ -806,7 +747,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
const pinnedAgentIds = useWorkspaceLayoutStore((state) =>
@@ -1525,9 +1465,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
case "workspace.tab.new":
handleCreateDraftTab();
return true;
case "workspace.terminal.new":
handleCreateTerminal();
return true;
case "workspace.tab.close-current":
if (activeTabId) {
void handleCloseTabById(activeTabId);
@@ -1556,7 +1493,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return false;
}
},
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
);
const handleWorkspacePaneAction = useCallback(
@@ -1623,11 +1560,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const activePaneTabId = focusedPaneTabState.activeTabId;
const adjacentPaneId = findAdjacentPane(workspaceLayout.root, focusedPane.id, direction);
if (activePaneTabId && adjacentPaneId) {
paneFocusSuppressedRef.current = true;
moveWorkspaceTabToPane(persistenceKey, activePaneTabId, adjacentPaneId);
requestAnimationFrame(() => {
paneFocusSuppressedRef.current = false;
});
}
}
return true;
@@ -1665,7 +1598,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
"workspace.tab.close-current",
"workspace.tab.navigate-index",
"workspace.tab.navigate-relative",
"workspace.terminal.new",
] as const,
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
priority: 100,
@@ -1791,33 +1723,37 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
retargetWorkspaceTab,
};
});
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: focusedPaneTabIds,
cap: 3,
const activePaneContent = useMemo(
() =>
activeTabDescriptor
? buildPaneContentModel({
tab: activeTabDescriptor,
paneId: focusedPaneTabState.pane?.id ?? null,
isPaneFocused: true,
focusPaneBeforeOpen: false,
})
: null,
[activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id],
);
const prevActivePaneDeps = useRef({
activeTabDescriptor,
buildPaneContentModel,
paneId: focusedPaneTabState.pane?.id,
});
useEffect(() => {
const prev = prevActivePaneDeps.current;
if (prev.activeTabDescriptor !== activeTabDescriptor)
console.log("[activePaneContent] activeTabDescriptor changed");
if (prev.buildPaneContentModel !== buildPaneContentModel)
console.log("[activePaneContent] buildPaneContentModel changed");
if (prev.paneId !== focusedPaneTabState.pane?.id)
console.log("[activePaneContent] paneId changed");
prevActivePaneDeps.current = {
activeTabDescriptor,
buildPaneContentModel,
paneId: focusedPaneTabState.pane?.id,
};
});
const mountedFocusedPaneTabIds = useMemo(
() => focusedPaneTabIds.filter((tabId) => mountedFocusedPaneTabIdsSet.has(tabId)),
[focusedPaneTabIds, mountedFocusedPaneTabIdsSet],
);
const buildMobilePaneContentModel = useCallback(
function buildMobilePaneContentModel(input: {
paneId: string | null;
tab: WorkspaceTabDescriptor;
isPaneFocused: boolean;
}) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
isPaneFocused: input.isPaneFocused,
focusPaneBeforeOpen: false,
});
},
[buildPaneContentModel],
);
const content = shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
@@ -1838,100 +1774,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</View>
)
) : (
mountedFocusedPaneTabIds.map((tabId) => {
const tabDescriptor = focusedPaneTabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MobileMountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor.tabId}
isPaneFocused={tabId === activeTabDescriptor.tabId}
paneId={focusedPaneId}
buildPaneContentModel={buildMobilePaneContentModel}
/>
);
})
<WorkspacePaneContent content={activePaneContent!} />
);
const buildDesktopPaneContentModel = useCallback(
function buildDesktopPaneContentModel(input: {
paneId: string;
tab: WorkspaceTabDescriptor;
isPaneFocused: boolean;
}) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
isPaneFocused: input.isPaneFocused,
focusPaneBeforeOpen: true,
});
},
[buildPaneContentModel],
);
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
}
focusWorkspacePane(persistenceKey, paneId);
});
const handleSplitPane = useCallback(
function handleSplitPane(input: {
tabId: string;
targetPaneId: string;
position: "left" | "right" | "top" | "bottom";
}) {
if (!persistenceKey) {
return;
}
splitWorkspacePane(persistenceKey, input);
},
[persistenceKey, splitWorkspacePane],
);
const handleMoveTabToPane = useCallback(
function handleMoveTabToPane(tabId: string, toPaneId: string) {
if (!persistenceKey) {
return;
}
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
},
[moveWorkspaceTabToPane, persistenceKey],
);
const handleResizePaneSplit = useCallback(
function handleResizePaneSplit(groupId: string, sizes: number[]) {
if (!persistenceKey) {
return;
}
resizeWorkspaceSplit(persistenceKey, groupId, sizes);
},
[persistenceKey, resizeWorkspaceSplit],
);
const handleReorderTabsInPane = useCallback(
function handleReorderTabsInPane(paneId: string, tabIds: string[]) {
if (!persistenceKey) {
return;
}
reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds);
},
[persistenceKey, reorderWorkspaceTabsInPane],
);
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
return (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No tabs in this pane.</Text>
</View>
);
}, []);
return (
<View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
{Platform.OS === "web" && activeTabDescriptor ? (
@@ -1950,7 +1795,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
) : null}
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
{(!isFocusModeEnabled || isMobile) && (
<ScreenHeader
left={
<>
@@ -2046,61 +1890,40 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={
isExplorerOpen ? "Close explorer" : "Open explorer"
}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) &&
styles.sourceControlButtonHovered,
]}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const iconColor = active
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<SourceControlPanelIcon
size={theme.iconSize.md}
color={iconColor}
/>
{workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>
+{workspaceDescriptor.diffStat.additions}
</Text>
<Text style={styles.diffStatDeletions}>
-{workspaceDescriptor.diffStat.deletions}
</Text>
</View>
) : null}
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent
testID="workspace-explorer-toggle-tooltip"
side="left"
align="center"
offset={8}
>
<View style={styles.explorerTooltipRow}>
<Text style={styles.explorerTooltipText}>Toggle explorer</Text>
<Shortcut keys={["mod", "E"]} style={styles.explorerTooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) && styles.sourceControlButtonHovered,
]}
>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const iconColor = active
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
{workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>
+{workspaceDescriptor.diffStat.additions}
</Text>
<Text style={styles.diffStatDeletions}>
-{workspaceDescriptor.diffStat.deletions}
</Text>
</View>
) : null}
</>
);
}}
</Pressable>
</>
) : null}
{!isMobile && !isGitCheckout ? (
@@ -2158,7 +1981,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</View>
}
/>
)}
{isMobile ? (
<MobileWorkspaceTabSwitcher
@@ -2189,7 +2011,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
{workspaceLayout && persistenceKey ? (
<SplitContainer
layout={workspaceLayout}
focusModeEnabled={isFocusModeEnabled && !isMobile}
workspaceKey={persistenceKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
@@ -2207,15 +2028,38 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseOtherTabs={handleCloseOtherTabsInPane}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane}
buildPaneContentModel={({ paneId, tab, isPaneFocused }) =>
buildPaneContentModel({
tab,
paneId,
isPaneFocused,
focusPaneBeforeOpen: true,
})
}
onFocusPane={(paneId) => {
focusWorkspacePane(persistenceKey, paneId);
}}
onNewTerminalTab={handleCreateTerminal}
onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateDraftSplit}
onMoveTabToPane={handleMoveTabToPane}
onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane}
renderPaneEmptyState={renderSplitPaneEmptyState}
onSplitPane={(input) => {
splitWorkspacePane(persistenceKey, input);
}}
onSplitPaneEmpty={(input) => {
handleCreateDraftSplit(input);
}}
onMoveTabToPane={(tabId, toPaneId) => {
moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId);
}}
onResizeSplit={(groupId, sizes) => {
resizeWorkspaceSplit(persistenceKey, groupId, sizes);
}}
onReorderTabsInPane={(paneId, tabIds) => {
reorderWorkspaceTabsInPane(persistenceKey, paneId, tabIds);
}}
renderPaneEmptyState={() => (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>No tabs in this pane.</Text>
</View>
)}
/>
) : (
content
@@ -2225,15 +2069,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</View>
</View>
{(!isFocusModeEnabled || isMobile) && (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={normalizedWorkspaceId}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
)}
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={normalizedWorkspaceId}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
</View>
</View>
);
@@ -2368,19 +2210,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
explorerTooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
explorerTooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
explorerTooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
mobileTabsRow: {
backgroundColor: theme.colors.surface0,
borderBottomWidth: theme.borderWidth[1],
@@ -4,9 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -20,10 +18,4 @@ describe("keyboard-shortcuts-store", () => {
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
});
it("toggles shortcut capture state", () => {
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
});
});
@@ -5,7 +5,6 @@ interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
cmdOrCtrlDown: boolean;
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
@@ -16,7 +15,6 @@ interface KeyboardShortcutsState {
setCommandCenterOpen: (open: boolean) => void;
setProjectPickerOpen: (open: boolean) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
setCmdOrCtrlDown: (down: boolean) => void;
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
@@ -28,7 +26,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
commandCenterOpen: false,
projectPickerOpen: false,
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
@@ -37,7 +34,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => set({ altDown: down }),
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
setSidebarShortcutWorkspaceTargets: (targets) =>
+1 -46
View File
@@ -33,7 +33,6 @@ type MobilePanelView = "agent" | "agent-list" | "file-explorer";
interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
focusModeEnabled: boolean;
}
export type SortOption = "name" | "modified" | "size";
@@ -79,8 +78,6 @@ interface PanelState {
closeToAgent: () => void;
toggleAgentList: () => void;
toggleFileExplorer: () => void;
toggleBothSidebars: () => void;
toggleFocusMode: () => void;
// File explorer settings actions
setExplorerTab: (tab: ExplorerTab) => void;
@@ -136,7 +133,6 @@ export const usePanelStore = create<PanelState>()(
desktop: {
agentListOpen: DEFAULT_DESKTOP_OPEN,
fileExplorerOpen: false,
focusModeEnabled: false,
},
// File explorer defaults
@@ -178,7 +174,6 @@ export const usePanelStore = create<PanelState>()(
// On desktop, closing depends on which panel triggered it
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
desktop: {
...state.desktop,
agentListOpen: state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
fileExplorerOpen:
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
@@ -221,29 +216,6 @@ export const usePanelStore = create<PanelState>()(
return nextState;
}),
toggleFocusMode: () =>
set((state) => ({
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})),
toggleBothSidebars: () =>
set((state) => {
// If either sidebar is open, close both. Otherwise, open both.
const anyOpen = state.desktop.agentListOpen || state.desktop.fileExplorerOpen;
if (anyOpen) {
return {
mobileView: "agent" as MobilePanelView,
desktop: { ...state.desktop, agentListOpen: false, fileExplorerOpen: false },
};
}
const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
return {
mobileView: "agent" as MobilePanelView,
desktop: { ...state.desktop, agentListOpen: true, fileExplorerOpen: true },
...(resolvedTab ? { explorerTab: resolvedTab } : {}),
};
}),
setExplorerTab: (tab) => set({ explorerTab: tab }),
setExplorerTabForCheckout: ({ serverId, cwd, isGit, tab }) =>
set((state) => {
@@ -295,7 +267,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 8,
version: 6,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -350,23 +322,6 @@ export const usePanelStore = create<PanelState>()(
state.explorerTabByCheckout = next;
}
if (version < 8) {
const desktop = state.desktop as Record<string, unknown> | undefined;
if (desktop) {
if ("zoomed" in desktop) {
desktop.focusModeEnabled = desktop.zoomed;
delete desktop.zoomed;
}
if ("focused" in desktop) {
desktop.focusModeEnabled = desktop.focused;
delete desktop.focused;
}
if (typeof desktop.focusModeEnabled !== "boolean") {
desktop.focusModeEnabled = false;
}
}
}
if (version < 6 || typeof state.sidebarWidth !== "number") {
state.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
}
@@ -5,12 +5,10 @@ import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
type StubTerminal = {
write: (text: string, callback?: () => void) => void;
reset: () => void;
resize?: (cols: number, rows: number) => void;
focus: () => void;
refresh?: (start: number, end: number) => void;
options?: { theme?: unknown };
rows?: number;
cols?: number;
};
function createRuntimeWithTerminal(): {
@@ -37,12 +35,10 @@ function createRuntimeWithTerminal(): {
resetCalls += 1;
terminal.resetCalls = resetCalls;
},
resize: () => {},
focus: () => {},
refresh: () => {},
options: { theme: undefined },
rows: 0,
cols: 0,
resetCalls,
};
@@ -192,16 +188,12 @@ describe("terminal-emulator-runtime", () => {
refresh,
options: { theme: { background: "before" } },
rows: 12,
cols: 40,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
runtime.setTheme({ theme: { background: "after" } as never });
expect(terminal.options?.theme).toEqual({
background: "after",
overviewRulerBorder: "after",
});
expect(terminal.options?.theme).toEqual({ background: "after" });
expect(refresh).toHaveBeenCalledWith(0, 11);
});
@@ -1,13 +1,7 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { LigaturesAddon } from "@xterm/addon-ligatures";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -16,12 +10,11 @@ import {
normalizeTerminalTransportKey,
shouldInterceptDomTerminalKey,
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalEmulatorRuntimeMountInput = {
root: HTMLDivElement;
host: HTMLDivElement;
initialSnapshot: TerminalState | null;
initialOutputText: string;
theme: ITheme;
};
@@ -57,10 +50,8 @@ type TerminalEmulatorRuntimeDisposables = {
};
type TerminalOutputOperation = {
type: "write" | "clear" | "snapshot";
type: "write" | "clear";
text: string;
rows?: number;
cols?: number;
suppressInput?: boolean;
onCommitted?: () => void;
};
@@ -154,9 +145,6 @@ export class TerminalEmulatorRuntime {
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.0,
macOptionIsMeta: true,
minimumContrastRatio: 1,
rescaleOverlappingGlyphs: true,
overviewRuler: {
width: 8,
},
@@ -168,14 +156,6 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
terminal.loadAddon(new LigaturesAddon());
} catch {
// Ligatures require Font Access API or compatible environment
}
terminal.open(input.host);
try {
terminal.unicode.activeVersion = "11";
@@ -209,14 +189,6 @@ export class TerminalEmulatorRuntime {
}
});
terminal.loadAddon(new ImageAddon());
// Suppress terminal query responses — the server-side headless xterm handles these.
// Without this, xterm.js generates DA/CPR responses via onData that feed back
// to the PTY as visible text.
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
});
@@ -235,10 +207,6 @@ export class TerminalEmulatorRuntime {
return;
}
if (input.root.offsetWidth === 0 || input.root.offsetHeight === 0) {
return;
}
try {
currentFitAddon.fit();
} catch {
@@ -364,8 +332,8 @@ export class TerminalEmulatorRuntime {
fitAndEmitResize(true);
}, 0);
if (input.initialSnapshot) {
this.renderSnapshot({ state: input.initialSnapshot });
if (input.initialOutputText.length > 0) {
this.write({ text: input.initialOutputText, suppressInput: true });
}
this.processOutputQueue();
@@ -466,22 +434,6 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
return;
}
this.outputOperations.push({
type: "snapshot",
text: renderTerminalSnapshotToAnsi(input.state),
rows: input.state.rows,
cols: input.state.cols,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
this.processOutputQueue();
}
resize(input?: { force?: boolean }): void {
this.fitAndEmitResize?.(input?.force ?? false);
}
@@ -571,22 +523,6 @@ export class TerminalEmulatorRuntime {
return;
}
if (operation.type === "snapshot") {
try {
if (
typeof operation.cols === "number" &&
typeof operation.rows === "number" &&
(terminal.cols !== operation.cols || terminal.rows !== operation.rows)
) {
terminal.resize(operation.cols, operation.rows);
}
terminal.reset();
} catch {
finalizeOperation(operation);
return;
}
}
const text = operation.text;
this.inFlightOutputOperationTimeout = setTimeout(() => {
finalizeOperation(operation);
@@ -685,6 +621,7 @@ export class TerminalEmulatorRuntime {
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 ?? "";
@@ -693,6 +630,8 @@ export class TerminalEmulatorRuntime {
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";
@@ -701,6 +640,9 @@ export class TerminalEmulatorRuntime {
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
if (screenElement) {
screenElement.style.pointerEvents = "none";
}
return () => {
if (viewportElement) {
@@ -714,6 +656,9 @@ export class TerminalEmulatorRuntime {
previousViewportWebkitOverflowScrolling,
);
}
if (screenElement) {
screenElement.style.pointerEvents = previousScreenPointerEvents;
}
};
}
@@ -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; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
deliveryTimeoutMs: 100,
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
vi.advanceTimersByTime(100);
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 1, text: "a", replay: false },
{ sequence: 2, text: "b", replay: false },
]);
vi.useRealTimers();
});
it("delivers first chunk immediately and blocks later chunks until consumed", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "bc", replay: false },
]);
});
it("ignores stale consume acknowledgements", () => {
const delivered = vi.fn();
const queue = new TerminalOutputDeliveryQueue({ onDeliver: delivered });
queue.enqueue({ sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 99 });
queue.enqueue({ sequence: 2, text: "y", replay: false });
expect(delivered).toHaveBeenCalledTimes(1);
expect(delivered).toHaveBeenNthCalledWith(1, { sequence: 1, text: "x", replay: false });
queue.consume({ sequence: 1 });
expect(delivered).toHaveBeenCalledTimes(2);
expect(delivered).toHaveBeenNthCalledWith(2, { sequence: 2, text: "y", replay: false });
});
it("resets in-flight and pending chunks", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "hello", replay: false });
queue.enqueue({ sequence: 2, text: " world", replay: false });
queue.reset();
queue.enqueue({ sequence: 3, text: "next", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "hello", replay: false },
{ sequence: 3, text: "next", replay: false },
]);
});
it("preserves empty chunk payloads for authoritative clears", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "abc", replay: false });
queue.consume({ sequence: 1 });
queue.enqueue({ sequence: 2, text: "", replay: false });
expect(delivered).toEqual([
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
});
it("treats clear chunks as a delivery barrier and drops pending stale text", () => {
const delivered: Array<{ sequence: number; text: string; replay: boolean }> = [];
const queue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk) => {
delivered.push(chunk);
},
});
queue.enqueue({ sequence: 1, text: "a", replay: false });
queue.enqueue({ sequence: 2, text: "b", replay: false });
queue.enqueue({ sequence: 3, text: "", replay: false });
queue.enqueue({ sequence: 4, text: "c", replay: false });
expect(delivered).toEqual([{ sequence: 1, text: "a", replay: false }]);
queue.consume({ sequence: 1 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
]);
queue.consume({ sequence: 3 });
expect(delivered).toEqual([
{ sequence: 1, text: "a", replay: false },
{ sequence: 3, text: "", replay: false },
{ sequence: 4, text: "c", replay: false },
]);
});
});
@@ -0,0 +1,113 @@
export type TerminalOutputDeliveryChunk = {
sequence: number;
text: string;
replay: boolean;
};
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);
this.tryDeliver();
return;
}
const lastPendingChunk = this.pendingChunks[this.pendingChunks.length - 1];
if (
lastPendingChunk &&
lastPendingChunk.text.length > 0 &&
lastPendingChunk.replay === chunk.replay
) {
lastPendingChunk.sequence = chunk.sequence;
lastPendingChunk.text += chunk.text;
} else {
this.pendingChunks.push(chunk);
}
this.tryDeliver();
}
consume(input: { sequence: number }): void {
if (this.inFlightChunk?.sequence !== input.sequence) {
return;
}
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;
this.deliverInFlightChunk();
}
private deliverInFlightChunk(): void {
const chunk = this.inFlightChunk;
if (!chunk) {
return;
}
this.clearInFlightTimeout();
this.inFlightTimeout = setTimeout(() => {
if (!this.inFlightChunk) {
return;
}
this.deliverInFlightChunk();
}, this.deliveryTimeoutMs);
this.options.onDeliver({
sequence: chunk.sequence,
text: chunk.text,
replay: chunk.replay,
});
}
private clearInFlightTimeout(): void {
if (!this.inFlightTimeout) {
return;
}
clearTimeout(this.inFlightTimeout);
this.inFlightTimeout = null;
}
}
@@ -0,0 +1,99 @@
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; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "a", replay: false });
pump.append({ terminalId: "term-1", text: "b", replay: false });
pump.append({ terminalId: "term-1", text: "c", replay: false });
expect(chunks).toEqual([]);
vi.runOnlyPendingTimers();
expect(chunks).toEqual([{ sequence: 1, text: "abc", replay: false }]);
vi.useRealTimers();
});
it("keeps per-terminal snapshots and switches selected stream deterministically", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "hello", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("hello");
pump.append({ terminalId: "term-2", text: "world", replay: false });
vi.runOnlyPendingTimers();
expect(pump.readSnapshot({ terminalId: "term-2" })).toBe("world");
pump.setSelectedTerminal({ terminalId: "term-2" });
pump.append({ terminalId: "term-2", text: "!", replay: false });
vi.runOnlyPendingTimers();
expect(chunks).toEqual([
{ sequence: 1, text: "hello", replay: false },
{ sequence: 2, text: "!", replay: false },
]);
vi.useRealTimers();
});
it("resets selected output when clearing selected terminal", () => {
vi.useFakeTimers();
const chunks: Array<{ sequence: number; text: string; replay: boolean }> = [];
const pump = new TerminalOutputPump({
maxOutputChars: 10,
onSelectedOutputChunk: (chunk) => {
chunks.push(chunk);
},
});
pump.setSelectedTerminal({ terminalId: "term-1" });
pump.append({ terminalId: "term-1", text: "abc", replay: false });
vi.runOnlyPendingTimers();
pump.clearTerminal({ terminalId: "term-1" });
expect(pump.readSnapshot({ terminalId: "term-1" })).toBe("");
expect(chunks).toEqual([
{ sequence: 1, text: "abc", replay: false },
{ sequence: 2, text: "", replay: false },
]);
vi.useRealTimers();
});
it("prunes orphaned terminal buffers", () => {
const pump = new TerminalOutputPump({
maxOutputChars: 100,
onSelectedOutputChunk: () => {},
});
pump.append({ terminalId: "a", text: "one", replay: false });
pump.append({ terminalId: "b", text: "two", replay: false });
pump.prune({ terminalIds: ["b"] });
expect(pump.readSnapshot({ terminalId: "a" })).toBe("");
expect(pump.readSnapshot({ terminalId: "b" })).toBe("two");
});
});
@@ -0,0 +1,175 @@
import {
appendTerminalOutputBuffer,
createTerminalOutputBuffer,
readTerminalOutputBuffer,
type TerminalOutputBuffer,
} from "@/utils/terminal-output-buffer";
export type TerminalOutputChunk = {
sequence: number;
text: string;
replay: boolean;
};
export type TerminalOutputPumpOptions = {
maxOutputChars: number;
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => void;
};
export type TerminalOutputPumpSetSelectedInput = {
terminalId: string | null;
};
export type TerminalOutputPumpAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
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 selectedChunkAccumulatorReplay: boolean | null = null;
private selectedChunkFlushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly options: TerminalOutputPumpOptions) {}
setSelectedTerminal(input: TerminalOutputPumpSetSelectedInput): void {
if (this.selectedTerminalId === input.terminalId) {
return;
}
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
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;
}
if (
this.selectedChunkAccumulator.length > 0 &&
typeof this.selectedChunkAccumulatorReplay === "boolean" &&
this.selectedChunkAccumulatorReplay !== input.replay
) {
this.flushSelectedChunkAccumulator();
}
if (this.selectedChunkAccumulator.length === 0) {
this.selectedChunkAccumulatorReplay = input.replay;
}
this.selectedChunkAccumulator += input.text;
this.scheduleSelectedChunkFlush();
}
clearTerminal(input: TerminalOutputPumpClearInput): void {
this.buffersByTerminalId.delete(input.terminalId);
if (this.selectedTerminalId === input.terminalId) {
this.clearSelectedChunkFlushTimer();
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text: "", replay: false });
}
}
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;
const replay = this.selectedChunkAccumulatorReplay ?? false;
this.selectedChunkAccumulator = "";
this.selectedChunkAccumulatorReplay = null;
this.emitSelectedChunk({ text, replay });
}
private emitSelectedChunk(input: { text: string; replay: boolean }): void {
this.selectedChunkSequence += 1;
this.options.onSelectedOutputChunk({
sequence: this.selectedChunkSequence,
text: input.text,
replay: input.replay,
});
}
private clearSelectedChunkFlushTimer(): void {
if (!this.selectedChunkFlushTimer) {
return;
}
clearTimeout(this.selectedChunkFlushTimer);
this.selectedChunkFlushTimer = null;
}
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
getTerminalOutputSession,
releaseTerminalOutputSession,
retainTerminalOutputSession,
} from "./terminal-output-session";
describe("terminal-output-session", () => {
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 0);
});
await Promise.resolve();
}
it("returns the same session instance for the same scope key", () => {
const a = getTerminalOutputSession({
scopeKey: "scope-a",
maxOutputChars: 1_000,
});
const b = getTerminalOutputSession({
scopeKey: "scope-a",
maxOutputChars: 50,
});
expect(a).toBe(b);
});
it("evicts a scope session when the retain count returns to zero", () => {
const scopeKey = "scope-retain-release";
const first = getTerminalOutputSession({
scopeKey,
maxOutputChars: 1_000,
});
retainTerminalOutputSession({ scopeKey });
releaseTerminalOutputSession({ scopeKey });
const second = getTerminalOutputSession({
scopeKey,
maxOutputChars: 1_000,
});
expect(second).not.toBe(first);
});
it("publishes selected terminal snapshot and chunk state via delivery/consume", async () => {
const session = getTerminalOutputSession({
scopeKey: "scope-session-state",
maxOutputChars: 1_000,
});
session.setSelectedTerminal({ terminalId: null });
session.setSelectedTerminal({ terminalId: "term-1" });
session.append({
terminalId: "term-1",
text: "abc",
replay: false,
});
await flushAsyncWork();
const stateAfterAppend = session.getState();
expect(stateAfterAppend.selectedTerminalId).toBe("term-1");
expect(stateAfterAppend.chunkSequence).toBeGreaterThan(0);
expect(stateAfterAppend.chunkText).toBe("abc");
expect(stateAfterAppend.snapshotText).toBe("abc");
expect(stateAfterAppend.snapshotSequence).toBe(stateAfterAppend.chunkSequence);
session.consume({ sequence: stateAfterAppend.chunkSequence });
const stateAfterConsume = session.getState();
expect(stateAfterConsume.snapshotText).toBe("abc");
});
it("keeps chunks for non-selected terminals out of selected state", async () => {
const session = getTerminalOutputSession({
scopeKey: "scope-other-terminal",
maxOutputChars: 1_000,
});
session.setSelectedTerminal({ terminalId: "term-1" });
session.append({
terminalId: "term-2",
text: "hidden",
replay: false,
});
await flushAsyncWork();
const state = session.getState();
expect(state.selectedTerminalId).toBe("term-1");
expect(state.chunkText).toBe("");
expect(state.snapshotText).toBe("");
});
});
@@ -0,0 +1,184 @@
import { TerminalOutputPump, type TerminalOutputChunk } from "./terminal-output-pump";
import {
TerminalOutputDeliveryQueue,
type TerminalOutputDeliveryChunk,
} from "./terminal-output-delivery-queue";
type TerminalOutputState = {
selectedTerminalId: string | null;
snapshotText: string;
snapshotSequence: number;
chunkText: string;
chunkSequence: number;
chunkReplay: boolean;
};
const EMPTY_STATE: TerminalOutputState = {
selectedTerminalId: null,
snapshotText: "",
snapshotSequence: 0,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
export type TerminalOutputSessionAppendInput = {
terminalId: string;
text: string;
replay: boolean;
};
export type TerminalOutputSessionSetSelectedInput = {
terminalId: string | null;
};
export type TerminalOutputSessionPruneInput = {
terminalIds: string[];
};
export type TerminalOutputSessionConsumeInput = {
sequence: number;
};
export type TerminalOutputSessionReadSnapshotInput = {
terminalId: string | null;
};
export class TerminalOutputSession {
private readonly listeners = new Set<() => void>();
private readonly outputPump: TerminalOutputPump;
private readonly deliveryQueue: TerminalOutputDeliveryQueue;
private state: TerminalOutputState = EMPTY_STATE;
constructor(input: { maxOutputChars: number }) {
this.outputPump = new TerminalOutputPump({
maxOutputChars: input.maxOutputChars,
onSelectedOutputChunk: (chunk: TerminalOutputChunk) => {
this.deliveryQueue.enqueue(chunk);
},
});
this.deliveryQueue = new TerminalOutputDeliveryQueue({
onDeliver: (chunk: TerminalOutputDeliveryChunk) => {
const snapshotText = this.outputPump.readSnapshot({
terminalId: this.state.selectedTerminalId,
});
this.state = {
...this.state,
snapshotText,
snapshotSequence: chunk.sequence,
chunkText: chunk.text,
chunkSequence: chunk.sequence,
chunkReplay: chunk.replay,
};
this.emit();
},
});
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getState(): TerminalOutputState {
return this.state;
}
setSelectedTerminal(input: TerminalOutputSessionSetSelectedInput): void {
if (this.state.selectedTerminalId === input.terminalId) {
return;
}
this.deliveryQueue.reset();
this.outputPump.setSelectedTerminal({ terminalId: input.terminalId });
const snapshotText = this.outputPump.readSnapshot({
terminalId: input.terminalId,
});
this.state = {
selectedTerminalId: input.terminalId,
snapshotText,
snapshotSequence: this.state.snapshotSequence,
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
this.emit();
}
append(input: TerminalOutputSessionAppendInput): void {
this.outputPump.append(input);
}
readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string {
return this.outputPump.readSnapshot(input);
}
clearTerminal(input: { terminalId: string }): void {
this.outputPump.clearTerminal(input);
if (this.state.selectedTerminalId !== input.terminalId) {
return;
}
this.deliveryQueue.reset();
this.state = {
...this.state,
snapshotText: "",
chunkText: "",
chunkSequence: 0,
chunkReplay: false,
};
this.emit();
}
prune(input: TerminalOutputSessionPruneInput): void {
this.outputPump.prune(input);
}
consume(input: TerminalOutputSessionConsumeInput): void {
this.deliveryQueue.consume(input);
}
private emit(): void {
for (const listener of this.listeners) {
listener();
}
}
}
const sessionsByScopeKey = new Map<string, TerminalOutputSession>();
const sessionRefCountByScopeKey = new Map<string, number>();
export function getTerminalOutputSession(input: {
scopeKey: string;
maxOutputChars: number;
}): TerminalOutputSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing;
}
const session = new TerminalOutputSession({
maxOutputChars: input.maxOutputChars,
});
sessionsByScopeKey.set(input.scopeKey, session);
return session;
}
export function retainTerminalOutputSession(input: { scopeKey: string }): void {
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
sessionRefCountByScopeKey.set(input.scopeKey, current + 1);
}
export function releaseTerminalOutputSession(input: { scopeKey: string }): void {
const current = sessionRefCountByScopeKey.get(input.scopeKey) ?? 0;
if (current <= 1) {
sessionRefCountByScopeKey.delete(input.scopeKey);
sessionsByScopeKey.delete(input.scopeKey);
return;
}
sessionRefCountByScopeKey.set(input.scopeKey, current - 1);
}
@@ -1,147 +0,0 @@
import { Terminal as HeadlessTerminal } from "@xterm/headless";
import { Terminal as ClientTerminal } from "@xterm/xterm";
import { describe, expect, it } from "vitest";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
type SnapshotCell = {
char: string;
fg: number | undefined;
bg: number | undefined;
fgMode?: number;
bgMode?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
type SnapshotState = {
rows: number;
cols: number;
grid: SnapshotCell[][];
scrollback: SnapshotCell[][];
cursor: {
row: number;
col: number;
hidden?: boolean;
style?: "block" | "underline" | "bar";
blink?: boolean;
};
};
async function writeToTerminal(
terminal: Pick<ClientTerminal | HeadlessTerminal, "write">,
text: string,
): Promise<void> {
await new Promise<void>((resolve) => {
terminal.write(text, () => resolve());
});
}
function extractState(terminal: ClientTerminal | HeadlessTerminal): SnapshotState {
const grid: SnapshotCell[][] = [];
const scrollback: SnapshotCell[][] = [];
const buffer = terminal.buffer.active;
const baseY = buffer.baseY;
for (let row = 0; row < baseY; row += 1) {
scrollback.push(extractRow(terminal, row));
}
for (let row = 0; row < terminal.rows; row += 1) {
grid.push(extractRow(terminal, baseY + row));
}
return {
rows: terminal.rows,
cols: terminal.cols,
grid,
scrollback,
cursor: extractCursorState(terminal),
};
}
function extractCursorState(
terminal: ClientTerminal | HeadlessTerminal,
): SnapshotState["cursor"] {
const buffer = terminal.buffer.active;
const coreService = (terminal as any)._core?.coreService;
const cursorStyle = coreService?.decPrivateModes?.cursorStyle;
const normalizedCursorStyle =
cursorStyle === "block" || cursorStyle === "underline" || cursorStyle === "bar"
? cursorStyle
: undefined;
const cursorBlink =
typeof coreService?.decPrivateModes?.cursorBlink === "boolean"
? coreService.decPrivateModes.cursorBlink
: undefined;
const hidden = Boolean(coreService?.isCursorHidden);
return {
row: buffer.cursorY,
col: buffer.cursorX,
...(hidden ? { hidden: true } : {}),
...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}),
...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}),
};
}
function extractRow(terminal: ClientTerminal | HeadlessTerminal, row: number): SnapshotCell[] {
const cells: SnapshotCell[] = [];
const line = terminal.buffer.active.getLine(row);
for (let col = 0; col < terminal.cols; col += 1) {
const cell = line?.getCell(col);
if (!cell) {
cells.push({ char: " ", fg: undefined, bg: undefined });
continue;
}
const fgModeRaw = cell.getFgColorMode();
const bgModeRaw = cell.getBgColorMode();
const fgMode = fgModeRaw >> 24;
const bgMode = bgModeRaw >> 24;
cells.push({
char: cell.getChars() || " ",
fg: fgMode !== 0 ? cell.getFgColor() : undefined,
bg: bgMode !== 0 ? cell.getBgColor() : undefined,
fgMode: fgMode !== 0 ? fgMode : undefined,
bgMode: bgMode !== 0 ? bgMode : undefined,
bold: cell.isBold() !== 0,
italic: cell.isItalic() !== 0,
underline: cell.isUnderline() !== 0,
});
}
return cells;
}
describe("terminal-snapshot", () => {
it("replays extracted terminal state into a client xterm with matching grid, scrollback, and cursor presentation", async () => {
const source = new HeadlessTerminal({
rows: 4,
cols: 12,
allowProposedApi: true,
scrollback: 100,
});
await writeToTerminal(source, "plain\r\n");
await writeToTerminal(source, "\u001b[31mred\u001b[0m\r\n");
await writeToTerminal(source, "\u001b[1mbold\u001b[0m\r\n");
await writeToTerminal(source, "cursor");
await writeToTerminal(source, "\u001b[2D");
await writeToTerminal(source, "\u001b[2 q");
await writeToTerminal(source, "\u001b[?25l");
const snapshot = extractState(source);
const client = new ClientTerminal({
rows: snapshot.rows,
cols: snapshot.cols,
allowProposedApi: true,
scrollback: 100,
});
await writeToTerminal(client, renderTerminalSnapshotToAnsi(snapshot));
expect(extractState(client)).toEqual(snapshot);
});
});
@@ -1,205 +0,0 @@
import type { TerminalCell, TerminalState } from "@server/shared/messages";
type TerminalStyle = {
fg: number | undefined;
bg: number | undefined;
fgMode: number | undefined;
bgMode: number | undefined;
bold: boolean;
italic: boolean;
underline: boolean;
dim: boolean;
inverse: boolean;
strikethrough: boolean;
};
const DEFAULT_STYLE: TerminalStyle = {
fg: undefined,
bg: undefined,
fgMode: undefined,
bgMode: undefined,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
strikethrough: false,
};
export function renderTerminalSnapshotToAnsi(state: TerminalState): string {
const rows = [...state.scrollback, ...state.grid];
const lines: string[] = ["\u001b[?7l"];
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
const row = rows[rowIndex] ?? [];
lines.push(renderTerminalRow(row));
if (rowIndex < rows.length - 1) {
lines.push("\r\n");
}
}
lines.push("\u001b[0m");
const cursorPresentationAnsi = renderCursorPresentationToAnsi(state.cursor);
if (cursorPresentationAnsi) {
lines.push(cursorPresentationAnsi);
}
lines.push(`\u001b[${state.cursor.row + 1};${state.cursor.col + 1}H`);
lines.push(state.cursor.hidden ? "\u001b[?25l" : "\u001b[?25h");
lines.push("\u001b[?7h");
return lines.join("");
}
function renderCursorPresentationToAnsi(cursor: TerminalState["cursor"]): string | null {
if (!cursor.style) {
return null;
}
const cursorStyleCode = (() => {
if (cursor.style === "block") {
return cursor.blink === false ? 2 : 1;
}
if (cursor.style === "underline") {
return cursor.blink === false ? 4 : 3;
}
return cursor.blink === false ? 6 : 5;
})();
return `\u001b[${cursorStyleCode} q`;
}
function renderTerminalRow(row: TerminalCell[]): string {
const output: string[] = [];
const length = getTerminalRowLength(row);
let previousStyle = DEFAULT_STYLE;
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
output.push("\u001b[0m");
}
return output.join("");
}
function getTerminalRowLength(row: TerminalCell[]): number {
for (let index = row.length - 1; index >= 0; index -= 1) {
const cell = row[index];
if (!cell) {
continue;
}
if (cell.char !== " ") {
return index + 1;
}
if (
cell.fg !== undefined ||
cell.bg !== undefined ||
cell.fgMode !== undefined ||
cell.bgMode !== undefined ||
cell.bold ||
cell.italic ||
cell.underline ||
cell.dim ||
cell.inverse ||
cell.strikethrough
) {
return index + 1;
}
}
return 0;
}
function getTerminalStyle(cell: TerminalCell): TerminalStyle {
return {
fg: cell.fg,
bg: cell.bg,
fgMode: cell.fgMode,
bgMode: cell.bgMode,
bold: Boolean(cell.bold),
italic: Boolean(cell.italic),
underline: Boolean(cell.underline),
dim: Boolean(cell.dim),
inverse: Boolean(cell.inverse),
strikethrough: Boolean(cell.strikethrough),
};
}
function terminalStylesEqual(left: TerminalStyle, right: TerminalStyle): boolean {
return (
left.fg === right.fg &&
left.bg === right.bg &&
left.fgMode === right.fgMode &&
left.bgMode === right.bgMode &&
left.bold === right.bold &&
left.italic === right.italic &&
left.underline === right.underline &&
left.dim === right.dim &&
left.inverse === right.inverse &&
left.strikethrough === right.strikethrough
);
}
function styleToAnsi(style: TerminalStyle): string {
const codes = ["0"];
if (style.bold) {
codes.push("1");
}
if (style.dim) {
codes.push("2");
}
if (style.italic) {
codes.push("3");
}
if (style.underline) {
codes.push("4");
}
if (style.inverse) {
codes.push("7");
}
if (style.strikethrough) {
codes.push("9");
}
if (style.fg !== undefined && style.fgMode !== undefined) {
codes.push(...colorToSgr(style.fgMode, style.fg, false));
}
if (style.bg !== undefined && style.bgMode !== undefined) {
codes.push(...colorToSgr(style.bgMode, style.bg, true));
}
return `\u001b[${codes.join(";")}m`;
}
function colorToSgr(mode: number, value: number, background: boolean): string[] {
if (mode === 1) {
if (value >= 8) {
return [String((background ? 100 : 90) + (value - 8))];
}
return [String((background ? 40 : 30) + value)];
}
if (mode === 2) {
return [background ? "48" : "38", "5", String(value)];
}
if (mode === 3) {
return [
background ? "48" : "38",
"2",
String((value >> 16) & 0xff),
String((value >> 8) & 0xff),
String(value & 0xff),
];
}
return [];
}
@@ -2,180 +2,586 @@ import { describe, expect, it } from "vitest";
import {
TerminalStreamController,
type TerminalStreamControllerAttachPayload,
type TerminalStreamControllerChunk,
type TerminalStreamControllerClient,
type TerminalStreamControllerResumeOffsets,
type TerminalStreamControllerStatus,
} from "./terminal-stream-controller";
type TerminalSnapshot = {
rows: number;
cols: number;
grid: Array<Array<{ char: string }>>;
scrollback: Array<Array<{ char: string }>>;
cursor: { row: number; col: number };
};
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
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 subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
}
return result;
}
unsubscribeTerminal(terminalId: string): void {
this.unsubscribeCalls.push(terminalId);
}
sendTerminalInput(
async attachTerminalStream(
terminalId: string,
message: { type: "resize"; rows: number; cols: number },
): void {
this.resizeCalls.push({ terminalId, rows: message.rows, cols: message.cols });
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;
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.listeners.add(handler);
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 () => {
this.listeners.delete(handler);
const current = this.streamSubscribers.get(streamId);
current?.delete(handler);
if (current && current.size === 0) {
this.streamSubscribers.delete(streamId);
}
};
}
emit(event: TerminalStreamEvent): void {
for (const listener of this.listeners) {
listener(event);
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 createHarness(input?: { client?: FakeTerminalStreamClient }) {
function createControllerHarness(input?: {
client?: FakeTerminalStreamClient;
resumeOffsets?: TerminalStreamControllerResumeOffsets;
}): {
client: FakeTerminalStreamClient;
chunks: Array<{ terminalId: string; text: string }>;
statuses: TerminalStreamControllerStatus[];
resets: string[];
controller: TerminalStreamController;
} {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; text: string }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const chunks: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const resets: string[] = [];
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
onOutput: (output) => {
outputs.push(output);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
resumeOffsets: input?.resumeOffsets,
onChunk: (chunk) => {
chunks.push({
terminalId: chunk.terminalId,
text: chunk.text,
});
},
onStatusChange: (status) => {
statuses.push(status);
},
onReset: ({ terminalId }) => {
resets.push(terminalId);
},
waitForDelay: async () => {},
});
return { client, controller, outputs, snapshots, statuses };
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("subscribes, resizes, and forwards snapshot/output events", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
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.emit({
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({
resumeOffset: 0,
rows: 24,
cols: 80,
});
expect(harness.controller.getActiveStreamId()).toBe(9);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
type: "snapshot",
state: {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
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("recovers when stream exits before initial attach completes", async () => {
const harness = createControllerHarness();
harness.client.nextAttachResponses.push({
streamId: 1,
currentOffset: 1200,
reset: false,
error: null,
});
harness.client.nextAttachResponses.push({
streamId: 2,
currentOffset: 1200,
reset: false,
error: null,
});
harness.controller.setTerminal({ terminalId: "term-attach-exit" });
harness.controller.handleStreamExit({
terminalId: "term-attach-exit",
streamId: 1,
});
await flushAsyncWork();
expect(harness.client.attachCalls).toHaveLength(2);
expect(harness.client.attachCalls[1]?.options).toEqual({
resumeOffset: 1200,
rows: 24,
cols: 80,
});
expect(harness.client.detachCalls).toContain(1);
expect(harness.controller.getActiveStreamId()).toBe(2);
});
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",
});
});
it("reuses external resume offsets across controller recreation", async () => {
const client = new FakeTerminalStreamClient();
const resumeOffsetByTerminalId = new Map<string, number>();
const resumeOffsets: TerminalStreamControllerResumeOffsets = {
get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId),
set: ({ terminalId, offset }) => {
resumeOffsetByTerminalId.set(terminalId, offset);
},
});
harness.client.emit({
terminalId: "term-1",
type: "output",
data: new TextEncoder().encode(" world"),
});
clear: ({ terminalId }) => {
resumeOffsetByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
resumeOffsetByTerminalId.delete(terminalId);
}
}
},
};
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
client.nextAttachResponses.push({
streamId: 81,
currentOffset: 0,
reset: false,
error: null,
});
});
it("surfaces subscribe failures without retrying", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({
terminalId: "term-1",
error: "network disconnected",
const firstHarness = createControllerHarness({
client,
resumeOffsets,
});
harness.controller.setTerminal({ terminalId: "term-1" });
firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
error: "network disconnected",
client.emitChunk({
streamId: 81,
offset: 0,
endOffset: 2,
data: "ok",
});
});
it("treats terminal exit as final and does not reconnect", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
firstHarness.controller.dispose();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
error: "Terminal exited",
});
});
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
harness.client.nextSubscribeResults.push({ terminalId: "term-2", error: null });
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
harness.controller.setTerminal({ terminalId: "term-2" });
await flushAsyncWork();
harness.controller.dispose();
expect(harness.client.unsubscribeCalls).toEqual(["term-1", "term-2"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: null,
isAttaching: false,
client.nextAttachResponses.push({
streamId: 82,
currentOffset: 2,
reset: false,
error: null,
});
const secondHarness = createControllerHarness({
client,
resumeOffsets,
});
secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
await flushAsyncWork();
expect(client.attachCalls.at(-1)?.options).toEqual({
resumeOffset: 2,
rows: 24,
cols: 80,
});
});
});
@@ -1,21 +1,41 @@
import type { TerminalState } from "@server/shared/messages";
import {
getTerminalAttachRetryDelayMs,
getTerminalResumeOffset,
isTerminalAttachRetryableError,
type TerminalResumeOffsetStore,
updateTerminalResumeOffset,
waitForDuration,
withPromiseTimeout,
} from "@/utils/terminal-attach";
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 = {
subscribeTerminal: (terminalId: string) => Promise<{
terminalId: string;
error?: string | null;
}>;
unsubscribeTerminal: (terminalId: string) => void;
sendTerminalInput: (
attachTerminalStream: (
terminalId: string,
message: { type: "resize"; rows: number; cols: number },
) => void;
onTerminalStreamEvent: (
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState },
) => void,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
) => Promise<TerminalStreamControllerAttachPayload>;
detachTerminalStream: (streamId: number) => Promise<unknown>;
onTerminalStreamData: (
streamId: number,
handler: (chunk: TerminalStreamControllerChunk) => void,
) => () => void;
};
@@ -26,126 +46,519 @@ export type TerminalStreamControllerSize = {
export type TerminalStreamControllerStatus = {
terminalId: string | null;
streamId: number | null;
isAttaching: boolean;
error: string | null;
};
export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & {
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
};
export type TerminalStreamControllerOptions = {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; text: string }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
resumeOffsets?: TerminalStreamControllerResumeOffsets;
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => 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;
};
const TERMINAL_EXITED_ERROR = "Terminal exited";
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 decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null;
private disposed = false;
private readonly exitedStreamsWhileAttaching = new Set<number>();
private readonly internalResumeOffsetByTerminalId = 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) {
this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => {
if (this.disposed || event.terminalId !== this.terminalId) {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
const text = this.decoder.decode(event.data, { stream: true });
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
}
});
constructor(private readonly options: TerminalStreamControllerOptions) {}
getActiveStreamId(): number | null {
return this.activeStream?.streamId ?? null;
}
setTerminal(input: { terminalId: string | null }): void {
if (this.disposed || input.terminalId === this.terminalId) {
if (this.isDisposed) {
return;
}
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
if (!nextTerminalId) {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client
.subscribeTerminal(nextTerminalId)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
if (payload.error) {
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: payload.error,
});
return;
}
const preferredSize = this.options.getPreferredSize();
if (preferredSize) {
this.options.client.sendTerminalInput(nextTerminalId, {
type: "resize",
rows: preferredSize.rows,
cols: preferredSize.cols,
});
}
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: null,
});
})
.catch((error: unknown) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: nextTerminalId,
isAttaching: false,
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
});
});
}
handleTerminalExit(input: { terminalId: string }): void {
if (this.disposed || input.terminalId !== this.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.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
isAttaching: false,
error: TERMINAL_EXITED_ERROR,
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,
});
}
dispose(): void {
if (this.disposed) {
handleStreamExit(input: { terminalId: string; streamId: number }): void {
if (this.isDisposed) {
return;
}
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {
this.options.client.unsubscribeTerminal(terminalId);
if (this.selectedTerminalId !== input.terminalId) {
return;
}
this.unsubscribeStreamEvents();
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
const activeStream = this.activeStream;
const activeMatches =
Boolean(activeStream) &&
activeStream!.terminalId === input.terminalId &&
activeStream!.streamId === input.streamId;
if (activeMatches) {
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,
});
return;
}
if (this.status.isAttaching && this.status.terminalId === input.terminalId) {
this.exitedStreamsWhileAttaching.add(input.streamId);
}
}
pruneResumeOffsets(input: { terminalIds: string[] }): void {
if (this.options.resumeOffsets) {
this.options.resumeOffsets.prune(input);
return;
}
const terminalIdSet = new Set(input.terminalIds);
for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
this.internalResumeOffsetByTerminalId.delete(terminalId);
}
}
}
dispose(): void {
if (this.isDisposed) {
return;
}
this.isDisposed = true;
this.attachGeneration += 1;
this.selectedTerminalId = null;
void this.detachActiveStream({ shouldDetach: true });
this.internalResumeOffsetByTerminalId.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;
}
try {
const preferredSize = this.options.getPreferredSize();
const resumeOffset = getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetStore: this.getResumeOffsetStore(),
});
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",
});
updateTerminalResumeOffset({
terminalId: input.terminalId,
offset: attachPayload.currentOffset,
resumeOffsetStore: this.getResumeOffsetStore(),
});
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";
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 (this.exitedStreamsWhileAttaching.has(attachPayload.streamId)) {
this.exitedStreamsWhileAttaching.delete(attachPayload.streamId);
void this.options.client.detachTerminalStream(attachPayload.streamId).catch(() => {});
this.attachGeneration += 1;
void this.attachTerminal({
terminalId: input.terminalId,
generation: this.attachGeneration,
});
return;
}
if (attachPayload.reset) {
this.options.resumeOffsets?.clear({ terminalId: input.terminalId });
this.options.onReset?.({ terminalId: input.terminalId });
}
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();
}
this.updateStatus({
terminalId: input.terminalId,
streamId,
isAttaching: false,
error: null,
});
return;
} catch (error) {
lastErrorMessage =
error instanceof Error ? error.message : "Unable to attach terminal stream";
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,
resumeOffsetStore: this.getResumeOffsetStore(),
});
const text = input.decoder.decode(input.chunk.data, { stream: true });
if (text.length === 0) {
return;
}
this.options.onChunk({
terminalId: input.terminalId,
text,
replay: Boolean(input.chunk.replay),
});
}
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,
resumeOffsetStore: this.getResumeOffsetStore(),
});
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;
}
this.activeStream = null;
try {
const tail = activeStream.decoder.decode();
if (tail.length > 0) {
this.options.onChunk({
terminalId: activeStream.terminalId,
text: tail,
replay: false,
});
}
} 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;
this.options.onStatusChange?.(status);
}
private getResumeOffsetStore(): TerminalResumeOffsetStore {
if (this.options.resumeOffsets) {
return this.options.resumeOffsets;
}
return {
get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId),
set: ({ terminalId, offset }) => {
this.internalResumeOffsetByTerminalId.set(terminalId, offset);
},
};
}
}
@@ -10,56 +10,43 @@ describe("workspace-terminal-session", () => {
it("returns the same workspace session instance for the same scope", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
maxOutputChars: 1_000,
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-a",
maxOutputChars: 50,
});
expect(second).toBe(first);
});
it("preserves snapshots across repeated lookups", () => {
it("preserves resume offsets across repeated lookups", () => {
const first = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
scopeKey: "workspace-resume",
maxOutputChars: 1_000,
});
first.snapshots.set({
first.resumeOffsets.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
offset: 42,
});
const second = getWorkspaceTerminalSession({
scopeKey: "workspace-snapshots",
scopeKey: "workspace-resume",
maxOutputChars: 1_000,
});
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
});
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42);
});
it("evicts workspace terminal session state when the retain count returns to zero", () => {
it("evicts workspace terminal session state when the workspace retain count returns to zero", () => {
const scopeKey = "workspace-release";
const first = getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: 1_000,
});
first.snapshots.set({
first.resumeOffsets.set({
terminalId: "term-1",
state: {
rows: 1,
cols: 1,
grid: [[{ char: "A" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
},
offset: 128,
});
retainWorkspaceTerminalSession({ scopeKey });
@@ -67,9 +54,10 @@ describe("workspace-terminal-session", () => {
const second = getWorkspaceTerminalSession({
scopeKey,
maxOutputChars: 1_000,
});
expect(second).not.toBe(first);
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined();
});
});
@@ -1,63 +1,85 @@
import type { TerminalState } from "@server/shared/messages";
import {
getTerminalOutputSession,
releaseTerminalOutputSession,
retainTerminalOutputSession,
type TerminalOutputSession,
} from "./terminal-output-session";
export type WorkspaceTerminalSnapshots = {
get: (input: { terminalId: string }) => TerminalState | null;
set: (input: { terminalId: string; state: TerminalState }) => void;
export type WorkspaceTerminalResumeOffsets = {
get: (input: { terminalId: string }) => number | undefined;
set: (input: { terminalId: string; offset: number }) => void;
clear: (input: { terminalId: string }) => void;
prune: (input: { terminalIds: string[] }) => void;
};
export type WorkspaceTerminalSession = {
scopeKey: string;
snapshots: WorkspaceTerminalSnapshots;
outputSession: TerminalOutputSession;
resumeOffsets: WorkspaceTerminalResumeOffsets;
};
type WorkspaceTerminalSessionRecord = {
snapshotByTerminalId: Map<string, TerminalState>;
resumeOffsetByTerminalId: Map<string, number>;
session: WorkspaceTerminalSession;
};
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
const refCountByScopeKey = new Map<string, number>();
function createSnapshots(input: {
snapshotByTerminalId: Map<string, TerminalState>;
}): WorkspaceTerminalSnapshots {
function createResumeOffsets(input: {
resumeOffsetByTerminalId: Map<string, number>;
}): WorkspaceTerminalResumeOffsets {
return {
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
set: ({ terminalId, state }) => {
input.snapshotByTerminalId.set(terminalId, state);
get: ({ terminalId }) => {
const offset = input.resumeOffsetByTerminalId.get(terminalId);
if (typeof offset !== "number" || !Number.isFinite(offset)) {
return undefined;
}
return Math.max(0, Math.floor(offset));
},
set: ({ terminalId, offset }) => {
if (!Number.isFinite(offset)) {
return;
}
input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset)));
},
clear: ({ terminalId }) => {
input.snapshotByTerminalId.delete(terminalId);
input.resumeOffsetByTerminalId.delete(terminalId);
},
prune: ({ terminalIds }) => {
const terminalIdSet = new Set(terminalIds);
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) {
if (!terminalIdSet.has(terminalId)) {
input.snapshotByTerminalId.delete(terminalId);
input.resumeOffsetByTerminalId.delete(terminalId);
}
}
},
};
}
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
export function getWorkspaceTerminalSession(input: {
scopeKey: string;
maxOutputChars: number;
}): WorkspaceTerminalSession {
const existing = sessionsByScopeKey.get(input.scopeKey);
if (existing) {
return existing.session;
}
const snapshotByTerminalId = new Map<string, TerminalState>();
const resumeOffsetByTerminalId = new Map<string, number>();
const session: WorkspaceTerminalSession = {
scopeKey: input.scopeKey,
snapshots: createSnapshots({
snapshotByTerminalId,
outputSession: getTerminalOutputSession({
scopeKey: input.scopeKey,
maxOutputChars: input.maxOutputChars,
}),
resumeOffsets: createResumeOffsets({
resumeOffsetByTerminalId,
}),
};
sessionsByScopeKey.set(input.scopeKey, {
snapshotByTerminalId,
resumeOffsetByTerminalId,
session,
});
return session;
@@ -66,9 +88,11 @@ export function getWorkspaceTerminalSession(input: { scopeKey: string }): Worksp
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
refCountByScopeKey.set(input.scopeKey, current + 1);
retainTerminalOutputSession(input);
}
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
releaseTerminalOutputSession(input);
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
if (current > 1) {
refCountByScopeKey.set(input.scopeKey, current - 1);
-12
View File
@@ -2,20 +2,8 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
export type ShortcutOs = "mac" | "non-mac";
const KEY_DISPLAY: Record<string, string> = {
Backspace: "⌫",
Enter: "↩",
Esc: "Esc",
Space: "␣",
Left: "←",
Right: "→",
Up: "↑",
Down: "↓",
};
function normalizeKey(key: string): string {
if (!key) return "";
if (KEY_DISPLAY[key]) return KEY_DISPLAY[key];
if (key.length === 1) return key.toUpperCase();
return key;
}
@@ -13,7 +13,7 @@ export interface SidebarProjectWorkspaceLinkRowModel {
export interface SidebarProjectSectionRowModel {
kind: "project_section";
chevron: "expand" | "collapse" | null;
chevron: "expand" | "collapse";
trailingAction: "new_worktree" | "none";
}
@@ -22,7 +22,7 @@ export type SidebarProjectRowModel =
| SidebarProjectSectionRowModel;
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
return project.workspaces.length === 1 && project.projectKind !== "git";
return project.workspaces.length === 1;
}
export function buildSidebarProjectRowModel(input: {
@@ -50,12 +50,9 @@ export function buildSidebarProjectRowModel(input: {
};
}
const collapsible =
input.project.projectKind === "git" || input.project.workspaces.length > 1;
return {
kind: "project_section",
chevron: collapsible ? (input.collapsed ? "expand" : "collapse") : null,
chevron: input.collapsed ? "expand" : "collapse",
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
};
}
@@ -0,0 +1,93 @@
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";
const resumeOffsetStore = {
get: ({ terminalId }: { terminalId: string }) => offsets.get(terminalId),
set: ({ terminalId, offset }: { terminalId: string; offset: number }) => {
offsets.set(terminalId, offset);
},
};
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).toBeUndefined();
updateTerminalResumeOffset({
terminalId,
offset: 8,
resumeOffsetStore,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).toBe(8);
// Stale offsets must not move resume backwards.
updateTerminalResumeOffset({
terminalId,
offset: 3,
resumeOffsetStore,
});
expect(
getTerminalResumeOffset({
terminalId,
resumeOffsetStore,
}),
).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");
});
});
+92
View File
@@ -0,0 +1,92 @@
const TERMINAL_ATTACH_RETRYABLE_ERROR_PATTERNS = [
"terminal not found",
"timed out",
"timeout",
"connection",
"network",
"disconnected",
"stream ended",
] as const;
export type TerminalResumeOffsetStore = {
get: (input: { terminalId: string }) => number | undefined;
set: (input: { terminalId: string; offset: number }) => void;
};
export function getTerminalResumeOffset(input: {
terminalId: string;
resumeOffsetStore: TerminalResumeOffsetStore;
}): number | undefined {
const offset = input.resumeOffsetStore.get({ terminalId: 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;
resumeOffsetStore: TerminalResumeOffsetStore;
}): void {
if (!Number.isFinite(input.offset)) {
return;
}
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousOffset =
getTerminalResumeOffset({
terminalId: input.terminalId,
resumeOffsetStore: input.resumeOffsetStore,
}) ?? -1;
if (normalizedOffset <= previousOffset) {
return;
}
input.resumeOffsetStore.set({
terminalId: input.terminalId,
offset: 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);
}
}
}
@@ -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");
});
});
@@ -0,0 +1,93 @@
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("");
}
+100 -18
View File
@@ -9,21 +9,22 @@ import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { addLsOptions, runLsCommand } from "./commands/agent/ls.js";
import { addRunOptions, runRunCommand } from "./commands/agent/run.js";
import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js";
import { addDeleteOptions, runDeleteCommand } from "./commands/agent/delete.js";
import { addStopOptions, runStopCommand } from "./commands/agent/stop.js";
import { addSendOptions, runSendCommand } from "./commands/agent/send.js";
import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.js";
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
import { runLsCommand } from "./commands/agent/ls.js";
import { runRunCommand } from "./commands/agent/run.js";
import { runLogsCommand } from "./commands/agent/logs.js";
import { runDeleteCommand } from "./commands/agent/delete.js";
import { runStopCommand } from "./commands/agent/stop.js";
import { runSendCommand } from "./commands/agent/send.js";
import { runInspectCommand } from "./commands/agent/inspect.js";
import { runWaitCommand } from "./commands/agent/wait.js";
import { runAttachCommand } from "./commands/agent/attach.js";
import { withOutput } from "./output/index.js";
import { onboardCommand } from "./commands/onboard.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
collectMultiple,
} from "./utils/command-options.js";
const require = createRequire(import.meta.url);
@@ -58,39 +59,120 @@ export function createCli(): Command {
// Primary agent commands (top-level)
addJsonAndDaemonHostOptions(
addLsOptions(program.command("ls")),
program
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
addRunOptions(program.command("run")),
program
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
addAttachOptions(program.command("attach")),
program
.command("attach")
.description("Attach to a running agent's output stream")
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
addLogsOptions(program.command("logs")),
program
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.option("-f, --follow", "Follow log output (streaming)")
.option("--tail <n>", "Show last n entries")
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)")
.option("--since <time>", "Show logs since timestamp"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
addStopOptions(program.command("stop")),
program
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
addDeleteOptions(program.command("delete")),
program
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
addSendOptions(program.command("send")),
program
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, []),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
addInspectOptions(program.command("inspect")),
program
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
addWaitOptions(program.command("wait")),
program
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Top-level local daemon shortcuts
@@ -1,10 +1,4 @@
import type { Command } from "commander";
export function addAttachOptions(cmd: Command): Command {
return cmd
.description("Attach to a running agent's output stream")
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type {
@@ -1,13 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory");
}
import type {
CommandOptions,
SingleResult,
+93 -18
View File
@@ -1,15 +1,15 @@
import { Command } from "commander";
import { runModeCommand } from "./mode.js";
import { runArchiveCommand } from "./archive.js";
import { addDeleteOptions, runDeleteCommand } from "./delete.js";
import { addLsOptions, runLsCommand } from "./ls.js";
import { addRunOptions, runRunCommand } from "./run.js";
import { addLogsOptions, runLogsCommand } from "./logs.js";
import { addStopOptions, runStopCommand } from "./stop.js";
import { addSendOptions, runSendCommand } from "./send.js";
import { addInspectOptions, runInspectCommand } from "./inspect.js";
import { addWaitOptions, runWaitCommand } from "./wait.js";
import { addAttachOptions, runAttachCommand } from "./attach.js";
import { runDeleteCommand } from "./delete.js";
import { runLsCommand } from "./ls.js";
import { runRunCommand } from "./run.js";
import { runLogsCommand } from "./logs.js";
import { runStopCommand } from "./stop.js";
import { runSendCommand } from "./send.js";
import { runInspectCommand } from "./inspect.js";
import { runWaitCommand } from "./wait.js";
import { runAttachCommand } from "./attach.js";
import { runUpdateCommand } from "./update.js";
import { withOutput } from "../../output/index.js";
import {
@@ -23,39 +23,114 @@ export function createAgentCommand(): Command {
// Primary agent commands (same as top-level)
addJsonAndDaemonHostOptions(
addLsOptions(agent.command("ls")),
agent
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
addRunOptions(agent.command("run")),
agent
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
addAttachOptions(agent.command("attach")),
agent
.command("attach")
.description("Attach to a running agent's output stream")
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
addLogsOptions(agent.command("logs")),
agent
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.option("-f, --follow", "Follow log output (streaming)")
.option("--tail <n>", "Show last n entries")
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
addStopOptions(agent.command("stop")),
agent
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
addDeleteOptions(agent.command("delete")),
agent
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
addSendOptions(agent.command("send")),
agent
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion"),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
addInspectOptions(agent.command("inspect")),
agent
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
addWaitOptions(agent.command("wait")),
agent
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Advanced agent commands (less common operations)
@@ -3,12 +3,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
export function addInspectOptions(cmd: Command): Command {
return cmd
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)");
}
/** Agent inspect data for display (matches CLI spec format) */
interface AgentInspect {
Id: string;
+1 -11
View File
@@ -1,20 +1,10 @@
import { Command } from "commander";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type { DaemonClient, AgentStreamMessage, AgentTimelineItem } from "@getpaseo/server";
import { curateAgentActivity } from "@getpaseo/server";
export function addLogsOptions(cmd: Command): Command {
return cmd
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.option("-f, --follow", "Follow log output (streaming)")
.option("--tail <n>", "Show last n entries")
.option("--filter <type>", "Filter by event type (tools, text, errors, permissions)")
.option("--since <time>", "Show logs since timestamp");
}
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean;
tail?: string;
-15
View File
@@ -2,21 +2,6 @@ import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
export function addLsOptions(cmd: Command): Command {
return cmd
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option("--thinking <id>", "Filter by thinking option ID");
}
/** Agent list item for display */
export interface AgentListItem {
+1 -44
View File
@@ -1,4 +1,4 @@
import { Command } from "commander";
import type { Command } from "commander";
import {
getStructuredAgentResponse,
StructuredAgentResponseError,
@@ -15,49 +15,6 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { lookup } from "mime-types";
import { parseDuration } from "../../utils/duration.js";
import { collectMultiple } from "../../utils/command-options.js";
export function addRunOptions(cmd: Command): Command {
return cmd
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
);
}
/** Result type for agent run command */
export interface AgentRunResult {
+1 -13
View File
@@ -1,5 +1,4 @@
import { type Command } from "commander";
import { collectMultiple } from "../../utils/command-options.js";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -34,17 +33,6 @@ export interface AgentSendOptions extends CommandOptions {
promptFile?: string;
}
export function addSendOptions(cmd: Command): Command {
return cmd
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
.option("--no-wait", "Return immediately without waiting for completion");
}
/**
* Read image files and convert them to base64 data URIs
*/
+1 -9
View File
@@ -1,4 +1,4 @@
import { Command } from "commander";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -20,14 +20,6 @@ export const stopSchema: OutputSchema<StopResult> = {
columns: [{ header: "INTERRUPTED", field: "stoppedCount" }],
};
export function addStopOptions(cmd: Command): Command {
return cmd
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory");
}
export interface AgentStopOptions extends CommandOptions {
all?: boolean;
cwd?: string;
+1 -8
View File
@@ -1,4 +1,4 @@
import { Command } from "commander";
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -53,13 +53,6 @@ async function getRecentActivityTranscript(
}
}
export function addWaitOptions(cmd: Command): Command {
return cmd
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)");
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
+4 -18
View File
@@ -1,23 +1,7 @@
#!/bin/sh
set -eu
SCRIPT_PATH=$0
case "$SCRIPT_PATH" in
/*) ;;
*) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;;
esac
# Follow symlinks so `/usr/local/bin/paseo` resolves back into the app bundle.
while [ -L "$SCRIPT_PATH" ]; do
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
LINK_TARGET=$(readlink "$SCRIPT_PATH")
case "$LINK_TARGET" in
/*) SCRIPT_PATH="$LINK_TARGET" ;;
*) SCRIPT_PATH="${SCRIPT_DIR}/${LINK_TARGET}" ;;
esac
done
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
# macOS: MacOS/Paseo is a sibling of Resources/
@@ -31,4 +15,6 @@ else
exit 1
fi
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
+4 -2
View File
@@ -4,10 +4,12 @@ setlocal
set "SCRIPT_DIR=%~dp0"
set "RESOURCES_DIR=%SCRIPT_DIR%.."
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js"
if not exist "%APP_EXECUTABLE%" (
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
exit /b 1
)
set "PASEO_DESKTOP_CLI=1"
"%APP_EXECUTABLE%" %*
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
-1
View File
@@ -29,7 +29,6 @@ mac:
linux:
category: Development
icon: assets
artifactName: "Paseo-${version}-${arch}.${ext}"
extraResources:
- from: bin/paseo
to: bin/paseo
-1
View File
@@ -8,7 +8,6 @@
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
+8 -14
View File
@@ -16,7 +16,7 @@ import {
sendLocalTransportMessage,
closeLocalTransportSession,
} from "./local-transport.js";
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
@@ -293,23 +293,17 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
const home = getPaseoHome();
const daemonRunner = resolveDaemonRunnerEntrypoint();
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
const invocation = createNodeEntrypointInvocation({
entrypoint: daemonRunner,
argvMode: "node-script",
args: [],
baseEnv: {
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
},
});
const child: ChildProcess = spawn(
invocation.command,
invocation.args,
process.execPath,
[...daemonRunner.execArgv, daemonRunner.entryPath],
{
detached: true,
env: invocation.env,
env: createElectronNodeEnv({
...process.env,
PASEO_HOME: home,
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
}),
stdio: ["ignore", "ignore", "ignore"],
},
);
@@ -1,151 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher";
const CLI_ENTRYPOINT: NodeEntrypointSpec = {
entryPath: "/tmp/paseo-cli.js",
execArgv: ["--import", "tsx"],
};
describe("node-entrypoint-launcher", () => {
describe("parseCliPassthroughArgsFromArgv", () => {
it("returns null when no CLI args are provided", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores macOS GUI launch arguments", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("ignores --no-sandbox injected by Linux wrapper", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox", "status"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["status"]);
});
it("returns null when only --no-sandbox is present", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/usr/bin/Paseo", "--no-sandbox"],
isDefaultApp: false,
forceCli: false,
}),
).toBeNull();
});
it("preserves CLI flags for direct app invocations", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
forceCli: false,
}),
).toEqual(["--version"]);
});
it("forces CLI mode for shim launches even without args", () => {
expect(
parseCliPassthroughArgsFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
isDefaultApp: false,
forceCli: true,
}),
).toEqual([]);
});
});
describe("createNodeEntrypointInvocation", () => {
it("uses the packaged runner when the desktop app is packaged", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
args: ["ls", "--json"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"bare",
"/tmp/paseo-cli.js",
"ls",
"--json",
],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
it("uses the entrypoint directly in development", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/opt/homebrew/bin/electron",
isPackaged: false,
packagedRunnerPath: null,
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
args: ["ls"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/opt/homebrew/bin/electron",
args: ["--import", "tsx", "/tmp/paseo-cli.js", "ls"],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
it("keeps node-style argv for packaged script entrypoints", () => {
expect(
createNodeEntrypointInvocation({
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "node-script",
args: ["--dev"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"node-script",
"/tmp/paseo-cli.js",
"--dev",
],
env: {
PATH: "/usr/bin",
ELECTRON_RUN_AS_NODE: "1",
},
});
});
});
});
@@ -1,78 +0,0 @@
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
export type NodeEntrypointSpec = {
entryPath: string;
execArgv: string[];
};
export type NodeEntrypointInvocation = {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
};
export type NodeEntrypointArgvMode = "bare" | "node-script";
type CreateNodeEntrypointInvocationInput = {
execPath: string;
isPackaged: boolean;
packagedRunnerPath: string | null;
entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode;
args: string[];
baseEnv: NodeJS.ProcessEnv;
};
type ParseCliPassthroughArgsFromArgvInput = {
argv: string[];
isDefaultApp: boolean;
forceCli: boolean;
};
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
}
export function parseCliPassthroughArgsFromArgv(
input: ParseCliPassthroughArgsFromArgvInput,
): string[] | null {
const startIndex = input.isDefaultApp ? 2 : 1;
const effective = input.argv
.slice(startIndex)
.filter((arg) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p)));
if (input.forceCli) {
return effective;
}
return effective.length > 0 ? effective : null;
}
export function createNodeEntrypointInvocation(
input: CreateNodeEntrypointInvocationInput,
): NodeEntrypointInvocation {
const env = createElectronNodeEnv(input.baseEnv);
if (input.isPackaged) {
if (!input.packagedRunnerPath) {
throw new Error("Packaged node entrypoint runner is required for desktop launches.");
}
return {
command: input.execPath,
args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
env,
};
}
return {
command: input.execPath,
args: [...input.entrypoint.execArgv, input.entrypoint.entryPath, ...input.args],
env,
};
}
@@ -1,21 +0,0 @@
import { pathToFileURL } from "node:url";
async function main(): Promise<void> {
const [argvMode, entryPath, ...args] = process.argv.slice(2);
if (argvMode !== "bare" && argvMode !== "node-script") {
throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? "<missing>"}`);
}
if (!entryPath) {
throw new Error("Missing node entrypoint path.");
}
process.argv =
argvMode === "bare" ? [process.argv[0] ?? "node", ...args] : [process.argv[0] ?? "node", entryPath, ...args];
await import(pathToFileURL(entryPath).href);
}
void main().catch((error) => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
+23 -45
View File
@@ -3,23 +3,21 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
import {
DESKTOP_CLI_ENV,
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
parseCliPassthroughArgsFromArgv as parseCliPassthroughArgs,
type NodeEntrypointArgvMode,
type NodeEntrypointInvocation,
type NodeEntrypointSpec,
} from "./node-entrypoint-launcher.js";
const CLI_PACKAGE_NAME = "@getpaseo/cli";
const SERVER_PACKAGE_NAME = "@getpaseo/server";
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
type PackageInfo = {
root: string;
};
export type NodeEntrypointSpec = {
entryPath: string;
execArgv: string[];
};
const esmRequire = createRequire(__filename);
function findPackageRootFromResolvedPath(input: {
@@ -75,10 +73,6 @@ function resolvePackagedAsarPath(): string {
return path.join(process.resourcesPath, "app.asar");
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
}
function assertPathExists(input: { label: string; filePath: string }): string {
if (!existsSync(input.filePath)) {
throw new Error(`${input.label} is missing at ${input.filePath}`);
@@ -88,11 +82,16 @@ function assertPathExists(input: { label: string; filePath: string }): string {
}
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
return parseCliPassthroughArgs({
argv,
isDefaultApp: process.defaultApp,
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
});
const startIndex = process.defaultApp ? 2 : 1;
const effective = argv
.slice(startIndex)
.filter(
(arg) =>
!arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX) &&
!arg.startsWith("--"),
);
return effective.length > 0 ? effective : null;
}
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
@@ -168,39 +167,18 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
};
}
export function createNodeEntrypointInvocation(input: {
entrypoint: NodeEntrypointSpec;
argvMode: NodeEntrypointArgvMode;
args: string[];
baseEnv: NodeJS.ProcessEnv;
}): NodeEntrypointInvocation {
return createSharedNodeEntrypointInvocation({
execPath: process.execPath,
isPackaged: app.isPackaged,
packagedRunnerPath: app.isPackaged
? assertPathExists({
label: "Bundled node entrypoint runner",
filePath: resolvePackagedNodeEntrypointRunnerPath(),
})
: null,
entrypoint: input.entrypoint,
argvMode: input.argvMode,
args: input.args,
baseEnv: input.baseEnv,
});
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return {
...baseEnv,
ELECTRON_RUN_AS_NODE: "1",
};
}
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
const cli = resolveCliEntrypoint();
const invocation = createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
args,
baseEnv: process.env,
});
return spawnSync(invocation.command, invocation.args, {
env: invocation.env,
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
env: createElectronNodeEnv(process.env),
stdio: "inherit",
});
}
+1 -1
View File
@@ -90,7 +90,7 @@ async function createMainWindow(): Promise<void> {
show: false,
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: isMac ? "hidden" : "default",
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
trafficLightPosition: isMac ? { x: 16, y: 17 } : undefined,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { readBadgeCount } from "./window-manager";
describe("window-manager", () => {
describe("readBadgeCount", () => {
it("returns valid non-negative integers", () => {
expect(readBadgeCount(0)).toBe(0);
expect(readBadgeCount(3)).toBe(3);
});
it("falls back to zero for invalid payloads", () => {
expect(readBadgeCount(undefined)).toBe(0);
expect(readBadgeCount(null)).toBe(0);
expect(readBadgeCount(Number.NaN)).toBe(0);
expect(readBadgeCount(Number.POSITIVE_INFINITY)).toBe(0);
expect(readBadgeCount(-1)).toBe(0);
expect(readBadgeCount(1.5)).toBe(0);
expect(readBadgeCount("2")).toBe(0);
expect(readBadgeCount({ count: 2 })).toBe(0);
});
});
});
+2 -19
View File
@@ -6,14 +6,6 @@ import {
type WindowMoveState,
} from "./window-drag-coordinates.js";
export function readBadgeCount(input: unknown): number {
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
return 0;
}
return input;
}
export function registerWindowManager(): void {
// ---------------------------------------------------------------------------
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
@@ -87,18 +79,9 @@ export function registerWindowManager(): void {
return win?.isFullScreen() ?? false;
});
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => {
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => {
if (process.platform === "darwin" || process.platform === "linux") {
const badgeCount = readBadgeCount(count);
try {
app.setBadgeCount(badgeCount);
} catch (error) {
console.warn("[window-manager] Failed to update badge count", {
count,
badgeCount,
error,
});
}
app.setBadgeCount(count ?? 0);
}
});
}
+1 -2
View File
@@ -5,9 +5,8 @@
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"build": "tsc",
"build": "expo-module build",
"clean": "expo-module clean",
"dev": "expo-module build",
"lint": "expo-module lint",
"test": "expo-module test",
"format": "biome check .",
+1 -1
View File
@@ -10,7 +10,7 @@ dotenv.config({
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const result = spawnSync(
process.execPath,
["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
{
stdio: "inherit",
env: process.env,
@@ -0,0 +1,131 @@
import { describe, expect, test, vi } from "vitest";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
function createChunk(input: {
streamId: number;
offset: number;
data: string;
}): TerminalStreamChunk {
const bytes = new TextEncoder().encode(input.data);
return {
streamId: input.streamId,
offset: input.offset,
endOffset: input.offset + bytes.byteLength,
replay: false,
data: bytes,
};
}
describe("TerminalStreamManager", () => {
test("buffers chunks and flushes with ack when handler subscribes", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
const seen: string[] = [];
manager.receiveChunk({
chunk: createChunk({ streamId: 7, offset: 4, data: "hello" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 7, offset: 9 });
manager.subscribe({
streamId: 7,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["hello"]);
expect(sendAck).toHaveBeenCalledTimes(1);
});
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", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 11,
handler: () => {
throw new Error("boom");
},
});
manager.receiveChunk({
chunk: createChunk({ streamId: 11, offset: 0, data: "x" }),
});
expect(sendAck).not.toHaveBeenCalled();
});
test("evicts oldest buffered chunks when max buffered chunk count is exceeded", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({
sendAck,
maxBufferedChunks: 2,
maxBufferedBytes: 1024,
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 0, data: "A" }),
});
manager.receiveChunk({
chunk: createChunk({ streamId: 5, offset: 1, data: "B" }),
});
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({
streamId: 5,
handler: (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
},
});
expect(seen).toEqual(["B", "C"]);
expect(sendAck).toHaveBeenCalledTimes(3);
});
test("tracks explicit ack offsets and skips stale auto-acks", () => {
const sendAck = vi.fn();
const manager = new TerminalStreamManager({ sendAck });
manager.subscribe({
streamId: 13,
handler: () => {
// no-op
},
});
manager.noteAck({ streamId: 13, offset: 10 });
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 0, data: "abc" }),
});
expect(sendAck).not.toHaveBeenCalled();
manager.receiveChunk({
chunk: createChunk({ streamId: 13, offset: 11, data: "z" }),
});
expect(sendAck).toHaveBeenCalledTimes(1);
expect(sendAck).toHaveBeenCalledWith({ streamId: 13, offset: 12 });
});
});
@@ -0,0 +1,173 @@
export type TerminalStreamChunk = {
streamId: number;
offset: number;
endOffset: number;
replay: boolean;
data: Uint8Array;
};
type TerminalChunkHandler = (chunk: TerminalStreamChunk) => void;
type BufferedTerminalStreamQueue = {
chunks: TerminalStreamChunk[];
bytes: number;
};
type TerminalStreamAck = {
streamId: number;
offset: number;
};
export type TerminalStreamManagerConfig = {
sendAck: (ack: TerminalStreamAck) => void;
maxBufferedChunks?: number;
maxBufferedBytes?: number;
};
const DEFAULT_MAX_BUFFERED_CHUNKS = 2048;
const DEFAULT_MAX_BUFFERED_BYTES = 2 * 1024 * 1024;
export class TerminalStreamManager {
private readonly handlers: Map<number, Set<TerminalChunkHandler>> = new Map();
private readonly bufferedChunks: Map<number, BufferedTerminalStreamQueue> = new Map();
private readonly ackOffsets: Map<number, number> = new Map();
private readonly maxBufferedChunks: number;
private readonly maxBufferedBytes: number;
constructor(private readonly config: TerminalStreamManagerConfig) {
this.maxBufferedChunks = config.maxBufferedChunks ?? DEFAULT_MAX_BUFFERED_CHUNKS;
this.maxBufferedBytes = config.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
}
clearAll(): void {
this.handlers.clear();
this.bufferedChunks.clear();
this.ackOffsets.clear();
}
clearStream(input: { streamId: number }): void {
this.handlers.delete(input.streamId);
this.bufferedChunks.delete(input.streamId);
this.ackOffsets.delete(input.streamId);
}
subscribe(input: { streamId: number; handler: TerminalChunkHandler }): () => void {
const { streamId, handler } = input;
if (!this.handlers.has(streamId)) {
this.handlers.set(streamId, new Set());
}
const streamHandlers = this.handlers.get(streamId)!;
streamHandlers.add(handler);
this.flushBufferedChunks({ streamId, handler });
return () => {
streamHandlers.delete(handler);
if (streamHandlers.size === 0) {
this.handlers.delete(streamId);
}
};
}
receiveChunk(input: { chunk: TerminalStreamChunk }): void {
const { chunk } = input;
const streamHandlers = this.handlers.get(chunk.streamId);
if (!streamHandlers || streamHandlers.size === 0) {
this.bufferChunk({ chunk });
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
return;
}
let delivered = false;
for (const handler of streamHandlers) {
try {
handler(chunk);
delivered = true;
} catch {
// no-op
}
}
if (delivered) {
this.maybeAckChunk({
streamId: chunk.streamId,
endOffset: chunk.endOffset,
});
}
}
noteAck(input: TerminalStreamAck): void {
const normalizedOffset = Math.max(0, Math.floor(input.offset));
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedOffset > previousAck) {
this.ackOffsets.set(input.streamId, normalizedOffset);
}
}
private flushBufferedChunks(input: { streamId: number; handler: TerminalChunkHandler }): void {
const buffered = this.bufferedChunks.get(input.streamId);
if (!buffered || buffered.chunks.length === 0) {
return;
}
for (const chunk of buffered.chunks) {
try {
input.handler(chunk);
this.maybeAckChunk({
streamId: input.streamId,
endOffset: chunk.endOffset,
});
} catch {
// no-op
}
}
this.bufferedChunks.delete(input.streamId);
}
private bufferChunk(input: { chunk: TerminalStreamChunk }): void {
const queue = this.bufferedChunks.get(input.chunk.streamId) ?? {
chunks: [],
bytes: 0,
};
queue.chunks.push(input.chunk);
queue.bytes += input.chunk.data.byteLength;
while (queue.chunks.length > this.maxBufferedChunks || queue.bytes > this.maxBufferedBytes) {
const removed = queue.chunks.shift();
if (!removed) {
break;
}
queue.bytes -= removed.data.byteLength;
if (queue.bytes < 0) {
queue.bytes = 0;
}
}
this.bufferedChunks.set(input.chunk.streamId, queue);
}
private maybeAckChunk(input: { streamId: number; endOffset: number }): void {
if (!Number.isFinite(input.endOffset) || input.endOffset < 0) {
return;
}
const normalizedEndOffset = Math.floor(input.endOffset);
const previousAck = this.ackOffsets.get(input.streamId) ?? -1;
if (normalizedEndOffset <= previousAck) {
return;
}
this.ackOffsets.set(input.streamId, normalizedEndOffset);
try {
this.config.sendAck({
streamId: input.streamId,
offset: normalizedEndOffset,
});
} catch {
// no-op
}
}
}
+170 -236
View File
@@ -1,14 +1,12 @@
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
import { DaemonClient, type DaemonTransport } from "./daemon-client";
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeTerminalResizePayload,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
} from "../shared/terminal-stream-protocol.js";
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from "../shared/binary-mux.js";
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
expectTypeOf<
@@ -1226,7 +1224,7 @@ describe("DaemonClient", () => {
});
});
test("emits output events for the active terminal stream", async () => {
test("auto-acks terminal stream chunks after delivery", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1244,154 +1242,36 @@ describe("DaemonClient", () => {
await connectPromise;
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
const decoder = new TextDecoder();
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
seen.push(decoder.decode(chunk.data));
});
const subscribePromise = client.subscribeTerminal("term-1", "sub-1");
const payload = new TextEncoder().encode("hello");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 11,
error: null,
requestId: "sub-1",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 11,
payload: new TextEncoder().encode("hello"),
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 7,
offset: 10,
payload,
}),
);
expect(seen).toEqual(["hello"]);
expect(mock.sent).toHaveLength(1);
const ackBytes = asUint8Array(mock.sent[0]);
expect(ackBytes).not.toBeNull();
const ackFrame = decodeBinaryMuxFrame(ackBytes!);
expect(ackFrame).not.toBeNull();
expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal);
expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(ackFrame!.streamId).toBe(7);
expect(ackFrame!.offset).toBe(15);
unsubscribe();
});
test("emits snapshot events for the subscribed terminal stream", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const snapshots: unknown[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "snapshot") {
return;
}
snapshots.push(event.state);
});
const subscribePromise = client.subscribeTerminal("term-1", "sub-2");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 12,
error: null,
requestId: "sub-2",
},
}),
);
await subscribePromise;
const state = {
rows: 1,
cols: 5,
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
scrollback: [],
cursor: { row: 0, col: 5 },
};
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: 12,
payload: encodeTerminalSnapshotPayload(state),
}),
);
expect(snapshots).toEqual([state]);
});
test("sends input and resize frames for the subscribed terminal slot", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const subscribePromise = client.subscribeTerminal("term-1", "sub-3");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 13,
error: null,
requestId: "sub-3",
},
}),
);
await subscribePromise;
mock.sent.length = 0;
client.sendTerminalInput("term-1", {
type: "input",
data: "echo hello\r",
});
client.sendTerminalInput("term-1", {
type: "resize",
rows: 24,
cols: 80,
});
const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!);
const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!);
expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input);
expect(inputFrame?.slot).toBe(13);
expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r");
expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize);
expect(resizeFrame?.slot).toBe(13);
expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({
rows: 24,
cols: 80,
});
});
test("routes concurrent terminal stream frames by slot", async () => {
test("handles terminal binary frames delivered as UTF-8 strings", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1409,78 +1289,75 @@ describe("DaemonClient", () => {
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`);
client.onTerminalStreamData(11, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 21,
error: null,
requestId: "sub-multi-1",
},
}),
);
await subscribeFirstPromise;
const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-2",
slot: 22,
error: null,
requestId: "sub-multi-2",
},
}),
);
await subscribeSecondPromise;
mock.sent.length = 0;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 22,
payload: new TextEncoder().encode("beta"),
}),
);
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 21,
payload: new TextEncoder().encode("alpha"),
}),
);
client.sendTerminalInput("term-2", {
type: "input",
data: "echo beta\r",
});
client.sendTerminalInput("term-1", {
type: "resize",
rows: 10,
cols: 20,
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);
const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!);
const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!);
mock.triggerMessage(frameAsString);
expect(seen).toEqual(["term-2:beta", "term-1:alpha"]);
expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input);
expect(inputFrame?.slot).toBe(22);
expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize);
expect(resizeFrame?.slot).toBe(21);
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("ignores terminal stream frames after terminal_stream_exit", async () => {
test("acks buffered terminal chunks when handler is attached", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 19,
offset: 0,
payload: new TextEncoder().encode("buffered"),
}),
);
expect(mock.sent).toHaveLength(1);
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(bufferedAck?.streamId).toBe(19);
expect(bufferedAck?.offset).toBe(8);
mock.sent.length = 0;
const seen: string[] = [];
client.onTerminalStreamData(19, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
expect(seen.join("")).toContain("buffered");
expect(mock.sent).toHaveLength(0);
});
test("stops delivering and acking after terminal_stream_exit", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -1498,57 +1375,114 @@ describe("DaemonClient", () => {
await connectPromise;
const seen: string[] = [];
const unsubscribe = client.onTerminalStreamEvent((event) => {
if (event.type !== "output") {
return;
}
seen.push(new TextDecoder().decode(event.data));
const unsubscribe = client.onTerminalStreamData(23, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const subscribePromise = client.subscribeTerminal("term-1", "sub-4");
mock.triggerMessage(
wrapSessionMessage({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 14,
error: null,
requestId: "sub-4",
},
}),
);
await subscribePromise;
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 14,
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 0,
payload: new TextEncoder().encode("before-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(1);
mock.sent.length = 0;
mock.triggerMessage(
wrapSessionMessage({
type: "terminal_stream_exit",
payload: {
streamId: 23,
terminalId: "term-1",
},
}),
);
mock.triggerMessage(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot: 14,
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 12,
payload: new TextEncoder().encode("after-exit"),
}),
);
expect(seen).toEqual(["before-exit"]);
expect(mock.sent).toHaveLength(1);
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(postExitAck?.streamId).toBe(23);
expect(postExitAck?.offset).toBe(22);
unsubscribe();
});
test("cleans local stream state even when detach reports success=false", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const seen: string[] = [];
client.onTerminalStreamData(31, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data));
});
const detachPromise = client.detachTerminalStream(31, "detach-31");
const detachRequest = JSON.parse(String(mock.sent[0])) as {
type: "session";
message: { type: string; streamId: number; requestId: string };
};
expect(detachRequest.message.type).toBe("detach_terminal_stream_request");
expect(detachRequest.message.streamId).toBe(31);
mock.triggerMessage(
wrapSessionMessage({
type: "detach_terminal_stream_response",
payload: {
streamId: 31,
success: false,
requestId: "detach-31",
},
}),
);
const payload = await detachPromise;
expect(payload.success).toBe(false);
mock.sent.length = 0;
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 31,
offset: 0,
payload: new TextEncoder().encode("after-detach"),
}),
);
expect(seen).toEqual([]);
expect(mock.sent).toHaveLength(1);
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack);
expect(detachedAck?.streamId).toBe(31);
expect(detachedAck?.offset).toBe(12);
});
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+168 -131
View File
@@ -44,8 +44,10 @@ import type {
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
TerminalState,
TerminalOutput,
KillTerminalResponse,
AttachTerminalStreamResponse,
DetachTerminalStreamResponse,
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
@@ -61,13 +63,18 @@ import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
import {
asUint8Array,
decodeTerminalSnapshotPayload,
decodeTerminalStreamFrame,
encodeTerminalResizePayload,
encodeTerminalStreamFrame,
TerminalStreamOpcode,
type TerminalStreamFrame,
} from "../shared/terminal-stream-protocol.js";
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
BinaryMuxChannel,
TerminalBinaryFlags,
TerminalBinaryMessageType,
type BinaryMuxFrame,
} from "../shared/binary-mux.js";
import { encodeTerminalKeyInput, type TerminalKeyInput } from "../shared/terminal-key-input.js";
import {
TerminalStreamManager,
type TerminalStreamChunk,
} from "./daemon-client-terminal-stream-manager.js";
import {
createRelayE2eeTransportFactory,
createWebSocketTransportFactory,
@@ -101,10 +108,7 @@ export type {
WebSocketFactory,
WebSocketLike,
} from "./daemon-client-transport.js";
export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState };
export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js";
export type ConnectionState =
| { status: "idle" }
@@ -240,7 +244,10 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type TerminalOutputPayload = TerminalOutput["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type AttachTerminalStreamPayload = AttachTerminalStreamResponse["payload"];
type DetachTerminalStreamPayload = DetachTerminalStreamResponse["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
@@ -419,11 +426,9 @@ export class DaemonClient {
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } }
>();
private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>();
private slotTerminals = new Map<number, string>();
private readonly terminalStreamListeners = new Set<(event: TerminalStreamEvent) => void>();
private logger: Logger;
private pendingSendQueue: PendingSend[] = [];
private terminalStreams: TerminalStreamManager;
private readonly logConnectionPath: "direct" | "relay";
private readonly logServerId: string | null;
private readonly logClientIdHash: string;
@@ -452,6 +457,17 @@ export class DaemonClient {
Number.isFinite(this.config.runtimeGeneration)
? this.config.runtimeGeneration
: null;
this.terminalStreams = new TerminalStreamManager({
sendAck: (ack) => {
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId: ack.streamId,
offset: ack.offset,
payload: new Uint8Array(0),
});
},
});
}
// ============================================================================
@@ -660,7 +676,7 @@ export class DaemonClient {
this.disposeTransport(1000, "Client closed");
this.clearWaiters(new Error("Daemon client closed"));
this.rejectPendingSendQueue(new Error("Daemon client closed"));
this.clearTerminalSlots();
this.terminalStreams.clearAll();
this.lastServerInfoMessage = null;
this.updateConnectionState(
{ status: "disposed" },
@@ -784,7 +800,7 @@ export class DaemonClient {
}
}
private sendBinaryFrame(frame: Uint8Array): void {
private sendBinaryFrame(frame: BinaryMuxFrame): void {
if (!this.transport || this.connectionState.status !== "connected") {
if (this.config.suppressSendErrors) {
return;
@@ -792,7 +808,7 @@ export class DaemonClient {
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
}
try {
this.transport.send(frame);
this.transport.send(encodeBinaryMuxFrame(frame));
} catch (error) {
if (this.config.suppressSendErrors) {
return;
@@ -2606,21 +2622,16 @@ export class DaemonClient {
terminalId,
requestId: resolvedRequestId,
});
const payload = await this.sendCorrelatedRequest({
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "subscribe_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
if (payload.error === null) {
this.setTerminalSlot(terminalId, payload.slot);
}
return payload;
}
unsubscribeTerminal(terminalId: string): void {
this.removeTerminalSlot(terminalId);
this.sendSessionMessage({
type: "unsubscribe_terminal_request",
terminalId,
@@ -2628,32 +2639,6 @@ export class DaemonClient {
}
sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void {
const slot = this.terminalSlots.get(terminalId);
if (typeof slot === "number") {
if (message.type === "input") {
this.sendBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot,
payload: encodeUtf8String(message.data),
}),
);
return;
}
if (message.type === "resize") {
this.sendBinaryFrame(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Resize,
slot,
payload: encodeTerminalResizePayload({
rows: message.rows,
cols: message.cols,
}),
}),
);
return;
}
}
this.sendSessionMessage({
type: "terminal_input",
terminalId,
@@ -2677,34 +2662,130 @@ export class DaemonClient {
});
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
this.terminalStreamListeners.add(handler);
return () => {
this.terminalStreamListeners.delete(handler);
};
async attachTerminalStream(
terminalId: string,
options?: {
resumeOffset?: number;
rows?: number;
cols?: number;
},
requestId?: string,
): Promise<AttachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "attach_terminal_stream_request",
terminalId,
requestId: resolvedRequestId,
...(options?.resumeOffset !== undefined ? { resumeOffset: options.resumeOffset } : {}),
...(options?.rows !== undefined ? { rows: options.rows } : {}),
...(options?.cols !== undefined ? { cols: options.cols } : {}),
});
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "attach_terminal_stream_response",
timeout: 10000,
options: { skipQueue: true },
});
}
async waitForTerminalStreamEvent(
predicate: (event: TerminalStreamEvent) => boolean,
async detachTerminalStream(
streamId: number,
requestId?: string,
): Promise<DetachTerminalStreamPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "detach_terminal_stream_request",
streamId,
requestId: resolvedRequestId,
});
const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "detach_terminal_stream_response",
timeout: 10000,
options: { skipQueue: true },
});
this.terminalStreams.clearStream({ streamId });
return payload;
}
onTerminalStreamData(
streamId: number,
handler: (chunk: TerminalStreamChunk) => void,
): () => void {
return this.terminalStreams.subscribe({ streamId, handler });
}
async waitForTerminalStreamData(
streamId: number,
predicate: (chunk: TerminalStreamChunk) => boolean,
timeout = 5000,
): Promise<TerminalStreamEvent> {
return new Promise<TerminalStreamEvent>((resolve, reject) => {
): Promise<TerminalStreamChunk> {
return new Promise<TerminalStreamChunk>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
unsubscribe();
reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`));
reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`));
}, timeout);
const unsubscribe = this.onTerminalStreamEvent((event) => {
if (!predicate(event)) {
const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => {
if (!predicate(chunk)) {
return;
}
clearTimeout(timeoutHandle);
unsubscribe();
resolve(event);
resolve(chunk);
});
});
}
sendTerminalStreamInput(streamId: number, data: string | Uint8Array): void {
const payload = typeof data === "string" ? encodeUtf8String(data) : data;
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.InputUtf8,
streamId,
offset: 0,
payload,
});
}
sendTerminalStreamKey(streamId: number, input: TerminalKeyInput): void {
const encoded = encodeTerminalKeyInput(input);
if (!encoded) {
return;
}
this.sendTerminalStreamInput(streamId, encoded);
}
sendTerminalStreamAck(streamId: number, offset: number): void {
const normalizedOffset = Math.max(0, Math.floor(offset));
this.terminalStreams.noteAck({ streamId, offset: normalizedOffset });
this.sendBinaryFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId,
offset: normalizedOffset,
payload: new Uint8Array(0),
});
}
async waitForTerminalOutput(terminalId: string, timeout = 5000): Promise<TerminalOutputPayload> {
return this.waitFor(
(msg) => {
if (msg.type !== "terminal_output") {
return null;
}
if (msg.payload.terminalId !== terminalId) {
return null;
}
return msg.payload;
},
timeout,
{ skipQueue: true },
);
}
// ============================================================================
// Internals
// ============================================================================
@@ -2713,37 +2794,6 @@ export class DaemonClient {
return requestId ?? crypto.randomUUID();
}
private setTerminalSlot(terminalId: string, slot: number): void {
const existingTerminalId = this.slotTerminals.get(slot);
if (existingTerminalId && existingTerminalId !== terminalId) {
this.terminalSlots.delete(existingTerminalId);
}
const existingSlot = this.terminalSlots.get(terminalId);
if (typeof existingSlot === "number" && existingSlot !== slot) {
this.slotTerminals.delete(existingSlot);
}
this.terminalSlots.set(terminalId, slot);
this.slotTerminals.set(slot, terminalId);
}
private removeTerminalSlot(terminalId: string): void {
const slot = this.terminalSlots.get(terminalId);
if (typeof slot !== "number") {
return;
}
this.terminalSlots.delete(terminalId);
if (this.slotTerminals.get(slot) === terminalId) {
this.slotTerminals.delete(slot);
}
}
private clearTerminalSlots(): void {
this.terminalSlots.clear();
this.slotTerminals.clear();
}
getLastServerInfoMessage(): ServerInfoStatusPayload | null {
return this.lastServerInfoMessage;
}
@@ -2840,7 +2890,7 @@ export class DaemonClient {
const rawBytes = asUint8Array(rawData);
if (rawBytes) {
const frame = decodeTerminalStreamFrame(rawBytes);
const frame = decodeBinaryMuxFrame(rawBytes);
if (frame) {
this.handleBinaryFrame(frame);
return;
@@ -2872,42 +2922,21 @@ export class DaemonClient {
this.handleSessionMessage(parsed.data.message);
}
private handleBinaryFrame(frame: TerminalStreamFrame): void {
const terminalId = this.slotTerminals.get(frame.slot);
if (!terminalId) {
private handleBinaryFrame(frame: BinaryMuxFrame): void {
if (
frame.channel === BinaryMuxChannel.Terminal &&
frame.messageType === TerminalBinaryMessageType.OutputUtf8
) {
const chunk: TerminalStreamChunk = {
streamId: frame.streamId,
offset: frame.offset,
endOffset: frame.offset + (frame.payload?.byteLength ?? 0),
replay: Boolean((frame.flags ?? 0) & TerminalBinaryFlags.Replay),
data: frame.payload ?? new Uint8Array(0),
};
this.terminalStreams.receiveChunk({ chunk });
return;
}
if (frame.opcode === TerminalStreamOpcode.Output) {
this.emitTerminalStreamEvent({
terminalId,
type: "output",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
return;
}
this.emitTerminalStreamEvent({
terminalId,
type: "snapshot",
state,
});
}
}
private emitTerminalStreamEvent(event: TerminalStreamEvent): void {
for (const listener of this.terminalStreamListeners) {
try {
listener(event);
} catch {
// no-op
}
}
}
private updateConnectionState(
@@ -2967,7 +2996,7 @@ export class DaemonClient {
// and responses from the previous connection will never arrive.
this.clearWaiters(new Error(reason ?? "Connection lost"));
this.rejectPendingSendQueue(new Error(reason ?? "Connection lost"));
this.clearTerminalSlots();
this.terminalStreams.clearAll();
this.lastServerInfoMessage = null;
if (wasDisposed) {
@@ -3022,7 +3051,7 @@ export class DaemonClient {
}
if (msg.type === "terminal_stream_exit") {
this.removeTerminalSlot(msg.payload.terminalId);
this.terminalStreams.clearStream({ streamId: msg.payload.streamId });
}
if (this.rawMessageListeners.size > 0) {
@@ -3124,6 +3153,14 @@ export class DaemonClient {
}
}
private async waitFor<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
_options?: { skipQueue?: boolean },
): Promise<T> {
return this.waitForWithCancel(predicate, timeout, _options).promise;
}
private waitForWithCancel<T>(
predicate: (msg: SessionOutboundMessage) => T | null,
timeout = 30000,
@@ -1588,6 +1588,34 @@ describe("AgentManager", () => {
}
});
test("notifyAgentState does not mutate updatedAt", 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 before = manager.getAgent(snapshot.id);
expect(before).toBeDefined();
manager.notifyAgentState(snapshot.id);
const after = manager.getAgent(snapshot.id);
expect(after).toBeDefined();
expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime());
});
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");
@@ -247,6 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
const RECENT_USER_MESSAGE_REPLAY_WINDOW_MS = 15_000;
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
const AgentIdSchema = z.string().uuid();
@@ -790,7 +791,6 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -807,7 +807,6 @@ export class AgentManager {
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -823,7 +822,6 @@ export class AgentManager {
}
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -842,7 +840,6 @@ export class AgentManager {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
await this.persistSnapshot(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -851,7 +848,6 @@ export class AgentManager {
if (!agent || agent.internal) {
return;
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -935,6 +931,66 @@ export class AgentManager {
}
}
classifyRecordedUserMessage(
agentId: string,
text: string,
options?: { messageId?: string },
): "new" | "duplicate" | "conflict" {
const normalizedMessageId = normalizeMessageId(options?.messageId);
if (!normalizedMessageId) {
return "new";
}
const agent = this.requireAgent(agentId);
for (const row of agent.timelineRows) {
if (row.item.type !== "user_message") {
continue;
}
const rowMessageId = normalizeMessageId(row.item.messageId);
if (rowMessageId !== normalizedMessageId) {
continue;
}
return row.item.text === text ? "duplicate" : "conflict";
}
if (this.isRecentUserMessageReplay(agent, text)) {
return "duplicate";
}
return "new";
}
private isRecentUserMessageReplay(agent: ManagedAgent, text: string): boolean {
let latestUserMessageRow: AgentTimelineRow | null = null;
for (let index = agent.timelineRows.length - 1; index >= 0; index -= 1) {
const row = agent.timelineRows[index];
if (!row || row.item.type !== "user_message") {
continue;
}
latestUserMessageRow = row;
break;
}
const latestUserMessageItem =
latestUserMessageRow?.item.type === "user_message" ? latestUserMessageRow.item : null;
if (!latestUserMessageRow || !latestUserMessageItem || latestUserMessageItem.text !== text) {
return false;
}
if (agent.lifecycle === "running" || agent.pendingRun || agent.pendingReplacement) {
return true;
}
const rowTimestampMs = Date.parse(latestUserMessageRow.timestamp);
const latestActivityMs = Math.max(
Number.isFinite(rowTimestampMs) ? rowTimestampMs : 0,
agent.updatedAt.getTime(),
agent.lastUserMessageAt?.getTime() ?? 0,
);
return Date.now() - latestActivityMs <= RECENT_USER_MESSAGE_REPLAY_WINDOW_MS;
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
@@ -1058,7 +1114,6 @@ export class AgentManager {
"streamAgent.finalize: applying terminal state",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
this.emitState(mutableAgent);
this.flushLiveEventBacklog(mutableAgent);
}
@@ -1155,7 +1210,6 @@ export class AgentManager {
const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle;
if (!hasForegroundRun && lifecycle === "running") {
latestActive.lifecycle = "idle";
self.touchUpdatedAt(latestActive);
self.emitState(latestActive);
self.flushLiveEventBacklog(latestActive);
}
@@ -1313,7 +1367,6 @@ export class AgentManager {
});
}
agent.pendingPermissions.clear();
this.touchUpdatedAt(agent);
this.emitState(agent);
}
@@ -770,7 +770,6 @@ type TimelineMessageState = {
class TimelineAssembler {
private readonly messages = new Map<string, TimelineMessageState>();
private readonly finalizedMessageIds = new Set<string>();
private readonly activeMessageByRun = new Map<string, string>();
private syntheticMessageCounter = 0;
@@ -800,9 +799,6 @@ class TimelineAssembler {
if (!messageId) {
return [];
}
if (this.finalizedMessageIds.has(messageId)) {
return [];
}
const state = this.ensureMessageState(messageId, runId);
const fragments = this.extractFragments(message.message?.content);
return this.applyAbsoluteFragments(state, fragments);
@@ -926,8 +922,6 @@ class TimelineAssembler {
if (runId && this.activeMessageByRun.get(runId) === messageId) {
this.activeMessageByRun.delete(runId);
}
this.finalizedMessageIds.add(messageId);
this.messages.delete(messageId);
return items;
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
@@ -97,4 +98,47 @@ describe("Codex app-server provider", () => {
expect(item.detail.newString).toBeUndefined();
}
});
test("does not buffer terminal Codex events before turn_started", () => {
const terminalEvents: AgentStreamEvent[] = [
{ type: "turn_completed", provider: "codex", usage: undefined },
{ type: "turn_failed", provider: "codex", error: "boom" },
{ type: "turn_canceled", provider: "codex", reason: "interrupted" },
];
for (const event of terminalEvents) {
expect(__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted(event)).toBe(false);
}
expect(
__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "stale chunk" },
}),
).toBe(true);
});
test("only synthesizes completion from idle thread status while a run is active", () => {
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: true,
}),
).toBe(true);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: false,
}),
).toBe(false);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "running",
hasActiveRun: true,
}),
).toBe(false);
});
});

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