Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 5347d8e1fe test(app): reproduce stale draft error after host reconnects
After an offline draft submit, reconnecting restores model selection but leaves the connection alert visible. Keep the failing browser assertion to guide the submission error ownership decision in #7307.
2026-10-19 00:38:12 +02:00
58 changed files with 289 additions and 1766 deletions
-4
View File
@@ -93,10 +93,6 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm run lint
- name: Test lint rules
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: node --test scripts/oxlint/paseo-plugin.test.mjs
- name: Build relay
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run build:relay
-112
View File
@@ -5,7 +5,6 @@
},
"ignorePatterns": [".dev/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
"correctness": "error",
"suspicious": "error",
@@ -357,117 +356,6 @@
"no-restricted-imports": "off"
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
"paseo/no-raw-pressable": "error"
}
},
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
// 94 files. They may only leave this list.
{
"files": [
"packages/app/src/components/ui/**/*.{ts,tsx}",
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
"packages/app/src/components/attachment-pill.tsx",
"packages/app/src/components/combined-model-selector.tsx",
"packages/app/src/components/compact-explorer-sidebar.tsx",
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/desktop/window-controls.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/file-explorer-pane.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/highlighted-code-block.tsx",
"packages/app/src/components/hosts/host-filter.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/import-session-sheet.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
"packages/app/src/components/message.tsx",
"packages/app/src/components/model-browser.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
"packages/app/src/components/realtime-voice-overlay.tsx",
"packages/app/src/components/schedules/schedule-row.tsx",
"packages/app/src/components/settings/collapsible-row.tsx",
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
"packages/app/src/components/sidebar-callout.tsx",
"packages/app/src/components/sidebar-resize-handle.tsx",
"packages/app/src/components/sidebar-workspace-list.tsx",
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
"packages/app/src/components/terminal-copy-paste-actions.tsx",
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
"packages/app/src/components/terminal-pane.tsx",
"packages/app/src/components/toast-host.tsx",
"packages/app/src/components/tool-call-sheet.tsx",
"packages/app/src/components/welcome-screen.tsx",
"packages/app/src/components/workspace-hover-card.tsx",
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
"packages/app/src/composer/agent-controls/index.tsx",
"packages/app/src/composer/diff-stat-pill.tsx",
"packages/app/src/composer/dock/internal/background.native.tsx",
"packages/app/src/composer/draft/import-pill.tsx",
"packages/app/src/composer/index.tsx",
"packages/app/src/composer/tracks.tsx",
"packages/app/src/desktop/browser/pane/index.electron.tsx",
"packages/app/src/git/actions-split-button.tsx",
"packages/app/src/git/commits-section/commit-row.tsx",
"packages/app/src/git/commits-section/commits-section.tsx",
"packages/app/src/git/diff-pane.tsx",
"packages/app/src/git/forges/gitlab.view.tsx",
"packages/app/src/git/pull-request-panel/checks-section.tsx",
"packages/app/src/git/pull-request-panel/pane.tsx",
"packages/app/src/git/pull-request-panel/section-kit.tsx",
"packages/app/src/hosts/host-chooser.tsx",
"packages/app/src/mobile-panels/presentation.tsx",
"packages/app/src/panels/new-tab-panel.tsx",
"packages/app/src/panels/setup-panel.tsx",
"packages/app/src/plugins/buttons/view.tsx",
"packages/app/src/review/surface.tsx",
"packages/app/src/screens/open-project-screen.tsx",
"packages/app/src/screens/project-settings-screen.tsx",
"packages/app/src/screens/projects-screen.tsx",
"packages/app/src/screens/settings-screen.tsx",
"packages/app/src/screens/settings/host-appearance-section.tsx",
"packages/app/src/screens/settings/host-page.tsx",
"packages/app/src/screens/settings/providers-section.tsx",
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
"packages/app/src/usage/sidebar-item.tsx",
"packages/app/src/usage/window-bar.tsx",
"packages/app/src/workspace-labels/manager-modal.tsx",
"packages/app/src/workspace-labels/swatch.tsx",
"packages/app/src/workspace/open-in-editor/button.tsx"
],
"rules": {
"paseo/no-raw-pressable": "off"
}
},
{
"files": ["**/e2e/**/fixtures.ts"],
"rules": {
+4 -16
View File
@@ -1,8 +1,8 @@
# Changelog
## 0.12.0 - 2026-10-19
## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
@@ -29,10 +29,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
@@ -47,7 +46,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
### Fixed
@@ -61,8 +59,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
@@ -72,7 +68,6 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
@@ -104,13 +99,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
@@ -167,10 +158,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
## 0.11.4 - 2026-10-15
+1 -2
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
@@ -249,7 +249,6 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
@@ -1,8 +0,0 @@
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
Added
- Added background workspaces, hidden from the sidebar unless Show background is on
Full notes: https://paseo.sh/changelog
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
+14 -16
View File
@@ -1,7 +1,6 @@
import { expect, test, type Page } from "../support/fixtures";
import {
blurComposerInput,
roundTripComposerTabBeforePaint,
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
@@ -18,6 +17,7 @@ import {
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
@@ -205,21 +205,19 @@ test.describe("touch screen wide enough for the desktop layout", () => {
title: "Second touch chat",
});
await openWorkspaceWithAgents(page, [first, second]);
// Repeat after an explicit edit so retained focus from an earlier visit is
// also dismissed when the same mounted editor becomes inactive again.
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
await composerLocator(page).fill(draft);
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await roundTripComposerTabBeforePaint(page, "Second touch chat");
await expectComposerDraft(page, draft);
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, `${draft} after return`);
}
await composerLocator(page).fill("Keep this touch draft");
await expectComposerFocused(page);
// An external keyboard changes tabs without a pointer click blurring the
// editor first. The device still has a touch-primary focus policy.
await switchComposerTabWithKeyboard(page, "previous");
await expectComposerDraft(page, "");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerDraft(page, "Keep this touch draft");
await expectComposerNotFocused(page);
await composerLocator(page).tap();
await expectComposerFocused(page);
await typeIntoFocusedComposer(page, " after return");
await expectComposerDraft(page, "Keep this touch draft after return");
} finally {
await workspace.cleanup();
}
@@ -11,7 +11,7 @@ async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaem
gate.releaseHeldServerMessage("status");
}
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
test("an offline workspace draft clears its connection error when the host reconnects", async ({
page,
withWorkspace,
}) => {
@@ -28,5 +28,6 @@ test("an offline workspace draft reports the disconnected host instead of loadin
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
await restoreHostConnection(gate);
await expectComposerModel(page, "Ten second stream");
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
});
@@ -266,22 +266,14 @@ test("keeps overview tool calls inline on desktop", async ({ page }) => {
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
try {
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
await openOverviewAgent(page, agent);
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
await gate.waitForFirstCompleted();
const group = page.getByTestId("tool-call-group").first();
await expect(group).toBeVisible();
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
const header = group.getByRole("button").first();
await header.click();
await group.click();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
gate.release(1);
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
} finally {
await agent.cleanup();
}
@@ -1,19 +0,0 @@
# Prerequisite: a native terminal showing the output of echo '한글 문장'
# on its first row, with no active selection. Use a dedicated QA workspace.
# Coordinates target the first half of 문 on a 1080x1920 phone at default
# code size. Override WORD_X/WORD_Y for other device geometry.
context platform=android timeout=30000 retries=0
env APP_ID=sh.paseo.debug
env WORD_X=105
env WORD_Y=251
open "${APP_ID}"
wait "label=\"Terminal input\"" 10000
wait "label=\"한글 문장\"" 10000
longpress "${WORD_X}" "${WORD_Y}" 650
wait "id=\"terminal-copy\"" 5000
press "id=\"terminal-copy\""
# Copy clears the selection; only Android's clipboard preview has this exact
# text, since the unselected terminal row contains the full sentence.
wait "text=\"문장\"" 3000
close
@@ -501,59 +501,3 @@ export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
/** Exercise the quick-return race before a hidden pane gets a rendering frame. */
export async function roundTripComposerTabBeforePaint(
page: Page,
activeChatTitle: string,
): Promise<void> {
const activeTab = page.getByRole("button", { name: activeChatTitle, exact: true });
await expect(activeTab).toHaveAttribute("aria-selected", "true");
const result = await activeTab.evaluate((tab) => {
const editor = document.activeElement;
return new Promise<{ returnedBeforePaint: boolean; focused: boolean }>((resolve) => {
let switchedAway = false;
let painted = false;
let frame = 0;
const shortcut = (code: "BracketLeft" | "BracketRight") => {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: code === "BracketLeft" ? "{" : "}",
code,
altKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
};
// Watch selection commits without reading layout: a layout read could make
// Chromium blur the hidden editor and erase the condition under test.
const observer = new MutationObserver(() => {
if (!switchedAway && tab.getAttribute("aria-selected") === "false") {
switchedAway = true;
frame = requestAnimationFrame(() => {
painted = true;
});
// Deliver the return shortcut before rendering, without a Playwright
// round trip allowing the browser to paint the inactive tab first.
shortcut("BracketRight");
} else if (switchedAway && tab.getAttribute("aria-selected") === "true") {
observer.disconnect();
cancelAnimationFrame(frame);
return resolve({
returnedBeforePaint: !painted,
focused: document.activeElement === editor,
});
}
});
observer.observe(tab, { attributes: true, attributeFilter: ["aria-selected"] });
shortcut("BracketLeft");
});
});
expect(
result.returnedBeforePaint,
"tab round trip must finish before rendering the hidden pane",
).toBe(true);
expect(result.focused, "returning on touch must leave the composer unfocused").toBe(false);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -208,56 +208,6 @@ it("lets the engine own code boundaries while classifying candidate values", asy
]);
});
describe("bare URL sentence punctuation", () => {
it.each([".", ",", ";", ":", "!", "?", ")", ").", "...", "?!"])(
"leaves %s outside the link and preserves the rendered sentence",
async (punctuation) => {
const url = "https://github.com/getpaseo/paseo/pull/7357";
const source = `${url}${punctuation} Not merged.`;
const prepared = prepareMarkdownSource(source);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
expect(tree.children?.[0]?.children).toEqual([
{
type: "Link",
attributes: { url },
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: `${punctuation} Not merged.` },
]);
},
);
it.each([
"https://example.com/a.b",
"https://example.com/a?foo=bar&baz=2",
"https://example.com/a#L10",
"https://en.wikipedia.org/wiki/Function_(mathematics)",
"https://example.com/a_(b_(c))",
])("preserves internal URL punctuation: %s", async (url) => {
const tree = await parseMarkdown(prepareMarkdownSource(`${url}.`).render());
expect(tree.children?.[0]?.children).toEqual([
{
type: "Link",
attributes: { url },
children: [{ type: "Text", content: url }],
},
{ type: "Text", content: "." },
]);
});
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
"preserves punctuation in explicit destinations: %s",
async (source) => {
const tree = await parseMarkdown(prepareMarkdownSource(source).render());
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
},
);
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
@@ -768,10 +718,3 @@ it("replaces the native placeholder only after the resolved URL decodes", () =>
const prepared = prepareMarkdownSource("![image](shot.png)", undefined, { supportsSvg: false });
expect(prepared.render(new Map([["shot.png", url]]))).toBe(`![image](<${url}>)`);
});
it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => {
expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({
userSelect: "text",
WebkitUserSelect: "text",
});
});
@@ -27,6 +27,7 @@ const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: st
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
const regularStyle = markdownStyle(false);
const compactStyle = markdownStyle(true);
const WEB_CONTAINER = { userSelect: "text" as const };
export interface MarkdownRendererProps extends MarkdownImageContext {
text: string;
@@ -98,6 +99,7 @@ const MarkdownPart = memo(function MarkdownPart({
<ThemedMarkdown
key={`${segment.kind}:${segment.id}`}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
@@ -2,13 +2,10 @@ import type { MarkdownStyle } from "react-native-enriched-markdown";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
// iOS Safari needs the prefix to override the gesture host's selection suppression.
const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
export function createMarkdownStyles(
theme: Theme,
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
): { markdownStyle: MarkdownStyle; selectionColor: string } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
// Every block is followed by the same gap; the library drops it after the last block.
const block = { marginTop: 0, marginBottom: blockGap };
@@ -26,7 +23,6 @@ export function createMarkdownStyles(
fontWeight: "bold",
});
return {
containerStyle: isWeb ? WEB_CONTAINER : undefined,
selectionColor: theme.colors.terminal.selectionBackground,
markdownStyle: {
paragraph,
@@ -868,9 +868,7 @@ function NativeTerminalEmulator({
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
return;
}
@@ -917,10 +915,7 @@ function NativeTerminalEmulator({
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
if (selectionPress?.status === "selecting") {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
+1 -6
View File
@@ -1295,12 +1295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Read pointer capability when restoring, without making a pointer change itself
// a focus trigger.
useLayoutEffect(() => {
const finePointer = hasFinePointer();
if (!isPaneFocused && isWeb && !finePointer) {
// Hiding a retained tab only blurs its editor after the browser renders.
// A quick return before that frame would otherwise keep touch focus.
textInputRef.current?.blur();
} else if (isPaneFocused && focusIntentRef.current && finePointer) {
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
textInputRef.current?.focus();
}
}, [isPaneFocused]);
@@ -4,7 +4,6 @@ import {
beginMessageSubmission,
getActiveMessageSubmissions,
getSendingClientMessageIds,
hasActiveMessageSubmission,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
} from "./model";
@@ -54,21 +53,6 @@ describe("message submission transactions", () => {
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
});
it("reports activity until every submission is provider acknowledged", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1" }),
{ clientMessageId: "client-2" },
);
const oneAcknowledged = observeMessageSubmissionCanonical(both, ["client-1"]);
expect(hasActiveMessageSubmission(undefined)).toBe(false);
expect(hasActiveMessageSubmission(both)).toBe(true);
expect(hasActiveMessageSubmission(oneAcknowledged)).toBe(true);
expect(
hasActiveMessageSubmission(observeMessageSubmissionCanonical(oneAcknowledged, ["client-2"])),
).toBe(false);
});
it("records provider acknowledgement without settling another transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1" }),
+23 -19
View File
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } fro
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
MENU_ITEM_HEIGHT,
MenuRoot,
MenuSeparator,
MenuSurface,
@@ -313,22 +312,6 @@ const styles = StyleSheet.create((theme) => {
backgroundColor: getStatusDotColor({ theme, bucket, showDoneAsInactive: true }) ?? undefined,
});
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
borderRadius: theme.borderRadius.md,
} as const;
return {
bar: {
position: "absolute",
@@ -349,8 +332,29 @@ const styles = StyleSheet.create((theme) => {
alignItems: "center",
gap: theme.spacing[1],
},
row: trackRow,
rowActive: { ...trackRow, backgroundColor: theme.colors.surface2 },
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
rowActive: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 32,
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
// Segments sit twice as far apart as a mark sits from its own text, so a count reads as
// belonging to the mark on its left rather than to the words on its right.
segments: {
@@ -162,7 +162,7 @@ import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recover
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
import {
buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector,
deriveWorkspaceAgentVisibility,
workspaceAgentVisibilityEqual,
} from "@/workspace-tabs/agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
@@ -1148,17 +1148,14 @@ function WorkspaceScreenContent({
),
});
const selectWorkspaceAgentVisibility = useMemo(
() =>
createWorkspaceAgentVisibilitySelector({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
}),
[normalizedServerId, normalizedWorkspaceId],
);
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
selectWorkspaceAgentVisibility,
(state) =>
deriveWorkspaceAgentVisibility({
sessionAgents: state.sessions[normalizedServerId]?.agents,
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
workspaceId: normalizedWorkspaceId,
}),
workspaceAgentVisibilityEqual,
);
+1 -163
View File
@@ -1,11 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import {
createSubagentsForParentSelector,
selectProviderSubagentsForParent,
selectSubagentsForParent,
} from "./select";
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
import { useProviderSubagentStore } from "./provider-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -440,160 +435,3 @@ describe("selectSubagentsForParent", () => {
);
});
});
describe("createSubagentsForParentSelector", () => {
class IterationCountingMap<K, V> extends Map<K, V> {
iterations = 0;
override values() {
this.iterations += 1;
return super.values();
}
override entries() {
this.iterations += 1;
return super.entries();
}
override [Symbol.iterator]() {
this.iterations += 1;
return super[Symbol.iterator]();
}
}
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
function streamTick(text: string): void {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
head: [
{
kind: "assistant_message",
id: "message-1",
text,
timestamp: new Date("2026-03-08T10:05:00.000Z"),
},
],
});
}
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
const agents = new IterationCountingMap<string, Agent>(
[
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
].map((agent) => [agent.id, agent]),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
const iterationsAfterFirstSelect = agents.iterations;
for (let tick = 0; tick < 100; tick += 1) {
streamTick(`token ${tick}`);
expect(select(useSessionStore.getState())).toBe(before);
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
expect(before.map((row) => row.id)).toEqual(["child"]);
});
it("recomputes when the agents map changes", () => {
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
const child = makeAgent({
id: "child-b",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:01:00.000Z"),
});
return new Map(current).set(child.id, child);
});
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
});
it("keeps the previous rows when a changed agents map yields the same rows", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
});
expect(select(useSessionStore.getState())).toBe(before);
});
it("applies the pending archive ids it was created with", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child-a", parentAgentId: "parent" }),
makeAgent({ id: "child-b", parentAgentId: "parent" }),
]);
const rows = createSubagentsForParentSelector(
params,
new Set(["child-b"]),
)(useSessionStore.getState());
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
const agents = new IterationCountingMap<string, Agent>(
Array.from({ length: 200 }, (_, i) => {
const agent = makeAgent({
id: `agent-${i}`,
workspaceId: "workspace",
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
});
return [agent.id, agent] as const;
}),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const selectVisibility = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: "workspace",
});
const selectChildren = Array.from({ length: 3 }, (_, i) =>
createSubagentsForParentSelector(
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
EMPTY_PENDING_ARCHIVE_IDS,
),
);
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
selectVisibility(state);
selectChildren.forEach((selector) => selector(state));
};
select(useSessionStore.getState());
const before = agents.iterations;
const unsubscribe = useSessionStore.subscribe(select);
try {
for (let tick = 0; tick < 100; tick++) {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
head: [
{
kind: "assistant_message",
id: "message",
text: `token ${tick}`,
timestamp: AGENT_TIMESTAMP,
},
],
});
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations - before).toBe(0);
} finally {
unsubscribe();
useSessionStore.getState().clearSession(SERVER_ID);
}
});
});
+5 -31
View File
@@ -97,31 +97,6 @@ export function selectSubagentsForParent(
return rows;
}
// Session-store selectors run on every store update, including every stream tick, which
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
// them when a recompute yields equal rows so the hook's equality check stays O(1).
export function createSubagentsForParentSelector(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Map<string, Agent> | undefined;
let previousRows: SubagentRow[] | null = null;
return (state) => {
const agents = state.sessions[params.serverId]?.agents;
if (previousRows && agents === previousAgents) {
return previousRows;
}
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
previousAgents = agents;
if (!previousRows || !equal(previousRows, rows)) {
previousRows = rows;
}
return previousRows;
};
}
export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
@@ -158,13 +133,12 @@ export function selectProviderSubagentsForParent(
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const { serverId, parentAgentId } = params;
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
const selectPaseoRows = useMemo(
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
[serverId, parentAgentId, pendingArchiveIds],
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
const paseoRows = useStoreWithEqualityFn(
useSessionStore,
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
equal,
);
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
+58 -48
View File
@@ -1,12 +1,11 @@
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useCallback, type ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Archive, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useProviderIcon } from "@/components/provider-icons";
import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
@@ -50,6 +49,7 @@ export interface SubagentsTrackProps {
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
/** Leading and action glyphs share one size so rows keep a single icon column. */
const ROW_ICON_SIZE = 14;
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
@@ -234,7 +234,6 @@ function SubagentsTrackRow({
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
compact={isCompact}
visible={actionsAlwaysVisible || active}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
@@ -247,7 +246,6 @@ function SubagentsTrackRow({
displayLabel,
handleArchivePress,
handleDetachPress,
isCompact,
onDetachSubagent,
presentation,
row.kind,
@@ -286,20 +284,17 @@ function SubagentFacts({ rowId, text }: { rowId: string; text: string | null })
function SubagentRowActions({
rowId,
displayLabel,
compact,
visible,
onDetachPress,
onArchivePress,
}: {
rowId: string;
displayLabel: string;
compact: boolean;
visible: boolean;
onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement {
const { t } = useTranslation();
const iconSize = paneContentToolbarIconSize(compact);
return (
<View
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
@@ -307,59 +302,69 @@ function SubagentRowActions({
>
{onDetachPress ? (
<SubagentActionButton
label={t("subagents.detachAction", { label: displayLabel })}
tooltipLabel={t("subagents.detachTooltip")}
compact={compact}
onPress={onDetachPress}
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
>
<ThemedUnlink size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
onPress={onDetachPress}
/>
) : null}
<SubagentActionButton
label={t("subagents.archiveAction", { label: displayLabel })}
tooltipLabel={t("subagents.archiveTooltip")}
compact={compact}
onPress={onArchivePress}
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
>
<ThemedArchive size={iconSize} uniProps={mutedIconColorMapping} />
</SubagentActionButton>
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
onPress={onArchivePress}
/>
</View>
);
}
/** The standard icon action, with a tooltip that names the action without repeating the row. */
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
return <ThemedArchive size={ROW_ICON_SIZE} uniProps={uniProps} />;
}
function SubagentActionButton({
label,
tooltipLabel,
compact,
onPress,
accessibilityLabel,
testID,
children,
tooltipLabel,
icon,
visible,
onPress,
}: {
label: string;
tooltipLabel: string;
compact: boolean;
onPress: () => void;
accessibilityLabel: string;
testID: string;
children: ReactNode;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
onPress: () => void;
}): ReactElement {
const tooltip = useMemo(
() => <Text style={styles.tooltipText}>{tooltipLabel}</Text>,
[tooltipLabel],
);
return (
<ToolbarButton
label={label}
tooltip={tooltip}
tooltipSide="top"
compact={compact}
onPress={onPress}
testID={testID}
>
{children}
</ToolbarButton>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
@@ -410,6 +415,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
opacity: 0,
},
actionButton: {
padding: theme.spacing[1],
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -151,61 +151,6 @@ describe("native terminal selection", () => {
});
});
it.each([
{ col: 0, expected: "한글", startCol: 0, endCol: 3 },
{ col: 1, expected: "한글", startCol: 0, endCol: 3 },
{ col: 2, expected: "한글", startCol: 0, endCol: 3 },
{ col: 3, expected: "한글", startCol: 0, endCol: 3 },
{ col: 5, expected: "문장", startCol: 5, endCol: 8 },
{ col: 6, expected: "문장", startCol: 5, endCol: 8 },
{ col: 7, expected: "문장", startCol: 5, endCol: 8 },
{ col: 8, expected: "문장", startCol: 5, endCol: 8 },
])(
"selects the whole Korean word from column $col, including continuation cells",
async ({ col, expected, startCol, endCol }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const row = bounds.cursorRow;
const selection = resolveTerminalWordSelection({ terminal, coordinate: { row, col } });
expect({ selection, text: extractTerminalSelectedText({ terminal, selection }) }).toEqual({
selection: {
start: { row, col: startCol },
end: { row, col: endCol },
coordinateEpoch: bounds.coordinateEpoch,
},
text: expected,
});
} finally {
terminal.dispose();
}
},
);
it.each([
{ text: "한글 문장", col: 4, expected: "" },
{ text: "(한글)", col: 2, expected: "한글" },
{ text: "漢字 test", col: 1, expected: "漢字" },
{ text: "a한b x", col: 2, expected: "a한b" },
{ text: "🙂🙂 ok", col: 1, expected: "🙂🙂" },
{ text: "e\u0301한 ok", col: 2, expected: "e\u0301한" },
{ text: "한글", col: 3, expected: "한글" },
])("preserves word boundaries in $text at column $col", async ({ text, col, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write(text);
const selection = resolveTerminalWordSelection({
terminal,
coordinate: { row: terminal.getBufferBounds().cursorRow, col },
});
expect(extractTerminalSelectedText({ terminal, selection })).toBe(expected);
} finally {
terminal.dispose();
}
});
it("maps screen coordinates through the current visible window", () => {
expect(
hitTestTerminalSelectionCell({
@@ -1,4 +1,4 @@
import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state";
import type { NativeHeadlessTerminal } from "./headless-terminal-state";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
export interface TerminalBufferCoordinate {
@@ -101,10 +101,6 @@ interface TerminalSelectionState {
const TERMINAL_WORD_SEPARATORS = " ()[]{}',\"`";
function isTerminalWordSeparator(cell: NativeTerminalCell): boolean {
return cell.width !== 0 && TERMINAL_WORD_SEPARATORS.includes(cell.char || " ");
}
function compareCoordinates(
left: TerminalBufferCoordinate,
right: TerminalBufferCoordinate,
@@ -187,7 +183,8 @@ export function resolveTerminalWordSelection(
if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) {
return null;
}
if (isTerminalWordSeparator(cells[input.coordinate.col])) {
const target = cells[input.coordinate.col]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(target)) {
return {
start: input.coordinate,
end: input.coordinate,
@@ -198,11 +195,13 @@ export function resolveTerminalWordSelection(
let startCol = input.coordinate.col;
let endCol = input.coordinate.col;
while (startCol > 0) {
if (isTerminalWordSeparator(cells[startCol - 1])) break;
const previous = cells[startCol - 1]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(previous)) break;
startCol -= 1;
}
while (endCol + 1 < cells.length) {
if (isTerminalWordSeparator(cells[endCol + 1])) break;
const next = cells[endCol + 1]?.char || " ";
if (TERMINAL_WORD_SEPARATORS.includes(next)) break;
endCol += 1;
}
@@ -1,9 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import {
buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector,
deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual,
@@ -338,139 +336,6 @@ describe("workspace agent visibility", () => {
});
});
describe("createWorkspaceAgentVisibilitySelector", () => {
const SERVER_ID = "srv";
class IterationCountingMap<K, V> extends Map<K, V> {
iterations = 0;
override values() {
this.iterations += 1;
return super.values();
}
override entries() {
this.iterations += 1;
return super.entries();
}
override [Symbol.iterator]() {
this.iterations += 1;
return super[Symbol.iterator]();
}
}
function seedSession(agents: Map<string, Agent>): void {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
}
function streamTick(text: string): void {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent-agent", {
head: [
{
kind: "assistant_message",
id: "message-1",
text,
timestamp: new Date("2026-03-04T00:02:00.000Z"),
},
],
});
}
const parent = makeAgent({ id: "parent-agent", cwd: "/repo", workspaceId: WORKSPACE_ID });
const child = makeAgent({
id: "child-agent",
cwd: "/repo",
workspaceId: WORKSPACE_ID,
parentAgentId: parent.id,
});
const otherWorkspace = makeAgent({ id: "other-agent", cwd: "/other", workspaceId: "ws-2" });
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
it("returns the cached result without walking agents when a stream tick leaves them unchanged", () => {
const agents = new IterationCountingMap<string, Agent>([
[parent.id, parent],
[otherWorkspace.id, otherWorkspace],
]);
seedSession(agents);
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
const iterationsAfterFirstSelect = agents.iterations;
for (let tick = 0; tick < 100; tick += 1) {
streamTick(`token ${tick}`);
expect(select(useSessionStore.getState())).toBe(before);
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
expect(before.activeAgentIds).toEqual(new Set([parent.id]));
});
it("recomputes when the agents map changes", () => {
const agents = new Map([[parent.id, parent]]);
seedSession(agents);
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, new Map(agents).set(child.id, child));
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.activeAgentIds).toEqual(new Set([parent.id, child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([parent.id]));
});
it("keeps the previous result when a changed agents map yields the same sets", () => {
const agents = new Map([
[parent.id, parent],
[otherWorkspace.id, otherWorkspace],
]);
seedSession(agents);
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore
.getState()
.setAgents(
SERVER_ID,
new Map(agents).set(otherWorkspace.id, { ...otherWorkspace, title: "Renamed" }),
);
expect(select(useSessionStore.getState())).toBe(before);
});
it("recomputes when agent details reveal a parent in another workspace", () => {
seedSession(new Map([[child.id, child]]));
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
expect(select(useSessionStore.getState()).autoOpenAgentIds).toEqual(new Set<string>());
useSessionStore
.getState()
.setAgentDetails(
SERVER_ID,
new Map([[parent.id, { ...parent, workspaceId: "ws-parent" }]]),
);
const after = select(useSessionStore.getState());
expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
});
});
describe("workspaceAgentVisibilityEqual", () => {
it("returns true for identical sets", () => {
const a = {
@@ -28,15 +28,17 @@ export function deriveWorkspaceAgentVisibility(input: {
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const agentsById = new Map<string, Agent>([
...(agentDetails?.entries() ?? []),
...(sessionAgents?.entries() ?? []),
]);
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
if (!agent.archivedAt) {
activeAgentIds.add(agent.id);
const parentAgent = agent.parentAgentId
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
: undefined;
const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined;
if (isWorkspaceRootAgent(agent, parentAgent)) {
autoOpenAgentIds.add(agent.id);
}
@@ -45,46 +47,6 @@ export function deriveWorkspaceAgentVisibility(input: {
return { activeAgentIds, autoOpenAgentIds };
}
interface AgentMapsSnapshot {
sessions: Record<
string,
{ agents: Map<string, Agent>; agentDetails: Map<string, Agent> } | undefined
>;
}
// Session-store selectors run on every store update, including every stream tick, which
// leaves the agent maps untouched. Reuse the previous result until either map changes, and
// keep it when a recompute yields the same sets so the hook's equality check stays O(1).
export function createWorkspaceAgentVisibilitySelector(input: {
serverId: string;
workspaceId: string | null | undefined;
}): (state: AgentMapsSnapshot) => WorkspaceAgentVisibility {
let previousAgents: Map<string, Agent> | undefined;
let previousAgentDetails: Map<string, Agent> | undefined;
let previousVisibility: WorkspaceAgentVisibility | null = null;
return (state) => {
const session = state.sessions[input.serverId];
const agents = session?.agents;
const agentDetails = session?.agentDetails;
if (previousVisibility && agents === previousAgents && agentDetails === previousAgentDetails) {
return previousVisibility;
}
const visibility = deriveWorkspaceAgentVisibility({
sessionAgents: agents,
agentDetails,
workspaceId: input.workspaceId,
});
previousAgents = agents;
previousAgentDetails = agentDetails;
if (!previousVisibility || !workspaceAgentVisibilityEqual(previousVisibility, visibility)) {
previousVisibility = visibility;
}
return previousVisibility;
};
}
export function buildWorkspaceTabSnapshot(input: {
agentVisibility: WorkspaceAgentVisibility;
agentsHydrated: boolean;
@@ -111,9 +73,8 @@ export function workspaceAgentVisibilityEqual(
b: WorkspaceAgentVisibility,
): boolean {
return (
a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/server": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -1,6 +0,0 @@
// COMPAT(paseoToolCallDetail): added in v0.12.0, remove after 2027-04-19 once plugins have migrated to paseo-tool-presentation/index.
export {
buildPaseoToolDetailSections,
type PaseoToolDetailField,
type PaseoToolDetailSection,
} from "./paseo-tool-presentation/index.js";
@@ -1,8 +1,3 @@
import { execFileSync } from "node:child_process";
import { copyFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { buildPaseoToolDetailSections } from "./index.js";
@@ -152,75 +147,3 @@ describe("Paseo tool-call detail presentation", () => {
expect(buildPaseoToolDetailSections("mcp__github__create_issue", {}, {})).toBeNull();
});
});
describe("published Paseo tool-call detail entry", () => {
it("preserves the 0.11.4 runtime export and types at the old subpath", () => {
const packageRoot = fileURLToPath(new URL("../../", import.meta.url));
const compiler = fileURLToPath(
new URL("../../../../node_modules/typescript/bin/tsc", import.meta.url),
);
const fixture = mkdtempSync(path.join(tmpdir(), "paseo-tool-call-detail-"));
try {
copyFileSync(path.join(packageRoot, "package.json"), path.join(fixture, "package.json"));
symlinkSync(
path.join(packageRoot, "../../node_modules"),
path.join(fixture, "node_modules"),
"junction",
);
execFileSync(
process.execPath,
[
compiler,
"-p",
path.join(packageRoot, "tsconfig.json"),
"--outDir",
path.join(fixture, "dist"),
"--incremental",
"false",
],
{ stdio: "pipe" },
);
writeFileSync(
path.join(fixture, "consumer.mts"),
`
import { strict as assert } from "node:assert";
import {
buildPaseoToolDetailSections,
type PaseoToolDetailField,
type PaseoToolDetailSection,
} from "@getpaseo/protocol/paseo-tool-call-detail";
import { buildPaseoToolDetailSections as current } from "@getpaseo/protocol/paseo-tool-presentation/index";
const field: PaseoToolDetailField = { label: "Agent", value: "agt_123" };
const sections: PaseoToolDetailSection[] = [
{ kind: "prose", title: "Prompt", text: "Hello" },
{ kind: "fields", title: "Details", fields: [field] },
];
const build: (toolName: string, input: unknown, output: unknown) => PaseoToolDetailSection[] | null = buildPaseoToolDetailSections;
assert.equal(build, current);
assert.deepEqual(build("mcp__paseo__send_agent_prompt", { prompt: "Hello", agentId: "agt_123" }, undefined), sections);
`,
);
execFileSync(
process.execPath,
[
compiler,
path.join(fixture, "consumer.mts"),
"--module",
"NodeNext",
"--moduleResolution",
"NodeNext",
"--target",
"ES2020",
"--strict",
"--skipLibCheck",
],
{ stdio: "inherit" },
);
expect(
execFileSync(process.execPath, [path.join(fixture, "consumer.mjs")], { encoding: "utf8" }),
).toBe("");
} finally {
rmSync(fixture, { recursive: true, force: true });
}
}, 30_000);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.12.0",
"@getpaseo/highlight": "0.12.0",
"@getpaseo/plugin": "0.12.0",
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"@getpaseo/client": "0.12.0-beta.3",
"@getpaseo/highlight": "0.12.0-beta.3",
"@getpaseo/plugin": "0.12.0-beta.3",
"@getpaseo/protocol": "0.12.0-beta.3",
"@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,59 +0,0 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
// Linux delivers the existing inventory as one batch. Other backends have
// different startup semantics, so they do not exercise this regression.
test.skipIf(process.platform !== "linux").each(["healthy", "directory-churn"])(
"%s benchmark collects a startup batch larger than the argument limit",
async (mode) => {
const temporaryRoot = await mkdtemp(join(tmpdir(), "paseo-watch-benchmark-test-"));
try {
const { stdout } = await execFileAsync(
process.execPath,
[
// Lower the argument limit instead of creating hundreds of thousands
// of files. This still runs the real observer and benchmark CLI.
"--stack-size=256",
"--import",
"tsx",
fileURLToPath(new URL("./measure-file-observer.ts", import.meta.url)),
"--child",
],
{
timeout: 60_000,
env: {
...process.env,
TMPDIR: temporaryRoot,
PASEO_WATCH_BENCH_MODE: mode,
PASEO_WATCH_BENCH_ROOTS: "1",
PASEO_WATCH_BENCH_DIRS: "10",
PASEO_WATCH_BENCH_PREEXISTING_FILES: "40000",
PASEO_WATCH_BENCH_IGNORED_FILES: "1",
PASEO_WATCH_BENCH_EDITS: "1",
PASEO_WATCH_BENCH_SUSTAINED_EDITS: "1",
PASEO_WATCH_BENCH_SUSTAINED_INTERVAL_MS: "1",
PASEO_WATCH_BENCH_CHURN_DIRECTORIES: "10",
PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY: "4000",
PASEO_WATCH_BENCH_CHURN_ROUNDS: "1",
PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND: "2",
},
},
);
const measurement = JSON.parse(stdout);
expect(measurement.startupEvents).toBe(40_000);
expect(measurement.deliveredEvents).toBeGreaterThan(measurement.startupEvents);
expect(measurement.missedTrackedPaths).toBe(0);
expect(measurement.ignoredEvents).toBe(0);
} finally {
await rm(temporaryRoot, { recursive: true, force: true });
}
},
90_000,
);
@@ -199,8 +199,8 @@ async function measure(run: number): Promise<Measurement> {
root,
(nextError, events) => {
error ??= nextError;
delivered.push(...events);
for (const event of events) {
delivered.push(event);
if (!firstObservedAt.has(event.path))
firstObservedAt.set(event.path, performance.now());
}
@@ -456,7 +456,7 @@ async function measureDirectoryChurn(run: number): Promise<Measurement> {
const setupStarted = performance.now();
subscription = await observer.subscribe(root, (nextError, events) => {
error ??= nextError;
for (const event of events) delivered.push(event);
delivered.push(...events);
});
const setupMs = performance.now() - setupStarted;
@@ -64,8 +64,7 @@ import { createGitMutationService } from "../session/git-mutation/git-mutation-s
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
import { createWorkerTerminalManager } from "../../terminal/worker-terminal-manager.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
@@ -1252,168 +1251,6 @@ describe("browser MCP tools", () => {
describe("terminal MCP tools", () => {
const logger = createTestLogger();
it.each<[string, string, string, boolean]>([
["Down", "\x1b[B", "", false],
["Up", "\x1b[A", "", false],
["Left", "\x1b[D", "", false],
["Right", "\x1b[C", "", false],
["PageUp", "\x1b[5~", "", false],
["PageDown", "\x1b[6~", "", false],
["Home", "\x1b[H", "", false],
["End", "\x1b[F", "", false],
["Insert", "\x1b[2~", "", false],
["Delete", "\x1b[3~", "", false],
["F1", "\x1bOP", "", false],
["F5", "\x1b[15~", "", false],
["F12", "\x1b[24~", "", false],
["S-Tab", "\x1b[Z", "", false],
["C-r", "\x12", "", false],
["M-b", "\x1bb", "", false],
["C-M-Left", "\x1b[1;7D", "", false],
["C-S-Up", "\x1b[1;6A", "", false],
["C-Space", "\x00", "", false],
["Enter", "\r", "", false],
["Tab", "\t", "", false],
["Escape", "\x1b", "", false],
["Space", " ", "", false],
["BSpace", "\x7f", "", false],
["C-c", "\x03", "", false],
["C-d", "\x04", "", false],
["C-z", "\x1a", "", false],
["C-l", "\x0c", "", false],
["C-a", "\x01", "", false],
["C-e", "\x05", "", false],
["Down", "\x1bOB", "\x1b[?1h", false],
["S-Enter", "\x1b[13;2u", "\x1b[=1;1u", false],
["C-Enter", "\x1b[13;28;13;1;8;1_", "\x1b[?9001h", false],
["echo hello\r", "echo hello\r", "", false],
["\x1b[B", "\x1b[B", "", false],
["UnknownKey", "UnknownKey", "", false],
["constructor", "constructor", "", false],
["__proto__", "__proto__", "", false],
["", "", "", false],
["Down", "Down", "\x1b[?1h", true],
["C-r", "C-r", "", true],
["S-Tab", "S-Tab", "", true],
])(
"sends terminal token %j as %j (preamble=%j, literal=%s)",
async (keys, data, preamble, literal) => {
const { agentManager, agentStorage } = createTestDeps();
const send = vi.fn();
const terminalManager = createTerminalManagerStub({
getTerminal: vi.fn().mockReturnValue({
send,
getReplayPreamble: () => preamble,
}),
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createClaudeOnlyManager(),
terminalManager,
logger,
});
try {
const response = await registeredTool(server, "send_terminal_keys").handler({
terminalId: "term-1",
keys,
literal,
});
expect(response.structuredContent).toEqual({ success: true });
expect(send).toHaveBeenCalledExactlyOnceWith({ type: "input", data });
} finally {
await server.close();
}
},
);
it.each([
["in-process", createTerminalManager],
["worker", createWorkerTerminalManager],
] as const)(
"delivers MCP key tokens through %s after terminal input modes change",
async (_name, createManager) => {
const { agentManager, agentStorage } = createTestDeps();
const terminalManager = createManager();
const terminal = await terminalManager.createTerminal({
cwd: process.cwd(),
workspaceId: "ws-terminal-keys",
command: process.execPath,
args: [
"-e",
`process.stdin.setRawMode(true);
process.stdout.write("READY\\r\\n");
process.stdin.on("data", data => {
if (data.toString() === "application") {
process.stdout.write("\\x1b[?1hAPPLICATION_READY\\r\\n");
return;
}
if (data.toString() === "normal") {
process.stdout.write("\\x1b[?1lNORMAL_READY\\r\\n");
return;
}
process.stdout.write("HEX:" + data.toString("hex") + "\\r\\n");
});`,
],
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createClaudeOnlyManager(),
terminalManager,
logger,
});
let output = "";
const unsubscribe = terminal.subscribe((message) => {
if (message.type === "output") {
output += message.data;
}
});
try {
await vi.waitFor(
async () => {
const capture = await terminalManager.captureTerminal(terminal.id);
expect(capture.lines).toContain("READY");
},
{ timeout: 5000 },
);
const tool = registeredTool(server, "send_terminal_keys");
await tool.handler({ terminalId: terminal.id, keys: "application", literal: true });
await vi.waitFor(() => expect(output).toContain("APPLICATION_READY"));
for (const [keys, hex] of [
["Down", "1b4f42"],
["Up", "1b4f41"],
["Left", "1b4f44"],
["Right", "1b4f43"],
["PageDown", "1b5b367e"],
["S-Tab", "1b5b5a"],
["C-r", "12"],
["M-b", "1b62"],
]) {
await tool.handler({ terminalId: terminal.id, keys });
await vi.waitFor(async () => {
const capture = await terminalManager.captureTerminal(terminal.id);
expect(capture.lines.join("\n")).toContain(`HEX:${hex}`);
});
}
await tool.handler({ terminalId: terminal.id, keys: "normal", literal: true });
await vi.waitFor(() => expect(output).toContain("NORMAL_READY"));
await tool.handler({ terminalId: terminal.id, keys: "Down" });
await vi.waitFor(async () => {
const capture = await terminalManager.captureTerminal(terminal.id);
expect(capture.lines).toContain("HEX:1b5b42");
});
} finally {
unsubscribe();
await server.close();
await terminalManager.killTerminalAndWait(terminal.id);
terminalManager.killAll();
}
},
);
it("captures terminal output through the terminal manager authority", async () => {
const { agentManager, agentStorage } = createTestDeps();
const captureTerminal = vi.fn().mockResolvedValue({
@@ -39,8 +39,6 @@ import type { FirstAgentContext } from "../../messages.js";
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js";
import {
@@ -596,44 +594,37 @@ const TerminalSummarySchema = z.object({
cwd: z.string(),
});
interface TerminalKeyTokenInput {
key: string;
literal: boolean;
preamble: string;
}
function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string {
function resolveTerminalKeyToken(key: string, literal: boolean): string {
if (literal) {
return key;
}
const match = /^((?:[CMS]-)*)(.+)$/.exec(key);
if (!match) {
return key;
switch (key) {
case "Enter":
return "\r";
case "Tab":
return "\t";
case "Escape":
return "\u001b";
case "Space":
return " ";
case "BSpace":
return "\u007f";
case "C-c":
return "\u0003";
case "C-d":
return "\u0004";
case "C-z":
return "\u001a";
case "C-l":
return "\u000c";
case "C-a":
return "\u0001";
case "C-e":
return "\u0005";
default:
return key;
}
const [, modifiers, token] = match;
const aliases: Record<string, string> = {
Up: "ArrowUp",
Down: "ArrowDown",
Left: "ArrowLeft",
Right: "ArrowRight",
BSpace: "Backspace",
Space: " ",
};
const inputMode = new TerminalInputModeTracker();
inputMode.feed(preamble);
const encodedKey = Object.hasOwn(aliases, token) ? aliases[token] : token;
return (
encodeTerminalKeyInput(
{
key: encodedKey,
ctrl: modifiers.includes("C-"),
alt: modifiers.includes("M-"),
shift: modifiers.includes("S-"),
},
{ inputMode: inputMode.getState() },
) || key
);
}
export function createPaseoToolCatalog(options: PaseoToolHostDependencies): PaseoToolCatalog {
@@ -2600,8 +2591,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
"send_terminal_keys",
{
title: "Send terminal keys",
description:
"Send text or a key token: Enter, Tab, Escape, Space, BSpace, Up, Down, Left, Right, Home, End, Insert, Delete, PageUp, PageDown, or F1-F12. Combine C- (Ctrl), M- (Alt), and S- (Shift) prefixes, e.g. C-r, S-Tab, or C-M-Left. Set literal=true to send the text unchanged.",
description: "Send literal text or special key tokens to a terminal session.",
inputSchema: {
terminalId: z.string(),
keys: z.string(),
@@ -2623,11 +2613,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
terminal.send({
type: "input",
data: resolveTerminalKeyToken({
key: keys,
literal,
preamble: terminal.getReplayPreamble(),
}),
data: resolveTerminalKeyToken(keys, literal),
});
return {
@@ -1,72 +1,5 @@
import { expect, test } from "vitest";
import { UsageSourceRegistry } from "./index.js";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
discoverSession as discoverCursorSession,
fetchUsage as fetchCursorUsage,
} from "../../../../../../plugins/cursor-usage-source/server/usage.js";
import { inputSchema as cursorInputSchema } from "../../../../../../plugins/cursor-usage-source/shared/input.js";
test("#7142: host usage retains distinct Cursor accounts after their individual details are read", async () => {
const root = await mkdtemp(join(tmpdir(), "cursor-accounts-"));
try {
const sessions = new Map<
string,
{ provider: string; sessionKey: string; env: Record<string, string> }
>();
const spends = new Map<string, number>();
for (const [agentId, account, spend] of [
["a", "user-a", 100],
["b", "user-b", 200],
["a-copy", "user-a", 100],
] as const) {
const configHome = join(root, agentId);
const token = `e30.${Buffer.from(JSON.stringify({ sub: account, nonce: agentId })).toString("base64url")}.signature`;
await mkdir(join(configHome, "cursor"), { recursive: true });
await writeFile(
join(configHome, "cursor", "auth.json"),
JSON.stringify({ accessToken: token }),
);
spends.set(`Bearer ${token}`, spend);
sessions.set(agentId, {
provider: "cursor",
sessionKey: agentId,
env: { XDG_CONFIG_HOME: configHome },
});
}
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => sessions.has(id),
usageSession: (id) => sessions.get(id) ?? null,
});
const fetchApi: typeof fetch = async (_url, init) => {
const authorization = new Headers(init?.headers).get("Authorization") ?? "";
const spend = spends.get(authorization);
if (spend === undefined) throw new Error("Unexpected fixture login");
return new Response(JSON.stringify({ planUsage: { totalSpend: spend, limit: 1000 } }));
};
registry.register({
id: "cursor",
label: "Cursor",
discover: (scope) =>
scope.kind === "session" ? discoverCursorSession(scope, "linux") : Promise.resolve([]),
fetch: (input) => fetchCursorUsage(cursorInputSchema.parse(input), fetchApi),
});
const spent = (reports: Awaited<ReturnType<typeof registry.listReports>>) =>
reports.map((entry) => {
if (entry.report.status !== "available")
throw new Error("Expected available fixture usage");
return entry.report.balances?.[0]?.used;
});
expect(spent(await registry.listReports({ agentId: "a" }))).toEqual([1]);
expect(spent(await registry.listReports({ agentId: "b" }))).toEqual([2]);
expect(spent(await registry.listReports({ agentId: "a-copy" }))).toEqual([1]);
expect(spent(await registry.listReports())).toEqual([1, 2]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
function source(input: {
id: string;
@@ -125,7 +125,6 @@ function watchTerminal(session: TerminalSession): void {
type: "terminalMessage",
terminalId: session.id,
message: { type: "output", data: payload.toString("utf8"), revision },
replayPreamble: session.getReplayPreamble(),
});
},
});
@@ -145,7 +144,6 @@ function watchTerminal(session: TerminalSession): void {
type: "terminalMessage",
terminalId: session.id,
message,
replayPreamble: session.getReplayPreamble(),
});
},
// Creation already sends an authoritative state in terminalCreated. A second
@@ -121,7 +121,6 @@ export type TerminalWorkerEvent =
type: "terminalMessage";
terminalId: string;
message: ServerMessage;
replayPreamble: string;
}
| {
type: "terminalExit";
@@ -67,7 +67,8 @@ interface WorkerTerminalRecord {
state: TerminalState;
activity: TerminalActivity | null;
// Cached input-mode preamble from the worker (the authoritative tracker lives
// in the worker process). Updated with streamed output and snapshot responses.
// in the worker process). Refreshed on every getTerminalState response and on
// the snapshotReady event that precedes a live-restore replay.
replayPreamble: string;
exitInfo: TerminalExitInfo | null;
messageListeners: Set<(msg: ServerMessage) => void>;
@@ -355,6 +356,11 @@ export function createWorkerTerminalManager(
};
},
getReplayPreamble(): string {
// Refreshed from every getTerminalState response, which the controller fetches
// before every snapshot replay (legacy + visible-snapshot restore). The one
// gap is restore.mode === "live", which replays without fetching state — there
// this can be stale/empty. No client sends "live" today; revisit (ship the
// preamble on the worker's snapshotReady) if one ever does.
return record.replayPreamble;
},
getTitle(): string | undefined {
@@ -423,7 +429,9 @@ export function createWorkerTerminalManager(
if (message.message.type === "snapshot") {
record.state = message.message.state;
}
record.replayPreamble = message.replayPreamble;
if (message.message.type === "snapshotReady" && message.message.replayPreamble !== undefined) {
record.replayPreamble = message.message.replayPreamble;
}
for (const listener of Array.from(record.messageListeners)) {
listener(message.message);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {
File diff suppressed because one or more lines are too long
@@ -273,50 +273,13 @@ describe("cursor usage source", () => {
writeFileSync(path, JSON.stringify({ accessToken: "session-token" }));
}
it("keeps different accounts separate while joining global and session logins for the same account", async () => {
function token(sub: string, nonce: string) {
return `e30.${Buffer.from(JSON.stringify({ sub, nonce })).toString("base64url")}.signature`;
}
writeCursorAuthJson(homeDir, token("user-a", "desktop"));
writeCursorAuthJson(sessionHome, token("user-b", "session"));
const globalAccounts = await discover();
const differentAccount = await discoverSession(cursorSession({}), "linux");
expect(globalAccounts).toHaveLength(1);
expect(differentAccount).toHaveLength(1);
expect(differentAccount[0]!.key).not.toBe(globalAccounts[0]!.key);
writeCursorAuthJson(sessionHome, token("user-a", "refreshed-session"));
const sameAccount = await discoverSession(cursorSession({}), "linux");
expect(sameAccount).toHaveLength(1);
expect(sameAccount[0]!.key).toBe(globalAccounts[0]!.key);
});
it("isolates logins without a readable subject by store and keeps their key across token rotation", async () => {
writeCursorAuthJson(homeDir, "opaque-global-token");
writeCursorAuthJson(sessionHome, "opaque-session-token");
const globalAccounts = await discover();
const sessionAccounts = await discoverSession(cursorSession({}), "linux");
expect(sessionAccounts).toHaveLength(1);
expect(globalAccounts).toHaveLength(1);
expect(sessionAccounts[0]!.key).not.toBe(globalAccounts[0]!.key);
writeCursorAuthJson(sessionHome, "e30.invalid-json.signature");
expect(await discoverSession(cursorSession({}), "linux")).toEqual(sessionAccounts);
writeCursorAuthJson(sessionHome, "e30.e30.signature");
expect(await discoverSession(cursorSession({}), "linux")).toEqual(sessionAccounts);
});
it("reads the login cursor-agent uses under the session's XDG_CONFIG_HOME", async () => {
const configHome = join(sessionHome, "xdg");
const authPath = join(configHome, "cursor", "auth.json");
writeAuthJson(authPath);
expect(
await discoverSession(cursorSession({ XDG_CONFIG_HOME: configHome }), "linux"),
).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: authPath },
},
]);
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
});
it("reads the login under the session's HOME, not the daemon's", async () => {
@@ -324,7 +287,7 @@ describe("cursor usage source", () => {
writeCursorAuthJson(sessionHome, "session-token");
expect(await discoverSession(cursorSession({}), "linux")).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
key: "default",
input: { store: "file", locator: join(sessionHome, ".config", "cursor", "auth.json") },
},
]);
@@ -335,10 +298,7 @@ describe("cursor usage source", () => {
const authPath = join(appData, "Cursor", "auth.json");
writeAuthJson(authPath);
expect(await discoverSession(cursorSession({ APPDATA: appData }), "win32")).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: authPath },
},
{ key: "default", input: { store: "file", locator: authPath } },
]);
});
@@ -348,12 +308,7 @@ describe("cursor usage source", () => {
expect(await discoverSession(cursorSession({}), "darwin")).toEqual([]);
expect(
await discoverSession(cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "file" }), "darwin"),
).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "file", locator: authPath },
},
]);
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
});
it.each([
@@ -386,10 +341,7 @@ it("discovery returns a locator when fetch finds cursor credentials", async () =
});
expect(requested).toBe(true);
expect(await discover()).toEqual([
{
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" },
},
{ key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } },
]);
} finally {
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
+4 -28
View File
@@ -1,5 +1,4 @@
import type { UsageInput } from "../shared/input.js";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
@@ -212,26 +211,6 @@ async function readToken(input: UsageInput): Promise<string | undefined> {
return token ?? undefined;
}
const CursorIdentitySchema = z.object({ sub: z.string().min(1) });
function cursorAccount(token: string, input: UsageInput): UsageAccount {
const parts = token.split(".");
if (parts.length === 3) {
try {
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
const identity = CursorIdentitySchema.safeParse(payload);
if (identity.success) {
const key = createHash("sha256").update(identity.data.sub).digest("hex");
return { key: `account-${key}`, input };
}
} catch {
// Non-JWT logins still have a usage card, isolated to their credential store.
}
}
const key = createHash("sha256").update(JSON.stringify(input)).digest("hex");
return { key: `store-${key}`, input };
}
export async function discover(): Promise<UsageAccount[]> {
const home = homedir();
const candidates: UsageInput[] = ["CURSOR_ACCESS_TOKEN", "CURSOR_TOKEN"].map((locator) => ({
@@ -258,14 +237,12 @@ export async function discover(): Promise<UsageAccount[]> {
].map((locator) => ({ store: "sqlite" as const, locator })),
{ store: "file", locator: join(home, ".config", "cursor", "auth.json") },
);
for (const input of candidates) {
const token = await readToken(input);
if (token) return [cursorAccount(token, input)];
}
for (const input of candidates) if (await readToken(input)) return [{ key: "default", input }];
return [];
}
// Account identity joins global and session logins without merging different users.
// Resolves the login a cursor-agent session runs on. The key matches global discovery so
// the session's login joins the host's Cursor card instead of adding a second one.
export async function discoverSession(
scope: Extract<UsageScope, { kind: "session" }>,
platform: NodeJS.Platform = process.platform,
@@ -274,8 +251,7 @@ export async function discoverSession(
const locator = cursorAgentAuthPath(scope.env, platform);
if (!locator) return [];
const input: UsageInput = { store: "file", locator };
const token = await readToken(input);
return token ? [cursorAccount(token, input)] : [];
return (await readToken(input)) ? [{ key: "default", input }] : [];
}
// Mirrors cursor-agent's credential store. Env credentials win over the stored login and
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.12.0",
"version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {
-1
View File
@@ -379,7 +379,6 @@ test("quality preserves commands and routes each domain independently after fail
"npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity",
"npm audit signatures",
"npm run lint",
"node --test scripts/oxlint/paseo-plugin.test.mjs",
],
relay: ["npm run build:relay", "npm run test --workspace=@getpaseo/relay"],
sdk: [
-42
View File
@@ -1,42 +0,0 @@
// Repo-specific oxlint rules. Each rule is its own name, so its burn-down list in .oxlintrc.json
// is one override that cannot interact with the `no-restricted-imports` burn-downs.
const PRESS_PRIMITIVE_SOURCES = new Set(["react-native", "react-native-gesture-handler"]);
const PRESS_PRIMITIVE_NAMES = new Set([
"Pressable",
"TouchableHighlight",
"TouchableNativeFeedback",
"TouchableOpacity",
"TouchableWithoutFeedback",
]);
export const NO_RAW_PRESSABLE_MESSAGE =
"A raw pressable has no standard press or hover feedback. Icon actions use ToolbarButton " +
"(@/components/ui/pane-content-toolbar); rows on a menu surface use MenuItem or " +
"ComposerTrackRow; other rows use PressHighlight (@/components/ui/press-highlight); labelled " +
"buttons use Button. See docs/design.md §2.";
const noRawPressable = {
create(context) {
return {
ImportDeclaration(node) {
if (!PRESS_PRIMITIVE_SOURCES.has(node.source.value)) return;
for (const specifier of node.specifiers) {
if (
specifier.type === "ImportSpecifier" &&
PRESS_PRIMITIVE_NAMES.has(specifier.imported.name)
) {
context.report({ node: specifier, message: NO_RAW_PRESSABLE_MESSAGE });
}
}
},
};
},
};
export default {
meta: { name: "paseo" },
rules: {
"no-raw-pressable": noRawPressable,
},
};
-54
View File
@@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const oxlintBin = path.join(repoRoot, "node_modules/.bin/oxlint");
const pluginPath = path.join(repoRoot, "scripts/oxlint/paseo-plugin.mjs");
function lint(source) {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-oxlint-"));
try {
writeFileSync(
path.join(dir, ".oxlintrc.json"),
JSON.stringify({ jsPlugins: [pluginPath], rules: { "paseo/no-raw-pressable": "error" } }),
);
writeFileSync(path.join(dir, "probe.tsx"), source);
const result = spawnSync(oxlintBin, ["-c", ".oxlintrc.json", "probe.tsx"], {
cwd: dir,
encoding: "utf8",
});
return { failed: result.status !== 0, output: result.stdout + result.stderr };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("a raw Pressable from react-native is reported", () => {
const result = lint(
'import { Pressable, View } from "react-native";\nexport { Pressable, View };\n',
);
assert.equal(result.failed, true);
assert.match(result.output, /no-raw-pressable/);
});
test("an aliased gesture-handler touchable is reported", () => {
const result = lint(
'import { TouchableOpacity as Tap } from "react-native-gesture-handler";\nexport { Tap };\n',
);
assert.equal(result.failed, true);
assert.match(result.output, /no-raw-pressable/);
});
test("press primitives and pressable types are allowed", () => {
const result = lint(
'import { View, type PressableProps } from "react-native";\n' +
'import { ToolbarButton } from "@/components/ui/pane-content-toolbar";\n' +
"export type Props = PressableProps;\nexport { View, ToolbarButton };\n",
);
assert.equal(result.failed, false, result.output);
});