Compare commits

..
740 changed files with 148055 additions and 6347 deletions
+3
View File
@@ -0,0 +1,3 @@
vendor/react-native-gpui/** linguist-vendored
packages/app/gpui/package-lock.json linguist-generated
packages/app/gpui/desktop/Cargo.lock linguist-generated
+6
View File
@@ -98,3 +98,9 @@ app.json
# Project-local Serena configuration and memories.
.serena/
# Vendored experimental React Native desktop runtime.
!vendor/
!vendor/react-native-gpui/
!vendor/react-native-gpui/**
!packages/app/gpui/app.json
+1 -1
View File
@@ -11,5 +11,5 @@
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx", "vendor/**"]
}
+1 -1
View File
@@ -3,7 +3,7 @@
"options": {
"typeAware": false
},
"ignorePatterns": [".dev/**"],
"ignorePatterns": [".dev/**", "vendor/**"],
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
"categories": {
+6 -72
View File
@@ -1,81 +1,15 @@
# Changelog
## 0.15.0-beta.1 - 2026-11-01
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
Added:
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
Improved:
### Added
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### Improved
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### Plugins
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
### Fixed
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
### Notes
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
+7 -73
View File
@@ -1,81 +1,15 @@
# Changelog
# 更新日志
## 0.15.0-beta.1 - 2026-11-01
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
新增:
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
改进:
### 新增
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
### 改进
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
### 插件
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
### 修复
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
### 说明
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
## 0.14.0 - 2026-10-29
+1 -4
View File
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+7
View File
@@ -29,6 +29,13 @@ the titlebar row. Production builds leave the variable unset and show no label.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Experimental GPUI client
The opt-in [GPUI target](../packages/app/gpui/README.md) runs the existing React
Native app on Linux. Its contributor guide covers setup, the renderer comparison,
unsupported features, native checks, and experiments to pick up. It connects to
an already-running daemon and does not replace the Electron dev command.
## Nix desktop package
The flake exposes `packages.<system>.desktop` on Linux and macOS:
-2
View File
@@ -507,8 +507,6 @@ interface AgentSession {
}
```
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
+3 -13
View File
@@ -6,25 +6,15 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
## Character widths
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨‍👩‍👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
## Ink outside the row
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
npm exec --offline -- electron-builder \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--mac \
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
+1368 -1092
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -122,7 +122,11 @@
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push",
"gpui:install": "node scripts/gpui.mjs install",
"gpui:bundle": "node scripts/gpui.mjs bundle",
"build:gpui": "node scripts/gpui.mjs build",
"dev:gpui": "node scripts/gpui.mjs run"
},
"devDependencies": {
"@types/ws": "^8.5.14",
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await submitMessage(page, text);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await sendDraftWhenReady(page);
await pressInComposer(page, "Enter");
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -38,9 +38,6 @@ import {
fillDirectHostUri,
expectDirectHostFormValues,
expectDirectHostSslEnabled,
expectDirectHostSslDisabled,
fillDirectHostAddress,
toggleDirectHostSsl,
expectDirectHostUriValue,
expectDirectHostUriHidden,
expectDiagnosticsContent,
@@ -287,16 +284,6 @@ test.describe("Settings sidebar navigation", () => {
await expectAddHostMethodOptions(page);
await selectHostConnectionType(page, "direct");
await fillDirectHostAddress(page, "example.paseo.test", "7443");
await expectDirectHostSslDisabled(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslEnabled(page);
await toggleHostAdvanced(page);
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
await toggleHostAdvanced(page);
await toggleDirectHostSsl(page);
await expectDirectHostSslDisabled(page);
await toggleHostAdvanced(page);
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
await toggleHostAdvanced(page);
@@ -426,81 +426,3 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
await seed.cleanup();
}
});
function workspaceRows(page: Page, ids: string[]) {
return page.locator(
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
);
}
async function openWorkspaceRow(page: Page, workspaceId: string) {
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
}
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await selectWorkspaceSort(page, "Created");
const { rows, group } = compactProjectRows(page, seed);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await revealWorkspaces(group, "Show more");
await openWorkspaceRow(page, ids[9]!);
await expectWorkspaceOrder(rows, ids);
await revealWorkspaces(group, "Show less");
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
).toHaveAttribute("aria-selected", "true");
await openWorkspaceRow(page, ids[2]!);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
).toHaveAttribute("aria-selected", "true");
} finally {
await seed.cleanup();
}
});
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
page,
}) => {
test.setTimeout(120_000);
const { seed, ids } = await seedCompactProject();
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
const projectKey = projectEquivalenceViewKey(seed.projectKey);
await restorePreBetaOrder(
page,
projectKey,
ids.map((id) => `${getServerId()}:${id}`),
);
await selectWorkspaceSort(page, "Manual");
const rows = workspaceRows(page, ids);
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
await expectWorkspaceOrder(rows, ids.slice(0, 7));
await toggle.click();
await openWorkspaceRow(page, ids[9]!);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await reconnectSidebar(page);
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
await toggle.click();
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
} finally {
await seed.cleanup();
}
});
@@ -70,31 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
workspaceId: order[8],
});
});
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
await expectCollapsedWorkspaceShortcuts(page, order);
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
await expectHiddenWorkspaceShortcuts(page, order);
});
} finally {
await project.cleanup();
}
});
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
await page.getByLabel("Show less", { exact: true }).click();
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(8);
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await expect(rows).toHaveCount(7);
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await page.keyboard.press("Alt+9");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
await expect(rows).toHaveCount(8);
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
await expect(rows).toHaveCount(8);
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
0,
);
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
await page.keyboard.press("Alt+]");
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
await expect(rows).toHaveCount(7);
}
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import xterm from "@xterm/headless";
import { PNG } from "pngjs";
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
@@ -83,8 +82,6 @@ async function fixtureTerminal(size) {
rows: size.rows,
allowProposedApi: true,
});
// The app's buffer uses grapheme widths; the expected cells must too.
terminal.loadAddon(new UnicodeGraphemesAddon());
await new Promise((done) =>
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
);
@@ -351,24 +351,9 @@ export async function expectDirectHostFormValues(
}
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
}
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
}
export async function toggleDirectHostSsl(page: Page): Promise<void> {
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
}
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
}
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
}
+3
View File
@@ -0,0 +1,3 @@
node_modules/
desktop/build/
desktop/autolink/
+3
View File
@@ -0,0 +1,3 @@
workspaces=false
legacy-peer-deps=true
install-links=true
+202
View File
@@ -0,0 +1,202 @@
# Experimental GPUI client
Run Paseo's existing React Native app in a native GPUI window on Linux. This is
a community starting point: the real workspace UI runs, but terminal/diff
renderers and several native services still need porting. The normal desktop
build remains Electron. No daemon changes or release installers are included.
## Run it
Start from the repository's [development setup](../../../docs/development.md).
A fresh checkout needs the root dependencies and workspace build outputs:
```sh
npm ci
npm run build:server
npm run gpui:install
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run dev:gpui
```
Use an already-running compatible daemon and replace `localhost:6768` with its
endpoint. These GPUI commands never start or restart a daemon. The address is
inlined at bundle time. The app's normal connection/authentication rules apply.
You also need Rust stable through rustup, CMake, Ninja, Clang, Linux X11/Wayland
development libraries, and Vulkan. The tested Linux x86_64 host already had
most build tools; it additionally needed `libxkbcommon-x11-dev`. A clean-machine
prerequisite check is still needed. The first native build downloads dependencies
and builds Rust/C++; allow several minutes and several GB of disk.
`dev:gpui` bundles the app, autolinks native modules, builds, and opens the window.
Restart after editing. It uses an offline production JS bundle; Fast Refresh is
not wired into this launcher. `build:gpui` builds without opening a window.
`gpui:bundle -- --dev false` only writes the JS bundle, source map, and assets to
`.artifacts/gpui/`. Bundle validation checks that the real routes are present and
that no `react-native-webview` modules were included.
## Approach and renderer choice
```text
Existing Paseo RootApp + Expo Router + state/client/plugin code
|
React in Hermes
|
React Native Fabric
|
Rust GPUI native renderer
|
Linux GPU window
Existing Paseo client <-- WebSocket --> separately running Node daemon
```
The separate npm project is deliberate: the selected runtime uses RN 0.87.1,
while Paseo's mobile app uses RN 0.86.3. Metro redirects native dependencies to
that installation and resolves the rest from the app. This avoids changing the
mobile dependency baseline just to try another renderer. It also creates a
version-maintenance cost that needs testing before this becomes a supported target.
The choice was to preserve the existing app's RN component and library contracts:
| Candidate | Integration model | Consequence for this experiment |
| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Streetwriters react-native-gpui](https://github.com/streetwriters/react-native-gpui/tree/de11aa265e6fb36c80e8a30315bce6d34a9be7d6) | React Native Fabric, Hermes, and GPUI, with ports of screens, gestures, Reanimated/worklets, safe areas, and SVG | Chosen because it lets the existing RN app reach a real Linux window with targeted adapters. These ports are still experimental. |
| [natew/react-native-gpui](https://github.com/natew/react-native-gpui/tree/b2254543357f02fdd983d0d8a584d1d1bd93f5da) | Custom React reconciler with an RN-like component surface; its README targets macOS arm64/Metal | Useful implementation reference. Running this Linux app would also require platform work and compatibility with the RN libraries it imports. |
| [GPUIX](https://github.com/remorses/gpuix) | Custom React/Solid renderer, its own JSX primitives, and Node-API native bindings; supports Bun/Node execution | Also native GPUI, with interesting packaging and specialized surfaces. Adopting it would require replacing or adapting Paseo's RN component/library contracts. It remains an alternative worth testing, not a rejected renderer. |
| Existing Electron target | RN Web inside Chromium, with the desktop host bridge | Preserves the working desktop feature set, but does not answer the experiment's question of running the app through a native renderer. |
This is an integration choice, not a performance comparison. There are no
controlled CPU, memory, startup, or frame-time results against these alternatives.
## What belongs in the change
| Area | Why it is here |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vendor/react-native-gpui/` | Editable, pinned runtime source. This accounts for roughly 130,000 lines of the diff. GitHub marks it as vendored; inspect [UPSTREAM](../../../vendor/react-native-gpui/UPSTREAM) for provenance and the two local patches. |
| npm and Cargo lockfiles | Reproduce the experiment's dependencies. Roughly 15,000 generated lines, collapsed on GitHub. |
| `desktop/`, `index.js`, `routes.js`, Metro/Babel config, root launcher | Register the full app, select the native runtime, compile native services, and bundle the actual route graph. The explicit route context prevents an apparently successful build with no app routes. |
| `adapters/` | Desktop implementations of the app-used APIs, plus explicit unavailable-capability implementations for mobile features. About 700 lines; these are limited ports, not complete replacements for Expo/Unistyles. |
| Shared app edits | One capability gate for alpha-mask shimmer. GPUI shows the logo and labels without that effect. Existing web/iOS/Android paths keep their behavior. |
| Native tests and this guide | Make the working slice reproducible and give contributors bounded follow-up work. |
Upstream examples, its tester/testing package, website, unused fonts/vector-icon
ports, starter navigation dependencies, sample icon, and unused nested Cargo config
are omitted. Build outputs, native binaries, downloaded SDKs, logs, screenshots,
and the exploratory size-measurement copy stay out of Git.
The retained runtime still includes its build machinery, platform backends,
headers, and tests. Arbitrarily deleting those to reduce line count makes an
upstream refresh harder. Vendoring can later be replaced by a pinned dependency
once the local patches are upstreamed and distribution is reproducible.
### Editing the runtime
Edit `vendor/react-native-gpui`, then run `npm run gpui:install` before rebuilding.
npm copies local packages rather than symlinking them; the install command
refreshes those copies even when their versions have not changed.
There are two local renderer changes:
- `node_view.rs`: a `pointerEvents="box-none"` view must not install its own hit
area. Before the patch, Paseo's empty portal overlay intercepted sidebar clicks.
- `StatusBar.desktop.js`: desktop windows do not have the mobile OS status bar.
GPUI remains a pinned Cargo git dependency. Upstream tooling downloads Hermes
and React Native prebuilt libraries. The snapshot is not an offline or fully
source-built distribution, and release packaging has not been verified.
## Working slice and limits
Verified on Linux x86_64: all 24 app routes in the bundle, connection to the dev
daemon, the real project/workspace sidebar, opening an existing workspace,
existing message rendering, and native composer typing/deletion. The native
regression test restores the draft and does not send a message. Rust tests cover
SHA-256/random-byte bounds and real SQLite parameter binding/transaction rollback.
The following are not feature-parity claims:
| Area | Current state |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Styling | Adapter evaluates existing Unistyles theme/style factories and breakpoints. No Nitro shadow-tree integration. Full theme/resize behavior needs QA. |
| Storage/crypto/localization | Native filesystem + SQLite + SHA-256 + OS randomness. Intl ships English locale data and IANA time zones. The Rust service contains Linux-specific OS access. |
| Clipboard/files | Text clipboard, native file dialogs, PNG/JPEG picking, and download/export adapters exist. End-to-end dialog/transfer coverage is still missing. |
| Voice/camera/push/power inhibition | Unavailable. Requests either report the missing capability or use the app's existing unavailable-capability implementation. |
| Image conversion/image clipboard | Unavailable. The picker accepts PNG/JPEG without conversion. |
| Mermaid/HTML/document previews | Mermaid and HTML show source. Document previews report unavailable. No embedded browser backend. |
| Terminals | The mobile view expects an Expo `PaseoTerminal` component. No GPUI implementation yet. |
| Diffs | The mobile surface uses React Native Skia. No GPUI drawing backend yet. |
| Plugins | The existing loader evaluates JS and injects host React/RN. No end-to-end plugin verification on GPUI yet. |
| Other platforms | The upstream runtime targets macOS/Windows too; this Paseo integration has only been exercised on Linux. |
The initial stripped development executable was about 68 MiB, with about 10 MiB
of Hermes/JSI libraries and a 21 MiB JS bundle, approximately 100 MiB uncompressed.
That measurement excludes the daemon, is not an optimized release installer, and
does not establish memory usage or a size advantage over another renderer.
## Experiments to pick up
Each experiment should preserve the real app and demonstrate a user workflow.
1. **Native terminal.** Implement a GPUI component for the existing
[`PaseoTerminalView`](../modules/paseo-terminal/index.tsx) frame contract.
Keep the JS headless xterm emulator, transport, and frame generation initially.
Verify resize/replay, keyboard/IME/paste, scrollback/selection, and the existing
Unicode corpus. [Terminal rendering](../../../docs/terminal-rendering.md)
explains why shaping must preserve xterm's cell columns.
2. **Diff surface.** Keep `src/git/diff-document`'s model and review actions;
add GPUI text measurement and painting instead of the Skia surface. Test
unified/split views, wrapping, scrolling, selection, and inline reviews on
large real diffs. The [C++ layout experiment](../modules/paseo-diff-prototype/README.md)
is a separate candidate for shared geometry; it is not a finished renderer.
3. **Plugins.** Load a small plugin into an isolated daemon, open its real screen
or workspace panel, call a plugin RPC, and reload it. Verify subscriptions and
cleanup. Track unsupported services explicitly instead of adding fake APIs.
4. **Native services and styling.** Exercise dialogs, downloads, clipboard,
persistence/restart, theme switching, resizing, and focus. Replace the narrow
adapters with complete implementations only where the app needs them.
5. **Portability and packaging.** Build on clean Linux, then macOS/Windows. Replace
Linux-only randomness/timezone access, check font/shaping and IME behavior, and
verify relocatable libraries/assets. Measure a release build and a controlled
workflow before claiming a performance or size improvement.
6. **Bundled daemon, independently.** First run the unchanged daemon under Bun
in isolation. Verify PTY/native dependencies, terminal-worker subprocesses,
provider launches, and dynamic plugins. Then compare standalone/AOT packaging
and size. This branch neither ports the daemon nor embeds it in the UI process.
A Rust/GPUI terminal or diff painter can share desktop implementation across the
upstream renderer's supported OSes. Reusing it on mobile still requires mobile
bindings and graphics integration; choosing Rust or C++ alone does not provide that.
## Verification
Run the normal checks from the root. GPUI adapters are excluded from the mobile
TypeScript project; its typecheck alone does not validate this target.
```sh
npm run typecheck
npm run lint
npm run format:check
```
For the native UI regression, use a daemon with an existing workspace and build
with the driver. Run the native app on an isolated display when testing on a
machine in use:
```sh
EXPO_PUBLIC_LOCAL_DAEMON=localhost:6768 npm run build:gpui -- --features test-driver,visual-capture
RN_GPUI_TEST_DRIVER=1 RN_GPUI_TEST_DRIVER_TOKEN=local-test \
RN_GPUI_DEV_MODE=0 RN_GPUI_BUNDLE="$PWD/.artifacts/gpui/paseo.bundle" \
RN_GPUI_ASSETS_DIR="$PWD/.artifacts/gpui/assets/assets" \
xvfb-run -a -s '-screen 0 2560x1600x24' \
packages/app/gpui/desktop/target/debug/react-native-gpui-paseo-gpui
```
Use the printed `RN_GPUI_TEST_DRIVER_PORT` in another shell:
```sh
PASEO_GPUI_TEST_URL=ws://127.0.0.1:PORT PASEO_GPUI_TEST_TOKEN=local-test \
npx vitest run packages/app/gpui/native.test.ts --bail=1
```
Without a driver URL the test is skipped. Put diagnostic logs/captures in
`.artifacts/gpui/`; do not commit them or publish private workspace contents as QA.
@@ -0,0 +1,73 @@
import { promises as fs, paths } from "@react-native-gpui/fs";
import Desktop from "../desktop/src/NativePaseoDesktop";
let writes = Promise.resolve();
const directory = () => `${paths.appData}/paseo-gpui/storage`;
const filename = (key) => `${directory()}/${Desktop.sha256(key)}.json`;
async function read(key) {
try {
return JSON.parse(await fs.readFile(filename(key), "utf8")).value;
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
}
function mutate(operation) {
const result = writes.then(operation);
writes = result.catch(() => {});
return result;
}
async function write(key, value) {
await fs.mkdir(directory(), { recursive: true });
const target = filename(key);
await fs.writeFile(`${target}.tmp`, JSON.stringify({ key, value }), "utf8");
await fs.rename(`${target}.tmp`, target);
}
async function keys() {
let entries;
try {
entries = await fs.readdir(directory());
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
return Promise.all(
entries
.filter((name) => name.endsWith(".json"))
.map(async (name) => JSON.parse(await fs.readFile(`${directory()}/${name}`, "utf8")).key),
);
}
const storage = {
async getItem(key) {
await writes;
return read(key);
},
setItem(key, value) {
return mutate(() => write(key, value));
},
removeItem(key) {
return mutate(() => fs.rm(filename(key), { force: true }));
},
async getAllKeys() {
await writes;
return keys();
},
async multiGet(items) {
await writes;
return Promise.all(items.map(async (key) => [key, await read(key)]));
},
multiSet(items) {
return mutate(async () => {
for (const [key, value] of items) await write(key, value);
});
},
multiRemove(items) {
return mutate(async () => {
for (const key of items) await fs.rm(filename(key), { force: true });
});
},
clear() {
return mutate(() => fs.rm(directory(), { recursive: true, force: true }));
},
};
export default storage;
+21
View File
@@ -0,0 +1,21 @@
// Preserve the voice model's lifecycle without loading mobile-only Expo audio
// during app startup. Requests fail explicitly until a Linux audio backend exists.
export function createAudioEngine() {
const unavailable = async () => {
throw new Error("Voice capture and playback are not available in the GPUI build yet.");
};
return {
initialize: unavailable,
startCapture: unavailable,
play: unavailable,
async destroy() {},
async stopCapture() {},
stop() {},
clearQueue() {},
isPlaying: () => false,
isMuted: () => false,
toggleMute() {
throw new Error("Voice capture is not available in the GPUI build yet.");
},
};
}
+16
View File
@@ -0,0 +1,16 @@
import { TurboModuleRegistry } from "react-native";
const clipboard = TurboModuleRegistry.getEnforcing("Clipboard");
export const getStringAsync = () => clipboard.getString();
export async function setStringAsync(value) {
clipboard.setString(value);
return true;
}
export async function hasStringAsync() {
return (await getStringAsync()).length > 0;
}
export async function getImageAsync() {
throw new Error("Image clipboard is not implemented for GPUI yet");
}
export async function setImageAsync() {
throw new Error("Image clipboard is not implemented for GPUI yet");
}
+24
View File
@@ -0,0 +1,24 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
export function getRandomValues(array) {
if (
!ArrayBuffer.isView(array) ||
array instanceof DataView ||
array instanceof Float32Array ||
array instanceof Float64Array
) {
throw new TypeError("getRandomValues requires an integer typed array");
}
if (array.byteLength > 65536) throw new RangeError("Random request exceeds 65536 bytes");
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set(
Desktop.randomBytes(array.byteLength),
);
return array;
}
export function randomUUID() {
return globalThis.crypto.randomUUID();
}
export const CryptoDigestAlgorithm = { SHA256: "SHA-256" };
export async function digestStringAsync(algorithm, text) {
if (algorithm !== "SHA-256") throw new Error(`Unsupported digest algorithm: ${algorithm}`);
return Desktop.sha256(text);
}
@@ -0,0 +1,14 @@
import { dialog, promises } from "@react-native-gpui/fs";
export async function getDocumentAsync(options = {}) {
const paths = await dialog.open({ multiple: options.multiple ?? false });
return {
canceled: paths.length === 0,
assets: await Promise.all(
paths.map(async (path) => ({
uri: `file://${path}`,
name: path.split("/").at(-1),
size: (await promises.stat(path)).size,
})),
),
};
}
@@ -0,0 +1,19 @@
import React from "react";
import { ScrollView, Text, View } from "react-native";
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
export function FileHtmlPreview({ html, testID }) {
return (
<ScrollView testID={testID}>
<Text>HTML rendering is not available in GPUI. Showing source.</Text>
<HighlightedCodeBlock code={html} language="html" />
</ScrollView>
);
}
export function DocumentFrame({ testID }) {
return (
<View testID={testID}>
<Text>Document preview is not available in the GPUI build yet.</Text>
</View>
);
}
@@ -0,0 +1,11 @@
import {
EventEmitter,
NativeModule,
SharedObject,
SharedRef,
} from "expo-modules-core/src/polyfill/CoreModule";
export function ensureNativeModulesAreInstalled() {
if (globalThis.expo) return;
globalThis.expo = { EventEmitter, NativeModule, SharedObject, SharedRef, modules: {} };
}
ensureNativeModulesAreInstalled();
@@ -0,0 +1,30 @@
import { Linking } from "react-native";
import { NativeModule } from "expo-modules-core/src/polyfill/CoreModule";
class DesktopLinking extends NativeModule {
initialURL = null;
subscription;
constructor() {
super();
Linking.getInitialURL()
.then((url) => {
this.initialURL = url;
if (url) this.emit("onURLReceived", { url });
return url;
})
.catch((error) => console.error("[GPUI linking]", error));
}
getLinkingURL = () => this.initialURL;
clearInitialURL() {
this.initialURL = null;
}
startObserving() {
this.subscription = Linking.addEventListener("url", (event) =>
this.emit("onURLReceived", event),
);
}
stopObserving() {
this.subscription?.remove();
}
}
export default new DesktopLinking();
+129
View File
@@ -0,0 +1,129 @@
import fs, { promises, paths } from "@react-native-gpui/fs";
import { Buffer } from "buffer";
const pathOf = (value) => String(value?.uri ?? value).replace(/^file:\/\//, "");
const join = (parts) =>
parts
.map(pathOf)
.join("/")
.replace(/\/{2,}/g, "/");
export const cacheDirectory = `file://${paths.appCache}/paseo-gpui/`;
export const documentDirectory = `file://${paths.appData}/paseo-gpui/`;
export const EncodingType = { Base64: "base64", UTF8: "utf8" };
export const Paths = { cache: cacheDirectory, document: documentDirectory };
export async function getInfoAsync(uri) {
try {
const stat = await promises.stat(pathOf(uri));
return { exists: true, isDirectory: stat.isDirectory(), size: stat.size, uri };
} catch (error) {
if (error.code === "ENOENT") return { exists: false, uri };
throw error;
}
}
export const makeDirectoryAsync = (uri, options = {}) =>
promises.mkdir(pathOf(uri), { recursive: options.intermediates });
export const copyAsync = ({ from, to }) => promises.copyFile(pathOf(from), pathOf(to));
export const moveAsync = ({ from, to }) => promises.rename(pathOf(from), pathOf(to));
export const readAsStringAsync = (uri, options = {}) =>
promises.readFile(pathOf(uri), options.encoding ?? "utf8");
export const writeAsStringAsync = (uri, contents, options = {}) =>
promises.writeFile(pathOf(uri), contents, options.encoding ?? "utf8");
export const deleteAsync = (uri, options = {}) =>
promises.rm(pathOf(uri), { recursive: true, force: options.idempotent });
export const readDirectoryAsync = (uri) => promises.readdir(pathOf(uri));
export class File {
constructor(...parts) {
this.uri = `file://${join(parts)}`;
}
get name() {
return pathOf(this).split("/").at(-1);
}
get exists() {
return fs.existsSync(pathOf(this));
}
get size() {
return fs.statSync(pathOf(this)).size;
}
create({ overwrite = false, intermediates = false } = {}) {
if (intermediates)
fs.mkdirSync(pathOf(this).split("/").slice(0, -1).join("/"), { recursive: true });
fs.writeFileSync(pathOf(this), "", { flag: overwrite ? "w" : "wx" });
}
write(value) {
fs.writeFileSync(pathOf(this), value);
}
delete() {
fs.rmSync(pathOf(this));
}
async bytes() {
return promises.readFile(pathOf(this));
}
async arrayBuffer() {
const bytes = await this.bytes();
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
async base64() {
return Buffer.from(await this.bytes()).toString("base64");
}
async text() {
return promises.readFile(pathOf(this), "utf8");
}
move(destination) {
fs.renameSync(pathOf(this), pathOf(destination));
this.uri = destination.uri;
}
copy(destination) {
fs.copyFileSync(pathOf(this), pathOf(destination));
}
static async downloadFileAsync(url, destination) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const file = destination instanceof File ? destination : new File(destination);
await promises.writeFile(pathOf(file), new Uint8Array(await response.arrayBuffer()));
return file;
}
}
export class Directory {
constructor(...parts) {
this.uri = `file://${join(parts)}`;
}
get exists() {
return fs.existsSync(pathOf(this));
}
create({ intermediates = false } = {}) {
fs.mkdirSync(pathOf(this), { recursive: intermediates });
}
delete() {
fs.rmSync(pathOf(this), { recursive: true });
}
list() {
return fs
.readdirSync(pathOf(this), { withFileTypes: true })
.map((entry) =>
entry.isDirectory() ? new Directory(this, entry.name) : new File(this, entry.name),
);
}
}
export function createDownloadResumable(url, uri, options = {}, onProgress) {
const controller = new AbortController();
return {
async downloadAsync() {
const response = await fetch(url, { headers: options.headers, signal: controller.signal });
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const target = pathOf(uri);
await promises.mkdir(target.split("/").slice(0, -1).join("/"), { recursive: true });
await promises.writeFile(`${target}.part`, bytes);
await promises.rename(`${target}.part`, target);
onProgress?.({ totalBytesWritten: bytes.length, totalBytesExpectedToWrite: bytes.length });
return {
uri,
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
};
},
async cancelAsync() {
controller.abort();
},
};
}
@@ -0,0 +1,8 @@
// The GPUI picker accepts JPEG/PNG directly. Other formats require a future
// native decoder; never claim conversion succeeded for an unsupported format.
export const SaveFormat = { PNG: "png", JPEG: "jpeg", WEBP: "webp" };
export const ImageManipulator = {
manipulate() {
throw new Error("Image conversion is not available in the GPUI build; select a PNG or JPEG.");
},
};
@@ -0,0 +1,28 @@
import { dialog, promises } from "@react-native-gpui/fs";
// Desktop access is granted for each selection by the system file dialog.
export const PermissionStatus = {
GRANTED: "granted",
DENIED: "denied",
UNDETERMINED: "undetermined",
};
const permission = { status: "granted", granted: true, canAskAgain: true, expires: "never" };
export const requestMediaLibraryPermissionsAsync = async () => permission;
export const useMediaLibraryPermissions = () => [permission, requestMediaLibraryPermissionsAsync];
export const getPendingResultAsync = async () => null;
export async function launchImageLibraryAsync(options = {}) {
const paths = await dialog.open({
multiple: options.allowsMultipleSelection,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }],
});
return {
canceled: !paths.length,
assets: await Promise.all(
paths.map(async (path) => ({
uri: `file://${path}`,
fileName: path.split("/").at(-1),
fileSize: (await promises.stat(path)).size,
mimeType: /\.png$/i.test(path) ? "image/png" : "image/jpeg",
})),
),
};
}
+16
View File
@@ -0,0 +1,16 @@
/* oxlint-disable import/no-unassigned-import -- These packages install Intl and locale data by side effect. */
// Hermes' Linux build omits Intl; install real locale algorithms before app code.
import "@formatjs/intl-getcanonicallocales/polyfill-force.js";
import "@formatjs/intl-locale/polyfill-force.js";
import "@formatjs/intl-pluralrules/polyfill-force.js";
import "@formatjs/intl-pluralrules/locale-data/en.js";
import "@formatjs/intl-numberformat/polyfill-force.js";
import "@formatjs/intl-numberformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/polyfill-force.js";
import "@formatjs/intl-datetimeformat/locale-data/en.js";
import "@formatjs/intl-datetimeformat/add-all-tz.js";
import "@formatjs/intl-displaynames/polyfill-force.js";
import "@formatjs/intl-displaynames/locale-data/en.js";
import "@formatjs/intl-segmenter/polyfill-force.js";
import Desktop from "../desktop/src/NativePaseoDesktop";
Intl.DateTimeFormat.__setDefaultTimeZone(Desktop.timezone());
+6
View File
@@ -0,0 +1,6 @@
// No desktop power-inhibition lease is acquired by this backend yet.
export const isAvailableAsync = async () => false;
export async function activateKeepAwakeAsync() {
throw new Error("Power inhibition is not available in the GPUI build.");
}
export async function deactivateKeepAwake() {}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect } from "react";
import { Keyboard, ScrollView, useAnimatedValue } from "react-native";
import { useSharedValue } from "react-native-reanimated";
// A GPUI desktop window has no software-keyboard inset. RN still owns focus
// and physical keyboard events; the mobile layout API observes its events.
export function KeyboardProvider({ children }) {
return children;
}
export const KeyboardAwareScrollView = ScrollView;
export const KeyboardController = {
isVisible: () => Keyboard.isVisible(),
dismiss: () => Keyboard.dismiss(),
};
export const KeyboardEvents = { addListener: (...args) => Keyboard.addListener(...args) };
export function useReanimatedKeyboardAnimation() {
const height = useSharedValue(0);
const progress = useSharedValue(0);
useEffect(() => {
const shown = Keyboard.addListener("keyboardDidShow", (event) => {
height.value = -event.endCoordinates.height;
progress.value = 1;
});
const hidden = Keyboard.addListener("keyboardDidHide", () => {
height.value = 0;
progress.value = 0;
});
return () => {
shown.remove();
hidden.remove();
};
}, [height, progress]);
return { height, progress };
}
export function useKeyboardAnimation() {
const height = useAnimatedValue(0);
const progress = useAnimatedValue(0);
return { height, progress };
}
export function useGenericKeyboardHandler(handlers) {
useEffect(() => {
const subscriptions = [
Keyboard.addListener("keyboardDidShow", (event) => {
const value = {
height: event.endCoordinates.height,
progress: 1,
duration: event.duration,
};
handlers.onStart?.(value);
handlers.onEnd?.(value);
}),
Keyboard.addListener("keyboardDidHide", () => {
const value = { height: 0, progress: 0, duration: 0 };
handlers.onStart?.(value);
handlers.onEnd?.(value);
}),
];
return () => subscriptions.forEach((subscription) => subscription.remove());
}, [handlers]);
}
@@ -0,0 +1,5 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
export function getLocales() {
const languageTag = Desktop.locale().split(".")[0].replaceAll("_", "-");
return [{ languageTag, languageCode: languageTag.split("-")[0] }];
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
import { Text, View } from "react-native";
import { HighlightedCodeBlock } from "../../src/components/highlighted-code-block";
export function MermaidFenceHost(props) {
return (
<View>
<Text style={props.inheritedStyles}>
Diagram rendering is not available in the GPUI build yet.
</Text>
<HighlightedCodeBlock {...props} language="mermaid" />
</View>
);
}
@@ -0,0 +1,5 @@
import { Buffer } from "buffer";
import Desktop from "../desktop/src/NativePaseoDesktop";
export function randomBytes(length) {
return Buffer.from(Desktop.randomBytes(length));
}
@@ -0,0 +1,3 @@
// GPUI's native TextInput owns text paste, selection, and IME. The mobile
// PasteInput module's image-paste extension is not available on this target.
export { TextInput as default } from "react-native";
+11
View File
@@ -0,0 +1,11 @@
import { dialog, promises } from "@react-native-gpui/fs";
// Linux exports a downloaded file through the native Save dialog.
export const isAvailableAsync = async () => true;
export async function shareAsync(uri, options = {}) {
const source = uri.replace(/^file:\/\//, "");
const destination = await dialog.save({
title: options.dialogTitle ?? "Save file",
defaultName: source.split("/").at(-1),
});
if (destination) await promises.copyFile(source, destination);
}
+37
View File
@@ -0,0 +1,37 @@
import Desktop from "../desktop/src/NativePaseoDesktop";
let pending = Promise.resolve();
function enqueue(operation) {
const result = pending.then(operation);
pending = result.catch(() => {});
return result;
}
const connection = {
async execAsync(sql) {
Desktop.sqlite(sql, "batch");
},
async runAsync(sql, params = []) {
Desktop.sqlite(sql, JSON.stringify(params));
},
async getAllAsync(sql, params = []) {
return JSON.parse(Desktop.sqlite(sql, JSON.stringify(params)));
},
};
export async function openDatabaseAsync(name) {
if (name !== "paseo-replica-row-store.db") throw new Error(`Unknown GPUI database: ${name}`);
return {
execAsync: (...args) => enqueue(() => connection.execAsync(...args)),
runAsync: (...args) => enqueue(() => connection.runAsync(...args)),
getAllAsync: (...args) => enqueue(() => connection.getAllAsync(...args)),
withExclusiveTransactionAsync: (operation) =>
enqueue(async () => {
await connection.execAsync("BEGIN IMMEDIATE");
try {
await operation(connection);
await connection.execAsync("COMMIT");
} catch (error) {
await connection.execAsync("ROLLBACK");
throw error;
}
}),
};
}
@@ -0,0 +1,26 @@
import * as RN from "react-native";
import { createUnistylesElement } from "./unistyles";
export const View = createUnistylesElement(RN.View);
export const Text = createUnistylesElement(RN.Text);
export const ScrollView = createUnistylesElement(RN.ScrollView);
export const TextInput = createUnistylesElement(RN.TextInput);
export const Pressable = createUnistylesElement(RN.Pressable);
export const Image = createUnistylesElement(RN.Image);
export const ImageBackground = createUnistylesElement(RN.ImageBackground);
export const FlatList = createUnistylesElement(RN.FlatList);
export const SectionList = createUnistylesElement(RN.SectionList);
export const TouchableOpacity = createUnistylesElement(RN.TouchableOpacity);
export const TouchableHighlight = createUnistylesElement(RN.TouchableHighlight);
export const TouchableWithoutFeedback = createUnistylesElement(RN.TouchableWithoutFeedback);
export const ActivityIndicator = createUnistylesElement(RN.ActivityIndicator);
export const KeyboardAvoidingView = createUnistylesElement(RN.KeyboardAvoidingView);
export const SafeAreaView = createUnistylesElement(RN.SafeAreaView);
export const Switch = createUnistylesElement(RN.Switch);
export const StatusBar = RN.StatusBar;
export const Animated = {
...RN.Animated,
View: createUnistylesElement(RN.Animated.View),
Text: createUnistylesElement(RN.Animated.Text),
ScrollView: createUnistylesElement(RN.Animated.ScrollView),
Image: createUnistylesElement(RN.Animated.Image),
};
+140
View File
@@ -0,0 +1,140 @@
import React, { forwardRef, useSyncExternalStore } from "react";
import * as Native from "react-native";
// GPUI has Fabric but no Nitro/Unistyles shadow-tree bindings. Keep the app's
// style factories and theme API; this backend updates subscribed React views.
const listeners = new Set();
const resolveStyle = Symbol("paseo.gpui.style");
let revision = 0;
let themes = {};
let themeName = "dark";
let adaptive = true;
let breakpoints = {};
const emit = () => {
revision += 1;
for (const listener of listeners) listener();
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const snapshot = () => revision;
const theme = () => themes[themeName];
const runtime = () => ({
themeName,
colorScheme: Native.Appearance.getColorScheme() ?? "light",
screen: Native.Dimensions.get("window"),
insets: { top: 0, bottom: 0, left: 0, right: 0, ime: 0 },
pixelRatio: Native.PixelRatio.get(),
fontScale: Native.PixelRatio.getFontScale(),
breakpoint: Object.keys(breakpoints).findLast(
(key) => breakpoints[key] <= Native.Dimensions.get("window").width,
),
});
Native.Dimensions.addEventListener("change", emit);
Native.Appearance.addChangeListener(({ colorScheme }) => {
if (adaptive && themes[colorScheme]) themeName = colorScheme;
emit();
});
function responsive(value) {
if (Array.isArray(value)) return value.map(responsive);
if (!value || typeof value !== "object") return value;
if (Object.getPrototypeOf(value) !== Object.prototype) return value;
const keys = Object.keys(value);
if (keys.length && keys.every((key) => key in breakpoints)) {
const width = Native.Dimensions.get("window").width;
const key = keys
.sort((a, b) => breakpoints[a] - breakpoints[b])
.findLast((candidate) => breakpoints[candidate] <= width);
return key === undefined ? undefined : responsive(value[key]);
}
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, responsive(item)]));
}
function materialize(style) {
if (Array.isArray(style)) return style.map(materialize);
if (style && typeof style === "object" && style[resolveStyle]) return style[resolveStyle]();
return style;
}
function styledProps(props) {
const result = { ...props };
for (const key of ["style", "contentContainerStyle"]) {
if (typeof props[key] === "function")
result[key] = (...args) => materialize(props[key](...args));
else if (props[key]) result[key] = materialize(props[key]);
}
return result;
}
export const UnistylesRuntime = {
get themeName() {
return themeName;
},
get breakpoint() {
return runtime().breakpoint;
},
get colorScheme() {
return runtime().colorScheme;
},
getTheme: theme,
setTheme(name) {
if (!themes[name]) throw new Error(`Unknown GPUI theme: ${name}`);
themeName = name;
emit();
},
updateTheme(name, update) {
themes[name] = update(themes[name]);
emit();
},
setAdaptiveThemes(value) {
adaptive = value;
if (value && themes[runtime().colorScheme]) themeName = runtime().colorScheme;
emit();
},
};
export const StyleSheet = {
...Native.StyleSheet,
configure(config) {
themes = config.themes;
breakpoints = config.breakpoints;
adaptive = config.settings?.adaptiveThemes ?? false;
themeName =
config.settings?.initialTheme ?? (adaptive ? runtime().colorScheme : Object.keys(themes)[0]);
emit();
},
create(factory) {
const evaluate = () => (typeof factory === "function" ? factory(theme(), runtime()) : factory);
const initial = evaluate();
const sheet = {};
for (const key of Object.keys(initial)) {
const wrap = (args) => {
const resolve = () => {
const value = evaluate()[key];
return responsive(typeof value === "function" ? value(...args) : value);
};
return Object.defineProperty({ ...resolve() }, resolveStyle, { value: resolve });
};
sheet[key] = typeof initial[key] === "function" ? (...args) => wrap(args) : wrap([]);
}
return sheet;
},
};
// Existing consumers retain this API; new app code should use style factories.
export function useUnistyles() {
useSyncExternalStore(subscribe, snapshot, snapshot);
return { theme: theme(), rt: runtime() };
}
export function withUnistyles(Component, mappings) {
const Wrapped = forwardRef(function GpuiThemed(props, ref) {
useSyncExternalStore(subscribe, snapshot, snapshot);
const { uniProps, ...rest } = props;
return React.createElement(Component, {
...styledProps({
...mappings?.(theme(), runtime()),
...uniProps?.(theme(), runtime()),
...rest,
}),
ref,
});
});
return Wrapped;
}
export const createUnistylesElement = withUnistyles;
+1
View File
@@ -0,0 +1 @@
{ "name": "Paseo", "displayName": "Paseo GPUI" }
+7
View File
@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [[require.resolve("babel-preset-expo"), { unstable_transformImportMeta: true }]],
plugins: [[require.resolve("react-native-unistyles/plugin"), { root: "../src" }]],
};
};
+4
View File
@@ -0,0 +1,4 @@
build/
.cache/
autolink/
target/
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
[package]
name = "react-native-gpui-paseo-gpui"
version = "0.1.0"
edition = "2021"
# Set CARGO_PROFILE_DEV_DEBUG=full to opt into full debug information.
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "packed"
# Dependencies are optimized even in a dev build: GPUI rebuilds and paints the
# element tree on every frame, and they are only compiled once.
[profile.dev.package."*"]
debug = false
opt-level = 3
# The C++ bridge picks its CMake build type from this crate's opt-level, and a
# Release build of React Native has no dev server, inspector or Fast Refresh.
[profile.dev.package.rn-gpui-sys]
opt-level = 0
[profile.release]
debug = "line-tables-only"
# Cargo strips inside the linker, before dsymutil, so package-desktop writes the
# dSYM and then strips. Full LTO: 2 MB smaller for 1.4x the build time.
strip = "none"
split-debuginfo = "packed"
lto = "fat"
codegen-units = 1
[build-dependencies]
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
serde_json = "1"
[features]
visual-capture = ["react-native-gpui/visual-capture"]
test-driver = ["react-native-gpui/test-driver"]
[dependencies]
serde_json = "1"
sha2 = "0.10"
rusqlite = { version = "0.38", features = ["bundled"] }
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
rn-gpui-autolink = { path = "autolink" }
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
[patch.crates-io]
# Managed by @react-native-gpui/cli autolink; do not edit between these markers.
react-native-gpui = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui" }
rn-gpui-sys = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-sys" }
rn-gpui-module = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module" }
rn-gpui-module-build = { path = "../node_modules/@react-native-gpui/core/ReactGpui/rn-gpui-module-build" }
gpui = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
gpui_platform = { git = "https://github.com/streetwriters/gpui-fast", rev = "8da7d89" }
# End managed by autolink.
+172
View File
@@ -0,0 +1,172 @@
fn main() {
// Forwards the engine's Node-API exports out of this executable, which is
// what lets a stock `.node` addon resolve them on Windows.
//
// A DLL records every import as a name *plus the file it comes from*, and a
// node-gyp addon records "from `node.exe`" — Windows has no global symbol
// scope for it to fall back on. Node's own answer is that the host process
// exports the Node-API functions, so that is what this does: the executable
// re-exports each of the VM's `napi_*`/`node_api_*` names, forwarded to
// `hermesvm.dll`. Addons that use node-gyp's delay-load hook (the default)
// then reach this process instead of looking for `node.exe` on disk.
//
// It is a no-op on macOS and Linux, where an addon's imports resolve against
// whatever is already loaded and need no forwarding. Declare it once, before
// the module list; it does not depend on anything else in this file.
rn_gpui_module_build::forward_node_api_exports();
// App-local Rust TurboModules: list one entry per `src/Native*.ts` spec,
// for example `&["src/NativeGreeting.ts"]`. Their glue is generated into
// this crate's OUT_DIR, and main.rs registers the modules.
rn_gpui_module_build::generate(&["src/NativePaseoDesktop.ts"]);
let desktop_root = std::path::PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set"),
);
// `app.json` names the app; `desktop/desktop.json` carries the identifier
// and everything else the packaging step needs. The loader the CLI uses is
// JavaScript, so this reads the same file directly and applies the two
// defaults it would apply.
let project_root = desktop_root
.parent()
.expect("desktop folder has a React Native project root");
let app_json_path = project_root.join("app.json");
println!("cargo:rerun-if-changed={}", app_json_path.display());
let app_json: serde_json::Value = serde_json::from_slice(
&std::fs::read(&app_json_path)
.unwrap_or_else(|error| panic!("read {}: {error}", app_json_path.display())),
)
.unwrap_or_else(|error| panic!("parse {}: {error}", app_json_path.display()));
let app_name = app_json["name"]
.as_str()
.filter(|name| !name.is_empty())
.expect("app.json must define a nonempty name");
if app_json["desktop"]
.as_object()
.is_some_and(|desktop| !desktop.is_empty())
{
panic!("move `app.json` desktop.* into `desktop/desktop.json` (see the README)");
}
let desktop_json_path = project_root.join("desktop/desktop.json");
println!("cargo:rerun-if-changed={}", desktop_json_path.display());
let desktop_json: serde_json::Value = std::fs::read(&desktop_json_path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or(serde_json::Value::Null);
let app_id = match desktop_json["identifier"].as_str() {
Some(identifier) if !identifier.is_empty() => identifier.to_string(),
_ => format!(
"com.example.{}",
app_name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
),
};
println!("cargo:rustc-env=RN_GPUI_APP_NAME={app_name}");
println!("cargo:rustc-env=RN_GPUI_APP_ID={app_id}");
// Only a debug build names the bundle it was developed against. A release
// binary must find the bundle beside its own executable, because the path
// baked in here is where this machine happened to build it — and on the
// machine the package is installed on, that path does not exist.
println!("cargo:rerun-if-env-changed=PROFILE");
if std::env::var("PROFILE").as_deref() == Ok("debug") {
println!(
"cargo:rustc-env=RN_GPUI_DEFAULT_BUNDLE={}",
desktop_root.join("build/index.bundle").display()
);
}
println!("cargo:rerun-if-env-changed=DEP_RN_GPUI_RUNTIME_RPATHS");
println!("cargo:rerun-if-env-changed=RN_GPUI_PACKAGING");
let Ok(paths) = std::env::var("DEP_RN_GPUI_RUNTIME_RPATHS") else {
return;
};
let dirs = paths.split('|').filter(|path| !path.is_empty());
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
stage_shared_libraries(dirs);
return;
}
// `RN_GPUI_PACKAGING` means this binary is about to be installed somewhere
// else, so only the relative rpaths belong in it. Keeping the absolute ones
// would let a packaged app load this build tree's libraries in preference to
// its own on the machine that built it — which hides exactly the packaging
// bugs the packaged run exists to catch.
let packaging = std::env::var("RN_GPUI_PACKAGING").is_ok();
if !packaging {
for path in dirs {
println!("cargo:rustc-link-arg=-Wl,-rpath,{path}");
}
}
emit_relocatable_rpaths(app_name);
}
/// rpaths that survive being installed somewhere else.
///
/// The absolute paths above let a development build run from this tree. A
/// packaged app is copied to another machine, where those paths do not exist,
/// so a release build also carries paths relative to the executable: the
/// layout the installers produce.
fn emit_relocatable_rpaths(app_name: &str) {
if std::env::var("PROFILE").as_deref() != Ok("release") {
return;
}
let os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let mut emit = |rpath: String| println!("cargo:rustc-link-arg=-Wl,-rpath,{rpath}");
match os.as_str() {
// An app bundle puts the executable in Contents/MacOS and the shipped
// libraries in Contents/Frameworks.
"macos" => emit("@executable_path/../Frameworks".into()),
// Linux installs the executable at `usr/bin` and ships its libraries
// under `usr/lib`:
// - the deb puts them in `usr/lib/<app>`;
// - an AppImage's libraries are flat in `usr/lib`, because that is the
// directory AppRun puts on the library path and the one linuxdeploy
// resolves dependencies from before the image is assembled.
// Both are covered, so one binary works in either layout.
"linux" => {
emit(format!("$ORIGIN/../lib/{app_name}"));
emit("$ORIGIN/../lib".into());
emit("$ORIGIN".into());
}
// Windows has no rpath: the DLLs are copied beside the executable.
_ => {}
}
}
/// Windows has no rpath: the loader finds a DLL next to the executable or on
/// `PATH`, so every directory the runtime reports as holding a shared library
/// (the Hermes binary directory in particular) is copied into the profile
/// directory that receives this app's executable. Without this the app builds
/// and then fails to start with "hermesvm.dll was not found".
fn stage_shared_libraries<'a>(dirs: impl Iterator<Item = &'a str>) {
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is set"));
// <profile>/build/<package>-<hash>/out -> <profile>
let Some(profile_dir) = out_dir.ancestors().nth(3) else {
return;
};
for dir in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let source = entry.path();
let is_shared_library = source
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("dll"));
if !is_shared_library {
continue;
}
let Some(name) = source.file_name() else {
continue;
};
println!("cargo:rerun-if-changed={}", source.display());
std::fs::copy(&source, profile_dir.join(name)).unwrap_or_else(|error| {
panic!("copy {} next to the executable: {error}", source.display())
});
}
}
}
+1
View File
@@ -0,0 +1 @@
{ "identifier": "sh.paseo.gpui" }
+83
View File
@@ -0,0 +1,83 @@
const path = require("node:path");
const { makeMetroConfig } = require("@react-native-gpui/core/metro.config");
const projectRoot = path.resolve(__dirname, "..");
process.env.EXPO_ROUTER_APP_ROOT = path.resolve(projectRoot, "../src/app");
const config = makeMetroConfig({ projectRoot });
const resolveRequest = config.resolver.resolveRequest;
config.watchFolders.push(path.resolve(projectRoot, "../../.."));
config.transformer.unstable_allowRequireContext = true;
config.resolver.disableHierarchicalLookup = false;
config.resolver.blockList = /[/\\]gpui[/\\]desktop[/\\](target|build|autolink)[/\\]/;
const nativePackages = [
"react",
"react-native-reanimated",
"react-native-worklets",
"react-native-gesture-handler",
"react-native-screens",
"react-native-safe-area-context",
"react-native-svg",
];
const adapters = {
"expo-keep-awake": "keep-awake.js",
"expo-image-picker": "image-picker.js",
"expo-image-manipulator": "image-manipulator.js",
"expo-sharing": "sharing.js",
"expo-document-picker": "document-picker.js",
"@mattermost/react-native-paste-input": "paste-input.js",
"@react-native-async-storage/async-storage": "async-storage.js",
"expo-localization": "localization.js",
"expo-clipboard": "clipboard.js",
"expo-file-system": "file-system.js",
"expo-file-system/legacy": "file-system.js",
"react-native-keyboard-controller": "keyboard.jsx",
crypto: "node-crypto.js",
"node:crypto": "node-crypto.js",
"expo-sqlite": "sqlite.js",
"expo-crypto": "crypto.js",
"react-native-unistyles": "unistyles.js",
};
const fileAdapters = {
"/audio/audio-engine.native.ts": "audio.js",
"/components/markdown/fence/mermaid/host.native.tsx": "mermaid.jsx",
"/file-panels/html-preview.tsx": "document-preview.jsx",
"/file-panels/document-frame/index.tsx": "document-preview.jsx",
};
config.resolver.resolveRequest = (context, name, platform) => {
if (adapters[name]) name = path.resolve(projectRoot, "adapters", adapters[name]);
if (
name.endsWith("/ensureNativeModulesAreInstalled") &&
context.originModulePath.includes("expo-modules-core")
)
name = path.resolve(projectRoot, "adapters/expo-install.js");
if (name === "./ExpoLinking" && context.originModulePath.includes("expo-linking"))
name = path.resolve(projectRoot, "adapters/expo-linking.js");
// Linux has no Expo mobile push-token or camera provider; reuse the app's
// existing unavailable-capability implementations.
if (name === "expo-notifications")
name = path.resolve(projectRoot, "../src/fdroid/expo-notifications.ts");
if (name === "expo-camera") name = path.resolve(projectRoot, "../src/fdroid/expo-camera.tsx");
if (name === "expo-router/_ctx") name = path.resolve(projectRoot, "routes.js");
if (name === "qrcode") name = "qrcode/lib/browser.js";
if (name.startsWith("react-native-unistyles/components/native/"))
name = path.resolve(projectRoot, "adapters/unistyles-components.js");
if (name.startsWith("@/")) name = path.resolve(projectRoot, "../src", name.slice(2));
if (
name.startsWith("react-native/") ||
nativePackages.some((pkg) => name === pkg || name.startsWith(`${pkg}/`))
) {
context = {
...context,
disableHierarchicalLookup: true,
nodeModulesPaths: [path.join(projectRoot, "node_modules")],
};
}
const result = resolveRequest(context, name, platform);
const replacement = Object.entries(fileAdapters).find(([suffix]) =>
result.filePath?.endsWith(suffix),
);
if (replacement)
return { type: "sourceFile", filePath: path.resolve(projectRoot, "adapters", replacement[1]) };
return result;
};
module.exports = config;
@@ -0,0 +1,9 @@
import { TurboModuleRegistry, type TurboModule } from "react-native";
export interface Spec extends TurboModule {
sqlite(sql: string, params: string): string;
locale(): string;
timezone(): string;
sha256(text: string): string;
randomBytes(length: number): Array<number>;
}
export default TurboModuleRegistry.getEnforcing<Spec>("PaseoDesktop");
+29
View File
@@ -0,0 +1,29 @@
rn_gpui_module::include_specs!();
mod services;
fn main() {
react_native_gpui::register_module(native_paseo_desktop_module::<services::PaseoDesktop>());
rn_gpui_autolink::ensure_linked();
// A debug build knows where its own bundle is; a release build does not, and
// must not: the path would name the machine that built it. A packaged release
// app resolves the bundle beside its executable instead.
#[cfg(debug_assertions)]
if std::env::var_os("RN_GPUI_BUNDLE").is_none() {
std::env::set_var("RN_GPUI_BUNDLE", env!("RN_GPUI_DEFAULT_BUNDLE"));
}
let config = react_native_gpui::DesktopAppConfig::from_env_with_defaults(
env!("RN_GPUI_APP_NAME"),
env!("RN_GPUI_APP_ID"),
)
.window_title("Paseo GPUI")
.window_size(1024.0, 768.0)
.dev_menu(true)
.configure_window(|options| {
// GPUI window options are public: titlebar, resizability, display,
// decorations, min size, and other native window behavior.
options.is_resizable = true;
});
react_native_gpui::run_with(config);
}
+174
View File
@@ -0,0 +1,174 @@
use crate::{NativePaseoDesktopEvents, NativePaseoDesktopSpec};
use rn_gpui_module::ModuleError;
use std::io::Read;
pub struct PaseoDesktop {
database: std::sync::Mutex<Option<rusqlite::Connection>>,
}
impl PaseoDesktop {
fn database(
&self,
) -> Result<std::sync::MutexGuard<'_, Option<rusqlite::Connection>>, ModuleError> {
let mut guard = self
.database
.lock()
.map_err(|e| ModuleError::new(e.to_string()))?;
if guard.is_none() {
let base = std::env::var("XDG_DATA_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| {
std::path::PathBuf::from(std::env::var("HOME").expect("HOME is set"))
.join(".local/share")
});
let dir = base.join("paseo-gpui");
std::fs::create_dir_all(&dir).map_err(|e| ModuleError::new(e.to_string()))?;
*guard = Some(
rusqlite::Connection::open(dir.join("replica.db"))
.map_err(|e| ModuleError::new(e.to_string()))?,
);
}
Ok(guard)
}
}
impl NativePaseoDesktopSpec for PaseoDesktop {
fn new(_events: NativePaseoDesktopEvents) -> Self {
Self {
database: std::sync::Mutex::new(None),
}
}
fn sqlite(&self, sql: String, params: String) -> Result<String, ModuleError> {
let fail = |e: rusqlite::Error| ModuleError::new(e.to_string());
let db = self.database()?;
let db = db.as_ref().unwrap();
if params == "batch" {
db.execute_batch(&sql).map_err(fail)?;
return Ok("[]".into());
}
let values: Vec<serde_json::Value> =
serde_json::from_str(&params).map_err(|e| ModuleError::new(e.to_string()))?;
let values: Vec<rusqlite::types::Value> = values
.into_iter()
.map(|v| match v {
serde_json::Value::Null => rusqlite::types::Value::Null,
serde_json::Value::String(v) => rusqlite::types::Value::Text(v),
serde_json::Value::Number(v) => v
.as_i64()
.map(rusqlite::types::Value::Integer)
.unwrap_or_else(|| rusqlite::types::Value::Real(v.as_f64().unwrap())),
_ => rusqlite::types::Value::Text(v.to_string()),
})
.collect();
let mut stmt = db.prepare(&sql).map_err(fail)?;
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
let mut rows = stmt
.query(rusqlite::params_from_iter(values))
.map_err(fail)?;
let mut results = Vec::new();
while let Some(row) = rows.next().map_err(fail)? {
let mut object = serde_json::Map::new();
for (index, name) in names.iter().enumerate() {
let value = match row.get_ref(index).map_err(fail)? {
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
rusqlite::types::ValueRef::Integer(v) => v.into(),
rusqlite::types::ValueRef::Real(v) => serde_json::json!(v),
rusqlite::types::ValueRef::Text(v) => {
String::from_utf8_lossy(v).into_owned().into()
}
rusqlite::types::ValueRef::Blob(_) => {
return Err(ModuleError::new(
"Replica store does not support blob columns",
))
}
};
object.insert(name.clone(), value);
}
results.push(object);
}
serde_json::to_string(&results).map_err(|e| ModuleError::new(e.to_string()))
}
fn timezone(&self) -> Result<String, ModuleError> {
if let Ok(zone) = std::env::var("TZ") {
if !zone.is_empty() {
return Ok(zone.trim_start_matches(':').into());
}
}
if let Ok(path) = std::fs::read_link("/etc/localtime") {
if let Some((_, zone)) = path.to_string_lossy().split_once("/zoneinfo/") {
return Ok(zone.into());
}
}
Ok(std::fs::read_to_string("/etc/timezone")
.map(|s| s.trim().into())
.unwrap_or_else(|_| "UTC".into()))
}
fn locale(&self) -> Result<String, ModuleError> {
Ok(["LC_ALL", "LC_MESSAGES", "LANG"]
.iter()
.find_map(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| "en_US.UTF-8".into()))
}
fn sha256(&self, text: String) -> Result<String, ModuleError> {
use sha2::{Digest, Sha256};
Ok(format!("{:x}", Sha256::digest(text.as_bytes())))
}
fn random_bytes(&self, length: f64) -> Result<Vec<f64>, ModuleError> {
if !length.is_finite() || length < 0.0 || length > 65536.0 || length.fract() != 0.0 {
return Err(ModuleError::new("Invalid random byte count"));
}
let mut bytes = vec![0u8; length as usize];
std::fs::File::open("/dev/urandom")
.and_then(|mut source| source.read_exact(&mut bytes))
.map_err(|error| ModuleError::new(error.to_string()))?;
Ok(bytes.into_iter().map(f64::from).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn service() -> PaseoDesktop {
PaseoDesktop {
database: std::sync::Mutex::new(Some(rusqlite::Connection::open_in_memory().unwrap())),
}
}
#[test]
fn crypto_matches_sha256_and_bounds_random_requests() {
let service = service();
assert_eq!(
service.sha256("abc".into()).unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(service.random_bytes(32.0).unwrap().len(), 32);
assert!(service.random_bytes(65537.0).is_err());
assert!(service.random_bytes(-1.0).is_err());
}
#[test]
fn replica_transactions_bind_values_and_roll_back() {
let service = service();
service
.sqlite(
"CREATE TABLE rows (key TEXT PRIMARY KEY, value TEXT);".into(),
"batch".into(),
)
.unwrap();
service
.sqlite(
"INSERT INTO rows VALUES (?, ?)".into(),
r#"["key","quoted ' value"]"#.into(),
)
.unwrap();
service
.sqlite(
"BEGIN IMMEDIATE; DELETE FROM rows; ROLLBACK;".into(),
"batch".into(),
)
.unwrap();
let result = service
.sqlite("SELECT key, value FROM rows".into(), "[]".into())
.unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
serde_json::json!([{"key":"key","value":"quoted ' value"}])
);
}
}
+10
View File
@@ -0,0 +1,10 @@
/* oxlint-disable import/no-unassigned-import -- Install globals before app modules evaluate. */
import "./adapters/intl";
import "../src/polyfills/install-early";
import "../src/styles/unistyles";
import { AppRegistry } from "react-native";
import { RootApp } from "../src/root-app";
// GPUI owns the native surface. Keep Paseo's full router and providers, while
// leaving Expo's mobile registration and WebView-based dev overlay out.
AppRegistry.registerComponent("Paseo", () => RootApp);
+103
View File
@@ -0,0 +1,103 @@
import { afterAll, beforeAll, expect, test } from "vitest";
// Runs against the actual Paseo bundle in the compiled GPUI app. Start with
// RN_GPUI_TEST_DRIVER=1 and pass its loopback URL and token to this test.
const endpoint = process.env.PASEO_GPUI_TEST_URL;
const token = process.env.PASEO_GPUI_TEST_TOKEN;
let socket: WebSocket;
let nextId = 0;
interface Node {
tag: number;
component: string;
testID: string;
text: string;
children?: Node[];
}
const pending = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
function request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`GPUI ${method} timed out`));
}, 15_000);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value as T);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
socket.send(JSON.stringify({ jsonrpc: "2.0", id, method, params: { ...params, token } }));
});
}
function flatten(node: Node): Node[] {
return [node, ...(node.children ?? []).flatMap(flatten)];
}
async function nodes() {
return (await request<{ children: Node[] }>("tree")).children.flatMap(flatten);
}
beforeAll(async () => {
if (!endpoint) return;
if (!token) throw new Error("PASEO_GPUI_TEST_TOKEN is required");
socket = new WebSocket(endpoint);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve(), { once: true });
socket.addEventListener("error", () => reject(new Error("GPUI driver connection failed")), {
once: true,
});
});
socket.addEventListener("message", (event) => {
const response = JSON.parse(String(event.data));
const waiter = pending.get(response.id);
if (!waiter) return;
pending.delete(response.id);
const error = response.error ?? response.result?.error;
if (error) waiter.reject(new Error(JSON.stringify(error)));
else waiter.resolve(response.result);
});
});
afterAll(() => socket?.close());
test.skipIf(!endpoint)(
"real Paseo portal passes workspace clicks and the native composer edits text",
async () => {
const workspace = (await nodes()).find((node) =>
node.testID.startsWith("sidebar-workspace-row-"),
);
expect(workspace, "connected daemon must have an existing workspace").toBeDefined();
// Regression: the upstream box-none portal hit area intercepted this click.
expect(await request("click", { tag: workspace!.tag })).toEqual({ clicked: true });
await expect
.poll(async () => (await nodes()).some((node) => node.testID === "workspace-header-title"), {
timeout: 15_000,
})
.toBe(true);
await expect
.poll(async () => (await nodes()).some((node) => node.component === "TextInput"), {
timeout: 15_000,
})
.toBe(true);
const input = (await nodes()).find((node) => node.component === "TextInput")!;
const original = (await request<{ text: string }>("getProps", { tag: input.tag })).text;
try {
await request("click", { tag: input.tag });
await request("press", { key: "ctrl-a" });
await request("press", { key: "backspace" });
expect(
await request("type", { tag: input.tag, text: "GPUI native input regression" }),
).toEqual({ typed: true });
await expect
.poll(async () => (await request<{ text: string }>("getProps", { tag: input.tag })).text)
.toBe("GPUI native input regression");
} finally {
await request("press", { key: "ctrl-a" });
await request("press", { key: "backspace" });
if (original) await request("type", { tag: input.tag, text: original });
}
},
45_000,
);
+6661
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "paseo-gpui",
"version": "0.0.0",
"private": true,
"dependencies": {
"@formatjs/intl-datetimeformat": "^7.9.0",
"@formatjs/intl-displaynames": "^7.4.0",
"@formatjs/intl-getcanonicallocales": "^3.3.0",
"@formatjs/intl-locale": "^5.4.1",
"@formatjs/intl-numberformat": "^9.5.0",
"@formatjs/intl-pluralrules": "^6.4.0",
"@formatjs/intl-segmenter": "^12.3.0",
"@react-native-gpui/codegen": "file:../../../vendor/react-native-gpui/packages/codegen",
"@react-native-gpui/core": "file:../../../vendor/react-native-gpui/packages/core",
"@react-native-gpui/fs": "file:../../../vendor/react-native-gpui/modules/fs",
"@react-native-gpui/gesture-handler": "file:../../../vendor/react-native-gpui/modules/gesture-handler",
"@react-native-gpui/hermes": "file:../../../vendor/react-native-gpui/packages/hermes",
"@react-native-gpui/reanimated": "file:../../../vendor/react-native-gpui/modules/reanimated",
"@react-native-gpui/safe-area-context": "file:../../../vendor/react-native-gpui/modules/safe-area-context",
"@react-native-gpui/screens": "file:../../../vendor/react-native-gpui/modules/screens",
"@react-native-gpui/svg": "file:../../../vendor/react-native-gpui/modules/svg",
"@react-native-gpui/worklets": "file:../../../vendor/react-native-gpui/modules/worklets",
"@react-native/assets-registry": "^0.87.1",
"react": "19.2.3",
"react-native": "0.87.1",
"react-native-gesture-handler": "3.3.0",
"react-native-reanimated": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/4.7.1-desktop.1/react-native-reanimated-4.7.1.tgz",
"react-native-safe-area-context": "5.10.1",
"react-native-screens": "4.28.0",
"react-native-svg": "15.15.5",
"react-native-worklets": "https://github.com/ammarahm-ed/react-native-reanimated/releases/download/worklets-0.13.0-desktop.1/react-native-worklets-0.13.0.tgz"
},
"devDependencies": {
"@react-native-community/cli": "20.2.0",
"@react-native-gpui/cli": "file:../../../vendor/react-native-gpui/packages/cli",
"@react-native/babel-preset": "0.87.1",
"@react-native/metro-config": "0.87.1",
"typescript": "^5.8.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="@react-native-gpui/core/types" />
// Added by react-native-gpui. Do not edit: it is regenerated.
+17
View File
@@ -0,0 +1,17 @@
const path = require("node:path");
module.exports = {
// This target only launches desktop. RN CLI 20.2's built-in
// run-ios/run-android option lists conflict with the CLI's global verbose
// option, and this app has no native mobile projects to launch.
commands: ["build-android", "build-ios", "log-android", "log-ios", "run-android", "run-ios"].map(
(name) => ({
name,
description: `${name} is not configured for Paseo GPUI`,
func: () => {
throw new Error(`${name} is not configured for Paseo GPUI`);
},
}),
),
project: { desktop: {} },
reactNativePath: path.dirname(require.resolve("react-native/package.json")),
};
+7
View File
@@ -0,0 +1,7 @@
// Use the existing route tree. Expo's transformer normally supplies this root
// through a Babel caller field; the RN desktop transformer does not.
export const ctx = require.context(
"../src/app",
true,
/^(?:\.\/)(?!(?:(?:(?:.*\+api)|(?:\+(html|native-intent))))\.[tj]sx?$).*\.[tj]sx?$/,
);
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
profile = "minimal"
@@ -1,21 +1,21 @@
[
{
"output": "स्ते",
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्ष",
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "নক্ষত্র",
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
"clusterStarts": [0, 1, 3, 5, 6, 7],
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
},
{
"output": "क्अ",
@@ -31,7 +31,7 @@
},
{
"output": "अ्क",
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
}
@@ -1,5 +1,6 @@
package sh.paseo.terminal
import android.os.Build
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
@@ -43,6 +44,13 @@ class TerminalRowLayoutTest {
val output = row.getString("output")
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
continue
}
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
}
+5 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -71,14 +71,13 @@
"@tanstack/react-virtual": "^3.13.21",
"@xterm/addon-clipboard": "^0.3.0-beta.213",
"@xterm/addon-fit": "^0.12.0-beta.213",
"@xterm/addon-image": "0.10.0-beta.331",
"@xterm/addon-image": "^0.10.0-beta.213",
"@xterm/addon-ligatures": "^0.11.0-beta.213",
"@xterm/addon-search": "^0.17.0-beta.213",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/addon-unicode11": "^0.10.0-beta.213",
"@xterm/addon-web-links": "^0.13.0-beta.213",
"@xterm/addon-webgl": "0.20.0-beta.329",
"@xterm/xterm": "6.1.0-beta.336",
"@xterm/addon-webgl": "0.20.0-beta.291",
"@xterm/xterm": "6.1.0-beta.292",
"buffer": "^6.0.3",
"expo": "~57.0.30",
"expo-asset": "~57.0.20",
@@ -156,7 +155,7 @@
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "6.1.0-beta.334",
"@xterm/headless": "^6.1.0-beta.213",
"babel-preset-expo": "~57.0.16",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
+33 -5
View File
@@ -6,12 +6,15 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import {
parseConnectionUri,
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -136,11 +139,37 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function isIpv6Host(host: string): boolean {
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
}
function buildConnectionUriFromDraft(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): string {
const host = draft.host.trim();
const port = Number(draft.port.trim());
if (!host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host,
port,
isIpv6: isIpv6Host(host),
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
function prepareDirectConnection(
draft: DirectConnectionDraft,
labels: DirectConnectionLabels,
): PreparedDirectConnection {
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
const endpoint = parsed.isIpv6
? `[${parsed.host}]:${parsed.port}`
: `${parsed.host}:${parsed.port}`;
@@ -391,7 +420,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
advancedTarget.current = new PairingTargetTracker("", true);
try {
setAdvancedUri(
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
);
} catch {
setAdvancedUri("");
@@ -476,7 +505,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityRole="checkbox"
accessibilityLabel={t("pairing.direct.fields.useSsl")}
accessibilityState={useTlsAccessibilityState}
aria-checked={useTls}
testID="direct-ssl-toggle"
>
<View style={checkboxStyle}>
@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
const labels = {
hostRequired: "host required",
invalidPort: "invalid port",
};
function buildUri(host: string, port = "6767"): string {
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
}
describe("normalizeDirectConnectionHost", () => {
it.each([
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
["[::1]", { host: "::1", port: null, isIpv6: true }],
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
["::1", { host: "::1", port: null, isIpv6: true }],
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
["", { host: "", port: null, isIpv6: false }],
])("normalizes %j", (input, expected) => {
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
});
});
describe("buildDirectConnectionUri", () => {
it.each([
["100.66.69.36", "tcp://100.66.69.36:6767"],
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
["my-host.local:6767", "tcp://my-host.local:6767"],
["[::1]", "tcp://[::1]:6767"],
["[::1]:7000", "tcp://[::1]:7000"],
["::1", "tcp://[::1]:6767"],
["fe80::1", "tcp://[fe80::1]:6767"],
])("builds a tcp URI from host %j", (host, expected) => {
expect(buildUri(host)).toBe(expected);
});
it("lets a port typed in the host field override the port field", () => {
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
});
it("keeps TLS and password settings", () => {
expect(
buildDirectConnectionUri(
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
labels,
),
).toBe("tcp://example.test:443?ssl=true&password=secret");
});
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
expect(() => buildUri(host)).toThrow("host required");
});
it("rejects an invalid port", () => {
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
});
});
@@ -1,70 +0,0 @@
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
export interface DirectConnectionHostInput {
host: string;
port: string;
useTls: boolean;
password: string;
}
export interface DirectConnectionHostLabels {
hostRequired: string;
invalidPort: string;
}
export interface NormalizedDirectConnectionHost {
host: string;
/** Port typed into the host field, if any. Overrides the separate port field. */
port: string | null;
isIpv6: boolean;
}
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
/**
* Users often paste `host:port` or a full URL into the host field.
* Reduce it to a bare host (plus an optional port) before building the URI.
*/
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
let host = input.trim().replace(SCHEME_PATTERN, "");
const suffixIndex = host.search(/[/?#]/);
if (suffixIndex !== -1) {
host = host.slice(0, suffixIndex);
}
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
if (bracketed) {
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
}
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
if (hostWithPort) {
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
}
return { host, port: null, isIpv6: host.split(":").length > 2 };
}
export function buildDirectConnectionUri(
draft: DirectConnectionHostInput,
labels: DirectConnectionHostLabels,
): string {
const normalized = normalizeDirectConnectionHost(draft.host);
const port = Number((normalized.port ?? draft.port).trim());
if (!normalized.host) {
throw new Error(labels.hostRequired);
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(labels.invalidPort);
}
return serializeConnectionUriForStorage({
host: normalized.host,
port,
isIpv6: normalized.isIpv6,
useTls: draft.useTls,
...(draft.password ? { password: draft.password } : {}),
});
}
+2 -2
View File
@@ -15,7 +15,7 @@ import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mut
import { TaskListRow } from "@/components/task-list-row";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
import { useRevealedText, type MarkdownPhase } from "@/hooks/use-revealed-text";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -1800,7 +1800,7 @@ function computeShimmerMetrics(input: {
// React Native Web only observes a node when onLayout exists at mount. Keep
// measuring while idle so a retained badge has dimensions when it starts loading.
const shouldMeasureWebShimmer = isWeb;
const shouldMeasureNativeShimmer = input.isLoading && isNative;
const shouldMeasureNativeShimmer = input.isLoading && isNative && supportsMaskedEffects;
const isNativeShimmer =
shouldMeasureNativeShimmer && input.labelRowWidth > 0 && input.labelRowHeight > 0;
const webShimmerSpanStartX = input.labelOffsetX;
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
const items = entries(Array(count).fill("done"));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
expect(result.current.canToggle).toBe(count > 7);
});
@@ -471,9 +471,7 @@ describe("sidebar group visibility", () => {
"keeps row twelve visible for %s without filling gaps",
(status) => {
const items = entries([...Array(11).fill("done"), status]);
const { result } = renderHook(() =>
useLimitedSidebarGroup({ items, selection: null, policy }),
);
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(names(result.current.visibleItems)).toEqual([
"row-1",
"row-2",
@@ -494,11 +492,7 @@ describe("sidebar group visibility", () => {
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
for (const entriesByKey of [new Map(), hydrated]) {
const { result } = renderHook(() =>
useLimitedSidebarGroup({
items,
selection: null,
policy: createWorkspaceVisibilityPolicy(entriesByKey),
}),
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
);
expect(result.current.canToggle).toBe(true);
expect(names(result.current.visibleItems)).toEqual(
@@ -518,7 +512,7 @@ describe("sidebar group visibility", () => {
"needs_input",
"failed",
]);
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(result.current.visibleItems).toEqual(items);
expect(result.current.canToggle).toBe(false);
});
@@ -533,7 +527,7 @@ describe("sidebar group visibility", () => {
"attention",
]);
const { result, rerender, unmount } = renderHook(
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
({ rows }) => useLimitedSidebarGroup(rows, policy),
{ initialProps: { rows: items } },
);
expect(names(result.current.visibleItems)).toEqual([
@@ -574,7 +568,7 @@ describe("sidebar group visibility", () => {
expect(result.current.expanded).toBe(false);
act(() => result.current.toggleExpanded());
unmount();
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
expect(remounted.result.current.expanded).toBe(false);
expect(remounted.result.current.visibleItems).toHaveLength(10);
});
@@ -598,12 +592,7 @@ describe("sidebar group visibility", () => {
lifecycle: "active" as const,
};
const { result, rerender } = renderHook(
({ last }) =>
useLimitedSidebarGroup({
items: [...items.slice(0, 11), last],
selection: null,
policy,
}),
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
{ initialProps: { last: items[11] } },
);
expect(result.current.visibleItems).toHaveLength(7);
@@ -641,7 +630,7 @@ describe("sidebar group visibility", () => {
it("preserves the twenty-row pinned policy regardless of status", () => {
const items = entries(Array(21).fill("running"));
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
const { result } = renderHook(() => useLimitedSidebarGroup(items));
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
expect(result.current.canToggle).toBe(true);
act(() => result.current.toggleExpanded());
@@ -1598,16 +1598,11 @@ function ProjectBlock({
[workspaceEntriesByKey],
);
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: project.workspaces,
selection: selectionEnabled ? activeWorkspaceSelection : null,
policy: visibilityPolicy,
});
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
@@ -1766,33 +1761,28 @@ function ProjectBlock({
let projectChildren = null;
if (!collapsed) {
if (project.workspaces.length > 0) {
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
projectChildren = (
<>
{manualSort ? (
<>
{leadingWorkspaceRow}
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
</>
<DraggableList
testID={`sidebar-workspace-list-${project.viewKey}`}
data={visibleWorkspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
) : (
<View
testID={`sidebar-workspace-list-${project.viewKey}`}
style={styles.workspaceListContainer}
>
{leadingWorkspaceRow}
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
</View>
)}
@@ -2198,15 +2188,11 @@ function ProjectModeList({
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const { pinnedChats, unpinnedProjects } = pinnedGroups;
const {
leadingItem: leadingPinnedChat,
visibleItems: visiblePinnedChats,
expanded: pinnedChatsExpanded,
canToggle: canTogglePinnedChats,
toggleExpanded: togglePinnedChatsExpanded,
} = useLimitedSidebarGroup({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
});
} = useLimitedSidebarGroup(pinnedChats);
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
@@ -2409,38 +2395,37 @@ function ProjectModeList({
[renderProjectBlock],
);
const renderPinnedChatRow = useCallback(
(
workspace: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
<MemoWorkspaceRowItem
key={workspace.workspaceKey}
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
),
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return (
<MemoWorkspaceRowItem
workspace={workspace}
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
leadingProjectName={workspace.projectName}
leadingProjectIconDataUri={
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
}
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canCopyBranchName={workspace.projectKind === "git"}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
);
},
[
activeWorkspaceSelection,
creatingWorkspaceIds,
@@ -2456,22 +2441,6 @@ function ProjectModeList({
],
);
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderPinnedChatRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
});
},
[renderPinnedChatRow],
);
const projectBody =
projects.length === 0 ? (
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
@@ -2504,7 +2473,6 @@ function ProjectModeList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedChats}
@@ -1,137 +0,0 @@
import { describe, expect, it } from "vitest";
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
interface Item extends SidebarGroupItem {
id: number;
statusBucket: SidebarStateBucket;
}
const serverId = "host-a";
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
return statuses.map((statusBucket, index) => ({
id: index + 1,
serverId: rowServerId,
workspaceId: `workspace-${index + 1}`,
statusBucket,
}));
}
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
kind: "workspaces",
getStatusBucket: (item) => item.statusBucket,
};
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
return { serverId: item.serverId, workspaceId: item.workspaceId };
}
describe("selectSidebarGroupVisibility", () => {
it("leads with a hidden open row while keeping the baseline order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBe(items[8]);
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("leaves selected rows in place when they are already in the baseline", () => {
const items = rows([...Array(8).fill("done"), "running", "done"]);
const expected = [...items.slice(0, 7), items[8]];
for (const selectedIndex of [2, 8]) {
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[selectedIndex]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(expected);
expect(result.canToggle).toBe(true);
}
});
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: selectionFor(items[7]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(false);
});
it("keeps expanded groups in their original order", () => {
const items = rows(Array(9).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: true,
policy: workspacePolicy,
selection: selectionFor(items[8]!),
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items);
expect(result.canToggle).toBe(true);
});
it("applies the twenty-row limit to pinned groups", () => {
const items = rows(Array(22).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: { kind: "pinned" },
selection: selectionFor(items[21]!),
});
expect(result.leadingItem).toBe(items[21]);
expect(result.visibleItems).toEqual(items.slice(0, 20));
expect(result.canToggle).toBe(true);
});
it("preserves the current limit when selection is null", () => {
const items = rows(Array(8).fill("done"));
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: null,
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
it("matches the open row by server and workspace identity", () => {
const items = rows(Array(9).fill("done"), "host-b");
const result = selectSidebarGroupVisibility({
items,
expanded: false,
policy: workspacePolicy,
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
});
expect(result.leadingItem).toBeNull();
expect(result.visibleItems).toEqual(items.slice(0, 7));
expect(result.canToggle).toBe(true);
});
});
@@ -1,28 +1,4 @@
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
export interface SidebarGroupItem {
serverId: string;
workspaceId: string;
}
export interface SidebarGroupVisibilityInput<T> {
items: readonly T[];
expanded: boolean;
policy: SidebarGroupVisibilityPolicy<T>;
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
selection: ActiveWorkspaceSelection | null;
}
export interface SidebarGroupVisibility<T> {
/**
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
* out of group order, outside any drag list. Never also present in `visibleItems`.
*/
leadingItem: T | null;
visibleItems: T[];
canToggle: boolean;
}
export type SidebarGroupVisibilityPolicy<T> =
| { kind: "pinned" }
@@ -40,45 +16,18 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
};
}
function isOpenWorkspace(
item: SidebarGroupItem,
selection: ActiveWorkspaceSelection | null,
): boolean {
return (
selection !== null &&
item.serverId === selection.serverId &&
item.workspaceId === selection.workspaceId
);
}
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
input: SidebarGroupVisibilityInput<T>,
): SidebarGroupVisibility<T> {
const { items, expanded, policy, selection } = input;
export function selectSidebarGroupVisibility<T>(
items: readonly T[],
expanded: boolean,
policy: SidebarGroupVisibilityPolicy<T>,
) {
const limit = policy.kind === "pinned" ? 20 : 7;
const baseline: T[] = [];
let leadingItem: T | null = null;
let canToggle = false;
items.forEach((item, index) => {
const inBaseline =
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
if (inBaseline) {
baseline.push(item);
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
leadingItem = item;
} else {
canToggle = true;
}
});
if (expanded || !canToggle) {
return { leadingItem: null, visibleItems: items.slice(), canToggle };
}
const baseline = items.filter(
(item, index) =>
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
);
return {
leadingItem,
visibleItems: baseline,
canToggle,
visibleItems: expanded ? items.slice() : baseline,
canToggle: baseline.length < items.length,
};
}
@@ -147,7 +147,6 @@ export function SidebarStatusWorkspaceList({
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -156,27 +155,23 @@ export function SidebarStatusWorkspaceList({
(state) => state.togglePinnedCollapsed,
);
const {
leadingItem: leadingPinnedWorkspace,
visibleItems: visiblePinnedWorkspaces,
expanded: pinnedWorkspacesExpanded,
canToggle: canTogglePinnedWorkspaces,
toggleExpanded: togglePinnedWorkspacesExpanded,
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
} = useLimitedSidebarGroup(pinnedWorkspaces);
const statusShortcutIndex = showShortcutBadges
? shortcutIndexByWorkspaceKey
: EMPTY_SHORTCUT_INDEX;
const renderPinnedWorkspaceRow = useCallback(
(
workspace: SidebarWorkspaceEntry,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
) => (
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
@@ -189,9 +184,9 @@ export function SidebarStatusWorkspaceList({
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
/>
),
[
@@ -204,20 +199,6 @@ export function SidebarStatusWorkspaceList({
supportsPinningByServerId,
],
);
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
renderPinnedWorkspaceRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
}),
[renderPinnedWorkspaceRow],
);
const content = (
<>
{pinnedWorkspaces.length > 0 ? (
@@ -230,7 +211,6 @@ export function SidebarStatusWorkspaceList({
/>
{pinnedCollapsed ? null : (
<>
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
<DraggableList
testID="sidebar-pinned-list"
data={visiblePinnedWorkspaces}
@@ -365,37 +345,12 @@ function StatusGroupRows({
supportsPinningByServerId: ReadonlyMap<string, boolean>;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const {
leadingItem: leadingWorkspace,
visibleItems: visibleWorkspaces,
expanded: workspacesExpanded,
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup({
items: group.rows,
selection: activeWorkspaceSelection,
policy: STATUS_GROUP_VISIBILITY,
});
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
return (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
);
}
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
return (
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
@@ -405,8 +360,22 @@ function StatusGroupRows({
style={styles.statusWorkspaceListContainer}
testID={`sidebar-status-group-rows-${group.key}`}
>
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
{visibleWorkspaces.map(renderWorkspaceRow)}
{visibleWorkspaces.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
{...buildStatusRowProjectPresentation({
workspace,
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
/>
))}
{canToggleWorkspaces ? (
<SidebarGroupToggleRow
expanded={workspacesExpanded}
@@ -1,32 +1,19 @@
import { useCallback, useMemo, useState } from "react";
import {
selectSidebarGroupVisibility,
type SidebarGroupItem,
type SidebarGroupVisibility,
type SidebarGroupVisibilityPolicy,
} from "./sidebar-group-visibility";
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
const PINNED_POLICY = { kind: "pinned" } as const;
interface LimitedSidebarGroupInput<T> {
items: readonly T[];
selection: ActiveWorkspaceSelection | null;
policy?: SidebarGroupVisibilityPolicy<T>;
}
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
items,
selection,
policy = PINNED_POLICY,
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
expanded: boolean;
toggleExpanded: () => void;
} {
export function useLimitedSidebarGroup<T>(
items: readonly T[],
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
) {
const [expanded, setExpanded] = useState(false);
const visibility = useMemo(
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
[expanded, items, policy, selection],
() => selectSidebarGroupVisibility(items, expanded, policy),
[expanded, items, policy],
);
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
@@ -12,7 +12,6 @@ import type {
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -33,7 +32,6 @@ export interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -93,7 +93,6 @@ function NativeTerminalEmulator({
testId = "terminal-surface",
xtermTheme = DEFAULT_XTERM_THEME,
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
keyboardInset = 0,
@@ -374,7 +373,6 @@ function NativeTerminalEmulator({
rows: size.rows,
cols: size.cols,
scrollbackLines,
characterWidths,
});
terminalRef.current = terminal;
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
@@ -389,7 +387,6 @@ function NativeTerminalEmulator({
resetInputModeTracker,
resolveTerminalScreen,
scrollbackLines,
characterWidths,
clearSelection,
]);
@@ -27,7 +27,6 @@ import {
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste";
import type { TerminalCharacterWidths } from "../terminal/character-widths";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -97,7 +96,6 @@ interface TerminalEmulatorProps {
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
characterWidths: TerminalCharacterWidths;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
@@ -156,7 +154,6 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
characterWidths,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
@@ -186,8 +183,6 @@ export default function TerminalEmulator({
const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
const characterWidthsRef = useRef(characterWidths);
characterWidthsRef.current = characterWidths;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
@@ -320,10 +315,6 @@ export default function TerminalEmulator({
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
}, [scrollbackLines]);
useEffect(() => {
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
}, [characterWidths]);
useEffect(() => {
const root = rootRef.current;
if (!root || !swipeGesturesEnabled) {
@@ -456,7 +447,6 @@ export default function TerminalEmulator({
},
});
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
runtime.mount({
root,
host,
@@ -1,6 +1,5 @@
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -305,11 +304,6 @@ export function TerminalPane({
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
? "graphemes"
: "legacy",
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
@@ -1177,7 +1171,6 @@ export function TerminalPane({
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={terminalStreamKey}
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
characterWidths={characterWidths}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
+3
View File
@@ -25,6 +25,9 @@ export const isWeb = Platform.OS === "web";
/** iOS or Android — the JS runtime is React Native. */
export const isNative = Platform.OS !== "web";
/** Platforms with an alpha-mask compositor for decorative shimmer effects. */
export const supportsMaskedEffects = ["web", "ios", "android"].includes(Platform.OS);
/** Development build/runtime — true in Metro dev bundles, false in production. */
export const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -16,8 +16,7 @@ import {
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { deriveProjectPlacementFromCwd } from "@/utils/project-placement";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
import { useWorkspaceLabels } from "@/workspace-labels";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
@@ -42,15 +41,12 @@ class FakeDirectoryClient {
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
observeAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
observeWorkspacesCalls = 0;
lastWorkspaceOptions: unknown;
listProjectsCalls = 0;
lastProjectOptions: unknown;
projectResult: ProjectListResult | null = null;
readonly listSnapshots: Promise<unknown>[] = [];
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
@@ -155,22 +151,15 @@ class FakeDirectoryClient {
}
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
this.observeAgentsCalls += 1;
const subscription = subscriptionFixture(
this.fetchAgents({ ...options, subscribe: {} }),
(receive) => this.on("agent_update", receive),
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
this.on("agent_update", receive),
);
this.listSnapshots.push(subscription.ready);
return subscription;
}
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
this.observeWorkspacesCalls += 1;
const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
return subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
this.on("workspace_update", receive),
);
this.listSnapshots.push(subscription.ready);
return subscription;
}
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
@@ -603,83 +592,6 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it.each([
["full", "before"],
["full", "after"],
["route", "before"],
["route", "after"],
] as const)(
"restores live directory updates when %s demand returns %s refresh completion",
async (demand, timing) => {
const serverId = `released-directory-${demand}-${timing}`;
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
const source = {};
function setDemand(demanded: boolean): void {
if (demand === "full") directory.setDemand(source, demanded);
else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []);
}
const releaseProjects = client.holdProjectFetch();
const projects: ProjectListResult = { requestId: "projects", projects: [] };
try {
setDemand(true);
const refresh = directory.refreshDemand();
await expect
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
.toEqual([1, 1]);
await Promise.all(client.listSnapshots);
setDemand(false);
if (timing === "before") setDemand(true);
releaseProjects(projects);
await refresh;
if (timing === "after") setDemand(true);
await expect
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
.toEqual([2, 2]);
client.emit({
type: "agent_update",
payload: {
kind: "upsert",
agent: {
...projectAgentSnapshot(createAgent(serverId, "agent-1")),
title: "Live title",
},
project: deriveProjectPlacementFromCwd("/repo"),
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" },
},
});
await expect
.poll(() => {
const session = useSessionStore.getState().sessions[serverId];
return [
session.agents.get("agent-1")?.title,
session.workspaces.get("workspace-1")?.name,
];
})
.toEqual(["Live title", "Live workspace"]);
} finally {
directory.dispose();
}
},
);
it("publishes cached directory data before the authoritative request completes", async () => {
const serverId = "cached-directory";
serverIds.add(serverId);
@@ -160,7 +160,6 @@ export class DirectorySync {
private readonly fullDemandSources = new Set<object>();
private readonly scriptStatusDemand = new Map<object, string>();
private demandRefresh: Promise<void> | null = null;
private subscriptionGeneration = 0;
private satisfiedDemandSource: DirectorySourceToken | null = null;
private cursors: DirectoryCheckpoint = {};
@@ -263,7 +262,6 @@ export class DirectorySync {
}
private releaseSubscriptions(): void {
this.subscriptionGeneration += 1;
for (const subscription of [
this.agentSubscription,
this.workspaceSubscription,
@@ -364,7 +362,6 @@ export class DirectorySync {
this.observeDirectoryEvents();
this.observeScriptStatusEvents();
const source = this.connection.source;
const subscriptionGeneration = this.subscriptionGeneration;
if (
!force &&
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
@@ -381,19 +378,17 @@ export class DirectorySync {
]).then(() => undefined);
this.demandRefresh = refresh
.then(() => {
if (this.subscriptionGeneration === subscriptionGeneration) {
this.satisfiedDemandSource = source;
}
this.satisfiedDemandSource = source;
return undefined;
})
.finally(() => {
this.demandRefresh = null;
const current = this.connection.source;
const sourceChanged =
current.clientGeneration !== source.clientGeneration ||
current.connectionEpoch !== source.connectionEpoch;
const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration;
if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) {
if (
this.hasDemand() &&
(current.clientGeneration !== source.clientGeneration ||
current.connectionEpoch !== source.connectionEpoch)
) {
void this.requestDemandRefresh().catch(() => undefined);
}
});
@@ -19,7 +19,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isNative, isWeb } from "@/constants/platform";
import { isNative, isWeb, supportsMaskedEffects } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
interface StartupSplashScreenProps {
@@ -86,7 +86,11 @@ function LogoShimmer() {
return <WebLogoShimmer color={theme.colors.foreground} />;
}
return <NativeLogoShimmer color={theme.colors.foreground} />;
return supportsMaskedEffects ? (
<NativeLogoShimmer color={theme.colors.foreground} />
) : (
<PaseoLogo size={LOGO_SIZE} color={theme.colors.foreground} />
);
}
function WebLogoShimmer({ color }: { color: string }) {
@@ -1,9 +0,0 @@
/**
* The table a terminal buffer measures characters with.
*
* The daemon answers cursor reports and replays the screen from its own buffer,
* so the app measures with the table the host advertises. `graphemes` gives each
* emoji cluster one two-column cell. `legacy` is what each renderer used before
* hosts advertised grapheme widths. See docs/terminal-rendering.md.
*/
export type TerminalCharacterWidths = "graphemes" | "legacy";
@@ -159,52 +159,4 @@ describe("native headless terminal state", () => {
{ char: "B", width: 1 },
]);
});
// Apple Color Emoji and Noto Color Emoji are two cells wide. The native
// renderer scales a cluster to its column slot, so a one-column emoji
// renders at half size. Each emoji cluster must occupy one wide cell.
test.each([
["presentation emoji", "😀"],
["thumbs up", "👍"],
["check mark", "✅"],
["VS16 heart", "❤️"],
["skin-tone modifier", "👋🏽"],
["regional-indicator flag", "🇰🇷"],
["ZWJ family", "👨‍👩‍👧"],
["ZWJ technologist", "👩‍💻"],
])("gives a %s cluster one two-column cell", async (_name, cluster) => {
const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 });
await terminal.write(`A${cluster}B`);
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
{ char: "A", width: 1 },
{ char: cluster, width: 2 },
{ char: " ", width: 0 },
{ char: "B", width: 1 },
]);
expect(terminal.getViewportState().cursor.col).toBe(4);
});
// A host without grapheme widths answers cursor reports and replays with
// xterm's default table. The native buffer matches it so the cursor lands
// where the host put it.
test("measures with xterm's default table for hosts without grapheme widths", async () => {
const terminal = createNativeHeadlessTerminal({
rows: 2,
cols: 12,
characterWidths: "legacy",
});
await terminal.write("A😀B");
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
{ char: "A", width: 1 },
{ char: "😀", width: 1 },
{ char: "B", width: 1 },
]);
expect(terminal.getViewportState().cursor.col).toBe(3);
});
});
@@ -1,5 +1,3 @@
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import type { TerminalCharacterWidths } from "../character-widths";
import {
Terminal as HeadlessTerminal,
type IBufferCell,
@@ -19,8 +17,6 @@ export interface NativeHeadlessTerminalOptions {
rows: number;
cols: number;
scrollbackLines?: number;
/** Defaults to `graphemes`, the table current daemons use. */
characterWidths?: TerminalCharacterWidths;
}
export interface NativeHeadlessTerminal {
@@ -301,12 +297,6 @@ export function createNativeHeadlessTerminal(
scrollback: options.scrollbackLines ?? 1000,
allowProposedApi: true,
});
// Grapheme widths give each emoji cluster one two-column cell, the width the
// native renderer draws it at. Legacy hosts measure with xterm's default
// table, so their cursor reports and replay stay consistent with it.
if ((options.characterWidths ?? "graphemes") === "graphemes") {
terminal.loadAddon(new UnicodeGraphemesAddon());
}
const decoder = new TextDecoder();
let coordinateEpoch = 0;
const coordinateStorage = new Map<object, TerminalCoordinateStorage>();
@@ -90,16 +90,18 @@ describe("native terminal frame", () => {
test("keeps combining marks and joiners inside the cell xterm assigned them", async () => {
const { frame } = await frameFor("éx̸ 각 क्ष 👩‍💻");
expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([
expect(cellLayout(frame.rows[0]).slice(0, 11)).toEqual([
{ text: "é", col: 0, span: 1 },
{ text: "x̸", col: 1, span: 1 },
{ text: " ", col: 2, span: 1 },
{ text: "각", col: 3, span: 2 },
{ text: " ", col: 5, span: 1 },
{ text: "क्ष", col: 6, span: 1 },
{ text: " ", col: 7, span: 1 },
{ text: "👩‍💻", col: 8, span: 2 },
{ text: " ", col: 10, span: 1 },
{ text: "क्", col: 6, span: 1 },
{ text: "ष", col: 7, span: 1 },
{ text: " ", col: 8, span: 1 },
{ text: "👩‍", col: 9, span: 1 },
{ text: "💻", col: 10, span: 1 },
{ text: " ", col: 11, span: 1 },
]);
});
@@ -235,7 +237,7 @@ test("builds native frames after replay commits at captured geometry and the lat
expect(frame.columns).toBe(50);
expect(frame.rows).toHaveLength(10);
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]);
expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]);
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
} finally {
drain.dispose();
@@ -717,13 +717,11 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -823,67 +821,6 @@ describe("incremental replay lifecycle", () => {
expect(mounted.inputs).toHaveLength(1);
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
});
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }),
);
const terminal = window.__paseoTerminal;
if (!terminal) {
throw new Error("Expected the browser terminal to be mounted");
}
const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY);
const cells = Array.from({ length: 13 }, (_, col) => {
const cell = line?.getCell(col);
return { chars: cell?.getChars(), width: cell?.getWidth() };
});
expect(cells).toEqual([
{ chars: "A", width: 1 },
{ chars: "😀", width: 2 },
{ chars: "", width: 0 },
{ chars: "B", width: 1 },
{ chars: "❤️", width: 2 },
{ chars: "", width: 0 },
{ chars: "C", width: 1 },
{ chars: "👋🏽", width: 2 },
{ chars: "", width: 0 },
{ chars: "D", width: 1 },
{ chars: "👨‍👩‍👧", width: 2 },
{ chars: "", width: 0 },
{ chars: "E", width: 1 },
]);
expect(terminal.buffer.active.cursorX).toBe(13);
terminal.select(0, terminal.buffer.active.cursorY, 13);
expect(terminal.getSelection()).toBe(text);
});
// A host without grapheme widths answers cursor reports and replays with its
// own table. The web client keeps the Unicode 11 table it used before.
it("measures with the legacy table for hosts without grapheme widths", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
const terminal = window.__paseoTerminal;
if (!terminal) {
throw new Error("Expected the browser terminal to be mounted");
}
const buffer = terminal.buffer;
async function cursorAfter(text: string): Promise<number> {
await new Promise<void>((resolve) =>
mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }),
);
return buffer.active.cursorX;
}
mounted.runtime.setCharacterWidths({ widths: "legacy" });
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]);
mounted.runtime.setCharacterWidths({ widths: "graphemes" });
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]);
});
});
it("restores fitted web geometry after replay before live output", async () => {
@@ -36,12 +36,6 @@ vi.mock("@xterm/addon-unicode11", () => ({
},
}));
vi.mock("@xterm/addon-unicode-graphemes", () => ({
UnicodeGraphemesAddon: class UnicodeGraphemesAddon {
dispose(): void {}
},
}));
vi.mock("@xterm/addon-web-links", () => ({
WebLinksAddon: class WebLinksAddon {
dispose(): void {}
@@ -88,7 +82,6 @@ import {
createTerminalResizeEvent,
encodeTerminalOutput,
TerminalEmulatorRuntime,
type TerminalClipboard,
} from "./terminal-emulator-runtime";
interface StubTerminal {
@@ -645,97 +638,3 @@ describe("terminal-emulator-runtime", () => {
expect(fitAndEmitResize).not.toHaveBeenCalled();
});
});
describe("terminal-emulator-runtime clipboard shortcuts", () => {
type KeyHandler = (event: KeyboardEvent) => boolean;
function createClipboard(available: boolean) {
return {
isAvailable: () => available,
readText: vi.fn(async () => "clipboard text"),
read: vi.fn(async () => []),
writeText: vi.fn(async (_text: string) => undefined),
} satisfies TerminalClipboard;
}
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
let handler: KeyHandler | null = null;
const paste = vi.fn();
runtime.attachKeyEventHandler({
attachCustomKeyEventHandler: (next: KeyHandler) => {
handler = next;
},
hasSelection: () => input.selection.length > 0,
getSelection: () => input.selection,
paste,
input: vi.fn(),
scrollToBottom: vi.fn(),
});
if (!handler) throw new Error("key handler was not attached");
return { handler: handler as KeyHandler, paste };
}
function ctrlKey(key: string) {
const preventDefault = vi.fn();
const event = {
type: "keydown",
key,
isComposing: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
preventDefault,
stopPropagation: vi.fn(),
} as unknown as KeyboardEvent;
return { event, preventDefault };
}
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(false);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
const clipboard = createClipboard(false);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("v");
expect(handler(event)).toBe(false);
expect(clipboard.readText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
expect(paste).not.toHaveBeenCalled();
});
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
const clipboard = createClipboard(false);
const { handler } = attachKeyHandler({ clipboard, selection: "" });
const { event, preventDefault } = ctrlKey("c");
expect(handler(event)).toBe(true);
expect(clipboard.writeText).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
it("copies and pastes through the Clipboard API when it is available", async () => {
const clipboard = createClipboard(true);
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
const copy = ctrlKey("c");
expect(handler(copy.event)).toBe(false);
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
expect(copy.preventDefault).not.toHaveBeenCalled();
const pasteKey = ctrlKey("v");
expect(handler(pasteKey.event)).toBe(false);
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
expect(clipboard.readText).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
});
});
@@ -3,12 +3,10 @@ import { FitAddon } from "@xterm/addon-fit";
import { ImageAddon } from "@xterm/addon-image";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalCharacterWidths } from "../character-widths";
import {
type TerminalInputModeState,
TerminalInputModeTracker,
@@ -184,26 +182,15 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
}
export interface TerminalClipboard {
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
isAvailable?(): boolean;
readText(): Promise<string>;
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
writeText(text: string): Promise<void>;
}
function getBrowserClipboard(): Clipboard | undefined {
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
}
function rejectUnavailableClipboard(): Promise<never> {
return Promise.reject(new Error("Clipboard API is unavailable"));
}
const browserClipboard: TerminalClipboard = {
isAvailable: () => !!getBrowserClipboard(),
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
readText: () => navigator.clipboard.readText(),
read: () => navigator.clipboard.read(),
writeText: (text) => navigator.clipboard.writeText(text),
};
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
@@ -234,12 +221,6 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
return null;
}
// Grapheme widths match the daemon and native buffers. Legacy hosts keep the
// Unicode 11 table web used before. See docs/terminal-rendering.md.
function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void {
terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11";
}
export class TerminalEmulatorRuntime {
constructor(
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
@@ -252,7 +233,6 @@ export class TerminalEmulatorRuntime {
shift: false,
alt: false,
};
private characterWidths: TerminalCharacterWidths = "graphemes";
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
@@ -340,16 +320,6 @@ export class TerminalEmulatorRuntime {
this.pendingModifiers = input.pendingModifiers;
}
setCharacterWidths(input: { widths: TerminalCharacterWidths }): void {
this.characterWidths = input.widths;
const terminal = this.terminal;
if (!terminal) {
return;
}
applyCharacterWidths(terminal, input.widths);
this.refreshVisibleRows();
}
private loadSearchAddon(terminal: Terminal): void {
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
terminal.loadAddon(searchAddon);
@@ -398,7 +368,22 @@ export class TerminalEmulatorRuntime {
!event.altKey &&
!event.metaKey
) {
return !this.handleCtrlClipboardShortcut(terminal, event);
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void this.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
event.preventDefault();
this.pasteClipboard(terminal);
return false;
}
return true;
}
if (this.maybeSendMacEditingShortcut(terminal, event)) {
@@ -447,36 +432,6 @@ export class TerminalEmulatorRuntime {
});
}
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
private handleCtrlClipboardShortcut(
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
event: KeyboardEvent,
): boolean {
const key = event.key.toLowerCase();
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
// copy/paste events still fire and xterm handles them, so leave the default action.
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
if (clipboardAvailable) {
void this.clipboard.writeText(terminal.getSelection());
}
return true;
}
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
if (clipboardAvailable) {
event.preventDefault();
this.pasteClipboard(terminal);
}
return true;
}
return false;
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
event.preventDefault();
@@ -574,12 +529,11 @@ export class TerminalEmulatorRuntime {
theme: withOverviewRulerBorderHidden(input.theme),
});
const fitAddon = new FitAddon();
const unicode11Addon = new Unicode11Addon();
let webglAddon: WebglAddon | null = null;
let imageAddon: ImageAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(new Unicode11Addon());
terminal.loadAddon(new UnicodeGraphemesAddon());
applyCharacterWidths(terminal, this.characterWidths);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new WebLinksAddon(openExternalLink));
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
@@ -614,6 +568,11 @@ export class TerminalEmulatorRuntime {
terminal.open(input.host);
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
this.applyThemeBackground(input.theme);
try {
terminal.unicode.activeVersion = "11";
} catch {
// Ignore if unicode API isn't available in this build/runtime.
}
const disposeImageAddon = (): void => {
try {
@@ -647,7 +606,6 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -656,7 +614,6 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
+2 -1
View File
@@ -13,5 +13,6 @@
"expo-env.d.ts",
"nativewind-env.d.ts",
"react-native-web.d.ts"
]
],
"exclude": ["gpui"]
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/server": "0.15.0-beta.1",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/server": "0.14.0",
"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.15.0-beta.1",
"version": "0.14.0",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/relay": "0.15.0-beta.1",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+3 -4
View File
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, transportFactory, ...clientConfig } = config;
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -41,7 +41,7 @@
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.15.0-beta.1",
"version": "0.14.0",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -77,8 +77,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -88,8 +88,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.0-beta.1",
"@getpaseo/protocol": "0.15.0-beta.1",
"@getpaseo/client": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"

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