mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c97ff8fa0 | ||
|
|
c0d2f20056 | ||
|
|
2a3cfc684b | ||
|
|
68c893f643 | ||
|
|
d43d30eeeb | ||
|
|
79dcbdc1c1 | ||
|
|
94bccf19ba | ||
|
|
83f205bd3d | ||
|
|
846c9b9da3 | ||
|
|
84f1dfc8cb | ||
|
|
33892f0698 | ||
|
|
0962529d48 | ||
|
|
008e4e846f | ||
|
|
789a559b31 | ||
|
|
f5f1ae7fa9 | ||
|
|
8e0ebfcaaa | ||
|
|
b151dfcfd5 | ||
|
|
de7bf2fb01 | ||
|
|
5f11f602fb | ||
|
|
369c5a4498 | ||
|
|
c46ff2e045 | ||
|
|
af10e64f82 | ||
|
|
db44a3e0e1 | ||
|
|
25c4cee01e | ||
|
|
3ec4e2c536 | ||
|
|
7dd9cbc506 | ||
|
|
6dc22483c5 |
33
CHANGELOG.md
33
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
173
docs/floating-panels.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-a4fXFcmH0dddKpaMPdWeHtB8oyajugBeWpWnGB/18yY=
|
||||
sha256-H18zIrryRN+tfQA68vfbWHNMN1mOxhO4PAWcZ4yuNSc=
|
||||
|
||||
286
package-lock.json
generated
286
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
packages/app/public/apple-touch-icon.png
Normal file
BIN
packages/app/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -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">
|
||||
|
||||
27
packages/app/public/manifest.json
Normal file
27
packages/app/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
packages/app/public/pwa-icon-192.png
Normal file
BIN
packages/app/public/pwa-icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
packages/app/public/pwa-icon-512.png
Normal file
BIN
packages/app/public/pwa-icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
89
packages/app/scripts/build-terminal-webview-html.mjs
Normal file
89
packages/app/scripts/build-terminal-webview-html.mjs
Normal 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)`);
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
66
packages/app/src/components/community-links.tsx
Normal file
66
packages/app/src/components/community-links.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
14
packages/app/src/components/icons/discord-icon.tsx
Normal file
14
packages/app/src/components/icons/discord-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
532
packages/app/src/components/terminal-emulator.native.tsx
Normal file
532
packages/app/src/components/terminal-emulator.native.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
176
packages/app/src/components/ui/autocomplete-popover.tsx
Normal file
176
packages/app/src/components/ui/autocomplete-popover.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
packages/app/src/styles/unistyles-inline-style.native.ts
Normal file
8
packages/app/src/styles/unistyles-inline-style.native.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
23
packages/app/src/styles/unistyles-inline-style.web.ts
Normal file
23
packages/app/src/styles/unistyles-inline-style.web.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
97
packages/app/src/utils/rich-clipboard.test.ts
Normal file
97
packages/app/src/utils/rich-clipboard.test.ts
Normal 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("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeMarkdownToRichClipboard", () => {
|
||||
it("writes plain text and html when a rich clipboard writer is available", async () => {
|
||||
const markdown = "- item";
|
||||
const writes: Array<Parameters<RichClipboardWriter["write"]>[0]> = [];
|
||||
const richWriter: RichClipboardWriter = {
|
||||
supportsHtml: () => true,
|
||||
write: async (data) => {
|
||||
writes.push(data);
|
||||
},
|
||||
};
|
||||
|
||||
await writeMarkdownToRichClipboard(markdown, {
|
||||
richWriter,
|
||||
writePlainText: setStringAsyncMock,
|
||||
});
|
||||
|
||||
const written = writes[0];
|
||||
if (!written) {
|
||||
throw new Error("Expected rich clipboard data to be written");
|
||||
}
|
||||
await expect(written["text/plain"].text()).resolves.toBe(markdown);
|
||||
await expect(written["text/html"].text()).resolves.toContain("<li>item</li>");
|
||||
expect(setStringAsyncMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to plain text when rich clipboard writing fails", async () => {
|
||||
const richWriter: RichClipboardWriter = {
|
||||
supportsHtml: () => true,
|
||||
write: async () => {
|
||||
throw new Error("clipboard denied");
|
||||
},
|
||||
};
|
||||
|
||||
await writeMarkdownToRichClipboard("**bold**", {
|
||||
richWriter,
|
||||
writePlainText: setStringAsyncMock,
|
||||
});
|
||||
|
||||
expect(setStringAsyncMock).toHaveBeenCalledWith("**bold**");
|
||||
});
|
||||
});
|
||||
81
packages/app/src/utils/rich-clipboard.ts
Normal file
81
packages/app/src/utils/rich-clipboard.ts
Normal 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)]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.81",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
packages/server/src/client/terminal-stream-router.test.ts
Normal file
28
packages/server/src/client/terminal-stream-router.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
61
packages/server/src/shared/messages.terminal-restore.test.ts
Normal file
61
packages/server/src/shared/messages.terminal-restore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
205
packages/server/src/shared/terminal-snapshot.ts
Normal file
205
packages/server/src/shared/terminal-snapshot.ts
Normal 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 [];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
72
packages/server/src/terminal/terminal-restore.test.ts
Normal file
72
packages/server/src/terminal/terminal-restore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
75
packages/server/src/terminal/terminal-restore.ts
Normal file
75
packages/server/src/terminal/terminal-restore.ts
Normal 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);
|
||||
}
|
||||
142
packages/server/src/terminal/terminal-session-controller.test.ts
Normal file
142
packages/server/src/terminal/terminal-session-controller.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.79",
|
||||
"version": "0.1.81",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user