Compare commits

..
Author SHA1 Message Date
an 96020ab773 fix(terminal): forward native touch gestures to mouse-aware apps
Fullscreen applications request wheel reports, while native touch drags
previously moved only local scrollback. Route gestures through the
existing terminal input path and preserve the requested modes on replay.

Co-authored-by: an <4361526@naver.com>
2026-10-24 04:52:34 +02:00
38 changed files with 684 additions and 525 deletions
+4 -11
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.13.0 - 2026-10-24
## 0.13.0-beta.2 - 2026-10-23
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
### Improved
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### Fixed
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
+4 -11
View File
@@ -1,6 +1,6 @@
# 更新日志
## 0.13.0 - 2026-10-24
## 0.13.0-beta.2 - 2026-10-23
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
### 改进
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
### 修复
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
@@ -1,15 +0,0 @@
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
Connect your daemon to another machine, then ask an agent to delegate work there:
`sh
paseo host add 192.168.1.20:6767 --label studio
paseo host add 'ssh://me@build-box' --label build-box
paseo host add 'relay://...' --label laptop
`
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -35695,7 +35695,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37961,12 +37961,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/server": "0.13.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -38213,10 +38213,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -38228,7 +38228,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38472,7 +38472,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -42124,7 +42124,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -42153,15 +42153,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -42171,8 +42171,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -42194,7 +42194,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -42221,7 +42221,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -42442,16 +42442,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/highlight": "0.13.0-beta.2",
"@getpaseo/plugin": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -43372,7 +43372,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -44982,7 +44982,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -43,8 +43,6 @@ import {
} from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import { expectAppRoute } from "../support/helpers/route-assertions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function openWorkspace(
page: import("@playwright/test").Page,
@@ -55,74 +53,6 @@ async function openWorkspace(
}
test.describe("Settings sidebar navigation", () => {
test("opening Settings closes the composer model selector until it is opened again", async ({
page,
withWorkspace,
}, testInfo) => {
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
});
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
page,
}, testInfo) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-existing-model-",
title: "Settings model selector",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
const popover = page.getByTestId("combobox-desktop-container");
await selector.click();
await expect(popover).toBeVisible();
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+,`);
await expect(page).toHaveURL(/\/settings\/general$/);
await page.screenshot({
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
});
await expect(popover).toHaveCount(0);
await clickSettingsBackToWorkspace(page);
await expect(selector).toBeVisible();
await expect(popover).toHaveCount(0);
await selector.click();
await expect(popover).toBeVisible();
await page.keyboard.press("Escape");
await expect(popover).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
@@ -217,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "settings-tab-switcher-",
title: "Compact tab switcher",
});
try {
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
await switcher.click();
await expect(newAgent).toBeInViewport();
// Navigate without dismissing the sheet through its backdrop first.
await page
.getByTestId("menu-button")
.filter({ visible: true })
.first()
.dispatchEvent("click");
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
await expectAppRoute(page, buildSettingsRoute());
await expect(newAgent).toHaveCount(0);
await closeCompactSettings(page);
await expect(switcher).toBeVisible();
await expect(newAgent).toHaveCount(0);
await switcher.click();
await expect(newAgent).toBeInViewport();
} finally {
await workspace.cleanup();
}
});
test("opens compact app and host details and returns through the settings list", async ({
page,
}) => {
@@ -10,16 +10,6 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
await page.keyboard.press(`${modifier}+Comma`);
}
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
await page
.getByTestId(`workspace-tab-agent_${agentId}`)
.filter({ visible: true })
.first()
.click();
await expectAgentTabActive(page, agentId);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
interface ComposerFrame {
at: number;
visible: boolean;
@@ -37,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
const sample = () => {
if (capture.stopped) return;
const root = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
).find((candidate) => candidate.offsetParent !== null);
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
if (root && input) {
const style = getComputedStyle(input);
@@ -190,8 +178,6 @@ test.describe("Settings toggle tab regression", () => {
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await waitForTabBar(page);
await openComposerTab(page, firstAgent.id);
await openComposerTab(page, secondAgent.id);
const input = page.getByRole("textbox", { name: "Message agent..." });
await input.fill("Keep this short draft in the composer");
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
@@ -208,7 +194,6 @@ test.describe("Settings toggle tab regression", () => {
const frames = await stopComposerFrameCapture(page);
const visibleFrames = frames.filter((frame) => frame.visible);
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
const tallestIndex = visibleFrames.reduce(
(index, frame, candidateIndex) =>
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -50,9 +50,9 @@ import {
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
@@ -95,6 +95,7 @@ interface NativeTerminalPanState {
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
origin: { x: number; y: number };
}
interface NativeTerminalSelectionPress {
@@ -214,6 +215,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
@@ -775,31 +777,33 @@ function NativeTerminalEmulator({
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
(gesture: PanResponderGestureState) => {
const metrics = metricsRef.current;
const viewport = resolveSelectionViewport();
if (!metrics || metrics.cellHeight <= 0 || !viewport) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
const { state, route } = routeTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
currentDy: gesture.dy,
point: { x: panState.origin.x + gesture.dx, y: panState.origin.y + gesture.dy },
metrics,
viewport,
inputMode: inputModeTrackerRef.current,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
panState.lastDy = state.lastDy;
panState.rowRemainder = state.rowRemainder;
panState.didScroll = state.didScroll;
if (route.kind === "scrollback") {
applyScroll(route.direction, route.rows);
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
if (route.kind === "input") {
callbacksRef.current.onInput?.(route.data);
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
[applyScroll, resolveSelectionViewport],
);
const panResponder = useMemo(
@@ -828,6 +832,10 @@ function NativeTerminalEmulator({
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
@@ -835,10 +843,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
origin: point,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
@@ -875,7 +880,7 @@ function NativeTerminalEmulator({
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
if (panState.didNavigate) {
@@ -903,7 +908,7 @@ function NativeTerminalEmulator({
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
handlePanResponderScroll(gesture);
return;
}
@@ -932,8 +937,9 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
const releaseAction = resolveTerminalGestureReleaseAction({
const { action: releaseAction, mouseInput } = routeTerminalGestureRelease({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
@@ -942,11 +948,18 @@ function NativeTerminalEmulator({
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
point: { x: selectionPress?.startX ?? 0, y: selectionPress?.startY ?? 0 },
metrics: metricsRef.current,
viewport: resolveSelectionViewport(),
inputMode: inputModeTrackerRef.current,
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (mouseInput !== null) {
callbacksRef.current.onInput?.(mouseInput);
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
@@ -961,6 +974,7 @@ function NativeTerminalEmulator({
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
origin: { x: 0, y: 0 },
};
},
}),
@@ -969,6 +983,7 @@ function NativeTerminalEmulator({
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
resolveSelectionViewport,
updateSelectionAtPoint,
],
);
+23 -32
View File
@@ -28,7 +28,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
}
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps {
isOpen: boolean;
bottomSheetRef: BottomSheetVisibility["sheetRef"];
snapPoints: string[];
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
@@ -941,22 +944,7 @@ interface MobileBodyProps {
safeAreaBottom: number;
}
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
if (!active) return null;
// The visibility tracker must leave the tree with its sheet.
return <MobileComboboxSheet {...props} />;
}
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: props.isOpen,
onClose: props.onClose,
});
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
const comboboxTitleStyle = useMemo(
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
return (
<IsolatedBottomSheetModal
ref={bottomSheetRef}
ref={props.bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
);
}
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
const active = useRetainedPanelActive();
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
const handleDesktopKey = props.handleDesktopKey;
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
[props.refs, setWebOverlayScope],
);
// Remove the portal before a retained chat can freeze its closing update.
if (!active) return null;
const overlay = (
<OverlayLayerProvider layer={props.overlayLayer}>
<View
@@ -1325,7 +1309,6 @@ export function Combobox({
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
const resolvedTitle = title ?? t("common.actions.select");
const isMobile = useIsCompactFormFactor();
const active = useRetainedPanelActive();
const floatingLayer = useOverlayLayer("floating");
const safeAreaInsets = useSafeAreaInsets();
const titleColor = theme.colors.foreground;
@@ -1365,10 +1348,6 @@ export function Combobox({
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useEffect(() => {
if (!active && isOpen) handleClose();
}, [active, isOpen, handleClose]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1434,6 +1413,16 @@ export function Combobox({
referenceWidth,
});
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: isOpen,
isEnabled: isMobile,
onClose: handleClose,
});
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = useMemo(
@@ -1583,8 +1572,10 @@ export function Combobox({
if (isMobile) {
return (
<MobileComboboxBody
isOpen={isOpen}
bottomSheetRef={bottomSheetRef}
snapPoints={snapPoints}
handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={resolvedTitle}
+29 -29
View File
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.36.0",
version: "0.33.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.73",
version: "3.0.46",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.73", "--acp"],
command: ["npx", "-y", "cline@3.0.46", "--acp"],
},
{
id: "codebuddy-code",
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.36",
version: "0.1.20",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.36"],
command: ["npx", "-y", "deepagents-acp@0.1.20"],
},
{
id: "devin",
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.5.21",
version: "0.2.36",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.5.21", "acp"],
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.5.23",
version: "0.4.22",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.249.0",
version: "0.179.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.10.47",
version: "0.9.22",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.65.0",
version: "0.52.0",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
},
{
id: "gjc",
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
id: "glm-acp-agent",
title: "GLM Agent",
description:
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.15.0",
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.3.0",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.15.0"],
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
},
{
id: "goose",
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
id: "minimax-code",
title: "MiniMax Code",
description: "MiniMax's coding agent for the terminal",
version: "0.6.13",
version: "0.1.2",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
},
{
id: "minion-code",
title: "Minion Code",
description:
"An enhanced AI code assistant built on the Minion framework with rich development tools",
version: "0.1.45",
version: "0.1.44",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
},
{
id: "mistral-vibe",
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.59",
version: "1.1.29",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
},
{
id: "poolside",
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.1.78",
version: "1.1.4",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.26.0",
version: "0.20.1",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -61,8 +61,8 @@ describe("ACP provider catalog", () => {
it("offers MiniMax Code through its pinned public ACP package", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
version: "0.1.2",
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
-16
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
@@ -706,21 +705,6 @@ describe("evaluatePluginClientBundle", () => {
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
});
it("keeps the host reveal hook on the deprecated client/react-native entry", () => {
const plugin = evaluatePluginClientBundle(
"legacy-reveal",
bundle(`
const legacy = require("@getpaseo/plugin/client/react-native");
const ui = require("@getpaseo/plugin/client/ui");
if (typeof legacy.useRevealedText !== "function") throw new Error("Missing legacy reveal hook");
if (legacy.StreamedText !== ui.StreamedText) throw new Error("Missing current UI exports");
if ("useRevealedText" in ui) throw new Error("Legacy hook leaked into current UI");
plugin.addScreen({ id: "reveal", title: "Reveal", Component: legacy.useRevealedText });
`),
);
expect(plugin.surfaces[0]?.Component).toBe(useRevealedText);
});
it("keeps shared and client runtime exports separate", () => {
expect(() =>
evaluatePluginClientBundle(
+1 -6
View File
@@ -2,7 +2,6 @@ import { requirePluginText } from "@getpaseo/plugin/client/host";
import type { createPluginHosts } from "./hosts";
import { openExternalUrl } from "@/utils/open-external-url";
import * as pluginUiRuntime from "./react-native/ui";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useSettings } from "./settings/use-settings";
import * as pluginSharedRuntime from "@getpaseo/plugin";
import * as pluginClientRuntime from "@getpaseo/plugin/client";
@@ -115,10 +114,6 @@ export type PluginClientRuntime = Pick<
| "addHeaderButton"
> & { hosts: ReturnType<typeof createPluginHosts> };
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
// The 0.12 entry exposed the reveal hook as well as UI components.
const pluginReactNativeCompatRuntime = { ...pluginUiRuntime, useRevealedText };
export function runPluginClientBundle(
id: string,
bundle: string,
@@ -508,7 +503,7 @@ export function runPluginClientBundle(
};
const runtimeRequire = (name: string): unknown => {
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
if (name === "@getpaseo/plugin/client/react-native") return pluginReactNativeCompatRuntime;
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
@@ -7,8 +8,64 @@ import {
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
routeTerminalGestureRelease,
routeTerminalScrollGesture,
type TerminalGestureReleaseRouteInput,
type TerminalScrollGestureRoute,
} from "./terminal-selection-gesture";
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
function trackerFromOutput(output: string): TerminalInputModeTracker {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
return tracker;
}
function dragRoutes(input: {
inputMode: TerminalInputModeTracker;
dys: number[];
point?: { x: number; y: number };
}): TerminalScrollGestureRoute[] {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const routes: TerminalScrollGestureRoute[] = [];
for (const currentDy of input.dys) {
const update = routeTerminalScrollGesture({
state,
currentDy,
point: input.point ?? { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: input.inputMode,
});
state = update.state;
routes.push(update.route);
}
return routes;
}
function tapRelease(
overrides: Partial<TerminalGestureReleaseRouteInput> = {},
): ReturnType<typeof routeTerminalGestureRelease> {
return routeTerminalGestureRelease({
status: "pressing",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
point: { x: 25, y: 50 },
metrics: { cellWidth: 8, cellHeight: 14 },
viewport: { rows: 30, cols: 40 },
inputMode: trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE),
...overrides,
});
}
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
@@ -351,4 +408,143 @@ describe("native terminal gesture policy", () => {
).toEqual("none");
});
});
describe("routeTerminalScrollGesture", () => {
it("sends SGR wheel reports at the touched cell while a fullscreen app tracks the mouse", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [13, 28, 42, 14] })).toEqual([
{ kind: "none" },
{ kind: "input", data: "\x1b[<64;4;4M\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<64;4;4M" },
{ kind: "input", data: "\x1b[<65;4;4M\x1b[<65;4;4M" },
]);
});
it("keeps ordinary shell drags in local scrollback", () => {
const inputMode = trackerFromOutput("\x1b[?2004h$ ls\r\n");
expect(dragRoutes({ inputMode, dys: [13, 28, 14] })).toEqual([
{ kind: "none" },
{ kind: "scrollback", direction: "up", rows: 2 },
{ kind: "scrollback", direction: "down", rows: 1 },
]);
});
it("returns drags to local scrollback after the app disables mouse tracking", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
inputMode.feed(PI_FULLSCREEN_MOUSE_DISABLE);
expect(dragRoutes({ inputMode, dys: [28] })).toEqual([
{ kind: "scrollback", direction: "up", rows: 2 },
]);
});
it("keeps forwarding wheel input after a reconnect replays the daemon preamble", () => {
const daemon = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
const client = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
client.feed(daemon.getPreamble());
expect(dragRoutes({ inputMode: client, dys: [14] })).toEqual([
{ kind: "input", data: "\x1b[<64;4;4M" },
]);
});
it("keeps pixel reports integral at fractional cell boundaries", () => {
const inputMode = trackerFromOutput("\x1b[?1003h\x1b[?1016h");
const { route } = routeTerminalScrollGesture({
state: { lastDy: 0, rowRemainder: 0, didScroll: false },
currentDy: 15,
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
inputMode,
});
expect(route).toEqual({ kind: "input", data: "\x1b[<64;25;43M" });
});
it("clamps wheel reports to the visible grid", () => {
const inputMode = trackerFromOutput(PI_FULLSCREEN_MOUSE_ENABLE);
expect(dragRoutes({ inputMode, dys: [-14], point: { x: 999, y: -5 } })).toEqual([
{ kind: "input", data: "\x1b[<65;40;1M" },
]);
});
});
describe("routeTerminalGestureRelease", () => {
it("clicks the tapped cell and still focuses while a fullscreen app tracks the mouse", () => {
expect(tapRelease()).toEqual({
action: "focus",
mouseInput: "\x1b[<0;4;4M\x1b[<0;4;4m",
});
});
it("only focuses on an ordinary shell tap", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?2004h$ ls\r\n") })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("sends only the press for X10 tracking", () => {
expect(tapRelease({ inputMode: trackerFromOutput("\x1b[?9h") })).toEqual({
action: "focus",
mouseInput: "\x1b[M $$",
});
});
it("keeps a long press as local selection without clicking", () => {
expect(tapRelease({ pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50 })).toEqual({
action: "select",
mouseInput: null,
});
});
it.each([
["moved beyond tap tolerance", { movedBeyondTapTolerance: true }],
["scrolled", { didScroll: true }],
])("never clicks after a drag that %s", (_name, overrides) => {
expect(tapRelease(overrides)).toEqual({ action: "none", mouseInput: null });
});
it("clears an existing selection without clicking", () => {
expect(tapRelease({ status: "selecting", startedWithSelection: true })).toEqual({
action: "clear",
mouseInput: null,
});
});
it("does not send live mouse input from a scrolled-back viewport", () => {
expect(tapRelease({ scrollMode: "scrolled" })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("does not click before the grid is measured", () => {
expect(tapRelease({ metrics: null, viewport: null })).toEqual({
action: "focus",
mouseInput: null,
});
});
it("clamps clicks to the visible grid", () => {
expect(tapRelease({ point: { x: 999, y: -5 } })).toEqual({
action: "focus",
mouseInput: "\x1b[<0;40;1M\x1b[<0;40;1m",
});
});
it("keeps pixel clicks integral at fractional cell boundaries", () => {
expect(
tapRelease({
inputMode: trackerFromOutput("\x1b[?1000h\x1b[?1016h"),
point: { x: 999, y: 999 },
metrics: { cellWidth: 8.25, cellHeight: 14.25 },
viewport: { rows: 3, cols: 3 },
}),
).toEqual({ action: "focus", mouseInput: "\x1b[<0;25;43M\x1b[<0;25;43m" });
});
});
});
@@ -1,3 +1,9 @@
import type {
TerminalInputModeTracker,
TerminalMousePosition,
} from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
@@ -33,8 +39,39 @@ export interface TerminalScrollGestureUpdate {
rowDelta: number;
}
export type TerminalScrollGestureRoute =
| { kind: "none" }
| { kind: "scrollback"; direction: "up" | "down"; rows: number }
| { kind: "input"; data: string };
export interface TerminalScrollGestureRouteInput {
state: TerminalScrollGestureState;
currentDy: number;
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
inputMode: Pick<TerminalInputModeTracker, "encodeWheel">;
}
export interface TerminalScrollGestureRouteUpdate {
state: TerminalScrollGestureState;
route: TerminalScrollGestureRoute;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export interface TerminalGestureReleaseRouteInput extends TerminalGestureReleaseInput {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics | null;
viewport: { rows: number; cols: number } | null;
inputMode: Pick<TerminalInputModeTracker, "encodeLeftClick">;
}
export interface TerminalGestureReleaseRoute {
action: TerminalGestureAction;
mouseInput: string | null;
}
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
@@ -78,6 +115,60 @@ export function advanceTerminalScrollGesture(input: {
};
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Clamps a touch point to the visible grid; pixel reports stay integral at fractional cell sizes. */
function resolveTerminalMousePosition(input: {
point: { x: number; y: number };
metrics: TerminalGridCellMetrics;
viewport: { rows: number; cols: number };
}): TerminalMousePosition {
const { cellWidth, cellHeight } = input.metrics;
const pixelX = clamp(
Math.floor(input.point.x),
0,
Math.ceil(input.viewport.cols * cellWidth) - 1,
);
const pixelY = clamp(
Math.floor(input.point.y),
0,
Math.ceil(input.viewport.rows * cellHeight) - 1,
);
return {
col: Math.floor(pixelX / cellWidth) + 1,
row: Math.floor(pixelY / cellHeight) + 1,
pixelX: pixelX + 1,
pixelY: pixelY + 1,
};
}
/** A drag scrolls local scrollback unless the foreground app asked for wheel reports. */
export function routeTerminalScrollGesture(
input: TerminalScrollGestureRouteInput,
): TerminalScrollGestureRouteUpdate {
const { state, rowDelta } = advanceTerminalScrollGesture({
state: input.state,
currentDy: input.currentDy,
cellHeight: input.metrics.cellHeight,
});
if (rowDelta === 0) {
return { state, route: { kind: "none" } };
}
const direction = rowDelta > 0 ? "up" : "down";
const rows = Math.abs(rowDelta);
const report = input.inputMode.encodeWheel({
direction,
...resolveTerminalMousePosition(input),
});
if (report === null) {
return { state, route: { kind: "scrollback", direction, rows } };
}
return { state, route: { kind: "input", data: report.repeat(rows) } };
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
@@ -106,3 +197,19 @@ export function resolveTerminalGestureReleaseAction(
return "focus";
}
/**
* Only a short tap on the live screen clicks; long press, drags and selection clears stay local.
* The caller sends the click before focusing, so the keyboard resize cannot move the target cell.
*/
export function routeTerminalGestureRelease(
input: TerminalGestureReleaseRouteInput,
): TerminalGestureReleaseRoute {
const action = resolveTerminalGestureReleaseAction(input);
const { metrics, viewport } = input;
if (action !== "focus" || input.scrollMode !== "following" || !metrics || !viewport) {
return { action, mouseInput: null };
}
const position = resolveTerminalMousePosition({ point: input.point, metrics, viewport });
return { action, mouseInput: input.inputMode.encodeLeftClick(position) };
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/server": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/server": "0.13.0-beta.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+6 -23
View File
@@ -101,29 +101,12 @@ describe("plugin SDK import boundaries", () => {
);
});
it.each(compatibilityEntries)("%s respects the client boundary", (entry) => {
expect(
boundaryViolations(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"client",
),
).toEqual([]);
});
it("typechecks a 0.12 reveal-hook consumer without exposing the hook on current UI", () => {
expect(
typeErrors(`
import { useRevealedText, Icon } from "@getpaseo/plugin/client/react-native";
import { StreamedText } from "@getpaseo/plugin/client/ui";
// @ts-expect-error the legacy hook is not part of the current UI API
import { useRevealedText as currentHook } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
function LegacyRow({ text, phase }: { text: string; phase: "streaming" | "complete" }) {
const revealed: string = useRevealedText(text, phase);
return <Text>{revealed}</Text>;
}
`),
).toEqual([]);
it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => {
const source = readFileSync(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"utf8",
);
expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";');
});
it.each(Object.entries(entries))(
@@ -1,6 +1,3 @@
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
export * from "./ui.js";
/** @deprecated Render StreamedText from @getpaseo/plugin/client/ui. Removed after 2027-04-20 with this entry. */
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -118,6 +118,111 @@ describe("TerminalInputModeTracker", () => {
expect(tracker.getPreamble()).toBe("");
});
const PI_FULLSCREEN_MOUSE_ENABLE =
"\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
const PI_FULLSCREEN_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
const wheelUpAt3x4 = { direction: "up" as const, col: 3, row: 4, pixelX: 30, pixelY: 80 };
it("encodes SGR wheel reports while a fullscreen app tracks the mouse", () => {
const tracker = new TerminalInputModeTracker();
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, direction: "down" })).toBe("\x1b[<65;3;4M");
});
it("stops encoding wheel reports once the app disables mouse tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed(PI_FULLSCREEN_MOUSE_ENABLE);
expect(tracker.feed(PI_FULLSCREEN_MOUSE_DISABLE).changed).toBe(true);
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(tracker.getPreamble()).toBe("");
});
it("replays mouse tracking so a reconnected client keeps forwarding wheel input", () => {
const daemon = new TerminalInputModeTracker();
daemon.feed(PI_FULLSCREEN_MOUSE_ENABLE);
const client = new TerminalInputModeTracker();
client.feed(PI_FULLSCREEN_MOUSE_ENABLE);
client.reset();
expect(client.encodeWheel(wheelUpAt3x4)).toBeNull();
expect(daemon.getPreamble()).toBe("\x1b[?1003h\x1b[?1006h");
client.feed(daemon.getPreamble());
expect(client.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;3;4M");
});
it("encodes SGR pixel wheel reports with pixel coordinates", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h\x1b[?1016h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[<64;30;80M");
expect(tracker.getPreamble()).toBe("\x1b[?1000h\x1b[?1016h");
});
it("encodes default wheel reports only while every byte stays ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBe("\x1b[M`#$");
expect(tracker.encodeWheel({ ...wheelUpAt3x4, col: 96 })).toBeNull();
expect(tracker.encodeWheel({ ...wheelUpAt3x4, row: 96 })).toBeNull();
});
it("does not encode wheel reports for X10 press-only tracking", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?9h\x1b[?1006h");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
});
const tapAt3x4 = { col: 3, row: 4, pixelX: 30, pixelY: 80 };
it.each([
["no tracking", "", null],
["SGR press/release", PI_FULLSCREEN_MOUSE_ENABLE, "\x1b[<0;3;4M\x1b[<0;3;4m"],
["SGR pixel press/release", "\x1b[?1000h\x1b[?1016h", "\x1b[<0;30;80M\x1b[<0;30;80m"],
["default press and button-3 release", "\x1b[?1000h", "\x1b[M #$\x1b[M##$"],
["X10 default press only", "\x1b[?9h", "\x1b[M #$"],
["X10 SGR press only", "\x1b[?9h\x1b[?1006h", "\x1b[<0;3;4M"],
[
"tracking disabled again",
`${PI_FULLSCREEN_MOUSE_ENABLE}${PI_FULLSCREEN_MOUSE_DISABLE}`,
null,
],
])("encodes a left click with %s", (_name, output, expected) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(output);
expect(tracker.encodeLeftClick(tapAt3x4)).toBe(expected);
});
it("does not encode a default left click that leaves ASCII", () => {
const tracker = new TerminalInputModeTracker();
tracker.feed("\x1b[?1000h");
expect(tracker.encodeLeftClick({ ...tapAt3x4, col: 96 })).toBeNull();
});
it.each([["\x1bc"], ["\x1b", "c"]])(
"clears every tracked mode on a full reset split as %j",
(...chunks) => {
const tracker = new TerminalInputModeTracker();
tracker.feed(`\x1b[>7u\x1b[?2004h${PI_FULLSCREEN_MOUSE_ENABLE}`);
const results = chunks.map((chunk) => tracker.feed(chunk));
expect(results.at(-1)).toEqual({ changed: true, responses: [] });
expect(tracker.getPreamble()).toBe("");
expect(tracker.encodeWheel(wheelUpAt3x4)).toBeNull();
},
);
it("ignores encoded key input sequences", () => {
const tracker = new TerminalInputModeTracker();
+107 -7
View File
@@ -10,6 +10,17 @@ export interface TerminalInputModeState {
bracketedPaste?: boolean;
}
export interface TerminalMousePosition {
col: number;
row: number;
pixelX: number;
pixelY: number;
}
export interface TerminalWheelInput extends TerminalMousePosition {
direction: "up" | "down";
}
export const DEFAULT_TERMINAL_INPUT_MODE_STATE: TerminalInputModeState = {
kittyKeyboardFlags: 0,
win32InputMode: false,
@@ -21,8 +32,20 @@ const ESC = String.fromCharCode(0x1b);
const APPLICATION_CURSOR_KEYS_MODE = 1;
const WIN32_INPUT_MODE = 9001;
const BRACKETED_PASTE_MODE = 2004;
const CSI_INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl]))`,
const X10_MOUSE_TRACKING_MODE = 9;
const MOUSE_TRACKING_MODES = new Set([X10_MOUSE_TRACKING_MODE, 1000, 1002, 1003]);
const SGR_MOUSE_ENCODING_MODE = 1006;
const SGR_PIXELS_MOUSE_ENCODING_MODE = 1016;
const MOUSE_ENCODING_MODES = new Set([SGR_MOUSE_ENCODING_MODE, SGR_PIXELS_MOUSE_ENCODING_MODE]);
const LEFT_BUTTON = 0;
const DEFAULT_RELEASE_BUTTON = 3;
const WHEEL_UP_BUTTON = 64;
const WHEEL_DOWN_BUTTON = 65;
const DEFAULT_MOUSE_ENCODING_OFFSET = 32;
const MAX_ASCII_CODE = 127;
const FULL_RESET = `${ESC}c`;
const INPUT_MODE_SEQUENCE = new RegExp(
`${ESC}(?:c|\\[(?:([<>=?]?)([0-9;]*)u|\\?([0-9;]*)([hl])))`,
"g",
);
const INCOMPLETE_CSI_INPUT_MODE_SEQUENCE = new RegExp(`${ESC}\\[[<>=?]?[0-9;]*$`);
@@ -74,6 +97,8 @@ export class TerminalInputModeTracker {
private win32InputMode = false;
private applicationCursorKeys = false;
private bracketedPaste = false;
private mouseTrackingMode = 0;
private mouseEncodingMode = 0;
private readonly kittyKeyboardStack: number[] = [];
private pending = "";
@@ -89,13 +114,19 @@ export class TerminalInputModeTracker {
const responses: string[] = [];
let consumedUntil = 0;
CSI_INPUT_MODE_SEQUENCE.lastIndex = 0;
INPUT_MODE_SEQUENCE.lastIndex = 0;
for (;;) {
const match = CSI_INPUT_MODE_SEQUENCE.exec(text);
const match = INPUT_MODE_SEQUENCE.exec(text);
if (!match) {
break;
}
consumedUntil = CSI_INPUT_MODE_SEQUENCE.lastIndex;
consumedUntil = INPUT_MODE_SEQUENCE.lastIndex;
if (match[0] === FULL_RESET) {
changed = this.getPreamble().length > 0 || changed;
this.reset();
continue;
}
if (match[4]) {
changed = this.applyPrivateModeSequence(match[3] ?? "", match[4]) || changed;
@@ -108,10 +139,10 @@ export class TerminalInputModeTracker {
}
const tail = text.slice(consumedUntil);
const pendingStart = tail.lastIndexOf(`${ESC}[`);
const pendingStart = tail.lastIndexOf(ESC);
if (pendingStart >= 0) {
const pending = tail.slice(pendingStart);
if (INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
if (pending === ESC || INCOMPLETE_CSI_INPUT_MODE_SEQUENCE.test(pending)) {
this.pending = pending;
}
}
@@ -124,6 +155,8 @@ export class TerminalInputModeTracker {
this.win32InputMode = false;
this.applicationCursorKeys = false;
this.bracketedPaste = false;
this.mouseTrackingMode = 0;
this.mouseEncodingMode = 0;
this.kittyKeyboardStack.length = 0;
this.pending = "";
}
@@ -159,9 +192,64 @@ export class TerminalInputModeTracker {
if (this.bracketedPaste) {
parts.push("\x1b[?2004h");
}
// The alternate screen is never replayed here: re-entering it would clear the restored screen.
if (this.mouseTrackingMode > 0) {
parts.push(`\x1b[?${this.mouseTrackingMode}h`);
}
if (this.mouseEncodingMode > 0) {
parts.push(`\x1b[?${this.mouseEncodingMode}h`);
}
return parts.join("");
}
/** Returns the wheel report the foreground app requested, or null when it does not track the wheel. */
encodeWheel(input: TerminalWheelInput): string | null {
const isWheelReported =
this.mouseTrackingMode > 0 && this.mouseTrackingMode !== X10_MOUSE_TRACKING_MODE;
if (!isWheelReported) {
return null;
}
const button = input.direction === "up" ? WHEEL_UP_BUTTON : WHEEL_DOWN_BUTTON;
return this.encodeMouseReport(button, input, "M");
}
/** Returns a left press and release, press only under X10, or null when the app does not track the mouse. */
encodeLeftClick(position: TerminalMousePosition): string | null {
if (this.mouseTrackingMode === 0) {
return null;
}
const press = this.encodeMouseReport(LEFT_BUTTON, position, "M");
if (press === null || this.mouseTrackingMode === X10_MOUSE_TRACKING_MODE) {
return press;
}
const isSgr = this.mouseEncodingMode !== 0;
const release = isSgr
? this.encodeMouseReport(LEFT_BUTTON, position, "m")
: this.encodeMouseReport(DEFAULT_RELEASE_BUTTON, position, "M");
return release === null ? null : `${press}${release}`;
}
private encodeMouseReport(
button: number,
position: TerminalMousePosition,
sgrFinal: "M" | "m",
): string | null {
if (this.mouseEncodingMode === SGR_PIXELS_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.pixelX};${position.pixelY}${sgrFinal}`;
}
if (this.mouseEncodingMode === SGR_MOUSE_ENCODING_MODE) {
return `\x1b[<${button};${position.col};${position.row}${sgrFinal}`;
}
// Input travels as UTF-8 text, so a default-encoded byte above ASCII would be re-encoded.
const codes = [button, position.col, position.row].map(
(value) => value + DEFAULT_MOUSE_ENCODING_OFFSET,
);
if (codes.some((code) => code > MAX_ASCII_CODE)) {
return null;
}
return `\x1b[M${String.fromCharCode(...codes)}`;
}
private applyKittyKeyboardSequence(
prefix: string,
params: string,
@@ -205,6 +293,18 @@ export class TerminalInputModeTracker {
const modes = parsePrivateModeParams(params);
let changed = false;
for (const mode of modes) {
const next = final === "h" ? mode : 0;
if (MOUSE_TRACKING_MODES.has(mode)) {
changed = this.mouseTrackingMode !== next || changed;
this.mouseTrackingMode = next;
}
if (MOUSE_ENCODING_MODES.has(mode)) {
changed = this.mouseEncodingMode !== next || changed;
this.mouseEncodingMode = next;
}
}
if (modes.has(WIN32_INPUT_MODE)) {
const previous = this.win32InputMode;
this.win32InputMode = final === "h";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.13.0",
"@getpaseo/highlight": "0.13.0",
"@getpaseo/plugin": "0.13.0",
"@getpaseo/protocol": "0.13.0",
"@getpaseo/relay": "0.13.0",
"@getpaseo/client": "0.13.0-beta.2",
"@getpaseo/highlight": "0.13.0-beta.2",
"@getpaseo/plugin": "0.13.0-beta.2",
"@getpaseo/protocol": "0.13.0-beta.2",
"@getpaseo/relay": "0.13.0-beta.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"type": "module",
"scripts": {
@@ -1,92 +0,0 @@
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { fetchUsage } from "./usage.js";
it("shows the reported Pro percentage pools without treating bonus spend as an overage", async () => {
const directory = await mkdtemp(join(tmpdir(), "cursor-reported-pools-"));
const locator = join(directory, "auth.json");
try {
await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" }));
const report = await fetchUsage({ store: "file", locator }, async () =>
Response.json({
billingCycleStart: "2026-09-25T07:10:08.000Z",
billingCycleEnd: "2026-10-25T07:10:08.000Z",
planUsage: {
totalSpend: 5717,
includedSpend: 2000,
bonusSpend: 3717,
limit: 2000,
autoPercentUsed: 9.157777777777778,
apiPercentUsed: 35.46666666666667,
totalPercentUsed: 11.54949494949495,
},
spendLimitUsage: { individualLimit: 100, individualRemaining: 100, limitType: "user" },
}),
);
expect(report).toMatchObject({
status: "available",
windows: [
{ id: "plan_usage", usedPct: 11.54949494949495 },
{ id: "cursor_usage", usedPct: 9.157777777777778 },
{ id: "third_party_usage", usedPct: 35.46666666666667 },
],
balances: [],
});
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it.each([
["Pro+", 42015, 7000, 34.583333333333336, 4.6818181818181825, 32.07251908396947],
["Teams", 77145, 2000, 61.8352380952381, 61.09, 61.716],
] as const)(
"preserves the reported %s pools separately from bonus-inclusive dollars",
async (_plan, totalSpend, limit, autoPercentUsed, apiPercentUsed, totalPercentUsed) => {
const report = await reportFor({
totalSpend,
limit,
autoPercentUsed,
apiPercentUsed,
totalPercentUsed,
});
expect(report).toMatchObject({
status: "available",
windows: [
{ id: "plan_usage", usedPct: totalPercentUsed, summary: true },
{ id: "cursor_usage", usedPct: autoPercentUsed },
{ id: "third_party_usage", usedPct: apiPercentUsed },
],
balances: [],
});
},
);
it("retains a zero percentage without inventing absent breakdowns", async () => {
expect(await reportFor({ totalPercentUsed: "0", autoPercentUsed: null })).toMatchObject({
status: "available",
windows: [{ id: "plan_usage", usedPct: 0, remainingPct: 100 }],
balances: [],
});
});
it("keeps dollar-only reports when the response has no percentages", async () => {
expect(await reportFor({ totalSpend: 1500, remaining: 2500, limit: 4000 })).toMatchObject({
status: "available",
windows: [],
balances: [{ id: "plan_usage", used: 15, remaining: 25, limit: 40, unit: "usd" }],
});
});
async function reportFor(planUsage: Record<string, unknown>) {
const directory = await mkdtemp(join(tmpdir(), "cursor-usage-pools-"));
const locator = join(directory, "auth.json");
try {
await writeFile(locator, JSON.stringify({ accessToken: "fixture-token" }));
return await fetchUsage({ store: "file", locator }, async () => Response.json({ planUsage }));
} finally {
await rm(directory, { recursive: true, force: true });
}
}
+1 -27
View File
@@ -9,12 +9,10 @@ import {
toneFromUsedPct,
usedPctOf,
unavailable,
windowFromUsedPct,
type UsageAccount,
type UsageReport,
type UsageBalance,
type UsageScope,
type UsageWindow,
} from "@getpaseo/plugin/server/usage";
const ApiNumberSchema = z.coerce.number().finite();
@@ -62,9 +60,6 @@ const CursorUsageResponseSchema = z.object({
bonusSpend: ApiNullableNumberSchema,
remaining: ApiNullableNumberSchema,
limit: ApiNullableNumberSchema,
totalPercentUsed: ApiNullableNumberSchema,
autoPercentUsed: ApiNullableNumberSchema,
apiPercentUsed: ApiNullableNumberSchema,
})
.nullish(),
billingCycleStart: CursorBillingCycleTimestampSchema,
@@ -182,29 +177,8 @@ export async function fetchUsage(
const resp = CursorUsageResponseSchema.parse(await res.json());
const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd);
const windows: UsageWindow[] = [];
const balances: UsageBalance[] = [];
if (resp.planUsage) {
// Total spend includes bonus credits; returned percentages measure quota usage.
for (const [id, label, usedPct] of [
["plan_usage", "Total", resp.planUsage.totalPercentUsed],
["cursor_usage", "Cursor", resp.planUsage.autoPercentUsed],
["third_party_usage", "Third Party", resp.planUsage.apiPercentUsed],
] as const) {
if (usedPct === null) continue;
windows.push(
windowFromUsedPct({
id,
label,
utilizationPct: usedPct,
resetsAt: billingCycleEnd,
summary: id === "plan_usage",
tone: toneFromUsedPct(usedPct),
}),
);
}
}
if (resp.planUsage && windows.length === 0) {
const totalSpend = centsToDollars(resp.planUsage.totalSpend);
const remaining = centsToDollars(resp.planUsage.remaining);
const limit = centsToDollars(resp.planUsage.limit);
@@ -223,7 +197,7 @@ export async function fetchUsage(
return {
status: "available",
planLabel: undefined,
windows,
windows: [],
balances,
details: [],
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.13.0",
"version": "0.13.0-beta.2",
"private": true,
"type": "module",
"scripts": {
+2 -3
View File
@@ -176,9 +176,8 @@ its registration; that registration belongs in the client entry.
### UI imports in v0.13
Use `/client/ui` for all Paseo UI components and UI hooks. The deprecated
`/client/react-native` entry retains `useRevealedText(text, phase): string` for 0.12 plugins
until removal after 2027-04-20. The hook is deprecated and is not exported from `/client/ui`.
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
undocumented compatibility alias until 2027-04-20.
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.