mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +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;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
buildQuestionFormResponse,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
resolveDismissLabel,
|
||||
@@ -148,44 +147,4 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
test("submits each question's answer as structured labels so commas stay unambiguous", () => {
|
||||
const input = {
|
||||
questions: [
|
||||
{
|
||||
question: "Which colors?",
|
||||
header: "Colors",
|
||||
options: [{ label: "Red, bright" }, { label: "Blue" }],
|
||||
multiSelect: true,
|
||||
allowOther: true,
|
||||
},
|
||||
{
|
||||
question: "Which city?",
|
||||
header: "City",
|
||||
options: [{ label: "Paris, France" }, { label: "Rome" }],
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
const questions = parseQuestionFormQuestions(input);
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
|
||||
expect(
|
||||
buildQuestionFormResponse(
|
||||
input,
|
||||
questions,
|
||||
{ 0: new Set([0, 1]), 1: new Set([1]) },
|
||||
{ 0: " Green, dark ", 1: "Lyon, France" },
|
||||
),
|
||||
).toEqual({
|
||||
behavior: "allow",
|
||||
questionAnswers: [
|
||||
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
|
||||
{ selected: [], text: "Lyon, France" },
|
||||
],
|
||||
updatedInput: {
|
||||
...input,
|
||||
answers: { Colors: "Red, bright, Blue, Green, dark", City: "Lyon, France" },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AgentPermissionResponse, AgentQuestionAnswer } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -103,60 +101,35 @@ export function areQuestionsAnswered(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuestionFormResponse(
|
||||
input: Record<string, unknown> | undefined,
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): AgentPermissionResponse {
|
||||
return {
|
||||
behavior: "allow",
|
||||
questionAnswers: buildQuestionAnswers(questions, selections, otherTexts),
|
||||
updatedInput: {
|
||||
...input,
|
||||
// COMPAT(questionAnswers): added in v0.9.3, remove after 2027-03-26 once daemon floor >= v0.9.3.
|
||||
// Older daemons read only these text answers; newer daemons derive them from questionAnswers.
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestionAnswers(
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): AgentQuestionAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
// Multi-select keeps the checked options alongside the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
return { selected: q.multiSelect ? labels : [], text: otherText };
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
return { selected: [], text: "" };
|
||||
}
|
||||
}
|
||||
return { selected: labels };
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQuestionFormAnswers(
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
buildQuestionAnswers(questions, selections, otherTexts).forEach((answer, i) => {
|
||||
if (answer.selected.length === 0 && answer.text === undefined) return;
|
||||
answers[questions[i].header] = [...answer.selected, ...(answer.text ? [answer.text] : [])].join(
|
||||
", ",
|
||||
);
|
||||
});
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
// Multi-select keeps the checked options and appends the custom answer, the way
|
||||
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
|
||||
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
answers[q.header] = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormResponse,
|
||||
buildQuestionFormAnswers,
|
||||
isQuestionAnswered,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
@@ -402,9 +402,13 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
setRespondingAction("submit");
|
||||
onRespond(
|
||||
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
|
||||
);
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
questions,
|
||||
allAnswered,
|
||||
@@ -419,9 +423,13 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
if (!questions) return;
|
||||
setRespondingAction("dismiss");
|
||||
if (shouldSubmitEmptyOnDismiss(questions)) {
|
||||
onRespond(
|
||||
buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
|
||||
);
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onRespond({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -466,22 +466,12 @@ export interface AgentPermissionRequest {
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to one question of a `question` permission request, aligned by index with
|
||||
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
|
||||
*/
|
||||
export interface AgentQuestionAnswer {
|
||||
selected: string[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
questionAnswers?: AgentQuestionAnswer[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
|
||||
@@ -456,11 +456,6 @@ const AgentPermissionActionSchema = z.object({
|
||||
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
const AgentQuestionAnswerSchema = z.object({
|
||||
selected: z.array(z.string()),
|
||||
text: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
||||
z.discriminatedUnion("behavior", [
|
||||
z.object({
|
||||
@@ -468,7 +463,6 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
||||
selectedActionId: z.string().optional(),
|
||||
updatedInput: z.record(z.string(), z.unknown()).optional(),
|
||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||
questionAnswers: z.array(AgentQuestionAnswerSchema).optional(),
|
||||
}),
|
||||
z.object({
|
||||
behavior: z.literal("deny"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
type ImportableProviderSession,
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { resolveQuestionAnswers } from "./question-answers.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentOwner } from "./agent-owner.js";
|
||||
@@ -1944,6 +1943,7 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
}
|
||||
this.refreshSessionPersistence(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
@@ -2982,11 +2982,7 @@ export class AgentManager {
|
||||
agent.inFlightPermissionResponses.add(requestId);
|
||||
|
||||
try {
|
||||
const request = agent.pendingPermissions.get(requestId);
|
||||
const result = await agent.session.respondToPermission(
|
||||
requestId,
|
||||
request ? resolveQuestionAnswers(request, response) : response,
|
||||
);
|
||||
const result = await agent.session.respondToPermission(requestId, response);
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
|
||||
try {
|
||||
|
||||
@@ -504,22 +504,12 @@ export interface AgentPermissionRequest {
|
||||
metadata?: AgentMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to one question of a `question` permission request, aligned by index with
|
||||
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
|
||||
*/
|
||||
export interface AgentQuestionAnswer {
|
||||
selected: string[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type AgentPermissionResponse =
|
||||
| {
|
||||
behavior: "allow";
|
||||
selectedActionId?: string;
|
||||
updatedInput?: AgentMetadata;
|
||||
updatedPermissions?: AgentPermissionUpdate[];
|
||||
questionAnswers?: AgentQuestionAnswer[];
|
||||
}
|
||||
| {
|
||||
behavior: "deny";
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
@@ -5042,7 +5238,6 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
await session.respondToPermission("permission-call-question-2", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: ["Tea"] }],
|
||||
updatedInput: {
|
||||
answers: {
|
||||
Drink: "Tea",
|
||||
@@ -5062,7 +5257,6 @@ describe("Codex app-server provider", () => {
|
||||
requestId: "permission-call-question-2",
|
||||
resolution: {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: ["Tea"] }],
|
||||
updatedInput: {
|
||||
answers: {
|
||||
Drink: "Tea",
|
||||
@@ -5102,33 +5296,6 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("answers multi-select questions with option labels that contain commas", async () => {
|
||||
const session = createSession();
|
||||
const pendingResponse = asInternals(session).handleToolApprovalRequest({
|
||||
itemId: "call-question-3",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
questions: [
|
||||
{
|
||||
id: "colors",
|
||||
header: "Colors",
|
||||
question: "Which colors?",
|
||||
multiSelect: true,
|
||||
options: [{ label: "Red, bright" }, { label: "Blue" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await session.respondToPermission("permission-call-question-3", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: ["Red, bright", "Blue"], text: "Green, dark" }],
|
||||
});
|
||||
|
||||
await expect(pendingResponse).resolves.toEqual({
|
||||
answers: { colors: { answers: ["Red, bright", "Blue", "Green, dark"] } },
|
||||
});
|
||||
});
|
||||
|
||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type ProviderRefreshContext,
|
||||
type ResolveAgentDefaultModeInput,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { questionAnswerValues } from "../question-answers.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -261,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,
|
||||
@@ -546,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 {
|
||||
@@ -657,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 {
|
||||
@@ -700,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[] = [];
|
||||
@@ -713,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) => {
|
||||
@@ -1265,21 +1266,39 @@ export function mapCodexQuestionRequestToToolCall(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodexQuestionResponse(params: {
|
||||
function mapCodexQuestionResponseByHeader(params: {
|
||||
questions: CodexQuestionPrompt[];
|
||||
response: AgentPermissionResponse;
|
||||
}): Record<string, { answers: string[] }> | null {
|
||||
if (params.response.behavior !== "allow" || !params.response.questionAnswers) {
|
||||
if (params.response.behavior !== "allow") {
|
||||
return null;
|
||||
}
|
||||
const questionAnswers = params.response.questionAnswers;
|
||||
const updatedInputRecord = toObjectRecord(params.response.updatedInput);
|
||||
const answersRecord = toObjectRecord(updatedInputRecord?.answers);
|
||||
if (!answersRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const answers: Record<string, { answers: string[] }> = {};
|
||||
params.questions.forEach((question, index) => {
|
||||
const values = questionAnswerValues(questionAnswers[index]);
|
||||
for (const question of params.questions) {
|
||||
const rawAnswer = answersRecord[question.header];
|
||||
if (typeof rawAnswer !== "string") {
|
||||
continue;
|
||||
}
|
||||
const normalizedAnswer = rawAnswer.trim();
|
||||
if (!normalizedAnswer) {
|
||||
continue;
|
||||
}
|
||||
const values = question.multiSelect
|
||||
? normalizedAnswer
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [normalizedAnswer];
|
||||
if (values.length > 0) {
|
||||
answers[question.id] = { answers: values };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(answers).length > 0 ? answers : null;
|
||||
}
|
||||
@@ -3337,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;
|
||||
@@ -3450,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";
|
||||
@@ -4020,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);
|
||||
@@ -4587,7 +4607,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
? pendingRequest.metadata.itemId
|
||||
: requestId;
|
||||
if (response.behavior === "allow") {
|
||||
const mappedAnswers = mapCodexQuestionResponse({
|
||||
const mappedAnswers = mapCodexQuestionResponseByHeader({
|
||||
questions,
|
||||
response,
|
||||
});
|
||||
@@ -4817,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;
|
||||
@@ -4924,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 {
|
||||
@@ -4938,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[] = [
|
||||
{
|
||||
@@ -7007,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(),
|
||||
};
|
||||
}
|
||||
@@ -7121,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,
|
||||
@@ -7152,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
|
||||
|
||||
@@ -332,7 +332,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
|
||||
await session.respondToPermission("question-1", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: ["Proceed"] }],
|
||||
updatedInput: { answers: { Decision: "Proceed" } },
|
||||
});
|
||||
|
||||
expect(openCodeClient.calls.questionReply).toHaveLength(1);
|
||||
@@ -386,7 +386,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
|
||||
await session.respondToPermission("question-1", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: [], text: "Use another answer" }],
|
||||
updatedInput: { answers: { Decision: "Use another answer" } },
|
||||
});
|
||||
|
||||
expect(openCodeClient.calls.questionReply).toEqual([
|
||||
|
||||
@@ -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();
|
||||
@@ -5296,7 +5366,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await parent.respondToPermission("question_provider_child", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [{ selected: ["A"] }],
|
||||
updatedInput: { answers: { Path: "A" } },
|
||||
});
|
||||
expect(parentClient.calls.questionReply).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -5308,50 +5378,6 @@ describe("OpenCode provider subagent contract", () => {
|
||||
await parent.close();
|
||||
});
|
||||
|
||||
test("replies to questions with option labels and custom answers that contain commas", async () => {
|
||||
const { parent, openCode } = await createParentSession("ses_parent_comma_question");
|
||||
openCode.emitEvent({
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question_comma",
|
||||
sessionID: "ses_parent_comma_question",
|
||||
questions: [
|
||||
{
|
||||
question: "Which colors?",
|
||||
header: "Colors",
|
||||
multiple: true,
|
||||
options: [
|
||||
{ label: "Red, bright", description: "Warm" },
|
||||
{ label: "Blue", description: "Cool" },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: "Which city?",
|
||||
header: "City",
|
||||
options: [{ label: "Paris, France", description: "Europe" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(parent.getPendingPermissions()).toHaveLength(1));
|
||||
|
||||
await parent.respondToPermission("question_comma", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [
|
||||
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
|
||||
{ selected: ["Paris, France"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(openCode.calls.questionReply).toContainEqual(
|
||||
expect.objectContaining({
|
||||
requestID: "question_comma",
|
||||
answers: [["Red, bright", "Blue", "Green, dark"], ["Paris, France"]],
|
||||
}),
|
||||
);
|
||||
await parent.close();
|
||||
});
|
||||
|
||||
test("emits the same child status when descriptor semantics change", async () => {
|
||||
const { parent, openCode } = await createParentSession("ses_parent_child_changed_status");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { questionAnswerValues } from "../question-answers.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
raceProviderRefreshAbort,
|
||||
@@ -3550,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> {
|
||||
@@ -4958,10 +4978,19 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
directory,
|
||||
});
|
||||
} else {
|
||||
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
|
||||
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
|
||||
const answers = questions.map((_, index) =>
|
||||
questionAnswerValues(response.questionAnswers?.[index]),
|
||||
);
|
||||
const answers = questions.map((item) => {
|
||||
const header = readNonEmptyString(readOpenCodeRecord(item)?.header);
|
||||
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null;
|
||||
if (!rawAnswer) {
|
||||
return [];
|
||||
}
|
||||
return rawAnswer
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
});
|
||||
|
||||
await this.client.question.reply({
|
||||
requestID: requestId,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,23 +35,17 @@ describe("OpenCode v2 questions", () => {
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
expect(session.getPendingPermissions()).toEqual([
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
questions: expect.arrayContaining([
|
||||
expect.objectContaining({ header: "features", multiSelect: true }),
|
||||
]),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
await session.respondToPermission("question", {
|
||||
behavior: "allow",
|
||||
questionAnswers: [
|
||||
{ selected: ["Blue"] },
|
||||
{ selected: ["Search"], text: "Web, images" },
|
||||
{ selected: [], text: "3" },
|
||||
{ selected: [], text: "false" },
|
||||
],
|
||||
updatedInput: {
|
||||
answers: {
|
||||
color: "Blue",
|
||||
features: ["Search"],
|
||||
count: "3",
|
||||
enabled: "false",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(answers).toEqual([
|
||||
{
|
||||
@@ -59,7 +53,7 @@ describe("OpenCode v2 questions", () => {
|
||||
formID: "question",
|
||||
answer: {
|
||||
color: "blue-id",
|
||||
features: ["search-id", "Web, images"],
|
||||
features: ["search-id"],
|
||||
count: 3,
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@@ -4,18 +4,16 @@ import type { V2Api } from "./api.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentQuestionAnswer,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
import { questionAnswerValues } from "../../../question-answers.js";
|
||||
|
||||
export class SessionPermissions {
|
||||
private readonly pending = new Map<string, AgentPermissionRequest>();
|
||||
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,
|
||||
@@ -39,16 +37,19 @@ 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 answers = response.questionAnswers;
|
||||
const raw = response.updatedInput?.answers;
|
||||
const answer: Record<string, FormValue> = {};
|
||||
if (!answers) throw new Error("OpenCode question response requires answers");
|
||||
form.fields.forEach((field, index) => {
|
||||
const normalized = formAnswer(field, answers[index]);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
||||
throw new Error("OpenCode question response requires answers");
|
||||
for (const field of form.fields) {
|
||||
const value: unknown =
|
||||
Reflect.get(raw, field.key) ?? Reflect.get(raw, field.title ?? field.key);
|
||||
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,
|
||||
@@ -56,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),
|
||||
@@ -66,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;
|
||||
@@ -102,7 +103,7 @@ export class SessionPermissions {
|
||||
header: field.key,
|
||||
question: field.title ?? field.key,
|
||||
options: "options" in field ? field.options : undefined,
|
||||
multiSelect: field.type === "multiselect",
|
||||
multiple: field.type === "multiselect",
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -123,13 +124,9 @@ function permissionReply(response: AgentPermissionResponse): "reject" | "once" |
|
||||
return response.selectedActionId === "always" ? "always" : "once";
|
||||
}
|
||||
|
||||
function formAnswer(
|
||||
field: FormInfo["fields"][number],
|
||||
answer: AgentQuestionAnswer | undefined,
|
||||
): FormValue | undefined {
|
||||
const values = questionAnswerValues(answer);
|
||||
const [value] = values;
|
||||
if (value === undefined || field.type === "external") return undefined;
|
||||
function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValue | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (field.type === "external") return undefined;
|
||||
const labelValue = (label: string) => {
|
||||
const options = "options" in field ? field.options : undefined;
|
||||
return (
|
||||
@@ -139,15 +136,21 @@ function formAnswer(
|
||||
);
|
||||
};
|
||||
if (field.type === "multiselect") {
|
||||
return values.map(labelValue);
|
||||
} else if (field.type === "string") {
|
||||
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string"))
|
||||
return value.map(labelValue);
|
||||
} else if (field.type === "string" && typeof value === "string") {
|
||||
return labelValue(value);
|
||||
} else if (field.type === "boolean") {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
} else if (field.type === "number" || field.type === "integer") {
|
||||
const numeric = value.trim() ? Number(value) : Number.NaN;
|
||||
if (Number.isFinite(numeric) && (field.type !== "integer" || Number.isInteger(numeric)))
|
||||
const numeric = typeof value === "string" && value.trim() ? Number(value) : value;
|
||||
if (
|
||||
typeof numeric === "number" &&
|
||||
Number.isFinite(numeric) &&
|
||||
(field.type !== "integer" || Number.isInteger(numeric))
|
||||
)
|
||||
return numeric;
|
||||
}
|
||||
throw new Error(`Invalid answer for OpenCode question ${field.key}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user