mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5236c00d8 | ||
|
|
d1443064db | ||
|
|
9f387873b0 | ||
|
|
12cd5345d2 | ||
|
|
2c6d4e5283 | ||
|
|
006bae2897 | ||
|
|
437aef1a7b | ||
|
|
b44e7b0b90 | ||
|
|
09f87df1b2 | ||
|
|
b013e0d428 | ||
|
|
c938e1cf44 | ||
|
|
0c18144967 | ||
|
|
c481ecf3e1 | ||
|
|
dfc9add774 | ||
|
|
da48803a43 | ||
|
|
7f5d32cdd5 | ||
|
|
dec2d861ec | ||
|
|
30178c4f58 | ||
|
|
52d345db7f | ||
|
|
a50a476009 | ||
|
|
6ca001c3e4 | ||
|
|
78a0f093eb | ||
|
|
d7b7016cc9 |
@@ -1,5 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
## 0.10.1 - 2026-09-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
|
||||
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
|
||||
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
|
||||
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
|
||||
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
|
||||
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
|
||||
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
|
||||
|
||||
## 0.10.0 - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
|
||||
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
|
||||
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
|
||||
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
|
||||
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
|
||||
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
|
||||
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
|
||||
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
|
||||
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
|
||||
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
|
||||
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
|
||||
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
|
||||
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
|
||||
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
|
||||
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
|
||||
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
|
||||
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
|
||||
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
|
||||
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
|
||||
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
|
||||
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
|
||||
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
|
||||
|
||||
## 0.9.2 - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,7 @@
|
||||
Added
|
||||
- Added OpenCode v2 support, selected automatically from the installed opencode version
|
||||
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
|
||||
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
|
||||
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,10 @@
|
||||
Added
|
||||
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level
|
||||
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools
|
||||
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads
|
||||
- Fixed a subagent opened from a split pane opening in a different pane
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-EU28KEISx1lV1wKSiB3npPER1g+hTMD64JuDWxDjHPE=
|
||||
sha256-wHz2O/y9bAQsZVQGUMnSYbg40h5W5ogv0hg2Lq+/UrM=
|
||||
|
||||
Generated
+51
-51
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -42,13 +42,6 @@
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@0no-co/graphql.web": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
|
||||
@@ -14390,6 +14383,13 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/7zip-bin": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abab": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
|
||||
@@ -33341,6 +33341,23 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
|
||||
@@ -33515,23 +33532,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -36410,7 +36410,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37476,12 +37476,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/server": "0.10.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37728,10 +37728,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37742,7 +37742,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37986,7 +37986,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38882,7 +38882,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38911,14 +38911,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38928,8 +38928,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38951,7 +38951,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38978,7 +38978,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39196,16 +39196,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/highlight": "0.10.1",
|
||||
"@getpaseo/plugin": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40110,7 +40110,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
|
||||
const filePath = path.join(workspace.repoPath, "src", "target.ts");
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Absolute file link tooltip",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
initialPrompt: "Show the file link",
|
||||
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await workspaceRow.click();
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
|
||||
|
||||
const fileLink = page.getByRole("link", { name: "Open target" }).first();
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.hover();
|
||||
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
});
|
||||
|
||||
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
|
||||
const guessed: TerminalProfile = {
|
||||
id: "e2e-cursor-guessed",
|
||||
name: "Cursor guessed",
|
||||
command: "cursor-agent",
|
||||
args: ["{{{prompt}}}"],
|
||||
};
|
||||
const explicit: TerminalProfile = {
|
||||
...guessed,
|
||||
id: "e2e-cursor-explicit",
|
||||
name: "Cursor explicit",
|
||||
icon: "cursor",
|
||||
};
|
||||
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
const menuIconPath = (name: string) =>
|
||||
menu
|
||||
.getByRole("menuitem", { name })
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedMenuPath = await menuIconPath(guessed.name);
|
||||
const explicitMenuPath = await menuIconPath(explicit.name);
|
||||
|
||||
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
|
||||
|
||||
const settingsIconPath = (id: string) =>
|
||||
page
|
||||
.getByTestId(`terminal-profile-row-${id}`)
|
||||
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
|
||||
const guessedSettingsPath = await settingsIconPath(guessed.id);
|
||||
const explicitSettingsPath = await settingsIconPath(explicit.id);
|
||||
|
||||
expect(explicitMenuPath).not.toBeNull();
|
||||
expect(explicitSettingsPath).not.toBeNull();
|
||||
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
|
||||
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
|
||||
} finally {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
|
||||
},
|
||||
});
|
||||
|
||||
async function selectMedium(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select thinking option/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function selectLongcat(page: Page) {
|
||||
await page
|
||||
.getByRole("button", { name: /^Select model \(/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await page
|
||||
.getByText("LongCat 2.5 Preview Free", { exact: true })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
async function askForReply(handle: AgentHandle) {
|
||||
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
|
||||
return handle.client.waitForFinish(handle.agentId, 120_000);
|
||||
}
|
||||
|
||||
test("switching an existing OpenCode session clears unsupported thinking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
cwd,
|
||||
provider: "opencode",
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "opencode/space-bunny-free" },
|
||||
});
|
||||
await selectMedium(page);
|
||||
expect((await askForReply(handle)).status).toBe("idle");
|
||||
await selectLongcat(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
const finish = await askForReply(handle);
|
||||
const screenshot = testInfo.outputPath("model-switch.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("After switching to Longcat", {
|
||||
path: screenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeFalsy();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).last(),
|
||||
).toContainText("OK");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
|
||||
|
||||
const SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId, agentId: parentId });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
|
||||
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-menu-agent").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${parentId}`)
|
||||
.click();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
||||
|
||||
const left = page.getByTestId("workspace-pane-main");
|
||||
const right = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
|
||||
await expect(left).toBeVisible();
|
||||
await expect(right).toBeVisible();
|
||||
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
|
||||
return { left, right };
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
});
|
||||
});
|
||||
|
||||
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((key) => {
|
||||
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
|
||||
}, SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
const childPane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
|
||||
await expect(childPane).toBeVisible();
|
||||
expect(await childPane.getAttribute("data-testid")).not.toBe(
|
||||
await right.getAttribute("data-testid"),
|
||||
);
|
||||
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Right parent",
|
||||
childTitle: "Child agent",
|
||||
});
|
||||
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
|
||||
await openSubagentsTrack(page);
|
||||
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
|
||||
|
||||
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
|
||||
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-file-origin-pane-",
|
||||
title: "File link parent",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
|
||||
const { left, right } = await openParentInRightPane(
|
||||
page,
|
||||
workspace.agentId,
|
||||
workspace.workspaceId,
|
||||
);
|
||||
const fileLink = right.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
|
||||
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -83,7 +83,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||
const timelineUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "chat");
|
||||
await page.getByRole("switch", { name: "Chat outline" }).click();
|
||||
await page.goto(timelineUrl);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ async function openPluginScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ interface SavedSettingsHostInput {
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
// Codex streams a line break and the next line's indent as their own chunks.
|
||||
it("keeps a line break when a chunk ends in the next line's indent", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
|
||||
harness.send(assistant(" "));
|
||||
const result = harness.send(assistant(" B --> C[Finish]\n```"));
|
||||
expect(result.head).toMatchObject([
|
||||
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
|
||||
]);
|
||||
});
|
||||
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
|
||||
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
|
||||
const index = prefix.length + offset;
|
||||
let blockText = text;
|
||||
if (offset === textBlocks.length - 1) {
|
||||
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingNewlines;
|
||||
// The split drops trailing blank lines, including a line break followed only
|
||||
// by the next line's indent. Keep them so the next append continues that line.
|
||||
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
|
||||
blockText += trailingBlankLines;
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
|
||||
interface AssistantMarkdownLinkProps {
|
||||
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
const workspaceRoot = configRef.current.workspaceRoot;
|
||||
const tooltipPath = useMemo(
|
||||
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
|
||||
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
|
||||
[target, workspaceRoot],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
@@ -127,38 +128,6 @@ export function AssistantMarkdownCodeLink({
|
||||
);
|
||||
}
|
||||
|
||||
function formatInlinePathTargetForTooltip(
|
||||
target: { path: string; lineStart?: number; lineEnd?: number },
|
||||
workspaceRoot: string | undefined,
|
||||
): string {
|
||||
let result = relativizePathToWorkspace(target.path, workspaceRoot);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
|
||||
if (!workspaceRoot) {
|
||||
return filePath;
|
||||
}
|
||||
const root = workspaceRoot.replace(/\/+$/, "");
|
||||
if (!root) {
|
||||
return filePath;
|
||||
}
|
||||
if (filePath === root) {
|
||||
return ".";
|
||||
}
|
||||
const prefix = `${root}/`;
|
||||
if (filePath.startsWith(prefix)) {
|
||||
return filePath.slice(prefix.length);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
interface AssistantInlineCodePathLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFileLinkTooltipPath } from "./tooltip-path";
|
||||
|
||||
describe("formatFileLinkTooltipPath", () => {
|
||||
it("shows a Windows file path relative to its workspace regardless of separators", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: {
|
||||
path: "C:/Users/me/repo/src/app.ts",
|
||||
lineStart: 12,
|
||||
lineEnd: 20,
|
||||
},
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("src/app.ts:12-20");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/src/app.ts", lineStart: 12 },
|
||||
workspaceRoot: "C:\\",
|
||||
}),
|
||||
).toBe("src/app.ts:12");
|
||||
});
|
||||
|
||||
it("shows the workspace root as a dot", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/repo" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe(".");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
|
||||
workspaceRoot: "c:\\users\\me\\repo",
|
||||
}),
|
||||
).toBe(".:4");
|
||||
});
|
||||
|
||||
it("keeps an absolute path outside the workspace", () => {
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "/Users/me/notes.md" },
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
}),
|
||||
).toBe("/Users/me/notes.md");
|
||||
expect(
|
||||
formatFileLinkTooltipPath({
|
||||
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
|
||||
workspaceRoot: "C:\\Users\\me\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/me/notes.md:8");
|
||||
});
|
||||
|
||||
it("keeps the target path when the workspace root is unavailable", () => {
|
||||
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
|
||||
"src/app.ts:12",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
isWorkspaceRootPath,
|
||||
resolveWorkspaceFilePaths,
|
||||
type WorkspaceFileLocation,
|
||||
} from "@/workspace/file-open";
|
||||
|
||||
interface FormatFileLinkTooltipPathInput {
|
||||
target: WorkspaceFileLocation;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export function formatFileLinkTooltipPath({
|
||||
target,
|
||||
workspaceRoot,
|
||||
}: FormatFileLinkTooltipPathInput): string {
|
||||
const resolvedPaths = workspaceRoot
|
||||
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
|
||||
: null;
|
||||
const isWorkspaceRoot = workspaceRoot
|
||||
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
|
||||
: false;
|
||||
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
|
||||
if (target.lineStart) {
|
||||
result += `:${target.lineStart}`;
|
||||
if (target.lineEnd && target.lineEnd !== target.lineStart) {
|
||||
result += `-${target.lineEnd}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -117,17 +117,16 @@ export function SettingsSelect<Value extends string>({
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value>) {
|
||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={row.label}
|
||||
accessibilityLabel={`${row.label}: ${selectedLabel}`}
|
||||
>
|
||||
<Text style={styles.value}>
|
||||
{options.find((option) => option.value === value)?.label ?? value}
|
||||
</Text>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{options.map((option) => (
|
||||
@@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
||||
control: { flexShrink: 1, maxWidth: "100%" },
|
||||
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
input: { minWidth: 180 },
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
|
||||
/**
|
||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a workspace-row-height row with no separator, for entries that
|
||||
* "compact": a row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
@@ -52,10 +52,9 @@ export function SidebarHeaderRow({
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.button,
|
||||
variant === "compact" && styles.buttonCompact,
|
||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||
],
|
||||
[isActive, variant],
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -64,7 +63,7 @@ export function SidebarHeaderRow({
|
||||
return (
|
||||
<>
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
@@ -74,7 +73,7 @@ export function SidebarHeaderRow({
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label, shortcutKeys],
|
||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||
// compact header entries sit tight against the workspace list below.
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
// Compact header entries (New workspace / History) sit tighter than the
|
||||
// workspace-row shape the base button mirrors.
|
||||
buttonCompact: {
|
||||
minHeight: 32,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the workspace list below (base button uses a wider spacing[3]).
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -228,22 +228,22 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonXs: {
|
||||
minHeight: buttonControlHeight.xs,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonSm: {
|
||||
minHeight: buttonControlHeight.sm,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonMd: {
|
||||
minHeight: buttonControlHeight.md,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonLg: {
|
||||
minHeight: buttonControlHeight.lg,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -306,17 +306,17 @@ export function createControlGeometry(theme: Theme) {
|
||||
segmentedSegmentXs: {
|
||||
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
type ButtonControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
|
||||
children?: ReactNode;
|
||||
chevron?: ReactNode | null;
|
||||
}
|
||||
|
||||
const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children" | "style"> {
|
||||
/** The selected option's label. */
|
||||
children: string;
|
||||
/** Icon or swatch before the label. */
|
||||
leading?: ReactNode;
|
||||
size?: ButtonControlSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* A select-style menu trigger: the selected value plus a chevron, drawn with the
|
||||
* outline Button's chrome and size scale so a dropdown and a button in the same
|
||||
* row share one geometry. Callers pick a size; they don't style it.
|
||||
*/
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
chevron,
|
||||
leading,
|
||||
size = "sm",
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "md") {
|
||||
sizeStyle = styles.md;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
} else {
|
||||
sizeStyle = styles.sm;
|
||||
}
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
],
|
||||
[disabled, sizeStyle],
|
||||
);
|
||||
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
|
||||
|
||||
return (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
<View style={styles.row}>
|
||||
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
|
||||
{leading}
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{children}
|
||||
{chevron !== null &&
|
||||
(chevron ?? (
|
||||
<View style={styles.chevronContainer}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
))}
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
chevronContainer: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
base: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
xs: geometry.buttonXs,
|
||||
sm: geometry.buttonSm,
|
||||
md: geometry.buttonMd,
|
||||
lg: geometry.buttonLg,
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
...geometry.buttonText,
|
||||
},
|
||||
labelXs: geometry.buttonTextXs,
|
||||
chevron: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,15 +32,32 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseColor(value: string): { rgb: string; alpha: number } {
|
||||
const channels = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 };
|
||||
}
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(["success", "warning", "error", "muted"] as const)(
|
||||
"uses the semantic badge shell for the %s variant",
|
||||
it("uses the neutral badge shell for the muted variant", () => {
|
||||
const style = getComputedStyle(mountBadge("muted"));
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
});
|
||||
|
||||
it.each(["success", "warning", "error"] as const)(
|
||||
"fills the %s variant with a translucent tint of its own status color",
|
||||
(variant) => {
|
||||
const badge = mountBadge(variant);
|
||||
const style = getComputedStyle(badge);
|
||||
const text = badge.lastElementChild;
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render its label");
|
||||
}
|
||||
const fill = parseColor(getComputedStyle(badge).backgroundColor);
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
|
||||
expect(fill.alpha).toBeGreaterThan(0);
|
||||
expect(fill.alpha).toBeLessThan(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "warning" && styles.pillWarning,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.pillText,
|
||||
@@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.pill}>
|
||||
<View style={pillStyle}>
|
||||
{leading}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</View>
|
||||
@@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.statusSuccessTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillWarning: {
|
||||
backgroundColor: theme.colors.statusWarningTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.statusDangerTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
|
||||
@@ -1953,8 +1953,11 @@ export const ar: TranslationResources = {
|
||||
groupInfo: "حول{{title}}",
|
||||
sections: {
|
||||
general: "عام",
|
||||
chat: "الدردشة",
|
||||
appearance: "مظهر",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "الشريط الجانبي",
|
||||
terminal: "الطرفية",
|
||||
browser: "المتصفح",
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
@@ -2013,6 +2016,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
browserData: {
|
||||
title: "بيانات المتصفح",
|
||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
@@ -2040,8 +2044,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "عناوين URL للخدمة",
|
||||
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
|
||||
options: {
|
||||
ask: "بسأل",
|
||||
inApp: "في Paseo",
|
||||
@@ -2060,7 +2062,6 @@ export const ar: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
|
||||
@@ -1978,9 +1978,12 @@ export const en = {
|
||||
groupInfo: "About {{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
layout: "Layout",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
notifications: "Notifications",
|
||||
@@ -1998,29 +2001,32 @@ export const en = {
|
||||
},
|
||||
sources: {
|
||||
explorerFiles: {
|
||||
label: "Selecting a file in Explorer",
|
||||
label: "Clicking a file in the Explorer sidebar",
|
||||
description: "Open files selected in the Explorer sidebar beside your work",
|
||||
},
|
||||
diffs: {
|
||||
label: "Opening a diff",
|
||||
label: "Clicking a change in the Explorer sidebar or a chat",
|
||||
description: "Open diffs from Explorer and agent conversations beside your work",
|
||||
},
|
||||
chatFiles: {
|
||||
label: "Opening a file from an agent chat",
|
||||
label: "Clicking a file in an agent chat",
|
||||
description: "Open file links and tool-call files beside the conversation",
|
||||
},
|
||||
diffFiles: {
|
||||
label: "Opening a file from Changes",
|
||||
label: "Clicking a file in a diff",
|
||||
description: "Open source files selected from a diff beside it",
|
||||
},
|
||||
subagents: {
|
||||
label: "Opening a subagent",
|
||||
label: "Clicking a subagent in an agent chat",
|
||||
description: "Open subagents beside their parent agent",
|
||||
},
|
||||
pullRequests: {
|
||||
label: "Opening a pull request from Changes",
|
||||
label: "Clicking a pull request in the Explorer sidebar",
|
||||
description: "Open pull request details beside Changes",
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Clicking a script's service URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2134,6 +2140,7 @@ export const en = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
browserData: {
|
||||
title: "Browser data",
|
||||
siteData: "Cookies and site data",
|
||||
@@ -2160,8 +2167,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Service URLs",
|
||||
description: "Where to open URLs from running scripts",
|
||||
options: {
|
||||
ask: "Ask",
|
||||
inApp: "In Paseo",
|
||||
@@ -2180,7 +2185,6 @@ export const en = {
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
accessibilityLabel: "Select tool call display ({{value}})",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
|
||||
@@ -2001,8 +2001,11 @@ export const es: TranslationResources = {
|
||||
groupInfo: "Acerca de{{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Apariencia",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
@@ -2061,6 +2064,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
browserData: {
|
||||
title: "Datos del navegador",
|
||||
siteData: "Cookies y datos de sitios",
|
||||
@@ -2091,8 +2095,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de servicio",
|
||||
description: "Dónde abrir URL desde scripts en ejecución",
|
||||
options: {
|
||||
ask: "Preguntar",
|
||||
inApp: "EnPaseo",
|
||||
@@ -2112,7 +2114,6 @@ export const es: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
|
||||
@@ -2006,8 +2006,11 @@ export const fr: TranslationResources = {
|
||||
groupInfo: "À propos de{{title}}",
|
||||
sections: {
|
||||
general: "Général",
|
||||
chat: "Discussion",
|
||||
appearance: "Apparence",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barre latérale",
|
||||
terminal: "Terminal",
|
||||
browser: "Navigateur",
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
@@ -2066,6 +2069,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
browserData: {
|
||||
title: "Données du navigateur",
|
||||
siteData: "Cookies et données des sites",
|
||||
@@ -2095,8 +2099,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de services",
|
||||
description: "Où ouvrir les URL à partir de scripts en cours d'exécution",
|
||||
options: {
|
||||
ask: "Demander",
|
||||
inApp: "DansPaseo",
|
||||
@@ -2115,7 +2117,6 @@ export const fr: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
|
||||
@@ -1973,8 +1973,11 @@ export const ja: TranslationResources = {
|
||||
groupInfo: "{{title}}について",
|
||||
sections: {
|
||||
general: "一般",
|
||||
chat: "チャット",
|
||||
appearance: "外観",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "サイドバー",
|
||||
terminal: "ターミナル",
|
||||
browser: "ブラウザ",
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
@@ -2033,6 +2036,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
browserData: {
|
||||
title: "ブラウザーデータ",
|
||||
siteData: "Cookie とサイトデータ",
|
||||
@@ -2059,8 +2063,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "サービスURL",
|
||||
description: "実行中のスクリプトからURLを開く場所",
|
||||
options: {
|
||||
ask: "確認する",
|
||||
inApp: "Paseoで",
|
||||
@@ -2079,7 +2081,6 @@ export const ja: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
|
||||
options: {
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
|
||||
@@ -1963,8 +1963,11 @@ export const ko: TranslationResources = {
|
||||
groupInfo: "{{title}} 정보",
|
||||
sections: {
|
||||
general: "일반",
|
||||
chat: "채팅",
|
||||
appearance: "모양",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "사이드바",
|
||||
terminal: "터미널",
|
||||
browser: "브라우저",
|
||||
editor: "편집기",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
@@ -2023,6 +2026,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
browserData: {
|
||||
title: "브라우저 데이터",
|
||||
siteData: "쿠키 및 사이트 데이터",
|
||||
@@ -2051,8 +2055,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "서비스 URL",
|
||||
description: "실행 중인 스크립트의 URL을 열 위치",
|
||||
options: {
|
||||
ask: "물어보기",
|
||||
inApp: "Paseo에서",
|
||||
@@ -2071,7 +2073,6 @@ export const ko: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
accessibilityLabel: "도구 호출 표시 선택({{value}})",
|
||||
options: {
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
|
||||
@@ -1986,8 +1986,11 @@ export const ptBR: TranslationResources = {
|
||||
groupInfo: "Sobre {{title}}",
|
||||
sections: {
|
||||
general: "Geral",
|
||||
chat: "Chat",
|
||||
appearance: "Aparência",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
@@ -2046,6 +2049,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
browserData: {
|
||||
title: "Dados do navegador",
|
||||
siteData: "Cookies e dados de sites",
|
||||
@@ -2074,8 +2078,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URLs de serviço",
|
||||
description: "Onde abrir URLs de scripts em execução",
|
||||
options: {
|
||||
ask: "Perguntar",
|
||||
inApp: "No Paseo",
|
||||
@@ -2095,7 +2097,6 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
|
||||
@@ -1986,8 +1986,11 @@ export const ru: TranslationResources = {
|
||||
groupInfo: "О разделе «{{title}}»",
|
||||
sections: {
|
||||
general: "Основные",
|
||||
chat: "Чат",
|
||||
appearance: "Оформление",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Боковая панель",
|
||||
terminal: "Терминал",
|
||||
browser: "Браузер",
|
||||
editor: "Редактор",
|
||||
shortcuts: "Сочетания клавиш",
|
||||
integrations: "Интеграции",
|
||||
@@ -2046,6 +2049,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
browserData: {
|
||||
title: "Данные браузера",
|
||||
siteData: "Файлы cookie и данные сайтов",
|
||||
@@ -2075,8 +2079,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL-адреса сервисов",
|
||||
description: "Где открывать URL-адреса запущенных скриптов",
|
||||
options: {
|
||||
ask: "Спрашивать",
|
||||
inApp: "В Paseo",
|
||||
@@ -2096,7 +2098,6 @@ export const ru: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
|
||||
options: {
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
|
||||
@@ -1932,8 +1932,11 @@ export const zhCN: TranslationResources = {
|
||||
groupInfo: "关于 {{title}}",
|
||||
sections: {
|
||||
general: "通用",
|
||||
chat: "聊天",
|
||||
appearance: "外观",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "侧边栏",
|
||||
terminal: "终端",
|
||||
browser: "浏览器",
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
@@ -1991,6 +1994,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
browserData: {
|
||||
title: "浏览器数据",
|
||||
siteData: "Cookie 和网站数据",
|
||||
@@ -2016,8 +2020,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "服务 URL",
|
||||
description: "运行脚本中的 URL 打开位置",
|
||||
options: {
|
||||
ask: "询问",
|
||||
inApp: "在 Paseo 中",
|
||||
@@ -2036,7 +2038,6 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用显示方式({{value}})",
|
||||
options: {
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -38,27 +37,27 @@ import {
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Blocks,
|
||||
PanelsTopLeft,
|
||||
Globe,
|
||||
PanelLeft,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
|
||||
import { LayoutSection } from "@/screens/settings/layout/layout-section";
|
||||
import { OpenLocationSection } from "@/screens/settings/open-location/open-location-section";
|
||||
import { TerminalSection } from "@/screens/settings/terminal/terminal-section";
|
||||
import { ChatSection } from "@/screens/settings/chat/chat-section";
|
||||
import { SidebarNavSection } from "@/screens/settings/sidebar/sidebar-nav-section";
|
||||
import { SendingSection } from "@/screens/settings/general/sending-section";
|
||||
import {
|
||||
useAppSettings,
|
||||
useSettings,
|
||||
parseTerminalScrollbackLines,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -114,7 +113,6 @@ import {
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
@@ -144,48 +142,88 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
desktopOnly?: boolean;
|
||||
webOnly?: boolean;
|
||||
/** The page body, for pages that need nothing from the settings screen. */
|
||||
Content?: ComponentType;
|
||||
}
|
||||
|
||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
||||
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
||||
{
|
||||
id: "layout",
|
||||
labelKey: "settings.sections.layout",
|
||||
icon: PanelsTopLeft,
|
||||
id: "appearance",
|
||||
labelKey: "settings.sections.appearance",
|
||||
icon: Palette,
|
||||
Content: AppearanceSection,
|
||||
},
|
||||
{
|
||||
id: "sidebar",
|
||||
labelKey: "settings.sections.sidebar",
|
||||
icon: PanelLeft,
|
||||
Content: SidebarNavSection,
|
||||
},
|
||||
{ id: "chat", labelKey: "settings.sections.chat", icon: MessageSquare, Content: ChatSection },
|
||||
{
|
||||
id: "terminal",
|
||||
labelKey: "settings.sections.terminal",
|
||||
icon: SquareTerminal,
|
||||
Content: TerminalSection,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
labelKey: "settings.sections.browser",
|
||||
icon: Globe,
|
||||
desktopOnly: true,
|
||||
Content: BrowserDataSection,
|
||||
},
|
||||
{
|
||||
id: "editor",
|
||||
labelKey: "settings.sections.editor",
|
||||
icon: Code2,
|
||||
webOnly: true,
|
||||
Content: EditorSection,
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
labelKey: "settings.sections.shortcuts",
|
||||
icon: Keyboard,
|
||||
desktopOnly: true,
|
||||
Content: KeyboardShortcutsSection,
|
||||
},
|
||||
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2, webOnly: true },
|
||||
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{
|
||||
id: "integrations",
|
||||
labelKey: "settings.sections.integrations",
|
||||
icon: Puzzle,
|
||||
desktopOnly: true,
|
||||
Content: IntegrationsSection,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
labelKey: "settings.sections.notifications",
|
||||
icon: Bell,
|
||||
desktopOnly: true,
|
||||
Content: DesktopNotificationsSection,
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
labelKey: "settings.sections.permissions",
|
||||
icon: Shield,
|
||||
desktopOnly: true,
|
||||
Content: DesktopPermissionsSection,
|
||||
},
|
||||
{ id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope },
|
||||
{ id: "about", labelKey: "settings.sections.about", icon: Info },
|
||||
];
|
||||
|
||||
function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean {
|
||||
return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb);
|
||||
}
|
||||
|
||||
interface HostSectionItem {
|
||||
id: HostSectionSlug;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
}
|
||||
|
||||
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
@@ -236,10 +274,6 @@ function renderHostSettingsContent(
|
||||
// Trigger + sidebar style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
|
||||
}
|
||||
|
||||
function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered];
|
||||
}
|
||||
@@ -252,82 +286,18 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
|
||||
];
|
||||
}
|
||||
|
||||
function getSendBehaviorOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
|
||||
{ value: "steer" as const, label: t("settings.general.defaultSend.options.steer") },
|
||||
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
|
||||
];
|
||||
}
|
||||
|
||||
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
|
||||
const labels: Record<ServiceUrlBehavior, string> = {
|
||||
ask: t("settings.general.serviceUrls.options.ask"),
|
||||
"in-app": t("settings.general.serviceUrls.options.inApp"),
|
||||
external: t("settings.general.serviceUrls.options.external"),
|
||||
};
|
||||
return labels[value];
|
||||
}
|
||||
|
||||
function getActiveLocale(language: string | undefined): SupportedLocale {
|
||||
const parsed = parseAppLanguage(language);
|
||||
return parsed && parsed !== "system" ? parsed : "en";
|
||||
}
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
isDesktopApp: boolean;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
handleLanguageChange: (language: AppLanguage) => void;
|
||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
interface SendBehaviorMenuItemProps {
|
||||
value: SendBehavior;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: SendBehavior) => void;
|
||||
}
|
||||
|
||||
function SendBehaviorMenuItem({ value, label, selected, onChange }: SendBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceUrlBehaviorMenuItem({
|
||||
value,
|
||||
label,
|
||||
selected,
|
||||
onChange,
|
||||
}: ServiceUrlBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface LanguageMenuItemProps {
|
||||
@@ -354,21 +324,9 @@ function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageM
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSection({
|
||||
settings,
|
||||
isDesktopApp,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
handleLanguageChange,
|
||||
handleTerminalScrollbackLinesChange,
|
||||
}: GeneralSectionProps) {
|
||||
function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const activeLocale = getActiveLocale(i18n.language);
|
||||
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
|
||||
const selectedSendBehaviorLabel =
|
||||
sendBehaviorOptions.find((option) => option.value === settings.sendBehavior)?.label ??
|
||||
settings.sendBehavior;
|
||||
const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`;
|
||||
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
|
||||
(option) => option.value === settings.language,
|
||||
);
|
||||
@@ -379,72 +337,17 @@ function GeneralSection({
|
||||
t(selectedLanguageOption.labelKey),
|
||||
)
|
||||
: settings.language;
|
||||
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
||||
String(settings.terminalScrollbackLines),
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
|
||||
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitTerminalScrollback = useCallback(() => {
|
||||
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
|
||||
const nextValue = parsed ?? settings.terminalScrollbackLines;
|
||||
setTerminalScrollbackValue(String(nextValue));
|
||||
if (nextValue !== settings.terminalScrollbackLines) {
|
||||
handleTerminalScrollbackLinesChange(nextValue);
|
||||
}
|
||||
}, [
|
||||
handleTerminalScrollbackLinesChange,
|
||||
settings.terminalScrollbackLines,
|
||||
terminalScrollbackValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${t("settings.general.defaultSend.label")}: ${selectedSendBehaviorLabel}`}
|
||||
style={themeTriggerStyle}
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedSendBehaviorLabel}</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{sendBehaviorOptions.map((option) => (
|
||||
<SendBehaviorMenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
selected={settings.sendBehavior === option.value}
|
||||
onChange={handleSendBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={selectedLanguageLabel}
|
||||
style={themeTriggerStyle}
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
|
||||
<DropdownTrigger accessibilityRole="button" accessibilityLabel={selectedLanguageLabel}>
|
||||
{selectedLanguageLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={300}>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
@@ -459,55 +362,6 @@ function GeneralSection({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
{isDesktopApp ? (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.serviceUrls.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger style={themeTriggerStyle}>
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
|
||||
</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
|
||||
<ServiceUrlBehaviorMenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
label={getServiceUrlBehaviorLabel(t, value)}
|
||||
selected={settings.serviceUrlBehavior === value}
|
||||
onChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.terminalScrollback.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.terminalScrollback.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
initialValue={terminalScrollbackValue}
|
||||
onChangeText={handleTerminalScrollbackChangeText}
|
||||
onBlur={commitTerminalScrollback}
|
||||
onSubmitEditing={commitTerminalScrollback}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.terminalScrollbackInput}
|
||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -889,11 +743,14 @@ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): Hos
|
||||
interface SidebarSectionButtonProps {
|
||||
itemId: SettingsSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
isSelected: boolean;
|
||||
onSelect: (section: SettingsSectionSlug) => void;
|
||||
}
|
||||
|
||||
// Lighter than lucide's default 2px stroke so the nav reads quieter than the content.
|
||||
const SIDEBAR_ICON_STROKE_WIDTH = 1.5;
|
||||
|
||||
function SidebarSectionButton({
|
||||
itemId,
|
||||
label,
|
||||
@@ -920,6 +777,7 @@ function SidebarSectionButton({
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -931,7 +789,7 @@ function SidebarSectionButton({
|
||||
interface SidebarHostSectionButtonProps {
|
||||
itemId: HostSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
isSelected: boolean;
|
||||
onSelect: (section: HostSectionSlug) => void;
|
||||
}
|
||||
@@ -963,6 +821,7 @@ function SidebarHostSectionButton({
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -1082,9 +941,7 @@ function SettingsSidebar({
|
||||
const hasHosts = sortedHosts.length > 0;
|
||||
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter(
|
||||
(item) => (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb),
|
||||
);
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp));
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDesktop = layout === "desktop";
|
||||
const outerContainerStyle = useMemo(
|
||||
@@ -1116,7 +973,6 @@ function SettingsSidebar({
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
{hasHosts ? (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
|
||||
@@ -1147,7 +1003,11 @@ function SettingsSidebar({
|
||||
testID="settings-add-host"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
{t("settings.addHost")}
|
||||
</Text>
|
||||
@@ -1160,7 +1020,11 @@ function SettingsSidebar({
|
||||
testID="settings-enable-built-in-daemon"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Server
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
{t("settings.enableBuiltInDaemon")}
|
||||
</Text>
|
||||
@@ -1216,7 +1080,6 @@ export interface SettingsScreenProps {
|
||||
|
||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
@@ -1265,20 +1128,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
});
|
||||
}, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]);
|
||||
|
||||
const handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
void updateSettings({ sendBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleServiceUrlBehaviorChange = useCallback(
|
||||
(behavior: ServiceUrlBehavior) => {
|
||||
void updateSettings({ serviceUrlBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(language: AppLanguage) => {
|
||||
void updateSettings({ language });
|
||||
@@ -1286,13 +1135,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackLinesChange = useCallback(
|
||||
(terminalScrollbackLines: number) => {
|
||||
void updateSettings({ terminalScrollbackLines });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleUseLegacyTerminalRendererChange = useCallback(
|
||||
(useLegacyTerminalRenderer: boolean) => {
|
||||
void updateSettings({ useLegacyTerminalRenderer });
|
||||
@@ -1465,112 +1307,89 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
titleAccessory?: ReactNode;
|
||||
} | null => {
|
||||
if (view.kind === "plugin") {
|
||||
const screen = installedPlugins
|
||||
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
||||
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return {
|
||||
title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`,
|
||||
Icon: screen ? resolvePluginIcon(screen.icon) : Blocks,
|
||||
};
|
||||
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` };
|
||||
}
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
return { title: t(item.labelKey) };
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
return { title: t(item.labelKey) };
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return { title: t("settings.projects"), Icon: FolderGit2 };
|
||||
return { title: t("settings.projects") };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
let content: ReactNode;
|
||||
if (view.kind === "section" && view.section === "layout") {
|
||||
content = isDesktopApp ? <LayoutSection /> : null;
|
||||
} else {
|
||||
content = (() => {
|
||||
if (view.kind === "plugin")
|
||||
return (
|
||||
<PluginSettingsContent
|
||||
serverId={view.serverId}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
return renderHostSettingsContent(view, handleHostRemoved);
|
||||
const content: ReactNode = (() => {
|
||||
if (view.kind === "plugin")
|
||||
return (
|
||||
<PluginSettingsContent
|
||||
serverId={view.serverId}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
return renderHostSettingsContent(view, handleHostRemoved);
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return (
|
||||
<ProjectSettingsScreen
|
||||
serverId={view.serverId}
|
||||
projectId={view.projectId}
|
||||
onBackToProjects={handleBackFromDetail}
|
||||
showBackToProjects={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section);
|
||||
if (!item || !isSectionAvailable(item, isDesktopApp)) return null;
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
<GeneralSection settings={settings} handleLanguageChange={handleLanguageChange} />
|
||||
<SendingSection />
|
||||
{isDesktopApp ? <OpenLocationSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
handlePlaybackTest={handlePlaybackTest}
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
return (
|
||||
<AboutSection
|
||||
appVersion={appVersion}
|
||||
appVersionText={appVersionText}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return item.Content ? <item.Content /> : null;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return (
|
||||
<ProjectSettingsScreen
|
||||
serverId={view.serverId}
|
||||
projectId={view.projectId}
|
||||
onBackToProjects={handleBackFromDetail}
|
||||
showBackToProjects={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
{isDesktopApp ? <BrowserDataSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
case "editor":
|
||||
return isWeb ? <EditorSection /> : null;
|
||||
case "shortcuts":
|
||||
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "notifications":
|
||||
return isDesktopApp ? <DesktopNotificationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
handlePlaybackTest={handlePlaybackTest}
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
return (
|
||||
<AboutSection
|
||||
appVersion={appVersion}
|
||||
appVersionText={appVersionText}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
@@ -1580,14 +1399,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
}
|
||||
|
||||
const desktopDetailHeaderLeft = detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
|
||||
const desktopPageTitle = detailHeader ? (
|
||||
<View style={styles.pageTitleRow}>
|
||||
<Text style={styles.pageTitle} testID="settings-detail-header-title">
|
||||
{detailHeader.title}
|
||||
</Text>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const addHostModals = (
|
||||
@@ -1679,13 +1497,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
left={desktopDetailHeaderLeft}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
{/* Keeps the titlebar drag region and window controls; the page
|
||||
title lives in the content, like a document heading. */}
|
||||
<ScreenHeader borderless />
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View style={styles.content}>
|
||||
{desktopPageTitle}
|
||||
{content}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
@@ -1724,6 +1543,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
pageTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Line up with the section titles, which sit inset from their cards.
|
||||
marginLeft: theme.spacing[1],
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -1744,33 +1576,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
themeTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
themeTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
terminalScrollbackInput: {
|
||||
width: 112,
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "right",
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -1783,7 +1588,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopStyles = StyleSheet.create((theme) => ({
|
||||
const desktopStyles = StyleSheet.create({
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
@@ -1791,10 +1596,7 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
},
|
||||
detailLeft: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
desktopContainer: {
|
||||
@@ -1816,12 +1618,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
@@ -1829,8 +1631,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
@@ -1850,8 +1652,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import { Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import {
|
||||
SYNTAX_THEME_OPTIONS,
|
||||
type SyntaxThemeId,
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsCard, SettingsSwitch } from "@/components/settings";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { useContributedThemes } from "@/appearance/provider";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
@@ -46,7 +45,6 @@ import { isNative } from "@/constants/platform";
|
||||
import type { PluginThemeOption } from "@/plugins/themes";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { AppearancePreview } from "./appearance-preview";
|
||||
import { SidebarNavSection } from "./sidebar-nav-section";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
|
||||
@@ -57,7 +55,6 @@ import { SidebarNavSection } from "./sidebar-nav-section";
|
||||
const ThemedSun = withUnistyles(Sun);
|
||||
const ThemedMoon = withUnistyles(Moon);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -83,10 +80,6 @@ function sizeDraftToOverride(value: string): number | undefined {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.trigger, pressed ? styles.triggerPressed : null];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme picker
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -175,26 +168,29 @@ function ThemeRow({
|
||||
const selectedLabel = selectedPluginTheme
|
||||
? selectedPluginTheme.name
|
||||
: getThemeLabel(t, builtInValue);
|
||||
const leading = useMemo(
|
||||
() =>
|
||||
selectedPluginTheme ? (
|
||||
<ThemeSwatch color={selectedPluginTheme.swatch} />
|
||||
) : (
|
||||
<ThemeLeading themeValue={builtInValue} />
|
||||
),
|
||||
[builtInValue, selectedPluginTheme],
|
||||
);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
leading={leading}
|
||||
>
|
||||
{selectedPluginTheme ? (
|
||||
<ThemeSwatch color={selectedPluginTheme.swatch} />
|
||||
) : (
|
||||
<ThemeLeading themeValue={builtInValue} />
|
||||
)}
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
|
||||
{THEME_OPTIONS.map((option, index) => {
|
||||
const previousOption = THEME_OPTIONS[index - 1];
|
||||
@@ -226,109 +222,6 @@ function ThemeRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoExpandReasoningRowProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsSwitch
|
||||
label={t("settings.general.autoExpandReasoning.label")}
|
||||
hint={t("settings.general.autoExpandReasoning.description")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatOutlineRowProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsSwitch
|
||||
label={t("settings.appearance.chatOutline.title")}
|
||||
hint={t("settings.appearance.chatOutline.description")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
function getToolCallDetailLevelLabel(
|
||||
t: TFunction,
|
||||
value: AppSettings["toolCallDetailLevel"],
|
||||
): string {
|
||||
return t(`settings.general.toolCallDetail.options.${value}`);
|
||||
}
|
||||
|
||||
interface ToolCallDetailMenuItemProps {
|
||||
value: AppSettings["toolCallDetailLevel"];
|
||||
selected: boolean;
|
||||
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
|
||||
}
|
||||
|
||||
function ToolCallDetailMenuItem({ value, selected, onChange }: ToolCallDetailMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => onChange(value), [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{getToolCallDetailLevelLabel(t, value)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallDetailRowProps {
|
||||
value: AppSettings["toolCallDetailLevel"];
|
||||
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
|
||||
}
|
||||
|
||||
function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = getToolCallDetailLevelLabel(t, value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.toolCallDetail.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.toolCallDetail.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={t("settings.general.toolCallDetail.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{TOOL_CALL_DETAIL_LEVELS.map((option) => (
|
||||
<ToolCallDetailMenuItem
|
||||
key={option}
|
||||
value={option}
|
||||
selected={value === option}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts: family text fields + numeric size fields (commit on blur/submit)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -478,15 +371,13 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityLabel={t("settings.appearance.syntax.highlightThemeAccessibility", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SYNTAX_THEME_OPTIONS.map((option) => (
|
||||
<SyntaxMenuItem
|
||||
@@ -556,27 +447,6 @@ export function AppearanceSection() {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleAutoExpandReasoningChange = useCallback(
|
||||
(autoExpandReasoning: boolean) => {
|
||||
void updateSettings({ autoExpandReasoning });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleToolCallDetailLevelChange = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => {
|
||||
void updateSettings({ toolCallDetailLevel });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleChatOutlineChange = useCallback(
|
||||
(chatOutlineEnabled: boolean) => {
|
||||
void updateSettings({ chatOutlineEnabled });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const commitUiFontFamily = useCallback(
|
||||
(value: string) => {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
@@ -680,25 +550,6 @@ export function AppearanceSection() {
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
|
||||
<SettingsCard>
|
||||
<AutoExpandReasoningRow
|
||||
value={settings.autoExpandReasoning}
|
||||
onChange={handleAutoExpandReasoningChange}
|
||||
/>
|
||||
<ToolCallDetailRow
|
||||
value={settings.toolCallDetailLevel}
|
||||
onChange={handleToolCallDetailLevelChange}
|
||||
/>
|
||||
{!isNative ? (
|
||||
<ChatOutlineRow
|
||||
value={settings.chatOutlineEnabled}
|
||||
onChange={handleChatOutlineChange}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
<SidebarNavSection />
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{showInterfaceFontFamilyRow ? (
|
||||
@@ -777,23 +628,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
triggerPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
swatch: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SettingsSelect,
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
export function ChatSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const toolCallDetailOptions = useMemo(
|
||||
() =>
|
||||
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.toolCallDetail.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const changeToolCallDetailLevel = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
|
||||
void updateSettings({ toolCallDetailLevel }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeAutoExpandReasoning = useCallback(
|
||||
(autoExpandReasoning: boolean) => void updateSettings({ autoExpandReasoning }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeChatOutline = useCallback(
|
||||
(chatOutlineEnabled: boolean) => void updateSettings({ chatOutlineEnabled }),
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
|
||||
<SettingsCard>
|
||||
<SettingsSwitch
|
||||
label={t("settings.general.autoExpandReasoning.label")}
|
||||
hint={t("settings.general.autoExpandReasoning.description")}
|
||||
value={settings.autoExpandReasoning}
|
||||
onValueChange={changeAutoExpandReasoning}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.toolCallDetail.label")}
|
||||
hint={t("settings.general.toolCallDetail.description")}
|
||||
value={settings.toolCallDetailLevel}
|
||||
options={toolCallDetailOptions}
|
||||
onValueChange={changeToolCallDetailLevel}
|
||||
/>
|
||||
{isNative ? null : (
|
||||
<SettingsSwitch
|
||||
label={t("settings.appearance.chatOutline.title")}
|
||||
hint={t("settings.appearance.chatOutline.description")}
|
||||
value={settings.chatOutlineEnabled}
|
||||
onValueChange={changeChatOutline}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
|
||||
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
|
||||
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
|
||||
|
||||
export function SendingSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SEND_BEHAVIORS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.defaultSend.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
const change = useCallback(
|
||||
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.sending")}>
|
||||
<SettingsCard>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.defaultSend.label")}
|
||||
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
|
||||
value={settings.sendBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Pencil } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Pencil } from "lucide-react-native";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -28,15 +24,10 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger;
|
||||
}
|
||||
|
||||
function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { renameHost } = useHostMutations();
|
||||
@@ -119,23 +110,22 @@ function ColorMenuItem({
|
||||
function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = colorLabel(t, color);
|
||||
const leading = useMemo(() => <ColorSwatch color={color} />, [color]);
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.color.label")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.host.appearance.color.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
leading={leading}
|
||||
>
|
||||
<ColorSwatch color={color} />
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{HOST_COLORS.map((option) => (
|
||||
<ColorMenuItem
|
||||
@@ -166,17 +156,15 @@ function BadgeDisplayRow({
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.badge.label")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<DropdownTrigger
|
||||
testID="host-appearance-badge-display"
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.host.appearance.badge.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{HOST_BADGE_DISPLAYS.map((option) => (
|
||||
<BadgeDisplayMenuItem
|
||||
@@ -312,23 +300,6 @@ export function HostAppearanceSection({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
triggerPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
swatch: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
|
||||
@@ -375,12 +375,6 @@ export function HostSettingsPage({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.daemonHeader}>
|
||||
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
||||
{host.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<HostStatusBadges serverId={serverId} />
|
||||
<HostConnectionError serverId={serverId} />
|
||||
|
||||
@@ -1714,18 +1708,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
daemonHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
daemonHeaderLabel: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
identityBadges: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
+41
-8
@@ -5,6 +5,7 @@ import {
|
||||
useAppSettings,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type ServiceUrlBehavior,
|
||||
} from "@/hooks/use-settings";
|
||||
|
||||
const SOURCES = [
|
||||
@@ -15,18 +16,26 @@ const SOURCES = [
|
||||
"subagents",
|
||||
] as const satisfies readonly (keyof OpenInSidePanePreferences)[];
|
||||
|
||||
type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests";
|
||||
const SERVICE_URL_BEHAVIORS: readonly ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
function LayoutPreferenceRow({
|
||||
const SERVICE_URL_LABEL_KEYS: Record<ServiceUrlBehavior, string> = {
|
||||
ask: "settings.general.serviceUrls.options.ask",
|
||||
"in-app": "settings.general.serviceUrls.options.inApp",
|
||||
external: "settings.general.serviceUrls.options.external",
|
||||
};
|
||||
|
||||
type OpenLocationSource = keyof OpenInSidePanePreferences | "pullRequests";
|
||||
|
||||
function OpenLocationRow({
|
||||
source,
|
||||
destination,
|
||||
allowExplorer,
|
||||
onDestinationChange,
|
||||
}: {
|
||||
source: LayoutPreferenceSource;
|
||||
source: OpenLocationSource;
|
||||
destination: PullRequestOpenLocation;
|
||||
allowExplorer?: boolean;
|
||||
onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void;
|
||||
onDestinationChange(source: OpenLocationSource, destination: PullRequestOpenLocation): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(() => {
|
||||
@@ -52,11 +61,34 @@ function LayoutPreferenceRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutSection() {
|
||||
function ServiceUrlRow() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SERVICE_URL_BEHAVIORS.map((value) => ({ value, label: t(SERVICE_URL_LABEL_KEYS[value]) })),
|
||||
[t],
|
||||
);
|
||||
const change = useCallback(
|
||||
(serviceUrlBehavior: ServiceUrlBehavior) => void updateSettings({ serviceUrlBehavior }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSelect
|
||||
label={t("settings.layout.openInSidePane.sources.serviceUrls.label")}
|
||||
value={settings.serviceUrlBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where things open: files, diffs, subagents, pull requests, and script URLs. Desktop only. */
|
||||
export function OpenLocationSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const handleDestinationChange = useCallback(
|
||||
(source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => {
|
||||
(source: OpenLocationSource, destination: PullRequestOpenLocation) => {
|
||||
if (source === "pullRequests") {
|
||||
void updateSettings({ pullRequestOpenLocation: destination });
|
||||
return;
|
||||
@@ -71,19 +103,20 @@ export function LayoutSection() {
|
||||
<SettingsSection title={t("settings.layout.openInSidePane.title")}>
|
||||
<SettingsCard>
|
||||
{SOURCES.map((source) => (
|
||||
<LayoutPreferenceRow
|
||||
<OpenLocationRow
|
||||
key={source}
|
||||
source={source}
|
||||
destination={settings.openInSidePane[source] ? "side" : "main"}
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
))}
|
||||
<LayoutPreferenceRow
|
||||
<OpenLocationRow
|
||||
source="pullRequests"
|
||||
destination={settings.pullRequestOpenLocation}
|
||||
allowExplorer
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
<ServiceUrlRow />
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsSection } from "@/components/settings";
|
||||
import { FormTextInput } from "@/components/ui/form-field";
|
||||
import { parseTerminalScrollbackLines, useAppSettings } from "@/hooks/use-settings";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
export function TerminalSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [scrollbackValue, setScrollbackValue] = useState(String(settings.terminalScrollbackLines));
|
||||
|
||||
const handleChangeText = useCallback((value: string) => {
|
||||
setScrollbackValue(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitScrollback = useCallback(() => {
|
||||
const nextValue =
|
||||
parseTerminalScrollbackLines(scrollbackValue) ?? settings.terminalScrollbackLines;
|
||||
setScrollbackValue(String(nextValue));
|
||||
if (nextValue !== settings.terminalScrollbackLines) {
|
||||
void updateSettings({ terminalScrollbackLines: nextValue });
|
||||
}
|
||||
}, [scrollbackValue, settings.terminalScrollbackLines, updateSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollbackValue(String(settings.terminalScrollbackLines));
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.sections.terminal")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.terminalScrollback.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.terminalScrollback.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<FormTextInput
|
||||
size="sm"
|
||||
initialValue={scrollbackValue}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={commitScrollback}
|
||||
onSubmitEditing={commitScrollback}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.scrollbackInput}
|
||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollbackInput: {
|
||||
width: 112,
|
||||
textAlign: "right",
|
||||
},
|
||||
});
|
||||
@@ -159,6 +159,20 @@ const darkStatusColors = {
|
||||
statusMerged: "#a890d5",
|
||||
};
|
||||
|
||||
// Status tints — the fill of a status badge. The status color itself at low opacity, so a
|
||||
// badge takes its state's hue from the same source as its text and never drifts from it.
|
||||
// Dark surfaces swallow more of the tint, so the dark band runs a little stronger.
|
||||
function statusTints(colors: typeof lightStatusColors, alphaHex: string) {
|
||||
return {
|
||||
statusSuccessTint: `${colors.statusSuccess}${alphaHex}`,
|
||||
statusDangerTint: `${colors.statusDanger}${alphaHex}`,
|
||||
statusWarningTint: `${colors.statusWarning}${alphaHex}`,
|
||||
};
|
||||
}
|
||||
|
||||
const lightStatusTints = statusTints(lightStatusColors, "1f"); // 12%
|
||||
const darkStatusTints = statusTints(darkStatusColors, "29"); // 16%
|
||||
|
||||
// Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in
|
||||
// for them. Same four hues and the same generation rule as the status colors above, but its
|
||||
// own band, because a dot is doing a different job than a check icon or a host badge.
|
||||
@@ -289,6 +303,7 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
|
||||
|
||||
...lightDiffColors,
|
||||
...lightStatusColors,
|
||||
...lightStatusTints,
|
||||
...lightStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
@@ -420,6 +435,7 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
|
||||
...darkDiffColors,
|
||||
...darkStatusColors,
|
||||
...darkStatusTints,
|
||||
...darkStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
|
||||
@@ -492,7 +492,10 @@ export function resolveKnownHostRoute(input: {
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"general",
|
||||
"appearance",
|
||||
"layout",
|
||||
"sidebar",
|
||||
"chat",
|
||||
"terminal",
|
||||
"browser",
|
||||
"editor",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collectAllPanes,
|
||||
DEFAULT_PANE_ID,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type WorkspaceTabPlacement,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -109,6 +110,10 @@ export function openWorkspaceTargetAtLocation(
|
||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
placement = paneId ? { mode: "prefer", paneId } : undefined;
|
||||
} else if (!shouldOpenBeside && layout && input.parentTabId) {
|
||||
// A target opened from a tab belongs in that tab's pane, whichever pane has focus.
|
||||
const parentPane = findPaneContainingTab(layout.root, input.parentTabId);
|
||||
placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined;
|
||||
}
|
||||
return store.openTab({
|
||||
workspaceKey: input.workspaceKey,
|
||||
|
||||
@@ -136,6 +136,24 @@ describe("resolveWorkspaceFilePaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves files under a drive-root workspace without doubling the separator", () => {
|
||||
expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||
absolutePath: "C:/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({
|
||||
absolutePath: "C:/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves files under the POSIX root workspace", () => {
|
||||
expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({
|
||||
absolutePath: "/src/app.ts",
|
||||
relativePath: "src/app.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for home-relative paths that cannot be anchored", () => {
|
||||
expect(
|
||||
resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }),
|
||||
|
||||
@@ -129,6 +129,17 @@ function startsWithPath(value: string, prefix: string): boolean {
|
||||
: value.startsWith(prefix);
|
||||
}
|
||||
|
||||
function pathWithinRoot(root: string, relativePath: string): string {
|
||||
return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`;
|
||||
}
|
||||
|
||||
/** Whether an absolute path names the workspace directory itself. */
|
||||
export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean {
|
||||
const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/"));
|
||||
const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
|
||||
return path !== null && root !== null && pathsEqual(path, root);
|
||||
}
|
||||
|
||||
export interface ResolvedWorkspaceFilePaths {
|
||||
/** Absolute path on the host, suitable for opening in an editor / file manager. */
|
||||
absolutePath: string;
|
||||
@@ -159,7 +170,7 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
if (pathsEqual(normalizedFile, workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
const prefix = `${workspaceRoot}/`;
|
||||
const prefix = pathWithinRoot(workspaceRoot, "");
|
||||
const relativePath = startsWithPath(normalizedFile, prefix)
|
||||
? normalizedFile.slice(prefix.length)
|
||||
: null;
|
||||
@@ -174,5 +185,5 @@ export function resolveWorkspaceFilePaths(input: {
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
|
||||
return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ const testTheme = {
|
||||
statusDanger: "#b91c1c",
|
||||
statusWarning: "#d97706",
|
||||
statusMerged: "#7c3aed",
|
||||
// Same rule as the real theme: the status color at 12%.
|
||||
statusSuccessTint: "#15803d1f",
|
||||
statusDangerTint: "#b91c1c1f",
|
||||
statusWarningTint: "#d977061f",
|
||||
// The light band's values, so a test can name the colour it expects.
|
||||
statusDotSuccess: "#299f51",
|
||||
statusDotDanger: "#f12e2f",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"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.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/server": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/server": "0.10.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -82,10 +82,15 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 4.8",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5-5",
|
||||
model: "Sonnet 5.5",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
model: "Sonnet 5",
|
||||
descriptionFragment: "Best for everyday tasks",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-7[1m]",
|
||||
@@ -430,7 +435,7 @@ try {
|
||||
"--quiet should print the same ordered model IDs returned by --json",
|
||||
);
|
||||
assert(
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"),
|
||||
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5-5"),
|
||||
"captured --json output should include the current Claude everyday model id",
|
||||
);
|
||||
console.log("✓ provider models --quiet outputs model IDs only\n");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,7 +9,10 @@ const MAX_TRANSIENT_DETACHED_PANES = 2;
|
||||
const SETTINGS_DESTINATIONS = [
|
||||
"General",
|
||||
"Appearance",
|
||||
"Layout",
|
||||
"Sidebar",
|
||||
"Chat",
|
||||
"Terminal",
|
||||
"Browser",
|
||||
"Editor",
|
||||
"Shortcuts",
|
||||
"Integrations",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -196,11 +196,13 @@ describe("guessTerminalProfileIcon", () => {
|
||||
["/usr/local/bin/gemini", "gemini"],
|
||||
["agy", "agy"],
|
||||
["/usr/local/bin/agy", "agy"],
|
||||
["cursor-agent", "cursor"],
|
||||
["/usr/local/bin/cursor-agent", "cursor"],
|
||||
])("guesses %s -> %s", (command, expected) => {
|
||||
expect(guessTerminalProfileIcon(command)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-agent"]])(
|
||||
it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-tool"]])(
|
||||
"returns undefined for unknown command %s",
|
||||
(command) => {
|
||||
expect(guessTerminalProfileIcon(command)).toBeUndefined();
|
||||
@@ -219,6 +221,26 @@ describe("getTerminalProfileIcon", () => {
|
||||
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
||||
});
|
||||
|
||||
it("shows the Cursor icon for a profile created in settings with the Cursor CLI", () => {
|
||||
const profile = {
|
||||
id: "profile_1",
|
||||
name: "Cursor",
|
||||
command: "cursor-agent",
|
||||
args: [PROMPT_SENTINEL],
|
||||
};
|
||||
expect(getTerminalProfileIcon(profile)).toBe("cursor");
|
||||
});
|
||||
|
||||
it("keeps an explicit icon over the Cursor CLI guess", () => {
|
||||
const profile = {
|
||||
id: "profile_1",
|
||||
name: "Cursor",
|
||||
command: "cursor-agent",
|
||||
icon: "claude",
|
||||
};
|
||||
expect(getTerminalProfileIcon(profile)).toBe("claude");
|
||||
});
|
||||
|
||||
it("returns undefined when no icon is set and command is unknown", () => {
|
||||
const profile = { id: "1", name: "Foo", command: "zsh" };
|
||||
expect(getTerminalProfileIcon(profile)).toBeUndefined();
|
||||
|
||||
@@ -105,7 +105,14 @@ export function resolveTerminalProfileLaunch(
|
||||
return { name: profile.name, ...substitutePrompt(profile, prompt) };
|
||||
}
|
||||
|
||||
const WELL_KNOWN_COMMAND_ICONS = new Map(KNOWN_PROVIDER_ICON_NAMES.map((name) => [name, name]));
|
||||
// CLIs whose binary is not named after their icon. Matching stays exact, so a
|
||||
// command only gets an icon when its whole base name is listed.
|
||||
const COMMAND_ICON_ALIASES: ReadonlyArray<[string, string]> = [["cursor-agent", "cursor"]];
|
||||
|
||||
const WELL_KNOWN_COMMAND_ICONS = new Map([
|
||||
...KNOWN_PROVIDER_ICON_NAMES.map((name): [string, string] => [name, name]),
|
||||
...COMMAND_ICON_ALIASES,
|
||||
]);
|
||||
|
||||
function getCommandBaseName(command: string): string {
|
||||
const lastSlash = command.lastIndexOf("/");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.2",
|
||||
"@getpaseo/highlight": "0.9.2",
|
||||
"@getpaseo/plugin": "0.9.2",
|
||||
"@getpaseo/protocol": "0.9.2",
|
||||
"@getpaseo/relay": "0.9.2",
|
||||
"@getpaseo/client": "0.10.1",
|
||||
"@getpaseo/highlight": "0.10.1",
|
||||
"@getpaseo/plugin": "0.10.1",
|
||||
"@getpaseo/protocol": "0.10.1",
|
||||
"@getpaseo/relay": "0.10.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -4851,6 +4851,53 @@ test("persists live mode, model, and thinking changes without an external snapsh
|
||||
expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-"));
|
||||
class ModelSession extends TestAgentSession {
|
||||
selection = { model: "source", thinkingOptionId: "medium" as string | undefined };
|
||||
async setModel(model: string | null) {
|
||||
this.selection = { model: model ?? "default", thinkingOptionId: undefined };
|
||||
this.pushEvent({
|
||||
type: "thinking_option_changed",
|
||||
provider: "codex",
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
}
|
||||
describePersistence() {
|
||||
return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } };
|
||||
}
|
||||
}
|
||||
class ModelClient extends TestAgentClient {
|
||||
async createSession(config: AgentSessionConfig) {
|
||||
return new ModelSession(config);
|
||||
}
|
||||
}
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ModelClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentModel(agent.id, "target");
|
||||
await manager.flush();
|
||||
const stored = await storage.get(agent.id);
|
||||
expect(stored?.config?.model).toBe("target");
|
||||
expect(stored?.config?.thinkingOptionId).toBeUndefined();
|
||||
expect(stored?.persistence?.metadata?.model).toBe("target");
|
||||
expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined();
|
||||
} finally {
|
||||
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("later explicit config mutations win over events emitted by earlier mutations", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-"));
|
||||
class ConfigMutationSession extends TestAgentSession {
|
||||
|
||||
@@ -1943,6 +1943,7 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -3704,18 +3704,29 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
readonly prompts: string[] = [];
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
private turnStartGate: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
) {}
|
||||
|
||||
/** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */
|
||||
holdTurnStart(): () => void {
|
||||
let release!: () => void;
|
||||
this.turnStartGate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return release;
|
||||
}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
await this.turnStartGate;
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
setTimeout(() => {
|
||||
@@ -4085,6 +4096,127 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a background prompt's accepted turn as running", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "send_agent_prompt");
|
||||
const childSession = childClient.sessions[0]!;
|
||||
const acknowledgeTurnStart = childSession.holdTurnStart();
|
||||
|
||||
const pending = invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "Follow up",
|
||||
});
|
||||
await vi.waitFor(() => expect(childSession.prompts).toEqual(["Follow up"]));
|
||||
acknowledgeTurnStart();
|
||||
const response = await pending;
|
||||
|
||||
expect(response.structuredContent).toMatchObject({ success: true, status: "running" });
|
||||
expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running");
|
||||
|
||||
childSession.finishTurn();
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(child.id);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a background turn completed as the manager publishes its start", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-fast-background-send-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", true);
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await agentManager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
let finished = false;
|
||||
const unsubscribe = agentManager.subscribe(
|
||||
(event) => {
|
||||
if (
|
||||
!finished &&
|
||||
event.type === "agent_state" &&
|
||||
event.agent.id === child.id &&
|
||||
event.agent.lifecycle === "running"
|
||||
) {
|
||||
finished = true;
|
||||
childClient.sessions[0]!.finishTurn();
|
||||
}
|
||||
},
|
||||
{ agentId: child.id, replayState: false },
|
||||
);
|
||||
|
||||
const response = await invokeToolWithParsedInput(
|
||||
registeredTool(server, "send_agent_prompt"),
|
||||
{
|
||||
agentId: child.id,
|
||||
prompt: "Fast reply",
|
||||
},
|
||||
);
|
||||
unsubscribe();
|
||||
expect(response.structuredContent).toMatchObject({ success: true, status: "idle" });
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(child.id);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("update_agent MCP tool", () => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
executableExists,
|
||||
findExecutable,
|
||||
} from "../../executable-resolution/executable-resolution.js";
|
||||
import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js";
|
||||
import {
|
||||
createExternalProcessEnv,
|
||||
type ProcessEnvRecord,
|
||||
type ExternalProcessEnv,
|
||||
} from "../paseo-env.js";
|
||||
export {
|
||||
AgentProviderRuntimeSettingsMapSchema,
|
||||
ProviderCommandSchema,
|
||||
@@ -242,7 +246,7 @@ export function createProviderEnvSpec(options: ProviderEnvOptions = {}): Provide
|
||||
};
|
||||
}
|
||||
|
||||
export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.ProcessEnv {
|
||||
export function createProviderEnv(options: ProviderEnvOptions = {}): ExternalProcessEnv {
|
||||
const spec = createProviderEnvSpec(options);
|
||||
return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ const mockState = vi.hoisted(() => {
|
||||
isCommandAvailable: vi.fn(async (_command: string) => false),
|
||||
runtimeModels: new Map<string, AgentModelDefinition[]>(),
|
||||
cursorListFeaturesConfigs: [] as AgentSessionConfig[],
|
||||
codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>,
|
||||
reset() {
|
||||
this.constructorArgs.claude = [];
|
||||
this.constructorArgs.codex = [];
|
||||
@@ -73,6 +74,7 @@ const mockState = vi.hoisted(() => {
|
||||
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
|
||||
this.runtimeModels.clear();
|
||||
this.cursorListFeaturesConfigs = [];
|
||||
this.codexNativeArchiveCalls = [];
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -171,6 +173,14 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
|
||||
};
|
||||
}
|
||||
|
||||
async archiveNativeSession(handle: unknown): Promise<void> {
|
||||
mockState.codexNativeArchiveCalls.push({ state: "archive", handle });
|
||||
}
|
||||
|
||||
async unarchiveNativeSession(handle: unknown): Promise<void> {
|
||||
mockState.codexNativeArchiveCalls.push({ state: "restore", handle });
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command: { mode?: string; argv?: string[] } | undefined =
|
||||
typeof this.runtimeSettings === "object" && this.runtimeSettings !== null
|
||||
@@ -651,6 +661,22 @@ test("new provider extending claude appears in registry", () => {
|
||||
expect(registry.zai.createClient(logger).provider).toBe("zai");
|
||||
});
|
||||
|
||||
test("new provider extending codex archives and unarchives its native sessions", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: { "my-codex": { extends: "codex", label: "My Codex" } },
|
||||
});
|
||||
const client = registry["my-codex"].createClient(logger);
|
||||
const handle = { provider: "my-codex", sessionId: "thread-1", nativeHandle: "thread-1" };
|
||||
|
||||
await client.archiveNativeSession?.(handle);
|
||||
await client.unarchiveNativeSession?.(handle);
|
||||
|
||||
expect(mockState.codexNativeArchiveCalls).toEqual([
|
||||
{ state: "archive", handle: { ...handle, provider: "codex" } },
|
||||
{ state: "restore", handle: { ...handle, provider: "codex" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => {
|
||||
const omp = new FakeOmp(["custom-omp"]);
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
|
||||
@@ -487,6 +487,8 @@ function wrapClientProvider(
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -575,6 +577,12 @@ function wrapClientProvider(
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
archiveNativeSession: archiveNativeSession
|
||||
? async (handle) => await archiveNativeSession({ ...handle, provider: inner.provider })
|
||||
: undefined,
|
||||
unarchiveNativeSession: unarchiveNativeSession
|
||||
? async (handle) => await unarchiveNativeSession({ ...handle, provider: inner.provider })
|
||||
: undefined,
|
||||
getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner),
|
||||
isAvailable: (signal, options) => inner.isAvailable(signal, options),
|
||||
getDiagnostic: inner.getDiagnostic?.bind(inner),
|
||||
|
||||
@@ -496,7 +496,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
resolveVersion: async () => "2.1.219",
|
||||
resolveVersion: async () => "2.1.284",
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
|
||||
});
|
||||
const { models } = await client.fetchCatalog({
|
||||
@@ -515,6 +515,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh");
|
||||
expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off");
|
||||
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
|
||||
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
|
||||
@@ -1238,6 +1240,26 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects disabled thinking on Sonnet 5.5, which only runs with adaptive thinking", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5-5",
|
||||
});
|
||||
|
||||
await expect(session.setThinkingOption?.("off")).rejects.toThrow(
|
||||
"Thinking option 'off' is not available for model 'claude-sonnet-5-5'",
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects an initial disabled-thinking config for an unsupported model", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -93,10 +93,19 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
supportsThinkingDisabled: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5-5",
|
||||
label: "Sonnet 5.5",
|
||||
description: "Sonnet 5.5 · Best for everyday tasks",
|
||||
minimumClaudeCodeVersion: "2.1.284",
|
||||
defaultThinkingOptionId: "medium",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-5",
|
||||
label: "Sonnet 5",
|
||||
description: "Sonnet 5 · Best for everyday tasks",
|
||||
description: "Sonnet 5 · Previous release",
|
||||
contextWindowMaxTokens: 200_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsThinkingDisabled: true,
|
||||
|
||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.284"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
@@ -57,6 +57,7 @@ describe("getClaudeModels", () => {
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-5[1m]",
|
||||
"claude-opus-4-7[1m]",
|
||||
@@ -90,6 +91,7 @@ describe("getClaudeModels", () => {
|
||||
["claude-fable-5[1m]", 1_000_000],
|
||||
["claude-opus-4-8[1m]", 1_000_000],
|
||||
["claude-opus-4-8", 200_000],
|
||||
["claude-sonnet-5-5", 1_000_000],
|
||||
["claude-sonnet-5", 200_000],
|
||||
["claude-sonnet-5[1m]", 1_000_000],
|
||||
["claude-opus-4-7[1m]", 1_000_000],
|
||||
@@ -116,6 +118,9 @@ describe("getClaudeModels", () => {
|
||||
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
|
||||
expect(getClaudeModels("2.1.283").map((model) => model.id)).not.toContain("claude-sonnet-5-5");
|
||||
expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5");
|
||||
});
|
||||
|
||||
it("derives thinking options from model effort capabilities", () => {
|
||||
@@ -178,6 +183,8 @@ describe("getClaudeModels", () => {
|
||||
it.each([
|
||||
["claude-opus-5-5", false, "medium"],
|
||||
["claude-opus-5-5-20260401", false, "medium"],
|
||||
["claude-sonnet-5-5", false, "medium"],
|
||||
["claude-sonnet-5-5-20260928", false, "medium"],
|
||||
["claude-opus-5", true, "high"],
|
||||
["claude-opus-5-20260724", true, "high"],
|
||||
["claude-sonnet-5", true, "high"],
|
||||
@@ -467,6 +474,16 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a prefixed Sonnet 5.5 onto Sonnet 5", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("anthropic/claude-sonnet-5-5")).toBe("claude-sonnet-5-5");
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-5-20260928-v1:0")).toBe(
|
||||
"claude-sonnet-5-5",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-20260101-v1:0")).toBe(
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeVersion", () => {
|
||||
@@ -600,6 +617,42 @@ describe("Claude Opus 5.5 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Sonnet 5.5 catalog", () => {
|
||||
it("offers one Sonnet 5.5 entry with a 1M context window", () => {
|
||||
const sonnet55Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-sonnet-5-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(sonnet55Models).toEqual([
|
||||
{ id: "claude-sonnet-5-5", label: "Sonnet 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers every effort level except off, because Sonnet 5.5 cannot disable thinking", () => {
|
||||
const sonnet55 = getClaudeModels().find((model) => model.id === "claude-sonnet-5-5");
|
||||
|
||||
expect(sonnet55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(sonnet55?.defaultThinkingOptionId).toBe("medium");
|
||||
expect(
|
||||
sonnet55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["medium"]);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Sonnet 5.5 IDs to the single catalog entry", () => {
|
||||
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5-20260928")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5-20260928[1m]")?.id).toBe("claude-sonnet-5-5");
|
||||
expect(findClaudeModel("claude-sonnet-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
@@ -610,6 +663,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
|
||||
|
||||
@@ -617,6 +617,111 @@ process.stdin.on("data", (chunk) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function withCustomCodexProviderHome<T>(
|
||||
run: (input: {
|
||||
session: AgentSession;
|
||||
readCaptured: () => CapturedFakeCodexRecord[];
|
||||
}) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-"));
|
||||
const daemonCodexHome = path.join(tempDir, "daemon-codex-home");
|
||||
const providerCodexHome = path.join(tempDir, "provider-codex-home");
|
||||
const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs");
|
||||
const capturedRequestsPath = path.join(tempDir, "requests.jsonl");
|
||||
mkdirSync(path.join(daemonCodexHome, "prompts"), { recursive: true });
|
||||
mkdirSync(path.join(providerCodexHome, "prompts"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(daemonCodexHome, "prompts", "probe-default.md"),
|
||||
"---\ndescription: Daemon home prompt\n---\nfrom the daemon home\n",
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(providerCodexHome, "prompts", "probe-profile.md"),
|
||||
"---\ndescription: Provider home prompt\n---\nfrom the provider home\n",
|
||||
);
|
||||
writeFileSync(
|
||||
fakeAppServerPath,
|
||||
`
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE;
|
||||
let buffer = "";
|
||||
|
||||
fs.appendFileSync(capturePath, JSON.stringify({ kind: "env", CODEX_HOME: process.env.CODEX_HOME }) + "\\n");
|
||||
|
||||
function resultFor(method) {
|
||||
if (method === "collaborationMode/list") return { data: [] };
|
||||
if (method === "skills/list") return { data: [] };
|
||||
if (method === "model/list") return { data: [{ id: "profile-model", isDefault: true }] };
|
||||
if (method === "thread/start") return { thread: { id: "thread-1" } };
|
||||
return {};
|
||||
}
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\\n");
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line) continue;
|
||||
const message = JSON.parse(line);
|
||||
fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method: message.method, params: message.params }) + "\\n");
|
||||
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
|
||||
}
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
vi.stubEnv("CODEX_HOME", daemonCodexHome);
|
||||
const registry = buildProviderRegistry(createTestLogger(), {
|
||||
providerOverrides: {
|
||||
"profile-codex": {
|
||||
extends: "codex",
|
||||
label: "Profile Codex",
|
||||
command: [process.execPath, fakeAppServerPath],
|
||||
env: {
|
||||
CODEX_HOME: providerCodexHome,
|
||||
PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await registry["profile-codex"].createClient(createTestLogger()).createSession({
|
||||
provider: "profile-codex",
|
||||
cwd: tempDir,
|
||||
modeId: "auto",
|
||||
});
|
||||
|
||||
try {
|
||||
return await run({
|
||||
session,
|
||||
readCaptured: () =>
|
||||
readFileSync(capturedRequestsPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as CapturedFakeCodexRecord),
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function listPromptCommandsFromCustomCodexHome(): Promise<string[]> {
|
||||
return withCustomCodexProviderHome(async ({ session }) => {
|
||||
const commands = await session.listCommands!();
|
||||
return commands.map((command) => command.name).filter((name) => name.startsWith("prompts:"));
|
||||
});
|
||||
}
|
||||
|
||||
async function runPromptFromCustomCodexHome(prompt: string): Promise<CapturedFakeCodexRecord[]> {
|
||||
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
|
||||
await session.startTurn(prompt);
|
||||
return readCaptured();
|
||||
});
|
||||
}
|
||||
|
||||
function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown {
|
||||
const threadStart = records.find((record) => record.method === "thread/start");
|
||||
const params = threadStart?.params as Record<string, unknown> | undefined;
|
||||
@@ -1833,6 +1938,79 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.each(["legacy", "paginated"] as const)(
|
||||
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
|
||||
async (historyMode) => {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
historyMode === "paginated"
|
||||
? {
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const customCodexConfig = {
|
||||
model_provider: "codex-custom",
|
||||
model_providers: {
|
||||
"codex-custom": {
|
||||
name: "Custom Codex",
|
||||
base_url: "https://custom-relay.example.com/v1",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
requires_openai_auth: false,
|
||||
wire_api: "responses",
|
||||
},
|
||||
},
|
||||
};
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({
|
||||
cwd: "/workspace/project",
|
||||
mcpServers: {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" },
|
||||
},
|
||||
}),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
{ customCodexConfig },
|
||||
);
|
||||
|
||||
await session.startTurn("remember first");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-first",
|
||||
text: "remember first",
|
||||
turnId: "turn-first",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
await session.startTurn("remember second");
|
||||
emitCodexUserMessage(appServer, {
|
||||
id: "codex-second",
|
||||
text: "remember second",
|
||||
turnId: "turn-second",
|
||||
});
|
||||
appServer.completeTurn();
|
||||
|
||||
await session.revertConversation({ messageId: "codex-first" });
|
||||
|
||||
const forkConfigs = appServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/fork")
|
||||
.map((request) => (request.params as { config?: unknown }).config);
|
||||
expect(forkConfigs).toEqual([
|
||||
expect.objectContaining({
|
||||
...customCodexConfig,
|
||||
mcp_servers: {
|
||||
paseo: expect.objectContaining({
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
},
|
||||
);
|
||||
|
||||
test("correlates a Codex user message with the submitting client message", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
@@ -1989,7 +2167,9 @@ describe("Codex app-server provider", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
|
||||
await expect(
|
||||
listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService),
|
||||
).resolves.toContainEqual({
|
||||
name: "shipper",
|
||||
description: "Ship changes carefully.",
|
||||
argumentHint: "",
|
||||
@@ -2250,6 +2430,22 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("lists custom prompts from the CODEX_HOME a custom provider runs Codex with", async () => {
|
||||
await expect(listPromptCommandsFromCustomCodexHome()).resolves.toEqual([
|
||||
"prompts:probe-profile",
|
||||
]);
|
||||
});
|
||||
|
||||
test("runs a custom prompt from the CODEX_HOME a custom provider runs Codex with", async () => {
|
||||
const records = await runPromptFromCustomCodexHome("/prompts:probe-profile");
|
||||
|
||||
expect(records.find((record) => record.kind === "env")?.CODEX_HOME).toMatch(
|
||||
/provider-codex-home$/,
|
||||
);
|
||||
const turnStart = records.find((record) => record.method === "turn/start");
|
||||
expect(JSON.stringify(turnStart?.params)).toContain("from the provider home");
|
||||
});
|
||||
|
||||
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
|
||||
@@ -260,6 +260,8 @@ interface CodexAppServerAgentDeps {
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: CodexCustomProviderConfig | null;
|
||||
// The CODEX_HOME the session's app-server runs with; prompts and skills are read from it.
|
||||
codexHome?: string;
|
||||
_createCodexClient?: (
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
logger: Logger,
|
||||
@@ -545,8 +547,8 @@ async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexHomeDir(): string {
|
||||
return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
function resolveCodexHomeDir(env: NodeJS.ProcessEnv): string {
|
||||
return env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
function decodeEscapedChar(next: string): string {
|
||||
@@ -656,8 +658,7 @@ function parseFrontMatter(markdown: string): {
|
||||
return { frontMatter, body };
|
||||
}
|
||||
|
||||
async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
||||
const codexHome = resolveCodexHomeDir();
|
||||
async function listCodexCustomPrompts(codexHome: string): Promise<AgentSlashCommand[]> {
|
||||
const promptsDir = path.join(codexHome, "prompts");
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
@@ -699,6 +700,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
||||
|
||||
export async function listCodexSkills(
|
||||
cwd: string,
|
||||
codexHome: string,
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||
): Promise<AgentSlashCommand[]> {
|
||||
const candidates: string[] = [];
|
||||
@@ -712,7 +714,7 @@ export async function listCodexSkills(
|
||||
candidates.push(path.join(repoRoot, ".codex", "skills"));
|
||||
}
|
||||
|
||||
candidates.push(path.join(resolveCodexHomeDir(), "skills"));
|
||||
candidates.push(path.join(codexHome, "skills"));
|
||||
|
||||
const candidateReads = await Promise.all(
|
||||
candidates.map(async (dir) => {
|
||||
@@ -3354,6 +3356,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly logger: Logger;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly asyncQuestions: CodexAsyncQuestions;
|
||||
private readonly codexHome: string;
|
||||
private currentMode: string;
|
||||
private hasWorkflowModeOverride: boolean;
|
||||
private readonly providerOptions: CodexProviderOptions;
|
||||
@@ -3467,6 +3470,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||
this.config = config;
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = "fast";
|
||||
@@ -4037,8 +4041,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): Promise<CodexPromptInput> {
|
||||
if (commandName.startsWith("prompts:")) {
|
||||
const promptName = commandName.slice("prompts:".length);
|
||||
const codexHome = resolveCodexHomeDir();
|
||||
const promptPath = path.join(codexHome, "prompts", `${promptName}.md`);
|
||||
const promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`);
|
||||
const raw = await fs.readFile(promptPath, "utf8");
|
||||
const parsed = parseFrontMatter(raw);
|
||||
return expandCodexCustomPrompt(parsed.body, args);
|
||||
@@ -4834,6 +4837,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
cwd: this.config.cwd ?? null,
|
||||
model: this.config.model ?? null,
|
||||
serviceTier: this.serviceTier,
|
||||
config: this.buildCodexInnerConfig(),
|
||||
userMessageTurns: this.codexUserMessageTurns(),
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
@@ -4941,7 +4945,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
const prompts = await listCodexCustomPrompts();
|
||||
const prompts = await listCodexCustomPrompts(this.codexHome);
|
||||
if (this.connectionState === "disconnected") {
|
||||
await this.connect();
|
||||
} else {
|
||||
@@ -4955,7 +4959,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}));
|
||||
const fallbackSkills =
|
||||
this.cachedSkills === null
|
||||
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
|
||||
? await listCodexSkills(this.config.cwd, this.codexHome, this.deps.workspaceGitService)
|
||||
: [];
|
||||
const builtin: AgentSlashCommand[] = [
|
||||
{
|
||||
@@ -7024,9 +7028,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
private sessionDeps(): CodexAppServerAgentDeps {
|
||||
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)),
|
||||
customCodexConfig: this.customProviderConfig(),
|
||||
};
|
||||
}
|
||||
@@ -7138,7 +7143,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
this.sessionDeps(launchContext?.env),
|
||||
options?.persistSession === false,
|
||||
goalsEnabled,
|
||||
autoReviewEnabled,
|
||||
@@ -7169,7 +7174,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(),
|
||||
this.sessionDeps(launchContext?.env),
|
||||
false,
|
||||
goalsEnabled,
|
||||
autoReviewEnabled,
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function revertCodexConversation(input: {
|
||||
cwd?: string | null;
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
@@ -85,19 +86,26 @@ export async function revertCodexConversation(input: {
|
||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||
}
|
||||
|
||||
// Codex does not carry the parent thread's config into a fork; without it the
|
||||
// forked thread falls back to the default model provider.
|
||||
const forkParams: CodexThreadForkParams = {
|
||||
threadId: input.threadId,
|
||||
cwd: input.cwd ?? null,
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId);
|
||||
if (historyMode === "paginated") {
|
||||
if (!targetTurn.turnId) {
|
||||
throw new Error(`Codex could not find the turn containing user message ${input.messageId}`);
|
||||
}
|
||||
const forked = await forkCodexThread(input.client, {
|
||||
threadId: input.threadId,
|
||||
...forkParams,
|
||||
beforeTurnId: targetTurn.turnId,
|
||||
cwd: input.cwd ?? null,
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
await input.setThreadId(forked.thread.id);
|
||||
return;
|
||||
@@ -105,14 +113,7 @@ export async function revertCodexConversation(input: {
|
||||
|
||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||
const forked = await forkCodexThread(input.client, {
|
||||
threadId: input.threadId,
|
||||
cwd: input.cwd ?? null,
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
const forked = await forkCodexThread(input.client, forkParams);
|
||||
const forkedThreadId = forked.thread.id;
|
||||
|
||||
// Codex rollback is chat-only by design. File edits from rewound turns stay
|
||||
|
||||
@@ -333,6 +333,76 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
model: TEST_MODEL,
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ variants: { medium: {}, custom: {} }, selected: "medium", expected: "medium" },
|
||||
{ variants: { custom: {} }, selected: "custom", expected: "custom" },
|
||||
{ variants: { high: {} }, selected: "medium", expected: undefined },
|
||||
{ variants: {}, selected: "medium", expected: undefined },
|
||||
])(
|
||||
"model switches resolve the retained variant: $selected / $variants",
|
||||
async ({ variants, selected, expected }) => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const upstream = new TestOpenCodeClient();
|
||||
upstream.providerListResponse = {
|
||||
data: {
|
||||
connected: ["test"],
|
||||
all: [
|
||||
{
|
||||
id: "test",
|
||||
name: "Test",
|
||||
source: "api",
|
||||
models: { target: { name: "Target", variants } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
runtime.enqueueClient(upstream);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: "test/source",
|
||||
thinkingOptionId: selected,
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.setModel!("test/target");
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(expected ?? null);
|
||||
expect(events).toContainEqual({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
upstream.sessionPromptAsyncEvents = [
|
||||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||||
];
|
||||
await collectTurnEvents(streamSession(session, "Use the selected model"));
|
||||
expect(upstream.calls.sessionPromptAsync).toEqual([
|
||||
expect.objectContaining({
|
||||
model: { providerID: "test", modelID: "target" },
|
||||
...(expected ? { variant: expected } : {}),
|
||||
}),
|
||||
]);
|
||||
if (!expected) expect(upstream.calls.sessionPromptAsync[0]).not.toHaveProperty("variant");
|
||||
await session.setThinkingOption!("medium");
|
||||
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||
"OpenCode model unavailable",
|
||||
);
|
||||
upstream.providerListResponse = { error: "Catalog unavailable" };
|
||||
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a session with valid id and provider", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
|
||||
@@ -3549,16 +3549,37 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: this.sessionId,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.currentMode,
|
||||
thinkingOptionId: this.config.thinkingOptionId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
await this.reconnectIfServerExited();
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
let variant = this.config.thinkingOptionId;
|
||||
if (!normalizedModelId) {
|
||||
variant = undefined;
|
||||
} else if (variant) {
|
||||
const model = this.parseModel(normalizedModelId);
|
||||
const response = await this.client.provider.list({ directory: this.config.cwd });
|
||||
if (response.error)
|
||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||
const provider = response.data?.all.find((entry) => entry.id === model?.providerID);
|
||||
const target = model && provider?.models[model.modelID];
|
||||
if (!target) throw new Error(`OpenCode model unavailable: ${normalizedModelId}`);
|
||||
if (!Object.hasOwn(target.variants ?? {}, variant)) variant = undefined;
|
||||
}
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.config.thinkingOptionId = variant;
|
||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||
this.config.model,
|
||||
);
|
||||
this.notifySubscribers({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: variant ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
@@ -7,6 +7,223 @@ import { OpenCodeV2AgentClient } from "./agent.js";
|
||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||
|
||||
describe("OpenCode v2 session lifecycle", () => {
|
||||
test.each([
|
||||
{ variants: ["medium", "custom"], selected: "medium", expected: "medium" },
|
||||
{ variants: ["custom"], selected: "custom", expected: "custom" },
|
||||
{ variants: ["high"], selected: "medium", expected: undefined },
|
||||
{ variants: [], selected: "medium", expected: undefined },
|
||||
{ variants: ["medium"], selected: "default", expected: undefined },
|
||||
])(
|
||||
"model switches retain only supported variants: $selected / $variants",
|
||||
async ({ variants, selected, expected }) => {
|
||||
const harness = new V2Harness();
|
||||
const target: ModelInfo = {
|
||||
id: "target",
|
||||
modelID: "target",
|
||||
providerID: "test",
|
||||
name: "Target",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
variants: variants.map((id) => ({ id })),
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 200000, output: 10000 },
|
||||
};
|
||||
harness.info.model = { providerID: "test", id: "source", variant: selected };
|
||||
harness.api.model.list = async () => ({ location: harness.info.location, data: [target] });
|
||||
harness.api.session.switchModel = async ({ model }) => {
|
||||
harness.info.model = model;
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
model: "test/source",
|
||||
thinkingOptionId: selected,
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.setModel!("test/target");
|
||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
||||
model: "test/target",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||
expect(events).toContainEqual({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: expected ?? null,
|
||||
});
|
||||
await expect(session.setModel!("test/missing")).rejects.toThrow(
|
||||
"OpenCode model unavailable",
|
||||
);
|
||||
harness.api.session.switchModel = async () => {
|
||||
throw new Error("Switch failed");
|
||||
};
|
||||
await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed");
|
||||
expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected);
|
||||
expect(harness.info.model).toEqual({
|
||||
providerID: "test",
|
||||
id: "target",
|
||||
...(expected ? { variant: expected } : {}),
|
||||
});
|
||||
harness.api.session.switchModel = async ({ model }) => {
|
||||
harness.info.model = model;
|
||||
};
|
||||
harness.api.model.default = async () => ({ location: harness.info.location, data: target });
|
||||
await session.setThinkingOption!("unsupported");
|
||||
await session.setModel!(null);
|
||||
expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull();
|
||||
harness.api.model.list = async () => {
|
||||
throw new Error("Catalog unavailable");
|
||||
};
|
||||
await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable");
|
||||
expect((await session.getRuntimeInfo()).model).toBe("test/target");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("restores each same-directory agent environment on reconnect and resume", async () => {
|
||||
const first = new V2Harness();
|
||||
const second = new V2Harness();
|
||||
first.info.id = "session-first";
|
||||
second.info.id = "session-second";
|
||||
const environments = new Map<string, Record<string, string>>();
|
||||
const bind = async (input: { sessionID: string; variables: Record<string, string> }) => {
|
||||
environments.set(input.sessionID, { ...input.variables });
|
||||
};
|
||||
first.api.session.environment = bind;
|
||||
second.api.session.environment = bind;
|
||||
const clients = [first, second].map(
|
||||
(harness) =>
|
||||
new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
settings: {
|
||||
env: {
|
||||
PASEO_ENV_TEST: "configured",
|
||||
PASEO_AGENT_ID: "configured",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PASEO_SUPERVISED: "1",
|
||||
CLAUDECODE: "1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const config = { provider: "opencode" as const, cwd: "/tmp/project" };
|
||||
const launches = ["first", "second"].map((id) => ({
|
||||
env: { PASEO_AGENT_ID: id, PASEO_AGENT_CWD: config.cwd },
|
||||
}));
|
||||
const assertEnvironment = (index: number) => {
|
||||
const env = environments.get([first, second][index].info.id);
|
||||
expect(env?.PATH).toBe(process.env.PATH);
|
||||
expect(env).toMatchObject({ ...launches[index].env, PASEO_ENV_TEST: "configured" });
|
||||
expect(env?.USER).toBe(process.env.USER);
|
||||
for (const key of [
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"PASEO_SUPERVISED",
|
||||
"CLAUDECODE",
|
||||
"ESBUILD_BINARY_PATH",
|
||||
"PASEO_NODE_ENV",
|
||||
]) {
|
||||
expect(env).not.toHaveProperty(key);
|
||||
}
|
||||
};
|
||||
const sessions = await Promise.all(
|
||||
clients.map((client, i) => client.createSession(config, launches[i])),
|
||||
);
|
||||
try {
|
||||
assertEnvironment(0);
|
||||
assertEnvironment(1);
|
||||
environments.clear();
|
||||
first.push({ id: "reconnected-first", created: 2, type: "server.connected", data: {} });
|
||||
second.push({ id: "reconnected-second", created: 2, type: "server.connected", data: {} });
|
||||
await expect.poll(() => environments.size).toBe(2);
|
||||
assertEnvironment(0);
|
||||
assertEnvironment(1);
|
||||
} finally {
|
||||
await Promise.all(sessions.map((session) => session.close()));
|
||||
}
|
||||
environments.clear();
|
||||
const resumed = await Promise.all(
|
||||
clients.map((client, i) =>
|
||||
client.resumeSession(sessions[i].describePersistence(), undefined, launches[i]),
|
||||
),
|
||||
);
|
||||
try {
|
||||
assertEnvironment(0);
|
||||
assertEnvironment(1);
|
||||
expect(first.prompts).toEqual([]);
|
||||
expect(second.prompts).toEqual([]);
|
||||
} finally {
|
||||
await Promise.all(resumed.map((session) => session.close()));
|
||||
}
|
||||
});
|
||||
|
||||
test("reconnects after a helper exits and restores session configuration on the next turn", async () => {
|
||||
const first = new V2Harness();
|
||||
const second = new V2Harness();
|
||||
let exit!: (error: Error) => void;
|
||||
const exited = new Promise<Error>((resolve) => {
|
||||
exit = resolve;
|
||||
});
|
||||
let acquisitions = 0;
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: {
|
||||
acquire: async () => {
|
||||
acquisitions += 1;
|
||||
return acquisitions === 1 ? { ...first.connection, exited } : second.connection;
|
||||
},
|
||||
shutdown: async () => undefined,
|
||||
},
|
||||
});
|
||||
first.wait = () => new Promise<void>(() => undefined);
|
||||
second.prompt = async (input) => {
|
||||
second.history.push({
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content: [{ type: "text", text: "recovered" }],
|
||||
});
|
||||
second.prompts.push(input.text);
|
||||
};
|
||||
const session = await client.createSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
mcpServers: { tools: { type: "stdio", command: "tools" } },
|
||||
},
|
||||
{ env: { TOKEN: "test" } },
|
||||
);
|
||||
const running = session.run("before exit");
|
||||
await expect.poll(() => first.prompts).toEqual(["before exit"]);
|
||||
exit(new Error("helper exited"));
|
||||
await expect(running).rejects.toThrow("helper exited");
|
||||
expect(acquisitions).toBe(1);
|
||||
try {
|
||||
expect((await session.run("after exit")).finalText).toBe("recovered");
|
||||
expect(second.prompts).toEqual(["after exit"]);
|
||||
expect(second.mcpAdds).toEqual(["tools"]);
|
||||
expect(second.environments).toMatchObject([
|
||||
{ sessionID: "session", variables: { TOKEN: "test", PATH: process.env.PATH } },
|
||||
]);
|
||||
expect(second.environments).toEqual(first.environments);
|
||||
expect(first.releases).toBe(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
await expect(session.startTurn("after close")).rejects.toThrow("OpenCode session is closed");
|
||||
});
|
||||
test("fails initialization when the event stream ends before connecting", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.api.event.subscribe = async function* () {};
|
||||
@@ -330,7 +547,7 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
);
|
||||
try {
|
||||
expect(acquires).toEqual([{}]);
|
||||
expect(harness.environments).toEqual([
|
||||
expect(harness.environments).toMatchObject([
|
||||
{
|
||||
sessionID: "session",
|
||||
variables: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" },
|
||||
|
||||
@@ -29,7 +29,10 @@ import type {
|
||||
ListImportableSessionsOptions,
|
||||
ProviderRefreshContext,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../../provider-launch-config.js";
|
||||
import {
|
||||
createProviderEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../../provider-launch-config.js";
|
||||
import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js";
|
||||
|
||||
import { importSessionFromPersistence } from "../../../provider-session-import.js";
|
||||
@@ -155,6 +158,11 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
launch: AgentLaunchContext | undefined,
|
||||
persist: boolean,
|
||||
) {
|
||||
const acquire = () =>
|
||||
this.runtime.acquire(
|
||||
requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {},
|
||||
);
|
||||
let ownedConnection = connection;
|
||||
const unbind = this.options.bridge?.bindSession({
|
||||
sessionId: info.id,
|
||||
env: launch?.env ?? {},
|
||||
@@ -162,7 +170,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
});
|
||||
const bound = new Map<string, () => void>();
|
||||
const bindChild = (childId: string) => {
|
||||
this.connections.set(childId, connection);
|
||||
this.connections.set(childId, ownedConnection);
|
||||
if (bound.has(childId)) return;
|
||||
const childUnbind = this.options.bridge?.bindSession({
|
||||
sessionId: childId,
|
||||
@@ -176,7 +184,12 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
unbind?.();
|
||||
for (const cleanup of bound.values()) cleanup();
|
||||
for (const [id, owner] of this.connections)
|
||||
if (owner === connection) this.connections.delete(id);
|
||||
if (owner === ownedConnection) this.connections.delete(id);
|
||||
};
|
||||
const moved = (next: V2Connection) => {
|
||||
for (const [id, owner] of this.connections)
|
||||
if (owner === ownedConnection) this.connections.set(id, next);
|
||||
ownedConnection = next;
|
||||
};
|
||||
const session = new OpenCodeV2Session(
|
||||
connection,
|
||||
@@ -187,13 +200,19 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
Boolean(this.options.bridge),
|
||||
releaseBindings,
|
||||
bindChild,
|
||||
acquire,
|
||||
moved,
|
||||
);
|
||||
try {
|
||||
if (this.options.bridge) {
|
||||
const location = { directory: config.cwd };
|
||||
await awaitPaseoPlugin({ client: connection.client, location });
|
||||
}
|
||||
await session.initialize(launch);
|
||||
await session.initialize(
|
||||
launch?.env
|
||||
? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] })
|
||||
: undefined,
|
||||
);
|
||||
return session;
|
||||
} catch (error) {
|
||||
await session.close();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { messages } from "./history.js";
|
||||
|
||||
import type { V2Api } from "./api.js";
|
||||
interface ChildrenOptions {
|
||||
client: V2Api;
|
||||
client(): V2Api;
|
||||
id: string;
|
||||
emit(event: AgentStreamEvent): void;
|
||||
reconcilePermissions(id: string): Promise<void>;
|
||||
@@ -34,14 +34,14 @@ export class SessionChildren {
|
||||
this.children.has(event.data.sessionID)
|
||||
) {
|
||||
await this.reconcileChild(
|
||||
await this.options.client.session.get({ sessionID: event.data.sessionID }),
|
||||
await this.options.client().session.get({ sessionID: event.data.sessionID }),
|
||||
);
|
||||
}
|
||||
}
|
||||
async reconcile(parentID: string) {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await this.options.client.session.list({
|
||||
const page = await this.options.client().session.list({
|
||||
...(cursor ? { cursor } : { parentID }),
|
||||
limit: 100,
|
||||
});
|
||||
@@ -60,7 +60,7 @@ export class SessionChildren {
|
||||
this.children.set(info.id, timeline);
|
||||
this.options.bindChild?.(info.id);
|
||||
}
|
||||
const active = await this.options.client.session.active();
|
||||
const active = await this.options.client().session.active();
|
||||
let status: "running" | "failed" | "canceled" | "completed" = "completed";
|
||||
if (active[info.id]) status = "running";
|
||||
else if (info.outcome === "failed") status = "failed";
|
||||
@@ -79,7 +79,7 @@ export class SessionChildren {
|
||||
this.childStates.set(info.id, signature);
|
||||
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
|
||||
}
|
||||
for (const event of timeline.messages(await messages(this.options.client, info.id))) {
|
||||
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
|
||||
if (event.type === "timeline")
|
||||
this.options.emit({
|
||||
type: "provider_subagent",
|
||||
|
||||
@@ -13,7 +13,7 @@ export class SessionPermissions {
|
||||
private readonly permissionOwners = new Map<string, string>();
|
||||
private readonly forms = new Map<string, FormInfo>();
|
||||
constructor(
|
||||
private readonly client: V2Api,
|
||||
private readonly getClient: () => V2Api,
|
||||
private readonly id: string,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly emit: (event: AgentStreamEvent) => void,
|
||||
@@ -37,7 +37,7 @@ export class SessionPermissions {
|
||||
const form = this.forms.get(requestId);
|
||||
if (form) {
|
||||
if (response.behavior === "deny")
|
||||
await this.client.session.form.cancel({ sessionID: form.sessionID, formID: form.id });
|
||||
await this.getClient().session.form.cancel({ sessionID: form.sessionID, formID: form.id });
|
||||
else {
|
||||
const raw = response.updatedInput?.answers;
|
||||
const answer: Record<string, FormValue> = {};
|
||||
@@ -49,7 +49,7 @@ export class SessionPermissions {
|
||||
const normalized = formAnswer(field, value);
|
||||
if (normalized !== undefined) answer[field.key] = normalized;
|
||||
}
|
||||
await this.client.session.form.reply({
|
||||
await this.getClient().session.form.reply({
|
||||
sessionID: form.sessionID,
|
||||
formID: form.id,
|
||||
answer,
|
||||
@@ -57,7 +57,7 @@ export class SessionPermissions {
|
||||
}
|
||||
this.forms.delete(requestId);
|
||||
} else {
|
||||
await this.client.permission.reply({
|
||||
await this.getClient().permission.reply({
|
||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||
requestID: requestId,
|
||||
decision: permissionReply(response),
|
||||
@@ -67,8 +67,8 @@ export class SessionPermissions {
|
||||
}
|
||||
async reconcile(sessionID: string) {
|
||||
const [permissions, forms] = await Promise.all([
|
||||
this.client.permission.list({ sessionID }),
|
||||
this.client.session.form.list({ sessionID }),
|
||||
this.getClient().permission.list({ sessionID }),
|
||||
this.getClient().session.form.list({ sessionID }),
|
||||
]);
|
||||
for (const request of permissions) {
|
||||
if (this.pending.has(request.id)) continue;
|
||||
|
||||
@@ -130,6 +130,14 @@ export class V2Runtime {
|
||||
private startTracked(env?: Record<string, string>): Promise<Generation> {
|
||||
const pending = this.start(env);
|
||||
this.starts.add(pending);
|
||||
void pending.then(
|
||||
(generation) =>
|
||||
generation.exited.then(() => {
|
||||
if (this.current === pending) this.current = null;
|
||||
return undefined;
|
||||
}),
|
||||
() => undefined,
|
||||
);
|
||||
void pending.then(
|
||||
() => this.starts.delete(pending),
|
||||
() => this.starts.delete(pending),
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { SessionInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import type { Logger } from "pino";
|
||||
import type {
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
@@ -36,6 +35,7 @@ import { features } from "./configuration.js";
|
||||
import { commands } from "./commands.js";
|
||||
import { messages } from "./history.js";
|
||||
import { SessionPermissions } from "./permissions.js";
|
||||
|
||||
export class OpenCodeV2Session implements AgentSession {
|
||||
readonly provider = "opencode";
|
||||
readonly capabilities = V2_CAPABILITIES;
|
||||
@@ -46,6 +46,10 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
private readonly turns: SessionTurns;
|
||||
private readonly children: SessionChildren;
|
||||
private stream: Promise<void> | null = null;
|
||||
private streamAbort = new AbortController();
|
||||
private exited = false;
|
||||
private reconnecting: Promise<void> | null = null;
|
||||
private launchEnv: Record<string, string> | undefined;
|
||||
private sync: Promise<void> = Promise.resolve();
|
||||
private dirty = false;
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -53,7 +57,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
private history: SessionMessageInfo[] = [];
|
||||
private modes: AgentMode[] = [];
|
||||
constructor(
|
||||
private readonly connection: V2Connection,
|
||||
private connection: V2Connection,
|
||||
private info: SessionInfo,
|
||||
private readonly config: AgentSessionConfig,
|
||||
private readonly logger: Logger,
|
||||
@@ -61,19 +65,24 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
private readonly requiresPaseoPlugin: boolean,
|
||||
private readonly unbind?: () => void,
|
||||
bindChild?: (id: string) => void,
|
||||
private readonly acquire?: () => Promise<V2Connection>,
|
||||
private readonly moved?: (connection: V2Connection) => void,
|
||||
) {
|
||||
this.permissions = new SessionPermissions(connection.client, info.id, config, (event) =>
|
||||
this.emit(event),
|
||||
this.permissions = new SessionPermissions(
|
||||
() => this.client,
|
||||
info.id,
|
||||
config,
|
||||
(event) => this.emit(event),
|
||||
);
|
||||
this.children = new SessionChildren({
|
||||
client: connection.client,
|
||||
client: () => this.client,
|
||||
id: info.id,
|
||||
emit: (event) => this.emit(event),
|
||||
reconcilePermissions: (id) => this.permissions.reconcile(id),
|
||||
bindChild,
|
||||
});
|
||||
this.turns = new SessionTurns({
|
||||
client: connection.client,
|
||||
client: () => this.client,
|
||||
id: info.id,
|
||||
cwd: config.cwd,
|
||||
signal: this.abort.signal,
|
||||
@@ -97,19 +106,31 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
private get client() {
|
||||
return this.connection.client;
|
||||
}
|
||||
async initialize(launch?: AgentLaunchContext) {
|
||||
void this.connection.exited.then((error) => {
|
||||
if (this.closed) return;
|
||||
this.abort.abort(error);
|
||||
async initialize(environment?: Record<string, string>) {
|
||||
// OpenCode replaces the whole local shell environment, rather than overlaying it.
|
||||
this.launchEnv = environment;
|
||||
this.watchExit(this.connection);
|
||||
await this.configureConnection();
|
||||
const location = { directory: this.config.cwd };
|
||||
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
|
||||
this.timeline.messages(await messages(this.client, this.id));
|
||||
await this.startStream();
|
||||
await this.children.reconcile(this.id);
|
||||
}
|
||||
private watchExit(connection: V2Connection) {
|
||||
void connection.exited.then((error) => {
|
||||
if (this.closed || this.connection !== connection) return undefined;
|
||||
this.exited = true;
|
||||
this.streamAbort.abort(error);
|
||||
this.turns.fail(error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
private async configureConnection() {
|
||||
const location = { directory: this.config.cwd };
|
||||
await waitForLocationReady({ client: this.client, location, signal: this.abort.signal });
|
||||
if (this.requiresPaseoPlugin)
|
||||
await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal });
|
||||
if (launch?.env)
|
||||
await this.client.session.environment({ sessionID: this.id, variables: launch.env });
|
||||
for (const [server, config] of Object.entries(this.config.mcpServers ?? {})) {
|
||||
await this.client.mcp.add({
|
||||
server,
|
||||
@@ -142,8 +163,8 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
key: "paseo",
|
||||
value: system,
|
||||
});
|
||||
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
|
||||
this.timeline.messages(await messages(this.client, this.id));
|
||||
}
|
||||
private async startStream() {
|
||||
let ready!: () => void;
|
||||
let fail!: (error: unknown) => void;
|
||||
const first = new Promise<void>((resolve, reject) => {
|
||||
@@ -155,7 +176,41 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)]),
|
||||
first,
|
||||
);
|
||||
await this.children.reconcile(this.id);
|
||||
}
|
||||
private async reconnectIfExited() {
|
||||
if (this.closed) throw new Error("OpenCode session is closed");
|
||||
if (!this.exited) return;
|
||||
this.reconnecting ??= this.reconnect().finally(() => {
|
||||
this.reconnecting = null;
|
||||
});
|
||||
await this.reconnecting;
|
||||
}
|
||||
private async reconnect() {
|
||||
if (!this.acquire) throw new Error("OpenCode helper server exited");
|
||||
await this.stream;
|
||||
await this.sync.catch(() => undefined);
|
||||
const next = await this.acquire();
|
||||
if (this.closed) {
|
||||
await next.release();
|
||||
throw new Error("OpenCode session is closed");
|
||||
}
|
||||
const old = this.connection;
|
||||
this.connection = next;
|
||||
this.streamAbort = new AbortController();
|
||||
this.exited = false;
|
||||
this.watchExit(next);
|
||||
try {
|
||||
await this.configureConnection();
|
||||
await this.startStream();
|
||||
} catch (error) {
|
||||
this.connection = old;
|
||||
this.exited = true;
|
||||
this.streamAbort.abort();
|
||||
await next.release();
|
||||
throw error;
|
||||
}
|
||||
this.moved?.(next);
|
||||
await old.release();
|
||||
}
|
||||
subscribe(callback: (event: AgentStreamEvent) => void) {
|
||||
this.listeners.add(callback);
|
||||
@@ -186,10 +241,12 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
this.emit({ ...event, ...(this.turns.id ? { turnId: this.turns.id } : {}) });
|
||||
}
|
||||
async *streamHistory() {
|
||||
await this.reconnectIfExited();
|
||||
const history = new V2Timeline(false);
|
||||
yield* history.messages(await messages(this.client, this.id));
|
||||
}
|
||||
async getRuntimeInfo() {
|
||||
await this.reconnectIfExited();
|
||||
this.info = await this.client.session.get({ sessionID: this.id });
|
||||
return {
|
||||
provider: "opencode",
|
||||
@@ -206,6 +263,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
return this.info.agent ?? null;
|
||||
}
|
||||
async setMode(modeId: string) {
|
||||
await this.reconnectIfExited();
|
||||
await this.client.session.switchAgent({ sessionID: this.id, agent: modeId });
|
||||
this.info.agent = modeId;
|
||||
this.config.modeId = modeId;
|
||||
@@ -217,26 +275,51 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
});
|
||||
}
|
||||
async setModel(model: string | null) {
|
||||
const selected = model
|
||||
? modelRef(model, this.config.thinkingOptionId)
|
||||
: (await this.client.model.default({ location: { directory: this.config.cwd } })).data;
|
||||
await this.reconnectIfExited();
|
||||
const location = { directory: this.config.cwd };
|
||||
const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data;
|
||||
if (!selected) throw new Error("OpenCode has no default model");
|
||||
await this.client.session.switchModel({
|
||||
sessionID: this.id,
|
||||
model: {
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
variant: this.config.thinkingOptionId,
|
||||
},
|
||||
});
|
||||
const catalog = await this.client.model.list({ location });
|
||||
const target = catalog.data.find(
|
||||
(entry) =>
|
||||
entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id,
|
||||
);
|
||||
if (!target)
|
||||
throw new Error(`OpenCode model unavailable: ${selected.providerID}/${selected.id}`);
|
||||
const retainedVariant = this.config.thinkingOptionId;
|
||||
const variant =
|
||||
retainedVariant !== "default" && target.variants.some((entry) => entry.id === retainedVariant)
|
||||
? retainedVariant
|
||||
: undefined;
|
||||
const nextModel = {
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
...(variant ? { variant } : {}),
|
||||
};
|
||||
await this.client.session.switchModel({ sessionID: this.id, model: nextModel });
|
||||
this.info.model = nextModel;
|
||||
this.config.model = model ?? undefined;
|
||||
this.config.thinkingOptionId = variant;
|
||||
this.emit({
|
||||
type: "thinking_option_changed",
|
||||
provider: "opencode",
|
||||
thinkingOptionId: variant ?? null,
|
||||
});
|
||||
this.emit({
|
||||
type: "model_changed",
|
||||
provider: "opencode",
|
||||
runtimeInfo: await this.getRuntimeInfo(),
|
||||
runtimeInfo: {
|
||||
provider: "opencode",
|
||||
sessionId: this.id,
|
||||
model: `${selected.providerID}/${selected.id}`,
|
||||
modeId: this.info.agent ?? null,
|
||||
thinkingOptionId: variant ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setThinkingOption(variant: string | null) {
|
||||
await this.reconnectIfExited();
|
||||
const model = this.info.model;
|
||||
if (!model) throw new Error("Select an OpenCode model before changing its variant");
|
||||
await this.client.session.switchModel({
|
||||
@@ -257,6 +340,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await this.respondToPermission(request.id, { behavior: "allow" });
|
||||
}
|
||||
async listCommands() {
|
||||
await this.reconnectIfExited();
|
||||
return commands(this.client, this.config.cwd);
|
||||
}
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
@@ -278,7 +362,8 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
item.type === "assistant_message" ? current + item.text : current,
|
||||
});
|
||||
}
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) {
|
||||
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) {
|
||||
await this.reconnectIfExited();
|
||||
return this.turns.startTurn(prompt, options);
|
||||
}
|
||||
steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult> {
|
||||
@@ -289,6 +374,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
async revertBoth(input: { messageId: string }) {
|
||||
await this.interrupt();
|
||||
await this.reconnectIfExited();
|
||||
await this.client.session.revert.stage({
|
||||
sessionID: this.id,
|
||||
messageID: input.messageId,
|
||||
@@ -300,6 +386,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
return [...this.permissions.list()];
|
||||
}
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
|
||||
await this.reconnectIfExited();
|
||||
await this.permissions.respondToPermission(requestId, response);
|
||||
}
|
||||
private async reconcileSnapshot() {
|
||||
@@ -324,6 +411,10 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
}
|
||||
private async reconcileConnection() {
|
||||
// Session environments are process-local. Reapply this agent's snapshot before
|
||||
// reconciling a new connection, including event-stream reconnections.
|
||||
if (this.launchEnv)
|
||||
await this.client.session.environment({ sessionID: this.id, variables: this.launchEnv });
|
||||
await this.reconcile();
|
||||
await this.children.reconcile(this.id);
|
||||
const active = await this.client.session.active();
|
||||
@@ -377,11 +468,12 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
this.turns.observeActiveTurn();
|
||||
}
|
||||
private async consume(ready: () => void, fail: (error: unknown) => void) {
|
||||
const signal = AbortSignal.any([this.abort.signal, this.streamAbort.signal]);
|
||||
let connected = false;
|
||||
while (!this.closed && !this.abort.signal.aborted) {
|
||||
while (!this.closed && !signal.aborted) {
|
||||
try {
|
||||
for await (const event of this.client.event.subscribe({ signal: this.abort.signal })) {
|
||||
if (this.closed || this.abort.signal.aborted) return;
|
||||
for await (const event of this.client.event.subscribe({ signal })) {
|
||||
if (this.closed || signal.aborted) return;
|
||||
if (event.type === "server.connected") {
|
||||
this.timeline.resetStreams();
|
||||
await this.reconcileConnection();
|
||||
@@ -393,7 +485,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
if (!connected) throw new Error("OpenCode event stream ended before connecting");
|
||||
} catch (error) {
|
||||
if (this.closed || this.abort.signal.aborted) return;
|
||||
if (this.closed || signal.aborted) return;
|
||||
if (!connected) {
|
||||
fail(error);
|
||||
return;
|
||||
@@ -403,15 +495,17 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
"OpenCode event stream interrupted; reconciling on reconnect",
|
||||
);
|
||||
}
|
||||
await delay(100, undefined, { signal: this.abort.signal }).catch(() => undefined);
|
||||
await delay(100, undefined, { signal }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.abort.abort();
|
||||
this.streamAbort.abort();
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||
await this.stream;
|
||||
await this.reconnecting?.catch(() => undefined);
|
||||
await this.sync.catch(() => undefined);
|
||||
this.unbind?.();
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,7 @@ interface TurnSnapshot {
|
||||
history: SessionMessageInfo[];
|
||||
}
|
||||
interface TurnOptions {
|
||||
client: V2Api;
|
||||
client(): V2Api;
|
||||
id: string;
|
||||
cwd: string;
|
||||
signal: AbortSignal;
|
||||
@@ -115,16 +115,16 @@ export class SessionTurns {
|
||||
) {
|
||||
const command = input.text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
||||
if (command?.[1] === "compact" || command?.[1] === "summarize") {
|
||||
await this.options.client.session.compact({ sessionID: this.options.id });
|
||||
await this.options.client().session.compact({ sessionID: this.options.id });
|
||||
return;
|
||||
}
|
||||
const selected = command
|
||||
? (await commands(this.options.client, this.options.cwd)).find(
|
||||
? (await commands(this.options.client(), this.options.cwd)).find(
|
||||
(item) => item.name === command[1],
|
||||
)
|
||||
: undefined;
|
||||
if (selected && selected.kind !== "skill") {
|
||||
await this.options.client.session.command({
|
||||
await this.options.client().session.command({
|
||||
sessionID: this.options.id,
|
||||
name: selected.name,
|
||||
text: command?.[2] ?? "",
|
||||
@@ -132,7 +132,7 @@ export class SessionTurns {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.options.client.session.prompt({
|
||||
await this.options.client().session.prompt({
|
||||
sessionID: this.options.id,
|
||||
...input,
|
||||
...(selected?.kind === "skill"
|
||||
@@ -148,10 +148,9 @@ export class SessionTurns {
|
||||
});
|
||||
}
|
||||
private async finish(id: string) {
|
||||
await this.options.client.session.wait(
|
||||
{ sessionID: this.options.id },
|
||||
{ signal: this.options.signal },
|
||||
);
|
||||
await this.options
|
||||
.client()
|
||||
.session.wait({ sessionID: this.options.id }, { signal: this.options.signal });
|
||||
const { info, history } = await this.options.reconcile();
|
||||
if (this.turn?.id !== id) return;
|
||||
if (info.outcome !== "failed" && info.outcome !== "interrupted")
|
||||
@@ -184,10 +183,12 @@ export class SessionTurns {
|
||||
}
|
||||
private async readExecutionError(): Promise<string> {
|
||||
let message = "OpenCode execution failed";
|
||||
for await (const event of this.options.client.session.log(
|
||||
{ sessionID: this.options.id, follow: false },
|
||||
{ signal: this.options.signal },
|
||||
)) {
|
||||
for await (const event of this.options
|
||||
.client()
|
||||
.session.log(
|
||||
{ sessionID: this.options.id, follow: false },
|
||||
{ signal: this.options.signal },
|
||||
)) {
|
||||
if (event.type === "session.execution.failed") message = event.data.error.message;
|
||||
}
|
||||
return message;
|
||||
@@ -209,7 +210,7 @@ export class SessionTurns {
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (this.turn?.id !== options.expectedTurnId || this.stopping) return { status: "unavailable" };
|
||||
await this.options.client.session.prompt({
|
||||
await this.options.client().session.prompt({
|
||||
sessionID: this.options.id,
|
||||
...this.promptInput(prompt),
|
||||
delivery: "steer",
|
||||
@@ -227,7 +228,7 @@ export class SessionTurns {
|
||||
const stop = (async () => {
|
||||
// A stop sent before the queued prompt is accepted would interrupt an idle session.
|
||||
await turn?.submitted;
|
||||
await this.options.client.session.interrupt({ sessionID: this.options.id });
|
||||
await this.options.client().session.interrupt({ sessionID: this.options.id });
|
||||
await turn?.completion;
|
||||
})();
|
||||
this.stopping = stop;
|
||||
|
||||
@@ -61,7 +61,11 @@ import {
|
||||
toScheduleSummary,
|
||||
waitForAgentWithTimeout,
|
||||
} from "../mcp-shared.js";
|
||||
import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js";
|
||||
import {
|
||||
sendPromptToAgent,
|
||||
setupFinishNotification,
|
||||
waitForAgentRunStartWithTimeout,
|
||||
} from "../agent-prompt.js";
|
||||
import { respondToAgentPermission } from "../permission-response.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
@@ -1924,7 +1928,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
return true;
|
||||
}
|
||||
|
||||
await sendPromptToAgent({
|
||||
const { disposition } = await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId,
|
||||
@@ -1963,8 +1967,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
|
||||
const notifying = armFinishNotification();
|
||||
|
||||
// Return immediately if background=true
|
||||
// Re-fetch snapshot since the state may have changed
|
||||
// Return once the provider has accepted the turn, so the status reports it running.
|
||||
if (disposition === "turn_started") {
|
||||
await waitForAgentRunStartWithTimeout(agentManager, agentId);
|
||||
}
|
||||
const currentSnapshot = agentManager.getAgent(agentId);
|
||||
|
||||
const responseData = {
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { once } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
// Runs the real `codex` binary against a local Responses endpoint standing in
|
||||
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
|
||||
|
||||
function sse(events: Array<Record<string, unknown>>): string {
|
||||
return events
|
||||
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function finalAnswer(): string {
|
||||
return sse([
|
||||
{ type: "response.created", response: { id: "resp-1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg-1",
|
||||
content: [{ type: "output_text", text: "done" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-1",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: null,
|
||||
output_tokens: 0,
|
||||
output_tokens_details: null,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function startResponsesEndpoint(): Promise<{
|
||||
baseUrl: string;
|
||||
server: Server;
|
||||
requestCount: () => number;
|
||||
}> {
|
||||
let requests = 0;
|
||||
const server = createServer((req, res) => {
|
||||
req.resume();
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/v1/responses") {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
requests += 1;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.end(finalAnswer());
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("no server address");
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
server,
|
||||
requestCount: () => requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
}
|
||||
|
||||
describe("Codex custom provider archive", () => {
|
||||
const cleanups: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
test("archiving hides the session from Import session and unarchiving lists it again", async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "codex-custom-archive-"));
|
||||
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const codexHome = path.join(root, "codex-home");
|
||||
mkdirSync(codexHome);
|
||||
const cwd = path.join(root, "project");
|
||||
mkdirSync(cwd);
|
||||
const { baseUrl, server } = await startResponsesEndpoint();
|
||||
cleanups.push(() => closeServer(server));
|
||||
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
logger: createTestLogger(),
|
||||
pluginsEnabled: false,
|
||||
providerOverrides: {
|
||||
"my-codex": {
|
||||
extends: "codex",
|
||||
label: "My Codex",
|
||||
env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" },
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanups.push(() => daemon.close());
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
cleanups.push(() => client.close());
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const manager = daemon.daemon.agentManager;
|
||||
|
||||
const agent = await client.createAgent({
|
||||
config: { provider: "my-codex", cwd, model: "mock-model", modeId: "full-access" },
|
||||
});
|
||||
await manager.runAgent(agent.id, "hello from the custom provider");
|
||||
const importable = async () =>
|
||||
(await manager.listImportableSessions({ cwd, providerFilter: new Set(["my-codex"]) }))
|
||||
.sessions;
|
||||
expect(await importable()).toHaveLength(1);
|
||||
|
||||
await client.archiveAgent(agent.id);
|
||||
expect(await importable()).toEqual([]);
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
expect(await importable()).toHaveLength(1);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Blocks,
|
||||
Bot,
|
||||
BookOpen,
|
||||
Braces,
|
||||
Coffee,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
Gem,
|
||||
GitFork,
|
||||
Laptop,
|
||||
Lock,
|
||||
Merge,
|
||||
Monitor,
|
||||
Puzzle,
|
||||
Smartphone,
|
||||
@@ -124,6 +129,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<AutomationSection />
|
||||
<ExtensibleSection />
|
||||
<FAQ />
|
||||
<PhilosophySection />
|
||||
<SponsorSection />
|
||||
<SponsorsSection />
|
||||
</div>
|
||||
@@ -875,6 +881,63 @@ function ExtensibleCard({
|
||||
);
|
||||
}
|
||||
|
||||
const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Private",
|
||||
description: "No telemetry, tracking, or forced login. Code stays on your machine.",
|
||||
},
|
||||
{
|
||||
icon: Compass,
|
||||
title: "Independent",
|
||||
description: "Paseo doesn't answer to investors. Its users guide what gets built.",
|
||||
},
|
||||
{
|
||||
icon: Gem,
|
||||
title: "Polished",
|
||||
description: "Install the app and start working. No need to know what a daemon is.",
|
||||
},
|
||||
{
|
||||
icon: Merge,
|
||||
title: "Unified",
|
||||
description: "Claude Code, Codex, OpenCode, Pi, and more, all used the same way.",
|
||||
},
|
||||
{
|
||||
icon: Blocks,
|
||||
title: "Composable",
|
||||
description: "Run agents on your laptop or a server, and connect from any device.",
|
||||
},
|
||||
{
|
||||
icon: Puzzle,
|
||||
title: "Extensible",
|
||||
description: "If Paseo doesn't fit how you work, change it with a plugin or fork.",
|
||||
},
|
||||
];
|
||||
|
||||
function PhilosophySection() {
|
||||
return (
|
||||
<FeatureSection
|
||||
title="Philosophy"
|
||||
description="What Paseo stands for, and what every feature is built on"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{PRINCIPLES.map((principle) => (
|
||||
<div
|
||||
key={principle.title}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.025] p-6"
|
||||
>
|
||||
<div className="mb-8 text-extra-muted-foreground">
|
||||
<principle.icon className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white/85">{principle.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/45">{principle.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FeatureSection>
|
||||
);
|
||||
}
|
||||
|
||||
function GetStarted() {
|
||||
const platform = useVisitorPlatform();
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Orca ADE Alternative With Agent Chat, a No-Account Relay, and Plugins
|
||||
description: Paseo is an Orca alternative for developers who want a chat-first interface, a daemon they can reach from any client, and plugins that run on the server and every client.
|
||||
nav: Orca
|
||||
order: 57
|
||||
---
|
||||
|
||||
# Paseo vs Orca
|
||||
|
||||
Orca is an MIT-licensed desktop app for running terminal coding agents in parallel Git worktrees. It includes a mobile companion app, SSH worktrees, remote Orca servers, an embedded browser, and a CLI.
|
||||
|
||||
Paseo is an app for orchestrating coding agents, with native clients on desktop, mobile, web, and the CLI. Open source (Apache-2.0).
|
||||
|
||||

|
||||
|
||||
## The main difference
|
||||
|
||||
Orca is built around terminals. Each agent runs in a terminal pane, and an experimental Chat UI can show supported agents as a chat transcript. Orca ships a broad set of built-in tools around those terminals.
|
||||
|
||||
Paseo is built around agent chat. It keeps a small core for starting agents, following their work, giving direction, and reviewing results, and adds everything else through plugins that run on the daemon and every client.
|
||||
|
||||
Orca is made by Stably AI, a venture-funded company (Y Combinator). Paseo is independent and open source.
|
||||
|
||||
## Architecture and remote machines
|
||||
|
||||
The Paseo daemon runs as its own process. Desktop, web, mobile, and CLI clients connect to it directly, over SSH, or through the optional end-to-end encrypted relay. Run the daemon on your laptop, a VM, a home server, or in Docker, and connect to any of them from any client. See [connectivity](/docs/connectivity).
|
||||
|
||||
Orca runs inside its desktop app by default and reaches other machines in two ways. SSH worktrees keep the Orca runtime on your laptop and run selected worktrees and terminals on a remote host. Remote Orca Servers, in beta, run Orca desktop or `orca serve` on another machine and let clients pair to it. Orca's documentation recommends Tailscale or another private network for Remote Orca Servers.
|
||||
|
||||
## Mobile and the relay
|
||||
|
||||
Both tools have iOS and Android apps.
|
||||
|
||||
Paseo ships native apps on the App Store and Google Play. To connect from your phone, enable the relay and scan a QR code. The daemon connects outbound to the relay and your phone meets it there, so you do not need a VPN, port forwarding, SSH, or an account. Traffic is end-to-end encrypted, and the [relay server](https://github.com/getpaseo/paseo-relay) is open source. You can also connect directly over Tailscale.
|
||||
|
||||
Orca's mobile companion is in beta, on the App Store for iOS and as an APK download for Android. It can reply to agents, open sessions as a terminal or Chat UI, commit changes, and create workspaces on the paired desktop. Orca Relay, also in beta, connects the phone when it is not on the same network as the desktop and requires signing in to an Orca account. Without Orca Relay, the phone needs a LAN or Tailscale path to the desktop.
|
||||
|
||||
## Chat
|
||||
|
||||
Paseo's main interface is a chat with the agent on desktop, web, and mobile. Paseo runs supported providers through structured harnesses, so messages, tool calls, permission requests, modes, slash commands, and file attachments come from the agent's own protocol.
|
||||
|
||||
Orca's main interface is the agent's terminal. Its Chat UI is an experimental view over the same terminal session and is turned on under **Settings → Experimental**. It decodes transcripts for Claude, Codex, Grok, and OMP, and the terminal remains the source of truth.
|
||||
|
||||
## Providers
|
||||
|
||||
Orca runs any CLI agent in a terminal and lists more than 30 supported agents.
|
||||
|
||||
Paseo runs Claude Code, Codex, OpenCode, and Pi through native structured harnesses, plus 30+ agents through its ACP catalog and any custom CLI agent. See [all supported providers](/agents).
|
||||
|
||||
## Application plugins
|
||||
|
||||
[Paseo plugins](/docs/plugins) extend Paseo itself. A plugin can add workspace panels, sidebar items, Command Center items, slash commands, composer pills, settings screens, themes, attachment sources, and agent providers. Its server code runs beside the daemon, and its client code runs in every connected client: desktop, browser, iOS, and Android.
|
||||
|
||||
Community plugins are listed at [paseo.cafe](https://paseo.cafe).
|
||||
|
||||
Orca has an experimental plugin system that is off by default. Orca plugins can add desktop panels rendered in a sandboxed frame, commands, keybindings, language packs, VM recipes, and agent profiles, and can be installed from git marketplaces. Plugin workers run on the desktop computer, and Orca does not document plugin surfaces in its mobile app.
|
||||
|
||||
## Scope
|
||||
|
||||
Orca includes many tools in the core app: a Monaco code editor, Design Mode for sending page elements from its browser to an agent, computer use, Linear and Jira boards, agent account switching with usage tracking, and per-workspace cloud VM recipes. If you want these in one app without extensions, Orca provides them.
|
||||
|
||||
Paseo keeps a smaller core. A capability that serves a specialized workflow is meant to be a plugin, so the default app stays short to learn and each plugin can be maintained on its own schedule.
|
||||
|
||||
## Workspaces and review
|
||||
|
||||
Both tools provide Git worktrees, split panes, terminals, diff review, an in-app browser, pull-request workflows, and scheduled agent runs.
|
||||
|
||||
Paseo also gives each worktree its own service URL, such as `web.fix-auth.my-app.localhost`, so parallel development servers do not compete for ports. Orca adds diff annotations that you send back to the agent, port forwarding for SSH worktrees, and pull requests on GitLab, Bitbucket, Azure DevOps, and Gitea as well as GitHub.
|
||||
|
||||
## Automation
|
||||
|
||||
Paseo exposes workspace and agent operations through its CLI, TypeScript SDK, and MCP server:
|
||||
|
||||
```bash
|
||||
paseo run --provider codex "implement OAuth"
|
||||
paseo --host ssh://user@devbox run --cwd /srv/app "run the test suite"
|
||||
paseo ls
|
||||
paseo send <agent-id> "add tests"
|
||||
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
|
||||
```
|
||||
|
||||
Orca's CLI creates worktrees, drives its browser, and manages automations. Orca installs skills so agents can call the same commands.
|
||||
|
||||
## Accounts and telemetry
|
||||
|
||||
Paseo does not require an account and does not collect telemetry.
|
||||
|
||||
Orca does not require an account for local use. Orca Relay and shared artifacts require an Orca account. Packaged Orca builds send anonymous usage telemetry, which you can turn off.
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Paseo | Orca |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ------------------------------------- |
|
||||
| License | Open source (Apache-2.0) | Open source (MIT) |
|
||||
| Funding | Independent | Venture-funded (Y Combinator) |
|
||||
| Desktop platforms | macOS, Linux, Windows | macOS, Linux, Windows |
|
||||
| Native mobile | iOS, Android | iOS, Android APK (beta) |
|
||||
| Main interface | Agent chat | Terminal, with experimental Chat UI |
|
||||
| Remote machines | Daemon on any machine, direct, SSH, or relay | SSH worktrees, Remote Orca Server |
|
||||
| Relay | Optional, end-to-end encrypted, no account | Beta, Orca account required |
|
||||
| Agent harnesses | Claude Code, Codex, OpenCode, Pi + 30+ via ACP catalog + custom | Any CLI agent in a terminal |
|
||||
| Application plugins | Server code and native client components on every client | Experimental, desktop only |
|
||||
| In-app terminal | Yes | Yes |
|
||||
| In-app browser | Yes | Yes, with Design Mode |
|
||||
| GitHub workflow in app | Commit, push, PR, checks, reviews, merge | Yes, plus GitLab, Bitbucket, and more |
|
||||
| Git worktrees | Yes | Yes |
|
||||
| Per-worktree dev server URLs | Yes | Port forwarding for SSH worktrees |
|
||||
| Automation | CLI, SDK, MCP | CLI, automations |
|
||||
| Voice | Local dictation and realtime voice | Dictation |
|
||||
| Telemetry | None | Anonymous usage data, opt-out |
|
||||
|
||||
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber).
|
||||
@@ -63,6 +63,7 @@ import { Route as BlogIndexRouteImport } from "./routes/blog/index";
|
||||
import { Route as DocsSplatRouteImport } from "./routes/docs/$";
|
||||
import { Route as BlogSplatRouteImport } from "./routes/blog/$";
|
||||
import { Route as AlternativesSupersetRouteImport } from "./routes/alternatives/superset";
|
||||
import { Route as AlternativesOrcaRouteImport } from "./routes/alternatives/orca";
|
||||
import { Route as AlternativesOpencodeDesktopRouteImport } from "./routes/alternatives/opencode-desktop";
|
||||
import { Route as AlternativesOpenchamberRouteImport } from "./routes/alternatives/openchamber";
|
||||
import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternatives/happy-coder";
|
||||
@@ -340,6 +341,11 @@ const AlternativesSupersetRoute = AlternativesSupersetRouteImport.update({
|
||||
path: "/alternatives/superset",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const AlternativesOrcaRoute = AlternativesOrcaRouteImport.update({
|
||||
id: "/alternatives/orca",
|
||||
path: "/alternatives/orca",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const AlternativesOpencodeDesktopRoute =
|
||||
AlternativesOpencodeDesktopRouteImport.update({
|
||||
id: "/alternatives/opencode-desktop",
|
||||
@@ -429,6 +435,7 @@ export interface FileRoutesByFullPath {
|
||||
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
|
||||
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
|
||||
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
|
||||
"/alternatives/orca": typeof AlternativesOrcaRoute;
|
||||
"/alternatives/superset": typeof AlternativesSupersetRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
"/docs/$": typeof DocsSplatRoute;
|
||||
@@ -489,6 +496,7 @@ export interface FileRoutesByTo {
|
||||
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
|
||||
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
|
||||
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
|
||||
"/alternatives/orca": typeof AlternativesOrcaRoute;
|
||||
"/alternatives/superset": typeof AlternativesSupersetRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
"/docs/$": typeof DocsSplatRoute;
|
||||
@@ -552,6 +560,7 @@ export interface FileRoutesById {
|
||||
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
|
||||
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
|
||||
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
|
||||
"/alternatives/orca": typeof AlternativesOrcaRoute;
|
||||
"/alternatives/superset": typeof AlternativesSupersetRoute;
|
||||
"/blog/$": typeof BlogSplatRoute;
|
||||
"/docs/$": typeof DocsSplatRoute;
|
||||
@@ -616,6 +625,7 @@ export interface FileRouteTypes {
|
||||
| "/alternatives/happy-coder"
|
||||
| "/alternatives/openchamber"
|
||||
| "/alternatives/opencode-desktop"
|
||||
| "/alternatives/orca"
|
||||
| "/alternatives/superset"
|
||||
| "/blog/$"
|
||||
| "/docs/$"
|
||||
@@ -676,6 +686,7 @@ export interface FileRouteTypes {
|
||||
| "/alternatives/happy-coder"
|
||||
| "/alternatives/openchamber"
|
||||
| "/alternatives/opencode-desktop"
|
||||
| "/alternatives/orca"
|
||||
| "/alternatives/superset"
|
||||
| "/blog/$"
|
||||
| "/docs/$"
|
||||
@@ -738,6 +749,7 @@ export interface FileRouteTypes {
|
||||
| "/alternatives/happy-coder"
|
||||
| "/alternatives/openchamber"
|
||||
| "/alternatives/opencode-desktop"
|
||||
| "/alternatives/orca"
|
||||
| "/alternatives/superset"
|
||||
| "/blog/$"
|
||||
| "/docs/$"
|
||||
@@ -801,6 +813,7 @@ export interface RootRouteChildren {
|
||||
AlternativesHappyCoderRoute: typeof AlternativesHappyCoderRoute;
|
||||
AlternativesOpenchamberRoute: typeof AlternativesOpenchamberRoute;
|
||||
AlternativesOpencodeDesktopRoute: typeof AlternativesOpencodeDesktopRoute;
|
||||
AlternativesOrcaRoute: typeof AlternativesOrcaRoute;
|
||||
AlternativesSupersetRoute: typeof AlternativesSupersetRoute;
|
||||
}
|
||||
|
||||
@@ -1184,6 +1197,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof AlternativesSupersetRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/alternatives/orca": {
|
||||
id: "/alternatives/orca";
|
||||
path: "/alternatives/orca";
|
||||
fullPath: "/alternatives/orca";
|
||||
preLoaderRoute: typeof AlternativesOrcaRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/alternatives/opencode-desktop": {
|
||||
id: "/alternatives/opencode-desktop";
|
||||
path: "/alternatives/opencode-desktop";
|
||||
@@ -1309,6 +1329,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AlternativesHappyCoderRoute: AlternativesHappyCoderRoute,
|
||||
AlternativesOpenchamberRoute: AlternativesOpenchamberRoute,
|
||||
AlternativesOpencodeDesktopRoute: AlternativesOpencodeDesktopRoute,
|
||||
AlternativesOrcaRoute: AlternativesOrcaRoute,
|
||||
AlternativesSupersetRoute: AlternativesSupersetRoute,
|
||||
};
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { alternativeRouteOptions } from "~/components/alternative-page";
|
||||
|
||||
export const Route = createFileRoute("/alternatives/orca")(alternativeRouteOptions("orca"));
|
||||
Reference in New Issue
Block a user