mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 23:23:21 +00:00
Compare commits
34
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 |
@@ -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
|
||||
|
||||
+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
+114
-2
@@ -12557,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",
|
||||
@@ -21343,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",
|
||||
@@ -21738,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",
|
||||
@@ -24073,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",
|
||||
@@ -25027,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"
|
||||
@@ -27603,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",
|
||||
@@ -28550,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",
|
||||
@@ -32406,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",
|
||||
@@ -34526,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": {
|
||||
@@ -34755,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",
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2187,7 +2187,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "300",
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
@@ -2230,12 +2230,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#2d8a4e",
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#c43a31",
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
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 Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
@@ -6,6 +6,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
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,
|
||||
@@ -18,15 +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;
|
||||
terminalId: string;
|
||||
isPaneFocused: boolean;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
@@ -83,8 +85,10 @@ export function TerminalPane({
|
||||
serverId,
|
||||
cwd,
|
||||
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";
|
||||
@@ -103,53 +107,18 @@ export function TerminalPane({
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
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 === terminalId) {
|
||||
return outputState;
|
||||
}
|
||||
|
||||
return {
|
||||
...outputState,
|
||||
selectedTerminalId: terminalId,
|
||||
snapshotText: outputSession.readSnapshot({ terminalId }),
|
||||
snapshotSequence: 0,
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
}, [outputSession, outputState, terminalId]);
|
||||
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 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 initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
@@ -163,7 +132,7 @@ export function TerminalPane({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isScreenFocused || !terminalId) {
|
||||
if (isMobile || !isScreenFocused || !isPaneFocused || !terminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -175,7 +144,14 @@ export function TerminalPane({
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
}, [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) {
|
||||
@@ -248,13 +224,16 @@ 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]);
|
||||
}, [client, isConnected, workspaceTerminalSession.snapshots]);
|
||||
|
||||
useEffect(() => {
|
||||
lastReportedSizeRef.current = null;
|
||||
@@ -263,20 +242,11 @@ export function TerminalPane({
|
||||
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);
|
||||
|
||||
@@ -287,12 +257,18 @@ 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,
|
||||
});
|
||||
@@ -313,26 +289,16 @@ export function TerminalPane({
|
||||
handleStreamControllerStatus,
|
||||
isConnected,
|
||||
isScreenFocused,
|
||||
outputSession,
|
||||
workspaceTerminalSession.resumeOffsets,
|
||||
workspaceTerminalSession.snapshots,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingTerminalInputRef.current = [];
|
||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||
outputSession.setSelectedTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
});
|
||||
streamControllerRef.current?.setTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
});
|
||||
}, [isScreenFocused, outputSession, terminalId]);
|
||||
|
||||
const activeStreamId =
|
||||
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
|
||||
const getCurrentActiveStreamId = useCallback(() => {
|
||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||
}, []);
|
||||
}, [isScreenFocused, terminalId]);
|
||||
|
||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
@@ -398,8 +364,10 @@ export function TerminalPane({
|
||||
}, [dispatchTerminalInputEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
flushPendingTerminalInput();
|
||||
}, [activeStreamId, flushPendingTerminalInput]);
|
||||
if (!isAttaching && !streamError) {
|
||||
flushPendingTerminalInput();
|
||||
}
|
||||
}, [flushPendingTerminalInput, isAttaching, streamError]);
|
||||
|
||||
const clearPendingModifiers = useCallback(() => {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
@@ -443,7 +411,7 @@ export function TerminalPane({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId],
|
||||
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
|
||||
);
|
||||
|
||||
const handleTerminalData = useCallback(
|
||||
@@ -496,7 +464,6 @@ export function TerminalPane({
|
||||
clearPendingModifiers,
|
||||
client,
|
||||
dispatchTerminalInputEntry,
|
||||
getCurrentActiveStreamId,
|
||||
modifiers.alt,
|
||||
modifiers.ctrl,
|
||||
modifiers.shift,
|
||||
@@ -505,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 || !terminalId || rows <= 0 || cols <= 0) {
|
||||
if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
@@ -524,7 +491,6 @@ export function TerminalPane({
|
||||
cols: normalizedCols,
|
||||
});
|
||||
},
|
||||
[client, terminalId],
|
||||
);
|
||||
|
||||
const handleTerminalKey = useCallback(
|
||||
@@ -538,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] }));
|
||||
@@ -589,6 +548,7 @@ export function TerminalPane({
|
||||
{isScreenFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={{
|
||||
style: { flex: 1 },
|
||||
matchContents: false,
|
||||
@@ -600,14 +560,10 @@ export function TerminalPane({
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
initialOutputText={selectedOutputState.snapshotText}
|
||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||
outputChunkText={selectedOutputState.chunkText}
|
||||
outputChunkSequence={selectedOutputState.chunkSequence}
|
||||
outputChunkReplay={selectedOutputState.chunkReplay}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onSwipeRight={() => {
|
||||
if (!swipeGesturesEnabled) {
|
||||
return;
|
||||
@@ -624,7 +580,6 @@ export function TerminalPane({
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||
onOutputChunkConsumed={handleOutputChunkConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -33,12 +33,16 @@ 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();
|
||||
@@ -157,6 +161,15 @@ 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",
|
||||
@@ -228,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;
|
||||
@@ -243,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) {
|
||||
@@ -397,5 +420,6 @@ export function useKeyboardShortcuts({
|
||||
selectedAgentId,
|
||||
toggleAgentList,
|
||||
toggleFileExplorer,
|
||||
toggleFocusMode,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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), []);
|
||||
}
|
||||
@@ -16,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"
|
||||
@@ -35,11 +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"
|
||||
@@ -26,6 +27,7 @@ export type KeyboardActionId =
|
||||
| "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 }
|
||||
|
||||
@@ -261,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",
|
||||
},
|
||||
{
|
||||
@@ -289,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", ({
|
||||
@@ -344,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 },
|
||||
@@ -364,6 +372,11 @@ 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 }) => {
|
||||
@@ -467,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"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -477,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", "."],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -439,7 +439,7 @@ 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: "tabs-panes",
|
||||
@@ -451,7 +451,7 @@ 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: "tabs-panes",
|
||||
@@ -463,7 +463,7 @@ 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: "tabs-panes",
|
||||
@@ -475,7 +475,7 @@ 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: "tabs-panes",
|
||||
@@ -487,7 +487,7 @@ 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: "tabs-panes",
|
||||
@@ -499,7 +499,7 @@ 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: "tabs-panes",
|
||||
@@ -511,7 +511,7 @@ 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: "tabs-panes",
|
||||
@@ -523,7 +523,7 @@ 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: "tabs-panes",
|
||||
@@ -535,7 +535,7 @@ 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: "tabs-panes",
|
||||
@@ -547,7 +547,7 @@ 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: "tabs-panes",
|
||||
@@ -559,7 +559,7 @@ 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: "tabs-panes",
|
||||
@@ -649,22 +649,16 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
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: "panels",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "."],
|
||||
keys: ["mod", "B"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -698,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",
|
||||
@@ -768,6 +814,48 @@ 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",
|
||||
|
||||
@@ -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 } = usePaneContext();
|
||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!isFocused) {
|
||||
@@ -61,6 +61,7 @@ function TerminalPanel() {
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
terminalId={target.terminalId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) =>
|
||||
@@ -1563,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;
|
||||
@@ -1727,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,
|
||||
@@ -1778,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 ? (
|
||||
@@ -1799,6 +1950,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
) : null}
|
||||
<View style={styles.threePaneRow}>
|
||||
<View style={styles.centerColumn}>
|
||||
{(!isFocusModeEnabled || isMobile) && (
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
@@ -1894,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 ? (
|
||||
@@ -1985,6 +2158,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
@@ -2015,6 +2189,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
{workspaceLayout && persistenceKey ? (
|
||||
<SplitContainer
|
||||
layout={workspaceLayout}
|
||||
focusModeEnabled={isFocusModeEnabled && !isMobile}
|
||||
workspaceKey={persistenceKey}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
@@ -2032,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
|
||||
@@ -2073,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>
|
||||
);
|
||||
@@ -2214,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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ mac:
|
||||
linux:
|
||||
category: Development
|
||||
icon: assets
|
||||
artifactName: "Paseo-${version}-${arch}.${ext}"
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ dotenv.config({
|
||||
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--inspect", "--heapsnapshot-near-heap-limit=3", ...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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryMessageType,
|
||||
asUint8Array,
|
||||
decodeBinaryMuxFrame,
|
||||
encodeBinaryMuxFrame,
|
||||
} from "../shared/binary-mux.js";
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalStreamFrame,
|
||||
TerminalStreamOpcode,
|
||||
} from "../shared/terminal-stream-protocol.js";
|
||||
|
||||
const wsModuleMock = vi.hoisted(() => {
|
||||
class MockWebSocketServer {
|
||||
@@ -44,7 +43,6 @@ const sessionMock = vi.hoisted(() => {
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
terminalDirectorySubscriptionCount: 0,
|
||||
terminalSubscriptionCount: 0,
|
||||
terminalStreamCount: 0,
|
||||
}));
|
||||
readonly args: Record<string, unknown>;
|
||||
|
||||
@@ -89,6 +87,7 @@ const TEST_DAEMON_VERSION = "1.2.3-test";
|
||||
|
||||
class MockSocket {
|
||||
readyState = 1;
|
||||
bufferedAmount = 0;
|
||||
sent: unknown[] = [];
|
||||
private listeners = new Map<string, Array<(...args: any[]) => void>>();
|
||||
|
||||
@@ -623,7 +622,7 @@ describe("relay external socket reconnect behavior", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("routes inbound binary mux frames to session.handleBinaryFrame", async () => {
|
||||
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
|
||||
const server = createServer();
|
||||
|
||||
const socket = new MockSocket();
|
||||
@@ -638,11 +637,9 @@ describe("relay external socket reconnect behavior", () => {
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
||||
streamId: 9,
|
||||
offset: 0,
|
||||
encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Input,
|
||||
slot: 9,
|
||||
payload: new TextEncoder().encode("ls\r"),
|
||||
}),
|
||||
),
|
||||
@@ -651,20 +648,18 @@ describe("relay external socket reconnect behavior", () => {
|
||||
|
||||
expect(session.handleBinaryFrame).toHaveBeenCalledTimes(1);
|
||||
const frame = session.handleBinaryFrame.mock.calls[0]?.[0] as {
|
||||
channel: number;
|
||||
messageType: number;
|
||||
streamId: number;
|
||||
offset: number;
|
||||
opcode: number;
|
||||
slot: number;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
expect(frame.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(frame.messageType).toBe(TerminalBinaryMessageType.InputUtf8);
|
||||
expect(frame.streamId).toBe(9);
|
||||
expect(frame.offset).toBe(0);
|
||||
expect(frame.opcode).toBe(TerminalStreamOpcode.Input);
|
||||
expect(frame.slot).toBe(9);
|
||||
expect(new TextDecoder().decode(frame.payload)).toBe("ls\r");
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("sends outbound binary mux frames from session over websocket", async () => {
|
||||
test("sends outbound terminal frames from session over websocket", async () => {
|
||||
const server = createServer();
|
||||
|
||||
const socket = new MockSocket();
|
||||
@@ -677,33 +672,19 @@ describe("relay external socket reconnect behavior", () => {
|
||||
const session = sessionMock.instances[0]!;
|
||||
|
||||
const onBinaryMessage = session.args.onBinaryMessage as
|
||||
| ((frame: {
|
||||
channel: number;
|
||||
messageType: number;
|
||||
streamId: number;
|
||||
offset: number;
|
||||
payload?: Uint8Array;
|
||||
}) => void)
|
||||
| ((frame: Uint8Array) => void)
|
||||
| undefined;
|
||||
expect(onBinaryMessage).toBeTypeOf("function");
|
||||
|
||||
onBinaryMessage?.({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 11,
|
||||
offset: 42,
|
||||
payload: new TextEncoder().encode("ok"),
|
||||
});
|
||||
onBinaryMessage?.(new Uint8Array([TerminalStreamOpcode.Output, 12, 0x6f, 0x6b]));
|
||||
|
||||
expect(socket.sent).toHaveLength(2);
|
||||
const binaryPayload = asUint8Array(socket.sent[1]);
|
||||
expect(binaryPayload).not.toBeNull();
|
||||
const frame = decodeBinaryMuxFrame(binaryPayload!);
|
||||
const frame = decodeTerminalStreamFrame(binaryPayload!);
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame!.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(frame!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
|
||||
expect(frame!.streamId).toBe(11);
|
||||
expect(frame!.offset).toBe(42);
|
||||
expect(frame!.opcode).toBe(TerminalStreamOpcode.Output);
|
||||
expect(frame!.slot).toBe(12);
|
||||
expect(new TextDecoder().decode(frame!.payload ?? new Uint8Array())).toBe("ok");
|
||||
|
||||
await server.close();
|
||||
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type WSOutboundMessage,
|
||||
wrapSessionMessage,
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeBinaryMuxFrame, encodeBinaryMuxFrame } from "../shared/binary-mux.js";
|
||||
import {
|
||||
asUint8Array,
|
||||
decodeTerminalStreamFrame,
|
||||
} from "../shared/terminal-stream-protocol.js";
|
||||
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import { isHostAllowed } from "./allowed-hosts.js";
|
||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||
@@ -161,6 +164,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe
|
||||
|
||||
type WebSocketLike = {
|
||||
readyState: number;
|
||||
bufferedAmount?: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
on: (event: "message" | "close" | "error", listener: (...args: any[]) => void) => void;
|
||||
@@ -492,12 +496,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
private sendBinaryToClient(
|
||||
ws: WebSocketLike,
|
||||
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
|
||||
frame: Uint8Array,
|
||||
): void {
|
||||
if (ws.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
ws.send(encodeBinaryMuxFrame(frame));
|
||||
ws.send(frame);
|
||||
}
|
||||
|
||||
private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {
|
||||
@@ -508,7 +512,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
private sendBinaryToConnection(
|
||||
connection: SessionConnection,
|
||||
frame: Parameters<typeof encodeBinaryMuxFrame>[0],
|
||||
frame: Uint8Array,
|
||||
): void {
|
||||
for (const ws of connection.sockets) {
|
||||
this.sendBinaryToClient(ws, frame);
|
||||
@@ -595,6 +599,16 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
this.sendBinaryToConnection(connection, frame);
|
||||
},
|
||||
getBinaryBufferedAmount: () => {
|
||||
if (!connection) {
|
||||
return 0;
|
||||
}
|
||||
let bufferedAmount = 0;
|
||||
for (const socket of connection.sockets) {
|
||||
bufferedAmount = Math.max(bufferedAmount, socket.bufferedAmount ?? 0);
|
||||
}
|
||||
return bufferedAmount;
|
||||
},
|
||||
onLifecycleIntent: (intent) => {
|
||||
this.onLifecycleIntent?.(intent);
|
||||
},
|
||||
@@ -900,7 +914,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
const buffer = bufferFromWsData(data);
|
||||
const asBytes = asUint8Array(buffer);
|
||||
if (asBytes) {
|
||||
const frame = decodeBinaryMuxFrame(asBytes);
|
||||
const frame = decodeTerminalStreamFrame(asBytes);
|
||||
if (frame) {
|
||||
if (!activeConnection) {
|
||||
this.incrementRuntimeCounter("binaryBeforeHelloRejected");
|
||||
@@ -1145,7 +1159,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
let checkoutDiffFallbackRefreshTargetCount = 0;
|
||||
let terminalDirectorySubscriptionCount = 0;
|
||||
let terminalSubscriptionCount = 0;
|
||||
let terminalStreamCount = 0;
|
||||
|
||||
for (const connection of uniqueConnections) {
|
||||
const sessionMetrics = connection.session.getRuntimeMetrics();
|
||||
@@ -1156,7 +1169,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
sessionMetrics.checkoutDiffFallbackRefreshTargetCount;
|
||||
terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount;
|
||||
terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount;
|
||||
terminalStreamCount += sessionMetrics.terminalStreamCount;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1166,7 +1178,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
checkoutDiffFallbackRefreshTargetCount,
|
||||
terminalDirectorySubscriptionCount,
|
||||
terminalSubscriptionCount,
|
||||
terminalStreamCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -297,27 +297,13 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
send: () => {
|
||||
sendAt = Date.now();
|
||||
},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: (listener) => {
|
||||
outputListener = (chunk) =>
|
||||
listener({
|
||||
data: chunk.data,
|
||||
startOffset: 0,
|
||||
endOffset: chunk.data.length,
|
||||
replay: false,
|
||||
});
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
outputListener = null;
|
||||
},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
subscribe: (listener) => {
|
||||
outputListener = (chunk) => listener({ type: "output", data: chunk.data });
|
||||
return () => {
|
||||
outputListener = null;
|
||||
};
|
||||
},
|
||||
getOutputOffset: () => 0,
|
||||
onExit: () => () => {},
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
@@ -398,18 +384,10 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit: () => () => {},
|
||||
subscribeRaw: () => ({
|
||||
unsubscribe: () => {},
|
||||
replayedFrom: 0,
|
||||
currentOffset: 0,
|
||||
earliestAvailableOffset: 0,
|
||||
reset: false,
|
||||
}),
|
||||
getOutputOffset: () => 1,
|
||||
getState: () => ({
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
grid: [],
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "$" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 0 },
|
||||
}),
|
||||
|
||||
@@ -383,9 +383,9 @@ function buildTerminalTimelineItem(input: {
|
||||
}
|
||||
|
||||
async function waitForTerminalBootstrapReadiness(
|
||||
terminal: Pick<TerminalSession, "getOutputOffset" | "subscribeRaw">,
|
||||
terminal: Pick<TerminalSession, "getState" | "subscribe">,
|
||||
): Promise<void> {
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
if (terminalHasOutput(terminal.getState())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,12 +410,14 @@ async function waitForTerminalBootstrapReadiness(
|
||||
resolve();
|
||||
};
|
||||
|
||||
const rawSubscription = terminal.subscribeRaw(() => {
|
||||
unsubscribe = terminal.subscribe((message) => {
|
||||
if (message.type !== "output") {
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
unsubscribe = rawSubscription.unsubscribe;
|
||||
|
||||
if (terminal.getOutputOffset() > 0) {
|
||||
if (terminalHasOutput(terminal.getState())) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
@@ -424,6 +426,17 @@ async function waitForTerminalBootstrapReadiness(
|
||||
});
|
||||
}
|
||||
|
||||
function terminalHasOutput(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||
for (const row of [...state.scrollback, ...state.grid]) {
|
||||
for (const cell of row) {
|
||||
if (cell.char.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runWorktreeTerminalBootstrap(
|
||||
options: RunAsyncWorktreeBootstrapOptions,
|
||||
runtimeEnv: WorktreeRuntimeEnv,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BinaryMuxChannel,
|
||||
TerminalBinaryFlags,
|
||||
TerminalBinaryMessageType,
|
||||
asUint8Array,
|
||||
decodeBinaryMuxFrame,
|
||||
encodeBinaryMuxFrame,
|
||||
} from "./binary-mux.js";
|
||||
|
||||
describe("binary mux frame codec", () => {
|
||||
it("encodes and decodes round trip", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||||
streamId: 42,
|
||||
offset: 1234,
|
||||
flags: TerminalBinaryFlags.Replay,
|
||||
payload,
|
||||
});
|
||||
|
||||
const decoded = decodeBinaryMuxFrame(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded!.channel).toBe(BinaryMuxChannel.Terminal);
|
||||
expect(decoded!.messageType).toBe(TerminalBinaryMessageType.OutputUtf8);
|
||||
expect(decoded!.streamId).toBe(42);
|
||||
expect(decoded!.offset).toBe(1234);
|
||||
expect(decoded!.flags).toBe(TerminalBinaryFlags.Replay);
|
||||
expect(Array.from(decoded!.payload ?? [])).toEqual(Array.from(payload));
|
||||
});
|
||||
|
||||
it("rejects malformed frame payload length", () => {
|
||||
const encoded = encodeBinaryMuxFrame({
|
||||
channel: BinaryMuxChannel.Terminal,
|
||||
messageType: TerminalBinaryMessageType.InputUtf8,
|
||||
streamId: 1,
|
||||
offset: 0,
|
||||
payload: new Uint8Array([1, 2, 3]),
|
||||
});
|
||||
const tampered = encoded.slice(0, encoded.byteLength - 1);
|
||||
expect(decodeBinaryMuxFrame(tampered)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts UTF-8 string payloads to bytes", () => {
|
||||
const bytes = asUint8Array("hello");
|
||||
expect(bytes).not.toBeNull();
|
||||
expect(Array.from(bytes ?? [])).toEqual(Array.from(new TextEncoder().encode("hello")));
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
const MUX_MAGIC_1 = 0x50; // 'P'
|
||||
const MUX_MAGIC_2 = 0x58; // 'X'
|
||||
const MUX_VERSION = 1;
|
||||
const HEADER_SIZE = 24;
|
||||
const UINT32_MAX = 0xffffffff;
|
||||
const TWO_POW_32 = 0x1_0000_0000;
|
||||
|
||||
export const BinaryMuxConstants = {
|
||||
magic1: MUX_MAGIC_1,
|
||||
magic2: MUX_MAGIC_2,
|
||||
version: MUX_VERSION,
|
||||
headerSize: HEADER_SIZE,
|
||||
} as const;
|
||||
|
||||
export const enum BinaryMuxChannel {
|
||||
Terminal = 1,
|
||||
FileTransfer = 2,
|
||||
}
|
||||
|
||||
export const enum TerminalBinaryMessageType {
|
||||
InputUtf8 = 1,
|
||||
OutputUtf8 = 2,
|
||||
Ack = 3,
|
||||
}
|
||||
|
||||
export const enum TerminalBinaryFlags {
|
||||
Replay = 1,
|
||||
}
|
||||
|
||||
export interface BinaryMuxFrame {
|
||||
channel: number;
|
||||
messageType: number;
|
||||
streamId: number;
|
||||
offset: number;
|
||||
flags?: number;
|
||||
payload?: Uint8Array;
|
||||
}
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data.charCodeAt(i) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLikelyBinaryMuxFrame(data: Uint8Array): boolean {
|
||||
if (data.byteLength < HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
return data[0] === MUX_MAGIC_1 && data[1] === MUX_MAGIC_2 && data[2] === MUX_VERSION;
|
||||
}
|
||||
|
||||
export function encodeBinaryMuxFrame(frame: BinaryMuxFrame): Uint8Array {
|
||||
const payload = frame.payload ?? new Uint8Array(0);
|
||||
const out = new Uint8Array(HEADER_SIZE + payload.byteLength);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
const flags = frame.flags ?? 0;
|
||||
|
||||
if (!Number.isFinite(frame.streamId) || frame.streamId < 0 || frame.streamId > UINT32_MAX) {
|
||||
throw new Error(`Invalid streamId: ${frame.streamId}`);
|
||||
}
|
||||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
||||
throw new Error(`Invalid offset: ${frame.offset}`);
|
||||
}
|
||||
|
||||
const offsetHi = Math.floor(frame.offset / TWO_POW_32);
|
||||
const offsetLo = frame.offset >>> 0;
|
||||
if (offsetHi > UINT32_MAX) {
|
||||
throw new Error(`Offset too large: ${frame.offset}`);
|
||||
}
|
||||
|
||||
view.setUint8(0, MUX_MAGIC_1);
|
||||
view.setUint8(1, MUX_MAGIC_2);
|
||||
view.setUint8(2, MUX_VERSION);
|
||||
view.setUint8(3, frame.channel & 0xff);
|
||||
view.setUint8(4, frame.messageType & 0xff);
|
||||
view.setUint8(5, flags & 0xff);
|
||||
view.setUint8(6, 0);
|
||||
view.setUint8(7, 0);
|
||||
view.setUint32(8, frame.streamId >>> 0);
|
||||
view.setUint32(12, offsetHi >>> 0);
|
||||
view.setUint32(16, offsetLo);
|
||||
view.setUint32(20, payload.byteLength >>> 0);
|
||||
|
||||
out.set(payload, HEADER_SIZE);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeBinaryMuxFrame(data: Uint8Array): BinaryMuxFrame | null {
|
||||
if (!isLikelyBinaryMuxFrame(data)) {
|
||||
return null;
|
||||
}
|
||||
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const payloadLength = view.getUint32(20);
|
||||
if (payloadLength !== data.byteLength - HEADER_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const offsetHi = view.getUint32(12);
|
||||
const offsetLo = view.getUint32(16);
|
||||
const offset = offsetHi * TWO_POW_32 + offsetLo;
|
||||
|
||||
return {
|
||||
channel: view.getUint8(3),
|
||||
messageType: view.getUint8(4),
|
||||
flags: view.getUint8(5),
|
||||
streamId: view.getUint32(8),
|
||||
offset,
|
||||
payload: data.subarray(HEADER_SIZE),
|
||||
};
|
||||
}
|
||||
@@ -1139,21 +1139,6 @@ export const KillTerminalRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AttachTerminalStreamRequestSchema = z.object({
|
||||
type: z.literal("attach_terminal_stream_request"),
|
||||
terminalId: z.string(),
|
||||
resumeOffset: z.number().int().nonnegative().optional(),
|
||||
rows: z.number().int().positive().optional(),
|
||||
cols: z.number().int().positive().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const DetachTerminalStreamRequestSchema = z.object({
|
||||
type: z.literal("detach_terminal_stream_request"),
|
||||
streamId: z.number().int().nonnegative(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
VoiceAudioChunkMessageSchema,
|
||||
AbortRequestMessageSchema,
|
||||
@@ -1219,8 +1204,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
UnsubscribeTerminalRequestSchema,
|
||||
TerminalInputSchema,
|
||||
KillTerminalRequestSchema,
|
||||
AttachTerminalStreamRequestSchema,
|
||||
DetachTerminalStreamRequestSchema,
|
||||
]);
|
||||
|
||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||
@@ -2112,24 +2095,43 @@ const TerminalInfoSchema = z.object({
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
const TerminalCellSchema = z.object({
|
||||
char: z.string(),
|
||||
fg: z.number().optional(),
|
||||
bg: z.number().optional(),
|
||||
fgMode: z.number().optional(),
|
||||
bgMode: z.number().optional(),
|
||||
bold: z.boolean().optional(),
|
||||
italic: z.boolean().optional(),
|
||||
underline: z.boolean().optional(),
|
||||
});
|
||||
export const TerminalCellSchema = z
|
||||
.object({
|
||||
char: z.string(),
|
||||
fg: z.number().optional(),
|
||||
bg: z.number().optional(),
|
||||
fgMode: z.number().optional(),
|
||||
bgMode: z.number().optional(),
|
||||
bold: z.boolean().optional(),
|
||||
italic: z.boolean().optional(),
|
||||
underline: z.boolean().optional(),
|
||||
dim: z.boolean().optional(),
|
||||
inverse: z.boolean().optional(),
|
||||
strikethrough: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const TerminalStateSchema = z.object({
|
||||
rows: z.number(),
|
||||
cols: z.number(),
|
||||
grid: z.array(z.array(TerminalCellSchema)),
|
||||
scrollback: z.array(z.array(TerminalCellSchema)),
|
||||
cursor: z.object({ row: z.number(), col: z.number() }),
|
||||
});
|
||||
export const TerminalCursorStyleSchema = z.enum(["block", "underline", "bar"]);
|
||||
|
||||
export const TerminalCursorSchema = z
|
||||
.object({
|
||||
row: z.number(),
|
||||
col: z.number(),
|
||||
hidden: z.boolean().optional(),
|
||||
style: TerminalCursorStyleSchema.optional(),
|
||||
blink: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TerminalStateSchema = z
|
||||
.object({
|
||||
rows: z.number(),
|
||||
cols: z.number(),
|
||||
grid: z.array(z.array(TerminalCellSchema)),
|
||||
scrollback: z.array(z.array(TerminalCellSchema)),
|
||||
cursor: TerminalCursorSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ListTerminalsResponseSchema = z.object({
|
||||
type: z.literal("list_terminals_response"),
|
||||
@@ -2159,20 +2161,19 @@ export const CreateTerminalResponseSchema = z.object({
|
||||
|
||||
export const SubscribeTerminalResponseSchema = z.object({
|
||||
type: z.literal("subscribe_terminal_response"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
state: TerminalStateSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalOutputSchema = z.object({
|
||||
type: z.literal("terminal_output"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
state: TerminalStateSchema,
|
||||
}),
|
||||
payload: z.union([
|
||||
z.object({
|
||||
terminalId: z.string(),
|
||||
slot: z.number().int().min(0).max(255),
|
||||
error: z.null(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
terminalId: z.string(),
|
||||
error: z.string(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export const KillTerminalResponseSchema = z.object({
|
||||
@@ -2184,33 +2185,9 @@ export const KillTerminalResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const AttachTerminalStreamResponseSchema = z.object({
|
||||
type: z.literal("attach_terminal_stream_response"),
|
||||
payload: z.object({
|
||||
terminalId: z.string(),
|
||||
streamId: z.number().int().nonnegative().nullable(),
|
||||
replayedFrom: z.number().int().nonnegative(),
|
||||
currentOffset: z.number().int().nonnegative(),
|
||||
earliestAvailableOffset: z.number().int().nonnegative(),
|
||||
reset: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const DetachTerminalStreamResponseSchema = z.object({
|
||||
type: z.literal("detach_terminal_stream_response"),
|
||||
payload: z.object({
|
||||
streamId: z.number().int().nonnegative(),
|
||||
success: z.boolean(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const TerminalStreamExitSchema = z.object({
|
||||
type: z.literal("terminal_stream_exit"),
|
||||
payload: z.object({
|
||||
streamId: z.number().int().nonnegative(),
|
||||
terminalId: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -2278,10 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
TerminalsChangedSchema,
|
||||
CreateTerminalResponseSchema,
|
||||
SubscribeTerminalResponseSchema,
|
||||
TerminalOutputSchema,
|
||||
KillTerminalResponseSchema,
|
||||
AttachTerminalStreamResponseSchema,
|
||||
DetachTerminalStreamResponseSchema,
|
||||
TerminalStreamExitSchema,
|
||||
]);
|
||||
|
||||
@@ -2415,13 +2389,12 @@ export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSc
|
||||
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
|
||||
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
|
||||
export type TerminalInput = z.infer<typeof TerminalInputSchema>;
|
||||
export type TerminalOutput = z.infer<typeof TerminalOutputSchema>;
|
||||
export type TerminalCell = z.infer<typeof TerminalCellSchema>;
|
||||
export type TerminalCursorStyle = z.infer<typeof TerminalCursorStyleSchema>;
|
||||
export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
|
||||
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||
export type AttachTerminalStreamRequest = z.infer<typeof AttachTerminalStreamRequestSchema>;
|
||||
export type AttachTerminalStreamResponse = z.infer<typeof AttachTerminalStreamResponseSchema>;
|
||||
export type DetachTerminalStreamRequest = z.infer<typeof DetachTerminalStreamRequestSchema>;
|
||||
export type DetachTerminalStreamResponse = z.infer<typeof DetachTerminalStreamResponseSchema>;
|
||||
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -19,6 +19,17 @@ describe("encodeTerminalKeyInput", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Backspace" })).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("encodes shift+enter as kitty keyboard protocol CSI u", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("encodes enter with modifiers using CSI u", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", meta: true })).toBe("\x1b[13;9u");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\x1b[13;6u");
|
||||
});
|
||||
|
||||
it("returns empty string for unsupported keys", () => {
|
||||
expect(encodeTerminalKeyInput({ key: "UnidentifiedKey" })).toBe("");
|
||||
});
|
||||
|
||||
@@ -9,13 +9,14 @@ export interface TerminalKeyInput {
|
||||
function modifierParam(input: TerminalKeyInput): number {
|
||||
let value = 1;
|
||||
if (input.shift) value += 1;
|
||||
if (input.alt || input.meta) value += 2;
|
||||
if (input.alt) value += 2;
|
||||
if (input.ctrl) value += 4;
|
||||
if (input.meta) value += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
||||
return input.alt || input.meta ? `\x1b${sequence}` : sequence;
|
||||
return input.alt ? `\x1b${sequence}` : sequence;
|
||||
}
|
||||
|
||||
function encodePrintableKey(input: TerminalKeyInput): string {
|
||||
@@ -85,8 +86,13 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
return applyAltLikePrefix("\r", input);
|
||||
case "Enter": {
|
||||
const mod = modifierParam(input);
|
||||
if (mod > 1) {
|
||||
return `\x1b[13;${mod}u`;
|
||||
}
|
||||
return "\r";
|
||||
}
|
||||
case "Tab":
|
||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
||||
return "\x1b[Z";
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalResizePayload,
|
||||
decodeTerminalSnapshotPayload,
|
||||
decodeTerminalStreamFrame,
|
||||
encodeTerminalResizePayload,
|
||||
encodeTerminalSnapshotPayload,
|
||||
encodeTerminalStreamFrame,
|
||||
} from "./terminal-stream-protocol.js";
|
||||
|
||||
describe("terminal stream protocol", () => {
|
||||
it("encodes output frames as opcode plus slot plus payload", () => {
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const encoded = encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(encoded[0]).toBe(TerminalStreamOpcode.Output);
|
||||
expect(encoded[1]).toBe(7);
|
||||
expect(Array.from(encoded.subarray(2))).toEqual(Array.from(payload));
|
||||
|
||||
const decoded = decodeTerminalStreamFrame(encoded);
|
||||
expect(decoded).toEqual({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
slot: 7,
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips resize payloads", () => {
|
||||
const payload = encodeTerminalResizePayload({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
|
||||
expect(decodeTerminalResizePayload(payload)).toEqual({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips snapshot payloads", () => {
|
||||
const state = {
|
||||
rows: 1,
|
||||
cols: 2,
|
||||
grid: [[{ char: "A" }, { char: "B" }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 2 },
|
||||
};
|
||||
|
||||
const payload = encodeTerminalSnapshotPayload(state);
|
||||
expect(decodeTerminalSnapshotPayload(payload)).toEqual(state);
|
||||
});
|
||||
|
||||
it("rejects unknown opcodes", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([0xff, 0x01, 0x02]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects frames without a slot byte", () => {
|
||||
expect(decodeTerminalStreamFrame(new Uint8Array([TerminalStreamOpcode.Output]))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON payloads", () => {
|
||||
const malformed = new TextEncoder().encode("{");
|
||||
|
||||
expect(decodeTerminalResizePayload(malformed)).toBeNull();
|
||||
expect(decodeTerminalSnapshotPayload(malformed)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects invalid resize and snapshot shapes", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: "24", cols: 80 })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A" }]],
|
||||
scrollback: [],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown fields in resize and snapshot payloads", () => {
|
||||
expect(
|
||||
decodeTerminalResizePayload(
|
||||
new TextEncoder().encode(JSON.stringify({ rows: 24, cols: 80, extra: true })),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeTerminalSnapshotPayload(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
grid: [[{ char: "A", extra: true }]],
|
||||
scrollback: [],
|
||||
cursor: { row: 0, col: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
import { TerminalStateSchema } from "./messages.js";
|
||||
|
||||
export const TerminalStreamResizeSchema = z
|
||||
.object({
|
||||
rows: z.number().int().positive(),
|
||||
cols: z.number().int().positive(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TerminalStreamOpcode = {
|
||||
Output: 0x01,
|
||||
Input: 0x02,
|
||||
Resize: 0x03,
|
||||
Snapshot: 0x04,
|
||||
} as const;
|
||||
|
||||
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: number;
|
||||
payload: Uint8Array;
|
||||
};
|
||||
|
||||
export function asUint8Array(data: unknown): Uint8Array | null {
|
||||
if (typeof data === "string") {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(data);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return new Uint8Array(Buffer.from(data, "utf8"));
|
||||
}
|
||||
const out = new Uint8Array(data.length);
|
||||
for (let index = 0; index < data.length; index += 1) {
|
||||
out[index] = data.charCodeAt(index) & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
return (
|
||||
value === TerminalStreamOpcode.Output ||
|
||||
value === TerminalStreamOpcode.Input ||
|
||||
value === TerminalStreamOpcode.Resize ||
|
||||
value === TerminalStreamOpcode.Snapshot
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeTerminalStreamFrame(input: {
|
||||
opcode: TerminalStreamOpcode;
|
||||
slot: number;
|
||||
payload?: Uint8Array | ArrayBuffer | string;
|
||||
}): Uint8Array {
|
||||
const payload = asUint8Array(input.payload ?? new Uint8Array(0)) ?? new Uint8Array(0);
|
||||
const bytes = new Uint8Array(2 + payload.byteLength);
|
||||
bytes[0] = input.opcode;
|
||||
bytes[1] = input.slot & 0xff;
|
||||
bytes.set(payload, 2);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function decodeTerminalStreamFrame(bytes: Uint8Array): TerminalStreamFrame | null {
|
||||
if (bytes.byteLength < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = bytes[0];
|
||||
if (!isTerminalStreamOpcode(opcode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
slot: bytes[1],
|
||||
payload: bytes.subarray(2),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeTerminalSnapshotPayload(
|
||||
state: z.infer<typeof TerminalStateSchema>,
|
||||
): Uint8Array {
|
||||
return encodeJsonPayload(state);
|
||||
}
|
||||
|
||||
export function decodeTerminalSnapshotPayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStateSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStateSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
export function encodeTerminalResizePayload(
|
||||
input: z.infer<typeof TerminalStreamResizeSchema>,
|
||||
): Uint8Array {
|
||||
return encodeJsonPayload(input);
|
||||
}
|
||||
|
||||
export function decodeTerminalResizePayload(
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof TerminalStreamResizeSchema> | null {
|
||||
const parsed = decodeJsonPayload(bytes);
|
||||
const result = TerminalStreamResizeSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function encodeJsonPayload(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeJsonPayload(bytes: Uint8Array): unknown {
|
||||
try {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,23 @@ async function waitForLines(
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForState(
|
||||
session: TerminalSession,
|
||||
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ReturnType<TerminalSession["getState"]>> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const state = session.getState();
|
||||
if (predicate(state)) {
|
||||
return state;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for terminal state predicate to match");
|
||||
}
|
||||
|
||||
describe("Terminal", () => {
|
||||
const sessions: TerminalSession[] = [];
|
||||
const temporaryDirs: string[] = [];
|
||||
@@ -385,10 +402,29 @@ describe("Terminal", () => {
|
||||
expect(state.grid.length).toBe(10);
|
||||
expect(state.grid[0].length).toBe(40);
|
||||
});
|
||||
|
||||
it("exposes the current size without extracting full state", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(session.getSize()).toEqual({ rows: 24, cols: 80 });
|
||||
|
||||
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(session.getSize()).toEqual({ rows: 10, cols: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("receives full state on initial subscription", async () => {
|
||||
it("receives a snapshot on initial subscription", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
@@ -405,12 +441,12 @@ describe("Terminal", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messages[0].type).toBe("full");
|
||||
expect(messages[0].type).toBe("snapshot");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("receives messages on updates", async () => {
|
||||
it("receives output messages on updates without replay snapshots", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
@@ -426,17 +462,72 @@ describe("Terminal", () => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "input", data: "echo test\r" });
|
||||
|
||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messages.some((message) => message.type === "output")).toBe(true);
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not emit snapshot messages for resize-only updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
const messages: Array<{ type: string }> = [];
|
||||
const unsubscribe = session.subscribe((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "resize", rows: 30, cols: 100 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||
expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("emits output only after getState reflects the new data", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
const outputSeenInState = new Promise<boolean>((resolve) => {
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type !== "output" || !message.data.includes("state-after-output")) {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
const stateText = getLines(session.getState()).join("\n");
|
||||
resolve(stateText.includes("state-after-output"));
|
||||
});
|
||||
});
|
||||
|
||||
session.send({ type: "input", data: "echo state-after-output\r" });
|
||||
expect(await outputSeenInState).toBe(true);
|
||||
});
|
||||
|
||||
it("unsubscribe stops receiving messages", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
@@ -463,8 +554,8 @@ describe("Terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("raw output stream", () => {
|
||||
it("streams raw output chunks with byte offsets", async () => {
|
||||
describe("stream snapshots", () => {
|
||||
it("streams raw output messages without replay metadata", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
@@ -475,28 +566,23 @@ describe("Terminal", () => {
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
|
||||
const sub = session.subscribeRaw((chunk) => {
|
||||
chunks.push({
|
||||
data: chunk.data,
|
||||
startOffset: chunk.startOffset,
|
||||
endOffset: chunk.endOffset,
|
||||
});
|
||||
const outputMessages: string[] = [];
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type !== "output") {
|
||||
return;
|
||||
}
|
||||
outputMessages.push(message.data);
|
||||
});
|
||||
|
||||
session.send({ type: "input", data: "echo raw-stream\r" });
|
||||
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
|
||||
}
|
||||
expect(outputMessages.join("")).toContain("raw-stream");
|
||||
|
||||
sub.unsubscribe();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("replays buffered output from offset", async () => {
|
||||
it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
@@ -507,21 +593,9 @@ describe("Terminal", () => {
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
|
||||
const firstSub = session.subscribeRaw((chunk) => {
|
||||
seen.push({
|
||||
data: chunk.data,
|
||||
endOffset: chunk.endOffset,
|
||||
replay: chunk.replay,
|
||||
});
|
||||
});
|
||||
|
||||
session.send({ type: "input", data: "echo before-detach\r" });
|
||||
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
|
||||
|
||||
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
|
||||
firstSub.unsubscribe();
|
||||
|
||||
session.send({ type: "input", data: "echo after-detach\r" });
|
||||
await waitForLines(session, [
|
||||
"$ echo before-detach",
|
||||
@@ -531,20 +605,26 @@ describe("Terminal", () => {
|
||||
"$",
|
||||
]);
|
||||
|
||||
const replayed: string[] = [];
|
||||
const secondSub = session.subscribeRaw(
|
||||
(chunk) => {
|
||||
if (chunk.replay) {
|
||||
replayed.push(chunk.data);
|
||||
}
|
||||
},
|
||||
{ fromOffset: resumeOffset },
|
||||
);
|
||||
let snapshotText = "";
|
||||
const unsubscribe = session.subscribe((message) => {
|
||||
if (message.type !== "snapshot") {
|
||||
return;
|
||||
}
|
||||
snapshotText = [...message.state.scrollback, ...message.state.grid]
|
||||
.map((row) =>
|
||||
row
|
||||
.map((cell) => cell.char)
|
||||
.join("")
|
||||
.trimEnd(),
|
||||
)
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
const replayText = replayed.join("");
|
||||
expect(replayText).toContain("after-detach");
|
||||
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
|
||||
secondSub.unsubscribe();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(snapshotText).toContain("before-detach");
|
||||
expect(snapshotText).toContain("after-detach");
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -572,6 +652,35 @@ describe("Terminal", () => {
|
||||
expect(typeof state.cursor.col).toBe("number");
|
||||
});
|
||||
|
||||
it("captures cursor presentation modes emitted by terminal apps", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" });
|
||||
|
||||
const state = await waitForState(
|
||||
session,
|
||||
(current) =>
|
||||
current.cursor.style === "block" &&
|
||||
current.cursor.blink === false &&
|
||||
current.cursor.hidden === true,
|
||||
);
|
||||
|
||||
expect(state.cursor).toMatchObject({
|
||||
style: "block",
|
||||
blink: false,
|
||||
hidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("grid cells have char and color attributes", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
|
||||
@@ -4,56 +4,11 @@ import { randomUUID } from "crypto";
|
||||
import { chmodSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const require = createRequire(import.meta.url);
|
||||
let nodePtySpawnHelperChecked = false;
|
||||
const STATE_BROADCAST_INTERVAL_MS = 33;
|
||||
|
||||
export interface Cell {
|
||||
char: string;
|
||||
fg: number | undefined;
|
||||
bg: number | undefined;
|
||||
fgMode?: number; // 0=default, 1=16 ANSI, 2=256, 3=RGB
|
||||
bgMode?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export interface Pos {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
rows: number;
|
||||
cols: number;
|
||||
grid: Cell[][];
|
||||
scrollback: Cell[][];
|
||||
cursor: Pos;
|
||||
}
|
||||
|
||||
export interface CellChange {
|
||||
row: number;
|
||||
col: number;
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export interface TerminalRawChunk {
|
||||
data: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
replay: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalRawSubscriptionResult {
|
||||
unsubscribe: () => void;
|
||||
replayedFrom: number;
|
||||
currentOffset: number;
|
||||
earliestAvailableOffset: number;
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
@@ -61,8 +16,8 @@ export type ClientMessage =
|
||||
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "full"; state: TerminalState }
|
||||
| { type: "diff"; changes: CellChange[]; cursor: Pos };
|
||||
| { type: "output"; data: string }
|
||||
| { type: "snapshot"; state: TerminalState };
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
@@ -71,11 +26,7 @@ export interface TerminalSession {
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
onExit(listener: () => void): () => void;
|
||||
subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number },
|
||||
): TerminalRawSubscriptionResult;
|
||||
getOutputOffset(): number;
|
||||
getSize(): { rows: number; cols: number };
|
||||
getState(): TerminalState;
|
||||
kill(): void;
|
||||
}
|
||||
@@ -156,7 +107,7 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
|
||||
}
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
if (!line) {
|
||||
@@ -188,17 +139,20 @@ function extractCell(terminal: TerminalType, row: number, col: number): Cell {
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
dim: cell.isDim() !== 0,
|
||||
inverse: cell.isInverse() !== 0,
|
||||
strikethrough: cell.isStrikethrough() !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractGrid(terminal: TerminalType): Cell[][] {
|
||||
const grid: Cell[][] = [];
|
||||
function extractGrid(terminal: TerminalType): TerminalCell[][] {
|
||||
const grid: TerminalCell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// Visible viewport starts at baseY
|
||||
const baseY = buffer.baseY;
|
||||
|
||||
for (let row = 0; row < terminal.rows; row++) {
|
||||
const rowCells: Cell[] = [];
|
||||
const rowCells: TerminalCell[] = [];
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
rowCells.push(extractCell(terminal, baseY + row, col));
|
||||
}
|
||||
@@ -208,15 +162,15 @@ function extractGrid(terminal: TerminalType): Cell[][] {
|
||||
return grid;
|
||||
}
|
||||
|
||||
function extractScrollback(terminal: TerminalType): Cell[][] {
|
||||
const scrollback: Cell[][] = [];
|
||||
function extractScrollback(terminal: TerminalType): TerminalCell[][] {
|
||||
const scrollback: TerminalCell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// baseY is the first row of the visible viewport (0-indexed)
|
||||
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
|
||||
const scrollbackLines = buffer.baseY;
|
||||
|
||||
for (let row = 0; row < scrollbackLines; row++) {
|
||||
const rowCells: Cell[] = [];
|
||||
const rowCells: TerminalCell[] = [];
|
||||
const line = buffer.getLine(row);
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
if (line) {
|
||||
@@ -237,6 +191,9 @@ function extractScrollback(terminal: TerminalType): Cell[][] {
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
dim: cell.isDim() !== 0,
|
||||
inverse: cell.isInverse() !== 0,
|
||||
strikethrough: cell.isStrikethrough() !== 0,
|
||||
});
|
||||
} else {
|
||||
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
||||
@@ -251,6 +208,28 @@ function extractScrollback(terminal: TerminalType): Cell[][] {
|
||||
return scrollback;
|
||||
}
|
||||
|
||||
function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
||||
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: terminal.buffer.active.cursorY,
|
||||
col: terminal.buffer.active.cursorX,
|
||||
...(hidden ? { hidden: true } : {}),
|
||||
...(normalizedCursorStyle ? { style: normalizedCursorStyle } : {}),
|
||||
...(typeof cursorBlink === "boolean" ? { blink: cursorBlink } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
@@ -263,21 +242,10 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
|
||||
const id = randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
||||
const exitListeners = new Set<() => void>();
|
||||
const rawOutputChunks: Array<{
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
data: string;
|
||||
}> = [];
|
||||
const maxRawBufferBytes = 8 * 1024 * 1024;
|
||||
let rawBufferBytes = 0;
|
||||
let outputOffset = 0;
|
||||
let killed = false;
|
||||
let disposed = false;
|
||||
let exitEmitted = false;
|
||||
let stateBroadcastScheduled = false;
|
||||
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -302,6 +270,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
},
|
||||
});
|
||||
|
||||
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
|
||||
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
||||
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
||||
ptyProcess.write("\x1b[?62;4;22c");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function emitExit(): void {
|
||||
if (exitEmitted) {
|
||||
return;
|
||||
@@ -322,101 +299,21 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
if (stateBroadcastTimer) {
|
||||
clearTimeout(stateBroadcastTimer);
|
||||
stateBroadcastTimer = null;
|
||||
}
|
||||
stateBroadcastScheduled = false;
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
rawListeners.clear();
|
||||
exitListeners.clear();
|
||||
}
|
||||
|
||||
function emitStateToListeners(): void {
|
||||
stateBroadcastScheduled = false;
|
||||
if (disposed || killed || listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
const state = getState();
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "full", state });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStateBroadcast(): void {
|
||||
if (disposed || killed || listeners.size === 0 || stateBroadcastScheduled) {
|
||||
return;
|
||||
}
|
||||
stateBroadcastScheduled = true;
|
||||
stateBroadcastTimer = setTimeout(() => {
|
||||
stateBroadcastTimer = null;
|
||||
emitStateToListeners();
|
||||
}, STATE_BROADCAST_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function appendRawOutputChunk(data: string): void {
|
||||
const chunkBytes = Buffer.byteLength(data);
|
||||
if (chunkBytes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startOffset = outputOffset;
|
||||
const endOffset = startOffset + chunkBytes;
|
||||
outputOffset = endOffset;
|
||||
|
||||
rawOutputChunks.push({
|
||||
startOffset,
|
||||
endOffset,
|
||||
data,
|
||||
});
|
||||
rawBufferBytes += chunkBytes;
|
||||
|
||||
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
|
||||
const removed = rawOutputChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
rawBufferBytes -= removed.endOffset - removed.startOffset;
|
||||
}
|
||||
|
||||
const chunk: TerminalRawChunk = {
|
||||
data,
|
||||
startOffset,
|
||||
endOffset,
|
||||
replay: false,
|
||||
};
|
||||
for (const listener of rawListeners) {
|
||||
listener(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function getEarliestAvailableOffset(): number {
|
||||
const earliest = rawOutputChunks[0];
|
||||
return earliest ? earliest.startOffset : outputOffset;
|
||||
}
|
||||
|
||||
function alignOffsetToChunkBoundary(offset: number): number {
|
||||
if (offset === outputOffset) {
|
||||
return offset;
|
||||
}
|
||||
for (const chunk of rawOutputChunks) {
|
||||
if (offset === chunk.startOffset || offset === chunk.endOffset) {
|
||||
return offset;
|
||||
}
|
||||
if (offset > chunk.startOffset && offset < chunk.endOffset) {
|
||||
return chunk.startOffset;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
appendRawOutputChunk(data);
|
||||
terminal.write(data, () => {
|
||||
scheduleStateBroadcast();
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "output", data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,10 +329,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
cols: terminal.cols,
|
||||
grid: extractGrid(terminal),
|
||||
scrollback: extractScrollback(terminal),
|
||||
cursor: {
|
||||
row: terminal.buffer.active.cursorY,
|
||||
col: terminal.buffer.active.cursorX,
|
||||
},
|
||||
cursor: extractCursorState(terminal),
|
||||
};
|
||||
}
|
||||
|
||||
function getSize(): { rows: number; cols: number } {
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -460,10 +361,9 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||
listeners.add(listener);
|
||||
|
||||
// Send initial full state
|
||||
queueMicrotask(() => {
|
||||
if (listeners.has(listener)) {
|
||||
listener({ type: "full", state: getState() });
|
||||
terminal.write("", () => {
|
||||
if (!disposed && listeners.has(listener)) {
|
||||
listener({ type: "snapshot", state: getState() });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -490,45 +390,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number },
|
||||
): TerminalRawSubscriptionResult {
|
||||
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
|
||||
const earliestAvailableOffset = getEarliestAvailableOffset();
|
||||
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
|
||||
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
|
||||
const reset = replayedFrom !== requestedOffset;
|
||||
|
||||
for (const chunk of rawOutputChunks) {
|
||||
if (chunk.endOffset <= replayedFrom) {
|
||||
continue;
|
||||
}
|
||||
listener({
|
||||
data: chunk.data,
|
||||
startOffset: chunk.startOffset,
|
||||
endOffset: chunk.endOffset,
|
||||
replay: true,
|
||||
});
|
||||
}
|
||||
|
||||
rawListeners.add(listener);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
rawListeners.delete(listener);
|
||||
},
|
||||
replayedFrom,
|
||||
currentOffset: outputOffset,
|
||||
earliestAvailableOffset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
function getOutputOffset(): number {
|
||||
return outputOffset;
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
@@ -548,8 +409,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
subscribeRaw,
|
||||
getOutputOffset,
|
||||
getSize,
|
||||
getState,
|
||||
kill,
|
||||
};
|
||||
|
||||
@@ -21,25 +21,25 @@ const downloadOptions: DownloadOption[] = [
|
||||
{
|
||||
platform: "mac-silicon",
|
||||
label: "Mac",
|
||||
href: `${releaseBase}/Paseo_${desktopVersion}_aarch64.dmg`,
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "mac-intel",
|
||||
label: "Mac Intel",
|
||||
href: `${releaseBase}/Paseo_${desktopVersion}_x86_64.dmg`,
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}.dmg`,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
label: "Windows",
|
||||
href: `${releaseBase}/Paseo_${desktopVersion}_x64-setup.exe`,
|
||||
href: `${releaseBase}/Paseo-Setup-${desktopVersion}.exe`,
|
||||
icon: WindowsIcon,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
label: "Linux",
|
||||
href: `${releaseBase}/Paseo_${desktopVersion}_amd64.AppImage`,
|
||||
href: `${releaseBase}/Paseo-${desktopVersion}.AppImage`,
|
||||
icon: LinuxIcon,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user