Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 979326c04f Install local speech runtime on demand
The daemon no longer bundles sherpa-onnx, sherpa-onnx-node, or
onnxruntime-node. On first boot they are downloaded into
$PASEO_HOME/runtime/local-speech alongside the existing voice models,
non-blocking. Users who don't enable local voice never pay the size
cost.

The wire protocol is unchanged. server_info.capabilities.voice.*
already gates voice in the client; readiness now reports
runtime_install_in_progress / runtime_missing / runtime_install_failed
on the same channel as the existing model_download_* states.

The native runtime lives under packages/server/src/server/speech/
providers/local/runtime and exposes a small surface: configure the
home once at speech-service init, ensureInstalled, loadPackage,
getStatus, plus a typed error. Provider configs do not know about
the install path.
2026-05-21 10:44:50 +00:00
117 changed files with 1807 additions and 3971 deletions
-33
View File
@@ -1,38 +1,5 @@
# Changelog
## 0.1.81 - 2026-05-24
### Added
- **Paseo can now be installed as a web app from supported browsers** ([#1144](https://github.com/getpaseo/paseo/pull/1144))
- **Pi extension dialogs now appear as Paseo permission prompts** ([#1134](https://github.com/getpaseo/paseo/pull/1134) by [@yuruiz](https://github.com/yuruiz))
- Added community links and a home button to the sidebar
### Improved
- **Mobile terminals load faster and restore existing output more smoothly** ([#1147](https://github.com/getpaseo/paseo/pull/1147))
- Copying assistant messages preserves formatting
- Agent metadata fallback failures now log each provider attempt for easier debugging
### Fixed
- Android: slash command suggestions stay interactive when opened from the composer
- macOS: Alt+letter shortcuts work again
- Terminal panes no longer flicker during resize
- OpenCode MCP servers are injected once instead of being connected twice
- Import session no longer shows empty sessions
- Worktree archive status no longer reports false unpushed commits ([#1158](https://github.com/getpaseo/paseo/pull/1158))
- The `/exit`, `/quit`, and `/q` slash command aliases now show as one row
- Shortcut chord badges are readable in light mode
- Segmented controls show their track under every segment
- Sheet header search text is readable in dark mode
## 0.1.80 - 2026-05-21
### Fixed
- Opening dropdown menus no longer crashes on mobile
## 0.1.79 - 2026-05-21
### Added
-1
View File
@@ -32,7 +32,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
+25 -7
View File
@@ -173,7 +173,25 @@ All fields are optional with sensible defaults.
---
## 3. Schedule
## 3. Local Speech Runtime
**Path:** `$PASEO_HOME/runtime/local-speech/node_modules/`
Native local voice packages are installed on demand at daemon bootstrap, not bundled with the daemon package. The runtime installer downloads npm tarballs into `$PASEO_HOME/runtime/local-speech/.downloads`, extracts them into a temporary directory, verifies required files, then atomically moves each package into `node_modules`.
Installed packages:
- `sherpa-onnx`
- `sherpa-onnx-node`
- `sherpa-onnx-<platform>-<arch>` for the current host
- `onnxruntime-common`
- `onnxruntime-node`
The daemon starts without waiting for these packages. While they are missing, `server_info.capabilities.voice.*` reports local voice unavailable through the existing capability fields. Once the runtime and required local speech models are present, the next speech runtime reconcile makes local voice available without restarting the daemon.
---
## 4. Schedule
**Path:** `$PASEO_HOME/schedules/{id}.json`
@@ -221,7 +239,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
---
## 4. Chat
## 5. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
@@ -258,7 +276,7 @@ Single file containing all rooms and messages.
---
## 5. Loop
## 6. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
@@ -347,7 +365,7 @@ Single file containing an array of all loop records. Writes are direct (not atom
---
## 6. Project Registry
## 7. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -370,7 +388,7 @@ emptied duplicate.
---
## 7. Workspace Registry
## 8. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -389,7 +407,7 @@ Array of workspace records. A workspace is a specific working directory within a
---
## 8. Push Token Store
## 9. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -403,7 +421,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 10. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
-14
View File
@@ -187,20 +187,6 @@ Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
## App web deploys
`packages/app` exports a single-page Expo web app and deploys the `dist/`
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
from `packages/app/public/index.html`. Keep the install icons in `public/` so
Cloudflare serves them from stable root URLs after `expo export`.
Do not add service-worker caching casually. Paseo is a live control surface for
agents, and an aggressive service worker can strand installed users on stale web
code. If offline behavior becomes a product requirement, add it deliberately
with an update strategy and test the installed-app upgrade path.
## Expo troubleshooting
```bash
-173
View File
@@ -1,173 +0,0 @@
# Floating Panels
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
float above an anchor element on iOS, Android, and web. This doc captures the
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
canonical files and are trying to add or change one.
## Canonical files
| File | Use case |
| ---------------------------------------- | -------------------------------------------------------------- |
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
| `components/ui/autocomplete-popover.tsx` | Inline slash-command autocomplete above a focused TextInput |
Each handles a different mix of concerns: combobox owns input focus, tooltip is
non-interactive, hover-card is web-only desktop, autocomplete coexists with a
focused TextInput. There is no shared "floating panel" primitive yet — when a
fifth use case shows up we can revisit; until then prefer copying the closest
file and trimming. The four gotchas below are what each of them re-learned.
## Gotcha 1 — Android touch hit-test by parent bounds
On Android, a child View whose bounds fall outside its parent's bounds renders
correctly (with `overflow: visible`, the default) but **does not receive touch
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
rect first, then iterates children. A touch in the overflowing region never
reaches the parent, let alone the child. iOS and web do not share this rule —
iOS hit-test descends into overflowing children, web uses standard CSS pointer
events. This is the bug that put autocomplete on this path: the popover was
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
Android touches sailed straight through to the chat scroll view behind it.
Two escape hatches in the codebase:
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
hit-testing starts fresh in that window. Side effect: a Modal opening on
Android can detach the IME from an underlying TextInput. Fine for combobox
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
(the composer's input must stay focused so the user keeps typing).
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
hoists the React subtree to a fixed mount point (`PortalHost name="root"` in
`app/_layout.tsx`) whose bounds cover the screen. Same window, same IME,
hit-test works because the new parent is full-screen. This is the right
default when you must keep IME attachment.
Choose Modal vs Portal by whether you need the underlying input to keep its
keyboard.
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
A Portal escapes Android's hit-test, but it also escapes two things you were
quietly relying on:
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
component's natural ancestor chain. When the user navigates away, your
component may stay mounted (offscreen, in a tab) — the popover stays with it.
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
`isPaneFocused` prop already exists and flips on pane switches; pass
`visible={isYourOwnVisible && isPaneFocused}`. See
`composer.tsx:1604` and `autocomplete-popover.tsx`.
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
content has the same transform applied (`agent-panel.tsx:939`). They move
together because they share the SharedValue. A portal'd popover is at the
app root — it does not get that transform unless you apply it yourself.
The fix for transforms is Gotcha 3.
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
`measureInWindow` returns the view's _current_ screen position. In theory that
includes Reanimated-applied transforms (Reanimated updates native view
properties, and Android's `getLocationInWindow` reads transformed coords). In
practice it's racy — the measurement may snapshot mid-animation, and on Android
with Reanimated worklets the result is not always stable.
Do not try to track the keyboard by re-measuring on every frame. Instead,
**slave the popover's transform to the same SharedValue the composer uses**:
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
to the popover wrapper.
When `shift` equals `openShift`, the translate is 0 and the popover sits at
the measured position. When the keyboard moves afterward, the delta translates
the popover by exactly the amount the composer translates. They move in
lockstep, no re-measurement needed. See `autocomplete-popover.tsx` —
`openShift` / `shift` / `animatedTransformStyle`.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.
## Gotcha 4 — Status-bar offset for Portal-based positioning on Android
On Android, `measureInWindow` returns coords **below** the status bar (the
status bar is system-owned space). A Portal overlay positioned at `top: 0`
inside the `PortalProvider` may start at the **top of the window** (above the
status bar) depending on the wrapper chain. The result: position the popover
at the measured `rect.y` and it sits `StatusBar.currentHeight` higher than the
anchor on Android.
Mirror what tooltip does: shift the measured rect down by the status bar height
on Android only.
```ts
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
```
See `tooltip.tsx` and `autocomplete-popover.tsx`. iOS and web do not need this.
## Gotcha 5 — The two-measurement flash
If your popover needs `top` (or `left`) computed from both:
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
- the popover's own size (`contentSize` from `onLayout`),
then a naïve implementation will flash through three positions on every open:
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
for either measurement. Wrapper has no `width`, so the inner content lays
out at its natural (often narrow) intrinsic width.
2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`.
But the stale `onLayout` from frame 1 has already set `contentSize` to the
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
at the wrong spot.
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
updates. Position snaps to the right place.
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
both:
- **Do not mount the floating content until `anchorRect` is set.** Return
`null` until then. This prevents the bad-width onLayout from happening at
all.
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
the final width but `opacity: 0`.** The first visible paint is at the
correct position. This is the combobox pattern —
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
`top: -9999` as the placeholder; the layout work still happens at -9999 and
any subsequent state-flash is visible when you flip back.
The "render invisible to measure, then reveal" pattern is the canonical
solution to chicken-and-egg positioning in this codebase. Reach for it before
anything fancier.
## Recipe for a new anchored panel
Before you write a new one, ask:
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
If no, use Portal.
2. **Does the panel need to dismiss on screen change?** Almost always yes —
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
3. **Does the panel sit above something that moves with the keyboard?** If
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
If no, you can probably skip the transform entirely.
4. **Will the panel's content height vary?** If yes, you need both
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
null until anchor, then opacity-0 until contentSize). If no — content has
a known fixed max height — you might be able to use bottom-anchored
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
`contentSize` round-trip entirely. **But only if the height is genuinely
bounded** — autocomplete's inner detail card escapes the 220-cap
container, which is why bottom-anchored looked clean but rendered
wrong. Verify before you commit.
5. **Are you on Android with Portal?** Add the status-bar offset (Gotcha 4).
Then copy the closest canonical file and trim.
-4
View File
@@ -24,10 +24,6 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
+28 -8
View File
@@ -4,17 +4,15 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## STOP — `useUnistyles()` Is Banned
## STOP — `useUnistyles()` Is Forbidden
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
Use these alternatives in order:
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -48,9 +46,31 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. There is no "last resort"
### 4. Lift the read into a tiny leaf component
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
### 5. (Last resort) `useUnistyles()`
Only acceptable when both of:
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
- (b) The component is small, leaf-level, and not on a hot render path.
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
### Hot-path forbidden list
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
- `Composer` and `MessageInput`
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
## How Updates Propagate
+1 -1
View File
@@ -1 +1 @@
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=
sha256-ipuV4cM3LUqMQ39LyB4X3ezz5UgxyHGEhpBOq7en2Jw=
+161 -154
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.81",
"version": "0.1.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.81",
"version": "0.1.79",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -12358,24 +12358,6 @@
"@types/node": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -12385,13 +12367,6 @@
"@types/unist": "*"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -13696,6 +13671,64 @@
"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",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
@@ -13705,6 +13738,15 @@
"addons/*"
]
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
@@ -13822,6 +13864,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -15143,6 +15186,7 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT"
},
"node_modules/bplist-creator": {
@@ -17009,6 +17053,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"dev": true,
"license": "MIT"
},
"node_modules/detect-node-es": {
@@ -18112,6 +18157,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
@@ -22151,6 +22197,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",
@@ -22534,6 +22607,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",
@@ -22617,6 +22699,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -22634,6 +22717,7 @@
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"type-fest": "^0.13.1"
@@ -22649,6 +22733,7 @@
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
@@ -22674,6 +22759,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -25328,6 +25414,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"dev": true,
"license": "ISC"
},
"node_modules/json5": {
@@ -26347,6 +26434,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0"
@@ -28987,12 +29075,14 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"dev": true,
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
@@ -30133,6 +30223,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",
@@ -31908,6 +32007,7 @@
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"boolean": "^3.0.1",
@@ -31925,6 +32025,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/rollup": {
@@ -32216,6 +32317,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
@@ -32505,6 +32607,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx/-/sherpa-onnx-1.12.28.tgz",
"integrity": "sha512-PaOuTCUA/Vh5o3zcq+z3j1Zpv2VlP7iubOgYoVODDyeFDicRxjskiEQqERdWEcpQUi6vh47q3A63bS7Y9FRHNg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/sherpa-onnx-darwin-arm64": {
@@ -32514,6 +32617,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32527,6 +32631,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32540,6 +32645,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32553,6 +32659,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32563,6 +32670,7 @@
"version": "1.12.28",
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.12.28.tgz",
"integrity": "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA==",
"dev": true,
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.12.28",
@@ -32580,6 +32688,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -32593,6 +32702,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -36323,7 +36433,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36339,15 +36449,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"@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",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -36373,7 +36483,6 @@
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -36401,13 +36510,12 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
@@ -36421,107 +36529,6 @@
"ws": "^8.20.0"
}
},
"packages/app/node_modules/@xterm/addon-clipboard": {
"version": "0.3.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
"integrity": "sha512-gFW1jSpKFhMN29ArgBXIyYzxrDcGeWVP943/BxcWelHthAjhWrt048LxkNtt7/FgF60ZfysIyvMXLeVetXfduA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-fit": {
"version": "0.12.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.12.0-beta.213.tgz",
"integrity": "sha512-hFupt6CX7MNO0XKjugPpm2T3/PUGT2Y7o92Y9UyQAH0a7Da/H5nzkDbJLH1LZHfyUFaJOOvkJOQl8oreWMr/SQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-image": {
"version": "0.10.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz",
"integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-ligatures": {
"version": "0.11.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
"integrity": "sha512-4YLFsl+K/RfTrIxHuMLXvQ3iJmBtxHlLaC4sutPZT1Nkh01QxOmMAn+JZI2jm5u1RPO2F6WRfwu1jtqhunGCpQ==",
"license": "MIT",
"dependencies": {
"lru-cache": "^6.0.0",
"opentype.js": "^0.8.0"
},
"engines": {
"node": ">8.0.0"
},
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-search": {
"version": "0.17.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.213.tgz",
"integrity": "sha512-SlXOagJg8ZH78T6tfoUqauJPAWo1N8yb8FamrHNsGP7HAFpOq3m1U9hH8q8iHeOQzs7VcVzD7J2/X3gAMJRn+g==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-unicode11": {
"version": "0.10.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.213.tgz",
"integrity": "sha512-Tk2u+4HrkOwlagO2w/Knc5RF2LLA9D2i6yxUMmnxFKaQ88qXOnyqEn4b+OhPCaWHJJnWsV5grjMMQq6utRGxcQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-web-links": {
"version": "0.13.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.13.0-beta.213.tgz",
"integrity": "sha512-SNMLWgcVxchXTSxIzqZkQ7xennu0YVpmtDMVZi3K03JyFXw8NDu57B0O01JR8d7Rlk+EWk+B/aMxIehnCSgX6Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/addon-webgl": {
"version": "0.20.0-beta.212",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.212.tgz",
"integrity": "sha512-RzaTYeukTXTJth+Sl7FzCmb8AqiyDrNWNnqG+/300qJbfHYfCMmBlBVZ0yCTSZIWMdgFbgkhmnmBN7Yzs6xoBA==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^6.1.0-beta.213"
}
},
"packages/app/node_modules/@xterm/headless": {
"version": "6.1.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz",
"integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==",
"dev": true,
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/@xterm/xterm": {
"version": "6.1.0-beta.213",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.213.tgz",
"integrity": "sha512-bq5nJkgoZDSg6Ma4zgiQ7jlooGNo7U+HDa6vnhx43yXsVEK2laIF7R08bMYAuU0B+C9xX0gwSrcsW18GxjUoJQ==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"packages/app/node_modules/expo-clipboard": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
@@ -36544,10 +36551,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.81",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36902,7 +36909,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.81",
"version": "0.1.79",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37263,7 +37270,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.81",
"version": "0.1.79",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37299,7 +37306,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37637,7 +37644,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37964,12 +37971,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.81",
"@getpaseo/relay": "0.1.81",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -37983,7 +37990,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -37991,8 +37997,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -38010,6 +38014,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
@@ -38843,7 +38850,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.81",
"version": "0.1.79",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.81",
"version": "0.1.79",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
@@ -181,7 +181,7 @@ test.describe("Client slash commands", () => {
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/exit");
await selectClientSlashCommand(page, "/qu", "/quit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},
+12 -15
View File
@@ -1,14 +1,13 @@
{
"name": "@getpaseo/app",
"version": "0.1.81",
"version": "0.1.79",
"private": true,
"main": "index.ts",
"scripts": {
"start": "cross-env APP_VARIANT=development expo start",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
"eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
"eas-build-post-install": "npm run build:workspace-deps",
"android": "npm run android:development",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
@@ -42,15 +41,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "^0.20.0-beta.212",
"@xterm/xterm": "^6.1.0-beta.213",
"@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",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -76,7 +75,6 @@
"expo-updates": "~29.0.12",
"fast-deep-equal": "^3.1.3",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -104,13 +102,12 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@testing-library/react": "^16.3.2",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.6",
"@types/react": "~19.2.0",
"@types/ws": "^8.18.1",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/headless": "^6.1.0-beta.213",
"@xterm/headless": "^6.0.0",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

-6
View File
@@ -4,16 +4,10 @@
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="robots" content="noindex,nofollow" />
<meta name="theme-color" content="#181B1A" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Paseo" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>%WEB_TITLE%</title>
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
<style id="expo-reset">
-27
View File
@@ -1,27 +0,0 @@
{
"id": "/",
"name": "Paseo",
"short_name": "Paseo",
"description": "Monitor and control local AI coding agents from anywhere.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"theme_color": "#181B1A",
"background_color": "#181B1A",
"categories": ["developer", "productivity", "utilities"],
"icons": [
{
"src": "/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

@@ -1,89 +0,0 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
async function resolveTsPath(basePath) {
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
`${basePath}.js`,
`${basePath}.jsx`,
path.join(basePath, "index.ts"),
path.join(basePath, "index.tsx"),
path.join(basePath, "index.js"),
path.join(basePath, "index.jsx"),
];
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try next candidate
}
}
return basePath;
}
const aliasPlugin = {
name: "paseo-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, async (args) => ({
path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
}));
build.onResolve({ filter: /^@server\// }, async (args) => ({
path: await resolveTsPath(
path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
),
}));
},
};
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
loader: {
".css": "text",
},
plugins: [aliasPlugin],
logLevel: "info",
});
const js = result.outputFiles[0]?.text;
if (!js) {
throw new Error("terminal webview bundle produced no JavaScript");
}
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
@@ -57,41 +57,33 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
}
describe("resolveClientSlashCommand", () => {
it("declares the exact canonical client commands with their aliases", () => {
expect(
CLIENT_SLASH_COMMANDS.map((command) => [
command.name,
[...command.aliases],
command.execution,
]),
).toEqual([
["exit", ["quit", "q"], "immediate"],
["clear", ["new"], "immediate"],
it("declares the exact client commands that execute immediately", () => {
expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([
["quit", "immediate"],
["exit", "immediate"],
["q", "immediate"],
["clear", "immediate"],
["new", "immediate"],
]);
});
it("resolves canonical names and aliases after trimming", () => {
it("resolves exact client commands after trimming", () => {
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
execution: "immediate",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
});
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })).toMatchObject({
name: "clear",
kind: "replace-agent-with-draft",
});
expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe(
"archive-agent",
);
expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe(
"replace-agent-with-draft",
);
});
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {
+22 -10
View File
@@ -6,7 +6,6 @@ export type ClientSlashCommandExecution = "immediate" | "insert";
export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
argumentHint: string;
kind: ClientSlashCommandKind;
@@ -14,9 +13,22 @@ export interface ClientSlashCommand {
}
export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
{
name: "quit",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "exit",
aliases: ["quit", "q"],
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "q",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
@@ -24,7 +36,13 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
{
name: "clear",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
{
name: "new",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
@@ -32,13 +50,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
];
const COMMAND_BY_NAME = new Map<string, ClientSlashCommand>();
for (const command of CLIENT_SLASH_COMMANDS) {
COMMAND_BY_NAME.set(command.name, command);
for (const alias of command.aliases) {
COMMAND_BY_NAME.set(alias, command);
}
}
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
export function resolveClientSlashCommand(input: {
text: string;
@@ -3,7 +3,7 @@ import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
@@ -205,9 +205,6 @@ const styles = StyleSheet.create((theme) => ({
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
@@ -240,43 +237,38 @@ export type AdaptiveTextInputProps = TextInputProps & {
// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
// can seed it once with initialValue and remount with resetKey for real resets.
// See https://github.com/facebook/react-native/issues/44157
//
// Text color and placeholder color are owned by this leaf — not the caller.
// `@gorhom/bottom-sheet` mounts header subtrees before the sheet is visible
// under whatever theme is active at mount time, then keeps them mounted across
// theme changes; any caller that paints color via `StyleSheet.create((theme) =>
// ...)` from outside this leaf ends up with stale colors in dark mode (see
// docs/unistyles.md "Hidden Sheet Content"). withUnistyles wraps the actual
// TextInput so theme-driven re-renders land on the wrapper.
const ThemedTextInput = withUnistyles(TextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
export const AdaptiveTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(
function AdaptiveTextInputInner(props, ref) {
const isMobile = useIsCompactFormFactor();
const { value: _value, initialValue, resetKey, defaultValue, style, ...inputProps } = props;
// Leaf-owned color goes LAST so callers cannot override it with a stale
// theme read. Outline color is theme-aware on web :focus-visible.
const {
value: _value,
initialValue,
resetKey,
defaultValue,
style,
placeholderTextColor,
...inputProps
} = props;
// Recolor the browser's :focus-visible outline (defined in public/index.html)
// so it matches the active theme's accent instead of its hard-coded fallback.
// Consumer style wins if it sets outlineColor explicitly.
const textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
style: [styles.adaptiveInputOutline, style],
};
if (isMobile && isNative) {
return (
<ThemedBottomSheetTextInput
<BottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
@@ -352,6 +344,7 @@ export function SheetHeaderView({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
@@ -407,6 +400,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
@@ -1,66 +0,0 @@
import { useCallback } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Heart } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { GitHubIcon } from "@/components/icons/github-icon";
import { DiscordIcon } from "@/components/icons/discord-icon";
import { openExternalUrl } from "@/utils/open-external-url";
const renderGitHubIcon = (color: string) => <GitHubIcon color={color} size={14} />;
const renderDiscordIcon = (color: string) => <DiscordIcon color={color} size={14} />;
export function CommunityLinks() {
const handleOpenGitHub = useCallback(() => {
void openExternalUrl("https://github.com/getpaseo/paseo");
}, []);
const handleOpenSponsor = useCallback(() => {
void openExternalUrl("https://github.com/sponsors/boudra");
}, []);
const handleOpenDiscord = useCallback(() => {
void openExternalUrl("https://discord.gg/jz8T2uahpH");
}, []);
return (
<View style={styles.row}>
<Button
variant="ghost"
size="sm"
leftIcon={renderGitHubIcon}
onPress={handleOpenGitHub}
testID="community-links-github-star"
>
Star
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={Heart}
onPress={handleOpenSponsor}
testID="community-links-sponsor"
>
Sponsor
</Button>
<Button
variant="ghost"
size="sm"
leftIcon={renderDiscordIcon}
onPress={handleOpenDiscord}
testID="community-links-discord"
>
Community
</Button>
</View>
);
}
const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 0,
},
}));
+34 -14
View File
@@ -62,7 +62,7 @@ import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import { Autocomplete } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import {
useHostRuntimeAgentDirectoryStatus,
@@ -256,6 +256,25 @@ function renderQueueList(args: RenderQueueListArgs): ReactElement | null {
);
}
function renderAutocompletePopover(
autocomplete: ReturnType<typeof useAgentAutocomplete>,
): ReactElement | null {
if (!autocomplete.isVisible) return null;
return (
<View style={styles.autocompletePopover} pointerEvents="box-none">
<Autocomplete
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
onSelect={autocomplete.onSelectOption}
/>
</View>
);
}
interface RenderComposerAttachmentPillArgs {
attachment: ComposerAttachment;
index: number;
@@ -1570,7 +1589,10 @@ export function Composer({
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
);
const messageInputContainerRef = useRef<View>(null);
const autocompletePopover = useMemo(
() => renderAutocompletePopover(autocomplete),
[autocomplete],
);
const isSubmitBusy = isProcessing || isSubmitLoading;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
@@ -1592,18 +1614,8 @@ export function Composer({
{queueList}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<AutocompletePopover
visible={autocomplete.isVisible && isPaneFocused}
anchorRef={messageInputContainerRef}
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
onSelect={autocomplete.onSelectOption}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText={autocomplete.loadingText}
emptyText={autocomplete.emptyText}
/>
<View style={styles.messageInputContainer}>
{autocompletePopover}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
@@ -1700,6 +1712,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
width: "100%",
gap: theme.spacing[3],
},
autocompletePopover: {
position: "absolute",
left: 0,
right: 0,
bottom: "100%",
marginBottom: theme.spacing[3],
zIndex: 30,
},
cancelButton: {
width: 28,
height: 28,
@@ -1,14 +0,0 @@
import Svg, { Path } from "react-native-svg";
interface DiscordIconProps {
size?: number;
color?: string;
}
export function DiscordIcon({ size = 16, color = "currentColor" }: DiscordIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</Svg>
);
}
+1 -29
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -58,7 +58,6 @@ import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
buildHostOpenProjectRoute,
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
@@ -95,7 +94,6 @@ interface SidebarSharedProps {
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
renderHostOption: (input: {
option: ComboboxOption;
@@ -225,17 +223,6 @@ export const LeftSidebar = memo(function LeftSidebar({
router.push(buildSettingsRoute());
}, []);
const handleHomeMobile = useCallback(() => {
if (!activeServerId) return;
showMobileAgent();
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId, showMobileAgent]);
const handleHomeDesktop = useCallback(() => {
if (!activeServerId) return;
router.push(buildHostOpenProjectRoute(activeServerId));
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
@@ -285,7 +272,6 @@ export const LeftSidebar = memo(function LeftSidebar({
isOpen={isOpen}
closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
@@ -298,7 +284,6 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
@@ -429,7 +414,6 @@ function SidebarFooter({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
}: {
theme: SidebarTheme;
@@ -443,7 +427,6 @@ function SidebarFooter({
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
@@ -473,13 +456,6 @@ function SidebarFooter({
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleHome}
testID="sidebar-home"
accessibilityLabel="Home"
icon={Home}
theme={theme}
/>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
@@ -525,7 +501,6 @@ function MobileSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
insetsBottom,
@@ -754,7 +729,6 @@ function MobileSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>
</View>
@@ -784,7 +758,6 @@ function DesktopSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
isOpen,
@@ -898,7 +871,6 @@ function DesktopSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>
+2 -2
View File
@@ -62,6 +62,7 @@ import Animated, {
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
@@ -72,7 +73,6 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -1117,7 +1117,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
return;
}
await writeMarkdownToRichClipboard(content);
await Clipboard.setStringAsync(content);
setCopied(true);
if (copyTimeoutRef.current) {
@@ -1,532 +0,0 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
type Ref,
} from "react";
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
interface TerminalEmulatorProps {
dom?: unknown;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
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: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
type BridgeInboundMessage =
| {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
type WebViewProps = ComponentProps<typeof WebView>;
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
}
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
function createMountMessage(input: {
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
return {
type: "mount",
streamKey: input.streamKey,
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
}
export default function TerminalEmulator({
ref,
streamKey,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const webViewRef = useRef<WebView>(null);
const [webViewEpoch, setWebViewEpoch] = useState(0);
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
const bridgeReadyRef = useRef(false);
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
pendingModifiers,
swipeGesturesEnabled,
});
mountConfigRef.current = {
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onSwipeLeft,
onSwipeRight,
};
const clearBridgeReadyTimeout = useCallback(() => {
if (bridgeReadyTimeoutRef.current === null) return;
clearTimeout(bridgeReadyTimeoutRef.current);
bridgeReadyTimeoutRef.current = null;
}, []);
const clearRendererReadyTimeout = useCallback(() => {
if (rendererReadyTimeoutRef.current === null) return;
clearTimeout(rendererReadyTimeoutRef.current);
rendererReadyTimeoutRef.current = null;
}, []);
const resetWebViewDocument = useCallback(() => {
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
setWebViewEpoch((value) => value + 1);
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
const scheduleBridgeReadyWatchdog = useCallback(() => {
clearBridgeReadyTimeout();
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
bridgeReadyTimeoutRef.current = setTimeout(() => {
bridgeReadyTimeoutRef.current = null;
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
return;
}
resetWebViewDocument();
}, BRIDGE_READY_TIMEOUT_MS);
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
const scheduleRendererReadyWatchdog = useCallback(() => {
clearRendererReadyTimeout();
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
rendererReadyTimeoutRef.current = setTimeout(() => {
rendererReadyTimeoutRef.current = null;
if (
rendererReadyVersionRef.current !== expectedRendererReadyVersion ||
mountedStreamKeyRef.current === streamKey
) {
return;
}
resetWebViewDocument();
}, RENDERER_READY_TIMEOUT_MS);
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
const flushPendingMessages = useCallback(() => {
if (!bridgeReadyRef.current || !webViewRef.current) return;
const pending = pendingMessagesRef.current.splice(0);
for (const message of pending) {
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}
}, []);
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
if (!bridgeReadyRef.current || !webViewRef.current) {
pendingMessagesRef.current.push(message);
return;
}
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}, []);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
mountedStreamKeyRef.current = streamKey;
sendToWebView(mountMessage);
flushPendingMessages();
scheduleRendererReadyWatchdog();
}, [
bridgeReadyVersion,
flushPendingMessages,
scheduleRendererReadyWatchdog,
sendToWebView,
streamKey,
]);
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
}, [pendingModifiers, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
return () => {
if (mountedStreamKeyRef.current) {
const previousStreamKey = mountedStreamKeyRef.current;
callbacksRef.current.onRendererReadyChange?.({
streamKey: previousStreamKey,
isReady: false,
});
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
}
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
};
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
const handleLifecycleMessage = useCallback(
(message: BridgeOutboundMessage): boolean => {
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
bridgeReadyVersionRef.current += 1;
clearBridgeReadyTimeout();
setBridgeReadyVersion((value) => value + 1);
return true;
}
if (message.type === "rendererReady") {
mountedStreamKeyRef.current = message.isReady ? message.streamKey : null;
if (message.isReady) {
rendererReadyVersionRef.current += 1;
clearRendererReadyTimeout();
}
callbacksRef.current.onRendererReadyChange?.({
streamKey: message.streamKey,
isReady: message.isReady,
});
return true;
}
return false;
},
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
key: message.key,
ctrl: message.ctrl,
shift: message.shift,
alt: message.alt,
meta: message.meta,
});
break;
case "pendingModifiersConsumed":
callbacksRef.current.onPendingModifiersConsumed?.();
break;
case "inputModeChange":
callbacksRef.current.onInputModeChange?.(message.state);
break;
case "openExternalUrl":
void openExternalUrl(message.url);
break;
case "swipeLeft":
callbacksRef.current.onSwipeLeft?.();
break;
case "swipeRight":
callbacksRef.current.onSwipeRight?.();
break;
case "debug":
break;
}
},
[],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeOutboundMessage;
try {
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
} catch {
return;
}
if (message.type === "bridgeReady" || message.type === "rendererReady") {
handleLifecycleMessage(message);
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage],
);
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
bridgeReadyRef.current = false;
mountedStreamKeyRef.current = null;
scheduleBridgeReadyWatchdog();
}, [scheduleBridgeReadyWatchdog]);
const handleContentProcessDidTerminate = useCallback<
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleRenderProcessGone = useCallback<
NonNullable<WebViewProps["onRenderProcessGone"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }],
[xtermTheme.background],
);
return (
<View style={styles.root} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={styles.webViewContainer}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
bounces={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled={false}
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
onRenderProcessGone={handleRenderProcessGone}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
backgroundColor: "#0b0b0b",
},
webView: {
flex: 1,
backgroundColor: "#0b0b0b",
},
webViewContainer: {
flex: 1,
backgroundColor: "#0b0b0b",
},
});
@@ -19,10 +19,7 @@ import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -32,8 +29,7 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -258,12 +254,8 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -282,11 +274,8 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
+2 -21
View File
@@ -25,9 +25,7 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -173,9 +171,6 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -359,18 +354,11 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, data }) => {
onOutput: ({ terminalId: outputTerminalId, text }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
emulatorRef.current?.writeOutput(text);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -379,12 +367,6 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -404,7 +386,6 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);
@@ -1,176 +0,0 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { Keyboard, Platform, StatusBar, View, type LayoutChangeEvent } from "react-native";
import { Portal } from "@gorhom/portal";
import Animated, {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
} from "react-native-reanimated";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
const OFFSET_FROM_ANCHOR = SPACING[3];
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
interface AutocompletePopoverProps {
visible: boolean;
anchorRef: React.RefObject<View | null>;
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
}
export function AutocompletePopover({
visible,
anchorRef,
options,
selectedIndex,
onSelect,
isLoading,
errorMessage,
loadingText,
emptyText,
}: AutocompletePopoverProps): ReactElement | null {
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const insets = useSafeAreaInsets();
const { height: rawKeyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInsetSV = useSharedValue(insets.bottom);
useEffect(() => {
bottomInsetSV.value = insets.bottom;
}, [bottomInsetSV, insets.bottom]);
// Same shift formula as useKeyboardShiftStyle({mode: "translate"}), so the popover
// tracks the composer's keyboard translate in lockstep.
const shift = useDerivedValue(() =>
Math.max(0, Math.abs(rawKeyboardHeight.value) - bottomInsetSV.value),
);
// Snapshot of `shift` at the moment we measured the anchor. Translate applied to the
// popover is `openShift - shift`, so when shift == openShift the popover sits at the
// measured position; when keyboard moves the popover translates with the composer.
const openShift = useSharedValue(0);
useEffect(() => {
if (!visible) {
setAnchorRect(null);
setContentSize(null);
return;
}
let cancelled = false;
// measureInWindow on Android returns coords below the status bar, while the Portal
// overlay starts at the top of the window. Mirror tooltip.tsx and shift the rect
// down by the status bar height to keep both in the same coord system.
const statusBarOffset = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
const remeasure = () => {
const element = anchorRef.current;
if (!element) return;
void measureElement(element).then((rect) => {
if (cancelled) return undefined;
setAnchorRect({ ...rect, y: rect.y + statusBarOffset });
openShift.value = shift.value;
return undefined;
});
};
remeasure();
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
);
return () => {
cancelled = true;
for (const sub of subscriptions) sub.remove();
};
}, [visible, anchorRef, openShift, shift]);
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
}, []);
const baseStyle = useMemo(() => {
if (!anchorRect) return null;
if (!contentSize) {
// Have the anchor, waiting on the popover's own height. Render with the
// final width so the inner Autocomplete lays out at its final size, but
// stay invisible — the first visible paint will already be at the correct
// top. Mirrors combobox.tsx `shouldHideDesktopContent`. See
// docs/floating-panels.md "the two-measurement flash".
return inlineUnistylesStyle({
position: "absolute" as const,
top: 0,
left: anchorRect.x,
width: anchorRect.width,
opacity: 0,
});
}
return inlineUnistylesStyle({
position: "absolute" as const,
top: anchorRect.y - contentSize.height - OFFSET_FROM_ANCHOR,
left: anchorRect.x,
width: anchorRect.width,
});
}, [anchorRect, contentSize]);
const animatedTransformStyle = useAnimatedStyle(() => ({
transform: [{ translateY: openShift.value - shift.value }],
}));
const composedStyle = useMemo(
() => [baseStyle, animatedTransformStyle],
[baseStyle, animatedTransformStyle],
);
if (!visible || !anchorRect || !baseStyle) return null;
return (
<Portal>
<View style={styles.overlay} pointerEvents="box-none">
<Animated.View style={composedStyle} onLayout={handleLayout}>
<Autocomplete
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
isLoading={isLoading}
errorMessage={errorMessage}
loadingText={loadingText}
emptyText={emptyText}
/>
</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create(() => ({
overlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
}));
@@ -194,6 +194,7 @@ export function SearchInput({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={value}
resetKey={resetKey}
value={value}
@@ -142,6 +142,7 @@ const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "stretch",
maxWidth: "100%",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,
+19 -2
View File
@@ -4,6 +4,23 @@ import { StyleSheet } from "react-native-unistyles";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
function colorWithOpacity(color: string, opacity: number): string {
const hex = color.trim();
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
const r = Number.parseInt(hex[1] + hex[1], 16);
const g = Number.parseInt(hex[2] + hex[2], 16);
const b = Number.parseInt(hex[3] + hex[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
if (/^#[0-9a-fA-F]{6}$/.test(hex)) {
const r = Number.parseInt(hex.slice(1, 3), 16);
const g = Number.parseInt(hex.slice(3, 5), 16);
const b = Number.parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
return `rgba(255, 255, 255, ${opacity})`;
}
export function Shortcut({
keys,
chord,
@@ -53,7 +70,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
borderWidth: 0,
},
sequence: {
@@ -65,6 +82,6 @@ const styles = StyleSheet.create((theme) => ({
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
color: colorWithOpacity(theme.colors.foreground, 0.6),
},
}));
@@ -324,13 +324,9 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
),
...providerCommands,
];
const matches = availableCommands.filter((entry) => {
if (entry.source === "provider") {
return entry.command.name.toLowerCase().includes(filterLower);
}
const candidates = [entry.command.name, ...entry.command.aliases];
return candidates.some((candidate) => candidate.toLowerCase().includes(filterLower));
});
const matches = availableCommands.filter((entry) =>
entry.command.name.toLowerCase().includes(filterLower),
);
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map(mapCommandToOption);
}
@@ -300,49 +300,6 @@ describe("keyboard-shortcuts", () => {
preventDefault: false,
stopPropagation: false,
},
// macOS rewrites event.key when Option is held (Option+T -> "†",
// Option+[ -> "“", Option+Shift+W -> "„", etc.). Every Alt-bound
// letter / bracket shortcut must still resolve.
{
name: "matches Cmd+Alt+T to cycle theme on macOS when Option substitutes event.key",
event: { key: "\u2020", code: "KeyT", metaKey: true, altKey: true },
context: { isMac: true },
action: "theme.cycle",
},
{
name: "matches Alt+Shift+[ to previous tab on macOS when Option substitutes event.key",
event: { key: "\u201D", code: "BracketLeft", altKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.tab.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches Alt+Shift+] to next tab on macOS when Option substitutes event.key",
event: { key: "\u2019", code: "BracketRight", altKey: true, shiftKey: true },
context: { isMac: true },
action: "workspace.tab.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches Alt+[ to previous workspace on macOS web when Option substitutes event.key",
event: { key: "\u201C", code: "BracketLeft", altKey: true },
context: { isMac: true, isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: -1 },
},
{
name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key",
event: { key: "\u2018", code: "BracketRight", altKey: true },
context: { isMac: true, isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: 1 },
},
{
name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key",
event: { key: "\u201E", code: "KeyW", altKey: true, shiftKey: true },
context: { isMac: true, isDesktop: false },
action: "workspace.tab.close.current",
},
];
it.each(matchingCases)(
@@ -444,14 +401,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "v", code: "Period", metaKey: true },
context: { isMac: true, isDesktop: true, focusScope: "message-input" },
},
// Sanity: the macOS Option-substitution fallback must still respect
// modifier checks — pressing Option+T alone (no Cmd) must not trigger
// the Cmd+Alt+T theme-cycle binding.
{
name: "does not cycle theme on macOS when Cmd is missing (Alt+T alone)",
event: { key: "\u2020", code: "KeyT", altKey: true },
context: { isMac: true },
},
];
it.each(nonMatchingCases)("$name", ({ event, context }) => {
+12 -19
View File
@@ -1020,24 +1020,6 @@ function parseDigit(event: KeyboardEvent): number | null {
return null;
}
function matchesKeyOrCode(combo: KeyCombo, event: KeyboardEvent): boolean {
if (combo.key === undefined) {
return event.code === combo.code;
}
const eventKey = event.key.toLowerCase();
if (eventKey === combo.key) return true;
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
return true;
}
// macOS rewrites event.key when Option is held (Option+T -> "†",
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
// keeps its logical-character matching (e.g. Cmd+V on physical Period
// must paste, not trigger Cmd+.).
if (combo.alt === true && event.code === combo.code) return true;
return combo.codeFallback === true && event.code === combo.code;
}
function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): boolean {
if (combo.mod) {
if (isMac) {
@@ -1058,7 +1040,18 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): bo
if (combo.code === "Digit") {
return parseDigit(event) !== null;
}
return matchesKeyOrCode(combo, event);
if (combo.key !== undefined) {
const eventKey = event.key.toLowerCase();
if (eventKey === combo.key) return true;
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
return true;
}
return combo.codeFallback === true && event.code === combo.code;
}
const codeMatch = event.code === combo.code;
return codeMatch;
}
function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {
@@ -4,7 +4,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useRouter } from "expo-router";
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { CommunityLinks } from "@/components/community-links";
import { MenuHeader } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
@@ -107,9 +106,6 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
) : null}
</View>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<PairDeviceModal
visible={isPairDeviceOpen}
onClose={handleClosePairDevice}
@@ -231,17 +227,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
communityRow: {
position: "absolute",
left: 0,
right: 0,
bottom: {
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[2],
md: HEADER_INNER_HEIGHT + theme.spacing[2],
},
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 0,
},
}));
@@ -56,7 +56,6 @@ import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { Button } from "@/components/ui/button";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
DropdownMenu,
@@ -458,9 +457,6 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
<View style={styles.aboutCommunity}>
<CommunityLinks />
</View>
</SettingsSection>
);
}
@@ -1271,9 +1267,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
aboutCommunity: {
marginTop: theme.spacing[4],
},
aboutUpdateActions: {
flexDirection: "row",
alignItems: "center",
@@ -714,12 +714,13 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const slotStyle = isVisible
? styles.mobileMountedTabSlotVisible
: styles.mobileMountedTabSlotHidden;
const slotStyle = useMemo(
() => ({ display: isVisible ? ("flex" as const) : ("none" as const), flex: 1 }),
[isVisible],
);
return (
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<View style={slotStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
@@ -2853,7 +2854,7 @@ function WorkspaceScreenContent({
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
activeTabId,
activeTabId: activeTabDescriptor?.tabId ?? null,
allTabIds: focusedPaneTabIds,
cap: 3,
});
@@ -3653,15 +3654,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
position: "relative",
},
mobileMountedTabSlotVisible: {
...StyleSheet.absoluteFillObject,
opacity: 1,
},
mobileMountedTabSlotHidden: {
...StyleSheet.absoluteFillObject,
opacity: 0,
},
contentPlaceholder: {
flex: 1,
@@ -1,8 +0,0 @@
/**
* Native does not use Unistyles' web CSS registry, and native Unistyles treats
* any `unistyles_*` key as a registered style hash. The web marker would crash
* native ref binding, so native keeps these dynamic styles as plain RN styles.
*/
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
return style;
}
@@ -1,3 +1,23 @@
const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
/**
* Forces a style object through Unistyles' inline/animated-style lane.
*
* Unistyles web sends ordinary style objects to the CSS registry. Styles that
* look like animated styles stay in React Native's style array instead. Use
* this only for high-churn values, such as measured dimensions, drag
* transforms, and pressed/hovered state.
*/
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
return style;
}
Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
value: {},
enumerable: true,
configurable: true,
});
return style;
}
@@ -1,23 +0,0 @@
const UNISTYLES_INLINE_STYLE_KEY = "unistyles_inline_style";
/**
* Forces a style object through Unistyles' inline/animated-style lane.
*
* Unistyles web sends ordinary style objects to the CSS registry. Styles that
* look like animated styles stay in React Native's style array instead. Use
* this only for high-churn values, such as measured dimensions, drag
* transforms, and pressed/hovered state.
*/
export function inlineUnistylesStyle<TStyle extends object>(style: TStyle): TStyle {
if (!Object.isExtensible(style) || UNISTYLES_INLINE_STYLE_KEY in style) {
return style;
}
Object.defineProperty(style, UNISTYLES_INLINE_STYLE_KEY, {
value: {},
enumerable: true,
configurable: true,
});
return style;
}
@@ -1,7 +1,7 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon {
@@ -49,10 +49,6 @@ function nextFrame(): Promise<void> {
});
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
async function waitFor(input: { predicate: () => boolean; timeoutMs?: number }): Promise<void> {
const startedAt = performance.now();
const timeoutMs = input.timeoutMs ?? 2_000;
@@ -257,7 +253,7 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ data: terminalOutput("\x1b[>7u") });
mounted.runtime.write({ text: "\x1b[>7u" });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -283,7 +279,7 @@ describe("terminal emulator runtime in a real browser", () => {
]);
mounted.terminalKeys.length = 0;
mounted.runtime.write({ data: terminalOutput("\x1b[=0;0u\x1b[?9001h") });
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -325,7 +321,7 @@ describe("terminal emulator runtime in a real browser", () => {
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ data: terminalOutput(bytes) });
mounted.runtime.write({ text: bytes });
await nextFrame();
await nextFrame();
@@ -75,10 +75,10 @@ vi.mock("@xterm/xterm", () => ({
},
}));
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
interface StubTerminal {
write: (data: string | Uint8Array, callback?: () => void) => void;
write: (text: string, callback?: () => void) => void;
reset: () => void;
resize?: (cols: number, rows: number) => void;
focus: () => void;
@@ -102,8 +102,8 @@ function createRuntimeWithTerminal(): {
let resetCalls = 0;
const terminal: StubTerminal & { resetCalls: number } = {
write: (data: string | Uint8Array, callback?: () => void) => {
writeTexts.push(decodeTerminalOutput(data));
write: (text: string, callback?: () => void) => {
writeTexts.push(text);
if (callback) {
writeCallbacks.push(callback);
}
@@ -131,17 +131,6 @@ function createRuntimeWithTerminal(): {
};
}
function terminalOutput(text: string): Uint8Array {
return encodeTerminalOutput(text);
}
function decodeTerminalOutput(data: string | Uint8Array): string {
if (typeof data === "string") {
return data;
}
return new TextDecoder().decode(data);
}
describe("terminal-emulator-runtime", () => {
const originalWindow = (globalThis as { window?: unknown }).window;
@@ -162,7 +151,7 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
data: terminalOutput("first"),
text: "first",
onCommitted: () => {
committed.push("first");
},
@@ -173,7 +162,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({
data: terminalOutput("second"),
text: "second",
onCommitted: () => {
committed.push("second");
},
@@ -199,7 +188,7 @@ describe("terminal-emulator-runtime", () => {
const onCommitted = vi.fn();
runtime.write({
data: terminalOutput("stuck"),
text: "stuck",
onCommitted,
});
@@ -219,7 +208,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.write({ text: "\x1b[>7u" });
runtime.renderSnapshot({
state: {
rows: 2,
@@ -245,13 +234,13 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
data: terminalOutput("first"),
text: "first",
onCommitted: () => {
committed.push("first");
},
});
runtime.write({
data: terminalOutput("second"),
text: "second",
onCommitted: () => {
committed.push("second");
},
@@ -273,11 +262,11 @@ describe("terminal-emulator-runtime", () => {
const onCommittedB = vi.fn();
runtime.write({
data: terminalOutput("a"),
text: "a",
onCommitted: onCommittedA,
});
runtime.write({
data: terminalOutput("b"),
text: "b",
onCommitted: onCommittedB,
});
@@ -312,15 +301,6 @@ describe("terminal-emulator-runtime", () => {
expect(writeTexts[0]).toContain("hi");
});
it("restores server-rendered ANSI snapshots through the snapshot write path", () => {
const { runtime, terminal, writeTexts } = createRuntimeWithTerminal();
runtime.restoreOutput({ data: terminalOutput("restored screen") });
expect(terminal.resetCalls).toBe(0);
expect(writeTexts).toEqual(["\u001bcrestored screen"]);
});
it("forces a refit when resize is requested", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
@@ -24,8 +24,6 @@ import {
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalOutputData = Uint8Array;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
@@ -69,7 +67,7 @@ interface TerminalEmulatorRuntimeDisposables {
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
data: TerminalOutputData;
text: string;
rows?: number;
cols?: number;
suppressInput?: boolean;
@@ -98,23 +96,7 @@ const isAppleHandheld =
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
const RESET_TERMINAL_OUTPUT = new Uint8Array([0x1b, 0x63]);
const terminalOutputEncoder = new TextEncoder();
export function encodeTerminalOutput(text: string): TerminalOutputData {
return terminalOutputEncoder.encode(text);
}
function prependTerminalOutput(
prefix: TerminalOutputData,
data: TerminalOutputData,
): TerminalOutputData {
const output = new Uint8Array(prefix.length + data.length);
output.set(prefix, 0);
output.set(data, prefix.length);
return output;
}
const RESET_TERMINAL_ANSI = "\u001bc";
const DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
@@ -158,7 +140,6 @@ export class TerminalEmulatorRuntime {
private outputOperations: TerminalOutputOperation[] = [];
private inFlightOutputOperation: TerminalOutputOperation | null = null;
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
private readonly inputModeDecoder = new TextDecoder();
private suppressInput = false;
private readonly inputModeTracker = new TerminalInputModeTracker();
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
@@ -197,13 +178,14 @@ export class TerminalEmulatorRuntime {
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
customGlyphs: true,
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.0,
macOptionIsMeta: true,
minimumContrastRatio: 1,
rescaleOverlappingGlyphs: true,
scrollbar: {
overviewRuler: {
width: 8,
},
scrollback: input.scrollback,
@@ -547,18 +529,14 @@ export class TerminalEmulatorRuntime {
};
}
write(input: {
data: TerminalOutputData;
suppressInput?: boolean;
onCommitted?: () => void;
}): void {
if (input.data.length === 0) {
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
data: input.data,
text: input.text,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -568,7 +546,7 @@ export class TerminalEmulatorRuntime {
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
data: EMPTY_TERMINAL_OUTPUT,
text: "",
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -580,25 +558,11 @@ export class TerminalEmulatorRuntime {
this.clear(input);
return;
}
this.restoreOutput({
data: encodeTerminalOutput(renderTerminalSnapshotToAnsi(input.state)),
rows: input.state.rows,
cols: input.state.cols,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
}
restoreOutput(input: {
data: TerminalOutputData;
rows?: number;
cols?: number;
onCommitted?: () => void;
}): void {
this.outputOperations.push({
type: "snapshot",
data: prependTerminalOutput(RESET_TERMINAL_OUTPUT, input.data),
rows: input.rows,
cols: input.cols,
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
rows: input.state.rows,
cols: input.state.cols,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -680,7 +644,6 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
}
@@ -702,7 +665,7 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.suppressInput) {
if (operation.type === "write") {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
@@ -717,7 +680,6 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
@@ -726,7 +688,6 @@ export class TerminalEmulatorRuntime {
}
if (operation.type === "snapshot") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
@@ -743,9 +704,8 @@ export class TerminalEmulatorRuntime {
}
}
const data = operation.data;
const text = operation.text;
if (operation.type === "write") {
const text = this.inputModeDecoder.decode(data, { stream: true });
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
@@ -756,7 +716,7 @@ export class TerminalEmulatorRuntime {
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(data, () => {
terminal.write(text, () => {
finalizeOperation(operation);
});
} catch {
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveTerminalRestoreOptions } from "./terminal-restore-options";
describe("terminal restore options", () => {
it("omits restore options for daemons without terminal restore modes", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: false,
size: { rows: 24, cols: 80 },
}),
).toBeUndefined();
});
it("requests visible snapshot restore with bounded scrollback for capable daemons", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: { rows: 24, cols: 80 },
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
});
});
it("omits size until the terminal has been measured", () => {
expect(
resolveTerminalRestoreOptions({
supportsTerminalRestoreModes: true,
size: null,
}),
).toEqual({
mode: "visible-snapshot",
scrollbackLines: 200,
});
});
});
@@ -1,22 +0,0 @@
import type { SubscribeTerminalRequest } from "@server/shared/messages";
export const TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
export interface ResolveTerminalRestoreOptionsInput {
supportsTerminalRestoreModes: boolean;
size: { rows: number; cols: number } | null;
}
export function resolveTerminalRestoreOptions(
input: ResolveTerminalRestoreOptionsInput,
): SubscribeTerminalRequest["restore"] | undefined {
if (!input.supportsTerminalRestoreModes) {
return undefined;
}
return {
mode: "visible-snapshot",
scrollbackLines: TERMINAL_VISIBLE_RESTORE_SCROLLBACK_LINES,
...(input.size ? { size: input.size } : {}),
};
}
@@ -1 +1,205 @@
export { renderTerminalSnapshotToAnsi } from "@server/shared/terminal-snapshot";
import type { TerminalCell, TerminalState } from "@server/shared/messages";
interface 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 [];
}
@@ -14,36 +14,19 @@ interface TerminalSnapshot {
cursor: { row: number; col: number };
}
function terminalCellText(cell: { char: string }): string {
return cell.char;
}
function terminalRowText(row: Array<{ char: string }>): string {
return row.map(terminalCellText).join("");
}
function terminalSnapshotText(state: TerminalSnapshot): string {
return state.grid.map(terminalRowText).join("\n");
}
function terminalOutput(text: string): Uint8Array {
return new TextEncoder().encode(text);
}
type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
| { terminalId: string; type: "snapshot"; state: TerminalSnapshot };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = [];
public subscribeCalls: string[] = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string, options?: unknown) {
this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) });
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
@@ -78,8 +61,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const outputs: Array<{ terminalId: string; text: string }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
@@ -88,13 +70,10 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: terminalSnapshotText(state),
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
});
},
onStatusChange: (status) => {
@@ -102,7 +81,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
},
});
return { client, controller, outputs, restores, snapshots, statuses };
return { client, controller, outputs, snapshots, statuses };
}
async function flushAsyncWork(): Promise<void> {
@@ -129,18 +108,16 @@ describe("terminal-stream-controller", () => {
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
type: "output",
data: outputData,
data: new TextEncoder().encode(" world"),
});
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
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[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -158,7 +135,7 @@ describe("terminal-stream-controller", () => {
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -175,7 +152,7 @@ describe("terminal-stream-controller", () => {
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -183,55 +160,6 @@ describe("terminal-stream-controller", () => {
});
});
it("requests configured restore options and forwards restore output", async () => {
const client = new FakeTerminalStreamClient();
const harness = createHarness({ client });
client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
const controller = new TerminalStreamController({
client,
getPreferredSize: () => ({ rows: 24, cols: 80 }),
getRestoreOptions: () => ({
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
}),
onOutput: (output) => harness.outputs.push(output),
onRestore: (restore) => harness.restores.push(restore),
onSnapshot: ({ terminalId, state }) => {
harness.snapshots.push({
terminalId,
text: terminalSnapshotText(state),
});
},
});
controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
const restoreData = terminalOutput("restored");
client.emit({
terminalId: "term-1",
type: "restore",
data: restoreData,
});
controller.dispose();
expect(client.subscribeCalls).toEqual([
{
terminalId: "term-1",
options: {
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
},
},
]);
expect(harness.restores[0]?.data).toBe(restoreData);
expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]);
expect(harness.outputs).toEqual([]);
});
it("unsubscribes when switching terminals and on dispose", async () => {
const harness = createHarness();
harness.client.nextSubscribeResults.push({ terminalId: "term-1", error: null });
@@ -1,11 +1,7 @@
import type { SubscribeTerminalRequest, TerminalState } from "@server/shared/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
import type { TerminalState } from "@server/shared/messages";
export interface TerminalStreamControllerClient {
subscribeTerminal: (
terminalId: string,
options?: { restore?: SubscribeTerminalRequest["restore"] },
) => Promise<{
subscribeTerminal: (terminalId: string) => Promise<{
terminalId: string;
error?: string | null;
}>;
@@ -18,8 +14,7 @@ export interface TerminalStreamControllerClient {
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array },
| { terminalId: string; type: "snapshot"; state: TerminalState },
) => void,
) => () => void;
}
@@ -38,16 +33,15 @@ export interface TerminalStreamControllerStatus {
export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void;
onOutput: (input: { terminalId: string; text: string }) => void;
onSnapshot: (input: { terminalId: string; state: TerminalState }) => void;
onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void;
getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined;
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
}
const TERMINAL_EXITED_ERROR = "Terminal exited";
export class TerminalStreamController {
private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void;
private terminalId: string | null = null;
private disposed = false;
@@ -58,17 +52,13 @@ export class TerminalStreamController {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
if (event.type === "restore") {
if (event.data.length > 0) {
this.options.onRestore?.({ terminalId: event.terminalId, data: event.data });
}
return;
}
if (event.data.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, data: event.data });
const text = this.decoder.decode(event.data, { stream: true });
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
}
});
}
@@ -80,6 +70,7 @@ export class TerminalStreamController {
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
@@ -87,10 +78,9 @@ export class TerminalStreamController {
this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null });
return;
}
const restore = this.options.getRestoreOptions?.();
this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null });
void this.options.client
.subscribeTerminal(nextTerminalId, restore ? { restore } : undefined)
.subscribeTerminal(nextTerminalId)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
@@ -136,6 +126,7 @@ export class TerminalStreamController {
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
this.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
@@ -149,6 +140,7 @@ export class TerminalStreamController {
return;
}
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {
@@ -1,308 +0,0 @@
import type { ITheme } from "@xterm/xterm";
import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@server/shared/messages";
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
interface MountMessage {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
type InboundMessage =
| MountMessage
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
type OutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
declare global {
interface Window {
ReactNativeWebView?: {
postMessage?: (data: string) => void;
};
__PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void;
}
}
const sendToNative = (message: OutboundMessage): void => {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
};
const installStyles = (): void => {
const style = document.createElement("style");
style.textContent = `
${xtermCss}
html,
body,
#terminal-root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
background: #0b0b0b;
}
#terminal-root {
display: flex;
min-width: 0;
min-height: 0;
}
#terminal-host {
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport {
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
width: 0;
height: 0;
}
`;
document.head.appendChild(style);
};
class TerminalWebViewBridge {
private runtime: TerminalEmulatorRuntime | null = null;
private streamKey: string | null = null;
private swipeGesturesEnabled = false;
private trackingSwipe = false;
private activePointerId: number | null = null;
private startX = 0;
private startY = 0;
private firedSwipe = false;
constructor(
private readonly root: HTMLDivElement,
private readonly host: HTMLDivElement,
) {
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false });
this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true });
this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true });
}
receive = (message: InboundMessage): void => {
try {
this.receiveUnsafe(message);
} catch (error) {
sendToNative({
type: "debug",
message: "terminal webview receive failed",
details: error instanceof Error ? { message: error.message, stack: error.stack } : error,
});
}
};
private receiveUnsafe(message: InboundMessage): void {
if (message.type === "mount") {
this.mount(message);
return;
}
if (message.type === "unmount") {
this.unmount(message.streamKey);
return;
}
if (!this.matches(message.streamKey)) {
return;
}
this.receiveMounted(message);
}
private receiveMounted(
message: Exclude<InboundMessage, MountMessage | { type: "unmount" }>,
): void {
switch (message.type) {
case "writeOutput":
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
break;
case "restoreOutput":
this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) });
break;
case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state });
break;
case "clear":
this.runtime?.clear();
break;
case "focus":
this.runtime?.focus();
break;
case "resize":
this.runtime?.resize({ force: true });
break;
case "setTheme":
this.runtime?.setTheme({ theme: message.theme });
break;
case "setScrollback":
this.runtime?.setScrollback({ lines: message.lines });
break;
case "setPendingModifiers":
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
break;
case "setSwipeGesturesEnabled":
this.swipeGesturesEnabled = message.enabled;
break;
}
}
private mount(message: MountMessage): void {
this.unmount(this.streamKey);
this.streamKey = message.streamKey;
this.swipeGesturesEnabled = message.swipeGesturesEnabled;
document.body.style.backgroundColor = message.theme.background ?? "#0b0b0b";
const runtime = new TerminalEmulatorRuntime();
this.runtime = runtime;
runtime.setCallbacks({
callbacks: {
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols }) =>
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
onTerminalKey: (input) =>
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
onPendingModifiersConsumed: () =>
sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }),
onInputModeChange: (state) =>
sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
onOpenExternalUrl: (url) =>
sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }),
},
});
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
runtime.mount({
root: this.root,
host: this.host,
initialSnapshot: message.initialSnapshot,
scrollback: message.scrollbackLines,
theme: message.theme,
});
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
}
private unmount(streamKey: string | null): void {
if (!this.runtime) {
return;
}
const previousStreamKey = this.streamKey;
this.runtime.unmount();
this.runtime = null;
this.streamKey = null;
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
}
}
private matches(streamKey: string): boolean {
return this.streamKey === streamKey;
}
private handlePointerDown = (event: PointerEvent): void => {
if (!this.swipeGesturesEnabled || !event.isPrimary) {
return;
}
this.trackingSwipe = true;
this.firedSwipe = false;
this.activePointerId = event.pointerId;
this.startX = event.clientX;
this.startY = event.clientY;
};
private handlePointerMove = (event: PointerEvent): void => {
if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) {
return;
}
if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
return;
}
const dx = event.clientX - this.startX;
const dy = event.clientY - this.startY;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDy >= 12 && absDy > absDx) {
this.resetSwipe();
return;
}
if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) {
return;
}
this.firedSwipe = true;
sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey });
if (event.cancelable) event.preventDefault();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
return;
}
this.resetSwipe();
};
private resetSwipe(): void {
this.trackingSwipe = false;
this.activePointerId = null;
this.startX = 0;
this.startY = 0;
this.firedSwipe = false;
}
}
installStyles();
const root = document.createElement("div");
root.id = "terminal-root";
root.dataset.terminalScrollbarRoot = "true";
const host = document.createElement("div");
host.id = "terminal-host";
root.appendChild(host);
document.body.appendChild(root);
const bridge = new TerminalWebViewBridge(root, host);
window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive;
sendToNative({ type: "bridgeReady" });
File diff suppressed because one or more lines are too long
@@ -1,97 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createMarkdownClipboardContent,
type RichClipboardWriter,
writeMarkdownToRichClipboard,
} from "./rich-clipboard";
const { setStringAsyncMock } = vi.hoisted(() => ({
setStringAsyncMock: vi.fn(async () => true),
}));
vi.mock("expo-clipboard", () => ({
setStringAsync: setStringAsyncMock,
}));
beforeEach(() => {
setStringAsyncMock.mockClear();
});
describe("createMarkdownClipboardContent", () => {
it("renders markdown structures to clipboard html", () => {
const markdown = [
"# Heading",
"",
"| Name | Value |",
"| --- | --- |",
"| One | Two |",
"",
"- Parent",
" - Child",
"",
"```ts",
"const value = 1;",
"```",
].join("\n");
const content = createMarkdownClipboardContent(markdown);
expect(content.plainText).toBe(markdown);
expect(content.html).toContain("<h1>Heading</h1>");
expect(content.html).toContain("<table>");
expect(content.html).toContain("<ul>");
expect(content.html).toContain('class="language-ts"');
});
it("escapes raw html instead of placing it on the rich clipboard", () => {
const content = createMarkdownClipboardContent(
'<script>alert("x")</script>\n\n[jump](javascript:alert("x"))',
);
expect(content.html).not.toContain("<script>");
expect(content.html).not.toContain('href="javascript:');
expect(content.html).toContain("&lt;script&gt;");
});
});
describe("writeMarkdownToRichClipboard", () => {
it("writes plain text and html when a rich clipboard writer is available", async () => {
const markdown = "- item";
const writes: Array<Parameters<RichClipboardWriter["write"]>[0]> = [];
const richWriter: RichClipboardWriter = {
supportsHtml: () => true,
write: async (data) => {
writes.push(data);
},
};
await writeMarkdownToRichClipboard(markdown, {
richWriter,
writePlainText: setStringAsyncMock,
});
const written = writes[0];
if (!written) {
throw new Error("Expected rich clipboard data to be written");
}
await expect(written["text/plain"].text()).resolves.toBe(markdown);
await expect(written["text/html"].text()).resolves.toContain("<li>item</li>");
expect(setStringAsyncMock).not.toHaveBeenCalled();
});
it("falls back to plain text when rich clipboard writing fails", async () => {
const richWriter: RichClipboardWriter = {
supportsHtml: () => true,
write: async () => {
throw new Error("clipboard denied");
},
};
await writeMarkdownToRichClipboard("**bold**", {
richWriter,
writePlainText: setStringAsyncMock,
});
expect(setStringAsyncMock).toHaveBeenCalledWith("**bold**");
});
});
-81
View File
@@ -1,81 +0,0 @@
import * as Clipboard from "expo-clipboard";
import MarkdownIt from "markdown-it";
import { isWeb } from "@/constants/platform";
const markdownRenderer = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
});
type ClipboardMimeType = "text/plain" | "text/html";
export interface MarkdownClipboardContent {
plainText: string;
html: string;
}
export interface RichClipboardWriter {
supportsHtml: () => boolean;
write: (data: Record<ClipboardMimeType, Blob>) => Promise<void>;
}
export interface MarkdownClipboardEnvironment {
richWriter?: RichClipboardWriter | null;
writePlainText: (text: string) => Promise<unknown>;
}
export function createMarkdownClipboardContent(markdown: string): MarkdownClipboardContent {
return {
plainText: markdown,
html: `<meta charset="utf-8">${markdownRenderer.render(markdown)}`,
};
}
export async function writeMarkdownToRichClipboard(
markdown: string,
environment: MarkdownClipboardEnvironment = getDefaultMarkdownClipboardEnvironment(),
): Promise<void> {
if (environment.richWriter?.supportsHtml()) {
const content = createMarkdownClipboardContent(markdown);
try {
await environment.richWriter.write({
"text/plain": new Blob([content.plainText], { type: "text/plain" }),
"text/html": new Blob([content.html], { type: "text/html" }),
});
return;
} catch {
// Fall through to the plain-text path. Some webviews expose rich clipboard
// APIs but deny writes depending on focus, permissions, or browser policy.
}
}
await environment.writePlainText(markdown);
}
function getDefaultMarkdownClipboardEnvironment(): MarkdownClipboardEnvironment {
return {
richWriter: getWebRichClipboardWriter(),
writePlainText: (text) => Clipboard.setStringAsync(text),
};
}
function getWebRichClipboardWriter(): RichClipboardWriter | null {
if (!isWeb) {
return null;
}
if (typeof navigator === "undefined" || typeof navigator.clipboard?.write !== "function") {
return null;
}
if (typeof ClipboardItem === "undefined") {
return null;
}
return {
supportsHtml: () =>
typeof ClipboardItem.supports !== "function" || ClipboardItem.supports("text/html"),
write: async (data) => {
await navigator.clipboard.write([new ClipboardItem(data)]);
},
};
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.81",
"version": "0.1.79",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.81",
"@getpaseo/server": "0.1.79",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.81",
"version": "0.1.79",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+2 -9
View File
@@ -54,8 +54,6 @@ import { runDesktopStartup } from "./desktop-startup.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
@@ -110,7 +108,7 @@ const FORWARDED_PASEO_SHORTCUT_KEYS = new Set([
]);
const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName(APP_NAME);
app.setName("Paseo");
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
@@ -376,7 +374,7 @@ async function createMainWindow(): Promise<void> {
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
const mainWindow = new BrowserWindow({
title,
width: 1200,
@@ -524,11 +522,6 @@ function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
// ---------------------------------------------------------------------------
function setupSingleInstanceLock(): boolean {
if (DISABLE_SINGLE_INSTANCE_LOCK) {
log.info("[single-instance] disabled by PASEO_DISABLE_SINGLE_INSTANCE_LOCK");
return true;
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.81",
"version": "0.1.79",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.81",
"version": "0.1.79",
"files": [
"dist"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.81",
"version": "0.1.79",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.81",
"version": "0.1.79",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -63,8 +63,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.81",
"@getpaseo/relay": "0.1.81",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -78,7 +78,6 @@
"fast-deep-equal": "^3.1.3",
"mnemonic-id": "^3.2.7",
"node-pty": "1.2.0-beta.11",
"onnxruntime-node": "^1.23.0",
"openai": "^4.20.0",
"p-limit": "^7.3.0",
"p-memoize": "^8.0.0",
@@ -86,8 +85,6 @@
"pino-pretty": "^13.1.3",
"qrcode": "^1.5.4",
"rotating-file-stream": "^3.2.9",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"uuid": "^9.0.1",
@@ -105,6 +102,9 @@
"@types/ws": "^8.5.8",
"@vitest/ui": "^4.1.6",
"cross-env": "^10.1.0",
"onnxruntime-node": "^1.23.0",
"sherpa-onnx": "1.12.28",
"sherpa-onnx-node": "1.12.28",
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
+1 -10
View File
@@ -67,7 +67,6 @@ import type {
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
SubscribeTerminalRequest,
CloseItemsResponse,
KillTerminalResponse,
CaptureTerminalResponse,
@@ -3771,19 +3770,13 @@ export class DaemonClient {
async subscribeTerminal(
terminalId: string,
optionsOrRequestId?:
| { restore?: SubscribeTerminalRequest["restore"]; requestId?: string }
| string,
requestId?: string,
): Promise<SubscribeTerminalPayload> {
const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined;
const requestId =
typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId;
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "subscribe_terminal_request",
terminalId,
requestId: resolvedRequestId,
...(restore ? { restore } : {}),
});
const payload = await this.sendCorrelatedRequest({
requestId: resolvedRequestId,
@@ -4370,8 +4363,6 @@ export class DaemonClient {
frameKind = "output";
} else if (frame.opcode === TerminalStreamOpcode.Snapshot) {
frameKind = "snapshot";
} else if (frame.opcode === TerminalStreamOpcode.Restore) {
frameKind = "output";
}
this.runtimeMetrics?.recordBinaryFrame(
frameKind,
@@ -1,28 +0,0 @@
import { describe, expect, test } from "vitest";
import { TerminalStreamOpcode } from "../shared/binary-frames/index.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
describe("terminal-stream-router", () => {
test("routes restore frames as restore events", () => {
const router = new TerminalStreamRouter();
const events: TerminalStreamEvent[] = [];
const payload = new TextEncoder().encode("restored screen");
router.setSlot("term-1", 7);
router.onEvent((event) => events.push(event));
router.handleFrame({
opcode: TerminalStreamOpcode.Restore,
slot: 7,
payload,
});
expect(events).toEqual([
{
terminalId: "term-1",
type: "restore",
data: payload,
},
]);
});
});
@@ -9,8 +9,7 @@ import type { TerminalInput, TerminalState } from "../shared/messages.js";
export type TerminalStreamEvent =
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array };
| { terminalId: string; type: "snapshot"; state: TerminalState };
export class TerminalStreamRouter {
private readonly terminalSlots = new Map<string, number>();
@@ -98,15 +97,6 @@ export class TerminalStreamRouter {
return;
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
this.emit({
terminalId,
type: "restore",
data: frame.payload,
});
return;
}
if (frame.opcode === TerminalStreamOpcode.Snapshot) {
const state = decodeTerminalSnapshotPayload(frame.payload);
if (!state) {
@@ -132,19 +132,25 @@ export async function generateAndApplyAgentMetadata(
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
logger: options.logger,
agentConfigOverrides: {
title: "Agent metadata generator",
internal: true,
},
});
} catch (error) {
const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Structured metadata generation failed",
);
return;
}
options.logger.error(
{ err: error, agentId: options.agentId, attempts },
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
? "Structured metadata generation failed"
: "Agent metadata generation failed",
{ err: error, agentId: options.agentId },
"Agent metadata generation failed",
);
return;
}
@@ -5,11 +5,6 @@ import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
import type { AgentManager } from "./agent-manager.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
export interface StructuredGenerationLogger {
info: (obj: object, msg?: string) => void;
warn: (obj: object, msg?: string) => void;
}
export type JsonSchema = Record<string, unknown>;
export type AgentCaller = (prompt: string) => Promise<string>;
@@ -95,7 +90,6 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
persistSession?: boolean;
maxRetries?: number;
schemaName?: string;
logger?: StructuredGenerationLogger;
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
}
@@ -403,7 +397,6 @@ export async function generateStructuredAgentResponseWithFallback<T>(
persistSession,
maxRetries,
schemaName,
logger,
runner,
} = options;
@@ -421,17 +414,12 @@ export async function generateStructuredAgentResponseWithFallback<T>(
for (const candidate of providers) {
const availabilityEntry = availabilityByProvider.get(candidate.provider);
if (availabilityEntry && !availabilityEntry.available) {
const reason = availabilityEntry.error ?? "unavailable";
attempts.push({
provider: candidate.provider,
model: candidate.model ?? null,
available: false,
error: availabilityEntry.error ?? null,
});
logger?.warn(
{ provider: candidate.provider, model: candidate.model, schemaName, reason },
"Structured generation: skipping unavailable provider",
);
continue;
}
@@ -451,17 +439,6 @@ export async function generateStructuredAgentResponseWithFallback<T>(
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
},
});
if (attempts.length > 0) {
logger?.info(
{
provider: candidate.provider,
model: candidate.model,
schemaName,
priorAttempts: attempts,
},
"Structured generation: succeeded after fallback",
);
}
return result;
} catch (error) {
attempts.push({
@@ -470,10 +447,6 @@ export async function generateStructuredAgentResponseWithFallback<T>(
available: true,
error: errorMessage(error),
});
logger?.warn(
{ err: error, provider: candidate.provider, model: candidate.model, schemaName },
"Structured generation: provider failed, trying next",
);
}
}
@@ -128,7 +128,6 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
cwd,
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
firstPrompt: "stored prompt",
}),
makeDescriptor({
sessionId: "older-session",
@@ -168,7 +167,6 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
cwd,
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
firstPrompt: "live prompt",
}),
];
const listImportablePersistedAgents = vi.fn(async () => descriptors);
@@ -123,9 +123,6 @@ export async function listImportableProviderSessions(
if (isMetadataGenerationDescriptor(descriptor)) {
continue;
}
if (!hasUserPrompt(descriptor)) {
continue;
}
const providerHandleId =
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
@@ -330,12 +327,6 @@ function isMetadataGenerationDescriptor(descriptor: PersistedAgentDescriptor): b
return false;
}
function hasUserPrompt(descriptor: PersistedAgentDescriptor): boolean {
return descriptor.timeline.some(
(item) => item.type === "user_message" && item.text.trim() !== "",
);
}
function collectProviderSessionHandleKeys(
target: Set<string>,
provider: AgentProvider | StoredAgentRecord["provider"] | string,
@@ -585,46 +585,6 @@ describe("OpenCode adapter context-window normalization", () => {
});
describe("OpenCode adapter startTurn error handling", () => {
test("dynamically adds injected MCP servers without config-backed connect", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
try {
const session = await client.createSession({
provider: "opencode",
cwd,
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test-agent",
},
},
});
await collectTurnEvents(streamSession(session, "hello"));
expect(openCodeClient.calls.mcpAdd).toEqual([
{
directory: cwd,
name: "paseo",
config: {
type: "remote",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test-agent",
enabled: true,
},
},
]);
expect(openCodeClient.calls.mcpConnect).toEqual([]);
await session.close();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("emits turn_started before live OpenCode timeline items", async () => {
const eventsGate = createTestDeferred<void>();
const globalEvents = [
@@ -3170,10 +3170,16 @@ class OpenCodeAgentSession implements AgentSession {
config,
}),
);
await this.runMcpOperation("connect", name, () =>
this.client.mcp.connect({
directory: this.config.cwd,
name,
}),
);
}
private async runMcpOperation(
operation: "add",
operation: "add" | "connect",
name: string,
run: () => Promise<{ error?: unknown }>,
): Promise<void> {
@@ -56,8 +56,6 @@ export class TestOpenCodeClient {
eventSubscribe: [] as unknown[],
experimentalSessionList: [] as unknown[],
globalEvent: [] as unknown[],
mcpAdd: [] as unknown[],
mcpConnect: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
@@ -76,8 +74,6 @@ export class TestOpenCodeClient {
commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown>;
experimentalSessionListResponse: OpenCodeResponse = { data: [] };
mcpAddResponse: OpenCodeResponse = {};
mcpConnectResponse: OpenCodeResponse = {};
permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
@@ -139,14 +135,8 @@ export class TestOpenCodeClient {
},
},
mcp: {
add: async (parameters: unknown) => {
this.calls.mcpAdd.push(parameters);
return this.mcpAddResponse;
},
connect: async (parameters: unknown) => {
this.calls.mcpConnect.push(parameters);
return this.mcpConnectResponse;
},
add: async () => ({}),
connect: async () => ({}),
},
permission: {
reply: async (parameters: unknown) => {
@@ -102,20 +102,6 @@ class SessionEvents {
);
}
nextPermissionRequest(): Promise<Extract<AgentStreamEvent, { type: "permission_requested" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
}
nextPermissionResolution(): Promise<Extract<AgentStreamEvent, { type: "permission_resolved" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
}
private nextEvent<T extends AgentStreamEvent>(
predicate: (event: AgentStreamEvent) => event is T,
): Promise<T> {
@@ -133,125 +119,6 @@ class SessionEvents {
}
describe("PiRpcAgentSession", () => {
test("bridges Pi RPC select extension UI requests through question permissions", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("ask");
fakeSession.emit({
type: "extension_ui_request",
id: "ui-1",
method: "select",
title: "Pick one",
options: ["A", "B"],
});
const permission = await events.nextPermissionRequest();
expect(permission.request).toMatchObject({
id: "ui-1",
provider: "pi",
kind: "question",
title: "Pick one",
input: {
questions: [
{
question: "Pick one",
header: "Response",
options: [{ label: "A" }, { label: "B" }],
multiSelect: false,
},
],
},
metadata: { extensionUiMethod: "select" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission("ui-1", {
behavior: "allow",
updatedInput: { answers: { Response: "B" } },
});
expect(fakeSession.extensionUiResponses).toEqual([{ id: "ui-1", response: { value: "B" } }]);
expect(session.getPendingPermissions()).toEqual([]);
await expect(events.nextPermissionResolution()).resolves.toMatchObject({
requestId: "ui-1",
resolution: { behavior: "allow" },
});
});
test("bridges Pi RPC input and confirm extension UI responses", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emit({
type: "extension_ui_request",
id: "input-1",
method: "input",
title: "Your name",
placeholder: "name",
});
await events.nextPermissionRequest();
await session.respondToPermission("input-1", {
behavior: "allow",
updatedInput: { answers: { Response: "Ada" } },
});
fakeSession.emit({
type: "extension_ui_request",
id: "confirm-1",
method: "confirm",
title: "Proceed?",
});
await events.nextPermissionRequest();
await session.respondToPermission("confirm-1", {
behavior: "allow",
updatedInput: { answers: { Response: "No" } },
});
expect(fakeSession.extensionUiResponses).toEqual([
{ id: "input-1", response: { value: "Ada" } },
{ id: "confirm-1", response: { confirmed: false } },
]);
});
test("cancels Pi RPC extension UI dialogs when question permission is denied", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emit({
type: "extension_ui_request",
id: "ui-cancel",
method: "select",
title: "Pick one",
options: ["A", "B"],
});
await events.nextPermissionRequest();
await session.respondToPermission("ui-cancel", {
behavior: "deny",
message: "Dismissed by user",
});
expect(fakeSession.extensionUiResponses).toEqual([
{ id: "ui-cancel", response: { cancelled: true } },
]);
});
test("ignores Pi RPC fire-and-forget extension UI requests", async () => {
const { pi } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emit({
type: "extension_ui_request",
id: "notify-1",
method: "notify",
message: "hello",
});
expect(fakeSession.extensionUiResponses).toEqual([]);
expect(fakeSession.canceledExtensionUiRequests).toEqual([]);
});
test("streams assistant text, reasoning, and tool calls from Pi events", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -403,108 +403,6 @@ function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
: null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function mapExtensionUiRequestToPermission(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
): AgentPermissionRequest | null {
switch (event.method) {
case "select":
return buildExtensionUiQuestionPermission(event, {
question: optionalString(event.title) ?? "Select an option",
options: Array.isArray(event.options)
? event.options.filter((option): option is string => typeof option === "string")
: [],
multiSelect: false,
});
case "input":
return buildExtensionUiQuestionPermission(event, {
question: optionalString(event.title) ?? "Enter a value",
options: [],
multiSelect: false,
});
case "editor":
return buildExtensionUiQuestionPermission(event, {
question: optionalString(event.title) ?? "Edit text",
options: [],
multiSelect: false,
});
case "confirm":
return buildExtensionUiQuestionPermission(event, {
question: [optionalString(event.title), optionalString(event.message)]
.filter(Boolean)
.join("\n\n"),
options: ["Yes", "No"],
multiSelect: false,
});
default:
return null;
}
}
function buildExtensionUiQuestionPermission(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
input: { question: string; options: string[]; multiSelect: boolean },
): AgentPermissionRequest {
const header = "Response";
return {
id: event.id,
provider: PI_PROVIDER,
name: `Pi ${event.method}`,
kind: "question",
title: input.question,
input: {
questions: [
{
question: input.question,
header,
options: input.options.map((label) => ({ label })),
multiSelect: input.multiSelect,
},
],
},
metadata: {
extensionUiMethod: event.method,
answerHeader: header,
},
};
}
function firstPermissionAnswer(input: AgentMetadata | undefined): string | null {
const answers = isRecord(input?.answers) ? input.answers : null;
if (!answers) {
return null;
}
const first = Object.values(answers).find((value) => typeof value === "string");
return typeof first === "string" ? first : null;
}
function buildExtensionUiResponse(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): { value?: string; confirmed?: boolean; cancelled?: boolean } {
if (response.behavior === "deny") {
return { cancelled: true };
}
const method = optionalString(request.metadata?.extensionUiMethod);
const answer = firstPermissionAnswer(response.updatedInput);
if (answer === null) {
return { cancelled: true };
}
if (method === "confirm") {
return { confirmed: /^yes$/i.test(answer.trim()) };
}
return { value: answer };
}
function mapPiModel(model: PiModel): AgentModelDefinition {
return {
provider: PI_PROVIDER,
@@ -530,7 +428,6 @@ export class PiRpcAgentSession implements AgentSession {
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly activeToolCalls = new Map<string, PiTrackedToolCall>();
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
private activeTurnId: string | null = null;
private lastKnownThinkingOptionId: string | null;
private state: PiSessionState;
@@ -643,27 +540,12 @@ export class PiRpcAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
return [...this.pendingExtensionUiRequests.values()];
return [];
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
const request = this.pendingExtensionUiRequests.get(requestId);
if (!request) {
throw new Error(`No pending permission request with id '${requestId}'`);
}
this.pendingExtensionUiRequests.delete(requestId);
this.runtimeSession.respondToExtensionUiRequest(
requestId,
buildExtensionUiResponse(request, response),
);
this.emit({
type: "permission_resolved",
provider: PI_PROVIDER,
requestId,
resolution: response,
turnId: this.currentTurnIdForEvent(),
});
void requestId;
void response;
}
describePersistence(): AgentPersistenceHandle | null {
@@ -742,26 +624,9 @@ export class PiRpcAgentSession implements AgentSession {
return this.activeTurnId ?? undefined;
}
private handleExtensionUiRequest(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
): void {
const request = mapExtensionUiRequestToPermission(event);
if (!request) {
return;
}
this.pendingExtensionUiRequests.set(request.id, request);
this.emit({
type: "permission_requested",
provider: PI_PROVIDER,
request,
turnId: this.currentTurnIdForEvent(),
});
}
private handleRuntimeEvent(event: PiRuntimeEvent): void {
if (event.type === "extension_ui_request") {
this.handleExtensionUiRequest(event);
this.runtimeSession.cancelExtensionUiRequest(event.id);
return;
}
if (event.type === "process_exit") {
@@ -166,15 +166,8 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return data.commands ?? [];
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.writeJsonLine({ type: "extension_ui_response", id, ...response });
}
cancelExtensionUiRequest(id: string): void {
this.respondToExtensionUiRequest(id, { cancelled: true });
this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
}
async close(): Promise<void> {
@@ -43,10 +43,6 @@ export interface PiRuntimeSession {
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
@@ -55,10 +55,6 @@ export class FakePiSession implements PiRuntimeSession {
readonly setThinkingLevelRequests: string[] = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
id: string;
response: { value?: string; confirmed?: boolean; cancelled?: boolean };
}> = [];
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
@@ -134,16 +130,8 @@ export class FakePiSession implements PiRuntimeSession {
return this.commands;
}
respondToExtensionUiRequest(
id: string,
response: { value?: string; confirmed?: boolean; cancelled?: boolean },
): void {
this.extensionUiResponses.push({ id, response });
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {}
+1
View File
@@ -841,6 +841,7 @@ export async function createPaseoDaemon(
const speechService = createSpeechService({
logger,
paseoHome: config.paseoHome,
openaiConfig: config.openai,
speechConfig: config.speech,
});
@@ -650,7 +650,6 @@ test("receives server_info on websocket connect", async () => {
const serverInfo = client.getLastServerInfoMessage();
expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
await client.close();
}, 15000);
@@ -46,13 +46,13 @@ function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): st
}
async function waitForCondition(
predicate: () => boolean | Promise<boolean>,
predicate: () => boolean,
timeoutMs: number,
intervalMs = 25,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await predicate()) {
if (predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -377,9 +377,6 @@ function getFrameText(frame: TerminalStreamFrame): string {
}
return extractStateText(state);
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
return "";
}
@@ -675,11 +672,6 @@ async function subscribeRawTerminal(
ws: WebSocket,
terminalId: string,
requestId: string,
restore?: {
mode: "live" | "visible-snapshot" | "full-snapshot";
scrollbackLines?: number;
size?: { rows: number; cols: number };
},
): Promise<number> {
const ready = waitForRawSessionMessage(
ws,
@@ -696,7 +688,6 @@ async function subscribeRawTerminal(
type: "subscribe_terminal_request",
terminalId,
requestId,
...(restore ? { restore } : {}),
},
}),
);
@@ -762,95 +753,6 @@ test("client connects and receives a snapshot of the current terminal state", as
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("live terminal restore skips the initial snapshot", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
try {
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf 'before-live\\n'\r",
});
await new Promise((resolve) => setTimeout(resolve, 300));
const slot = await subscribeRawTerminal(ws, terminalId, "subscribe-live", { mode: "live" });
const outputFramesPromise = collectRawBinaryFrames(
ws,
(frames) => frames.some((frame) => getFrameText(frame).includes("after-live")),
10000,
);
ws.send(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Input,
slot,
payload: "printf 'after-live\\n'\r",
}),
);
const outputFrames = await outputFramesPromise;
const outputText = outputFrames.map((frame) => getFrameText(frame)).join("");
expect(outputText).toContain("after-live");
expect(outputText).not.toContain("before-live");
expect(
outputFrames.some(
(frame) =>
frame.opcode === TerminalStreamOpcode.Snapshot ||
frame.opcode === TerminalStreamOpcode.Restore,
),
).toBe(false);
} finally {
await closeWebSocket(ws);
}
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("visible terminal restore sends bounded ANSI history", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
command: "/bin/sh",
args: [
"-lc",
"i=1; while [ $i -le 1200 ]; do printf 'restore-line-%04d\\n' $i; i=$((i+1)); done; sleep 30",
],
});
const terminalId = created.terminal!.id;
const ws = await connectRawWebSocket(ctx.daemon.port);
try {
await waitForCondition(async () => {
const capture = await ctx.client.captureTerminal(terminalId);
return capture.lines.join("\n").includes("restore-line-1200");
}, 10000);
const restoreFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Restore,
10000,
);
await subscribeRawTerminal(ws, terminalId, "subscribe-visible", {
mode: "visible-snapshot",
scrollbackLines: 5,
size: { rows: 10, cols: 80 },
});
const restoreFrame = await restoreFramePromise;
const restoreText = getFrameText(restoreFrame);
const restoredLines = restoreText.match(/restore-line-\d{4}/g) ?? [];
expect(restoredLines[0]).toBe("restore-line-1187");
expect(restoredLines.at(-1)).toBe("restore-line-1200");
expect(restoreText).not.toContain("restore-line-1186");
expect(restoreText).not.toContain("restore-line-0001");
expect(restoredLines.length).toBeLessThanOrEqual(15);
} finally {
await closeWebSocket(ws);
}
rmSync(cwd, { recursive: true, force: true });
}, 30000);
test("propagates debounced terminal titles through list responses and snapshots", async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd, undefined, undefined, {
@@ -1164,13 +1066,13 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
});
await new Promise((resolve) => setTimeout(resolve, 750));
const catchUpFramePromise = waitForRawBinaryFrame(
rawSocket!.resume();
const catchUpFrame = await waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
15000,
);
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
await closeWebSocket(ws);
+2 -1
View File
@@ -2658,11 +2658,12 @@ export class Session {
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
const missingModelIds = state.missingAssets.find((asset) => asset.kind === "models")?.ids ?? [];
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
missingModelIds: missingModelIds as LocalSpeechModelId[],
};
}
@@ -1917,7 +1917,6 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
cwd: "/tmp/recent",
title: "Already stored",
lastActivityAt: "2026-04-30T12:04:00.000Z",
firstPrompt: "stored prompt",
}),
makePersistedProviderSession({
provider: "claude",
@@ -1968,7 +1967,6 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
firstPrompt: "live prompt",
}),
];
// The real AgentManager filters by providerFilter at the fan-out level
@@ -2098,7 +2096,6 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
firstPrompt: "live prompt",
}),
];
@@ -11,6 +11,7 @@ import {
pcm16leToFloat32,
} from "../../../audio.js";
import { Pcm16MonoResampler } from "../../../../agent/pcm16-resampler.js";
import { loadLocalSpeechPackage } from "../runtime/index.js";
type OrtModule = typeof import("onnxruntime-node");
type OrtSession = import("onnxruntime-node").InferenceSession;
@@ -98,7 +99,9 @@ function normalizeTextForPocket(text: string): string {
}
async function loadOrt(): Promise<OrtModule> {
return import("onnxruntime-node");
return loadLocalSpeechPackage<OrtModule>("onnxruntime-node", {
requireFrom: import.meta.url,
});
}
interface SentencePieceModule {
@@ -0,0 +1,20 @@
import type { LocalSpeechRuntimePackageId } from "./package-specs.js";
export interface LocalSpeechRuntimeStatus {
runtimeDir: string;
missingPackageIds: LocalSpeechRuntimePackageId[];
}
export class LocalSpeechRuntimeUnavailableError extends Error {
public readonly code = "local_speech_runtime_not_installed";
public readonly runtimeDir: string;
public readonly missingPackageIds: LocalSpeechRuntimePackageId[];
constructor(status: LocalSpeechRuntimeStatus) {
const missing = status.missingPackageIds.join(", ");
super(`Local voice runtime is downloading / not installed (${missing}).`);
this.name = "LocalSpeechRuntimeUnavailableError";
this.runtimeDir = status.runtimeDir;
this.missingPackageIds = [...status.missingPackageIds];
}
}
@@ -0,0 +1,10 @@
export { LocalSpeechRuntimeUnavailableError } from "./errors.js";
export type { LocalSpeechRuntimeStatus } from "./errors.js";
export { ensureLocalSpeechRuntime } from "./install.js";
export {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
loadLocalSpeechPackage,
} from "./load-package.js";
export { type LocalSpeechRuntimePackageId } from "./package-specs.js";
export { getLocalSpeechRuntimeStatus } from "./status.js";
@@ -0,0 +1,58 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ensureLocalSpeechRuntime } from "./index.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
const logger = pino({ level: "silent" });
function makePaseoHome(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-speech-runtime-"));
}
function writeRuntimePackageFiles(paseoHome: string): void {
for (const spec of getLocalSpeechRuntimePackageSpecs()) {
const packageDir = getLocalSpeechRuntimePackageDir(paseoHome, spec.packageName);
for (const relPath of spec.requiredFiles) {
const absPath = path.join(packageDir, relPath);
mkdirSync(path.dirname(absPath), { recursive: true });
writeFileSync(absPath, "x");
}
if (spec.id === "onnxruntime-node") {
const bindingPath = path.join(
packageDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
);
mkdirSync(path.dirname(bindingPath), { recursive: true });
writeFileSync(bindingPath, "x");
}
}
}
describe("local speech runtime installer", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("ensureLocalSpeechRuntime succeeds without downloading when packages are installed", async () => {
const paseoHome = makePaseoHome();
writeRuntimePackageFiles(paseoHome);
const fetchMock = vi.spyOn(globalThis, "fetch");
const status = await ensureLocalSpeechRuntime({
paseoHome,
logger,
});
expect(status.missingPackageIds).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type pino from "pino";
import {
downloadToFile,
extractTarArchive,
hasRequiredFiles,
isNonEmptyFile,
} from "../sherpa/model-downloader.js";
import { LocalSpeechRuntimeUnavailableError, type LocalSpeechRuntimeStatus } from "./errors.js";
import {
getLocalSpeechRuntimePackageSpecs,
type LocalSpeechRuntimePackageSpec,
} from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
import { getLocalSpeechRuntimeStatus } from "./status.js";
function npmTarballUrl(packageName: string, version: string): string {
const escapedName = packageName.startsWith("@") ? packageName.replace("/", "%2f") : packageName;
const basename = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
return `https://registry.npmjs.org/${escapedName}/-/${basename}-${version}.tgz`;
}
async function installPackageFromTarball(params: {
runtimeDir: string;
spec: LocalSpeechRuntimePackageSpec;
}): Promise<void> {
const { runtimeDir, spec } = params;
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
if (
(await hasRequiredFiles(packageDir, spec.requiredFiles)) &&
(!spec.validate || spec.validate(packageDir))
) {
return;
}
const downloadsDir = path.join(runtimeDir, ".downloads");
const archivePath = path.join(
downloadsDir,
`${spec.packageName.replace("/", "__")}-${spec.version}.tgz`,
);
if (!(await isNonEmptyFile(archivePath))) {
await downloadToFile({
url: npmTarballUrl(spec.packageName, spec.version),
outputPath: archivePath,
});
}
const extractDir = path.join(
runtimeDir,
".extract",
`${spec.packageName.replace("/", "__")}-${Date.now()}`,
);
const extractedPackageDir = path.join(extractDir, "package");
const nextPackageDir = `${packageDir}.tmp-${Date.now()}`;
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await mkdir(path.dirname(packageDir), { recursive: true });
await extractTarArchive(archivePath, extractDir);
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(extractedPackageDir, nextPackageDir);
if (
!(await hasRequiredFiles(nextPackageDir, spec.requiredFiles)) ||
(spec.validate && !spec.validate(nextPackageDir))
) {
await rm(nextPackageDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error(
`Downloaded ${spec.packageName}@${spec.version}, but required runtime files are missing.`,
);
}
await rm(packageDir, { recursive: true, force: true }).catch(() => undefined);
await rename(nextPackageDir, packageDir);
await rm(extractDir, { recursive: true, force: true }).catch(() => undefined);
await rm(archivePath, { force: true }).catch(() => undefined);
}
export async function ensureLocalSpeechRuntime(options: {
paseoHome: string;
logger: pino.Logger;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const logger = options.logger.child({
module: "speech",
provider: "local",
component: "runtime-installer",
});
const runtimeDir = getLocalSpeechRuntimeDir(options.paseoHome);
const specs = getLocalSpeechRuntimePackageSpecs(options.platform, options.arch);
const before = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (before.missingPackageIds.length === 0) {
return before;
}
logger.info(
{
runtimeDir,
missingPackageIds: before.missingPackageIds,
},
"Starting local speech runtime install",
);
for (const spec of specs) {
await installPackageFromTarball({ runtimeDir, spec });
}
const after = await getLocalSpeechRuntimeStatus({
paseoHome: options.paseoHome,
platform: options.platform,
arch: options.arch,
});
if (after.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(after);
}
logger.info({ runtimeDir }, "Local speech runtime install completed");
return after;
}
@@ -0,0 +1,74 @@
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { LocalSpeechRuntimeUnavailableError } from "./errors.js";
import { getLocalSpeechRuntimePackageDir } from "./paths.js";
import { getLocalSpeechRuntimeStatusSync } from "./status.js";
import {
prependEnvPath,
sherpaLoaderEnvKey,
sherpaPlatformPackageName,
} from "../sherpa/sherpa-runtime-env.js";
let configuredPaseoHome: string | null = null;
export function configureLocalSpeechRuntimeHome(paseoHome: string): void {
configuredPaseoHome = paseoHome;
}
export function clearLocalSpeechRuntimeHome(): void {
configuredPaseoHome = null;
}
function findEnvKey(env: NodeJS.ProcessEnv, key: string): string {
const lower = key.toLowerCase();
for (const k of Object.keys(env)) {
if (k.toLowerCase() === lower) return k;
}
return key;
}
function applyConfiguredSherpaLoaderEnv(): void {
if (!configuredPaseoHome) {
return;
}
const envKey = sherpaLoaderEnvKey();
if (!envKey) {
return;
}
const platformPackageDir = getLocalSpeechRuntimePackageDir(
configuredPaseoHome,
sherpaPlatformPackageName(),
);
const actualKey = findEnvKey(process.env, envKey);
process.env[actualKey] = prependEnvPath(process.env[actualKey], platformPackageDir);
}
export function loadLocalSpeechPackage<T>(
packageName: string,
options?: { requireFrom?: string },
): T {
const devRequire = createRequire(options?.requireFrom ?? import.meta.url);
if (!configuredPaseoHome) {
return devRequire(packageName) as T;
}
if (packageName === "sherpa-onnx-node") {
applyConfiguredSherpaLoaderEnv();
}
const status = getLocalSpeechRuntimeStatusSync({ paseoHome: configuredPaseoHome });
if (status.missingPackageIds.length > 0) {
throw new LocalSpeechRuntimeUnavailableError(status);
}
const runtimePackageDir = getLocalSpeechRuntimePackageDir(configuredPaseoHome, packageName);
const runtimePackageJson = path.join(runtimePackageDir, "package.json");
if (existsSync(runtimePackageJson)) {
const runtimeRequire = createRequire(runtimePackageJson);
return runtimeRequire(runtimePackageDir) as T;
}
throw new LocalSpeechRuntimeUnavailableError(status);
}
@@ -0,0 +1,85 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { sherpaPlatformPackageName } from "../sherpa/sherpa-runtime-env.js";
const SHERPA_ONNX_VERSION = "1.12.28";
const ONNXRUNTIME_VERSION = "1.24.3";
export type LocalSpeechRuntimePackageId =
| "sherpa-onnx"
| "sherpa-onnx-node"
| "sherpa-platform"
| "onnxruntime-common"
| "onnxruntime-node";
export interface LocalSpeechRuntimePackageSpec {
id: LocalSpeechRuntimePackageId;
packageName: string;
version: string;
requiredFiles: string[];
validate?: (packageDir: string) => boolean;
}
function hasOnnxruntimeNativeBinding(
packageDir: string,
platform: NodeJS.Platform,
arch: string,
): boolean {
try {
const binDir = path.join(packageDir, "bin");
const napiDirs = readdirSync(binDir).filter((entry) => entry.startsWith("napi-"));
return napiDirs.some((napiDir) => {
const archDir = path.join(binDir, napiDir, platform, arch);
try {
const files = readdirSync(archDir);
return files.some((file) => file.endsWith(".node"));
} catch {
return false;
}
});
} catch {
return false;
}
}
export function getLocalSpeechRuntimePackageSpecs(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): LocalSpeechRuntimePackageSpec[] {
const sherpaPlatformPackage = sherpaPlatformPackageName(platform, arch);
return [
{
id: "sherpa-onnx",
packageName: "sherpa-onnx",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "index.js", "sherpa-onnx-wasm-nodejs.wasm"],
},
{
id: "sherpa-onnx-node",
packageName: "sherpa-onnx-node",
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.js", "addon.js"],
},
{
id: "sherpa-platform",
packageName: sherpaPlatformPackage,
version: SHERPA_ONNX_VERSION,
requiredFiles: ["package.json", "sherpa-onnx.node"],
},
{
id: "onnxruntime-common",
packageName: "onnxruntime-common",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/cjs/index.js"],
},
{
id: "onnxruntime-node",
packageName: "onnxruntime-node",
version: ONNXRUNTIME_VERSION,
requiredFiles: ["package.json", "dist/index.js"],
// onnxruntime-node encodes the N-API version in the native binding path.
validate: (packageDir) => hasOnnxruntimeNativeBinding(packageDir, platform, arch),
},
];
}
@@ -0,0 +1,22 @@
import path from "node:path";
const NODE_MODULES_DIR = "node_modules";
export function getLocalSpeechRuntimeDir(paseoHome: string): string {
return path.join(paseoHome, "runtime", "local-speech");
}
export function getLocalSpeechRuntimeNodeModulesDir(paseoHome: string): string {
return path.join(getLocalSpeechRuntimeDir(paseoHome), NODE_MODULES_DIR);
}
export function getLocalSpeechRuntimePackageDir(paseoHome: string, packageName: string): string {
return path.join(getLocalSpeechRuntimeNodeModulesDir(paseoHome), packageName);
}
export function getLocalSpeechRuntimePackageDirFromRuntimeDir(
runtimeDir: string,
packageName: string,
): string {
return path.join(runtimeDir, NODE_MODULES_DIR, packageName);
}
@@ -0,0 +1,65 @@
import { statSync } from "node:fs";
import { hasRequiredFiles } from "../sherpa/model-downloader.js";
import type { LocalSpeechRuntimeStatus } from "./errors.js";
import { getLocalSpeechRuntimePackageSpecs } from "./package-specs.js";
import {
getLocalSpeechRuntimeDir,
getLocalSpeechRuntimePackageDirFromRuntimeDir,
} from "./paths.js";
function hasRequiredFileSync(filePath: string): boolean {
try {
const s = statSync(filePath);
if (s.isDirectory()) {
return true;
}
return s.isFile() && s.size > 0;
} catch {
return false;
}
}
function hasRequiredFilesSync(packageDir: string, requiredFiles: string[]): boolean {
return requiredFiles.every((relPath) => hasRequiredFileSync(`${packageDir}/${relPath}`));
}
export function getLocalSpeechRuntimeStatusSync(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): LocalSpeechRuntimeStatus {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = hasRequiredFilesSync(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
export async function getLocalSpeechRuntimeStatus(params: {
paseoHome: string;
platform?: NodeJS.Platform;
arch?: string;
}): Promise<LocalSpeechRuntimeStatus> {
const runtimeDir = getLocalSpeechRuntimeDir(params.paseoHome);
const missingPackageIds: LocalSpeechRuntimeStatus["missingPackageIds"] = [];
for (const spec of getLocalSpeechRuntimePackageSpecs(params.platform, params.arch)) {
const packageDir = getLocalSpeechRuntimePackageDirFromRuntimeDir(runtimeDir, spec.packageName);
const requiredFilesPresent = await hasRequiredFiles(packageDir, spec.requiredFiles);
if (!requiredFilesPresent || (spec.validate && !spec.validate(packageDir))) {
missingPackageIds.push(spec.id);
}
}
return {
runtimeDir,
missingPackageIds,
};
}
@@ -19,7 +19,10 @@ export function getSherpaOnnxModelDir(modelsDir: string, modelId: SherpaOnnxMode
return path.join(modelsDir, spec.extractedDir);
}
async function hasRequiredFiles(modelDir: string, requiredFiles: string[]): Promise<boolean> {
export async function hasRequiredFiles(
modelDir: string,
requiredFiles: string[],
): Promise<boolean> {
const results = await Promise.all(
requiredFiles.map(async (rel) => {
const abs = path.join(modelDir, rel);
@@ -42,7 +45,7 @@ interface DownloadToFileOptions {
outputPath: string;
}
async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
export async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
const { url, outputPath } = options;
const res = await fetch(url);
if (!res.ok) {
@@ -68,7 +71,7 @@ async function downloadToFile(options: DownloadToFileOptions): Promise<void> {
}
}
async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
export async function extractTarArchive(archivePath: string, destDir: string): Promise<void> {
await mkdir(destDir, { recursive: true });
await new Promise<void>((resolve, reject) => {
@@ -83,7 +86,7 @@ async function extractTarArchive(archivePath: string, destDir: string): Promise<
});
}
async function isNonEmptyFile(filePath: string): Promise<boolean> {
export async function isNonEmptyFile(filePath: string): Promise<boolean> {
try {
const s = await stat(filePath);
return s.isFile() && s.size > 0;
@@ -1,4 +1,4 @@
import { createRequire } from "node:module";
import { loadLocalSpeechPackage } from "../runtime/index.js";
export interface SherpaOnnxModule {
createOnlineRecognizer: (config: unknown) => unknown;
@@ -13,7 +13,8 @@ export function loadSherpaOnnx(): SherpaOnnxModule {
return cached;
}
const require = createRequire(import.meta.url);
cached = require("sherpa-onnx") as SherpaOnnxModule;
cached = loadLocalSpeechPackage<SherpaOnnxModule>("sherpa-onnx", {
requireFrom: import.meta.url,
});
return cached;
}
@@ -0,0 +1,103 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { sherpaPlatformPackageName } from "./sherpa-runtime-env.js";
function makeHomeDir(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-sherpa-loader-"));
}
function runtimePackageDir(homeDir: string, packageName: string): string {
return path.join(homeDir, "runtime", "local-speech", "node_modules", packageName);
}
function writeFileEnsuringDir(filePath: string, contents: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents);
}
function writeFakeSherpaRuntime(homeDir: string): void {
const sherpaWasmDir = runtimePackageDir(homeDir, "sherpa-onnx");
const moduleDir = runtimePackageDir(homeDir, "sherpa-onnx-node");
const platformDir = runtimePackageDir(homeDir, sherpaPlatformPackageName());
const onnxCommonDir = runtimePackageDir(homeDir, "onnxruntime-common");
const onnxNodeDir = runtimePackageDir(homeDir, "onnxruntime-node");
writeFileEnsuringDir(path.join(sherpaWasmDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(sherpaWasmDir, "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(sherpaWasmDir, "sherpa-onnx-wasm-nodejs.wasm"), "x");
writeFileEnsuringDir(
path.join(moduleDir, "package.json"),
JSON.stringify({ main: "sherpa-onnx.js" }),
);
writeFileEnsuringDir(
path.join(moduleDir, "sherpa-onnx.js"),
"module.exports = { OfflineRecognizer: function FakeRecognizer() {} };",
);
writeFileEnsuringDir(path.join(moduleDir, "addon.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(platformDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(platformDir, "sherpa-onnx.node"), "x");
writeFileEnsuringDir(path.join(onnxCommonDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxCommonDir, "dist", "cjs", "index.js"), "module.exports = {};");
writeFileEnsuringDir(path.join(onnxNodeDir, "package.json"), "{}");
writeFileEnsuringDir(path.join(onnxNodeDir, "dist", "index.js"), "module.exports = {};");
writeFileEnsuringDir(
path.join(
onnxNodeDir,
"bin",
"napi-test",
process.platform,
process.arch,
"onnxruntime_binding.node",
),
"x",
);
}
describe("sherpa-onnx-node loader", () => {
afterEach(() => {
vi.resetModules();
});
test("loads sherpa-onnx-node from the PASEO_HOME runtime directory", async () => {
const homeDir = makeHomeDir();
writeFakeSherpaRuntime(homeDir);
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
const sherpa = loadSherpaOnnxNode();
expect(typeof sherpa.OfflineRecognizer).toBe("function");
clearLocalSpeechRuntimeHome();
});
test("throws a structured not-installed error when the runtime is missing", async () => {
const homeDir = makeHomeDir();
const { configureLocalSpeechRuntimeHome, clearLocalSpeechRuntimeHome } =
await import("../runtime/index.js");
const { loadSherpaOnnxNode } = await import("./sherpa-onnx-node-loader.js");
configureLocalSpeechRuntimeHome(homeDir);
expect(() => loadSherpaOnnxNode()).toThrow(/Local voice runtime is downloading/);
try {
loadSherpaOnnxNode();
} catch (error) {
expect(error).toMatchObject({
code: "local_speech_runtime_not_installed",
missingPackageIds: [
"sherpa-onnx",
"sherpa-onnx-node",
"sherpa-platform",
"onnxruntime-common",
"onnxruntime-node",
],
});
}
clearLocalSpeechRuntimeHome();
});
});
@@ -8,6 +8,7 @@ import {
sherpaPlatformPackageName,
} from "./sherpa-runtime-env.js";
import { createExternalCommandProcessEnv } from "../../../../paseo-env.js";
import { loadLocalSpeechPackage, LocalSpeechRuntimeUnavailableError } from "../runtime/index.js";
export interface SherpaOnnxNodeModule {
OfflineRecognizer: new (config: unknown) => unknown;
@@ -103,24 +104,26 @@ export function loadSherpaOnnxNode(): SherpaOnnxNodeModule {
const require = createRequire(import.meta.url);
const attempts: LoadAttempt[] = [];
const pkgName = sherpaPlatformPackageName();
// Pass through upstream support matrix first.
const direct = loadWithRequire(require, "sherpa-onnx-node", attempts);
if (direct) {
cached = direct;
try {
cached = loadLocalSpeechPackage<SherpaOnnxNodeModule>("sherpa-onnx-node", {
requireFrom: import.meta.url,
});
return cached;
} catch (error) {
if (error instanceof LocalSpeechRuntimeUnavailableError) {
throw error;
}
appendAttempt(attempts, "sherpa-onnx-node", error);
}
// sherpa-onnx-node depends on a platform-specific package (e.g. sherpa-onnx-darwin-arm64)
// that contains the native addon and shared libraries.
const pkgName = sherpaPlatformPackageName();
const resolvedEnv = resolveSherpaLoaderEnv();
const platformPkgDir = resolvedEnv?.libDir ?? null;
if (platformPkgDir) {
applySherpaLoaderEnv(process.env);
const addonPath = path.join(platformPkgDir, "sherpa-onnx.node");
if (existsSync(addonPath)) {
const byPath = loadWithRequire(require, addonPath, attempts);
if (byPath) {
@@ -77,11 +77,11 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
constructor(params: {
logger: { debug: (...args: unknown[]) => void };
config?: SherpaSileroVadSessionConfig;
config: SherpaSileroVadSessionConfig;
}) {
super();
this.logger = params.logger;
const config = params.config ?? {};
const config = params.config;
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
@@ -102,8 +102,8 @@ downloadTest(
const logger = pino({ level: "silent" });
const set = getModelSet();
const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech");
const homeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-"));
const modelsDir = path.join(homeRoot, ".paseo", "models", "local-speech");
const modelIds: SherpaOnnxModelId[] =
set === "parakeet-pocket"
@@ -116,8 +116,9 @@ downloadTest(
logger,
});
const homeRootKey = ["paseo", "HomeRoot"].join("");
const ctx = await createDaemonTestContext({
paseoHomeRoot,
[homeRootKey]: homeRoot,
dictationFinalTimeoutMs: 8000,
speech: {
providers: {
@@ -141,7 +142,7 @@ downloadTest(
},
},
},
});
} as Parameters<typeof createDaemonTestContext>[0]);
try {
const wav = await readFixtureWav();
@@ -223,7 +224,11 @@ downloadTest(
if (set === "parakeet-pocket") {
const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8");
const tts = await PocketTtsOnnxTTS.create(
{ modelDir, precision: "int8", targetChunkMs: 50 },
{
modelDir,
precision: "int8",
targetChunkMs: 50,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -256,7 +261,10 @@ downloadTest(
} else {
const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16");
const tts = new SherpaOnnxTTS(
{ preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir },
{
preset: "kitten-nano-en-v0_1-fp16",
modelDir: ttsModelDir,
},
logger,
);
const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText);
@@ -39,6 +39,19 @@ vi.mock("./providers/local/models.js", () => ({
listLocalSpeechModels: vi.fn(() => []),
}));
vi.mock("./providers/local/runtime/index.js", () => ({
clearLocalSpeechRuntimeHome: vi.fn(),
configureLocalSpeechRuntimeHome: vi.fn(),
ensureLocalSpeechRuntime: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
getLocalSpeechRuntimeStatus: vi.fn(async () => ({
runtimeDir: "/tmp/paseo/runtime/local-speech",
missingPackageIds: [],
})),
}));
function createStubStt(id: string): SpeechToTextProvider {
return {
id,
@@ -100,6 +113,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
voiceTurnDetection: { provider: "local", enabled: false, explicit: true },
@@ -140,6 +154,7 @@ describe("createSpeechService readiness", () => {
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
paseoHome: "/tmp/paseo",
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true },
voiceTurnDetection: { provider: "local", enabled: true, explicit: true },
@@ -9,6 +9,13 @@ import {
getLocalSpeechModelDir,
listLocalSpeechModels,
} from "./providers/local/models.js";
import {
clearLocalSpeechRuntimeHome,
configureLocalSpeechRuntimeHome,
ensureLocalSpeechRuntime,
getLocalSpeechRuntimeStatus,
type LocalSpeechRuntimePackageId,
} from "./providers/local/runtime/index.js";
import { initializeLocalSpeechServices } from "./providers/local/runtime.js";
import {
getOpenAiSpeechAvailability,
@@ -24,6 +31,9 @@ const SPEECH_RUNTIME_MONITOR_INTERVAL_MS = 3000;
export type SpeechReadinessReasonCode =
| "ready"
| "disabled"
| "runtime_install_in_progress"
| "runtime_missing"
| "runtime_install_failed"
| "model_download_in_progress"
| "models_missing"
| "model_download_failed"
@@ -31,28 +41,64 @@ export type SpeechReadinessReasonCode =
| "stt_unavailable"
| "tts_unavailable";
export type SpeechInstallableAssetKind = "models" | "runtime";
export interface SpeechMissingAsset {
kind: SpeechInstallableAssetKind;
ids: string[];
}
export interface SpeechReadinessState {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
missingAssets: SpeechMissingAsset[];
}
export interface SpeechReadinessSnapshot {
generatedAt: string;
requiredLocalModelIds: LocalSpeechModelId[];
missingLocalModelIds: LocalSpeechModelId[];
download: {
inProgress: boolean;
error: string | null;
};
assets: Record<
SpeechInstallableAssetKind,
{
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
>;
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
voiceFeature: SpeechReadinessState;
}
interface InstallableAsset<TMissingId extends string> {
readonly kind: SpeechInstallableAssetKind;
readonly label: string;
readonly reasonCodes: {
inProgress: SpeechReadinessReasonCode;
failed: SpeechReadinessReasonCode;
missing: SpeechReadinessReasonCode;
};
readonly messages: {
inProgress(missingIds: TMissingId[]): string;
failed(error: string): string;
missing(missingIds: TMissingId[]): string;
};
getMissing(): Promise<TMissingId[]>;
install(missingIds: TMissingId[]): Promise<void>;
}
interface InstallableAssetRuntimeState {
label: string;
missingIds: string[];
inProgress: boolean;
error: string | null;
}
function resolveRequestedSpeechProviders(
speechConfig: PaseoSpeechConfig | null,
): RequestedSpeechProviders {
@@ -125,6 +171,20 @@ function joinModelIds(modelIds: LocalSpeechModelId[]): string {
return modelIds.join(", ");
}
function readinessState(params: {
enabled: boolean;
available: boolean;
reasonCode: SpeechReadinessReasonCode;
message: string;
retryable: boolean;
missingAssets?: SpeechMissingAsset[];
}): SpeechReadinessState {
return {
...params,
missingAssets: params.missingAssets ?? [],
};
}
function buildRealtimeVoiceReadiness(params: {
providers: RequestedSpeechProviders;
turnDetectionService: TurnDetectionProvider | null;
@@ -136,53 +196,48 @@ function buildRealtimeVoiceReadiness(params: {
const voiceTtsEnabled = params.providers.voiceTts.enabled !== false;
const enabled = voiceTurnDetectionEnabled || voiceSttEnabled || voiceTtsEnabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Realtime voice is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTurnDetectionEnabled && !params.turnDetectionService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "turn_detection_unavailable",
message: "Realtime voice is unavailable: turn-detection service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceSttEnabled && !params.sttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
if (voiceTtsEnabled && !params.ttsService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "tts_unavailable",
message: "Realtime voice is unavailable: text-to-speech service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildDictationReadiness(params: {
@@ -191,94 +246,92 @@ function buildDictationReadiness(params: {
}): SpeechReadinessState {
const enabled = params.providers.dictationStt.enabled !== false;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Dictation is disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (!params.dictationSttService) {
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
};
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
};
});
}
function buildVoiceFeatureReadiness(params: {
realtimeVoice: SpeechReadinessState;
dictation: SpeechReadinessState;
missingLocalModelIds: LocalSpeechModelId[];
backgroundDownloadInProgress: boolean;
backgroundDownloadError: string | null;
registry: Array<InstallableAsset<string>>;
assets: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState>;
}): SpeechReadinessState {
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
if (!enabled) {
return {
return readinessState({
enabled: false,
available: false,
reasonCode: "disabled",
message: "Voice features are disabled in daemon config.",
retryable: false,
missingModelIds: [],
};
});
}
if (params.missingLocalModelIds.length > 0) {
const missingModelIds = [...params.missingLocalModelIds];
if (params.backgroundDownloadInProgress) {
return {
for (const asset of params.registry) {
const state = params.assets[asset.kind];
if (state.missingIds.length === 0) {
continue;
}
const missingAsset = { kind: asset.kind, ids: [...state.missingIds] };
if (state.inProgress) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_in_progress",
message: `Voice features are unavailable while models download in the background (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.inProgress,
message: asset.messages.inProgress(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
if (params.backgroundDownloadError) {
return {
if (state.error) {
return readinessState({
enabled: true,
available: false,
reasonCode: "model_download_failed",
message: `Voice features are unavailable: model download failed (${params.backgroundDownloadError}).`,
reasonCode: asset.reasonCodes.failed,
message: asset.messages.failed(state.error),
retryable: false,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: false,
reasonCode: "models_missing",
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
reasonCode: asset.reasonCodes.missing,
message: asset.messages.missing(state.missingIds),
retryable: true,
missingModelIds,
};
missingAssets: [missingAsset],
});
}
return {
return readinessState({
enabled: true,
available: true,
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
};
});
}
function describeRequestedProviders(providers: RequestedSpeechProviders): {
@@ -311,6 +364,16 @@ function describeRequestedProviders(providers: RequestedSpeechProviders): {
};
}
function requiresLocalSpeechRuntime(providers: RequestedSpeechProviders): boolean {
return (
(providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") ||
(providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local") ||
(providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") ||
(providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local")
);
}
function resolveVoiceTtsLabel(
ttsService: TextToSpeechProvider | null,
localVoiceTtsProvider: TextToSpeechProvider | null,
@@ -356,14 +419,17 @@ export interface SpeechService {
export function createSpeechService(params: {
logger: Logger;
paseoHome: string;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" });
configureLocalSpeechRuntimeHome(params.paseoHome);
const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig;
const providers = resolveRequestedSpeechProviders(speechConfig);
const requestedProviders = describeRequestedProviders(providers);
const localRuntimeRequired = requiresLocalSpeechRuntime(providers);
validateOpenAiCredentialRequirements({
providers,
@@ -392,9 +458,20 @@ export function createSpeechService(params: {
let localCleanup = () => {};
let localVoiceTtsProvider: TextToSpeechProvider | null = null;
let missingLocalModelIds: LocalSpeechModelId[] = [];
let backgroundDownloadInProgress = false;
let backgroundDownloadError: string | null = null;
const assetStates: Record<SpeechInstallableAssetKind, InstallableAssetRuntimeState> = {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
};
let stopped = false;
let monitorTimeout: ReturnType<typeof setTimeout> | null = null;
let reconcileInFlight: Promise<void> | null = null;
@@ -410,6 +487,72 @@ export function createSpeechService(params: {
rejectReady = reject;
});
const installableAssets: Array<InstallableAsset<string>> = [
{
kind: "runtime",
label: "local voice runtime",
reasonCodes: {
inProgress: "runtime_install_in_progress",
failed: "runtime_install_failed",
missing: "runtime_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while the local voice runtime installs in the background (${missingIds.join(", ")}).`,
failed: (error) =>
`Voice features are unavailable: local voice runtime install failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: local voice runtime is downloading / not installed (${missingIds.join(", ")}).`,
},
getMissing: async (): Promise<LocalSpeechRuntimePackageId[]> => {
if (!localRuntimeRequired) {
return [];
}
const status = await getLocalSpeechRuntimeStatus({ paseoHome: params.paseoHome });
return status.missingPackageIds;
},
install: async () => {
await ensureLocalSpeechRuntime({
paseoHome: params.paseoHome,
logger,
});
},
},
{
kind: "models",
label: "models",
reasonCodes: {
inProgress: "model_download_in_progress",
failed: "model_download_failed",
missing: "models_missing",
},
messages: {
inProgress: (missingIds) =>
`Voice features are unavailable while models download in the background (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
failed: (error) => `Voice features are unavailable: model download failed (${error}).`,
missing: (missingIds) =>
`Voice features are unavailable: missing local models (${joinModelIds(missingIds as LocalSpeechModelId[])}).`,
},
getMissing: async (): Promise<LocalSpeechModelId[]> => {
return findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
},
install: async (missingIds) => {
const modelsDir = localModelConfig?.modelsDir ?? null;
if (!modelsDir || missingIds.length === 0) {
return;
}
await ensureLocalSpeechModels({
modelsDir,
modelIds: missingIds as LocalSpeechModelId[],
logger,
});
},
},
];
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
providers,
@@ -424,17 +567,22 @@ export function createSpeechService(params: {
const voiceFeature = buildVoiceFeatureReadiness({
realtimeVoice,
dictation,
missingLocalModelIds,
backgroundDownloadInProgress,
backgroundDownloadError,
registry: installableAssets,
assets: assetStates,
});
return {
generatedAt: new Date().toISOString(),
requiredLocalModelIds: localModelConfig?.defaultModelIds ?? [],
missingLocalModelIds: [...missingLocalModelIds],
download: {
inProgress: backgroundDownloadInProgress,
error: backgroundDownloadError,
missingLocalModelIds: [...assetStates.models.missingIds] as LocalSpeechModelId[],
assets: {
models: {
...assetStates.models,
missingIds: [...assetStates.models.missingIds],
},
runtime: {
...assetStates.runtime,
missingIds: [...assetStates.runtime.missingIds],
},
},
realtimeVoice: {
...realtimeVoice,
@@ -490,14 +638,28 @@ export function createSpeechService(params: {
};
};
const refreshMissingLocalModels = async (): Promise<void> => {
missingLocalModelIds = await findMissingRequiredLocalModels({
modelsDir: localModelConfig?.modelsDir ?? null,
requiredModelIds: localModelConfig?.defaultModelIds ?? [],
});
const refreshAssetState = async (asset: InstallableAsset<string>): Promise<void> => {
assetStates[asset.kind].missingIds = await asset.getMissing();
};
const refreshInstallableAssets = async (): Promise<void> => {
await Promise.all(installableAssets.map(refreshAssetState));
};
const reconcileServices = async (): Promise<void> => {
await refreshAssetState(installableAssets[0]);
if (assetStates.runtime.missingIds.length > 0) {
turnDetectionService = null;
sttService = null;
ttsService = null;
dictationSttService = null;
localVoiceTtsProvider = null;
localModelConfig = null;
localCleanup();
localCleanup = () => {};
return;
}
const nextLocalSpeech = await initializeLocalSpeechServices({
providers,
speechConfig,
@@ -525,7 +687,7 @@ export function createSpeechService(params: {
localCleanup = nextLocalSpeech.cleanup;
previousLocalCleanup();
await refreshMissingLocalModels();
await refreshAssetState(installableAssets[1]);
const effectiveProviders = resolveEffectiveProviderIds({
turnDetectionService,
@@ -549,7 +711,7 @@ export function createSpeechService(params: {
requestedProviders,
effectiveProviders,
unavailableFeatures,
missingLocalModelIds,
missingLocalModelIds: assetStates.models.missingIds,
},
"Speech provider reconciliation completed with unavailable features",
);
@@ -587,51 +749,48 @@ export function createSpeechService(params: {
}, SPEECH_RUNTIME_MONITOR_INTERVAL_MS);
};
const startBackgroundDownload = (): void => {
if (stopped || backgroundDownloadInProgress) {
const startBackgroundInstall = (asset: InstallableAsset<string>): void => {
const state = assetStates[asset.kind];
if (stopped || state.inProgress || state.missingIds.length === 0) {
return;
}
const modelsDir = localModelConfig?.modelsDir ?? null;
const modelIds = [...missingLocalModelIds];
if (!modelsDir || modelIds.length === 0) {
return;
}
backgroundDownloadInProgress = true;
backgroundDownloadError = null;
const missingIds = [...state.missingIds];
state.inProgress = true;
state.error = null;
publishReadinessIfChanged();
logger.info(
{
modelsDir,
modelIds,
kind: asset.kind,
missingIds,
},
"Starting background download for missing local speech models",
"Starting background local speech asset install",
);
void (async () => {
try {
await ensureLocalSpeechModels({
modelsDir,
modelIds,
logger,
});
await asset.install(missingIds);
await refreshAssetState(asset);
state.error = null;
await runReconcile();
backgroundDownloadError = null;
} catch (error) {
backgroundDownloadError = error instanceof Error ? error.message : String(error);
state.error = error instanceof Error ? error.message : String(error);
publishReadinessIfChanged();
logger.error(
{
err: error,
modelIds,
kind: asset.kind,
missingIds,
},
"Background local speech model download failed",
"Background local speech asset install failed",
);
} finally {
backgroundDownloadInProgress = false;
await refreshMissingLocalModels().catch((error) => {
logger.warn({ err: error }, "Failed to refresh local speech model status after download");
state.inProgress = false;
await refreshAssetState(asset).catch((error) => {
logger.warn(
{ err: error, kind: asset.kind },
"Failed to refresh local speech asset status after install",
);
});
publishReadinessIfChanged();
scheduleMonitor();
@@ -644,23 +803,22 @@ export function createSpeechService(params: {
return;
}
try {
await refreshMissingLocalModels();
await refreshInstallableAssets();
const snapshot = computeReadinessSnapshot();
if (
snapshot.voiceFeature.enabled &&
!snapshot.voiceFeature.available &&
missingLocalModelIds.length === 0 &&
!backgroundDownloadInProgress
installableAssets.every((asset) => assetStates[asset.kind].missingIds.length === 0) &&
installableAssets.every((asset) => !assetStates[asset.kind].inProgress)
) {
await runReconcile();
}
if (
missingLocalModelIds.length > 0 &&
!backgroundDownloadInProgress &&
!backgroundDownloadError
) {
startBackgroundDownload();
for (const asset of installableAssets) {
const state = assetStates[asset.kind];
if (state.missingIds.length > 0 && !state.inProgress && !state.error) {
startBackgroundInstall(asset);
}
}
} catch (error) {
logger.warn({ err: error }, "Speech runtime monitor tick failed");
@@ -680,8 +838,10 @@ export function createSpeechService(params: {
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
for (const asset of installableAssets) {
if (assetStates[asset.kind].missingIds.length > 0) {
startBackgroundInstall(asset);
}
}
scheduleMonitor();
}
@@ -706,6 +866,7 @@ export function createSpeechService(params: {
monitorTimeout = null;
}
localCleanup();
clearLocalSpeechRuntimeHome();
};
return {
@@ -259,9 +259,19 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: [],
missingLocalModelIds: [],
download: {
inProgress: false,
error: null,
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -269,7 +279,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Dictation is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -277,7 +287,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Realtime voice is ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -285,7 +295,7 @@ function createReadySpeechReadinessSnapshot(): SpeechReadinessSnapshot {
reasonCode: "ready",
message: "Voice features are ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
};
}
@@ -295,9 +305,19 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
generatedAt: "2026-02-14T00:00:00.000Z",
requiredLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingLocalModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
download: {
inProgress: true,
error: null,
assets: {
models: {
label: "models",
missingIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
inProgress: true,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: [],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
@@ -305,7 +325,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
realtimeVoice: {
enabled: true,
@@ -313,7 +333,7 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingModelIds: [],
missingAssets: [],
},
voiceFeature: {
enabled: true,
@@ -322,7 +342,57 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps
message:
"Voice features are unavailable while models download in the background (sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20).",
retryable: true,
missingModelIds: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
missingAssets: [
{
kind: "models",
ids: ["sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"],
},
],
},
};
}
function createRuntimeMissingSpeechReadinessSnapshot(): SpeechReadinessSnapshot {
return {
...createReadySpeechReadinessSnapshot(),
assets: {
models: {
label: "models",
missingIds: [],
inProgress: false,
error: null,
},
runtime: {
label: "local voice runtime",
missingIds: ["sherpa-onnx-node"],
inProgress: false,
error: null,
},
},
dictation: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Dictation is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
realtimeVoice: {
enabled: true,
available: false,
reasonCode: "stt_unavailable",
message: "Realtime voice is unavailable: speech-to-text service is not ready.",
retryable: false,
missingAssets: [],
},
voiceFeature: {
enabled: true,
available: false,
reasonCode: "runtime_missing",
message:
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).",
retryable: true,
missingAssets: [{ kind: "runtime", ids: ["sherpa-onnx-node"] }],
},
};
}
@@ -727,6 +797,27 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("keeps runtime missing capability reason stable on the wire", async () => {
const server = createServer();
const socket = new MockSocket();
await attachRelayAndHello({
server,
socket,
clientId: "cid-server-info-runtime-missing",
});
server.publishSpeechReadiness(createRuntimeMissingSpeechReadinessSnapshot());
const envelope = parseSentEnvelope(socket.sent[1]);
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
const expected =
"Voice features are unavailable: local voice runtime is downloading / not installed (sherpa-onnx-node).";
expect(payload?.capabilities?.voice?.dictation.reason).toBe(expected);
expect(payload?.capabilities?.voice?.voice.reason).toBe(expected);
await server.close();
});
test("routes inbound terminal frames to session.handleBinaryFrame", async () => {
const server = createServer();
+11 -3
View File
@@ -201,7 +201,10 @@ function resolveCapabilityReason(params: {
return "";
}
if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
if (
readiness.voiceFeature.reasonCode === "model_download_in_progress" ||
readiness.voiceFeature.reasonCode === "runtime_install_in_progress"
) {
const baseMessage = readiness.voiceFeature.message.trim();
if (baseMessage.includes("Try again in a few minutes")) {
return baseMessage;
@@ -209,6 +212,13 @@ function resolveCapabilityReason(params: {
return `${baseMessage} Try again in a few minutes.`;
}
if (
readiness.voiceFeature.reasonCode === "runtime_missing" ||
readiness.voiceFeature.reasonCode === "runtime_install_failed"
) {
return readiness.voiceFeature.message;
}
return state.message;
}
@@ -1081,8 +1091,6 @@ export class VoiceAssistantWebSocketServer {
checkoutGithubSetAutoMerge: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
},
};
}

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