Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b29bd98b5f feat(plugins): accept inline SVG and component icons
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 07:51:46 +02:00
Mohamed Boudra d61dcaf1df fix(app): give subagent rows room on compact and standard press feedback (#7364)
The subagent track rows were as dense on a phone as in the desktop popover,
and the archive and detach actions were hand-rolled pressables that only
recoloured the glyph, so a tap or hover never showed the rounded highlight
every other icon action has.

Rows now take the compact menu-row height and padding below the `md`
breakpoint, decided once in the track row style; the desktop popover keeps
its density. The actions are `ToolbarButton`, which owns the hitbox, the
rounded highlight on hover and press, the compact size, and the tooltip.

A repo oxlint rule, `paseo/no-raw-pressable`, reports `Pressable` and
`Touchable*` imports outside `components/ui/` so the next tap target reaches
for a primitive. Existing importers are on a burn-down list they can only
leave. The rule is its own name rather than another `no-restricted-imports`
entry so its override cannot interact with the existing burn-downs. Its test
runs the real oxlint binary and is wired into the CI quality job.
2026-10-19 12:24:14 +07:00
paseo-bot[bot] 644d66ba4f Select complete Korean terminal words on long press (#7353)
* fix(terminal): select complete Korean words on long press

Wide-character continuation cells carry a blank character but occupy no extra glyph. Treat only actual cells as word separators so either half selects the complete word while real spaces still delimit it.

* fix(terminal): preserve selected words until a drag begins

An unmoved touch release contracted the word selected by the long-press timer. Only extend selection after crossing the existing drag threshold. The Android regression checks the copied whole word after the finger lifts.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 07:03:24 +02:00
Mohamed Boudra 23e388b054 test(app): stabilize desktop overview tool-call expansion (#7359)
Hold the stream through the click so autoscroll cannot move the group between pointer-down and pointer-up. Resume after expansion and verify that inline calls keep updating.
2026-10-19 10:44:35 +07:00
Mohamed Boudra 66348a5e5c fix(app): exclude sentence punctuation from bare Markdown links (#7361)
The shared MD4C permissive autolink scanner consumed trailing path periods and rejected colon boundaries. Fix recognition in the dependency patch and rebuild its web parser so native and web agree. Preserve balanced path parentheses and explicit destinations.
2026-10-19 10:44:31 +07:00
Mohamed Boudra b50acc9344 fix(app): restore assistant text selection in iPhone Safari (#7360)
The Markdown DOM container bypasses React Native Web style prefixing. Safari inherits the gesture host selection suppression unless the selectable boundary also sets WebkitUserSelect. Keep container styling in the existing style configuration so its selection contract is covered by the renderer tests.
2026-10-19 10:44:28 +07:00
Mohamed Boudra 727d5eed59 fix(protocol): preserve the published tool detail import (#7357)
The presentation module move removed a subpath shipped in 0.11.4. Forward its original runtime export and types so plugins retain their imports when upgrading to 0.12.
2026-10-19 10:01:18 +07:00
paseo-bot[bot] a0790e7527 fix(server): collect large file observer benchmark batches (#7349)
Startup inventories exceed the function argument limit when spread into push. Append each event individually in both benchmark collectors so large fixtures can reach their measurement summary.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 04:58:10 +02:00
paseo-bot[bot] be0378ce63 fix(usage): keep distinct Cursor accounts in separate cards (#7344)
Cursor discovery used one constant account key across all credential stores. Derive that key from the token subject in both discovery paths, and isolate unreadable identities by store, so host merging preserves each account while shared accounts still deduplicate.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 03:55:30 +02:00
paseo-bot[bot]andAling33 f0acc2c72b Send MCP terminal keys in the running program’s input mode (#7343)
* fix(server): 正确编码 MCP 终端按键

send_terminal_keys 会将方向键、导航键和多数修饰键组合当作普通文字输入。
复用协议层编码器,并从终端前导序列恢复输入模式,保留 literal 和未知文本的原样发送行为。

补充 MCP 入口回归与真实 PTY 字节验证;MCP 文件 197 项测试通过,
最终终端回归 44 项通过,服务端类型、Lint 和格式检查通过。

Fixes #7337

* fix(terminal): keep MCP keys in the running program input mode

The worker mirror refreshed its input preamble only on snapshot reads, so MCP arrows used normal mode after an unattached program enabled application cursor keys. Carry the authoritative preamble with streamed worker messages before notifying consumers.

Co-authored-by: Aling33 <3489986145@qq.com>

---------

Co-authored-by: Aling33 <3489986145@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 01:53:55 +02:00
559c0200e0 Reuse workspace and subagent lists while replies stream (#7342)
* perf(app): skip workspace agent visibility rebuild on stream ticks

The workspace screen derived agent visibility on every session-store
update: it copied every agent and agent detail into a new Map, walked
all agents, and allocated two Sets, then compared them. Stream ticks
update the store but leave the agent maps untouched, so this was pure
waste at stream rate.

createWorkspaceAgentVisibilitySelector returns the previous result until
session.agents or session.agentDetails changes, and keeps it when a
recompute yields the same sets. Parent lookups read the two maps
directly instead of merging them.

Fixes #4817

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(app): skip subagent row rebuild on stream ticks

Every mounted agent panel selected its subagent rows by walking all
session agents, allocating rows, sorting, and deep-comparing on every
session-store update, including every stream tick.

createSubagentsForParentSelector returns the previous rows until
session.agents changes, and keeps them when a recompute yields equal
rows. The hook rebuilds the selector when the parent or the pending
archive ids change.

Refs #4817

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(app): check pending submissions without allocating

selectAgentTurnPresentation runs from several session-store selectors
and filtered the submission list only to read its length. Use some().

Refs #4817

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 01:05:05 +02:00
paseo-bot[bot]andStephen Holiday dc7bed417a Keep late composer pills clear of completed turn actions (#7341)
* fix(app): keep composer tracks clear of timeline footers

* fix(app): preserve detached scroll on overlay mount

* style(app): use object input for overlay anchor policy

---------

Co-authored-by: Stephen Holiday <stephen.holiday@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 00:08:37 +02:00
paseo-bot[bot]andHemanth 08853039fb fix(server): keep resumed goal turns visible and stoppable (#7339)
A Codex goal can start while thread/resume is still connecting, before the manager can subscribe. Retain those startup events until the first subscriber and apply them before registration returns, preserving run state and cancellation ownership.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Hemanth <git@soni.im>
2026-10-18 21:55:29 +02:00
paseo-bot[bot] aef7cc36a0 fix(server): connect to relays over high-latency networks (#7336)
The runtime default cancels a pending IPv4 connect before slow routes complete, then fails immediately on unreachable IPv6. Give each relay address five seconds within the existing ten-second handshake deadline, covering both control and data sockets.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 20:57:22 +02:00
7c7b6555f1 Show provider artwork in the catalog and provider rows (#7330)
* Add missing monochrome provider icons (kiro, devin, hermes, minimax-code, traecli, grok, deepseek-tui)

* fix(app): add Kiro catalog icon and Antigravity builtin icon

The ACP catalog's kiro entry had iconId null and no vendored svg, and
the native Antigravity provider resolved to the Bot fallback before the
daemon snapshot arrived. Vendor the Kiro brand mark, wire iconId, and
register a builtin AntigravityIcon matching the plugin's theme-aware
icon.svg.

* fix(app): keep Kiro eyes in monochrome icon

* test(providers): cover catalog artwork and snapshot-free icons

Lock down the missing provider marks through the catalog and the shared icon hook. Exercise the rendered catalog artwork in Chromium alongside coverage for the pre-snapshot fallback.

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: Lu <jack1125516912@outlook.com>

* fix(website): preserve unrelated agent directory artwork

Drop the unrelated website mark introduced with the provider artwork. Cover the existing directory fallback and retained provider mark in a browser regression.

---------

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Lu <jack1125516912@outlook.com>
2026-10-18 20:00:36 +02:00
105 changed files with 2995 additions and 512 deletions
+4
View File
@@ -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
View File
@@ -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": {
+2 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A 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.
+27 -9
View File
@@ -240,8 +240,9 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
set; an unknown name renders nothing so it cannot break the plugin surface.
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
[host UI contract](../public-docs/plugins/reference.md#host-ui).
@@ -332,7 +333,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
errors are logged and do not interrupt host teardown.
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
and render error boundary. The contributed component owns the complete body below the header.
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
@@ -400,6 +401,25 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
for the author-facing contract.
### Plugin icons
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
`icon` is a Lucide name, `{ svg }`, or a component.
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
The daemon applies it to provider and usage-source icon files; the app applies it to inline
icons. Change the rules there, never at a call site.
- The app validates each icon once, at registration, through `parsePluginIcon` in
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
- That module is the only code that branches on the icon form. Surfaces draw with
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
component icon never reach an SVG element.
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
each attachment, and a component cannot be stored.
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
markup string.
## Lifecycle hooks
Server entries register lifecycle observers with `server.on()` and request transforms with
@@ -491,15 +511,13 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
owns author workflow, lifecycle, testing, and distribution guidance.
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
string is never inline SVG or a URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
@@ -0,0 +1,138 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { gotoAppShell } from "../support/helpers/app";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "plugin-icons-e2e";
const SVG_PATH = "M4 12l5 5L20 6";
const pluginClientSource = `import React from "react";
import { Text, View } from "react-native";
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
function BadgeIcon({ size, color }) {
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function Panel() {
return <View><Text>Plugin icon panel</Text></View>;
}
const panels = [
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
];
export default function contribute(client) {
for (const panel of panels) {
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
client.addCommandCenterItem({
id: panel.id,
title: "Open " + panel.title,
icon: panel.icon,
context: "workspace",
onSelect({ openPanel }) { openPanel(panel.id); },
});
}
return () => {};
}`;
function pluginTab(page: Page, panelId: string): Locator {
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin icons", () => {
test.describe.configure({ timeout: 180_000 });
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 800 });
await gotoAppShell(page);
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
await waitForWorkspaceInSidebar(page, target);
await switchWorkspaceViaSidebar({ page, ...target });
await test.step("Command Center rows draw each icon form", async () => {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("icon panel");
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
await expect(
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
).toBeVisible();
}
await expect(
panel
.getByRole("button", { name: "Open SVG icon panel", exact: true })
.locator(`path[d="${SVG_PATH}"]`),
).toBeVisible();
await expect(
panel
.getByRole("button", { name: "Open Component icon panel", exact: true })
.getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "command-center-icons");
await page.keyboard.press("Escape");
});
await test.step("workspace panel tabs draw each icon form", async () => {
await runCommand(page, "Open Lucide icon panel");
await runCommand(page, "Open SVG icon panel");
await runCommand(page, "Open Component icon panel");
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
await expect(
pluginTab(page, "component").getByTestId("plugin-component-icon"),
).toBeVisible();
await capture(page, testInfo, "workspace-tab-icons");
});
} finally {
try {
await client.removePlugin(PLUGIN_ID);
} finally {
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await workspace.cleanup();
await client.close();
await rm(directory, { recursive: true, force: true });
}
}
});
});
@@ -0,0 +1,51 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import type { TestInfo } from "@playwright/test";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import {
findAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
async function openProviderCatalog(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
}
async function expectProviderArtwork(
page: Page,
testInfo: TestInfo,
id: "devin" | "hermes" | "kiro",
name: string,
) {
await findAcpCatalogProvider(page, name);
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
await expect(icon).toBeVisible();
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
await expect.soft(icon).toHaveAttribute("width", "24");
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, ACP_PROVIDER_ICON_SVGS[id]);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
}
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
page,
}, testInfo) => {
await openProviderCatalog(page);
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
});
@@ -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
@@ -0,0 +1,47 @@
# Late composer pill clearance
This regression opens a completed conversation, relaunches the Android app, and
registers a composer pill five seconds after the client plugin loads. It checks
that the completed turn's Copy and Fork buttons remain fully above the pill.
The daemon, plugin API, native app, layout, and accessibility bounds are real;
no provider calls are needed during the test.
Prerequisites:
- Build the server and app dependencies with `npm run build:server` and
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
- Run an isolated development daemon on a non-default port.
- Run this checkout's Metro server and open the connected Android dev client
with `agent-device`. The app must already know the isolated daemon.
- Open a completed conversation on a compact portrait Android emulator, at the
latest turn, with no other composer tracks visible. Keep the software keyboard
closed. Note the workspace and agent IDs from the daemon.
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
repository root, substituting the session and ports:
```sh
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
node packages/app/e2e/mobile/composer-overlay/android.mjs \
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
```
The runner installs and removes its delayed plugin and leaves the conversation
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
explicit directory, temporary evidence is removed with the fixture.
To check an existing capture independently:
```sh
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
```
On the failing baseline, the Copy button extends below the pill's top edge. With
the correction, both actions are clear before the composer receives focus.
Additional checks on the same conversation: opening the keyboard keeps the footer
clear; after scrolling up, a newly registered pill must not jump back to the latest
turn. Native iOS uses a different keyboard implementation and needs its own device
verification.
@@ -0,0 +1,95 @@
// Start on a completed conversation in the connected Android dev client.
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket } from "ws";
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
process.argv;
assert(
session && daemonUrl && workspaceId && agentId,
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
);
const url = new URL(daemonUrl);
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
await mkdir(artifacts, { recursive: true });
const pluginId = "composer-overlay-mobile-repro";
const pillLabel = "Overlay regression pill";
const client = new DaemonClient({
url: daemonUrl,
clientId: "composer-overlay-mobile-regression",
clientType: "cli",
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
webSocketFactory: (address, options) => new WebSocket(address, options),
});
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 90_000,
});
}
let installed = false;
let previousConfig;
try {
ad("wait", 'label="Copy turn"', "20000");
await client.connect();
previousConfig = (await client.getDaemonConfig()).config;
await client.patchDaemonConfig({ pluginsEnabled: true });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: pluginId,
requirements: { paseo: ">=0.11.4" },
}),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
let pill;
const timer = setTimeout(() => {
pill = client.addComposerPill({
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
});
}, 5000);
return () => { clearTimeout(timer); pill?.remove(); };
}`,
);
await client.installDirectoryPlugin(directory);
installed = true;
ad("wait", `label="${pillLabel}"`, "20000");
// Match the report: reopen a completed conversation, then let the plugin register late.
ad(
"open",
appId,
"--platform",
"android",
"--metro-port",
metroPort,
"--no-test-ime",
"--relaunch",
);
ad("wait", `label="${pillLabel}"`, "20000");
const capture = path.join(artifacts, "cold-reopen.json");
await writeFile(capture, ad("snapshot", "--raw", "--json"));
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
execFileSync(
process.execPath,
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
{ stdio: "inherit" },
);
} finally {
if (installed) await client.removePlugin(pluginId);
if (previousConfig)
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
}
@@ -0,0 +1,21 @@
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const [, , snapshotPath, pillLabel] = process.argv;
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
const pill = buttons.find((node) => node.label === pillLabel);
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
for (const label of ["Copy turn", "Fork chat from here"]) {
const button = buttons.find((node) => node.label === label);
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
const bottom = button.rect.y + button.rect.height;
assert(
bottom <= pill.rect.y,
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
);
}
console.log("PASS: completed-turn actions remain fully above the composer pill");
+6
View File
@@ -1252,6 +1252,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) {
reasons.push("bottomOverlayControlClearance");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>

After

Width:  |  Height:  |  Size: 895 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,17 @@
import Svg, { Path } from "react-native-svg";
interface AntigravityIconProps {
size?: number;
color?: string;
}
export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
transform="translate(12 12) scale(0.24) translate(-55.738 -56.945)"
d="M89.6992 93.695C94.3659 97.195 101.366 94.8617 94.9492 88.445C75.6992 69.7783 79.7825 18.445 55.8659 18.445C31.9492 18.445 36.0325 69.7783 16.7825 88.445C9.78251 95.445 17.3658 97.195 22.0325 93.695C40.1159 81.445 38.9492 59.8617 55.8659 59.8617C72.7825 59.8617 71.6159 81.445 89.6992 93.695Z"
/>
</Svg>
);
}
@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface KiroIconProps {
size?: number;
color?: string;
}
export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001zM12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z"
/>
</Svg>
);
}
@@ -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("![image](shot.png)", undefined, { supportsSvg: false });
expect(prepared.render(new Map([["shot.png", url]]))).toBe(`![image](<${url}>)`);
});
it("makes the web Markdown container selectable in Safari as well as unprefixed browsers", () => {
expect(createMarkdownStyles(lightTheme, { blockGap: 12 }).containerStyle).toEqual({
userSelect: "text",
WebkitUserSelect: "text",
});
});
@@ -27,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,
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { Bot } from "lucide-react-native";
import { Bot, PackagePlus } from "lucide-react-native";
import { SvgXml } from "react-native-svg";
import { afterEach, describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
}
describe("useProviderIcon", () => {
it.each(["devin", "hermes", "kiro", "antigravity"])(
"#7087: %s has a provider mark before a host snapshot arrives",
(provider) => {
const Icon = renderHook(() => useProviderIcon(provider)).result.current;
expect(Icon).not.toBe(Bot);
expect(Icon).not.toBe(PackagePlus);
expect(typeof Icon).toBe("function");
},
);
it("renders registered snapshot SVG metadata with the requested size and color", () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
@@ -1,12 +1,14 @@
import { useStore } from "zustand";
import { providerSnapshotIcons } from "@/data/provider-icons";
import { Bot, PackagePlus } from "lucide-react-native";
import { Bot } from "lucide-react-native";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
import { AntigravityIcon } from "@/components/icons/antigravity-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { KiroIcon } from "@/components/icons/kiro-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
antigravity: AntigravityIcon as unknown as ProviderIconComponent,
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
kiro: KiroIcon as unknown as ProviderIconComponent,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
@@ -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;
@@ -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" }),
+19 -23
View File
@@ -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: {
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
title: "Devin CLI",
description: "Cognition's Devin for Terminal via Agent Client Protocol",
version: "manual",
iconId: null,
iconId: "devin",
installLink: "https://cli.devin.ai/docs",
command: ["devin", "acp"],
},
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
title: "Hermes",
description: "Nous Research self-improving AI agent",
version: "manual",
iconId: null,
iconId: "hermes",
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
iconId: null,
iconId: "kiro",
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
@@ -11,6 +11,13 @@ function findProvider(id: string) {
}
describe("ACP provider catalog", () => {
it.each(["devin", "hermes", "kiro"])(
"#7087: %s supplies its provider mark to the catalog picker",
(provider) => {
expect(findProvider(provider).iconSvg).toContain("<svg");
},
);
it("vendors provider entries with unique ids and concrete commands", () => {
const ids = new Set<string>();
@@ -1,6 +1,10 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "@getpaseo/plugin";
import {
PluginAttachmentItemSchema,
type PluginAttachmentItem,
type PluginSerializableIcon,
} from "@getpaseo/plugin";
import type { UserComposerAttachment } from "@/attachments/types";
export const PluginResourceComposerAttachmentSchema = z.object({
@@ -8,7 +12,8 @@ export const PluginResourceComposerAttachmentSchema = z.object({
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
// Validated when the source registered; a draft keeps the icon its source had then.
sourceIcon: z.union([z.string().min(1), z.object({ svg: z.string().min(1) })]),
item: PluginAttachmentItemSchema,
});
@@ -20,7 +25,7 @@ export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
sourceIcon: PluginSerializableIcon;
}
export function createPluginResourceAttachment(
+14 -14
View File
@@ -2,15 +2,18 @@ import { useCallback, useMemo, useState, type ReactElement, type RefObject } fro
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin";
import type {
PluginAttachmentItem,
PluginAttachmentSourceContribution,
PluginSerializableIcon,
} from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { PluginIconView } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
@@ -19,8 +22,8 @@ const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
@@ -145,15 +148,12 @@ export function usePluginAttachmentPicker(
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
sources.map(({ key, source }) => ({
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
})),
[sources],
);
if (!active) return { menuItems, picker: null };
+6 -12
View File
@@ -3,19 +3,14 @@ import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginSerializableIcon } from "@getpaseo/plugin";
import { PluginIconView } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
function ResourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
@@ -39,12 +34,11 @@ export function PluginResourceAttachmentPill({
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
() => <ThemedResourceIcon icon={attachment.sourceIcon} uniProps={iconColorMapping} />,
[attachment.sourceIcon],
);
return (
<AttachmentPill
@@ -30,8 +30,9 @@ function installation(): InstalledPlugin {
function store() {
return new PluginButtonStore({
validateIconName(name) {
if (name !== "Scan") throw new Error("Unknown icon");
parseIcon(icon) {
if (icon !== "Scan") throw new Error("Unknown icon");
return icon;
},
});
}
+4 -2
View File
@@ -1,4 +1,6 @@
import { resolvePluginIcon } from "../icons";
import { parsePluginIcon } from "../icons";
import { PluginButtonStore } from "./model";
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
export const pluginButtonStore = new PluginButtonStore({
parseIcon: (icon) => parsePluginIcon("Plugin button", icon),
});
@@ -1,7 +1,8 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
export interface ButtonValidation {
validateIconName(name: string): void;
/** Returns the normalized icon, or throws naming why the icon is unusable. */
parseIcon(icon: PluginButtonIcon): PluginButtonIcon;
}
export function requireButtonId(value: string): string {
@@ -29,12 +30,6 @@ function isComponent(value: unknown): boolean {
);
}
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
if (typeof icon === "string") validation.validateIconName(icon);
else if (!isComponent(icon))
throw new Error("Plugin button icon must be a Lucide name or component");
}
function validateBehavior(
behavior: PluginButtonBehavior,
validation: ButtonValidation,
@@ -57,9 +52,9 @@ function validateBehavior(
ids.add(item.id);
if (item.kind === "separator") return { ...item };
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
if (item.icon !== undefined) validateIcon(item.icon, validation);
return {
...item,
icon: item.icon === undefined ? undefined : validation.parseIcon(item.icon),
title: requireText(item.title, "menu item title"),
visible: optionalBoolean(item.visible, true),
disabled: optionalBoolean(item.disabled, false),
@@ -83,9 +78,9 @@ export function validateButton(
button: PluginButton,
validation: ButtonValidation,
): ResolvedPluginButton {
validateIcon(button.icon, validation);
return {
...button,
icon: validation.parseIcon(button.icon),
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
visible: optionalBoolean(button.visible, true),
+3 -15
View File
@@ -32,7 +32,7 @@ import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { Icon } from "../icons";
import { pluginIconComponent } from "../icons";
import {
PluginEnvironmentProvider,
PluginPopoverContent,
@@ -79,6 +79,7 @@ function ButtonEnvironment({ view, children }: { view: ButtonView; children: Rea
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
const color = view.props.theme.colors.foregroundMuted;
const size = view.entry.placement === "composer" ? 14 : 16;
const Icon = pluginIconComponent(icon);
return (
<View
style={styles.icon}
@@ -87,24 +88,11 @@ function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{typeof icon === "string" ? (
<Icon name={icon} size={size} color={color} />
) : (
renderCustomIcon(icon, view, size, color)
)}
<Icon {...view.props} size={size} color={color} />
</View>
);
}
function renderCustomIcon(
CustomIcon: Exclude<PluginButtonIcon, string>,
view: ButtonView,
size: number,
color: string,
) {
return <CustomIcon {...view.props} size={size} color={color} />;
}
function pressButton(view: ButtonView, path: readonly string[]) {
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
view.toast.error(error instanceof Error ? error.message : String(error));
@@ -1,7 +1,7 @@
import type { PluginClientStateSource } from "@getpaseo/plugin/client/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { InstalledPlugin } from "../types";
import { createPluginCapabilities, type PluginNavigation } from "../actions";
@@ -83,7 +83,7 @@ export function buildPluginCommandCenterContributions(
kind: "action",
title: item.title,
sectionTitle: plugin.id,
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
icon: getCommandCenterIcon(pluginIconComponent(item.icon)),
},
run,
});
+66
View File
@@ -381,6 +381,72 @@ describe("evaluatePluginClientBundle", () => {
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
});
it("registers inline SVG and component icons", () => {
const svg = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function ReviewPanel() { return null; }
function BadgeIcon() { return null; }
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: { svg: ${JSON.stringify(` ${svg}\n`)} },
context: "workspace",
Component: ReviewPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: BadgeIcon,
context: "global",
onSelect() {},
});
plugin.addAttachmentSource({
id: "issues",
title: "Issues",
icon: { svg: ${JSON.stringify(svg)} },
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.workspacePanels[0]?.icon).toEqual({ svg });
expect(plugin.commandCenterItems[0]?.icon).toHaveProperty("name", "BadgeIcon");
expect(plugin.attachmentSources[0]?.icon).toEqual({ svg });
});
it.each([
[
"workspace panel",
`plugin.addWorkspacePanel({ id: "review", title: "Review", icon: ICON, context: "workspace", Component() { return null; } })`,
"Workspace panel review has an invalid icon: script elements are not allowed",
],
[
"Command Center item",
`plugin.addCommandCenterItem({ id: "review", title: "Review", icon: ICON, context: "global", onSelect() {} })`,
"Command Center item review has an invalid icon: script elements are not allowed",
],
[
"settings screen",
`plugin.addSettingsScreen({ id: "review", title: "Review", icon: ICON, Component() { return null; } })`,
"Settings screen review has an invalid icon: script elements are not allowed",
],
[
"attachment source",
`plugin.addAttachmentSource({ id: "review", title: "Review", icon: ICON, pickerTitle: "Pick", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } })`,
"Attachment source review has an invalid icon: script elements are not allowed",
],
])("fails the plugin when a %s registers an unsafe SVG", (_kind, registration, message) => {
const icon = JSON.stringify({ svg: "<svg><script>run()</script></svg>" });
expect(() =>
evaluatePluginClientBundle("review", bundle(registration.replace("ICON", icon))),
).toThrow(message);
});
it("normalizes and validates workspace panel locations", () => {
const plugin = evaluatePluginClientBundle(
"review",
+10 -15
View File
@@ -33,7 +33,7 @@ import {
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
@@ -199,9 +199,9 @@ export function runPluginClientBundle(
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
resolvePluginIcon(contribution.icon);
const icon = parsePluginIcon(`Settings screen ${screenId}`, contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
return register(collector.settingsScreens, { ...contribution, id: screenId, icon }, () =>
settingsScreenIds.delete(screenId),
);
},
@@ -241,12 +241,10 @@ export function runPluginClientBundle(
// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29
addSidebarItem(contribution: PluginSidebarContribution) {
const normalizedId = requireId(contribution.id, "sidebar item id");
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
resolvePluginIcon(contribution.icon.trim());
const legacy = {
id: normalizedId,
title: contribution.title.trim(),
icon: contribution.icon.trim(),
icon: parsePluginIcon(`Sidebar item ${normalizedId}`, contribution.icon),
surface: requireId(contribution.surface, "sidebar surface id"),
};
// Shares the header's ids: the sidebar orders both kinds of item under one key.
@@ -262,16 +260,14 @@ export function runPluginClientBundle(
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Workspace panel ${normalizedId}`, contribution.icon);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
resolvePluginIcon(icon);
const locations = normalizePanelLocations(normalizedId, contribution.locations);
workspacePanelIds.add(normalizedId);
return register(
@@ -292,9 +288,8 @@ export function runPluginClientBundle(
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
const icon = parsePluginIcon(`Command Center item ${normalizedId}`, contribution.icon);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
@@ -305,7 +300,6 @@ export function runPluginClientBundle(
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
return register(
collector.commandCenterItems,
@@ -350,18 +344,19 @@ export function runPluginClientBundle(
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
const icon = parseSerializablePluginIcon(
`Attachment source ${normalizedId}`,
contribution.icon,
);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
return register(
collector.attachmentSources,
-18
View File
@@ -1,18 +0,0 @@
import { Settings } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { Icon } from "./icons";
describe("Icon", () => {
it("renders a host Lucide icon with the requested presentation", () => {
expect(Icon({ name: "Settings", size: 18, color: "#123456" })).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders nothing for an unknown icon name", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { Settings } from "lucide-react-native";
import React, { isValidElement, type ReactElement, type ReactNode } from "react";
import { SvgXml } from "react-native-svg";
import { describe, expect, it } from "vitest";
import type { PluginIconProps } from "@getpaseo/plugin";
import { Icon, parsePluginIcon, parseSerializablePluginIcon, PluginIconView } from "./icons";
const SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M4 4h16v16H4z" /></svg>';
const SVG_ICON = { svg: SVG };
const GLYPHS = new Set<unknown>([Settings, SvgXml]);
/** Expands function components down to what reaches the screen: a Lucide glyph, SvgXml, or DOM. */
function draw(node: ReactNode): ReactElement | null {
if (!isValidElement(node)) return null;
const { type } = node;
if (typeof type !== "function" || GLYPHS.has(type)) return node;
return draw((type as (props: unknown) => ReactNode)(node.props));
}
function BadgeIcon({ size, color }: PluginIconProps) {
return <span data-size={size} data-color={color} />;
}
describe("PluginIconView", () => {
it("draws a Lucide name as that Lucide icon", () => {
expect(draw(<PluginIconView icon="Settings" size={18} color="#123456" />)).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("draws inline SVG sized to the surface, with color reaching currentColor", () => {
expect(draw(<PluginIconView icon={SVG_ICON} size={18} color="#123456" />)).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, height: 18, color: "#123456" },
});
});
it("draws a component with the surface's size and color", () => {
expect(draw(<PluginIconView icon={BadgeIcon} size={18} color="#123456" />)).toMatchObject({
type: "span",
props: { "data-size": 18, "data-color": "#123456" },
});
});
});
describe("parsePluginIcon", () => {
it("normalizes each icon form", () => {
expect(parsePluginIcon("Workspace panel files", " Settings ")).toBe("Settings");
expect(parsePluginIcon("Workspace panel files", { svg: `\n${SVG}\n` })).toEqual({ svg: SVG });
expect(parsePluginIcon("Workspace panel files", BadgeIcon)).toBe(BadgeIcon);
});
it.each([
["", "Workspace panel files has no icon"],
[
"NotALucideIcon",
'Workspace panel files has an invalid icon: unknown Lucide icon "NotALucideIcon"',
],
[
{ svg: "<svg><script>run()</script></svg>" },
"Workspace panel files has an invalid icon: script elements are not allowed",
],
[{ svg: "hello" }, "Workspace panel files has an invalid icon: not an SVG document"],
[
{ path: "icon.svg" },
"Workspace panel files has an invalid icon: expected a Lucide icon name, { svg }, or a component",
],
])("rejects %j", (icon, message) => {
expect(() => parsePluginIcon("Workspace panel files", icon as never)).toThrow(message);
});
it("refuses a component where the icon is stored as data", () => {
expect(() =>
parseSerializablePluginIcon("Attachment source issues", BadgeIcon as never),
).toThrow(
"Attachment source issues has an invalid icon: expected a Lucide icon name or { svg }",
);
});
});
describe("Icon", () => {
it("renders a host Lucide icon by name with the requested presentation", () => {
expect(draw(Icon({ name: "Settings", size: 18, color: "#123456" }))).toMatchObject({
type: Settings,
props: { size: 18, color: "#123456" },
});
});
it("renders a plugin's own SVG through the icon prop", () => {
expect(draw(Icon({ icon: { svg: SVG }, size: 18, color: "#123456" }))).toMatchObject({
type: SvgXml,
props: { xml: SVG, width: 18, color: "#123456" },
});
});
it("renders nothing for an unusable icon", () => {
expect(Icon({ name: "NotALucideIcon" })).toBeNull();
expect(Icon({ name: "Icon" })).toBeNull();
expect(Icon({ name: "createLucideIcon" })).toBeNull();
expect(Icon({ icon: { svg: '<svg onload="run()"></svg>' } })).toBeNull();
});
});
+139 -13
View File
@@ -1,24 +1,150 @@
import { createElement, type ReactElement } from "react";
import { createElement, type ComponentType, type ReactElement } from "react";
import * as LucideIcons from "lucide-react-native";
import type { PluginIconProps } from "@getpaseo/plugin/client";
import type { LucideIcon } from "lucide-react-native";
import {
findSvgIconProblem,
type PluginIconProps,
type PluginSerializableIcon,
type PluginSvgIcon,
} from "@getpaseo/plugin";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { IconProps } from "@getpaseo/plugin/client/react-native";
import { SvgIcon } from "@/components/provider-icons";
function findPluginIcon(name: string): LucideIcon | null {
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
type HostIconProps = PluginIconProps & { strokeWidth?: number };
/** A host-drawn icon. A plain function, so it stands in for any plugin icon component type. */
type HostIcon = (props: HostIconProps) => ReactElement | null;
const lucideIconComponents = new Map<string, HostIcon>();
const svgIconComponents = new Map<string, HostIcon>();
function findLucideIcon(name: string): LucideIcon | null {
const candidate = Reflect.get(LucideIcons, name);
if (candidate === LucideIcons.Icon || candidate === LucideIcons.createLucideIcon) return null;
const isComponent =
typeof candidate === "function" ||
(typeof candidate === "object" && candidate !== null && "$$typeof" in candidate);
return isComponent ? (candidate as LucideIcon) : null;
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
export function resolvePluginIcon(name: string): LucideIcon {
const icon = findPluginIcon(name);
if (!icon) throw new Error(`Unknown Lucide icon: ${name}`);
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
(typeof value === "object" && value !== null && "$$typeof" in value)
);
}
function isSvgIcon(icon: unknown): icon is PluginSvgIcon {
return (
typeof icon === "object" &&
icon !== null &&
!("$$typeof" in icon) &&
typeof Reflect.get(icon, "svg") === "string"
);
}
function isComponentIcon<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): icon is ComponentType<Props> {
return isComponent(icon);
}
function invalidIcon(owner: string, reason: string): Error {
return new Error(`${owner} has an invalid icon: ${reason}`);
}
/** Validates an icon that is stored with the data it labels. Returns the normalized icon. */
export function parseSerializablePluginIcon(
owner: string,
icon: PluginSerializableIcon,
): PluginSerializableIcon {
if (typeof icon === "string") {
const name = icon.trim();
if (!name) throw new Error(`${owner} has no icon`);
if (!findLucideIcon(name)) throw invalidIcon(owner, `unknown Lucide icon "${name}"`);
return name;
}
if (!isSvgIcon(icon)) throw invalidIcon(owner, "expected a Lucide icon name or { svg }");
const svg = icon.svg.trim();
const problem = findSvgIconProblem(svg);
if (problem) throw invalidIcon(owner, problem);
return { svg };
}
/** Validates a contribution's icon once, at registration. Rendering trusts the result. */
export function parsePluginIcon<Props extends PluginIconProps>(
owner: string,
icon: PluginIcon<Props>,
): PluginIcon<Props> {
if (isComponentIcon(icon)) return icon;
if (typeof icon !== "string" && !isSvgIcon(icon)) {
throw invalidIcon(owner, "expected a Lucide icon name, { svg }, or a component");
}
return parseSerializablePluginIcon(owner, icon);
}
function MissingIcon(): null {
return null;
}
function createLucideIcon(name: string, Lucide: LucideIcon): HostIcon {
function PluginLucideIcon({ size, color, strokeWidth }: HostIconProps) {
return createElement(Lucide, { size, color, strokeWidth });
}
PluginLucideIcon.displayName = `PluginLucideIcon(${name})`;
return PluginLucideIcon;
}
function lucideIconComponent(name: string): HostIcon {
const cached = lucideIconComponents.get(name);
if (cached) return cached;
const Lucide = findLucideIcon(name);
if (!Lucide) return MissingIcon;
const icon = createLucideIcon(name, Lucide);
lucideIconComponents.set(name, icon);
return icon;
}
export function Icon({ name, size, color }: PluginIconProps): ReactElement | null {
const icon = findPluginIcon(name);
return icon ? createElement(icon, { size, color }) : null;
function svgIconComponent(svg: string): HostIcon {
const cached = svgIconComponents.get(svg);
if (cached) return cached;
function PluginSvgIcon({ size, color }: PluginIconProps) {
return createElement(SvgIcon, { svg, size, color });
}
svgIconComponents.set(svg, PluginSvgIcon);
return PluginSvgIcon;
}
/**
* A stable component for a registered icon, for surfaces that take an icon component. Lucide and
* SVG icons draw from `size` and `color` only; a plugin component receives every prop.
*/
export function pluginIconComponent<Props extends PluginIconProps>(
icon: PluginIcon<Props>,
): ComponentType<Props> {
if (typeof icon === "string") return lucideIconComponent(icon);
if (isSvgIcon(icon)) return svgIconComponent(icon.svg);
return icon;
}
export interface PluginIconViewProps extends PluginIconProps {
icon: PluginIcon;
}
/** Draws a registered plugin icon. `color` tints Lucide icons and `currentColor` in SVG. */
export function PluginIconView({ icon, size, color }: PluginIconViewProps): ReactElement {
return createElement(pluginIconComponent(icon), { size, color });
}
function isRenderable(icon: PluginIcon): boolean {
if (typeof icon === "string") return findLucideIcon(icon) !== null;
if (isSvgIcon(icon)) return findSvgIconProblem(icon.svg) === null;
return isComponent(icon);
}
/** The kit `Icon`. Plugin code passes it anything, so an unusable icon renders nothing. */
export function Icon(props: IconProps): ReactElement | null {
const icon = props.icon === undefined ? props.name : props.icon;
if (!isRenderable(icon)) return null;
const size = props.size ?? 24;
const color = props.color ?? "currentColor";
return createElement(PluginIconView, { icon, size, color });
}
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
PluginSidebarItemContribution,
PluginSidebarSection,
} from "./types";
import type { PluginIcon } from "@getpaseo/plugin/client";
export interface PluginSidebarTarget {
plugin: InstalledPlugin;
@@ -36,7 +37,7 @@ export type PluginSidebarGroup =
| (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] })
| (PluginSidebarGroupBase & {
kind: "legacy";
icon: string;
icon: PluginIcon;
targets: PluginLegacySidebarTarget[];
});
@@ -1,16 +1,12 @@
import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useRef } from "react";
import type { SidebarRowProps } from "@getpaseo/plugin/client/ui";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { resolvePluginIcon } from "../icons";
import { parsePluginIcon, pluginIconComponent } from "../icons";
import { useSidebarItemFrame } from "./frame";
function resolveIcon(icon: SidebarIcon) {
return typeof icon === "string" ? resolvePluginIcon(icon) : icon;
}
/**
* An item may render several rows. Any press inside a row, on the row or on its trailing content,
* makes that row the popover anchor: the capture-phase responder check runs before the pressed
@@ -29,6 +25,11 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
anchorTo(rowRef.current);
return false;
}, [anchorTo]);
// Rows get their icon at render, not registration, so an invalid icon throws to the item's boundary.
const iconComponent = useMemo(
() => (icon ? pluginIconComponent(parsePluginIcon("Sidebar row", icon)) : null),
[icon],
);
// Keyboard activation skips the responder system, so the row's own press anchors as well.
const handlePress = useCallback(() => {
anchorTo(rowRef.current);
@@ -37,7 +38,7 @@ export function SidebarRow({ id, icon, label, onPress, active, trailing }: Sideb
return (
<View ref={rowRef} collapsable={false} onStartShouldSetResponderCapture={anchorHere}>
<SidebarHeaderRow
icon={icon ? resolveIcon(icon) : null}
icon={iconComponent}
label={label ?? frame.title}
onPress={handlePress}
isActive={active}
@@ -2,7 +2,7 @@ import { router, usePathname } from "expo-router";
import { useCallback } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { rememberPluginContributionHost } from "../contribution-host";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups";
@@ -33,7 +33,7 @@ export function LegacyPluginSidebarRow({
}, [group.key, onBeforeNavigate, route, target.plugin.serverId]);
return (
<SidebarHeaderRow
icon={resolvePluginIcon(group.icon)}
icon={pluginIconComponent(group.icon)}
label={group.title}
onPress={navigate}
isActive={isActive}
+8 -14
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginIcon, PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { X } from "lucide-react-native";
import { useCallback, useMemo, type ComponentType } from "react";
@@ -17,7 +17,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { usePluginHostNavigation } from "./host-navigation";
import { resolvePluginIcon } from "./icons";
import { PluginIconView } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes";
@@ -47,14 +47,8 @@ function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
function PluginHeaderIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={16} color={color} />;
function PluginHeaderIcon({ icon, color = "" }: { icon: PluginIcon; color?: string }) {
return <PluginIconView icon={icon} size={16} color={color} />;
}
const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
@@ -162,7 +156,7 @@ export function PluginSurfaceScreen() {
: (sidebarItem?.title ?? (pluginId || "Plugin")),
[params, pluginId, sidebarItem, surface],
);
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const icon = sidebarItem?.icon ?? null;
const close = useCallback(() => {
if (router.canGoBack()) router.back();
else router.replace(`/h/${encodeURIComponent(serverId)}`);
@@ -172,15 +166,15 @@ export function PluginSurfaceScreen() {
const headerLeft = useMemo(
() => (
<>
{Icon ? (
{icon ? (
<HeaderIconBadge>
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
<ThemedPluginHeaderIcon icon={icon} uniProps={mutedColorMapping} />
</HeaderIconBadge>
) : null}
<ScreenTitle testID="plugin-surface-title">{title}</ScreenTitle>
</>
),
[Icon, title],
[icon, title],
);
const hostFilter = useMemo(
() =>
@@ -33,7 +33,7 @@ vi.mock("../client-runtime", () => ({
}));
vi.mock("../icons", () => ({
Icon: () => null,
resolvePluginIcon: () => () => null,
pluginIconComponent: () => () => null,
}));
import { pluginRegistry } from "../registry";
@@ -17,7 +17,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { usePluginHostNavigation } from "../host-navigation";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { resolvePluginIcon } from "../icons";
import { pluginIconComponent } from "../icons";
import { useInstalledPlugin } from "../registry";
import { PluginInstallationProvider } from "../installation-provider";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
@@ -144,7 +144,7 @@ function usePluginPanelDescriptor(
subtitle: target.pluginId,
tooltip: panel.title,
titleState: "ready",
icon: resolvePluginIcon(panel.icon),
icon: pluginIconComponent(panel.icon),
statusBucket: null,
};
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useCallback, useMemo, type ComponentType, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -26,7 +26,7 @@ import {
type SidebarNavItem,
type SidebarSection,
} from "@/sidebar-nav/model";
import { resolvePluginIcon } from "@/plugins/icons";
import { pluginIconComponent } from "@/plugins/icons";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -50,15 +50,17 @@ const BUILTIN_ICONS: Record<BuiltinSidebarItemId, LucideIcon> = {
/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */
const PLUGIN_ICON = Blocks;
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
type NavIconComponent = ComponentType<{ size: number; color: string }>;
function NavIcon({ Icon, color = "" }: { Icon: NavIconComponent; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedNavIcon = withUnistyles(NavIcon);
function navItemIcon(item: SidebarNavItem): LucideIcon {
function navItemIcon(item: SidebarNavItem): NavIconComponent {
if (item.kind === "builtin") return BUILTIN_ICONS[item.id];
return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON;
return item.group.kind === "legacy" ? pluginIconComponent(item.group.icon) : PLUGIN_ICON;
}
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
@@ -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,
);
+163 -1
View File
@@ -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);
}
});
});
+31 -5
View File
@@ -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,
);
+44 -54
View File
@@ -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))
);
}
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import { Globe, SquarePen, SquareTerminal } from "lucide-react-native";
import invariant from "tiny-invariant";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { resolvePluginIcon } from "@/plugins/icons";
import { pluginIconComponent } from "@/plugins/icons";
import { useInstalledPlugins } from "@/plugins/registry";
import { pluginPanelSupportsLocation } from "@/plugins/workspace-panels/locations";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
@@ -212,7 +212,7 @@ export function useWorkspaceTabLaunchCatalog(input: {
pluginItems.push({
id: `plugin:${plugin.id}:${panel.id}`,
label: panel.title,
Icon: resolvePluginIcon(panel.icon),
Icon: pluginIconComponent(panel.icon),
disabled: false,
panelKind: "plugin",
launch: launchSelection(selection),
+4 -4
View File
@@ -1,16 +1,16 @@
import type { ComponentType } from "react";
import type { PluginHostProps } from "./contracts.js";
import type { PluginIconProps } from "../icons.js";
import type { PluginHostProps, PluginIcon } from "./contracts.js";
export type PluginButtonContext =
| { context: "workspace"; workspaceId: string }
| { context: "agent"; workspaceId: string; agentId: string };
export type PluginButtonIconProps = PluginHostProps &
PluginButtonContext & { size: number; color: string };
export type PluginButtonIconProps = PluginHostProps & PluginButtonContext & PluginIconProps;
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
export type PluginButtonIcon = PluginIcon<PluginButtonIconProps>;
export type PluginButtonBehavior =
| { kind: "action"; onPress(): void | Promise<void> }
+12 -9
View File
@@ -17,6 +17,7 @@ import type {
PluginTimelineTransformResult,
PluginCleanup,
} from "../contracts.js";
import type { PluginIconProps, PluginSerializableIcon } from "../icons.js";
export interface PluginHostProps {
theme: PluginTheme;
@@ -87,11 +88,13 @@ export interface PluginSidebarItemContribution {
Component: ComponentType<PluginSidebarItemProps>;
}
export interface PluginIconProps {
name: string;
size?: number;
color?: string;
}
/**
* A Lucide icon name, inline SVG markup, or a component. Components receive the surface's size
* and color; buttons widen the props with their host and target context.
*/
export type PluginIcon<Props extends PluginIconProps = PluginIconProps> =
| PluginSerializableIcon
| ComponentType<Props>;
export type PluginPanelLocation = "workspace" | "explorer";
@@ -102,7 +105,7 @@ export interface PluginOpenPanelOptions {
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
icon: PluginIcon;
locations?: readonly PluginPanelLocation[];
}
@@ -168,7 +171,7 @@ export type PluginWorkspacePanelContribution =
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
icon: PluginIcon;
Component: ComponentType<PluginSurfaceProps>;
}
@@ -190,7 +193,7 @@ export interface PluginSurfaceContribution {
export interface PluginSidebarContribution {
id: string;
title: string;
icon: string;
icon: PluginIcon;
surface: string;
}
@@ -259,7 +262,7 @@ export interface PluginAgentCommandContext extends PluginCommandCapabilities {
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
icon: PluginIcon;
keywords?: readonly string[];
}
+2 -1
View File
@@ -8,7 +8,7 @@ export type {
PluginScreenLocation,
PluginSidebarItemProps,
PluginSidebarItemContribution,
PluginIconProps,
PluginIcon,
PluginPanelLocation,
PluginOpenPanelOptions,
PluginWorkspacePanelProps,
@@ -45,6 +45,7 @@ export type {
PluginComposerPillContribution,
PluginHeaderButtonContribution,
} from "./buttons.js";
export type { PluginIconProps, PluginSerializableIcon, PluginSvgIcon } from "../icons.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
+8 -3
View File
@@ -17,7 +17,8 @@ import type {
TextInput as NativeTextInput,
TextInputProps,
} from "react-native";
import type { PluginIconProps } from "./contracts.js";
import type { PluginIconProps } from "../icons.js";
import type { PluginIcon } from "./contracts.js";
export interface ModalProps {
title: string;
@@ -53,12 +54,16 @@ export interface ToastApi {
error(message: string): void;
}
export declare const Icon: ComponentType<PluginIconProps>;
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
export type IconProps = Partial<PluginIconProps> &
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
export declare const Icon: ComponentType<IconProps>;
export declare const Modal: ModalComponent;
export declare function useToast(): ToastApi;
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
export type { PluginIconProps } from "./contracts.js";
export type { PluginIconProps } from "../icons.js";
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
export declare const ScrollView: ForwardRefExoticComponent<
+3 -2
View File
@@ -1,4 +1,5 @@
import type { ComponentType, ReactNode, Ref } from "react";
import type { PluginIcon } from "./contracts.js";
export interface SettingsSectionProps {
title: string;
@@ -64,14 +65,14 @@ export interface ExternalLinkProps {
}
export declare const ExternalLink: ComponentType<ExternalLinkProps>;
export type SidebarIcon = string | ComponentType<{ size: number; color: string }>;
export type SidebarIcon = PluginIcon;
export interface SidebarRowProps {
/**
* Tells rows of one item apart when the item renders several: "bot-2". Unique within the item.
* Omit it when the item renders one row.
*/
id?: string;
/** A Lucide icon name or a component. */
/** A Lucide icon name, inline SVG, or a component. */
icon?: SidebarIcon;
/** Defaults to the item's registered title. */
label?: string;
+2 -1
View File
@@ -1,4 +1,5 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginSerializableIcon } from "./icons.js";
import type { PluginRpcContract } from "./rpc.js";
export interface PluginTheme {
@@ -73,7 +74,7 @@ export interface PluginThemeContribution {
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
icon: PluginSerializableIcon;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { findSvgIconProblem } from "./icons.js";
describe("findSvgIconProblem", () => {
it.each([
["a plain SVG", '<svg viewBox="0 0 24 24"><path d="M0 0h24v24z" /></svg>'],
["leading whitespace", '\n <svg viewBox="0 0 24 24"></svg>'],
[
"a fragment reference",
'<svg><defs><path id="mark" d="M0 0h1v1z" /></defs><use href="#mark" /></svg>',
],
["currentColor fills", '<svg fill="currentColor"><circle r="4" /></svg>'],
])("accepts %s", (_name, svg) => {
expect(findSvgIconProblem(svg)).toBeNull();
});
it.each([
["a non-SVG document", "hello", "not an SVG document"],
["an <svgx> element", "<svgx></svgx>", "not an SVG document"],
["a script", "<svg><script /></svg>", "script elements are not allowed"],
["a foreign object", "<svg><foreignObject /></svg>", "foreignObject elements are not allowed"],
["a style", "<svg><style /></svg>", "style elements are not allowed"],
[
"an event handler",
'<svg><path onclick="run()" /></svg>',
"event-handler attributes are not allowed",
],
[
"a javascript URL",
'<svg><a href="javascript:run()" /></svg>',
"javascript URLs are not allowed",
],
[
"an external href",
'<svg><use href="https://example.com/icon.svg" /></svg>',
"external href references are not allowed",
],
[
"an external xlink:href",
"<svg><image xlink:href=icon.png /></svg>",
"external href references are not allowed",
],
["markup over 64 KiB", `<svg>${" ".repeat(64 * 1024)}</svg>`, "exceeds 64 KiB"],
])("rejects %s", (_name, svg, reason) => {
expect(findSvgIconProblem(svg)).toBe(reason);
});
it("measures the size in UTF-8 bytes", () => {
const multibyte = "é".repeat(32 * 1024);
expect(findSvgIconProblem(`<svg>${multibyte}</svg>`)).toBe("exceeds 64 KiB");
});
});
+38
View File
@@ -0,0 +1,38 @@
/** What a component icon receives from the surface that draws it. */
export interface PluginIconProps {
size: number;
color: string;
}
/** Inline SVG markup. Paseo validates it when the contribution registers. */
export interface PluginSvgIcon {
svg: string;
}
/** An icon that can be stored or sent: a Lucide icon name, or inline SVG. */
export type PluginSerializableIcon = string | PluginSvgIcon;
export const PLUGIN_SVG_ICON_MAX_BYTES = 64 * 1024;
const EXTERNAL_HREF = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
/**
* Why Paseo refuses to render this SVG, or null when it is a self-contained document.
* The daemon applies it to provider and usage-source icon files; the app to inline plugin icons.
*/
export function findSvgIconProblem(svg: string): string | null {
if (new TextEncoder().encode(svg).byteLength > PLUGIN_SVG_ICON_MAX_BYTES) {
return "exceeds 64 KiB";
}
if (!/^\s*<svg(?:\s|>)/i.test(svg)) return "not an SVG document";
if (/<script(?:\s|>)/i.test(svg)) return "script elements are not allowed";
if (/<foreignObject(?:\s|>)/i.test(svg)) return "foreignObject elements are not allowed";
if (/<style(?:\s|>)/i.test(svg)) return "style elements are not allowed";
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) return "event-handler attributes are not allowed";
if (/javascript\s*:/i.test(svg)) return "javascript URLs are not allowed";
for (const match of svg.matchAll(EXTERNAL_HREF)) {
const href = match[2] ?? match[3] ?? "";
if (!href.startsWith("#")) return "external href references are not allowed";
}
return null;
}
+7
View File
@@ -20,3 +20,10 @@ export {
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
export {
findSvgIconProblem,
PLUGIN_SVG_ICON_MAX_BYTES,
type PluginIconProps,
type PluginSerializableIcon,
type PluginSvgIcon,
} from "./icons.js";
@@ -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,4 +1,5 @@
export const BUILTIN_PROVIDER_ICON_NAMES = [
"antigravity",
"claude",
"codex",
"copilot",
@@ -22,6 +23,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"crow-cli",
"cursor",
"deepagents",
"devin",
"dimcode",
"dirac",
"factory-droid",
@@ -31,6 +33,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"glm-acp-agent",
"goose",
"grok",
"hermes",
"hoplite",
"junie",
"kilo",
@@ -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;
@@ -3654,6 +3654,8 @@ export class AgentManager {
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
await this.drainSessionEvents(managed.id);
this.assertAgentRegistrationActive(managed);
return { ...managed };
} catch (error) {
if (!registered) {
@@ -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({
@@ -9,6 +9,7 @@ import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import type {
AgentClient,
AgentLaunchContext,
AgentPermissionRequest,
AgentSession,
@@ -94,6 +95,7 @@ import {
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
@@ -7073,3 +7075,95 @@ describe("Codex denied plan approvals", () => {
});
});
});
describe("Codex interactive resume startup", () => {
test("delivers an autonomous turn that starts during resume to the first subscriber", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({}),
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
appServer.says({ threadId: "saved-thread", text: "Working on the goal" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ modeId: "full-access" }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("Working on the goal"),
),
).toBe(true);
await session.interrupt();
expect(
appServer.requests().find((request) => request.method === "turn/interrupt"),
).toMatchObject({ params: { threadId: "saved-thread", turnId: "autonomous-turn" } });
unsubscribe();
const laterEvents: AgentStreamEvent[] = [];
session.subscribe((event) => laterEvents.push(event));
expect(laterEvents).toEqual([]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test.each([false, true])(
"applies resumed goal events before returning (completed during resume: %s)",
async (completedDuringResume) => {
const appServer = createFakeCodexAppServer({
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
if (completedDuringResume) appServer.completeTurn({ threadId: "saved-thread" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
const client: AgentClient = {
provider: "codex",
capabilities: session.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: async () => {
throw new Error("Unexpected create");
},
resumeSession: async () => {
await session.connect();
return session;
},
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
let id: string | undefined;
try {
const resumed = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "saved-thread" },
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
);
id = resumed.id;
expect(resumed.lifecycle).toBe(completedDuringResume ? "idle" : "running");
expect(manager.getAgent(id)?.lifecycle).toBe(completedDuringResume ? "idle" : "running");
} finally {
if (id) await manager.closeAgent(id);
await session.close();
appServer.assertNoErrors();
}
},
);
});
@@ -3453,6 +3453,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private client: CodexAppServerClient | null = null;
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
@@ -3571,6 +3572,8 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true;
// Resuming can start a goal before the caller receives the session.
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
}
}
@@ -4543,6 +4546,9 @@ export class CodexAppServerAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
const startupEvents = this.startupEvents;
this.startupEvents = null;
for (const event of startupEvents ?? []) this.notifySubscribers(event);
return () => {
this.subscribers.delete(callback);
};
@@ -5028,6 +5034,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.clearPendingPermissions();
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.startupEvents = null;
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
@@ -5375,6 +5382,10 @@ export class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
if (this.startupEvents) {
this.startupEvents.push(tagged);
return;
}
this.logger.trace(
{
agentId: this.agentId,
@@ -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,57 +0,0 @@
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import type { SessionOutboundMessage } from "../messages.js";
test("deleting an agent removes it from every connected agent subscription", async () => {
const ctx = await createDaemonTestContext();
const observer = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` });
const updates: SessionOutboundMessage[] = [];
const requestorUpdates: SessionOutboundMessage[] = [];
try {
await observer.connect();
const agent = await ctx.client.createAgent({
provider: "codex",
cwd: ctx.daemon.staticDir,
title: "Cross-client deletion",
});
const snapshot = await observer.fetchAgents({ subscribe: {} });
expect(snapshot.entries.some((entry) => entry.agent.id === agent.id)).toBe(true);
observer.subscribeRawMessages((message) => updates.push(message));
ctx.client.subscribeRawMessages((message) => requestorUpdates.push(message));
await ctx.client.deleteAgent(agent.id);
// The record is gone, and a fresh query sees that. An existing subscriber
// must learn the same fact without reconnecting or fetching another list.
const afterDelete = await ctx.client.fetchAgents();
expect(afterDelete.entries.some((entry) => entry.agent.id === agent.id)).toBe(false);
expect(
requestorUpdates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
).toBe(true);
await expect(observer.fetchAgentTimeline(agent.id, { direction: "tail" })).rejects.toThrow(
"Agent not found",
);
await expect
.poll(
() =>
updates.some(
(message) =>
message.type === "agent_update" &&
message.payload.kind === "remove" &&
message.payload.agentId === agent.id,
),
{ timeout: 2_000 },
)
.toBe(true);
} finally {
await observer.close();
await ctx.cleanup();
}
});
@@ -534,6 +534,25 @@ export type Value = string;`,
expect(clientBundle).not.toContain("React.createElement");
});
it("imports an SVG file from a runtime directory as its markup", async () => {
const entries = await createSplitPlugin();
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
await writeFile(path.join(entries.directory, "client", "icon.svg"), svg);
await writeFile(
entries.client,
`import icon from "./client/icon.svg";
export default function contribute(client) {
client.addCommandCenterItem({ id: "open", title: "Open", icon: { svg: icon }, context: "global", onSelect() {} });
return () => undefined;
}`,
);
const { clientBundle } = await compilePlugin(entries);
const registered: unknown[] = [];
const exports = new Function(`return ${clientBundle}`)()(() => undefined);
exports.default({ addCommandCenterItem: (item: unknown) => registered.push(item) });
expect(registered).toMatchObject([{ icon: { svg } }]);
});
it("lowers async callbacks before Hermes evaluates the client bundle", async () => {
const entries = await createSplitPlugin();
await writeFile(
@@ -393,6 +393,8 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
bundle: true,
format: "cjs",
jsx: "automatic",
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
loader: { ".svg": "text" },
platform: target === "server" ? "node" : "neutral",
target: target === "server" ? "node20" : "es2020",
// The neutral platform reads no package.json entry fields, so packages without
@@ -29,18 +29,14 @@ describe("readPluginProviderIcon", () => {
});
test.each([
["non-SVG document", "hello", "file is not an SVG document"],
["script", "<svg><script /></svg>", "script elements"],
["foreign object", "<svg><foreignObject /></svg>", "foreignObject elements"],
["style", "<svg><style /></svg>", "style elements"],
["event handler", '<svg><path onclick="run()" /></svg>', "event-handler attributes"],
["javascript URL", '<svg><a href="javascript:run()" /></svg>', "javascript URLs"],
["non-SVG document", "hello", "not an SVG document"],
["script", "<svg><script /></svg>", "script elements are not allowed"],
["external href", '<svg><use href="https://example.com/icon.svg" /></svg>', "external href"],
])("rejects %s", async (_name, svg, reason) => {
])("rejects %s with the shared SVG rules", async (_name, svg, reason) => {
const directory = await writeIcon(svg);
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
`icon.svg": ${reason}`,
`Invalid plugin provider icon "icon.svg": ${reason}`,
);
});
@@ -78,7 +74,7 @@ describe("readPluginProviderIcon", () => {
const directory = await writeIcon(`<svg>${" ".repeat(64 * 1024)}</svg>`);
await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow(
"file exceeds 64 KiB",
`Invalid plugin provider icon "icon.svg": exceeds 64 KiB`,
);
});
});
@@ -1,7 +1,6 @@
import { readFile, realpath, stat } from "node:fs/promises";
import path from "node:path";
const MAX_ICON_BYTES = 64 * 1024;
import { findSvgIconProblem } from "@getpaseo/plugin";
export async function readPluginProviderIcon(
pluginDirectory: string,
@@ -20,10 +19,10 @@ export async function readPluginProviderIcon(
if (!iconStat.isFile()) {
throw iconError(iconPath, "file does not exist or is not a regular file");
}
if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB");
const svg = await readFile(resolvedIcon, "utf8");
validateSvg(iconPath, svg);
const problem = findSvgIconProblem(svg);
if (problem) throw iconError(iconPath, problem);
return svg;
}
@@ -38,27 +37,6 @@ function assertInsidePluginDirectory(
if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory");
}
function validateSvg(iconPath: string, svg: string): void {
if (!/^\s*<svg(?:\s|>)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document");
if (/<script(?:\s|>)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed");
if (/<foreignObject(?:\s|>)/i.test(svg)) {
throw iconError(iconPath, "foreignObject elements are not allowed");
}
if (/<style(?:\s|>)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed");
if (/\son[a-z0-9_-]*\s*=/i.test(svg)) {
throw iconError(iconPath, "event-handler attributes are not allowed");
}
if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed");
const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi;
for (const match of svg.matchAll(hrefPattern)) {
const href = match[2] ?? match[3] ?? "";
if (!href.startsWith("#")) {
throw iconError(iconPath, "external href references are not allowed");
}
}
}
function iconError(iconPath: string, reason: string): Error {
return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`);
}
@@ -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;
@@ -0,0 +1,61 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const run = promisify(execFile);
// Opt in on Linux with passwordless sudo, unshare, ip, mount, and tc available:
// RUN_RELAY_LATENCY_E2E=1 npx vitest run src/server/relay-transport.latency.e2e.test.ts
// All networking and the hosts-file mount are private to the child namespaces.
test.runIf(process.platform === "linux" && process.env.RUN_RELAY_LATENCY_E2E === "1")(
"connects relay control and data over a slow dual-stack route with an unreachable address",
async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-latency-"));
try {
const hosts = path.join(directory, "hosts");
await writeFile(
hosts,
`${await readFile("/etc/hosts", "utf8")}\n127.0.0.1 relay-latency.test\n2001:db8:1::1 relay-latency.test\n`,
);
const fixture = fileURLToPath(
new URL("./test-utils/relay-latency-fixture.ts", import.meta.url),
);
for (const order of ["ipv4first", "ipv6first"]) {
const { stdout } = await run(
"sudo",
[
"-n",
"unshare",
"--mount",
"--net",
"sh",
"-ec",
`mount --make-rprivate /
mount --bind "$1" /etc/hosts
ip link set lo up
ip link add relay-test type dummy
ip addr add 192.0.2.2/32 dev relay-test
ip -6 addr add 2001:db8::2/128 dev relay-test
ip link set relay-test up
tc qdisc add dev lo root netem delay 350ms
exec "$2" --import tsx "$3" "$4"`,
"sh",
hosts,
process.execPath,
fixture,
order,
],
{ timeout: 20_000 },
);
expect(stdout).toContain("relay data round trip passed");
}
} finally {
await rm(directory, { recursive: true, force: true });
}
},
45_000,
);
@@ -56,7 +56,13 @@ type ControlMessage =
const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000;
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
const RELAY_WEBSOCKET_OPTIONS = {
handshakeTimeout: 10_000,
perMessageDeflate: false,
// Node cancels each non-final address when this expires, even if the next
// address is unreachable. Allow high-latency routes time to connect.
autoSelectFamilyAttemptTimeout: 5_000,
} as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import dns from "node:dns";
import { once } from "node:events";
import net from "node:net";
import { Writable } from "node:stream";
import pino from "pino";
import { WebSocketServer } from "ws";
import { startRelayTransport } from "../relay-transport.js";
// Match the reported runtime even on Node versions with a newer default.
net.setDefaultAutoSelectFamilyAttemptTimeout(250);
const order = process.argv[2];
assert.ok(order === "ipv4first" || order === "ipv6first");
dns.setDefaultResultOrder(order);
const logs: string[] = [];
const logger = pino(
{ level: "debug" },
new Writable({
write(chunk, _encoding, callback) {
logs.push(chunk.toString());
callback();
},
}),
);
const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(relay, "listening");
const address = relay.address();
assert.ok(address && typeof address !== "string");
relay.on("connection", (socket, request) => {
const url = new URL(request.url!, "http://relay-latency.test");
if (url.searchParams.has("connectionId")) {
socket.on("message", (data) => socket.send(data));
} else {
socket.send(JSON.stringify({ type: "sync", connectionIds: ["latency-client"] }));
}
});
let complete!: () => void;
let fail!: (error: Error) => void;
const delivered = new Promise<void>((resolve, reject) => {
complete = resolve;
fail = reject;
});
const deadline = setTimeout(() => fail(new Error(logs.join(""))), 8_000);
const transport = startRelayTransport({
logger,
relayEndpoint: `relay-latency.test:${address.port}`,
relayUseTls: false,
serverId: "latency-server",
attachSocket: async (socket) => {
socket.on("message", (data) => {
try {
assert.equal(String(data), "latency round trip");
complete();
} catch (error) {
fail(error as Error);
}
});
socket.send("latency round trip");
},
});
try {
await delivered;
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_control_connected"));
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_data_connected"));
assert.ok(!logs.some((line) => JSON.parse(line).msg === "relay_error"));
console.log("relay data round trip passed");
} finally {
clearTimeout(deadline);
await transport.stop();
for (const socket of relay.clients) socket.terminate();
await new Promise<void>((resolve) => relay.close(() => resolve()));
}
@@ -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);
}
+26
View File
@@ -0,0 +1,26 @@
import { expect, test, type Page } from "playwright/test";
test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => {
await openSupportedAgents(page);
await expectGenericAgentMark(page, "CodeWhale");
await expectVendoredAgentMark(page, "Hermes Agent");
});
async function openSupportedAgents(page: Page) {
await page.goto("/agents");
await expect(page.getByRole("heading", { level: 1, name: "Supported agents" })).toBeVisible();
}
async function expectGenericAgentMark(page: Page, name: string) {
const card = page.getByRole("link", { name, exact: true });
await expect(card).toBeVisible();
// Decorative SVGs have no accessible role; the fallback is Lucide's bot mark.
await expect(card.locator("svg.lucide-bot")).toBeVisible();
}
async function expectVendoredAgentMark(page: Page, name: string) {
const card = page.getByRole("link", { name, exact: true });
await expect(card).toBeVisible();
await expect(card.locator("svg")).toBeVisible();
await expect(card.locator("svg.lucide-bot")).toHaveCount(0);
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
/** Inline SVG icon. `currentColor` follows the surface's icon color. */
export const counterIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="4" />
<path d="M7.5 8v8M10.5 8v8M13.5 8v8M16.5 8v8M6 15l12-6" />
</svg>`;
@@ -1,11 +1,12 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { counterIcon } from "./client/icons";
import { contributeClient, ExamplePanel } from "./client/main";
export default function contribute(client: PluginClientContext) {
client.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
icon: { svg: counterIcon },
context: "workspace",
locations: ["workspace", "explorer"],
Component: ExamplePanel,
@@ -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"];
+28 -4
View File
@@ -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
+2 -1
View File
@@ -222,7 +222,8 @@ client.addCommandCenterItem({
});
```
`icon` is a [Lucide](https://lucide.dev/icons/) icon name.
`icon` is a [Lucide](https://lucide.dev/icons/) icon name, inline SVG, or a component. See
[Icons](/docs/plugins/reference#icons).
## Edit and reload

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