mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c28d917a4b | ||
|
|
2f6b8cea01 | ||
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -16,7 +17,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
@@ -31,6 +32,18 @@ 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:
|
||||
|
||||
@@ -132,6 +132,7 @@ 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 }}
|
||||
@@ -237,6 +238,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
@@ -345,6 +347,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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
|
||||
+16
-10
@@ -37,18 +37,24 @@ 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.
|
||||
|
||||
To retry a failed workflow for an existing tag:
|
||||
**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.
|
||||
|
||||
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
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build:
|
||||
|
||||
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.
|
||||
```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.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
Generated
+150
-2
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"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",
|
||||
@@ -12556,18 +12557,58 @@
|
||||
"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",
|
||||
@@ -15184,6 +15225,31 @@
|
||||
"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",
|
||||
@@ -21317,6 +21383,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -21712,6 +21805,15 @@
|
||||
"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",
|
||||
@@ -24047,6 +24149,12 @@
|
||||
"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",
|
||||
@@ -25001,7 +25109,6 @@
|
||||
"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"
|
||||
@@ -27577,6 +27684,18 @@
|
||||
"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",
|
||||
@@ -28524,6 +28643,15 @@
|
||||
"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",
|
||||
@@ -32380,6 +32508,12 @@
|
||||
"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",
|
||||
@@ -32547,6 +32681,16 @@
|
||||
"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",
|
||||
@@ -34490,7 +34634,6 @@
|
||||
"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": {
|
||||
@@ -34719,8 +34862,13 @@
|
||||
"@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",
|
||||
|
||||
+2
-1
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
@@ -59,6 +59,7 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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 --prefix ../expo-two-way-audio",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/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,8 +44,13 @@
|
||||
"@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",
|
||||
|
||||
@@ -264,6 +264,9 @@ 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;
|
||||
@@ -274,6 +277,8 @@ function AppContainer({
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
});
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -284,7 +289,7 @@ function AppContainer({
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
|
||||
@@ -120,6 +120,7 @@ export function AgentInputArea({
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(agentDirectoryStatus === "ready" ||
|
||||
@@ -593,12 +594,14 @@ export function AgentInputArea({
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const rightContent = (
|
||||
@@ -625,7 +628,9 @@ export function AgentInputArea({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
|
||||
{voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -283,55 +283,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}
|
||||
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Tooltip
|
||||
@@ -423,6 +374,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}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -484,49 +484,6 @@ 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
|
||||
@@ -596,6 +553,49 @@ 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,11 +1,12 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
@@ -41,6 +42,7 @@ import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-typ
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { QuestionFormCard } from "./question-form-card";
|
||||
@@ -63,6 +65,7 @@ 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,
|
||||
@@ -83,12 +86,13 @@ export interface AgentStreamViewProps {
|
||||
agent: Agent;
|
||||
streamItems: StreamItem[];
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
isPaneFocused?: boolean;
|
||||
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
|
||||
isAuthoritativeHistoryReady?: boolean;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
agentId,
|
||||
@@ -96,6 +100,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
agent,
|
||||
streamItems,
|
||||
pendingPermissions,
|
||||
isPaneFocused = false,
|
||||
routeBottomAnchorRequest = null,
|
||||
isAuthoritativeHistoryReady = true,
|
||||
onOpenWorkspaceFile,
|
||||
@@ -136,6 +141,11 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
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";
|
||||
@@ -164,7 +174,7 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
|
||||
if (normalized.file) {
|
||||
if (onOpenWorkspaceFile) {
|
||||
onOpenWorkspaceFile({ filePath: normalized.file });
|
||||
openWorkspaceFile({ filePath: normalized.file });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,10 +207,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
resolvedServerId,
|
||||
router,
|
||||
setExplorerTabForCheckout,
|
||||
onOpenWorkspaceFile,
|
||||
openWorkspaceFile,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
const pendingPermissionItems = useMemo(
|
||||
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
@@ -483,18 +497,18 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
const pendingPermissionsNode =
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
||||
{pendingPermissionItems.map((permission, index) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
client={client}
|
||||
shortcutActive={isPaneFocused && index === 0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null;
|
||||
@@ -518,7 +532,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
workingIndicator: workingIndicatorNode,
|
||||
},
|
||||
};
|
||||
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]);
|
||||
}, [
|
||||
baseRenderModel,
|
||||
client,
|
||||
getGapBetween,
|
||||
isPaneFocused,
|
||||
pendingPermissionItems,
|
||||
showWorkingIndicator,
|
||||
]);
|
||||
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
if (
|
||||
@@ -657,6 +678,9 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
|
||||
},
|
||||
);
|
||||
|
||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||
AgentStreamView.displayName = "AgentStreamView";
|
||||
|
||||
function WorkingIndicator() {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
@@ -717,9 +741,11 @@ function WorkingIndicator() {
|
||||
function PermissionRequestCard({
|
||||
permission,
|
||||
client,
|
||||
shortcutActive,
|
||||
}: {
|
||||
permission: PendingPermission;
|
||||
client: DaemonClient | null;
|
||||
shortcutActive: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
@@ -730,9 +756,6 @@ function PermissionRequestCard({
|
||||
const description = request.description ?? "";
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
return undefined;
|
||||
}
|
||||
const planFromMetadata =
|
||||
typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined;
|
||||
if (planFromMetadata) {
|
||||
@@ -858,6 +881,7 @@ function PermissionRequestCard({
|
||||
resetPermissionMutation();
|
||||
setRespondingAction(null);
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
respondToPermission({
|
||||
@@ -871,12 +895,52 @@ function PermissionRequestCard({
|
||||
[permission.agentId, permission.request.id, respondToPermission],
|
||||
);
|
||||
|
||||
const handleDeny = useCallback(() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}, [handleResponse]);
|
||||
|
||||
const handleAccept = useCallback(() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}, [handleResponse]);
|
||||
|
||||
const handlePromptSelection = useCallback(
|
||||
(action: { id: string; index?: number }): boolean => {
|
||||
if (action.id !== "agent.prompt.select" || request.kind === "question" || isResponding) {
|
||||
return false;
|
||||
}
|
||||
if (action.index === 1) {
|
||||
handleDeny();
|
||||
return true;
|
||||
}
|
||||
if (action.index === 2) {
|
||||
handleAccept();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[handleAccept, handleDeny, isResponding, request.kind],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `agent-prompt-permission:${permission.key}`,
|
||||
actions: ["agent.prompt.select"],
|
||||
enabled: shortcutActive && request.kind !== "question" && !isResponding,
|
||||
priority: 150,
|
||||
handle: handlePromptSelection,
|
||||
});
|
||||
|
||||
if (request.kind === "question") {
|
||||
return (
|
||||
<QuestionFormCard
|
||||
permission={permission}
|
||||
onRespond={handleResponse}
|
||||
isResponding={isResponding}
|
||||
shortcutActive={shortcutActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -948,13 +1012,7 @@ function PermissionRequestCard({
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("deny");
|
||||
handleResponse({
|
||||
behavior: "deny",
|
||||
message: "Denied by user",
|
||||
});
|
||||
}}
|
||||
onPress={handleDeny}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "deny" ? (
|
||||
@@ -979,10 +1037,7 @@ function PermissionRequestCard({
|
||||
},
|
||||
pressed ? permissionStyles.optionButtonPressed : null,
|
||||
]}
|
||||
onPress={() => {
|
||||
setRespondingAction("accept");
|
||||
handleResponse({ behavior: "allow" });
|
||||
}}
|
||||
onPress={handleAccept}
|
||||
disabled={isResponding}
|
||||
>
|
||||
{respondingAction === "accept" ? (
|
||||
|
||||
@@ -178,7 +178,7 @@ export function CommandCenter() {
|
||||
</View>
|
||||
</View>
|
||||
{action.shortcutKeys ? (
|
||||
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</CommandCenterRow>
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 {
|
||||
@@ -17,6 +19,7 @@ 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;
|
||||
@@ -77,6 +80,11 @@ 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="Keyboard shortcuts"
|
||||
title="Shortcuts"
|
||||
visible={open}
|
||||
onClose={() => setOpen(false)}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
|
||||
@@ -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 keys={newAgentKeys} />}
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</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>
|
||||
<Shortcut keys={["⌘", "⇧", "O"]} />
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -33,6 +33,7 @@ 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,
|
||||
@@ -210,6 +211,10 @@ 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);
|
||||
@@ -1006,10 +1011,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
: "Mute voice"
|
||||
: "Dictation"}
|
||||
</Text>
|
||||
<Shortcut
|
||||
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
|
||||
style={styles.tooltipShortcut}
|
||||
/>
|
||||
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
|
||||
<Shortcut
|
||||
chord={
|
||||
(isRealtimeVoiceForCurrentAgent
|
||||
? voiceMuteToggleKeys
|
||||
: dictationToggleKeys)!
|
||||
}
|
||||
style={styles.tooltipShortcut}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1032,7 +1043,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Queue</Text>
|
||||
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
|
||||
{queueKeys ? (
|
||||
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1055,7 +1068,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>
|
||||
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
interface QuestionOption {
|
||||
@@ -17,6 +18,9 @@ interface Question {
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
type QuestionSelections = Record<number, Set<number>>;
|
||||
type QuestionOtherTexts = Record<number, string>;
|
||||
|
||||
function parseQuestions(input: unknown): Question[] | null {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
@@ -26,128 +30,334 @@ function parseQuestions(input: unknown): Question[] | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: Question[] = [];
|
||||
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const q = item as Record<string, unknown>;
|
||||
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
|
||||
if (!Array.isArray(q.options)) return null;
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = item as Record<string, unknown>;
|
||||
if (typeof question.question !== "string" || typeof question.header !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(question.options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options: QuestionOption[] = [];
|
||||
for (const opt of q.options as unknown[]) {
|
||||
if (typeof opt !== "object" || opt === null) return null;
|
||||
const o = opt as Record<string, unknown>;
|
||||
if (typeof o.label !== "string") return null;
|
||||
for (const option of question.options as unknown[]) {
|
||||
if (typeof option !== "object" || option === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = option as Record<string, unknown>;
|
||||
if (typeof candidate.label !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
label: candidate.label,
|
||||
description: typeof candidate.description === "string" ? candidate.description : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
questions.push({
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
question: question.question,
|
||||
header: question.header,
|
||||
options,
|
||||
multiSelect: q.multiSelect === true,
|
||||
multiSelect: question.multiSelect === true,
|
||||
});
|
||||
}
|
||||
|
||||
return questions.length > 0 ? questions : null;
|
||||
}
|
||||
|
||||
function cloneSelections(input: QuestionSelections): QuestionSelections {
|
||||
const next: QuestionSelections = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
next[Number(key)] = new Set(value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isQuestionAnswered(input: {
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
questionIndex: number;
|
||||
}): boolean {
|
||||
const selected = input.selections[input.questionIndex];
|
||||
const otherText = input.otherTexts[input.questionIndex]?.trim();
|
||||
return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0);
|
||||
}
|
||||
|
||||
function findFirstUnansweredQuestionIndex(input: {
|
||||
questions: readonly Question[];
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
}): number | null {
|
||||
for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) {
|
||||
if (
|
||||
!isQuestionAnswered({
|
||||
selections: input.selections,
|
||||
otherTexts: input.otherTexts,
|
||||
questionIndex,
|
||||
})
|
||||
) {
|
||||
return questionIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function areAllQuestionsAnswered(input: {
|
||||
questions: readonly Question[];
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
}): boolean {
|
||||
return input.questions.every((_, questionIndex) =>
|
||||
isQuestionAnswered({
|
||||
selections: input.selections,
|
||||
otherTexts: input.otherTexts,
|
||||
questionIndex,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function buildQuestionAnswers(input: {
|
||||
questions: readonly Question[];
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
}): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
|
||||
for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) {
|
||||
const question = input.questions[questionIndex];
|
||||
if (!question) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const selected = input.selections[questionIndex];
|
||||
const otherText = input.otherTexts[questionIndex]?.trim();
|
||||
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[question.header] = otherText;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!selected || selected.size === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labels = Array.from(selected)
|
||||
.map((optionIndex) => question.options[optionIndex]?.label)
|
||||
.filter((label): label is string => typeof label === "string");
|
||||
if (labels.length > 0) {
|
||||
answers[question.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return answers;
|
||||
}
|
||||
|
||||
function selectQuestionOption(input: {
|
||||
questions: readonly Question[];
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
questionIndex: number;
|
||||
optionIndex: number;
|
||||
}): {
|
||||
selections: QuestionSelections;
|
||||
otherTexts: QuestionOtherTexts;
|
||||
} {
|
||||
const question = input.questions[input.questionIndex];
|
||||
if (!question || !question.options[input.optionIndex]) {
|
||||
return {
|
||||
selections: input.selections,
|
||||
otherTexts: input.otherTexts,
|
||||
};
|
||||
}
|
||||
|
||||
const selections = cloneSelections(input.selections);
|
||||
const otherTexts = { ...input.otherTexts };
|
||||
const current = selections[input.questionIndex] ?? new Set<number>();
|
||||
const next = new Set(current);
|
||||
|
||||
if (question.multiSelect) {
|
||||
if (next.has(input.optionIndex)) {
|
||||
next.delete(input.optionIndex);
|
||||
} else {
|
||||
next.add(input.optionIndex);
|
||||
}
|
||||
} else if (next.has(input.optionIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(input.optionIndex);
|
||||
}
|
||||
|
||||
selections[input.questionIndex] = next;
|
||||
delete otherTexts[input.questionIndex];
|
||||
|
||||
return { selections, otherTexts };
|
||||
}
|
||||
|
||||
interface QuestionFormCardProps {
|
||||
permission: PendingPermission;
|
||||
onRespond: (response: AgentPermissionResponse) => void;
|
||||
isResponding: boolean;
|
||||
shortcutActive: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
export function QuestionFormCard({
|
||||
permission,
|
||||
onRespond,
|
||||
isResponding,
|
||||
shortcutActive,
|
||||
}: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const questions = parseQuestions(permission.request.input);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const [selections, setSelections] = useState<QuestionSelections>({});
|
||||
const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
|
||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
setSelections((prev) => {
|
||||
const current = prev[qIndex] ?? new Set<number>();
|
||||
const next = new Set(current);
|
||||
if (multiSelect) {
|
||||
if (next.has(optIndex)) {
|
||||
next.delete(optIndex);
|
||||
} else {
|
||||
next.add(optIndex);
|
||||
}
|
||||
} else {
|
||||
if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
const submitResponses = useCallback(
|
||||
(input: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => {
|
||||
if (!questions) {
|
||||
return;
|
||||
}
|
||||
return { ...prev, [qIndex]: next };
|
||||
});
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[qIndex];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
if (text.length > 0) {
|
||||
setSelections((prev) => {
|
||||
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
|
||||
return { ...prev, [qIndex]: new Set<number>() };
|
||||
setRespondingAction("submit");
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionAnswers({
|
||||
questions,
|
||||
selections: input.selections,
|
||||
otherTexts: input.otherTexts,
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
[onRespond, permission.request.input, questions],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(input: { questionIndex: number; optionIndex: number }) => {
|
||||
if (!questions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = selectQuestionOption({
|
||||
questions,
|
||||
selections,
|
||||
otherTexts,
|
||||
questionIndex: input.questionIndex,
|
||||
optionIndex: input.optionIndex,
|
||||
});
|
||||
setSelections(next.selections);
|
||||
setOtherTexts(next.otherTexts);
|
||||
},
|
||||
[otherTexts, questions, selections],
|
||||
);
|
||||
|
||||
const setOtherText = useCallback((input: { questionIndex: number; text: string }) => {
|
||||
setOtherTexts((previous) => ({ ...previous, [input.questionIndex]: input.text }));
|
||||
if (input.text.length > 0) {
|
||||
setSelections((previous) => {
|
||||
if (!previous[input.questionIndex] || previous[input.questionIndex]?.size === 0) {
|
||||
return previous;
|
||||
}
|
||||
return { ...previous, [input.questionIndex]: new Set<number>() };
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!questions) {
|
||||
return null;
|
||||
}
|
||||
const handleSubmit = useCallback(() => {
|
||||
submitResponses({ selections, otherTexts });
|
||||
}, [otherTexts, selections, submitResponses]);
|
||||
|
||||
const allAnswered = questions.every((_, qIndex) => {
|
||||
const selected = selections[qIndex];
|
||||
const otherText = otherTexts[qIndex]?.trim();
|
||||
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
setRespondingAction("submit");
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions!.length; i++) {
|
||||
const q = questions![i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
} else if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: { ...permission.request.input, answers },
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeny() {
|
||||
const handleDeny = useCallback(() => {
|
||||
setRespondingAction("dismiss");
|
||||
onRespond({
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
}, [onRespond]);
|
||||
|
||||
const handlePromptSelection = useCallback(
|
||||
(action: { id: string; index?: number }): boolean => {
|
||||
if (action.id !== "agent.prompt.select" || isResponding || !questions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const questionIndex = findFirstUnansweredQuestionIndex({
|
||||
questions,
|
||||
selections,
|
||||
otherTexts,
|
||||
});
|
||||
if (questionIndex === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionIndex = (action.index ?? 0) - 1;
|
||||
const question = questions[questionIndex];
|
||||
if (!question?.options[optionIndex]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = selectQuestionOption({
|
||||
questions,
|
||||
selections,
|
||||
otherTexts,
|
||||
questionIndex,
|
||||
optionIndex,
|
||||
});
|
||||
setSelections(next.selections);
|
||||
setOtherTexts(next.otherTexts);
|
||||
|
||||
const shouldAutoSubmit =
|
||||
areAllQuestionsAnswered({
|
||||
questions,
|
||||
selections: next.selections,
|
||||
otherTexts: next.otherTexts,
|
||||
}) && questions.every((candidate) => candidate.multiSelect !== true);
|
||||
|
||||
if (shouldAutoSubmit) {
|
||||
submitResponses(next);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isResponding, otherTexts, questions, selections, submitResponses],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `agent-prompt-question:${permission.key}`,
|
||||
actions: ["agent.prompt.select"],
|
||||
enabled: shortcutActive && !isResponding && questions !== null,
|
||||
priority: 150,
|
||||
handle: handlePromptSelection,
|
||||
});
|
||||
|
||||
if (!questions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allAnswered = areAllQuestionsAnswered({
|
||||
questions,
|
||||
selections,
|
||||
otherTexts,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -158,24 +368,24 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
},
|
||||
]}
|
||||
>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
{questions.map((question, questionIndex) => {
|
||||
const selected = selections[questionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[questionIndex] ?? "";
|
||||
|
||||
return (
|
||||
<View key={qIndex} style={styles.questionBlock}>
|
||||
<View key={questionIndex} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
|
||||
{q.question}
|
||||
{question.question}
|
||||
</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => {
|
||||
const isSelected = selected.has(optIndex);
|
||||
{question.options.map((option, optionIndex) => {
|
||||
const isSelected = selected.has(optionIndex);
|
||||
return (
|
||||
<Pressable
|
||||
key={optIndex}
|
||||
key={optionIndex}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.optionItem,
|
||||
(hovered || isSelected) && {
|
||||
@@ -183,22 +393,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
]}
|
||||
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
|
||||
onPress={() =>
|
||||
toggleOption({
|
||||
questionIndex,
|
||||
optionIndex,
|
||||
})
|
||||
}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
|
||||
{opt.label}
|
||||
{option.label}
|
||||
</Text>
|
||||
{opt.description ? (
|
||||
{option.description ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.optionDescription,
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
{opt.description}
|
||||
{option.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -222,12 +437,21 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" },
|
||||
IS_WEB && {
|
||||
outlineStyle: "none",
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
},
|
||||
]}
|
||||
placeholder="Other..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={otherText}
|
||||
onChangeText={(text) => setOtherText(qIndex, text)}
|
||||
onChangeText={(text) =>
|
||||
setOtherText({
|
||||
questionIndex,
|
||||
text,
|
||||
})
|
||||
}
|
||||
editable={!isResponding}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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,7 +48,6 @@ 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";
|
||||
@@ -73,12 +72,19 @@ 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) {
|
||||
@@ -91,6 +97,12 @@ 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[];
|
||||
@@ -116,6 +128,7 @@ interface ProjectHeaderRowProps {
|
||||
onPress: () => void;
|
||||
serverId: string | null;
|
||||
canCreateWorktree: boolean;
|
||||
isProjectActive?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
shortcutNumber?: number | null;
|
||||
@@ -145,22 +158,53 @@ interface WorkspaceRowInnerProps {
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
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 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 WorkspaceStatusIndicator({
|
||||
@@ -173,38 +217,48 @@ function WorkspaceStatusIndicator({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const color = resolveStatusDotColor({ theme, bucket });
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
||||
const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done";
|
||||
|
||||
if (isIdle) {
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<ActivityIndicator size={8} 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}>
|
||||
{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 }]} />
|
||||
)}
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -224,29 +278,72 @@ 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}>
|
||||
{showChevron && chevron !== null ? (
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={activeWorkspace.statusBucket}
|
||||
workspaceKind={activeWorkspace.workspaceKind}
|
||||
loading={isArchiving}
|
||||
{projectIcon}
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -267,14 +364,17 @@ 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"}>
|
||||
@@ -309,8 +409,13 @@ function NewWorktreeButton({
|
||||
}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</View>
|
||||
@@ -544,6 +649,7 @@ function ProjectHeaderRow({
|
||||
onPress,
|
||||
serverId,
|
||||
canCreateWorktree,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
@@ -594,6 +700,17 @@ 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,
|
||||
@@ -636,6 +753,7 @@ function ProjectHeaderRow({
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
@@ -711,10 +829,16 @@ 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,
|
||||
@@ -749,91 +873,99 @@ function WorkspaceRowInner({
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<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}
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
{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-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
<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}`}
|
||||
>
|
||||
{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}
|
||||
{({ 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}
|
||||
<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}
|
||||
>
|
||||
{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>
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -903,7 +1035,7 @@ function WorkspaceRowWithMenu({
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive worktree?",
|
||||
message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`,
|
||||
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.`,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
@@ -991,6 +1123,23 @@ 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}
|
||||
@@ -1010,6 +1159,7 @@ function WorkspaceRowWithMenu({
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1182,6 +1332,7 @@ function FlattenedProjectRow({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
isProjectActive = false,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1196,6 +1347,7 @@ function FlattenedProjectRow({
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
return (
|
||||
@@ -1226,6 +1378,7 @@ function FlattenedProjectRow({
|
||||
onPress={onPress}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1327,6 +1480,13 @@ 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,
|
||||
@@ -1419,6 +1579,7 @@ function ProjectBlock({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={isProjectActive}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1432,6 +1593,7 @@ function ProjectBlock({
|
||||
onPress={onToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
@@ -1800,12 +1962,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectBlock: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
workspaceListContainer: {
|
||||
marginLeft: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[2],
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderLeftColor: theme.colors.surface1,
|
||||
},
|
||||
workspaceListContainer: {},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
@@ -1815,7 +1972,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectRow: {
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
flexDirection: "row",
|
||||
@@ -1861,6 +2018,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
@@ -1883,6 +2041,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
@@ -1923,20 +2082,37 @@ 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.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: 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",
|
||||
@@ -1976,17 +2152,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
width: 14,
|
||||
position: "relative",
|
||||
width: WORKSPACE_STATUS_DOT_WIDTH,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
workspaceStatusDotFill: {
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
workspaceArchivingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -2006,6 +2187,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
@@ -2017,6 +2199,23 @@ 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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
@@ -29,6 +31,8 @@ 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,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
deriveWorkspacePaneState,
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
@@ -62,6 +67,7 @@ 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;
|
||||
@@ -105,6 +111,7 @@ interface SplitContainerProps {
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: () => ReactNode;
|
||||
focusModeEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface WorkspaceTabDragData {
|
||||
@@ -149,6 +156,81 @@ 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(
|
||||
@@ -196,6 +278,7 @@ export function SplitContainer({
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
focusModeEnabled,
|
||||
}: SplitContainerProps) {
|
||||
const [activeDragTabId, setActiveDragTabId] = useState<string | null>(null);
|
||||
const [dropPreview, setDropPreview] = useState<SplitDropZoneHover | null>(null);
|
||||
@@ -214,6 +297,17 @@ 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") {
|
||||
@@ -425,7 +519,7 @@ export function SplitContainer({
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SplitNodeView
|
||||
node={layout.root}
|
||||
node={effectiveRoot}
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
@@ -714,6 +808,9 @@ 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({
|
||||
@@ -723,7 +820,18 @@ 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) => ({
|
||||
@@ -734,17 +842,6 @@ 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") {
|
||||
@@ -764,14 +861,14 @@ function SplitPaneView({
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
stableOnFocusPane(pane.id);
|
||||
};
|
||||
|
||||
const handlePaneFocusIn = (event: FocusEvent) => {
|
||||
if (!shouldFocusPaneFromEventTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
onFocusPane(pane.id);
|
||||
stableOnFocusPane(pane.id);
|
||||
};
|
||||
|
||||
paneElement.addEventListener("pointerdown", handlePanePointerDown, true);
|
||||
@@ -781,11 +878,17 @@ function SplitPaneView({
|
||||
paneElement.removeEventListener("pointerdown", handlePanePointerDown, true);
|
||||
paneElement.removeEventListener("focusin", handlePaneFocusIn, true);
|
||||
};
|
||||
}, [onFocusPane, pane.id]);
|
||||
}, [stableOnFocusPane, pane.id]);
|
||||
|
||||
return (
|
||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||
<View style={styles.paneTabs}>
|
||||
<View
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
isFocusModeEnabled &&
|
||||
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
|
||||
]}
|
||||
>
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
isFocused={isFocused}
|
||||
@@ -821,8 +924,24 @@ function SplitPaneView({
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{paneContent ? (
|
||||
<WorkspacePaneContent content={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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
(renderPaneEmptyState?.() ?? null)
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use dom";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type Ref } 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";
|
||||
@@ -12,6 +14,12 @@ 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;
|
||||
@@ -63,17 +71,14 @@ 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: {
|
||||
@@ -84,7 +89,6 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onOutputChunkConsumed?: (sequence: number) => Promise<void> | void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -123,12 +127,8 @@ 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,7 +150,6 @@ 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);
|
||||
@@ -169,6 +168,23 @@ 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 });
|
||||
@@ -315,17 +331,15 @@ export default function TerminalEmulator({
|
||||
runtime.mount({
|
||||
root,
|
||||
host,
|
||||
initialOutputText,
|
||||
initialSnapshot,
|
||||
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]);
|
||||
|
||||
@@ -344,45 +358,11 @@ 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();
|
||||
@@ -617,9 +597,9 @@ export default function TerminalEmulator({
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 8,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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,
|
||||
@@ -24,19 +20,15 @@ import {
|
||||
} from "@/terminal/runtime/terminal-stream-controller";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
selectedTerminalId: string | null;
|
||||
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
|
||||
hideHeader?: boolean;
|
||||
manageTerminalDirectorySubscription?: boolean;
|
||||
terminalId: string;
|
||||
isPaneFocused: 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 = {
|
||||
@@ -79,8 +71,6 @@ type PendingTerminalInput =
|
||||
};
|
||||
};
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -91,32 +81,14 @@ 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,
|
||||
selectedTerminalId,
|
||||
onSelectedTerminalIdChange,
|
||||
hideHeader = false,
|
||||
manageTerminalDirectorySubscription = true,
|
||||
terminalId,
|
||||
isPaneFocused,
|
||||
}: 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";
|
||||
@@ -129,119 +101,28 @@ 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,
|
||||
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 workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
|
||||
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 [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 emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateSelectedTerminalId = useCallback(
|
||||
(next: string | null) => {
|
||||
onSelectedTerminalIdChange?.(next);
|
||||
},
|
||||
[onSelectedTerminalIdChange],
|
||||
);
|
||||
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
terminalIdRef.current = terminalId;
|
||||
}, [terminalId]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
@@ -251,19 +132,26 @@ export function TerminalPane({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isScreenFocused || !selectedTerminalId) {
|
||||
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
|
||||
const nextFocusKey = `${scopeKey}:${terminalId}`;
|
||||
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
|
||||
}, [isMobile, isPaneFocused, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaneFocused && isScreenFocused && isAppVisible && terminalId) {
|
||||
lastReportedSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
}
|
||||
}, [isAppVisible, isPaneFocused, isScreenFocused, requestTerminalReflow, terminalId]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
@@ -302,7 +190,7 @@ export function TerminalPane({
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!selectedTerminalId) {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
// Navigation transitions can temporarily report stale dimensions.
|
||||
@@ -318,23 +206,9 @@ export function TerminalPane({
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [requestTerminalReflow, selectedTerminalId]),
|
||||
}, [requestTerminalReflow, terminalId]),
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -350,135 +224,29 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
|
||||
streamControllerRef.current?.handleStreamExit({
|
||||
workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId });
|
||||
if (terminalIdRef.current === exitedTerminalId) {
|
||||
emulatorRef.current?.clear();
|
||||
}
|
||||
streamControllerRef.current?.handleTerminalExit({
|
||||
terminalId: exitedTerminalId,
|
||||
streamId: message.payload.streamId,
|
||||
});
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
});
|
||||
}, [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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
||||
|
||||
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);
|
||||
|
||||
@@ -489,19 +257,25 @@ export function TerminalPane({
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
resumeOffsets: workspaceTerminalSession.resumeOffsets,
|
||||
onChunk: ({ terminalId, text, replay }) => {
|
||||
outputSession.append({ terminalId, text, replay });
|
||||
onOutput: ({ terminalId, text }) => {
|
||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.writeOutput(text);
|
||||
},
|
||||
onReset: ({ terminalId }) => {
|
||||
outputSession.clearTerminal({ terminalId });
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
workspaceTerminalSession.snapshots.set({ terminalId, state });
|
||||
if (!isScreenFocused || terminalIdRef.current !== terminalId) {
|
||||
return;
|
||||
}
|
||||
emulatorRef.current?.renderSnapshot(state);
|
||||
},
|
||||
onStatusChange: handleStreamControllerStatus,
|
||||
});
|
||||
|
||||
streamControllerRef.current = controller;
|
||||
controller.setTerminal({
|
||||
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
|
||||
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -515,34 +289,16 @@ export function TerminalPane({
|
||||
handleStreamControllerStatus,
|
||||
isConnected,
|
||||
isScreenFocused,
|
||||
outputSession,
|
||||
workspaceTerminalSession.resumeOffsets,
|
||||
workspaceTerminalSession.snapshots,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingTerminalInputRef.current = [];
|
||||
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
|
||||
outputSession.setSelectedTerminal({
|
||||
terminalId: nextSelectedTerminalId,
|
||||
});
|
||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||
streamControllerRef.current?.setTerminal({
|
||||
terminalId: nextSelectedTerminalId,
|
||||
terminalId: nextTerminalId,
|
||||
});
|
||||
}, [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]);
|
||||
}, [isScreenFocused, terminalId]);
|
||||
|
||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
@@ -558,13 +314,13 @@ export function TerminalPane({
|
||||
return false;
|
||||
}
|
||||
|
||||
const terminalId = selectedTerminalIdRef.current;
|
||||
if (!terminalId) {
|
||||
const currentTerminalId = terminalIdRef.current;
|
||||
if (!currentTerminalId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.type === "data") {
|
||||
client.sendTerminalInput(terminalId, {
|
||||
client.sendTerminalInput(currentTerminalId, {
|
||||
type: "input",
|
||||
data: entry.data,
|
||||
});
|
||||
@@ -575,7 +331,7 @@ export function TerminalPane({
|
||||
if (encoded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
client.sendTerminalInput(terminalId, {
|
||||
client.sendTerminalInput(currentTerminalId, {
|
||||
type: "input",
|
||||
data: encoded,
|
||||
});
|
||||
@@ -608,31 +364,10 @@ export function TerminalPane({
|
||||
}, [dispatchTerminalInputEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
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],
|
||||
);
|
||||
if (!isAttaching && !streamError) {
|
||||
flushPendingTerminalInput();
|
||||
}
|
||||
}, [flushPendingTerminalInput, isAttaching, streamError]);
|
||||
|
||||
const clearPendingModifiers = useCallback(() => {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
@@ -646,7 +381,7 @@ export function TerminalPane({
|
||||
alt: boolean;
|
||||
meta?: boolean;
|
||||
}): boolean => {
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
if (!client || !terminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
input: {
|
||||
@@ -676,7 +411,7 @@ export function TerminalPane({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -710,7 +445,7 @@ export function TerminalPane({
|
||||
}
|
||||
}
|
||||
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
if (!client || !terminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "data",
|
||||
data,
|
||||
@@ -729,7 +464,6 @@ export function TerminalPane({
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
getCurrentActiveStreamId,
|
||||
modifiers.alt,
|
||||
modifiers.ctrl,
|
||||
modifiers.shift,
|
||||
@@ -738,10 +472,10 @@ export function TerminalPane({
|
||||
],
|
||||
);
|
||||
|
||||
const handleTerminalResize = useCallback(
|
||||
async (input: { rows: number; cols: number }) => {
|
||||
const handleTerminalResize = useStableEvent(
|
||||
(input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
||||
if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
@@ -751,13 +485,12 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
client.sendTerminalInput(selectedTerminalId, {
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
},
|
||||
[client, selectedTerminalId],
|
||||
);
|
||||
|
||||
const handleTerminalKey = useCallback(
|
||||
@@ -771,13 +504,6 @@ 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] }));
|
||||
@@ -816,121 +542,13 @@ 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}>
|
||||
{selectedTerminal && isScreenFocused ? (
|
||||
{isScreenFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={{
|
||||
style: { flex: 1 },
|
||||
matchContents: false,
|
||||
@@ -941,15 +559,11 @@ export function TerminalPane({
|
||||
automaticallyAdjustContentInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||
initialOutputText={selectedOutputState.snapshotText}
|
||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||
outputChunkText={selectedOutputState.chunkText}
|
||||
outputChunkSequence={selectedOutputState.chunkSequence}
|
||||
outputChunkReplay={selectedOutputState.chunkReplay}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onSwipeRight={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
@@ -966,18 +580,13 @@ 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>
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isScreenFocused ? (
|
||||
@@ -987,10 +596,10 @@ export function TerminalPane({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{combinedError ? (
|
||||
{streamError ? (
|
||||
<View style={styles.errorRow}>
|
||||
<Text style={styles.statusError} numberOfLines={2}>
|
||||
{combinedError}
|
||||
{streamError}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -1048,100 +657,6 @@ 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, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
|
||||
@@ -226,6 +226,22 @@ 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",
|
||||
@@ -249,9 +265,22 @@ 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);
|
||||
@@ -314,6 +343,7 @@ 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(
|
||||
@@ -324,7 +354,7 @@ export function DropdownMenuContent({
|
||||
[],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
if (!modalVisible) return null;
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
@@ -337,7 +367,7 @@ export function DropdownMenuContent({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
visible={modalVisible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
@@ -351,30 +381,38 @@ export function DropdownMenuContent({
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
<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 }}
|
||||
{!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),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
@@ -559,9 +597,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
@@ -604,16 +642,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: "transparent",
|
||||
},
|
||||
itemHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemSelected: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemSelectedInteractive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
itemSelectedAccent: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
|
||||
@@ -6,22 +6,46 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
chord,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
keys: ShortcutKey[];
|
||||
keys?: ShortcutKey[];
|
||||
chord?: 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.root, style]}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
badge: {
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -29,6 +53,12 @@ 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,
|
||||
|
||||
@@ -516,12 +516,31 @@ 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, scheduleAuthoritativeRevalidation, serverId],
|
||||
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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,6 +3,7 @@ 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;
|
||||
@@ -15,6 +16,48 @@ 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,
|
||||
@@ -48,3 +91,28 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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 { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { chordStringToShortcutKeys } 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,8 +115,9 @@ function resolveActionShortcutKeys(
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return comboStringToShortcutKeys(override);
|
||||
return getDefaultKeysForAction(actionId, platform) ?? undefined;
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : undefined;
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
} from "@/keyboard/actions";
|
||||
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
type ChordState,
|
||||
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";
|
||||
@@ -29,18 +33,27 @@ 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 ??
|
||||
@@ -148,11 +161,35 @@ export function useKeyboardShortcuts({
|
||||
switch (input.action) {
|
||||
case "agent.new":
|
||||
return openProjectPicker();
|
||||
case "agent.prompt.select":
|
||||
if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) {
|
||||
return false;
|
||||
}
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "agent.prompt.select",
|
||||
scope: "workspace",
|
||||
index: input.payload.index,
|
||||
});
|
||||
case "workspace.tab.new":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
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",
|
||||
@@ -204,6 +241,11 @@ 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;
|
||||
@@ -219,6 +261,11 @@ 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) {
|
||||
@@ -256,6 +303,11 @@ 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);
|
||||
@@ -270,12 +322,11 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
const match = resolveKeyboardShortcut({
|
||||
const result = resolveKeyboardShortcut({
|
||||
event,
|
||||
context: {
|
||||
isMac,
|
||||
@@ -288,25 +339,41 @@ export function useKeyboardShortcuts({
|
||||
toggleFileExplorer,
|
||||
}),
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
if (!match) {
|
||||
|
||||
chordStateRef.current = result.nextChordState;
|
||||
|
||||
if (result.preventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: match.action,
|
||||
payload: match.payload,
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
event,
|
||||
});
|
||||
if (!handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.preventDefault) {
|
||||
if (result.match.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (match.stopPropagation) {
|
||||
if (result.match.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
@@ -330,6 +397,14 @@ 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);
|
||||
@@ -345,5 +420,6 @@ export function useKeyboardShortcuts({
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
toggleFocusMode,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { chordStringToShortcutKeys } 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,9 +18,10 @@ export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override) {
|
||||
return comboStringToShortcutKeys(override);
|
||||
return chordStringToShortcutKeys(override);
|
||||
}
|
||||
|
||||
return getDefaultKeysForAction(actionId, platform);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : null;
|
||||
}, [actionId, overrides, isMac, isDesktop]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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,6 +7,8 @@ export type KeyboardFocusScope =
|
||||
|
||||
export type MessageInputKeyboardActionKind =
|
||||
| "focus"
|
||||
| "send"
|
||||
| "queue"
|
||||
| "dictation-toggle"
|
||||
| "dictation-cancel"
|
||||
| "voice-toggle"
|
||||
@@ -14,6 +16,7 @@ export type MessageInputKeyboardActionKind =
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.new"
|
||||
| "agent.prompt.select"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close.current"
|
||||
| "workspace.tab.navigate.index"
|
||||
@@ -33,8 +36,13 @@ 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 =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.prompt.select"
|
||||
| "message-input.focus"
|
||||
| "message-input.dictation-toggle"
|
||||
| "message-input.dictation-cancel"
|
||||
@@ -20,9 +21,13 @@ export type KeyboardActionId =
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close";
|
||||
| "workspace.pane.close"
|
||||
| "workspace.terminal.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "agent.prompt.select"; scope: KeyboardActionScope; index: number }
|
||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
|
||||
@@ -42,7 +47,10 @@ 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.pane.close"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||
|
||||
export type KeyboardActionHandler = {
|
||||
handlerId: string;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
resolveKeyboardShortcut,
|
||||
type ChordState,
|
||||
type KeyboardShortcutContext,
|
||||
type ParsedShortcutBinding,
|
||||
} from "./keyboard-shortcuts";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
@@ -31,6 +34,30 @@ 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>;
|
||||
@@ -39,29 +66,33 @@ function expectShortcutResolution(input: {
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}) {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
const result = resolveShortcut({
|
||||
event: input.event,
|
||||
context: input.context,
|
||||
});
|
||||
|
||||
expect(match?.action).toBe(input.action);
|
||||
expect(result.match?.action).toBe(input.action);
|
||||
if ("payload" in input) {
|
||||
expect(match?.payload).toEqual(input.payload);
|
||||
expect(result.match?.payload).toEqual(input.payload);
|
||||
}
|
||||
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
|
||||
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
|
||||
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());
|
||||
}
|
||||
|
||||
function expectNoShortcutResolution(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
}) {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
const result = resolveShortcut({
|
||||
event: input.event,
|
||||
context: input.context,
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
expect(result.match).toBeNull();
|
||||
expect(result.preventDefault).toBe(false);
|
||||
expect(result.nextChordState).toEqual(initialChordState());
|
||||
}
|
||||
|
||||
type MatchingShortcutCase = {
|
||||
@@ -230,9 +261,15 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "sidebar.toggle.left",
|
||||
},
|
||||
{
|
||||
name: "keeps Mod+. as sidebar toggle fallback",
|
||||
name: "matches Mod+. to toggle both sidebars on non-mac",
|
||||
event: { key: ".", code: "Period", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
action: "sidebar.toggle.both",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+B sidebar toggle on non-mac",
|
||||
event: { key: "b", code: "KeyB", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
action: "sidebar.toggle.left",
|
||||
},
|
||||
{
|
||||
@@ -258,6 +295,13 @@ describe("keyboard-shortcuts", () => {
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
},
|
||||
{
|
||||
name: "routes number keys to prompt selection outside editable scopes",
|
||||
event: { key: "2", code: "Digit2" },
|
||||
context: { focusScope: "other" },
|
||||
action: "agent.prompt.select",
|
||||
payload: { index: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
it.each(matchingCases)("$name", ({
|
||||
@@ -313,11 +357,6 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "k", code: "KeyK", ctrlKey: true },
|
||||
context: { isMac: false, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not bind Ctrl+B on non-mac",
|
||||
event: { key: "b", code: "KeyB", ctrlKey: true },
|
||||
context: { isMac: false },
|
||||
},
|
||||
{
|
||||
name: "does not route message-input actions when terminal is focused",
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
@@ -333,11 +372,76 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: " ", code: "Space" },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not route prompt selection while typing in the message input",
|
||||
event: { key: "1", code: "Digit1" },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
];
|
||||
|
||||
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", () => {
|
||||
@@ -376,6 +480,9 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-tab-close-current": ["meta", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
"agent-prompt-select-1": ["1"],
|
||||
"agent-prompt-select-2": ["2"],
|
||||
"agent-prompt-select-3": ["3"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -386,10 +493,11 @@ describe("keyboard-shortcut help sections", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses mod+period as non-mac left sidebar shortcut",
|
||||
name: "uses mod+b and mod+period for non-mac sidebar shortcuts",
|
||||
context: { isMac: false, isDesktop: false },
|
||||
expectedKeys: {
|
||||
"toggle-left-sidebar": ["mod", "."],
|
||||
"toggle-left-sidebar": ["mod", "B"],
|
||||
"toggle-both-sidebars": ["mod", "."],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
KeyboardShortcutPayload,
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
|
||||
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
|
||||
|
||||
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||
|
||||
@@ -33,8 +33,15 @@ export type KeyboardShortcutHelpRow = {
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ShortcutSectionId =
|
||||
| "navigation"
|
||||
| "tabs-panes"
|
||||
| "projects"
|
||||
| "panels"
|
||||
| "agent-input";
|
||||
|
||||
export type KeyboardShortcutHelpSection = {
|
||||
id: "global" | "agent-input";
|
||||
id: ShortcutSectionId;
|
||||
title: string;
|
||||
rows: KeyboardShortcutHelpRow[];
|
||||
};
|
||||
@@ -69,7 +76,7 @@ type ShortcutPayloadDef =
|
||||
|
||||
interface ShortcutHelp {
|
||||
id: string;
|
||||
section: "global" | "agent-input";
|
||||
section: ShortcutSectionId;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
@@ -88,13 +95,22 @@ interface ShortcutBinding {
|
||||
}
|
||||
|
||||
export interface ParsedShortcutBinding extends ShortcutBinding {
|
||||
parsedCombo: KeyCombo;
|
||||
parsedChord: KeyCombo[];
|
||||
}
|
||||
|
||||
export interface ChordState {
|
||||
candidateIndices: number[];
|
||||
step: number;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
|
||||
global: "Global",
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
|
||||
navigation: "Navigation",
|
||||
"tabs-panes": "Tabs & Panes",
|
||||
projects: "Projects",
|
||||
panels: "Panels",
|
||||
"agent-input": "Agent Input",
|
||||
};
|
||||
|
||||
@@ -109,7 +125,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
section: "projects",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
@@ -121,12 +137,64 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
section: "projects",
|
||||
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",
|
||||
@@ -135,7 +203,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
@@ -147,7 +215,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
@@ -159,7 +227,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["meta", "W"],
|
||||
},
|
||||
@@ -171,7 +239,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["ctrl", "W"],
|
||||
},
|
||||
@@ -183,7 +251,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { desktop: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Close current tab",
|
||||
keys: ["alt", "shift", "W"],
|
||||
},
|
||||
@@ -198,7 +266,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
},
|
||||
@@ -211,7 +279,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
},
|
||||
@@ -224,7 +292,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Jump to workspace",
|
||||
keys: ["alt", "1-9"],
|
||||
},
|
||||
@@ -239,7 +307,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "1-9"],
|
||||
},
|
||||
@@ -252,7 +320,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "shift", "1-9"],
|
||||
},
|
||||
@@ -267,7 +335,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
@@ -280,7 +348,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
@@ -293,7 +361,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
@@ -306,7 +374,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
@@ -319,7 +387,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Previous workspace",
|
||||
keys: ["alt", "["],
|
||||
},
|
||||
@@ -332,7 +400,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Next workspace",
|
||||
keys: ["alt", "]"],
|
||||
},
|
||||
@@ -347,7 +415,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Previous tab",
|
||||
keys: ["alt", "shift", "["],
|
||||
},
|
||||
@@ -360,7 +428,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "global",
|
||||
section: "navigation",
|
||||
label: "Next tab",
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
@@ -371,10 +439,10 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-split-right-cmd-backslash",
|
||||
action: "workspace.pane.split.right",
|
||||
combo: "Cmd+\\",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-right",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Split pane right",
|
||||
keys: ["mod", "\\"],
|
||||
},
|
||||
@@ -383,10 +451,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-down",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Split pane down",
|
||||
keys: ["mod", "shift", "\\"],
|
||||
},
|
||||
@@ -395,10 +463,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-left",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane left",
|
||||
keys: ["mod", "shift", "Left"],
|
||||
},
|
||||
@@ -407,10 +475,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-right",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane right",
|
||||
keys: ["mod", "shift", "Right"],
|
||||
},
|
||||
@@ -419,10 +487,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-up",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane up",
|
||||
keys: ["mod", "shift", "Up"],
|
||||
},
|
||||
@@ -431,10 +499,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-down",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Focus pane down",
|
||||
keys: ["mod", "shift", "Down"],
|
||||
},
|
||||
@@ -443,10 +511,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-left",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab left",
|
||||
keys: ["mod", "shift", "alt", "Left"],
|
||||
},
|
||||
@@ -455,10 +523,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-right",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab right",
|
||||
keys: ["mod", "shift", "alt", "Right"],
|
||||
},
|
||||
@@ -467,10 +535,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-up",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab up",
|
||||
keys: ["mod", "shift", "alt", "Up"],
|
||||
},
|
||||
@@ -479,10 +547,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, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-down",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
label: "Move tab down",
|
||||
keys: ["mod", "shift", "alt", "Down"],
|
||||
},
|
||||
@@ -491,15 +559,41 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
id: "workspace-pane-close-cmd-shift-w",
|
||||
action: "workspace.pane.close",
|
||||
combo: "Cmd+Shift+W",
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-close",
|
||||
section: "global",
|
||||
section: "tabs-panes",
|
||||
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",
|
||||
@@ -508,7 +602,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
@@ -520,7 +614,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
@@ -535,7 +629,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { focusScope: "other" },
|
||||
help: {
|
||||
id: "show-shortcuts",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
@@ -550,27 +644,21 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
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+.",
|
||||
combo: "Ctrl+B",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "."],
|
||||
keys: ["mod", "B"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -580,7 +668,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
@@ -592,7 +680,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "global",
|
||||
section: "panels",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
@@ -604,6 +692,58 @@ 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",
|
||||
@@ -674,6 +814,94 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
keys: ["Esc"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-prompt-select-1",
|
||||
action: "agent.prompt.select",
|
||||
combo: "1",
|
||||
repeat: false,
|
||||
when: { focusScope: "other", commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "agent-prompt-select-1",
|
||||
section: "agent-input",
|
||||
label: "Select prompt option 1",
|
||||
keys: ["1"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-prompt-select-2",
|
||||
action: "agent.prompt.select",
|
||||
combo: "2",
|
||||
repeat: false,
|
||||
when: { focusScope: "other", commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "agent-prompt-select-2",
|
||||
section: "agent-input",
|
||||
label: "Select prompt option 2",
|
||||
keys: ["2"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-prompt-select-3",
|
||||
action: "agent.prompt.select",
|
||||
combo: "3",
|
||||
repeat: false,
|
||||
when: { focusScope: "other", commandCenter: false },
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "agent-prompt-select-3",
|
||||
section: "agent-input",
|
||||
label: "Select prompt option 3",
|
||||
keys: ["3"],
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -693,11 +921,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
// --- Parse bindings at module load ---
|
||||
|
||||
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
|
||||
const parsedCombo = parseShortcutString(binding.combo);
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
const parsedChord = parseChordString(binding.combo);
|
||||
const lastCombo = parsedChord.at(-1);
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, parsedCombo };
|
||||
return { ...binding, parsedChord };
|
||||
}
|
||||
|
||||
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
|
||||
@@ -711,16 +940,17 @@ export function buildEffectiveBindings(
|
||||
if (override === undefined) {
|
||||
return binding;
|
||||
}
|
||||
let parsedCombo: KeyCombo;
|
||||
let parsedChord: KeyCombo[];
|
||||
try {
|
||||
parsedCombo = parseShortcutString(override);
|
||||
parsedChord = parseChordString(override);
|
||||
} catch {
|
||||
return binding;
|
||||
}
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
const lastCombo = parsedChord.at(-1);
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, combo: override, parsedCombo };
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -797,6 +1027,27 @@ 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,
|
||||
@@ -811,24 +1062,131 @@ function helpMatchesPlatform(
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
chordState: ChordState;
|
||||
onChordReset: () => void;
|
||||
bindings?: readonly ParsedShortcutBinding[];
|
||||
}): KeyboardShortcutMatch | null {
|
||||
const { event, context, bindings = DEFAULT_BINDINGS } = input;
|
||||
for (const binding of bindings) {
|
||||
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
|
||||
}): {
|
||||
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)) {
|
||||
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 {
|
||||
action: binding.action,
|
||||
payload: resolvePayload(binding.payload, event),
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
match: completedMatch,
|
||||
nextChordState: resetChordState(chordState),
|
||||
preventDefault: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBindingIdForAction(
|
||||
@@ -868,8 +1226,11 @@ export function buildKeyboardShortcutHelpSections(
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): KeyboardShortcutHelpSection[] {
|
||||
const seenRows = new Set<string>();
|
||||
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
|
||||
["global", []],
|
||||
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
|
||||
["navigation", []],
|
||||
["tabs-panes", []],
|
||||
["projects", []],
|
||||
["panels", []],
|
||||
["agent-input", []],
|
||||
]);
|
||||
|
||||
@@ -899,7 +1260,7 @@ export function buildKeyboardShortcutHelpSections(
|
||||
});
|
||||
}
|
||||
|
||||
const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"];
|
||||
const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
|
||||
|
||||
return sectionOrder.flatMap((sectionId) => {
|
||||
const rows = rowsBySection.get(sectionId) ?? [];
|
||||
|
||||
@@ -36,6 +36,8 @@ 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" };
|
||||
@@ -101,6 +103,10 @@ 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[] = [];
|
||||
|
||||
@@ -120,6 +126,10 @@ 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",
|
||||
@@ -156,6 +166,10 @@ 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;
|
||||
|
||||
@@ -22,6 +22,7 @@ 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 {
|
||||
@@ -103,12 +104,13 @@ function useAgentPanelDescriptor(
|
||||
function AgentPanel() {
|
||||
const { serverId, target, isPaneFocused, openFileInWorkspace } = usePaneContext();
|
||||
invariant(target.kind === "agent", "AgentPanel requires agent target");
|
||||
const handleOpenWorkspaceFile = useCallback(
|
||||
(input: { filePath: string }) => {
|
||||
openFileInWorkspace(input.filePath);
|
||||
},
|
||||
[openFileInWorkspace],
|
||||
);
|
||||
|
||||
function openWorkspaceFile(input: { filePath: string }) {
|
||||
openFileInWorkspace(input.filePath);
|
||||
}
|
||||
|
||||
const handleOpenWorkspaceFile = useStableEvent(openWorkspaceFile);
|
||||
|
||||
return (
|
||||
<AgentPanelContent
|
||||
serverId={serverId}
|
||||
@@ -320,6 +322,7 @@ function AgentPanelBody({
|
||||
attentionReason: agent?.attentionReason,
|
||||
isScreenFocused: isPaneFocused,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
|
||||
}, [attentionController.clearOnAgentBlur]);
|
||||
@@ -794,6 +797,7 @@ function AgentPanelBody({
|
||||
agent={effectiveAgent}
|
||||
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
isPaneFocused={isPaneFocused}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
|
||||
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
|
||||
|
||||
function TerminalPanel() {
|
||||
const isFocused = useIsFocused();
|
||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!isFocused) {
|
||||
@@ -60,15 +60,8 @@ function TerminalPanel() {
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
openTab({ kind: "terminal", terminalId });
|
||||
}}
|
||||
hideHeader
|
||||
manageTerminalDirectorySubscription={false}
|
||||
terminalId={target.terminalId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "keyboard-shortcuts"
|
||||
| "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: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
|
||||
{ id: "shortcuts", label: "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 "keyboard-shortcuts":
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsSection />;
|
||||
case "diagnostics":
|
||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||
|
||||
@@ -10,16 +10,31 @@ import {
|
||||
getBindingIdForAction,
|
||||
type KeyboardShortcutHelpRow,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
comboStringToShortcutKeys,
|
||||
keyboardEventToComboString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
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,
|
||||
}: {
|
||||
@@ -27,29 +42,38 @@ function ShortcutRow({
|
||||
bindingId: string | null;
|
||||
overrideCombo: string | undefined;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
onRebind: () => void;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
|
||||
const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<Text style={styles.capturingText}>Press shortcut...</Text>
|
||||
<ShortcutSequence chord={capturedCombos} />
|
||||
) : (
|
||||
<Shortcut keys={displayKeys} />
|
||||
<Shortcut chord={displayChord} />
|
||||
)}
|
||||
{bindingId !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={isCapturing ? onCancel : onRebind}
|
||||
>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
</Button>
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
@@ -63,51 +87,73 @@ 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;
|
||||
|
||||
const activeBindingId = capturingBindingId;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCapturingBindingId(null);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
return;
|
||||
}
|
||||
|
||||
const comboString = keyboardEventToComboString(event);
|
||||
if (comboString === null) return;
|
||||
if (comboString === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void setOverride(activeBindingId, comboString);
|
||||
setCapturingBindingId(null);
|
||||
setCapturedCombos((current) => [...current, comboString]);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [capturingBindingId, setOverride]);
|
||||
}, [capturingBindingId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setCapturingShortcut(false);
|
||||
};
|
||||
}, [setCapturingShortcut]);
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||
<Text style={styles.mobileText}>
|
||||
Keyboard shortcuts are only available on desktop.
|
||||
@@ -120,7 +166,7 @@ export function KeyboardShortcutsSection() {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -144,8 +190,14 @@ export function KeyboardShortcutsSection() {
|
||||
bindingId={bindingId}
|
||||
overrideCombo={overrideCombo}
|
||||
isCapturing={capturingBindingId === bindingId}
|
||||
onRebind={() => setCapturingBindingId(bindingId)}
|
||||
onCancel={() => setCapturingBindingId(null)}
|
||||
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
|
||||
onRebind={() => {
|
||||
if (bindingId) {
|
||||
startCapture(bindingId);
|
||||
}
|
||||
}}
|
||||
onDone={saveCapture}
|
||||
onCancel={cancelCapture}
|
||||
onReset={() => {
|
||||
if (bindingId) void removeOverride(bindingId);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,6 +20,7 @@ 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 {
|
||||
@@ -308,6 +309,10 @@ 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);
|
||||
|
||||
@@ -448,10 +453,12 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
|
||||
{newAgentTabKeys ? (
|
||||
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -467,8 +474,13 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
<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>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -483,8 +495,13 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
<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>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -499,8 +516,13 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</View>
|
||||
|
||||
@@ -74,6 +74,7 @@ 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 {
|
||||
@@ -96,7 +97,9 @@ 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,
|
||||
@@ -437,6 +440,68 @@ 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();
|
||||
|
||||
@@ -485,6 +550,7 @@ 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 =
|
||||
@@ -609,17 +675,10 @@ 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]);
|
||||
@@ -747,6 +806,7 @@ 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) =>
|
||||
@@ -1465,6 +1525,9 @@ 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);
|
||||
@@ -1493,7 +1556,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
@@ -1560,7 +1623,11 @@ 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;
|
||||
@@ -1598,6 +1665,7 @@ 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,
|
||||
@@ -1723,37 +1791,33 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
retargetWorkspaceTab,
|
||||
};
|
||||
});
|
||||
const activePaneContent = useMemo(
|
||||
() =>
|
||||
activeTabDescriptor
|
||||
? buildPaneContentModel({
|
||||
tab: activeTabDescriptor,
|
||||
paneId: focusedPaneTabState.pane?.id ?? null,
|
||||
isPaneFocused: true,
|
||||
focusPaneBeforeOpen: false,
|
||||
})
|
||||
: null,
|
||||
[activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id],
|
||||
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 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 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 content = shouldRenderMissingWorkspaceDescriptor({
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
@@ -1774,9 +1838,100 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<WorkspacePaneContent content={activePaneContent!} />
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
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 ? (
|
||||
@@ -1795,6 +1950,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
) : null}
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{(!isFocusModeEnabled || isMobile) && (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
@@ -1890,40 +2046,61 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
{!isMobile && !isGitCheckout ? (
|
||||
@@ -1981,6 +2158,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
@@ -2011,6 +2189,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
{workspaceLayout && persistenceKey ? (
|
||||
<SplitContainer
|
||||
layout={workspaceLayout}
|
||||
focusModeEnabled={isFocusModeEnabled && !isMobile}
|
||||
workspaceKey={persistenceKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -2028,38 +2207,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
buildPaneContentModel={({ paneId, tab, isPaneFocused }) =>
|
||||
buildPaneContentModel({
|
||||
tab,
|
||||
paneId,
|
||||
isPaneFocused,
|
||||
focusPaneBeforeOpen: true,
|
||||
})
|
||||
}
|
||||
onFocusPane={(paneId) => {
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
}}
|
||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||
onFocusPane={handleFocusPane}
|
||||
onNewTerminalTab={handleCreateTerminal}
|
||||
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>
|
||||
)}
|
||||
onSplitPane={handleSplitPane}
|
||||
onSplitPaneEmpty={handleCreateDraftSplit}
|
||||
onMoveTabToPane={handleMoveTabToPane}
|
||||
onResizeSplit={handleResizePaneSplit}
|
||||
onReorderTabsInPane={handleReorderTabsInPane}
|
||||
renderPaneEmptyState={renderSplitPaneEmptyState}
|
||||
/>
|
||||
) : (
|
||||
content
|
||||
@@ -2069,13 +2225,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={normalizedWorkspaceId}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
{(!isFocusModeEnabled || isMobile) && (
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={normalizedWorkspaceId}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -2210,6 +2368,19 @@ 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,7 +4,9 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
|
||||
beforeEach(() => {
|
||||
useKeyboardShortcutsStore.setState({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
@@ -18,4 +20,10 @@ 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,6 +5,7 @@ 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. */
|
||||
@@ -15,6 +16,7 @@ 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;
|
||||
@@ -26,6 +28,7 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
@@ -34,6 +37,7 @@ 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) =>
|
||||
|
||||
@@ -33,6 +33,7 @@ type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
interface DesktopSidebarState {
|
||||
agentListOpen: boolean;
|
||||
fileExplorerOpen: boolean;
|
||||
focusModeEnabled: boolean;
|
||||
}
|
||||
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
@@ -78,6 +79,8 @@ interface PanelState {
|
||||
closeToAgent: () => void;
|
||||
toggleAgentList: () => void;
|
||||
toggleFileExplorer: () => void;
|
||||
toggleBothSidebars: () => void;
|
||||
toggleFocusMode: () => void;
|
||||
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
@@ -133,6 +136,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
desktop: {
|
||||
agentListOpen: DEFAULT_DESKTOP_OPEN,
|
||||
fileExplorerOpen: false,
|
||||
focusModeEnabled: false,
|
||||
},
|
||||
|
||||
// File explorer defaults
|
||||
@@ -174,6 +178,7 @@ 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,
|
||||
@@ -216,6 +221,29 @@ 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) => {
|
||||
@@ -267,7 +295,7 @@ export const usePanelStore = create<PanelState>()(
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 6,
|
||||
version: 8,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) => {
|
||||
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||
@@ -322,6 +350,23 @@ 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,10 +5,12 @@ 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(): {
|
||||
@@ -35,10 +37,12 @@ function createRuntimeWithTerminal(): {
|
||||
resetCalls += 1;
|
||||
terminal.resetCalls = resetCalls;
|
||||
},
|
||||
resize: () => {},
|
||||
focus: () => {},
|
||||
refresh: () => {},
|
||||
options: { theme: undefined },
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
resetCalls,
|
||||
};
|
||||
|
||||
@@ -188,12 +192,16 @@ 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" });
|
||||
expect(terminal.options?.theme).toEqual({
|
||||
background: "after",
|
||||
overviewRulerBorder: "after",
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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,
|
||||
@@ -10,11 +16,12 @@ import {
|
||||
normalizeTerminalTransportKey,
|
||||
shouldInterceptDomTerminalKey,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
|
||||
|
||||
export type TerminalEmulatorRuntimeMountInput = {
|
||||
root: HTMLDivElement;
|
||||
host: HTMLDivElement;
|
||||
initialOutputText: string;
|
||||
initialSnapshot: TerminalState | null;
|
||||
theme: ITheme;
|
||||
};
|
||||
|
||||
@@ -50,8 +57,10 @@ type TerminalEmulatorRuntimeDisposables = {
|
||||
};
|
||||
|
||||
type TerminalOutputOperation = {
|
||||
type: "write" | "clear";
|
||||
type: "write" | "clear" | "snapshot";
|
||||
text: string;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
suppressInput?: boolean;
|
||||
onCommitted?: () => void;
|
||||
};
|
||||
@@ -145,6 +154,9 @@ export class TerminalEmulatorRuntime {
|
||||
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
rescaleOverlappingGlyphs: true,
|
||||
overviewRuler: {
|
||||
width: 8,
|
||||
},
|
||||
@@ -156,6 +168,14 @@ 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";
|
||||
@@ -189,6 +209,14 @@ 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,
|
||||
});
|
||||
@@ -207,6 +235,10 @@ export class TerminalEmulatorRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.root.offsetWidth === 0 || input.root.offsetHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentFitAddon.fit();
|
||||
} catch {
|
||||
@@ -332,8 +364,8 @@ export class TerminalEmulatorRuntime {
|
||||
fitAndEmitResize(true);
|
||||
}, 0);
|
||||
|
||||
if (input.initialOutputText.length > 0) {
|
||||
this.write({ text: input.initialOutputText, suppressInput: true });
|
||||
if (input.initialSnapshot) {
|
||||
this.renderSnapshot({ state: input.initialSnapshot });
|
||||
}
|
||||
|
||||
this.processOutputQueue();
|
||||
@@ -434,6 +466,22 @@ 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);
|
||||
}
|
||||
@@ -523,6 +571,22 @@ 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);
|
||||
@@ -621,7 +685,6 @@ 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 ?? "";
|
||||
@@ -630,8 +693,6 @@ 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";
|
||||
@@ -640,9 +701,6 @@ export class TerminalEmulatorRuntime {
|
||||
viewportElement.style.pointerEvents = "auto";
|
||||
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
|
||||
}
|
||||
if (screenElement) {
|
||||
screenElement.style.pointerEvents = "none";
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewportElement) {
|
||||
@@ -656,9 +714,6 @@ export class TerminalEmulatorRuntime {
|
||||
previousViewportWebkitOverflowScrolling,
|
||||
);
|
||||
}
|
||||
if (screenElement) {
|
||||
screenElement.style.pointerEvents = previousScreenPointerEvents;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
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,586 +2,180 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerAttachPayload,
|
||||
type TerminalStreamControllerChunk,
|
||||
type TerminalStreamControllerClient,
|
||||
type TerminalStreamControllerResumeOffsets,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "./terminal-stream-controller";
|
||||
|
||||
type FakeStreamSubscriber = (chunk: TerminalStreamControllerChunk) => void;
|
||||
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 };
|
||||
|
||||
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
private readonly streamSubscribers = new Map<number, Set<FakeStreamSubscriber>>();
|
||||
private readonly pendingChunksByStreamId = new Map<number, TerminalStreamControllerChunk[]>();
|
||||
public attachCalls: Array<{
|
||||
terminalId: string;
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
};
|
||||
}> = [];
|
||||
public detachCalls: number[] = [];
|
||||
public nextAttachResponses: TerminalStreamControllerAttachPayload[] = [];
|
||||
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 }> = [];
|
||||
|
||||
async attachTerminalStream(
|
||||
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(
|
||||
terminalId: string,
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
},
|
||||
): Promise<TerminalStreamControllerAttachPayload> {
|
||||
this.attachCalls.push({ terminalId, options });
|
||||
const response = this.nextAttachResponses.shift();
|
||||
if (!response) {
|
||||
throw new Error("Missing fake attach response");
|
||||
}
|
||||
return response;
|
||||
message: { type: "resize"; rows: number; cols: number },
|
||||
): void {
|
||||
this.resizeCalls.push({ terminalId, rows: message.rows, cols: message.cols });
|
||||
}
|
||||
|
||||
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);
|
||||
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
||||
this.listeners.add(handler);
|
||||
return () => {
|
||||
const current = this.streamSubscribers.get(streamId);
|
||||
current?.delete(handler);
|
||||
if (current && current.size === 0) {
|
||||
this.streamSubscribers.delete(streamId);
|
||||
}
|
||||
this.listeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
emit(event: TerminalStreamEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
const chunk: TerminalStreamControllerChunk = {
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
};
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
bufferChunk(input: {
|
||||
streamId: number;
|
||||
offset?: number;
|
||||
endOffset: number;
|
||||
replay?: boolean;
|
||||
data: string;
|
||||
}): void {
|
||||
const chunks = this.pendingChunksByStreamId.get(input.streamId) ?? [];
|
||||
const bytes = new TextEncoder().encode(input.data);
|
||||
chunks.push({
|
||||
offset: input.offset ?? input.endOffset - bytes.byteLength,
|
||||
endOffset: input.endOffset,
|
||||
replay: input.replay,
|
||||
data: bytes,
|
||||
});
|
||||
this.pendingChunksByStreamId.set(input.streamId, chunks);
|
||||
}
|
||||
}
|
||||
|
||||
function createControllerHarness(input?: {
|
||||
client?: FakeTerminalStreamClient;
|
||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||
}): {
|
||||
client: FakeTerminalStreamClient;
|
||||
chunks: Array<{ terminalId: string; text: string }>;
|
||||
statuses: TerminalStreamControllerStatus[];
|
||||
resets: string[];
|
||||
controller: TerminalStreamController;
|
||||
} {
|
||||
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
|
||||
const client = input?.client ?? new FakeTerminalStreamClient();
|
||||
const chunks: Array<{ terminalId: string; text: string }> = [];
|
||||
const outputs: Array<{ terminalId: string; text: string }> = [];
|
||||
const snapshots: Array<{ terminalId: string; text: string }> = [];
|
||||
const statuses: TerminalStreamControllerStatus[] = [];
|
||||
const resets: string[] = [];
|
||||
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
resumeOffsets: input?.resumeOffsets,
|
||||
onChunk: (chunk) => {
|
||||
chunks.push({
|
||||
terminalId: chunk.terminalId,
|
||||
text: chunk.text,
|
||||
onOutput: (output) => {
|
||||
outputs.push(output);
|
||||
},
|
||||
onSnapshot: ({ terminalId, state }) => {
|
||||
snapshots.push({
|
||||
terminalId,
|
||||
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
|
||||
});
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
statuses.push(status);
|
||||
},
|
||||
onReset: ({ terminalId }) => {
|
||||
resets.push(terminalId);
|
||||
},
|
||||
waitForDelay: async () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
client,
|
||||
chunks,
|
||||
statuses,
|
||||
resets,
|
||||
controller,
|
||||
};
|
||||
return { client, controller, outputs, snapshots, statuses };
|
||||
}
|
||||
|
||||
async function flushAsyncWork(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 0);
|
||||
});
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("terminal-stream-controller", () => {
|
||||
it("streams burst chunks in order without dropping intermediate chunks", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: 7,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
it("subscribes, resizes, and forwards snapshot/output events", async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
|
||||
|
||||
harness.controller.setTerminal({ terminalId: "term-1" });
|
||||
await flushAsyncWork();
|
||||
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 1,
|
||||
data: "a",
|
||||
harness.client.emit({
|
||||
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 },
|
||||
},
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 2,
|
||||
data: "b",
|
||||
});
|
||||
harness.client.emitChunk({
|
||||
streamId: 7,
|
||||
endOffset: 3,
|
||||
data: "c",
|
||||
harness.client.emit({
|
||||
terminalId: "term-1",
|
||||
type: "output",
|
||||
data: new TextEncoder().encode(" world"),
|
||||
});
|
||||
|
||||
expect(harness.chunks).toEqual([
|
||||
{ terminalId: "term-1", text: "a" },
|
||||
{ terminalId: "term-1", text: "b" },
|
||||
{ terminalId: "term-1", text: "c" },
|
||||
]);
|
||||
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,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries retryable attach failures and then attaches", async () => {
|
||||
const harness = createControllerHarness();
|
||||
harness.client.nextAttachResponses.push({
|
||||
streamId: null,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
it("surfaces subscribe failures without retrying", async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.nextSubscribeResults.push({
|
||||
terminalId: "term-1",
|
||||
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.client.subscribeCalls).toEqual(["term-1"]);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
terminalId: "term-1",
|
||||
streamId: 9,
|
||||
isAttaching: false,
|
||||
error: null,
|
||||
error: "network disconnected",
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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.client.emitChunk({
|
||||
streamId: 3,
|
||||
endOffset: 2,
|
||||
data: "hi",
|
||||
});
|
||||
harness.controller.handleStreamExit({
|
||||
terminalId: "term-1",
|
||||
streamId: 3,
|
||||
});
|
||||
harness.controller.handleTerminalExit({ terminalId: "term-1" });
|
||||
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.client.subscribeCalls).toEqual(["term-1"]);
|
||||
expect(harness.statuses.at(-1)).toEqual({
|
||||
terminalId: "term-1",
|
||||
streamId: 4,
|
||||
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,
|
||||
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);
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.nextAttachResponses.push({
|
||||
streamId: 81,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const firstHarness = createControllerHarness({
|
||||
client,
|
||||
resumeOffsets,
|
||||
});
|
||||
firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
|
||||
await flushAsyncWork();
|
||||
|
||||
client.emitChunk({
|
||||
streamId: 81,
|
||||
offset: 0,
|
||||
endOffset: 2,
|
||||
data: "ok",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
firstHarness.controller.dispose();
|
||||
|
||||
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,41 +1,21 @@
|
||||
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;
|
||||
};
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
|
||||
export type TerminalStreamControllerClient = {
|
||||
attachTerminalStream: (
|
||||
subscribeTerminal: (terminalId: string) => Promise<{
|
||||
terminalId: string;
|
||||
error?: string | null;
|
||||
}>;
|
||||
unsubscribeTerminal: (terminalId: string) => void;
|
||||
sendTerminalInput: (
|
||||
terminalId: string,
|
||||
options?: {
|
||||
resumeOffset?: number;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
},
|
||||
) => Promise<TerminalStreamControllerAttachPayload>;
|
||||
detachTerminalStream: (streamId: number) => Promise<unknown>;
|
||||
onTerminalStreamData: (
|
||||
streamId: number,
|
||||
handler: (chunk: TerminalStreamControllerChunk) => void,
|
||||
message: { type: "resize"; rows: number; cols: number },
|
||||
) => void;
|
||||
onTerminalStreamEvent: (
|
||||
handler: (
|
||||
event:
|
||||
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||
| { terminalId: string; type: "snapshot"; state: TerminalState },
|
||||
) => void,
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
@@ -46,519 +26,126 @@ 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;
|
||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
||||
onReset?: (input: { terminalId: string }) => void;
|
||||
onOutput: (input: { terminalId: string; text: string }) => void;
|
||||
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
maxAttachAttempts?: number;
|
||||
attachTimeoutMs?: number;
|
||||
reconnectErrorMessage?: string;
|
||||
withTimeout?: <T>(input: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
timeoutMessage: string;
|
||||
}) => Promise<T>;
|
||||
waitForDelay?: (input: { durationMs: number }) => Promise<void>;
|
||||
isRetryableError?: (input: { message: string }) => boolean;
|
||||
getRetryDelayMs?: (input: { attempt: number }) => number;
|
||||
};
|
||||
|
||||
type TerminalStreamControllerActiveStream = {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
decoder: TextDecoder;
|
||||
nextExpectedOffset: number | null;
|
||||
catchUpEndOffset: number | null;
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
||||
const TERMINAL_EXITED_ERROR = "Terminal exited";
|
||||
|
||||
export class TerminalStreamController {
|
||||
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,
|
||||
};
|
||||
private readonly decoder = new TextDecoder();
|
||||
private readonly unsubscribeStreamEvents: () => void;
|
||||
private terminalId: string | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly options: TerminalStreamControllerOptions) {}
|
||||
|
||||
getActiveStreamId(): number | null {
|
||||
return this.activeStream?.streamId ?? 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setTerminal(input: { terminalId: string | null }): void {
|
||||
if (this.isDisposed) {
|
||||
if (this.disposed || input.terminalId === this.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
const previousTerminalId = this.terminalId;
|
||||
this.terminalId = nextTerminalId;
|
||||
this.decoder.decode();
|
||||
if (previousTerminalId) {
|
||||
this.options.client.unsubscribeTerminal(previousTerminalId);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus({
|
||||
terminalId: nextTerminalId,
|
||||
streamId: null,
|
||||
isAttaching: true,
|
||||
error: null,
|
||||
});
|
||||
void this.attachTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
handleStreamExit(input: { terminalId: string; streamId: number }): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedTerminalId !== input.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
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 (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,
|
||||
if (payload.error) {
|
||||
this.terminalId = null;
|
||||
this.options.onStatusChange?.({
|
||||
terminalId: nextTerminalId,
|
||||
isAttaching: false,
|
||||
error: lastErrorMessage,
|
||||
error: payload.error,
|
||||
});
|
||||
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,
|
||||
const preferredSize = this.options.getPreferredSize();
|
||||
if (preferredSize) {
|
||||
this.options.client.sendTerminalInput(nextTerminalId, {
|
||||
type: "resize",
|
||||
rows: preferredSize.rows,
|
||||
cols: preferredSize.cols,
|
||||
});
|
||||
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,
|
||||
this.options.onStatusChange?.({
|
||||
terminalId: nextTerminalId,
|
||||
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,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (this.disposed || this.terminalId !== nextTerminalId) {
|
||||
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,
|
||||
this.terminalId = null;
|
||||
this.options.onStatusChange?.({
|
||||
terminalId: nextTerminalId,
|
||||
isAttaching: false,
|
||||
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
activeStream.unsubscribe();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
if (!input.shouldDetach) {
|
||||
handleTerminalExit(input: { terminalId: string }): void {
|
||||
if (this.disposed || input.terminalId !== this.terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.client.detachTerminalStream(activeStream.streamId);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
this.decoder.decode();
|
||||
this.terminalId = null;
|
||||
this.options.onStatusChange?.({
|
||||
terminalId: input.terminalId,
|
||||
isAttaching: false,
|
||||
error: TERMINAL_EXITED_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
private isAttachGenerationCurrent(input: { generation: number; terminalId: string }): boolean {
|
||||
if (this.isDisposed) {
|
||||
return false;
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
this.disposed = true;
|
||||
this.decoder.decode();
|
||||
const terminalId = this.terminalId;
|
||||
this.terminalId = null;
|
||||
if (terminalId) {
|
||||
this.options.client.unsubscribeTerminal(terminalId);
|
||||
}
|
||||
return {
|
||||
get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId),
|
||||
set: ({ terminalId, offset }) => {
|
||||
this.internalResumeOffsetByTerminalId.set(terminalId, offset);
|
||||
},
|
||||
};
|
||||
this.unsubscribeStreamEvents();
|
||||
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,43 +10,56 @@ 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 resume offsets across repeated lookups", () => {
|
||||
it("preserves snapshots across repeated lookups", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-resume",
|
||||
maxOutputChars: 1_000,
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
first.resumeOffsets.set({
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
offset: 42,
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-resume",
|
||||
maxOutputChars: 1_000,
|
||||
scopeKey: "workspace-snapshots",
|
||||
});
|
||||
|
||||
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42);
|
||||
expect(second.snapshots.get({ terminalId: "term-1" })).toEqual({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts workspace terminal session state when the workspace retain count returns to zero", () => {
|
||||
it("evicts workspace terminal session state when the retain count returns to zero", () => {
|
||||
const scopeKey = "workspace-release";
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
first.resumeOffsets.set({
|
||||
first.snapshots.set({
|
||||
terminalId: "term-1",
|
||||
offset: 128,
|
||||
state: {
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey });
|
||||
@@ -54,10 +67,9 @@ describe("workspace-terminal-session", () => {
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined();
|
||||
expect(second.snapshots.get({ terminalId: "term-1" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,85 +1,63 @@
|
||||
import {
|
||||
getTerminalOutputSession,
|
||||
releaseTerminalOutputSession,
|
||||
retainTerminalOutputSession,
|
||||
type TerminalOutputSession,
|
||||
} from "./terminal-output-session";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
|
||||
export type WorkspaceTerminalResumeOffsets = {
|
||||
get: (input: { terminalId: string }) => number | undefined;
|
||||
set: (input: { terminalId: string; offset: number }) => void;
|
||||
export type WorkspaceTerminalSnapshots = {
|
||||
get: (input: { terminalId: string }) => TerminalState | null;
|
||||
set: (input: { terminalId: string; state: TerminalState }) => void;
|
||||
clear: (input: { terminalId: string }) => void;
|
||||
prune: (input: { terminalIds: string[] }) => void;
|
||||
};
|
||||
|
||||
export type WorkspaceTerminalSession = {
|
||||
scopeKey: string;
|
||||
outputSession: TerminalOutputSession;
|
||||
resumeOffsets: WorkspaceTerminalResumeOffsets;
|
||||
snapshots: WorkspaceTerminalSnapshots;
|
||||
};
|
||||
|
||||
type WorkspaceTerminalSessionRecord = {
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
session: WorkspaceTerminalSession;
|
||||
};
|
||||
|
||||
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||
const refCountByScopeKey = new Map<string, number>();
|
||||
|
||||
function createResumeOffsets(input: {
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): WorkspaceTerminalResumeOffsets {
|
||||
function createSnapshots(input: {
|
||||
snapshotByTerminalId: Map<string, TerminalState>;
|
||||
}): WorkspaceTerminalSnapshots {
|
||||
return {
|
||||
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)));
|
||||
get: ({ terminalId }) => input.snapshotByTerminalId.get(terminalId) ?? null,
|
||||
set: ({ terminalId, state }) => {
|
||||
input.snapshotByTerminalId.set(terminalId, state);
|
||||
},
|
||||
clear: ({ terminalId }) => {
|
||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
},
|
||||
prune: ({ terminalIds }) => {
|
||||
const terminalIdSet = new Set(terminalIds);
|
||||
for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) {
|
||||
for (const terminalId of Array.from(input.snapshotByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||
input.snapshotByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalSession(input: {
|
||||
scopeKey: string;
|
||||
maxOutputChars: number;
|
||||
}): WorkspaceTerminalSession {
|
||||
export function getWorkspaceTerminalSession(input: { scopeKey: string }): WorkspaceTerminalSession {
|
||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||
if (existing) {
|
||||
return existing.session;
|
||||
}
|
||||
|
||||
const resumeOffsetByTerminalId = new Map<string, number>();
|
||||
const snapshotByTerminalId = new Map<string, TerminalState>();
|
||||
const session: WorkspaceTerminalSession = {
|
||||
scopeKey: input.scopeKey,
|
||||
outputSession: getTerminalOutputSession({
|
||||
scopeKey: input.scopeKey,
|
||||
maxOutputChars: input.maxOutputChars,
|
||||
}),
|
||||
resumeOffsets: createResumeOffsets({
|
||||
resumeOffsetByTerminalId,
|
||||
snapshots: createSnapshots({
|
||||
snapshotByTerminalId,
|
||||
}),
|
||||
};
|
||||
|
||||
sessionsByScopeKey.set(input.scopeKey, {
|
||||
resumeOffsetByTerminalId,
|
||||
snapshotByTerminalId,
|
||||
session,
|
||||
});
|
||||
return session;
|
||||
@@ -88,11 +66,9 @@ export function getWorkspaceTerminalSession(input: {
|
||||
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);
|
||||
|
||||
@@ -2,8 +2,20 @@ 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";
|
||||
chevron: "expand" | "collapse" | null;
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export type SidebarProjectRowModel =
|
||||
| SidebarProjectSectionRowModel;
|
||||
|
||||
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
|
||||
return project.workspaces.length === 1;
|
||||
return project.workspaces.length === 1 && project.projectKind !== "git";
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
@@ -50,9 +50,12 @@ export function buildSidebarProjectRowModel(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const collapsible =
|
||||
input.project.projectKind === "git" || input.project.workspaces.length > 1;
|
||||
|
||||
return {
|
||||
kind: "project_section",
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
chevron: collapsible ? (input.collapsed ? "expand" : "collapse") : null,
|
||||
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
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("");
|
||||
}
|
||||
+18
-100
@@ -9,22 +9,21 @@ 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 { 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 { 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 { 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);
|
||||
@@ -59,120 +58,39 @@ export function createCli(): Command {
|
||||
|
||||
// Primary agent commands (top-level)
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addLsOptions(program.command("ls")),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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",
|
||||
),
|
||||
addRunOptions(program.command("run")),
|
||||
).action(withOutput(runRunCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
program
|
||||
.command("attach")
|
||||
.description("Attach to a running agent's output stream")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
addAttachOptions(program.command("attach")),
|
||||
).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(
|
||||
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"),
|
||||
addLogsOptions(program.command("logs")),
|
||||
).action(runLogsCommand);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addStopOptions(program.command("stop")),
|
||||
).action(withOutput(runStopCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addDeleteOptions(program.command("delete")),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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, []),
|
||||
addSendOptions(program.command("send")),
|
||||
).action(withOutput(runSendCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
program
|
||||
.command("inspect")
|
||||
.description("Show detailed information about an agent")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
addInspectOptions(program.command("inspect")),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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)"),
|
||||
addWaitOptions(program.command("wait")),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
// Top-level local daemon shortcuts
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
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,5 +1,13 @@
|
||||
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,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Command } from "commander";
|
||||
import { runModeCommand } from "./mode.js";
|
||||
import { runArchiveCommand } from "./archive.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 { 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 { runUpdateCommand } from "./update.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import {
|
||||
@@ -23,114 +23,39 @@ export function createAgentCommand(): Command {
|
||||
|
||||
// Primary agent commands (same as top-level)
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addLsOptions(agent.command("ls")),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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",
|
||||
),
|
||||
addRunOptions(agent.command("run")),
|
||||
).action(withOutput(runRunCommand));
|
||||
|
||||
addDaemonHostOption(
|
||||
agent
|
||||
.command("attach")
|
||||
.description("Attach to a running agent's output stream")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
addAttachOptions(agent.command("attach")),
|
||||
).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(
|
||||
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)"),
|
||||
addLogsOptions(agent.command("logs")),
|
||||
).action(runLogsCommand);
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addStopOptions(agent.command("stop")),
|
||||
).action(withOutput(runStopCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addDeleteOptions(agent.command("delete")),
|
||||
).action(withOutput(runDeleteCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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"),
|
||||
addSendOptions(agent.command("send")),
|
||||
).action(withOutput(runSendCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
agent
|
||||
.command("inspect")
|
||||
.description("Show detailed information about an agent")
|
||||
.argument("<id>", "Agent ID (or prefix)"),
|
||||
addInspectOptions(agent.command("inspect")),
|
||||
).action(withOutput(runInspectCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
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)"),
|
||||
addWaitOptions(agent.command("wait")),
|
||||
).action(withOutput(runWaitCommand));
|
||||
|
||||
// Advanced agent commands (less common operations)
|
||||
|
||||
@@ -3,6 +3,12 @@ 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,10 +1,20 @@
|
||||
import type { Command } from "commander";
|
||||
import { 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;
|
||||
|
||||
@@ -2,6 +2,21 @@ 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,4 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
getStructuredAgentResponse,
|
||||
StructuredAgentResponseError,
|
||||
@@ -15,6 +15,49 @@ 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,4 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import { type Command } from "commander";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -33,6 +34,17 @@ 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,4 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -20,6 +20,14 @@ 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,4 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
@@ -53,6 +53,13 @@ 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,
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
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)
|
||||
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
|
||||
|
||||
# macOS: MacOS/Paseo is a sibling of Resources/
|
||||
@@ -15,6 +31,4 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
|
||||
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
|
||||
|
||||
@@ -4,12 +4,10 @@ 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 "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
|
||||
set "PASEO_DESKTOP_CLI=1"
|
||||
"%APP_EXECUTABLE%" %*
|
||||
|
||||
@@ -29,6 +29,7 @@ mac:
|
||||
linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"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": {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
@@ -293,17 +293,23 @@ 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(
|
||||
process.execPath,
|
||||
[...daemonRunner.execArgv, daemonRunner.entryPath],
|
||||
invocation.command,
|
||||
invocation.args,
|
||||
{
|
||||
detached: true,
|
||||
env: createElectronNodeEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: home,
|
||||
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
|
||||
}),
|
||||
env: invocation.env,
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
});
|
||||
@@ -3,21 +3,23 @@ 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: {
|
||||
@@ -73,6 +75,10 @@ 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}`);
|
||||
@@ -82,16 +88,11 @@ function assertPathExists(input: { label: string; filePath: string }): string {
|
||||
}
|
||||
|
||||
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
|
||||
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;
|
||||
return parseCliPassthroughArgs({
|
||||
argv,
|
||||
isDefaultApp: process.defaultApp,
|
||||
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
|
||||
@@ -167,18 +168,39 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
|
||||
const cli = resolveCliEntrypoint();
|
||||
const invocation = createNodeEntrypointInvocation({
|
||||
entrypoint: cli,
|
||||
argvMode: "bare",
|
||||
args,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
|
||||
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
|
||||
env: createElectronNodeEnv(process.env),
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ async function createMainWindow(): Promise<void> {
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: isMac ? "hidden" : "default",
|
||||
trafficLightPosition: isMac ? { x: 16, y: 17 } : undefined,
|
||||
trafficLightPosition: isMac ? { x: 16, y: 14 } : undefined,
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,14 @@ 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).
|
||||
@@ -79,9 +87,18 @@ export function registerWindowManager(): void {
|
||||
return win?.isFullScreen() ?? false;
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: number) => {
|
||||
ipcMain.handle("paseo:window:setBadgeCount", (_event, count?: unknown) => {
|
||||
if (process.platform === "darwin" || process.platform === "linux") {
|
||||
app.setBadgeCount(count ?? 0);
|
||||
const badgeCount = readBadgeCount(count);
|
||||
try {
|
||||
app.setBadgeCount(badgeCount);
|
||||
} catch (error) {
|
||||
console.warn("[window-manager] Failed to update badge count", {
|
||||
count,
|
||||
badgeCount,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "expo-module build",
|
||||
"build": "tsc",
|
||||
"clean": "expo-module clean",
|
||||
"dev": "expo-module build",
|
||||
"lint": "expo-module lint",
|
||||
"test": "expo-module test",
|
||||
"format": "biome check .",
|
||||
|
||||
@@ -10,7 +10,7 @@ dotenv.config({
|
||||
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
|
||||
["--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)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, describe, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { DaemonClient, type DaemonTransport } from "./daemon-client";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryMessageType,
|
||||
asUint8Array,
|
||||
decodeBinaryMuxFrame,
|
||||
encodeBinaryMuxFrame,
|
||||
} from "../shared/binary-mux.js";
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
TerminalStreamOpcode,
|
||||
} from "../shared/terminal-stream-protocol.js";
|
||||
|
||||
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
||||
expectTypeOf<
|
||||
@@ -1224,7 +1226,7 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("auto-acks terminal stream chunks after delivery", async () => {
|
||||
test("emits output events for the active terminal stream", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -1242,36 +1244,41 @@ describe("DaemonClient", () => {
|
||||
await connectPromise;
|
||||
|
||||
const seen: string[] = [];
|
||||
const decoder = new TextDecoder();
|
||||
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
|
||||
seen.push(decoder.decode(chunk.data));
|
||||
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "output") {
|
||||
return;
|
||||
}
|
||||
seen.push(new TextDecoder().decode(event.data));
|
||||
});
|
||||
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const subscribePromise = client.subscribeTerminal("term-1", "sub-1");
|
||||
mock.triggerMessage(
|
||||
encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 7,
|
||||
offset: 10,
|
||||
payload,
|
||||
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"),
|
||||
}),
|
||||
);
|
||||
|
||||
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("handles terminal binary frames delivered as UTF-8 strings", async () => {
|
||||
test("emits snapshot events for the subscribed terminal stream", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -1288,76 +1295,103 @@ describe("DaemonClient", () => {
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const seen: string[] = [];
|
||||
client.onTerminalStreamData(11, (chunk) => {
|
||||
seen.push(new TextDecoder().decode(chunk.data));
|
||||
const snapshots: unknown[] = [];
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "snapshot") {
|
||||
return;
|
||||
}
|
||||
snapshots.push(event.state);
|
||||
});
|
||||
|
||||
const payload = new TextEncoder().encode("ls\r\n");
|
||||
const frame = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 11,
|
||||
offset: 0,
|
||||
payload,
|
||||
});
|
||||
const frameAsString = new TextDecoder("utf-8", { fatal: true }).decode(frame);
|
||||
|
||||
mock.triggerMessage(frameAsString);
|
||||
|
||||
expect(seen).toEqual(["ls\r\n"]);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!);
|
||||
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack);
|
||||
expect(ackFrame?.streamId).toBe(11);
|
||||
expect(ackFrame?.offset).toBe(4);
|
||||
});
|
||||
|
||||
test("acks buffered terminal chunks when handler is attached", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
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-2");
|
||||
mock.triggerMessage(
|
||||
encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 19,
|
||||
offset: 0,
|
||||
payload: new TextEncoder().encode("buffered"),
|
||||
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(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);
|
||||
expect(snapshots).toEqual([state]);
|
||||
});
|
||||
|
||||
test("stops delivering and acking after terminal_stream_exit", async () => {
|
||||
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 () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -1375,114 +1409,146 @@ describe("DaemonClient", () => {
|
||||
await connectPromise;
|
||||
|
||||
const seen: string[] = [];
|
||||
const unsubscribe = client.onTerminalStreamData(23, (chunk) => {
|
||||
seen.push(new TextDecoder().decode(chunk.data));
|
||||
client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "output") {
|
||||
return;
|
||||
}
|
||||
seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`);
|
||||
});
|
||||
|
||||
const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1");
|
||||
mock.triggerMessage(
|
||||
encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 23,
|
||||
offset: 0,
|
||||
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 inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!);
|
||||
const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test("ignores terminal stream frames after terminal_stream_exit", 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[] = [];
|
||||
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
||||
if (event.type !== "output") {
|
||||
return;
|
||||
}
|
||||
seen.push(new TextDecoder().decode(event.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,
|
||||
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(
|
||||
encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 23,
|
||||
offset: 12,
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 14,
|
||||
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();
|
||||
|
||||
@@ -44,10 +44,8 @@ import type {
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalOutput,
|
||||
TerminalState,
|
||||
KillTerminalResponse,
|
||||
AttachTerminalStreamResponse,
|
||||
DetachTerminalStreamResponse,
|
||||
TerminalInput,
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
@@ -63,18 +61,13 @@ import { getAgentProviderDefinition } from "../server/agent/provider-manifest.js
|
||||
import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js";
|
||||
import {
|
||||
asUint8Array,
|
||||
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";
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalStreamFrame,
|
||||
TerminalStreamOpcode,
|
||||
type TerminalStreamFrame,
|
||||
} from "../shared/terminal-stream-protocol.js";
|
||||
import {
|
||||
createRelayE2eeTransportFactory,
|
||||
createWebSocketTransportFactory,
|
||||
@@ -108,7 +101,10 @@ export type {
|
||||
WebSocketFactory,
|
||||
WebSocketLike,
|
||||
} from "./daemon-client-transport.js";
|
||||
export type { TerminalStreamChunk } from "./daemon-client-terminal-stream-manager.js";
|
||||
|
||||
export type TerminalStreamEvent =
|
||||
| { terminalId: string; type: "output"; data: Uint8Array }
|
||||
| { terminalId: string; type: "snapshot"; state: TerminalState };
|
||||
|
||||
export type ConnectionState =
|
||||
| { status: "idle" }
|
||||
@@ -244,10 +240,7 @@ 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"];
|
||||
@@ -426,9 +419,11 @@ 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;
|
||||
@@ -457,17 +452,6 @@ 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -676,7 +660,7 @@ export class DaemonClient {
|
||||
this.disposeTransport(1000, "Client closed");
|
||||
this.clearWaiters(new Error("Daemon client closed"));
|
||||
this.rejectPendingSendQueue(new Error("Daemon client closed"));
|
||||
this.terminalStreams.clearAll();
|
||||
this.clearTerminalSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
this.updateConnectionState(
|
||||
{ status: "disposed" },
|
||||
@@ -800,7 +784,7 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private sendBinaryFrame(frame: BinaryMuxFrame): void {
|
||||
private sendBinaryFrame(frame: Uint8Array): void {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -808,7 +792,7 @@ export class DaemonClient {
|
||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||
}
|
||||
try {
|
||||
this.transport.send(encodeBinaryMuxFrame(frame));
|
||||
this.transport.send(frame);
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -2622,16 +2606,21 @@ export class DaemonClient {
|
||||
terminalId,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
const payload = await 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,
|
||||
@@ -2639,6 +2628,32 @@ 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,
|
||||
@@ -2662,130 +2677,34 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
|
||||
this.terminalStreamListeners.add(handler);
|
||||
return () => {
|
||||
this.terminalStreamListeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
async waitForTerminalStreamEvent(
|
||||
predicate: (event: TerminalStreamEvent) => boolean,
|
||||
timeout = 5000,
|
||||
): Promise<TerminalStreamChunk> {
|
||||
return new Promise<TerminalStreamChunk>((resolve, reject) => {
|
||||
): Promise<TerminalStreamEvent> {
|
||||
return new Promise<TerminalStreamEvent>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timeout waiting for terminal stream data (${timeout}ms)`));
|
||||
reject(new Error(`Timeout waiting for terminal stream event (${timeout}ms)`));
|
||||
}, timeout);
|
||||
|
||||
const unsubscribe = this.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (!predicate(chunk)) {
|
||||
const unsubscribe = this.onTerminalStreamEvent((event) => {
|
||||
if (!predicate(event)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeoutHandle);
|
||||
unsubscribe();
|
||||
resolve(chunk);
|
||||
resolve(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -2794,6 +2713,37 @@ 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;
|
||||
}
|
||||
@@ -2890,7 +2840,7 @@ export class DaemonClient {
|
||||
|
||||
const rawBytes = asUint8Array(rawData);
|
||||
if (rawBytes) {
|
||||
const frame = decodeBinaryMuxFrame(rawBytes);
|
||||
const frame = decodeTerminalStreamFrame(rawBytes);
|
||||
if (frame) {
|
||||
this.handleBinaryFrame(frame);
|
||||
return;
|
||||
@@ -2922,21 +2872,42 @@ export class DaemonClient {
|
||||
this.handleSessionMessage(parsed.data.message);
|
||||
}
|
||||
|
||||
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 });
|
||||
private handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||
const terminalId = this.slotTerminals.get(frame.slot);
|
||||
if (!terminalId) {
|
||||
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(
|
||||
@@ -2996,7 +2967,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.terminalStreams.clearAll();
|
||||
this.clearTerminalSlots();
|
||||
this.lastServerInfoMessage = null;
|
||||
|
||||
if (wasDisposed) {
|
||||
@@ -3051,7 +3022,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
if (msg.type === "terminal_stream_exit") {
|
||||
this.terminalStreams.clearStream({ streamId: msg.payload.streamId });
|
||||
this.removeTerminalSlot(msg.payload.terminalId);
|
||||
}
|
||||
|
||||
if (this.rawMessageListeners.size > 0) {
|
||||
@@ -3153,14 +3124,6 @@ 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,
|
||||
|
||||
@@ -790,6 +790,7 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -806,6 +807,7 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -821,6 +823,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -839,6 +842,7 @@ export class AgentManager {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
await this.persistSnapshot(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -1054,6 +1058,7 @@ export class AgentManager {
|
||||
"streamAgent.finalize: applying terminal state",
|
||||
);
|
||||
if (!shouldHoldBusyForReplacement) {
|
||||
this.touchUpdatedAt(mutableAgent);
|
||||
this.emitState(mutableAgent);
|
||||
this.flushLiveEventBacklog(mutableAgent);
|
||||
}
|
||||
@@ -1150,6 +1155,7 @@ 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);
|
||||
}
|
||||
@@ -1307,6 +1313,7 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
agent.pendingPermissions.clear();
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
|
||||
@@ -770,6 +770,7 @@ 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;
|
||||
|
||||
@@ -799,6 +800,9 @@ 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);
|
||||
@@ -922,6 +926,8 @@ class TimelineAssembler {
|
||||
if (runId && this.activeMessageByRun.get(runId) === messageId) {
|
||||
this.activeMessageByRun.delete(runId);
|
||||
}
|
||||
this.finalizedMessageIds.add(messageId);
|
||||
this.messages.delete(messageId);
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
|
||||
type AgentUpdatePayload = AgentUpdateMessage["payload"];
|
||||
type AgentUpsertPayload = Extract<AgentUpdatePayload, { kind: "upsert" }>;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function collectAgentUpdates(client: DaemonClient): {
|
||||
updates: AgentUpdatePayload[];
|
||||
unsub: () => void;
|
||||
} {
|
||||
const updates: AgentUpdatePayload[] = [];
|
||||
const unsub = client.on("agent_update", (message) => {
|
||||
if (message.type === "agent_update") {
|
||||
updates.push(message.payload);
|
||||
}
|
||||
});
|
||||
return { updates, unsub };
|
||||
}
|
||||
|
||||
function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
|
||||
return updates
|
||||
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
describe("mode-switch update propagation", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.cleanup();
|
||||
}, 30000);
|
||||
|
||||
test("agent_update event arrives with updated currentModeId after setAgentMode", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
expect(agent.currentModeId).toBe("default");
|
||||
|
||||
await sleep(300);
|
||||
const beforeCount = updates.length;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await sleep(500);
|
||||
|
||||
const modeUpdate = updates
|
||||
.slice(beforeCount)
|
||||
.find(
|
||||
(u): u is AgentUpsertPayload =>
|
||||
u.kind === "upsert" &&
|
||||
u.agent.id === agent.id &&
|
||||
u.agent.currentModeId === "bypassPermissions",
|
||||
);
|
||||
|
||||
expect(modeUpdate).toBeDefined();
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("updatedAt is bumped when mode changes", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await sleep(300);
|
||||
|
||||
const updatedAtBefore = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await sleep(500);
|
||||
|
||||
const updatedAtAfter = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
|
||||
|
||||
expect(updatedAtBefore).toBeTruthy();
|
||||
expect(updatedAtAfter).toBeTruthy();
|
||||
expect(updatedAtAfter).not.toBe(updatedAtBefore);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("mode switch is visible in second client bootstrap snapshot", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
await sleep(200);
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "plan");
|
||||
await sleep(200);
|
||||
|
||||
const client2 = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
});
|
||||
await client2.connect();
|
||||
|
||||
const bootstrapResult = await client2.fetchAgents({
|
||||
subscribe: { subscriptionId: "bootstrap-test" },
|
||||
});
|
||||
|
||||
const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id);
|
||||
expect(found?.agent.currentModeId).toBe("plan");
|
||||
|
||||
const { updates: client2Updates, unsub } = collectAgentUpdates(client2);
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "acceptEdits");
|
||||
await sleep(500);
|
||||
|
||||
const modeUpdate = client2Updates.find(
|
||||
(u): u is AgentUpsertPayload =>
|
||||
u.kind === "upsert" &&
|
||||
u.agent.id === agent.id &&
|
||||
u.agent.currentModeId === "acceptEdits",
|
||||
);
|
||||
expect(modeUpdate).toBeDefined();
|
||||
|
||||
unsub();
|
||||
await client2.close();
|
||||
});
|
||||
|
||||
test("rapid mode switches settle to the final value", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await ctx.client.setAgentMode(agent.id, "plan");
|
||||
await ctx.client.setAgentMode(agent.id, "acceptEdits");
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("acceptEdits");
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("mode switch immediately after creation reflects correct mode", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("bypassPermissions");
|
||||
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ import {
|
||||
type UnsubscribeTerminalRequest,
|
||||
type TerminalInput,
|
||||
type KillTerminalRequest,
|
||||
type AttachTerminalStreamRequest,
|
||||
type DetachTerminalStreamRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
@@ -35,11 +33,12 @@ import {
|
||||
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
|
||||
import type { TerminalSession } from "../terminal/terminal.js";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryFlags,
|
||||
TerminalBinaryMessageType,
|
||||
type BinaryMuxFrame,
|
||||
} from "../shared/binary-mux.js";
|
||||
TerminalStreamOpcode,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
decodeTerminalResizePayload,
|
||||
type TerminalStreamFrame,
|
||||
} from "../shared/terminal-stream-protocol.js";
|
||||
import { TTSManager } from "./agent/tts-manager.js";
|
||||
import { STTManager } from "./agent/stt-manager.js";
|
||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
||||
@@ -191,9 +190,9 @@ const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024;
|
||||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048;
|
||||
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
|
||||
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
|
||||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||
|
||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||
const firstContentLine = prompt
|
||||
@@ -277,11 +276,12 @@ type CheckoutErrorPayload = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type TerminalStreamPendingChunk = {
|
||||
data: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
replay: boolean;
|
||||
type ActiveTerminalStream = {
|
||||
terminalId: string;
|
||||
slot: number;
|
||||
unsubscribe: () => void;
|
||||
needsSnapshot: boolean;
|
||||
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export type SessionRuntimeMetrics = {
|
||||
@@ -291,7 +291,6 @@ export type SessionRuntimeMetrics = {
|
||||
checkoutDiffFallbackRefreshTargetCount: number;
|
||||
terminalDirectorySubscriptionCount: number;
|
||||
terminalSubscriptionCount: number;
|
||||
terminalStreamCount: number;
|
||||
};
|
||||
|
||||
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
|
||||
@@ -395,7 +394,8 @@ type VoiceTranscriptionResultPayload = {
|
||||
export type SessionOptions = {
|
||||
clientId: string;
|
||||
onMessage: (msg: SessionOutboundMessage) => void;
|
||||
onBinaryMessage?: (frame: BinaryMuxFrame) => void;
|
||||
onBinaryMessage?: (frame: Uint8Array) => void;
|
||||
getBinaryBufferedAmount?: () => number;
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||||
logger: pino.Logger;
|
||||
downloadTokenStore: DownloadTokenStore;
|
||||
@@ -546,7 +546,8 @@ export class Session {
|
||||
private readonly clientId: string;
|
||||
private readonly sessionId: string;
|
||||
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
||||
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null;
|
||||
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
||||
private readonly getBinaryBufferedAmount: (() => number) | null;
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private readonly sessionLogger: pino.Logger;
|
||||
private readonly paseoHome: string;
|
||||
@@ -605,21 +606,10 @@ export class Session {
|
||||
private readonly terminalManager: TerminalManager | null;
|
||||
private readonly subscribedTerminalDirectories = new Set<string>();
|
||||
private unsubscribeTerminalsChanged: (() => void) | null = null;
|
||||
private terminalSubscriptions: Map<string, () => void> = new Map();
|
||||
private terminalExitSubscriptions: Map<string, () => void> = new Map();
|
||||
private readonly terminalStreams = new Map<
|
||||
number,
|
||||
{
|
||||
terminalId: string;
|
||||
unsubscribe: () => void;
|
||||
lastOutputOffset: number;
|
||||
lastAckOffset: number;
|
||||
pendingChunks: TerminalStreamPendingChunk[];
|
||||
pendingBytes: number;
|
||||
}
|
||||
>();
|
||||
private readonly terminalStreamByTerminalId = new Map<string, number>();
|
||||
private nextTerminalStreamId = 1;
|
||||
private readonly activeTerminalStreams = new Map<number, ActiveTerminalStream>();
|
||||
private readonly terminalIdToSlot = new Map<string, number>();
|
||||
private nextTerminalSlot = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>();
|
||||
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>();
|
||||
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
|
||||
@@ -648,6 +638,7 @@ export class Session {
|
||||
clientId,
|
||||
onMessage,
|
||||
onBinaryMessage,
|
||||
getBinaryBufferedAmount,
|
||||
onLifecycleIntent,
|
||||
logger,
|
||||
downloadTokenStore,
|
||||
@@ -670,6 +661,7 @@ export class Session {
|
||||
this.sessionId = uuidv4();
|
||||
this.onMessage = onMessage;
|
||||
this.onBinaryMessage = onBinaryMessage ?? null;
|
||||
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? null;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.downloadTokenStore = downloadTokenStore;
|
||||
this.pushTokenStore = pushTokenStore;
|
||||
@@ -761,8 +753,7 @@ export class Session {
|
||||
checkoutDiffWatcherCount,
|
||||
checkoutDiffFallbackRefreshTargetCount,
|
||||
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
|
||||
terminalSubscriptionCount: this.terminalSubscriptions.size,
|
||||
terminalStreamCount: this.terminalStreams.size,
|
||||
terminalSubscriptionCount: this.activeTerminalStreams.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1763,14 +1754,6 @@ export class Session {
|
||||
case "kill_terminal_request":
|
||||
await this.handleKillTerminalRequest(msg);
|
||||
break;
|
||||
|
||||
case "attach_terminal_stream_request":
|
||||
await this.handleAttachTerminalStreamRequest(msg);
|
||||
break;
|
||||
|
||||
case "detach_terminal_stream_request":
|
||||
this.handleDetachTerminalStreamRequest(msg);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -1805,73 +1788,42 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
public handleBinaryFrame(frame: BinaryMuxFrame): void {
|
||||
switch (frame.channel) {
|
||||
case BinaryMuxChannel.Terminal:
|
||||
this.handleTerminalBinaryFrame(frame);
|
||||
break;
|
||||
default:
|
||||
this.sessionLogger.warn(
|
||||
{ channel: frame.channel, messageType: frame.messageType },
|
||||
"Unhandled binary mux channel",
|
||||
);
|
||||
break;
|
||||
public handleBinaryFrame(frame: TerminalStreamFrame): void {
|
||||
const activeStream = this.activeTerminalStreams.get(frame.slot);
|
||||
if (!activeStream || !this.terminalManager) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void {
|
||||
if (frame.messageType === TerminalBinaryMessageType.InputUtf8) {
|
||||
const binding = this.terminalStreams.get(frame.streamId);
|
||||
if (!binding) {
|
||||
this.sessionLogger.warn(
|
||||
{ streamId: frame.streamId },
|
||||
"Terminal stream not found for input",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.terminalManager) {
|
||||
return;
|
||||
}
|
||||
const session = this.terminalManager.getTerminal(binding.terminalId);
|
||||
if (!session) {
|
||||
this.detachTerminalStream(frame.streamId, { emitExit: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = frame.payload ?? new Uint8Array(0);
|
||||
if (payload.byteLength === 0) {
|
||||
return;
|
||||
}
|
||||
const text = Buffer.from(payload).toString("utf8");
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
session.send({ type: "input", data: text });
|
||||
const terminal = this.terminalManager.getTerminal(activeStream.terminalId);
|
||||
if (!terminal) {
|
||||
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.messageType === TerminalBinaryMessageType.Ack) {
|
||||
const binding = this.terminalStreams.get(frame.streamId);
|
||||
if (binding) {
|
||||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
||||
switch (frame.opcode) {
|
||||
case TerminalStreamOpcode.Input: {
|
||||
if (frame.payload.byteLength === 0) {
|
||||
return;
|
||||
}
|
||||
const nextAckOffset = Math.max(
|
||||
binding.lastAckOffset,
|
||||
Math.min(Math.floor(frame.offset), binding.lastOutputOffset),
|
||||
);
|
||||
if (nextAckOffset > binding.lastAckOffset) {
|
||||
binding.lastAckOffset = nextAckOffset;
|
||||
this.flushPendingTerminalStreamChunks(frame.streamId, binding);
|
||||
const text = Buffer.from(frame.payload).toString("utf8");
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
terminal.send({ type: "input", data: text });
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionLogger.warn(
|
||||
{ streamId: frame.streamId, messageType: frame.messageType },
|
||||
"Unhandled terminal binary frame",
|
||||
);
|
||||
case TerminalStreamOpcode.Resize: {
|
||||
const resize = decodeTerminalResizePayload(frame.payload);
|
||||
if (!resize) {
|
||||
return;
|
||||
}
|
||||
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
||||
@@ -2032,7 +1984,18 @@ export class Session {
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
await this.agentStorage.upsert(nextRecord);
|
||||
this.agentManager.notifyAgentState(agentId);
|
||||
|
||||
// Unload the agent from memory — the storage record is the source of truth now.
|
||||
// This tears down the provider session and drops the hydrated timeline,
|
||||
// freeing memory. ensureAgentLoaded will re-initialize if needed later.
|
||||
if (this.agentManager.getAgent(agentId)) {
|
||||
try {
|
||||
await this.agentManager.closeAgent(agentId);
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn({ err: error, agentId }, "Failed to close agent during archive");
|
||||
}
|
||||
}
|
||||
|
||||
return { archivedAt, archivedRecord: nextRecord };
|
||||
}
|
||||
|
||||
@@ -7447,7 +7410,7 @@ export class Session {
|
||||
this.onMessage(msg);
|
||||
}
|
||||
|
||||
private emitBinary(frame: BinaryMuxFrame): void {
|
||||
private emitBinary(frame: Uint8Array): void {
|
||||
if (!this.onBinaryMessage) {
|
||||
return;
|
||||
}
|
||||
@@ -7506,15 +7469,11 @@ export class Session {
|
||||
}
|
||||
this.subscribedTerminalDirectories.clear();
|
||||
|
||||
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.terminalSubscriptions.clear();
|
||||
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
||||
unsubscribeExit();
|
||||
}
|
||||
this.terminalExitSubscriptions.clear();
|
||||
this.detachAllTerminalStreams({ emitExit: false });
|
||||
this.disposeTerminalSubscriptions();
|
||||
|
||||
for (const target of this.checkoutDiffTargets.values()) {
|
||||
this.closeCheckoutDiffWatchTarget(target);
|
||||
@@ -7550,23 +7509,7 @@ export class Session {
|
||||
this.terminalExitSubscriptions.delete(terminalId);
|
||||
}
|
||||
|
||||
const unsubscribe = this.terminalSubscriptions.get(terminalId);
|
||||
if (unsubscribe) {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, terminalId },
|
||||
"Failed to unsubscribe terminal after process exit",
|
||||
);
|
||||
}
|
||||
this.terminalSubscriptions.delete(terminalId);
|
||||
}
|
||||
|
||||
const streamId = this.terminalStreamByTerminalId.get(terminalId);
|
||||
if (typeof streamId === "number") {
|
||||
this.detachTerminalStream(streamId, { emitExit: true });
|
||||
}
|
||||
this.detachTerminalStream(terminalId, { emitExit: true });
|
||||
}
|
||||
|
||||
private emitTerminalsChangedSnapshot(input: {
|
||||
@@ -7717,7 +7660,6 @@ export class Session {
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
state: null,
|
||||
error: "Terminal manager not available",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
@@ -7731,7 +7673,6 @@ export class Session {
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
state: null,
|
||||
error: "Terminal not found",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
@@ -7740,32 +7681,31 @@ export class Session {
|
||||
}
|
||||
this.ensureTerminalExitSubscription(session);
|
||||
|
||||
// Unsubscribe from previous subscription if any
|
||||
const existing = this.terminalSubscriptions.get(msg.terminalId);
|
||||
if (existing) {
|
||||
existing();
|
||||
const slot = this.bindActiveTerminalStream(session);
|
||||
if (slot === null) {
|
||||
this.sessionLogger.warn(
|
||||
{
|
||||
terminalId: msg.terminalId,
|
||||
activeTerminalStreamCount: this.activeTerminalStreams.size,
|
||||
},
|
||||
"Terminal stream slot exhaustion",
|
||||
);
|
||||
this.emit({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
error: "No terminal stream slots available",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to terminal updates
|
||||
const unsubscribe = session.subscribe((serverMsg) => {
|
||||
if (serverMsg.type === "full") {
|
||||
this.emit({
|
||||
type: "terminal_output",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
state: serverMsg.state,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
this.terminalSubscriptions.set(msg.terminalId, unsubscribe);
|
||||
|
||||
// Send initial state
|
||||
this.emit({
|
||||
type: "subscribe_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
state: session.getState(),
|
||||
slot,
|
||||
error: null,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
@@ -7773,11 +7713,7 @@ export class Session {
|
||||
}
|
||||
|
||||
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
||||
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId);
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
this.terminalSubscriptions.delete(msg.terminalId);
|
||||
}
|
||||
this.detachTerminalStream(msg.terminalId, { emitExit: false });
|
||||
}
|
||||
|
||||
private handleTerminalInput(msg: TerminalInput): void {
|
||||
@@ -7792,21 +7728,18 @@ export class Session {
|
||||
}
|
||||
this.ensureTerminalExitSubscription(session);
|
||||
|
||||
if (msg.message.type === "resize") {
|
||||
const currentSize = session.getSize();
|
||||
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
session.send(msg.message);
|
||||
}
|
||||
|
||||
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
|
||||
const unsubscribe = this.terminalSubscriptions.get(terminalId);
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
this.terminalSubscriptions.delete(terminalId);
|
||||
}
|
||||
|
||||
const streamId = this.terminalStreamByTerminalId.get(terminalId);
|
||||
if (typeof streamId === "number") {
|
||||
this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true });
|
||||
}
|
||||
|
||||
this.detachTerminalStream(terminalId, { emitExit: options?.emitExit ?? true });
|
||||
this.terminalManager?.killTerminal(terminalId);
|
||||
}
|
||||
|
||||
@@ -7867,277 +7800,169 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise<void> {
|
||||
if (!this.terminalManager || !this.onBinaryMessage) {
|
||||
this.emit({
|
||||
type: "attach_terminal_stream_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
streamId: null,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: true,
|
||||
error: "Terminal streaming not available",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
private bindActiveTerminalStream(terminal: TerminalSession): number | null {
|
||||
if (!this.onBinaryMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = this.terminalManager.getTerminal(msg.terminalId);
|
||||
if (!session) {
|
||||
this.emit({
|
||||
type: "attach_terminal_stream_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
streamId: null,
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: true,
|
||||
error: "Terminal not found",
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
const existingSlot = this.terminalIdToSlot.get(terminal.id);
|
||||
if (typeof existingSlot === "number") {
|
||||
const existingStream = this.activeTerminalStreams.get(existingSlot);
|
||||
if (existingStream) {
|
||||
existingStream.needsSnapshot = true;
|
||||
this.trySendTerminalSnapshot(existingStream);
|
||||
return existingSlot;
|
||||
}
|
||||
this.terminalIdToSlot.delete(terminal.id);
|
||||
}
|
||||
|
||||
if (msg.rows || msg.cols) {
|
||||
const state = session.getState();
|
||||
session.send({
|
||||
type: "resize",
|
||||
rows: msg.rows ?? state.rows,
|
||||
cols: msg.cols ?? state.cols,
|
||||
});
|
||||
const slot = this.allocateTerminalSlot();
|
||||
if (slot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId);
|
||||
if (typeof existingStreamId === "number") {
|
||||
// Replacing an active stream can happen when multiple UI surfaces attach to the
|
||||
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
|
||||
// instead of continuing to send input to an invalid stream id.
|
||||
this.detachTerminalStream(existingStreamId, { emitExit: true });
|
||||
}
|
||||
|
||||
const streamId = this.allocateTerminalStreamId();
|
||||
const requestedResumeOffset = typeof msg.resumeOffset === "number" ? msg.resumeOffset : 0;
|
||||
const initialOffset = Math.max(0, Math.floor(requestedResumeOffset));
|
||||
const binding: {
|
||||
terminalId: string;
|
||||
unsubscribe: () => void;
|
||||
lastOutputOffset: number;
|
||||
lastAckOffset: number;
|
||||
pendingChunks: TerminalStreamPendingChunk[];
|
||||
pendingBytes: number;
|
||||
} = {
|
||||
terminalId: msg.terminalId,
|
||||
const activeStream: ActiveTerminalStream = {
|
||||
terminalId: terminal.id,
|
||||
slot,
|
||||
unsubscribe: () => {},
|
||||
lastOutputOffset: initialOffset,
|
||||
lastAckOffset: initialOffset,
|
||||
pendingChunks: [],
|
||||
pendingBytes: 0,
|
||||
needsSnapshot: true,
|
||||
snapshotRetryTimer: null,
|
||||
};
|
||||
this.terminalStreams.set(streamId, binding);
|
||||
this.terminalStreamByTerminalId.set(msg.terminalId, streamId);
|
||||
|
||||
let rawSub;
|
||||
try {
|
||||
rawSub = session.subscribeRaw(
|
||||
(chunk) => {
|
||||
const currentBinding = this.terminalStreams.get(streamId);
|
||||
if (!currentBinding) {
|
||||
return;
|
||||
}
|
||||
this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, {
|
||||
data: chunk.data,
|
||||
startOffset: chunk.startOffset,
|
||||
endOffset: chunk.endOffset,
|
||||
replay: chunk.replay,
|
||||
});
|
||||
},
|
||||
{ fromOffset: requestedResumeOffset },
|
||||
);
|
||||
} catch (error) {
|
||||
this.terminalStreams.delete(streamId);
|
||||
this.terminalStreamByTerminalId.delete(msg.terminalId);
|
||||
throw error;
|
||||
}
|
||||
this.activeTerminalStreams.set(slot, activeStream);
|
||||
this.terminalIdToSlot.set(terminal.id, slot);
|
||||
|
||||
binding.unsubscribe = rawSub.unsubscribe;
|
||||
binding.lastAckOffset = rawSub.replayedFrom;
|
||||
if (binding.lastOutputOffset < rawSub.replayedFrom) {
|
||||
binding.lastOutputOffset = rawSub.replayedFrom;
|
||||
}
|
||||
this.flushPendingTerminalStreamChunks(streamId, binding);
|
||||
|
||||
this.emit({
|
||||
type: "attach_terminal_stream_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
streamId,
|
||||
replayedFrom: rawSub.replayedFrom,
|
||||
currentOffset: rawSub.currentOffset,
|
||||
earliestAvailableOffset: rawSub.earliestAvailableOffset,
|
||||
reset: rawSub.reset,
|
||||
error: null,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number {
|
||||
return Math.max(0, chunk.endOffset - chunk.startOffset);
|
||||
}
|
||||
|
||||
private canEmitTerminalStreamChunk(
|
||||
binding: {
|
||||
lastAckOffset: number;
|
||||
},
|
||||
chunk: TerminalStreamPendingChunk,
|
||||
): boolean {
|
||||
return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES;
|
||||
}
|
||||
|
||||
private emitTerminalStreamChunk(
|
||||
streamId: number,
|
||||
binding: {
|
||||
lastOutputOffset: number;
|
||||
},
|
||||
chunk: TerminalStreamPendingChunk,
|
||||
): void {
|
||||
const payload = new Uint8Array(Buffer.from(chunk.data, "utf8"));
|
||||
this.emitBinary({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId,
|
||||
offset: chunk.startOffset,
|
||||
flags: chunk.replay ? TerminalBinaryFlags.Replay : 0,
|
||||
payload,
|
||||
});
|
||||
binding.lastOutputOffset = chunk.endOffset;
|
||||
}
|
||||
|
||||
private enqueueOrEmitTerminalStreamChunk(
|
||||
streamId: number,
|
||||
binding: {
|
||||
lastAckOffset: number;
|
||||
lastOutputOffset: number;
|
||||
pendingChunks: TerminalStreamPendingChunk[];
|
||||
pendingBytes: number;
|
||||
},
|
||||
chunk: TerminalStreamPendingChunk,
|
||||
): void {
|
||||
const chunkBytes = this.getTerminalStreamChunkByteLength(chunk);
|
||||
|
||||
if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) {
|
||||
if (
|
||||
binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS ||
|
||||
binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES
|
||||
) {
|
||||
this.sessionLogger.warn(
|
||||
{
|
||||
streamId,
|
||||
pendingChunks: binding.pendingChunks.length,
|
||||
pendingBytes: binding.pendingBytes,
|
||||
chunkBytes,
|
||||
},
|
||||
"Terminal stream pending buffer overflow; closing stream",
|
||||
);
|
||||
this.detachTerminalStream(streamId, { emitExit: true });
|
||||
activeStream.unsubscribe = terminal.subscribe((message) => {
|
||||
if (this.activeTerminalStreams.get(slot) !== activeStream) {
|
||||
return;
|
||||
}
|
||||
binding.pendingChunks.push(chunk);
|
||||
binding.pendingBytes += chunkBytes;
|
||||
if (message.type === "snapshot") {
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
return;
|
||||
}
|
||||
if (activeStream.needsSnapshot || message.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||
this.markAllActiveTerminalStreamsForSnapshot();
|
||||
return;
|
||||
}
|
||||
this.emitBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot,
|
||||
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
||||
}),
|
||||
);
|
||||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||||
this.markAllActiveTerminalStreamsForSnapshot();
|
||||
}
|
||||
});
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private trySendTerminalSnapshot(activeStream: ActiveTerminalStream): void {
|
||||
if (
|
||||
this.activeTerminalStreams.get(activeStream.slot) !== activeStream ||
|
||||
!activeStream.needsSnapshot
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emitTerminalStreamChunk(streamId, binding, chunk);
|
||||
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
|
||||
if (!activeStream.snapshotRetryTimer) {
|
||||
activeStream.snapshotRetryTimer = setTimeout(() => {
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
}, 33);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeStream.snapshotRetryTimer) {
|
||||
clearTimeout(activeStream.snapshotRetryTimer);
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
}
|
||||
|
||||
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
|
||||
if (!terminal) {
|
||||
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
|
||||
return;
|
||||
}
|
||||
|
||||
activeStream.needsSnapshot = false;
|
||||
this.emitBinary(
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Snapshot,
|
||||
slot: activeStream.slot,
|
||||
payload: encodeTerminalSnapshotPayload(terminal.getState()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private flushPendingTerminalStreamChunks(
|
||||
streamId: number,
|
||||
binding: {
|
||||
lastAckOffset: number;
|
||||
lastOutputOffset: number;
|
||||
pendingChunks: TerminalStreamPendingChunk[];
|
||||
pendingBytes: number;
|
||||
},
|
||||
): void {
|
||||
while (binding.pendingChunks.length > 0) {
|
||||
const next = binding.pendingChunks[0];
|
||||
if (!next || !this.canEmitTerminalStreamChunk(binding, next)) {
|
||||
break;
|
||||
}
|
||||
binding.pendingChunks.shift();
|
||||
binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next);
|
||||
if (binding.pendingBytes < 0) {
|
||||
binding.pendingBytes = 0;
|
||||
}
|
||||
this.emitTerminalStreamChunk(streamId, binding, next);
|
||||
private markAllActiveTerminalStreamsForSnapshot(): void {
|
||||
for (const activeStream of this.activeTerminalStreams.values()) {
|
||||
activeStream.needsSnapshot = true;
|
||||
this.trySendTerminalSnapshot(activeStream);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void {
|
||||
const success = this.detachTerminalStream(msg.streamId, { emitExit: false });
|
||||
this.emit({
|
||||
type: "detach_terminal_stream_response",
|
||||
payload: {
|
||||
streamId: msg.streamId,
|
||||
success,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private detachAllTerminalStreams(options?: { emitExit: boolean }): void {
|
||||
for (const streamId of Array.from(this.terminalStreams.keys())) {
|
||||
this.detachTerminalStream(streamId, options);
|
||||
private allocateTerminalSlot(): number | null {
|
||||
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
|
||||
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
|
||||
if (this.activeTerminalStreams.has(slot)) {
|
||||
continue;
|
||||
}
|
||||
this.nextTerminalSlot = (slot + 1) % MAX_TERMINAL_STREAM_SLOTS;
|
||||
return slot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean {
|
||||
const binding = this.terminalStreams.get(streamId);
|
||||
if (!binding) {
|
||||
private detachTerminalStream(terminalId: string, options?: { emitExit: boolean }): boolean {
|
||||
const slot = this.terminalIdToSlot.get(terminalId);
|
||||
if (typeof slot !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeStream = this.activeTerminalStreams.get(slot);
|
||||
if (!activeStream) {
|
||||
this.terminalIdToSlot.delete(terminalId);
|
||||
return false;
|
||||
}
|
||||
this.activeTerminalStreams.delete(slot);
|
||||
this.terminalIdToSlot.delete(terminalId);
|
||||
if (activeStream.snapshotRetryTimer) {
|
||||
clearTimeout(activeStream.snapshotRetryTimer);
|
||||
activeStream.snapshotRetryTimer = null;
|
||||
}
|
||||
try {
|
||||
binding.unsubscribe();
|
||||
activeStream.unsubscribe();
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn({ err: error, streamId }, "Failed to unsubscribe terminal stream");
|
||||
this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream");
|
||||
}
|
||||
this.terminalStreams.delete(streamId);
|
||||
if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) {
|
||||
this.terminalStreamByTerminalId.delete(binding.terminalId);
|
||||
}
|
||||
|
||||
if (options?.emitExit) {
|
||||
this.emit({
|
||||
type: "terminal_stream_exit",
|
||||
payload: {
|
||||
streamId,
|
||||
terminalId: binding.terminalId,
|
||||
terminalId: activeStream.terminalId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private allocateTerminalStreamId(): number {
|
||||
let attempts = 0;
|
||||
while (attempts < 0xffffffff) {
|
||||
const candidate = this.nextTerminalStreamId >>> 0;
|
||||
this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0;
|
||||
if (candidate === 0) {
|
||||
attempts += 1;
|
||||
continue;
|
||||
}
|
||||
if (!this.terminalStreams.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
attempts += 1;
|
||||
private disposeTerminalSubscriptions(): void {
|
||||
for (const terminalId of [...this.terminalIdToSlot.keys()]) {
|
||||
this.detachTerminalStream(terminalId, { emitExit: false });
|
||||
}
|
||||
throw new Error("Unable to allocate terminal stream id");
|
||||
}
|
||||
|
||||
private getCurrentBinaryBufferedAmount(): number {
|
||||
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
|
||||
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(bufferedAmount);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user