Compare commits

...

27 Commits

Author SHA1 Message Date
Mohamed Boudra
4c97ff8fa0 chore(release): cut 0.1.81 2026-05-24 13:13:01 +07:00
Mohamed Boudra
c0d2f20056 Update changelog for 0.1.81 2026-05-24 13:11:48 +07:00
Mohamed Boudra
2a3cfc684b Merge branch 'main' of github.com:getpaseo/paseo 2026-05-24 12:59:53 +07:00
Mohamed Boudra
68c893f643 fix(server): inject OpenCode MCP servers once 2026-05-24 12:58:30 +07:00
Mohamed Boudra
d43d30eeeb fix(server): log each provider fallback during metadata generation 2026-05-24 12:54:08 +07:00
Mohamed Boudra
79dcbdc1c1 Fix false unpushed commit warnings for worktree archive (#1158)
* fix(server): avoid false unpushed worktree commits

* fix(server): preserve local-only worktree push counts
2026-05-24 12:41:36 +08:00
paseo-ai[bot]
94bccf19ba fix: update lockfile signatures and Nix hash [skip ci] 2026-05-24 04:13:18 +00:00
Mohamed Boudra
83f205bd3d Preserve assistant message formatting on copy 2026-05-24 04:10:09 +00:00
Mohamed Boudra
846c9b9da3 Make mobile terminals load faster (#1147)
* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
2026-05-23 18:41:10 +08:00
paseo-ai[bot]
84f1dfc8cb fix: update lockfile signatures and Nix hash [skip ci] 2026-05-23 10:25:37 +00:00
Mohamed Boudra
33892f0698 fix(app): stop terminal flicker on resize 2026-05-23 17:22:24 +07:00
Mohamed Boudra
0962529d48 Make the web app installable (#1144)
* feat(app): make web app installable

* fix: restore CI for import and shortcut tests
2026-05-23 12:17:51 +08:00
Mohamed Boudra
008e4e846f fix(app): make slash command popover interactive on Android
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
2026-05-22 20:44:37 +07:00
Mohamed Boudra
789a559b31 fix(app): make sheet header search text readable in dark mode
The model picker search input rendered text in the light-theme
foreground color on a dark surface, leaving typed characters invisible.

@gorhom/bottom-sheet mounts header subtrees before the sheet is visible
and keeps them mounted across theme changes, so any color read from
StyleSheet.create((theme) => ...) at the caller goes stale. Move text
color and placeholder color into AdaptiveTextInput itself via
withUnistyles, with the leaf's color appended last so callers cannot
re-introduce a stale read. Drop the now-redundant placeholderTextColor
props at the header search call sites.

Also ban useUnistyles() in docs/unistyles.md. The hook subscribes to
every runtime change and forces a re-render each cycle; new code uses
withUnistyles or StyleSheet.create instead.
2026-05-22 18:23:11 +07:00
Mohamed Boudra
f5f1ae7fa9 fix(app): show one /exit row with /quit and /q as aliases 2026-05-22 18:22:19 +07:00
Mohamed Boudra
8e0ebfcaaa fix(server): hide empty sessions from the import list 2026-05-22 18:12:15 +07:00
Mohamed Boudra
b151dfcfd5 fix(app): show segmented control track under all segments 2026-05-22 18:05:35 +07:00
Mohamed Boudra
de7bf2fb01 fix(app): restore Alt+letter shortcuts on macOS
The key matcher only consulted event.code when a binding explicitly
opted in via codeFallback. macOS rewrites event.key whenever Option
is held (Option+T -> "†", Option+[ -> "“", Option+Shift+W -> "„",
etc.), so the comparison against combo.key always failed and every
Alt-bound letter / bracket shortcut silently stopped firing on Mac:
Cmd+Alt+T (cycle theme), Alt+Shift+[/], Alt+[/], Alt+Shift+W.

Fall back to event.code when combo.alt is set. Keep the existing
key-first behaviour for non-Alt bindings so Dvorak users still get
logical-character matching (Cmd+V on physical Period keeps pasting
instead of triggering Cmd+. for sidebar toggle).
2026-05-22 17:55:48 +07:00
Mohamed Boudra
5f11f602fb feat(app): add community links and sidebar home button 2026-05-22 17:40:42 +07:00
Mohamed Boudra
369c5a4498 fix(app): show shortcut chord badges in light mode
The Shortcut component computed its colors with a hex-only opacity
helper that received unistyles' CSS-var theme strings on web (e.g.
"var(--colors-foreground)"), fell through both regex branches, and
returned the rgba(255,255,255,...) fallback. Badges and chord text
rendered as near-white on white, so the keyboard shortcuts settings
page looked blank. Use the existing surface2 and foregroundMuted
tokens directly, which work in both themes without per-platform
color math.
2026-05-22 17:28:58 +07:00
Mohamed Boudra
c46ff2e045 feat(app): native terminal on WebView with xterm.js
Replace the Expo DOM-backed terminal on iOS and Android with a managed
WebView running xterm.js. Web and desktop continue to use the existing
DOM implementation.

Mobile mounted tabs switch from display:none to opacity:0 for the hidden
slot on purpose: terminals stay mounted under the LRU tab cache, and
keeping the WebView in the layer tree avoids cold-starting it every time
the user returns to a terminal tab.

EAS native builds rebuild the WebView bundle post-install so the
generated HTML stays in sync with the entry source.

This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect
symptom that prompted the investigation. After restoring the baseline,
that symptom could not be replicated; do not read this commit as its
root cause or fix.
2026-05-22 15:33:58 +07:00
Yurui Zhou
af10e64f82 feat(pi): bridge extension UI dialogs (#1134) 2026-05-22 00:03:42 +08:00
paseo-ai[bot]
db44a3e0e1 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 13:06:14 +00:00
Mohamed Boudra
25c4cee01e chore(release): cut 0.1.80 2026-05-21 20:02:05 +07:00
Mohamed Boudra
3ec4e2c536 docs(changelog): draft 0.1.80 entry 2026-05-21 20:00:36 +07:00
Mohamed Boudra
7dd9cbc506 fix(app): keep inline style marker web-only 2026-05-21 19:58:31 +07:00
paseo-ai[bot]
6dc22483c5 fix: update lockfile signatures and Nix hash [skip ci] 2026-05-21 10:11:01 +00:00
96 changed files with 3810 additions and 743 deletions

View File

@@ -1,5 +1,38 @@
# 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

View File

@@ -32,6 +32,7 @@ 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 |

View File

@@ -187,6 +187,20 @@ 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
docs/floating-panels.md Normal file
View File

@@ -0,0 +1,173 @@
# 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.

View File

@@ -24,6 +24,10 @@ 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.
---

View File

@@ -4,15 +4,17 @@ 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 Forbidden
## STOP — `useUnistyles()` Is Banned
**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):
**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):
> 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. 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.
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.
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
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:
### 1. `StyleSheet.create((theme) => ...)` — default
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
(Mind the `> *` child-selector leak documented further down.)
### 4. Lift the read into a tiny leaf component
### 4. There is no "last resort"
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.
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.
## How Updates Propagate

View File

@@ -1 +1 @@
sha256-a4fXFcmH0dddKpaMPdWeHtB8oyajugBeWpWnGB/18yY=
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=

286
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.79",
"version": "0.1.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.79",
"version": "0.1.81",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -12358,6 +12358,24 @@
"@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",
@@ -12367,6 +12385,13 @@
"@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",
@@ -13671,64 +13696,6 @@
"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",
@@ -13738,15 +13705,6 @@
"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",
@@ -22193,33 +22151,6 @@
}
}
},
"node_modules/font-finder": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/font-finder/-/font-finder-1.1.0.tgz",
"integrity": "sha512-wpCL2uIbi6GurJbU7ZlQ3nGd61Ho+dSU6U83/xJT5UPFfN35EeCW/rOtS+5k+IuEZu2SYmHzDIPL9eA5tSYRAw==",
"license": "MIT",
"dependencies": {
"get-system-fonts": "^2.0.0",
"promise-stream-reader": "^1.0.1"
},
"engines": {
"node": ">8.0.0"
}
},
"node_modules/font-ligatures": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/font-ligatures/-/font-ligatures-1.4.1.tgz",
"integrity": "sha512-7W6zlfyhvCqShZ5ReUWqmSd9vBaUudW0Hxis+tqUjtHhsPU+L3Grf8mcZAtCiXHTzorhwdRTId2WeH/88gdFkw==",
"license": "MIT",
"dependencies": {
"font-finder": "^1.0.3",
"lru-cache": "^6.0.0",
"opentype.js": "^0.8.0"
},
"engines": {
"node": ">8.0.0"
}
},
"node_modules/fontfaceobserver": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz",
@@ -22603,15 +22534,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-system-fonts": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-system-fonts/-/get-system-fonts-2.0.2.tgz",
"integrity": "sha512-zzlgaYnHMIEgHRrfC7x0Qp0Ylhw/sHpM6MHXeVBTYIsvGf5GpbnClB+Q6rAPdn+0gd2oZZIo6Tj3EaWrt4VhDQ==",
"license": "MIT",
"engines": {
"node": ">8.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -30211,15 +30133,6 @@
"node": ">=10"
}
},
"node_modules/promise-stream-reader": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-stream-reader/-/promise-stream-reader-1.0.1.tgz",
"integrity": "sha512-Tnxit5trUjBAqqZCGWwjyxhmgMN4hGrtpW3Oc/tRI4bpm/O2+ej72BB08l6JBnGQgVDGCLvHFGjGgQS6vzhwXg==",
"license": "MIT",
"engines": {
"node": ">8.0.0"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -36410,7 +36323,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36426,15 +36339,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@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",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -36460,6 +36373,7 @@
"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",
@@ -36487,12 +36401,13 @@
"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.0.0",
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
@@ -36506,6 +36421,107 @@
"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",
@@ -36528,10 +36544,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.79",
"@getpaseo/server": "0.1.81",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36886,7 +36902,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.79",
"version": "0.1.81",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37247,7 +37263,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79",
"version": "0.1.81",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37283,7 +37299,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -37621,7 +37637,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -37948,12 +37964,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.79",
"@getpaseo/relay": "0.1.79",
"@getpaseo/highlight": "0.1.81",
"@getpaseo/relay": "0.1.81",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38827,7 +38843,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.79",
"version": "0.1.81",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.79",
"version": "0.1.81",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -181,7 +181,7 @@ test.describe("Client slash commands", () => {
page,
{ title: "Slash quit autocomplete e2e" },
async ({ agent, title }) => {
await selectClientSlashCommand(page, "/qu", "/quit");
await selectClientSlashCommand(page, "/qu", "/exit");
await expectWorkspaceTabHidden(page, agent.id);
await expectAgentArchivedInSessions(page, title);
},

View File

@@ -1,13 +1,14 @@
{
"name": "@getpaseo/app",
"version": "0.1.79",
"version": "0.1.81",
"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",
"eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
"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",
@@ -41,15 +42,15 @@
"@react-navigation/native": "^7.1.8",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
"@xterm/addon-ligatures": "^0.10.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@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",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-asset": "~12.0.12",
@@ -75,6 +76,7 @@
"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",
@@ -102,12 +104,13 @@
"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.0.0",
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

@@ -4,10 +4,16 @@
<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">

View File

@@ -0,0 +1,27 @@
{
"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.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,89 @@
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)`);

View File

@@ -57,33 +57,41 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
}
describe("resolveClientSlashCommand", () => {
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("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("resolves exact client commands after trimming", () => {
it("resolves canonical names and aliases after trimming", () => {
expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({
name: "exit",
kind: "archive-agent",
execution: "immediate",
});
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",
);
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",
});
});
it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => {

View File

@@ -6,6 +6,7 @@ export type ClientSlashCommandExecution = "immediate" | "insert";
export interface ClientSlashCommand {
name: string;
aliases: readonly string[];
description: string;
argumentHint: string;
kind: ClientSlashCommandKind;
@@ -13,22 +14,9 @@ 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",
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
execution: "immediate",
},
{
name: "q",
aliases: ["quit", "q"],
description: "Archive the current agent",
argumentHint: "",
kind: "archive-agent",
@@ -36,13 +24,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
{
name: "clear",
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
execution: "immediate",
},
{
name: "new",
aliases: ["new"],
description: "Archive this agent and start a fresh draft",
argumentHint: "",
kind: "replace-agent-with-draft",
@@ -50,7 +32,13 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
},
];
const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command]));
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);
}
}
export function resolveClientSlashCommand(input: {
text: string;

View File

@@ -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 } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
@@ -205,6 +205,9 @@ const styles = StyleSheet.create((theme) => ({
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
adaptiveInputText: {
color: theme.colors.foreground,
},
adaptiveInputPlaceholder: {
color: theme.colors.foregroundMuted,
},
@@ -237,38 +240,43 @@ 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,
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 { 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 textInputProps = {
...inputProps,
defaultValue: initialValue ?? defaultValue,
placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color,
style: [styles.adaptiveInputOutline, style],
style: [styles.adaptiveInputOutline, style, styles.adaptiveInputText],
};
if (isMobile && isNative) {
return (
<BottomSheetTextInput
<ThemedBottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
return <ThemedTextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
@@ -344,7 +352,6 @@ 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}
@@ -400,7 +407,6 @@ 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}

View File

@@ -0,0 +1,66 @@
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,
},
}));

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 { Autocomplete } from "@/components/ui/autocomplete";
import { AutocompletePopover } from "@/components/ui/autocomplete-popover";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
import {
useHostRuntimeAgentDirectoryStatus,
@@ -256,25 +256,6 @@ 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;
@@ -1589,10 +1570,7 @@ export function Composer({
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
);
const autocompletePopover = useMemo(
() => renderAutocompletePopover(autocomplete),
[autocomplete],
);
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
@@ -1614,8 +1592,18 @@ export function Composer({
{queueList}
{sendErrorNode}
<View style={styles.messageInputContainer}>
{autocompletePopover}
<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}
/>
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<StableMessageInput
@@ -1712,14 +1700,6 @@ 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,

View File

@@ -0,0 +1,14 @@
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>
);
}

View File

@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, MessagesSquare, Settings, X } from "lucide-react-native";
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -58,6 +58,7 @@ import { resolveActiveHost } from "@/utils/active-host";
import { formatConnectionStatus } from "@/utils/daemons";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
buildHostOpenProjectRoute,
buildHostSessionsRoute,
buildSettingsRoute,
mapPathnameToServer,
@@ -94,6 +95,7 @@ interface SidebarSharedProps {
handleRefresh: () => void;
handleHostSelect: (nextServerId: string) => void;
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
renderHostOption: (input: {
option: ComboboxOption;
@@ -223,6 +225,17 @@ 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;
@@ -272,6 +285,7 @@ export const LeftSidebar = memo(function LeftSidebar({
isOpen={isOpen}
closeToAgent={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
handleSettings={handleSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
/>
@@ -284,6 +298,7 @@ export const LeftSidebar = memo(function LeftSidebar({
insetsTop={insets.top}
isOpen={isOpen}
handleOpenProject={handleOpenProjectDesktop}
handleHome={handleHomeDesktop}
handleSettings={handleSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
/>
@@ -414,6 +429,7 @@ function SidebarFooter({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
}: {
theme: SidebarTheme;
@@ -427,6 +443,7 @@ function SidebarFooter({
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleHome: () => void;
handleSettings: () => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
@@ -456,6 +473,13 @@ 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"
@@ -501,6 +525,7 @@ function MobileSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
insetsBottom,
@@ -729,6 +754,7 @@ function MobileSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>
</View>
@@ -758,6 +784,7 @@ function DesktopSidebar({
handleHostSelect,
renderHostOption,
handleOpenProject,
handleHome,
handleSettings,
insetsTop,
isOpen,
@@ -871,6 +898,7 @@ function DesktopSidebar({
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
/>

View File

@@ -62,7 +62,6 @@ 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";
@@ -73,6 +72,7 @@ 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 Clipboard.setStringAsync(content);
await writeMarkdownToRichClipboard(content);
setCopied(true);
if (copyTimeoutRef.current) {

View File

@@ -0,0 +1,532 @@
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",
},
});

View File

@@ -19,7 +19,10 @@ 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 } from "../terminal/runtime/terminal-emulator-runtime";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} 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";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
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 });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });

View File

@@ -25,7 +25,9 @@ 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";
@@ -171,6 +173,9 @@ 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]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);

View File

@@ -0,0 +1,176 @@
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,
},
}));

View File

@@ -194,7 +194,6 @@ 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}

View File

@@ -142,7 +142,6 @@ const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "stretch",
maxWidth: "100%",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,

View File

@@ -4,23 +4,6 @@ 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,
@@ -70,7 +53,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
backgroundColor: colorWithOpacity(theme.colors.foreground, 0.05),
backgroundColor: theme.colors.surface2,
borderWidth: 0,
},
sequence: {
@@ -82,6 +65,6 @@ const styles = StyleSheet.create((theme) => ({
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: colorWithOpacity(theme.colors.foreground, 0.6),
color: theme.colors.foregroundMuted,
},
}));

View File

@@ -324,9 +324,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
),
...providerCommands,
];
const matches = availableCommands.filter((entry) =>
entry.command.name.toLowerCase().includes(filterLower),
);
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 orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map(mapCommandToOption);
}

View File

@@ -300,6 +300,49 @@ 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)(
@@ -401,6 +444,14 @@ 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 }) => {

View File

@@ -1020,6 +1020,24 @@ 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) {
@@ -1040,18 +1058,7 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): bo
if (combo.code === "Digit") {
return parseDigit(event) !== null;
}
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;
return matchesKeyOrCode(combo, event);
}
function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {

View File

@@ -4,6 +4,7 @@ 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";
@@ -106,6 +107,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
) : null}
</View>
</View>
<View style={styles.communityRow}>
<CommunityLinks />
</View>
<PairDeviceModal
visible={isPairDeviceOpen}
onClose={handleClosePairDevice}
@@ -227,4 +231,17 @@ 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,
},
}));

View File

@@ -56,6 +56,7 @@ 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,
@@ -457,6 +458,9 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
<View style={styles.aboutCommunity}>
<CommunityLinks />
</View>
</SettingsSection>
);
}
@@ -1267,6 +1271,9 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
aboutCommunity: {
marginTop: theme.spacing[4],
},
aboutUpdateActions: {
flexDirection: "row",
alignItems: "center",

View File

@@ -714,13 +714,12 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const slotStyle = useMemo(
() => ({ display: isVisible ? ("flex" as const) : ("none" as const), flex: 1 }),
[isVisible],
);
const slotStyle = isVisible
? styles.mobileMountedTabSlotVisible
: styles.mobileMountedTabSlotHidden;
return (
<View style={slotStyle}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
@@ -2854,7 +2853,7 @@ function WorkspaceScreenContent({
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
activeTabId: activeTabDescriptor?.tabId ?? null,
activeTabId,
allTabIds: focusedPaneTabIds,
cap: 3,
});
@@ -3654,6 +3653,15 @@ 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,

View File

@@ -0,0 +1,8 @@
/**
* 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;
}

View File

@@ -1,23 +1,3 @@
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;
}

View File

@@ -0,0 +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;
}

View File

@@ -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 { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon {
@@ -49,6 +49,10 @@ 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;
@@ -253,7 +257,7 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.terminalKeys).toEqual([]);
mounted.runtime.write({ text: "\x1b[>7u" });
mounted.runtime.write({ data: terminalOutput("\x1b[>7u") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -279,7 +283,7 @@ describe("terminal emulator runtime in a real browser", () => {
]);
mounted.terminalKeys.length = 0;
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
mounted.runtime.write({ data: terminalOutput("\x1b[=0;0u\x1b[?9001h") });
await waitFor({
predicate: () =>
mounted.inputModeChanges.some(
@@ -321,7 +325,7 @@ describe("terminal emulator runtime in a real browser", () => {
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.runtime.write({ text: bytes });
mounted.runtime.write({ data: terminalOutput(bytes) });
await nextFrame();
await nextFrame();

View File

@@ -75,10 +75,10 @@ vi.mock("@xterm/xterm", () => ({
},
}));
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
interface StubTerminal {
write: (text: string, callback?: () => void) => void;
write: (data: string | Uint8Array, 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: (text: string, callback?: () => void) => {
writeTexts.push(text);
write: (data: string | Uint8Array, callback?: () => void) => {
writeTexts.push(decodeTerminalOutput(data));
if (callback) {
writeCallbacks.push(callback);
}
@@ -131,6 +131,17 @@ 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;
@@ -151,7 +162,7 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
@@ -162,7 +173,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -188,7 +199,7 @@ describe("terminal-emulator-runtime", () => {
const onCommitted = vi.fn();
runtime.write({
text: "stuck",
data: terminalOutput("stuck"),
onCommitted,
});
@@ -208,7 +219,7 @@ describe("terminal-emulator-runtime", () => {
},
});
runtime.write({ text: "\x1b[>7u" });
runtime.write({ data: terminalOutput("\x1b[>7u") });
runtime.renderSnapshot({
state: {
rows: 2,
@@ -234,13 +245,13 @@ describe("terminal-emulator-runtime", () => {
const committed: string[] = [];
runtime.write({
text: "first",
data: terminalOutput("first"),
onCommitted: () => {
committed.push("first");
},
});
runtime.write({
text: "second",
data: terminalOutput("second"),
onCommitted: () => {
committed.push("second");
},
@@ -262,11 +273,11 @@ describe("terminal-emulator-runtime", () => {
const onCommittedB = vi.fn();
runtime.write({
text: "a",
data: terminalOutput("a"),
onCommitted: onCommittedA,
});
runtime.write({
text: "b",
data: terminalOutput("b"),
onCommitted: onCommittedB,
});
@@ -301,6 +312,15 @@ 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();

View File

@@ -24,6 +24,8 @@ import {
} from "@/utils/terminal-keys";
import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot";
export type TerminalOutputData = Uint8Array;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
@@ -67,7 +69,7 @@ interface TerminalEmulatorRuntimeDisposables {
interface TerminalOutputOperation {
type: "write" | "clear" | "snapshot";
text: string;
data: TerminalOutputData;
rows?: number;
cols?: number;
suppressInput?: boolean;
@@ -96,7 +98,23 @@ 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 RESET_TERMINAL_ANSI = "\u001bc";
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 DEFAULT_TERMINAL_FONT_FAMILY = [
// Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs.
@@ -140,6 +158,7 @@ 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();
@@ -178,14 +197,13 @@ 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,
overviewRuler: {
scrollbar: {
width: 8,
},
scrollback: input.scrollback,
@@ -529,14 +547,18 @@ export class TerminalEmulatorRuntime {
};
}
write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void {
if (input.text.length === 0) {
write(input: {
data: TerminalOutputData;
suppressInput?: boolean;
onCommitted?: () => void;
}): void {
if (input.data.length === 0) {
input.onCommitted?.();
return;
}
this.outputOperations.push({
type: "write",
text: input.text,
data: input.data,
suppressInput: input.suppressInput ?? false,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -546,7 +568,7 @@ export class TerminalEmulatorRuntime {
clear(input?: { onCommitted?: () => void }): void {
this.outputOperations.push({
type: "clear",
text: "",
data: EMPTY_TERMINAL_OUTPUT,
suppressInput: false,
...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -558,11 +580,25 @@ export class TerminalEmulatorRuntime {
this.clear(input);
return;
}
this.outputOperations.push({
type: "snapshot",
text: `${RESET_TERMINAL_ANSI}${renderTerminalSnapshotToAnsi(input.state)}`,
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,
suppressInput: true,
...(input.onCommitted ? { onCommitted: input.onCommitted } : {}),
});
@@ -644,6 +680,7 @@ export class TerminalEmulatorRuntime {
this.fitAndEmitResize = null;
this.lastSize = null;
this.suppressInput = false;
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
}
@@ -665,7 +702,7 @@ export class TerminalEmulatorRuntime {
this.inFlightOutputOperation = operation;
const previousSuppressInput = this.suppressInput;
if (operation.type === "write") {
if (operation.suppressInput) {
this.suppressInput = Boolean(operation.suppressInput);
}
const finalizeOperation = (expectedOperation: TerminalOutputOperation) => {
@@ -680,6 +717,7 @@ export class TerminalEmulatorRuntime {
};
if (operation.type === "clear") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
terminal.reset();
@@ -688,6 +726,7 @@ export class TerminalEmulatorRuntime {
}
if (operation.type === "snapshot") {
this.inputModeDecoder.decode();
this.inputModeTracker.reset();
this.emitInputModeChange();
try {
@@ -704,8 +743,9 @@ export class TerminalEmulatorRuntime {
}
}
const text = operation.text;
const data = operation.data;
if (operation.type === "write") {
const text = this.inputModeDecoder.decode(data, { stream: true });
const result = this.inputModeTracker.feed(text);
if (result.changed) {
this.emitInputModeChange();
@@ -716,7 +756,7 @@ export class TerminalEmulatorRuntime {
}, OUTPUT_OPERATION_TIMEOUT_MS);
try {
terminal.write(text, () => {
terminal.write(data, () => {
finalizeOperation(operation);
});
} catch {

View File

@@ -0,0 +1,39 @@
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,
});
});
});

View File

@@ -0,0 +1,22 @@
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 } : {}),
};
}

View File

@@ -1,205 +1 @@
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 [];
}
export { renderTerminalSnapshotToAnsi } from "@server/shared/terminal-snapshot";

View File

@@ -14,19 +14,36 @@ 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: "snapshot"; state: TerminalSnapshot }
| { terminalId: string; type: "restore"; data: Uint8Array };
class FakeTerminalStreamClient implements TerminalStreamControllerClient {
private readonly listeners = new Set<(event: TerminalStreamEvent) => void>();
public subscribeCalls: string[] = [];
public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = [];
public unsubscribeCalls: string[] = [];
public resizeCalls: Array<{ terminalId: string; rows: number; cols: number }> = [];
public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = [];
async subscribeTerminal(terminalId: string) {
this.subscribeCalls.push(terminalId);
async subscribeTerminal(terminalId: string, options?: unknown) {
this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) });
const result = this.nextSubscribeResults.shift();
if (!result) {
throw new Error("Missing fake subscribe result");
@@ -61,7 +78,8 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
function createHarness(input?: { client?: FakeTerminalStreamClient }) {
const client = input?.client ?? new FakeTerminalStreamClient();
const outputs: Array<{ terminalId: string; text: string }> = [];
const outputs: Array<{ terminalId: string; data: Uint8Array }> = [];
const restores: Array<{ terminalId: string; data: Uint8Array }> = [];
const snapshots: Array<{ terminalId: string; text: string }> = [];
const statuses: TerminalStreamControllerStatus[] = [];
const controller = new TerminalStreamController({
@@ -70,10 +88,13 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
onOutput: (output) => {
outputs.push(output);
},
onRestore: (restore) => {
restores.push(restore);
},
onSnapshot: ({ terminalId, state }) => {
snapshots.push({
terminalId,
text: state.grid.map((row) => row.map((cell) => cell.char).join("")).join("\n"),
text: terminalSnapshotText(state),
});
},
onStatusChange: (status) => {
@@ -81,7 +102,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) {
},
});
return { client, controller, outputs, snapshots, statuses };
return { client, controller, outputs, restores, snapshots, statuses };
}
async function flushAsyncWork(): Promise<void> {
@@ -108,16 +129,18 @@ describe("terminal-stream-controller", () => {
cursor: { row: 0, col: 5 },
},
});
const outputData = terminalOutput(" world");
harness.client.emit({
terminalId: "term-1",
type: "output",
data: new TextEncoder().encode(" world"),
data: outputData,
});
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.client.resizeCalls).toEqual([{ terminalId: "term-1", rows: 24, cols: 80 }]);
expect(harness.snapshots).toEqual([{ terminalId: "term-1", text: "hello" }]);
expect(harness.outputs).toEqual([{ terminalId: "term-1", text: " world" }]);
expect(harness.outputs[0]?.data).toBe(outputData);
expect(harness.outputs).toEqual([{ terminalId: "term-1", data: terminalOutput(" world") }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -135,7 +158,7 @@ describe("terminal-stream-controller", () => {
harness.controller.setTerminal({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -152,7 +175,7 @@ describe("terminal-stream-controller", () => {
harness.controller.handleTerminalExit({ terminalId: "term-1" });
await flushAsyncWork();
expect(harness.client.subscribeCalls).toEqual(["term-1"]);
expect(harness.client.subscribeCalls).toEqual([{ terminalId: "term-1" }]);
expect(harness.statuses.at(-1)).toEqual({
terminalId: "term-1",
isAttaching: false,
@@ -160,6 +183,55 @@ 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 });

View File

@@ -1,7 +1,11 @@
import type { TerminalState } from "@server/shared/messages";
import type { SubscribeTerminalRequest, TerminalState } from "@server/shared/messages";
import type { TerminalOutputData } from "./terminal-emulator-runtime";
export interface TerminalStreamControllerClient {
subscribeTerminal: (terminalId: string) => Promise<{
subscribeTerminal: (
terminalId: string,
options?: { restore?: SubscribeTerminalRequest["restore"] },
) => Promise<{
terminalId: string;
error?: string | null;
}>;
@@ -14,7 +18,8 @@ export interface TerminalStreamControllerClient {
handler: (
event:
| { terminalId: string; type: "output"; data: Uint8Array }
| { terminalId: string; type: "snapshot"; state: TerminalState },
| { terminalId: string; type: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array },
) => void,
) => () => void;
}
@@ -33,15 +38,16 @@ export interface TerminalStreamControllerStatus {
export interface TerminalStreamControllerOptions {
client: TerminalStreamControllerClient;
getPreferredSize: () => TerminalStreamControllerSize | null;
onOutput: (input: { terminalId: string; text: string }) => void;
onOutput: (input: { terminalId: string; data: TerminalOutputData }) => 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;
@@ -52,13 +58,17 @@ export class TerminalStreamController {
return;
}
if (event.type === "snapshot") {
this.decoder.decode();
this.options.onSnapshot({ terminalId: event.terminalId, state: event.state });
return;
}
const text = this.decoder.decode(event.data, { stream: true });
if (text.length > 0) {
this.options.onOutput({ terminalId: event.terminalId, text });
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 });
}
});
}
@@ -70,7 +80,6 @@ export class TerminalStreamController {
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
this.options.client.unsubscribeTerminal(previousTerminalId);
}
@@ -78,9 +87,10 @@ 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)
.subscribeTerminal(nextTerminalId, restore ? { restore } : undefined)
.then((payload) => {
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
@@ -126,7 +136,6 @@ export class TerminalStreamController {
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
this.decoder.decode();
this.terminalId = null;
this.options.onStatusChange?.({
terminalId: input.terminalId,
@@ -140,7 +149,6 @@ export class TerminalStreamController {
return;
}
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;
this.terminalId = null;
if (terminalId) {

View File

@@ -0,0 +1,308 @@
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

View File

@@ -0,0 +1,97 @@
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**");
});
});

View File

@@ -0,0 +1,81 @@
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)]);
},
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.79",
"version": "0.1.81",
"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.79",
"@getpaseo/server": "0.1.81",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.79",
"version": "0.1.81",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -54,6 +54,8 @@ 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) {
@@ -108,7 +110,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("Paseo");
app.setName(APP_NAME);
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
@@ -374,7 +376,7 @@ async function createMainWindow(): Promise<void> {
const iconPath = getWindowIconPath();
const systemTheme = resolveSystemWindowTheme();
const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo";
const title = devWorktreeName ? `${APP_NAME} (${devWorktreeName})` : APP_NAME;
const mainWindow = new BrowserWindow({
title,
width: 1200,
@@ -522,6 +524,11 @@ 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();

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.79",
"version": "0.1.81",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.79",
"version": "0.1.81",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.79",
"version": "0.1.81",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.79",
"version": "0.1.81",
"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.79",
"@getpaseo/relay": "0.1.79",
"@getpaseo/highlight": "0.1.81",
"@getpaseo/relay": "0.1.81",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -67,6 +67,7 @@ import type {
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
SubscribeTerminalRequest,
CloseItemsResponse,
KillTerminalResponse,
CaptureTerminalResponse,
@@ -3770,13 +3771,19 @@ export class DaemonClient {
async subscribeTerminal(
terminalId: string,
requestId?: string,
optionsOrRequestId?:
| { restore?: SubscribeTerminalRequest["restore"]; requestId?: string }
| 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,
@@ -4363,6 +4370,8 @@ 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,

View File

@@ -0,0 +1,28 @@
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,
},
]);
});
});

View File

@@ -9,7 +9,8 @@ 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: "snapshot"; state: TerminalState }
| { terminalId: string; type: "restore"; data: Uint8Array };
export class TerminalStreamRouter {
private readonly terminalSlots = new Map<string, number>();
@@ -97,6 +98,15 @@ 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) {

View File

@@ -132,25 +132,19 @@ 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) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
options.logger.warn(
{ err: error, agentId: options.agentId },
"Structured metadata generation failed",
);
return;
}
const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
options.logger.error(
{ err: error, agentId: options.agentId },
"Agent metadata generation failed",
{ err: error, agentId: options.agentId, attempts },
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
? "Structured metadata generation failed"
: "Agent metadata generation failed",
);
return;
}

View File

@@ -5,6 +5,11 @@ 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>;
@@ -90,6 +95,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
persistSession?: boolean;
maxRetries?: number;
schemaName?: string;
logger?: StructuredGenerationLogger;
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
}
@@ -397,6 +403,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
persistSession,
maxRetries,
schemaName,
logger,
runner,
} = options;
@@ -414,12 +421,17 @@ 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;
}
@@ -439,6 +451,17 @@ 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({
@@ -447,6 +470,10 @@ 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",
);
}
}

View File

@@ -128,6 +128,7 @@ 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",
@@ -167,6 +168,7 @@ 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);

View File

@@ -123,6 +123,9 @@ 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))) {
@@ -327,6 +330,12 @@ 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,

View File

@@ -585,6 +585,46 @@ 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 = [

View File

@@ -3170,16 +3170,10 @@ class OpenCodeAgentSession implements AgentSession {
config,
}),
);
await this.runMcpOperation("connect", name, () =>
this.client.mcp.connect({
directory: this.config.cwd,
name,
}),
);
}
private async runMcpOperation(
operation: "add" | "connect",
operation: "add",
name: string,
run: () => Promise<{ error?: unknown }>,
): Promise<void> {

View File

@@ -56,6 +56,8 @@ 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[],
@@ -74,6 +76,8 @@ 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;
@@ -135,8 +139,14 @@ export class TestOpenCodeClient {
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),
add: async (parameters: unknown) => {
this.calls.mcpAdd.push(parameters);
return this.mcpAddResponse;
},
connect: async (parameters: unknown) => {
this.calls.mcpConnect.push(parameters);
return this.mcpConnectResponse;
},
},
permission: {
reply: async (parameters: unknown) => {

View File

@@ -102,6 +102,20 @@ 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> {
@@ -119,6 +133,125 @@ 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();

View File

@@ -403,6 +403,108 @@ 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,
@@ -428,6 +530,7 @@ 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;
@@ -540,12 +643,27 @@ export class PiRpcAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
return [];
return [...this.pendingExtensionUiRequests.values()];
}
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
void requestId;
void response;
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(),
});
}
describePersistence(): AgentPersistenceHandle | null {
@@ -624,9 +742,26 @@ 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.runtimeSession.cancelExtensionUiRequest(event.id);
this.handleExtensionUiRequest(event);
return;
}
if (event.type === "process_exit") {

View File

@@ -166,8 +166,15 @@ 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.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
this.respondToExtensionUiRequest(id, { cancelled: true });
}
async close(): Promise<void> {

View File

@@ -43,6 +43,10 @@ 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>;
}

View File

@@ -55,6 +55,10 @@ 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[] = [];
@@ -130,8 +134,16 @@ 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> {}

View File

@@ -650,6 +650,7 @@ 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);

View File

@@ -46,13 +46,13 @@ function extractStateText(state: Pick<TerminalState, "grid" | "scrollback">): st
}
async function waitForCondition(
predicate: () => boolean,
predicate: () => boolean | Promise<boolean>,
timeoutMs: number,
intervalMs = 25,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) {
if (await predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -377,6 +377,9 @@ function getFrameText(frame: TerminalStreamFrame): string {
}
return extractStateText(state);
}
if (frame.opcode === TerminalStreamOpcode.Restore) {
return new TextDecoder().decode(frame.payload);
}
return "";
}
@@ -672,6 +675,11 @@ 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,
@@ -688,6 +696,7 @@ async function subscribeRawTerminal(
type: "subscribe_terminal_request",
terminalId,
requestId,
...(restore ? { restore } : {}),
},
}),
);
@@ -753,6 +762,95 @@ 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, {
@@ -1066,13 +1164,13 @@ test("fast output to a slow websocket client falls back to a snapshot", async ()
});
await new Promise((resolve) => setTimeout(resolve, 750));
rawSocket!.resume();
const catchUpFrame = await waitForRawBinaryFrame(
const catchUpFramePromise = waitForRawBinaryFrame(
ws,
(frame) => frame.opcode === TerminalStreamOpcode.Snapshot,
15000,
);
rawSocket!.resume();
const catchUpFrame = await catchUpFramePromise;
expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot);
await closeWebSocket(ws);

View File

@@ -1917,6 +1917,7 @@ 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",
@@ -1967,6 +1968,7 @@ 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
@@ -2096,6 +2098,7 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun
cwd: "/tmp/recent",
title: "Already live",
lastActivityAt: "2026-04-30T12:01:00.000Z",
firstPrompt: "live prompt",
}),
];

View File

@@ -1081,6 +1081,8 @@ 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,
},
};
}

View File

@@ -12,6 +12,7 @@ import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
interface BranchNameGeneratorLogger {
info: (obj: object, msg?: string) => void;
warn: (obj: object, msg?: string) => void;
error: (obj: object, msg?: string) => void;
}
@@ -77,6 +78,7 @@ export async function generateBranchNameFromFirstAgentContext(
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
persistSession: false,
logger: options.logger,
agentConfigOverrides: {
title: "Branch name generator",
internal: true,
@@ -84,14 +86,13 @@ export async function generateBranchNameFromFirstAgentContext(
});
return result.branch.trim() || null;
} catch (error) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
options.logger.warn({ err: error }, "Structured branch name generation failed");
return null;
}
options.logger.error({ err: error }, "Branch name generation failed");
const attempts = error instanceof StructuredAgentFallbackError ? error.attempts : undefined;
options.logger.error(
{ err: error, attempts },
error instanceof StructuredAgentResponseError || error instanceof StructuredAgentFallbackError
? "Structured branch name generation failed"
: "Branch name generation failed",
);
return null;
}
}

View File

@@ -13,6 +13,7 @@ export const TerminalStreamOpcode = {
Input: 0x02,
Resize: 0x03,
Snapshot: 0x04,
Restore: 0x05,
} as const;
export type TerminalStreamOpcode = (typeof TerminalStreamOpcode)[keyof typeof TerminalStreamOpcode];
@@ -57,7 +58,8 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Output ||
value === TerminalStreamOpcode.Input ||
value === TerminalStreamOpcode.Resize ||
value === TerminalStreamOpcode.Snapshot
value === TerminalStreamOpcode.Snapshot ||
value === TerminalStreamOpcode.Restore
);
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { ServerInfoStatusPayloadSchema, SubscribeTerminalRequestSchema } from "./messages.js";
describe("terminal restore schemas", () => {
test("accepts legacy terminal subscribe requests without restore options", () => {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
}),
).toEqual({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
});
});
test("accepts kebab-case terminal restore modes", () => {
for (const mode of ["live", "visible-snapshot", "full-snapshot"] as const) {
expect(
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: `req-${mode}`,
restore: {
mode,
scrollbackLines: 200,
size: { rows: 24, cols: 80 },
},
}).restore?.mode,
).toBe(mode);
}
});
test("rejects camel-case terminal restore modes", () => {
expect(() =>
SubscribeTerminalRequestSchema.parse({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visibleSnapshot",
},
}),
).toThrow();
});
test("accepts terminal restore mode feature metadata", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "server-1",
features: {
"terminal-restore-modes": true,
},
}).features?.["terminal-restore-modes"],
).toBe(true);
});
});

View File

@@ -1754,6 +1754,20 @@ export const SubscribeTerminalRequestSchema = z.object({
type: z.literal("subscribe_terminal_request"),
terminalId: z.string(),
requestId: z.string(),
restore: z
.object({
mode: z.enum(["live", "visible-snapshot", "full-snapshot"]),
scrollbackLines: z.number().int().nonnegative().optional(),
size: z
.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
})
.strict()
.optional(),
})
.strict()
.optional(),
});
export const UnsubscribeTerminalRequestSchema = z.object({
@@ -2079,6 +2093,8 @@ export const ServerInfoStatusPayloadSchema = z
checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
})
.optional(),
})

View File

@@ -0,0 +1,205 @@
import type { TerminalCell, TerminalState } from "./messages.js";
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 [];
}

View File

@@ -1,4 +1,9 @@
import { createTerminal, type TerminalSession, type TerminalStateSnapshot } from "./terminal.js";
import {
createTerminal,
type TerminalSession,
type TerminalStateSnapshot,
type TerminalStateSnapshotOptions,
} from "./terminal.js";
import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js";
import { resolve, sep, win32, posix } from "node:path";
@@ -29,7 +34,10 @@ export interface TerminalManager {
}): Promise<TerminalSession>;
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null>;
setTerminalTitle(id: string, title: string): boolean;
killTerminal(id: string): void;
killTerminalAndWait(
@@ -208,8 +216,11 @@ export function createTerminalManager(): TerminalManager {
return terminalsById.get(id);
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot() ?? null;
async getTerminalState(
id: string,
options?: TerminalStateSnapshotOptions,
): Promise<TerminalStateSnapshot | null> {
return terminalsById.get(id)?.getStateSnapshot(options) ?? null;
},
setTerminalTitle(id: string, title: string): boolean {

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "vitest";
import { TerminalStreamOpcode, decodeTerminalStreamFrame } from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import {
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
} from "./terminal-restore.js";
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
describe("terminal restore policy", () => {
test("uses ready snapshots only for restore-aware subscriptions", () => {
expect(resolveTerminalSubscriptionSnapshotMode(undefined)).toBe("state");
expect(resolveTerminalSubscriptionSnapshotMode({ mode: "live" })).toBe("ready");
});
test("resolves bounded restore snapshot options", () => {
expect(resolveTerminalRestoreSnapshotOptions({ mode: "live" })).toBeNull();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "full-snapshot" })).toBeUndefined();
expect(resolveTerminalRestoreSnapshotOptions({ mode: "visible-snapshot" })).toEqual({
scrollbackLines: 200,
});
expect(
resolveTerminalRestoreSnapshotOptions({
mode: "visible-snapshot",
scrollbackLines: 999,
}),
).toEqual({ scrollbackLines: 500 });
});
test("promotes live restore to visible restore after output overflow", () => {
expect(resolveRestoreAfterOutputOverflow({ mode: "live" })).toEqual({
mode: "visible-snapshot",
});
expect(resolveRestoreAfterOutputOverflow({ mode: "full-snapshot" })).toEqual({
mode: "full-snapshot",
});
});
test("encodes restore snapshots as restore frames", () => {
const frame = decodeTerminalStreamFrame(
encodeTerminalRestoreFrame({
slot: 4,
snapshot: {
state: terminalState("restored"),
revision: 1,
},
}),
);
expect(frame?.opcode).toBe(TerminalStreamOpcode.Restore);
expect(frame?.slot).toBe(4);
expect(new TextDecoder().decode(frame?.payload ?? new Uint8Array())).toContain("restored");
});
});

View File

@@ -0,0 +1,75 @@
import type { SubscribeTerminalRequest } from "../server/messages.js";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { renderTerminalSnapshotToAnsi } from "../shared/terminal-snapshot.js";
import type { TerminalStateSnapshot, TerminalStateSnapshotOptions } from "./terminal.js";
export const MAX_TERMINAL_OUTPUT_FRAME_BYTES = 256 * 1024;
const DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES = 200;
const MAX_VISIBLE_RESTORE_SCROLLBACK_LINES = 500;
export type TerminalRestoreOptions = NonNullable<SubscribeTerminalRequest["restore"]>;
export type TerminalSubscriptionSnapshotMode = "state" | "ready";
export function resolveTerminalSubscriptionSnapshotMode(
restore: TerminalRestoreOptions | undefined,
): TerminalSubscriptionSnapshotMode {
return restore ? "ready" : "state";
}
export function resolveRestoreAfterOutputOverflow(
restore: TerminalRestoreOptions | undefined,
): TerminalRestoreOptions | undefined {
if (restore?.mode === "live") {
return { mode: "visible-snapshot" };
}
return restore;
}
export function resolveTerminalRestoreSnapshotOptions(
restore: TerminalRestoreOptions,
): TerminalStateSnapshotOptions | null | undefined {
if (restore.mode === "live") {
return null;
}
if (restore.mode === "visible-snapshot") {
return {
scrollbackLines: resolveVisibleRestoreScrollbackLines(restore.scrollbackLines),
};
}
return undefined;
}
export function encodeLegacyTerminalSnapshotFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: input.slot,
payload: encodeTerminalSnapshotPayload(input.snapshot.state),
});
}
export function encodeTerminalRestoreFrame(input: {
slot: number;
snapshot: TerminalStateSnapshot;
}): Uint8Array {
return encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Restore,
slot: input.slot,
payload: renderTerminalSnapshotToAnsi(input.snapshot.state),
});
}
function resolveVisibleRestoreScrollbackLines(value: number | undefined): number {
if (typeof value !== "number") {
return DEFAULT_VISIBLE_RESTORE_SCROLLBACK_LINES;
}
return Math.min(Math.max(0, value), MAX_VISIBLE_RESTORE_SCROLLBACK_LINES);
}

View File

@@ -0,0 +1,142 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { SessionOutboundMessage } from "../server/messages.js";
import {
TerminalStreamOpcode,
decodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js";
import { TerminalSessionController } from "./terminal-session-controller.js";
import type { TerminalManager } from "./terminal-manager.js";
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function terminalRow(text: string, cols = 80): TerminalCell[] {
return Array.from({ length: cols }, (_, index) => ({
char: text[index] ?? " ",
}));
}
function terminalState(text: string): TerminalState {
return {
rows: 1,
cols: 80,
grid: [terminalRow(text)],
scrollback: [],
cursor: { row: 0, col: text.length },
};
}
function createLogger(): pino.Logger {
return {
warn: vi.fn(),
error: vi.fn(),
} as unknown as pino.Logger;
}
describe("terminal-session-controller restore", () => {
test("delivers output produced while restore is in flight after the restore frame", async () => {
let terminalListener: ((message: ServerMessage) => void) | null = null;
const snapshot = deferred<TerminalStateSnapshot | null>();
const binaryFrames: TerminalStreamFrame[] = [];
const outboundMessages: SessionOutboundMessage[] = [];
const terminal: TerminalSession = {
id: "term-1",
name: "Terminal",
cwd: "/tmp",
send: vi.fn(),
subscribe: (listener) => {
terminalListener = listener;
queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 }));
return vi.fn();
},
onExit: () => vi.fn(),
onCommandFinished: () => vi.fn(),
onTitleChange: () => vi.fn(),
getSize: () => ({ rows: 1, cols: 80 }),
getState: () => terminalState("restore-before"),
getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }),
getReplayPreamble: () => "",
getTitle: () => undefined,
setTitle: vi.fn(),
getExitInfo: () => null,
kill: vi.fn(),
killAndWait: vi.fn(),
};
const terminalManager: TerminalManager = {
getTerminals: vi.fn(),
createTerminal: vi.fn(),
registerCwdEnv: vi.fn(),
getTerminal: vi.fn(() => terminal),
getTerminalState: vi.fn(() => snapshot.promise),
setTerminalTitle: vi.fn(),
killTerminal: vi.fn(),
killTerminalAndWait: vi.fn(),
captureTerminal: vi.fn(),
listDirectories: vi.fn(() => []),
killAll: vi.fn(),
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
};
const controller = new TerminalSessionController({
terminalManager,
emit: (message) => outboundMessages.push(message),
emitBinary: (bytes) => {
const frame = decodeTerminalStreamFrame(bytes);
if (frame) {
binaryFrames.push(frame);
}
},
hasBinaryChannel: () => true,
isPathWithinRoot: () => false,
sessionLogger: createLogger(),
});
await controller.dispatch({
type: "subscribe_terminal_request",
terminalId: "term-1",
requestId: "req-1",
restore: {
mode: "visible-snapshot",
scrollbackLines: 200,
},
});
await Promise.resolve();
expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1);
terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 });
snapshot.resolve({ state: terminalState("restore-before"), revision: 1 });
await snapshot.promise;
await new Promise((resolve) => setTimeout(resolve, 20));
expect(outboundMessages).toContainEqual({
type: "subscribe_terminal_response",
payload: {
terminalId: "term-1",
slot: 0,
error: null,
requestId: "req-1",
},
});
expect(binaryFrames.map((frame) => frame.opcode)).toEqual([
TerminalStreamOpcode.Restore,
TerminalStreamOpcode.Output,
]);
expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before");
expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n");
});
});

View File

@@ -17,11 +17,19 @@ import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "../ser
import {
TerminalStreamOpcode,
decodeTerminalResizePayload,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
type TerminalStreamFrame,
} from "../shared/binary-frames/index.js";
import { TerminalOutputCoalescer } from "./terminal-output-coalescer.js";
import {
MAX_TERMINAL_OUTPUT_FRAME_BYTES,
encodeLegacyTerminalSnapshotFrame,
encodeTerminalRestoreFrame,
resolveRestoreAfterOutputOverflow,
resolveTerminalRestoreSnapshotOptions,
resolveTerminalSubscriptionSnapshotMode,
type TerminalRestoreOptions,
} from "./terminal-restore.js";
import type { TerminalSession } from "./terminal.js";
import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js";
@@ -38,10 +46,18 @@ interface ActiveTerminalStream {
unsubscribe: () => void;
needsSnapshot: boolean;
snapshotInFlight: boolean;
readyRevision?: number;
restore?: TerminalRestoreOptions;
bufferedOutputs: BufferedTerminalOutput[];
outputBytesSinceSnapshot: number;
outputCoalescer: TerminalOutputCoalescer;
}
interface SnapshotSendResult {
shouldContinue: boolean;
replayRevision?: number;
}
export interface TerminalSessionControllerOptions {
terminalManager: TerminalManager | null;
emit: (msg: SessionOutboundMessage) => void;
@@ -488,7 +504,21 @@ export class TerminalSessionController {
}
this.ensureExitSubscription(session);
const slot = this.bindActiveStream(session);
if (msg.restore?.size) {
const currentSize = session.getSize();
if (
currentSize.rows !== msg.restore.size.rows ||
currentSize.cols !== msg.restore.size.cols
) {
session.send({
type: "resize",
rows: msg.restore.size.rows,
cols: msg.restore.size.cols,
});
}
}
const slot = this.bindActiveStream(session, { restore: msg.restore });
if (slot === null) {
this.sessionLogger.warn(
{
@@ -628,7 +658,10 @@ export class TerminalSessionController {
}
}
private bindActiveStream(terminal: TerminalSession): number | null {
private bindActiveStream(
terminal: TerminalSession,
options?: { restore?: TerminalRestoreOptions },
): number | null {
if (!this.hasBinaryChannel()) {
return null;
}
@@ -638,6 +671,7 @@ export class TerminalSessionController {
const existingStream = this.activeStreams.get(existingSlot);
if (existingStream) {
existingStream.needsSnapshot = true;
existingStream.restore = options?.restore;
return existingSlot;
}
this.idToSlot.delete(terminal.id);
@@ -654,13 +688,23 @@ export class TerminalSessionController {
unsubscribe: () => {},
needsSnapshot: true,
snapshotInFlight: false,
readyRevision: undefined,
restore: options?.restore,
bufferedOutputs: [],
outputBytesSinceSnapshot: 0,
outputCoalescer: new TerminalOutputCoalescer({
timers: { setTimeout, clearTimeout },
onFlush: ({ payload }) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
activeStream.outputBytesSinceSnapshot += payload.byteLength;
if (activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES) {
activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore);
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
@@ -675,31 +719,35 @@ export class TerminalSessionController {
this.activeStreams.set(slot, activeStream);
this.idToSlot.set(terminal.id, slot);
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot") {
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
});
activeStream.unsubscribe = terminal.subscribe(
(message) => {
if (this.activeStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot" || message.type === "snapshotReady") {
activeStream.readyRevision = message.revision;
activeStream.outputCoalescer.flush();
activeStream.needsSnapshot = true;
void this.trySendSnapshot(activeStream);
return;
}
if (message.type === "titleChange") {
return;
}
if (message.data.length === 0) {
return;
}
if (activeStream.needsSnapshot || activeStream.snapshotInFlight) {
activeStream.bufferedOutputs.push({
data: message.data,
revision: message.revision,
});
return;
}
activeStream.outputCoalescer.handle(message.data);
},
{ initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) },
);
return slot;
}
@@ -722,43 +770,23 @@ export class TerminalSessionController {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
if (activeStream.restore && activeStream.readyRevision === undefined) {
return;
}
activeStream.outputCoalescer.flush();
activeStream.snapshotInFlight = true;
try {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
const restore = activeStream.restore;
const snapshotResult = restore
? await this.emitRestoreSnapshot(activeStream, terminalManager, restore)
: await this.emitLegacySnapshot(activeStream, terminalManager);
if (!snapshotResult.shouldContinue) {
return;
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: activeStream.slot,
payload: encodeTerminalSnapshotPayload(snapshot.state),
}),
);
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (output.revision !== undefined && output.revision <= snapshot.revision) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision);
activeStream.needsSnapshot = false;
activeStream.outputBytesSinceSnapshot = 0;
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId: activeStream.terminalId },
@@ -770,6 +798,85 @@ export class TerminalSessionController {
}
}
private async emitLegacySnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
): Promise<SnapshotSendResult> {
const snapshot = await terminalManager.getTerminalState(activeStream.terminalId);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeLegacyTerminalSnapshotFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private async emitRestoreSnapshot(
activeStream: ActiveTerminalStream,
terminalManager: TerminalManager,
restore: TerminalRestoreOptions,
): Promise<SnapshotSendResult> {
const snapshotOptions = resolveTerminalRestoreSnapshotOptions(restore);
if (snapshotOptions === null) {
return { shouldContinue: true };
}
const snapshot = await terminalManager.getTerminalState(
activeStream.terminalId,
snapshotOptions,
);
if (this.activeStreams.get(activeStream.slot) !== activeStream) {
return { shouldContinue: false };
}
if (!snapshot) {
this.detachStream(activeStream.terminalId, { emitExit: true });
return { shouldContinue: false };
}
this.emitBinary(
encodeTerminalRestoreFrame({
slot: activeStream.slot,
snapshot,
}),
);
return { shouldContinue: true, replayRevision: snapshot.revision };
}
private replayTerminalOutputAfterSnapshot(
activeStream: ActiveTerminalStream,
terminal: TerminalSession,
replayRevision: number | undefined,
): void {
const replayPreamble = terminal.getReplayPreamble();
if (replayPreamble.length > 0) {
activeStream.outputCoalescer.handle(replayPreamble);
}
const bufferedOutputs = activeStream.bufferedOutputs.splice(
0,
activeStream.bufferedOutputs.length,
);
for (const output of bufferedOutputs) {
if (
replayRevision !== undefined &&
output.revision !== undefined &&
output.revision <= replayRevision
) {
continue;
}
activeStream.outputCoalescer.handle(output.data);
}
}
private allocateSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;

View File

@@ -176,7 +176,7 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
type: "response",
requestId: message.requestId,
ok: true,
result: session?.getStateSnapshot() ?? null,
result: session?.getStateSnapshot(message.options) ?? null,
});
return;
}

View File

@@ -3,6 +3,7 @@ import type {
ServerMessage,
ClientMessage,
TerminalStateSnapshot,
TerminalStateSnapshotOptions,
} from "./terminal.js";
import type { TerminalState } from "../shared/messages.js";
import type { CaptureTerminalLinesResult } from "./terminal-capture.js";
@@ -61,6 +62,7 @@ export type TerminalWorkerRequest =
type: "getTerminalState";
requestId: string;
terminalId: string;
options?: TerminalStateSnapshotOptions;
}
| {
type: "captureTerminal";

View File

@@ -33,6 +33,14 @@ export interface TerminalStateSnapshot {
revision: number;
}
export interface TerminalStateSnapshotOptions {
scrollbackLines?: number;
}
export interface TerminalSubscribeOptions {
initialSnapshot?: "state" | "ready";
}
export type ClientMessage =
| { type: "input"; data: string }
| { type: "resize"; rows: number; cols: number }
@@ -41,6 +49,7 @@ export type ClientMessage =
export type ServerMessage =
| { type: "output"; data: string; revision?: number }
| { type: "snapshot"; state: TerminalState; revision?: number }
| { type: "snapshotReady"; revision?: number }
| { type: "titleChange"; title?: string };
export interface TerminalSession {
@@ -48,13 +57,13 @@ export interface TerminalSession {
name: string;
cwd: string;
send(msg: ClientMessage): void;
subscribe(listener: (msg: ServerMessage) => void): () => void;
subscribe(listener: (msg: ServerMessage) => void, options?: TerminalSubscribeOptions): () => void;
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(): TerminalStateSnapshot;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string;
getTitle(): string | undefined;
setTitle(title: string): void;
@@ -292,14 +301,21 @@ function extractGrid(terminal: TerminalType): TerminalCell[][] {
return grid;
}
function extractScrollback(terminal: TerminalType): TerminalCell[][] {
function extractScrollback(
terminal: TerminalType,
options?: { scrollbackLines?: number },
): TerminalCell[][] {
const scrollback: TerminalCell[][] = [];
const buffer = terminal.buffer.active;
// baseY is the first row of the visible viewport (0-indexed)
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
const scrollbackLines = buffer.baseY;
const startRow =
typeof options?.scrollbackLines === "number"
? Math.max(0, scrollbackLines - options.scrollbackLines)
: 0;
for (let row = 0; row < scrollbackLines; row++) {
for (let row = startRow; row < scrollbackLines; row++) {
const rowCells: TerminalCell[] = [];
const line = buffer.getLine(row);
for (let col = 0; col < terminal.cols; col++) {
@@ -813,20 +829,22 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function getState(): TerminalState {
function getState(snapshotOptions?: TerminalStateSnapshotOptions): TerminalState {
return {
rows: terminal.rows,
cols: terminal.cols,
grid: extractGrid(terminal),
scrollback: extractScrollback(terminal),
scrollback: extractScrollback(terminal, {
scrollbackLines: snapshotOptions?.scrollbackLines,
}),
cursor: extractCursorState(terminal),
...(title ? { title } : {}),
};
}
function getStateSnapshot(): TerminalStateSnapshot {
function getStateSnapshot(snapshotOptions?: TerminalStateSnapshotOptions): TerminalStateSnapshot {
return {
state: getState(),
state: getState(snapshotOptions),
revision: stateRevision,
};
}
@@ -891,10 +909,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
function subscribe(listener: (msg: ServerMessage) => void): () => void {
function subscribe(
listener: (msg: ServerMessage) => void,
subscribeOptions?: TerminalSubscribeOptions,
): () => void {
let active = true;
let snapshotDelivered = false;
const queuedMessages: ServerMessage[] = [];
const initialSnapshot = subscribeOptions?.initialSnapshot ?? "state";
const subscriptionListener = (msg: ServerMessage): void => {
if (!active) {
return;
@@ -911,7 +933,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
terminal.write("", () => {
if (!disposed && active && listeners.has(subscriptionListener)) {
snapshotDelivered = true;
listener({ type: "snapshot", ...getStateSnapshot() });
if (initialSnapshot === "ready") {
listener({ type: "snapshotReady", revision: stateRevision });
} else {
listener({ type: "snapshot", ...getStateSnapshot() });
}
for (const message of queuedMessages.splice(0)) {
listener(message);
}

View File

@@ -200,8 +200,18 @@ export function createWorkerTerminalManager(
}
sendBestEffortRequest({ type: "send", terminalId: record.info.id, message });
},
subscribe(listener: (msg: ServerMessage) => void): () => void {
subscribe(
listener: (msg: ServerMessage) => void,
options?: { initialSnapshot?: "state" | "ready" },
): () => void {
record.messageListeners.add(listener);
if (options?.initialSnapshot === "ready") {
queueMicrotask(() => {
if (record.messageListeners.has(listener)) {
listener({ type: "snapshotReady", revision: 0 });
}
});
}
return () => {
record.messageListeners.delete(listener);
};
@@ -244,9 +254,14 @@ export function createWorkerTerminalManager(
getState(): TerminalState {
return record.state;
},
getStateSnapshot(): TerminalStateSnapshot {
getStateSnapshot(options?: { scrollbackLines?: number }): TerminalStateSnapshot {
const scrollbackLines = options?.scrollbackLines;
const scrollback =
typeof scrollbackLines === "number"
? record.state.scrollback.slice(-scrollbackLines)
: record.state.scrollback;
return {
state: record.state,
state: { ...record.state, scrollback },
revision: 0,
};
},
@@ -539,10 +554,14 @@ export function createWorkerTerminalManager(
return recordsById.get(id)?.session;
},
async getTerminalState(id: string): Promise<TerminalStateSnapshot | null> {
async getTerminalState(
id: string,
options?: { scrollbackLines?: number },
): Promise<TerminalStateSnapshot | null> {
return (await sendRequest({
type: "getTerminalState",
terminalId: id,
...(options ? { options } : {}),
})) as TerminalWorkerStateResult;
},

View File

@@ -493,6 +493,53 @@ const x = 1;
expect(status.aheadOfOrigin).toBeNull();
});
it("does not report full history as unpushed for fresh no-track Paseo worktrees", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
commitFile(repoDir, "second.txt", "second\n", "second commit");
execFileSync("git", ["push"], { cwd: repoDir });
const worktree = await createLegacyWorktreeForTest({
branchName: "fresh-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "fresh-feature",
paseoHome,
});
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
expect(status).toMatchObject({
isGit: true,
isPaseoOwnedWorktree: true,
baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
});
});
it("reports local-only worktree commits as unpushed relative to base", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
commitFile(repoDir, "second.txt", "second\n", "second commit");
execFileSync("git", ["push"], { cwd: repoDir });
const worktree = await createLegacyWorktreeForTest({
branchName: "fresh-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "fresh-feature",
paseoHome,
});
commitFile(worktree.worktreePath, "feature.txt", "feature\n", "feature commit");
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
expect(status).toMatchObject({
isGit: true,
isPaseoOwnedWorktree: true,
baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 },
aheadOfOrigin: 1,
});
});
it("does not report incoming additions when the base branch is behind its remote", async () => {
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update");

View File

@@ -1268,6 +1268,7 @@ async function getAheadBehind(
async function getAheadOfOrigin(
cwd: string,
currentBranch: string,
baseRef: string | null,
context?: CheckoutContext,
): Promise<number | null> {
if (!currentBranch) {
@@ -1286,12 +1287,15 @@ async function getAheadOfOrigin(
if (trackedOriginBranch) {
return null;
}
if (!baseRef || normalizeLocalBranchRefName(baseRef) === currentBranch) {
return null;
}
try {
const { stdout } = await runGitCommand(["rev-list", "--count", currentBranch], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context);
const { stdout } = await runGitCommand(
["rev-list", "--count", `${comparisonBaseRef}..${currentBranch}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
);
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
} catch {
@@ -1503,7 +1507,7 @@ export async function getCheckoutStatus(
? getAheadBehind(cwd, baseRef, currentBranch, context)
: Promise.resolve(null),
hasRemote && currentBranch
? getAheadOfOrigin(cwd, currentBranch, context)
? getAheadOfOrigin(cwd, currentBranch, baseRef, context)
: Promise.resolve(null),
hasRemote && currentBranch
? getBehindOfOrigin(cwd, currentBranch, context)

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.79",
"version": "0.1.81",
"private": true,
"type": "module",
"scripts": {