mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafe4b01c8 | ||
|
|
8dd3e2e34e | ||
|
|
b8bcd204a9 | ||
|
|
d61dcaf1df | ||
|
|
644d66ba4f | ||
|
|
23e388b054 | ||
|
|
66348a5e5c | ||
|
|
b50acc9344 | ||
|
|
727d5eed59 | ||
|
|
a0790e7527 | ||
|
|
be0378ce63 | ||
|
|
f0acc2c72b | ||
|
|
559c0200e0 |
@@ -93,6 +93,10 @@ 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
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
@@ -356,6 +357,117 @@
|
||||
"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": {
|
||||
|
||||
+16
-4
@@ -1,8 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.3 - 2026-10-18
|
||||
## 0.12.0 - 2026-10-19
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
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
|
||||
|
||||
@@ -29,9 +29,10 @@ 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))
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -46,6 +47,7 @@ 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
|
||||
|
||||
@@ -59,6 +61,8 @@ 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))
|
||||
@@ -68,6 +72,7 @@ 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))
|
||||
@@ -99,9 +104,13 @@ 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 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 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 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))
|
||||
@@ -158,7 +167,10 @@ 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
|
||||
|
||||
|
||||
+2
-1
@@ -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 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 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.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -249,6 +249,7 @@ 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.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
@@ -0,0 +1,8 @@
|
||||
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
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36349,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37469,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/server": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/server": "0.12.0",
|
||||
"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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/relay": "0.12.0-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@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-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-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",
|
||||
"@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",
|
||||
"@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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-beta.3",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
@@ -17,7 +18,6 @@ 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,19 +205,21 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
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");
|
||||
// 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`);
|
||||
}
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaem
|
||||
gate.releaseHeldServerMessage("status");
|
||||
}
|
||||
|
||||
test("an offline workspace draft clears its connection error when the host reconnects", async ({
|
||||
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -28,6 +28,5 @@ test("an offline workspace draft clears its connection error when the host recon
|
||||
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,14 +266,22 @@ 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();
|
||||
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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,3 +501,59 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -208,6 +208,56 @@ 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;
|
||||
@@ -718,3 +768,10 @@ it("replaces the native placeholder only after the resolved URL decodes", () =>
|
||||
const prepared = prepareMarkdownSource("", undefined, { supportsSvg: false });
|
||||
expect(prepared.render(new Map([["shot.png", url]]))).toBe(``);
|
||||
});
|
||||
|
||||
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,7 +27,6 @@ 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;
|
||||
@@ -99,7 +98,6 @@ 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,10 +2,13 @@ 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 } {
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
|
||||
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 };
|
||||
@@ -23,6 +26,7 @@ export function createMarkdownStyles(
|
||||
fontWeight: "bold",
|
||||
});
|
||||
return {
|
||||
containerStyle: isWeb ? WEB_CONTAINER : undefined,
|
||||
selectionColor: theme.colors.terminal.selectionBackground,
|
||||
markdownStyle: {
|
||||
paragraph,
|
||||
|
||||
@@ -868,7 +868,9 @@ function NativeTerminalEmulator({
|
||||
panState.movedBeyondTapTolerance = true;
|
||||
}
|
||||
if (selectionPress?.status === "selecting") {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
if (panState.movedBeyondTapTolerance) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -915,7 +917,10 @@ function NativeTerminalEmulator({
|
||||
onPanResponderRelease: (event, gesture) => {
|
||||
clearSelectionLongPressTimeout();
|
||||
const selectionPress = selectionPressRef.current;
|
||||
if (selectionPress?.status === "selecting") {
|
||||
if (
|
||||
selectionPress?.status === "selecting" &&
|
||||
panStateRef.current.movedBeyondTapTolerance
|
||||
) {
|
||||
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
|
||||
}
|
||||
selectionPressRef.current = null;
|
||||
|
||||
@@ -1295,7 +1295,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// Read pointer capability when restoring, without making a pointer change itself
|
||||
// a focus trigger.
|
||||
useLayoutEffect(() => {
|
||||
if (isPaneFocused && focusIntentRef.current && hasFinePointer()) {
|
||||
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) {
|
||||
textInputRef.current?.focus();
|
||||
}
|
||||
}, [isPaneFocused]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
getSendingClientMessageIds,
|
||||
hasActiveMessageSubmission,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
} from "./model";
|
||||
@@ -53,6 +54,21 @@ 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" }),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -312,6 +313,22 @@ 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",
|
||||
@@ -332,29 +349,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
row: trackRow,
|
||||
rowActive: { ...trackRow, 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,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
@@ -1148,14 +1148,17 @@ function WorkspaceScreenContent({
|
||||
),
|
||||
});
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
const selectWorkspaceAgentVisibility = useMemo(
|
||||
() =>
|
||||
createWorkspaceAgentVisibilitySelector({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
}),
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
selectWorkspaceAgentVisibility,
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
||||
import {
|
||||
createSubagentsForParentSelector,
|
||||
selectProviderSubagentsForParent,
|
||||
selectSubagentsForParent,
|
||||
} from "./select";
|
||||
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
|
||||
import { useProviderSubagentStore } from "./provider-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -435,3 +440,160 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,31 @@ 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,
|
||||
@@ -133,12 +158,13 @@ export function selectProviderSubagentsForParent(
|
||||
}
|
||||
|
||||
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
|
||||
const paseoRows = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
|
||||
equal,
|
||||
const { serverId, parentAgentId } = params;
|
||||
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
|
||||
const selectPaseoRows = useMemo(
|
||||
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
|
||||
[serverId, parentAgentId, pendingArchiveIds],
|
||||
);
|
||||
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
|
||||
const supported = useSessionStore(
|
||||
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import {
|
||||
@@ -49,7 +50,6 @@ 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,6 +234,7 @@ function SubagentsTrackRow({
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
compact={isCompact}
|
||||
visible={actionsAlwaysVisible || active}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
@@ -246,6 +247,7 @@ function SubagentsTrackRow({
|
||||
displayLabel,
|
||||
handleArchivePress,
|
||||
handleDetachPress,
|
||||
isCompact,
|
||||
onDetachSubagent,
|
||||
presentation,
|
||||
row.kind,
|
||||
@@ -284,17 +286,20 @@ 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}
|
||||
@@ -302,69 +307,59 @@ function SubagentRowActions({
|
||||
>
|
||||
{onDetachPress ? (
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
label={t("subagents.detachAction", { label: displayLabel })}
|
||||
tooltipLabel={t("subagents.detachTooltip")}
|
||||
icon="detach"
|
||||
visible={visible}
|
||||
compact={compact}
|
||||
onPress={onDetachPress}
|
||||
/>
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
>
|
||||
<ThemedUnlink size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
</SubagentActionButton>
|
||||
) : null}
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
label={t("subagents.archiveAction", { label: displayLabel })}
|
||||
tooltipLabel={t("subagents.archiveTooltip")}
|
||||
icon="archive"
|
||||
visible={visible}
|
||||
compact={compact}
|
||||
onPress={onArchivePress}
|
||||
/>
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
>
|
||||
<ThemedArchive size={iconSize} uniProps={mutedIconColorMapping} />
|
||||
</SubagentActionButton>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
/** The standard icon action, with a tooltip that names the action without repeating the row. */
|
||||
function SubagentActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
label,
|
||||
tooltipLabel,
|
||||
icon,
|
||||
visible,
|
||||
compact,
|
||||
onPress,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
label: string;
|
||||
tooltipLabel: string;
|
||||
icon: SubagentActionIcon;
|
||||
visible: boolean;
|
||||
compact: boolean;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const tooltip = useMemo(
|
||||
() => <Text style={styles.tooltipText}>{tooltipLabel}</Text>,
|
||||
[tooltipLabel],
|
||||
);
|
||||
return (
|
||||
<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>
|
||||
<ToolbarButton
|
||||
label={label}
|
||||
tooltip={tooltip}
|
||||
tooltipSide="top"
|
||||
compact={compact}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
>
|
||||
{children}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -415,11 +410,6 @@ 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,6 +151,61 @@ 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 } from "./headless-terminal-state";
|
||||
import type { NativeHeadlessTerminal, NativeTerminalCell } from "./headless-terminal-state";
|
||||
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
|
||||
|
||||
export interface TerminalBufferCoordinate {
|
||||
@@ -101,6 +101,10 @@ 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,
|
||||
@@ -183,8 +187,7 @@ export function resolveTerminalWordSelection(
|
||||
if (!cells || input.coordinate.col < 0 || input.coordinate.col >= cells.length) {
|
||||
return null;
|
||||
}
|
||||
const target = cells[input.coordinate.col]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(target)) {
|
||||
if (isTerminalWordSeparator(cells[input.coordinate.col])) {
|
||||
return {
|
||||
start: input.coordinate,
|
||||
end: input.coordinate,
|
||||
@@ -195,13 +198,11 @@ export function resolveTerminalWordSelection(
|
||||
let startCol = input.coordinate.col;
|
||||
let endCol = input.coordinate.col;
|
||||
while (startCol > 0) {
|
||||
const previous = cells[startCol - 1]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(previous)) break;
|
||||
if (isTerminalWordSeparator(cells[startCol - 1])) break;
|
||||
startCol -= 1;
|
||||
}
|
||||
while (endCol + 1 < cells.length) {
|
||||
const next = cells[endCol + 1]?.char || " ";
|
||||
if (TERMINAL_WORD_SEPARATORS.includes(next)) break;
|
||||
if (isTerminalWordSeparator(cells[endCol + 1])) break;
|
||||
endCol += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
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 {
|
||||
buildWorkspaceTabSnapshot,
|
||||
createWorkspaceAgentVisibilitySelector,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -336,6 +338,139 @@ 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,17 +28,15 @@ 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 ? agentsById.get(agent.parentAgentId) : undefined;
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
if (isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
autoOpenAgentIds.add(agent.id);
|
||||
}
|
||||
@@ -47,6 +45,46 @@ 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;
|
||||
@@ -73,8 +111,9 @@ export function workspaceAgentVisibilityEqual(
|
||||
b: WorkspaceAgentVisibility,
|
||||
): boolean {
|
||||
return (
|
||||
setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds)
|
||||
a === b ||
|
||||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
|
||||
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/server": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/server": "0.12.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/relay": "0.12.0-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@getpaseo/relay": "0.12.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"@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-beta.3",
|
||||
"@getpaseo/protocol": "0.12.0-beta.3",
|
||||
"@getpaseo/client": "0.12.0",
|
||||
"@getpaseo/protocol": "0.12.0",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// 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,3 +1,8 @@
|
||||
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";
|
||||
@@ -147,3 +152,75 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"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-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",
|
||||
"@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",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
delivered.push(...events);
|
||||
for (const event of events) delivered.push(event);
|
||||
});
|
||||
const setupMs = performance.now() - setupStarted;
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ 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 type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { createWorkerTerminalManager } from "../../terminal/worker-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";
|
||||
@@ -1251,6 +1252,168 @@ 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,6 +39,8 @@ 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 {
|
||||
@@ -594,37 +596,44 @@ const TerminalSummarySchema = z.object({
|
||||
cwd: z.string(),
|
||||
});
|
||||
|
||||
function resolveTerminalKeyToken(key: string, literal: boolean): string {
|
||||
interface TerminalKeyTokenInput {
|
||||
key: string;
|
||||
literal: boolean;
|
||||
preamble: string;
|
||||
}
|
||||
|
||||
function resolveTerminalKeyToken({ key, literal, preamble }: TerminalKeyTokenInput): string {
|
||||
if (literal) {
|
||||
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 match = /^((?:[CMS]-)*)(.+)$/.exec(key);
|
||||
if (!match) {
|
||||
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 {
|
||||
@@ -2591,7 +2600,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
"send_terminal_keys",
|
||||
{
|
||||
title: "Send terminal keys",
|
||||
description: "Send literal text or special key tokens to a terminal session.",
|
||||
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.",
|
||||
inputSchema: {
|
||||
terminalId: z.string(),
|
||||
keys: z.string(),
|
||||
@@ -2613,7 +2623,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
|
||||
terminal.send({
|
||||
type: "input",
|
||||
data: resolveTerminalKeyToken(keys, literal),
|
||||
data: resolveTerminalKeyToken({
|
||||
key: keys,
|
||||
literal,
|
||||
preamble: terminal.getReplayPreamble(),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,72 @@
|
||||
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,6 +125,7 @@ function watchTerminal(session: TerminalSession): void {
|
||||
type: "terminalMessage",
|
||||
terminalId: session.id,
|
||||
message: { type: "output", data: payload.toString("utf8"), revision },
|
||||
replayPreamble: session.getReplayPreamble(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -144,6 +145,7 @@ 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,6 +121,7 @@ export type TerminalWorkerEvent =
|
||||
type: "terminalMessage";
|
||||
terminalId: string;
|
||||
message: ServerMessage;
|
||||
replayPreamble: string;
|
||||
}
|
||||
| {
|
||||
type: "terminalExit";
|
||||
|
||||
@@ -67,8 +67,7 @@ interface WorkerTerminalRecord {
|
||||
state: TerminalState;
|
||||
activity: TerminalActivity | null;
|
||||
// Cached input-mode preamble from the worker (the authoritative tracker lives
|
||||
// in the worker process). Refreshed on every getTerminalState response and on
|
||||
// the snapshotReady event that precedes a live-restore replay.
|
||||
// in the worker process). Updated with streamed output and snapshot responses.
|
||||
replayPreamble: string;
|
||||
exitInfo: TerminalExitInfo | null;
|
||||
messageListeners: Set<(msg: ServerMessage) => void>;
|
||||
@@ -356,11 +355,6 @@ 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 {
|
||||
@@ -429,9 +423,7 @@ export function createWorkerTerminalManager(
|
||||
if (message.message.type === "snapshot") {
|
||||
record.state = message.message.state;
|
||||
}
|
||||
if (message.message.type === "snapshotReady" && message.message.replayPreamble !== undefined) {
|
||||
record.replayPreamble = message.message.replayPreamble;
|
||||
}
|
||||
record.replayPreamble = message.replayPreamble;
|
||||
for (const listener of Array.from(record.messageListeners)) {
|
||||
listener(message.message);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -273,13 +273,50 @@ 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: "default", input: { store: "file", locator: authPath } }]);
|
||||
).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "file", locator: authPath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the login under the session's HOME, not the daemon's", async () => {
|
||||
@@ -287,7 +324,7 @@ describe("cursor usage source", () => {
|
||||
writeCursorAuthJson(sessionHome, "session-token");
|
||||
expect(await discoverSession(cursorSession({}), "linux")).toEqual([
|
||||
{
|
||||
key: "default",
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "file", locator: join(sessionHome, ".config", "cursor", "auth.json") },
|
||||
},
|
||||
]);
|
||||
@@ -298,7 +335,10 @@ describe("cursor usage source", () => {
|
||||
const authPath = join(appData, "Cursor", "auth.json");
|
||||
writeAuthJson(authPath);
|
||||
expect(await discoverSession(cursorSession({ APPDATA: appData }), "win32")).toEqual([
|
||||
{ key: "default", input: { store: "file", locator: authPath } },
|
||||
{
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "file", locator: authPath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -308,7 +348,12 @@ describe("cursor usage source", () => {
|
||||
expect(await discoverSession(cursorSession({}), "darwin")).toEqual([]);
|
||||
expect(
|
||||
await discoverSession(cursorSession({ AGENT_CLI_CREDENTIAL_STORE: "file" }), "darwin"),
|
||||
).toEqual([{ key: "default", input: { store: "file", locator: authPath } }]);
|
||||
).toEqual([
|
||||
{
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "file", locator: authPath },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -341,7 +386,10 @@ it("discovery returns a locator when fetch finds cursor credentials", async () =
|
||||
});
|
||||
expect(requested).toBe(true);
|
||||
expect(await discover()).toEqual([
|
||||
{ key: "default", input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" } },
|
||||
{
|
||||
key: expect.stringMatching(/^store-[a-f0-9]{64}$/),
|
||||
input: { store: "env", locator: "CURSOR_ACCESS_TOKEN" },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["CURSOR_ACCESS_TOKEN"];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -211,6 +212,26 @@ 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) => ({
|
||||
@@ -237,12 +258,14 @@ 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) if (await readToken(input)) return [{ key: "default", input }];
|
||||
for (const input of candidates) {
|
||||
const token = await readToken(input);
|
||||
if (token) return [cursorAccount(token, input)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Account identity joins global and session logins without merging different users.
|
||||
export async function discoverSession(
|
||||
scope: Extract<UsageScope, { kind: "session" }>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -251,7 +274,8 @@ export async function discoverSession(
|
||||
const locator = cursorAgentAuthPath(scope.env, platform);
|
||||
if (!locator) return [];
|
||||
const input: UsageInput = { store: "file", locator };
|
||||
return (await readToken(input)) ? [{ key: "default", input }] : [];
|
||||
const token = await readToken(input);
|
||||
return token ? [cursorAccount(token, input)] : [];
|
||||
}
|
||||
|
||||
// Mirrors cursor-agent's credential store. Env credentials win over the stored login and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.3",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -379,6 +379,7 @@ 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: [
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user