Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c28d917a4b Simplify prompt shortcut handling 2026-03-24 11:14:13 +07:00
Mohamed Boudra 2f6b8cea01 Add number key shortcuts for agent prompts 2026-03-23 21:56:39 +07:00
Mohamed Boudra 988c432ded fix(release): resolve android-v* tags to release tag in APK workflow
android-v0.1.32 must resolve to v0.1.32 for the release upload step,
otherwise it looks for a non-existent release and times out.
2026-03-23 15:59:21 +07:00
Mohamed Boudra dce4e668f1 fix(release): set EP_GH_IGNORE_TIME to allow asset overwrites on rebuilds
electron-builder skips uploading to releases older than 2 hours.
This breaks all rebuild workflows since the release already exists.
2026-03-23 15:58:27 +07:00
Mohamed Boudra 2c69008a4a fix(desktop): use explicit artifact name for Linux to prevent scoped-name conflicts
The scoped package name @getpaseo/desktop was leaking into the tar.gz
artifact name. GitHub sanitizes the / to . on upload, causing a name
mismatch that prevents electron-builder's overwrite logic from finding
the existing asset on rebuild.
2026-03-23 15:53:34 +07:00
Mohamed Boudra 19452c2742 fix(release): add android retry tags and ban workflow_dispatch for rebuilds
workflow_dispatch checks out the tag ref, not main — so build fixes
on main never get picked up. Always use retry tags instead.
2026-03-23 15:49:38 +07:00
Mohamed Boudra 956828fa55 Fix terminal stream stalls after resize 2026-03-23 15:34:07 +07:00
Mohamed Boudra b873523eab fix(terminal): skip resize snapshots and standardize key encoding 2026-03-23 14:47:27 +07:00
Mohamed Boudra b862f7b72d fix(desktop): guard invalid badge count payloads 2026-03-23 14:42:17 +07:00
Mohamed Boudra af0f629073 fix(website): update download links to match Electron asset filenames
The Electron migration changed the release asset naming convention from
Tauri's underscore format (Paseo_VERSION_ARCH.ext) to Electron's hyphen
format (Paseo-VERSION-arch.ext).
2026-03-23 14:36:31 +07:00
Mohamed Boudra a4f433195d fix(terminal): only send resize from the focused client
Multiple clients viewing the same terminal would fight over PTY size
because every client sent resizes independently. Gate resize sends on
three focus levels: pane focus (split panes), screen focus (navigation),
and app visibility (browser window / mobile foreground). When a client
regains focus, clear the last reported size and trigger a reflow to
reclaim the PTY dimensions.
2026-03-23 13:31:07 +07:00
Mohamed Boudra 12ea75ab65 fix(app): add tooltip with shortcut hint to source control explorer toggle
The git checkout mode explorer button was missing the tooltip that
the non-git and mobile variants already had via HeaderToggleButton.
2026-03-23 13:25:41 +07:00
Mohamed Boudra a6f24508e8 docs: add 0.1.32 release notes 2026-03-23 13:12:25 +07:00
Mohamed Boudra d562fd40c0 Merge branch 'terminal-multiplexing-slot' 2026-03-23 13:05:20 +07:00
Mohamed Boudra cded2c52ab feat(app): add focus mode (Cmd+Shift+F) to show only the active pane
Hides ScreenHeader, LeftSidebar, and ExplorerSidebar on desktop,
replacing the split layout with the single focused pane. Respects
macOS traffic light padding in the tab bar when active. Shortcut
is restricted to workspace screens. Also nudges traffic light
buttons up 3px globally.
2026-03-23 13:05:07 +07:00
Mohamed Boudra df99442123 Fix terminal slot review issues 2026-03-23 12:44:18 +07:00
Mohamed Boudra b9b1601bb8 fix(terminal): suppress DA response feedback loop after closing vim
Client-side xterm.js was generating Device Attributes responses via
onData, which fed back to the PTY as visible text. Register CSI handlers
to consume query responses on the client and respond to DA1 on the server.
2026-03-23 12:43:21 +07:00
Mohamed Boudra 5efac0dfaf fix(app): match sidebar diff stat colors to workspace header
Use theme palette colors instead of hardcoded muted hex values.
2026-03-23 12:36:54 +07:00
Mohamed Boudra ea41dbc8e7 Add multiplexed terminal stream slots 2026-03-23 12:36:35 +07:00
Mohamed Boudra c9ca1697e5 fix(server): free finalized message text in TimelineAssembler
TimelineAssembler.messages map retained full assistantText and
reasoningText for every message for the lifetime of the session.
Replace with a lightweight finalizedMessageIds set that prevents
duplicate emission during history replay without holding the text.
2026-03-23 12:29:23 +07:00
Mohamed Boudra ed3ed78ec3 feat(app): add xterm addons and improve daemon memory reporting 2026-03-23 12:06:21 +07:00
Mohamed Boudra 37aaa7b155 feat(app): keep workspace tabs alive in panes 2026-03-23 12:03:02 +07:00
Mohamed Boudra 4b4e55a246 fix(workspace): suppress pane focus when moving tabs between panes 2026-03-23 11:30:35 +07:00
Mohamed Boudra d6977352aa feat(terminal): capture cursor presentation modes (style, blink, hidden) 2026-03-23 10:58:39 +07:00
Mohamed Boudra 87145f1e6a Stabilize workspace stream render boundaries 2026-03-23 10:45:48 +07:00
Mohamed Boudra 7f9dafab68 refactor(cli): deduplicate command options between top-level and agent subcommands
Extract addXxxOptions() builder functions for all 9 shared commands
(ls, run, attach, logs, stop, delete, send, inspect, wait) so both
paseo <cmd> and paseo agent <cmd> use a single source of truth.

Fixes drift where agent subcommands were missing --wait-timeout (run),
--worktree/--base/--image (run), --since (logs), and --image (send).
2026-03-23 10:33:06 +07:00
Mohamed Boudra 07720f1d99 feat(keyboard): add Cmd+./Ctrl+. shortcut to toggle both sidebars
Reassign Cmd+. (Mac) and Ctrl+. (Win/Linux) from toggling the left
sidebar to toggling both sidebars at once. If either is open, both
close; if both are closed, both open. Left sidebar toggle on non-Mac
now uses Ctrl+B to match the Mac Cmd+B binding.
2026-03-23 10:06:38 +07:00
Mohamed Boudra c0e63f76da feat(terminal): add shift+enter support and force resize on focus 2026-03-23 00:03:09 +07:00
Mohamed Boudra 93f4b84b8b Add terminal prod-ready e2e coverage 2026-03-22 23:17:26 +07:00
Mohamed Boudra 804d5d2487 Reorder status bar: permission trigger after thinking trigger 2026-03-22 23:13:48 +07:00
Mohamed Boudra babc724220 Align terminal stream with snapshot-only attach 2026-03-22 22:49:05 +07:00
Mohamed Boudra 138c774297 Remove terminal output buffer leftovers 2026-03-22 22:31:57 +07:00
Mohamed Boudra 50ea5a76fb Simplify terminal streaming protocol 2026-03-22 22:31:23 +07:00
Mohamed Boudra 54282409bb refactor(sidebar): restore workspace name font weight to 400 2026-03-22 21:36:15 +07:00
81 changed files with 4434 additions and 5215 deletions
+14 -1
View File
@@ -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:
+3
View File
@@ -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"
+31
View File
@@ -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
View File
@@ -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
+114 -2
View File
@@ -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",
+5
View File
@@ -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",
+6 -1
View File
@@ -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,
+137 -18
View File
@@ -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();
+48 -93
View File
@@ -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}
+49
View File
@@ -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), []);
}
+3
View File
@@ -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", "."],
},
},
];
+107 -19
View File
@@ -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",
+10 -6
View File
@@ -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}
+2 -1
View File
@@ -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],
+46 -1
View File
@@ -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");
});
});
-92
View File
@@ -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
View File
@@ -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,
+18 -93
View File
@@ -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;
+11 -1
View File
@@ -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;
+15
View File
@@ -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 {
+44 -1
View File
@@ -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 {
+13 -1
View File
@@ -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
*/
+9 -1
View File
@@ -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;
+8 -1
View File
@@ -1,4 +1,4 @@
import type { Command } from "commander";
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -53,6 +53,13 @@ async function getRecentActivityTranscript(
}
}
export function addWaitOptions(cmd: Command): Command {
return cmd
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)");
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
+1
View File
@@ -29,6 +29,7 @@ mac:
linux:
category: Development
icon: assets
artifactName: "Paseo-${version}-${arch}.${ext}"
extraResources:
- from: bin/paseo
to: bin/paseo
+1 -1
View File
@@ -90,7 +90,7 @@ async function createMainWindow(): Promise<void> {
show: false,
...(iconPath ? { icon: iconPath } : {}),
titleBarStyle: isMac ? "hidden" : "default",
trafficLightPosition: isMac ? { x: 16, y: 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);
});
});
});
+19 -2
View File
@@ -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,
});
}
}
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ dotenv.config({
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
const result = spawnSync(
process.execPath,
["--inspect", "--heapsnapshot-near-heap-limit=3", ...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
}
}
}
+236 -170
View File
@@ -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();
+131 -168
View File
@@ -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
+220 -395
View File
@@ -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();
+19 -8
View File
@@ -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")));
});
});
-133
View File
@@ -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),
};
}
+53 -80
View File
@@ -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;
}
}
+154 -45
View File
@@ -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({
+66 -206
View File
@@ -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,
},
];