Compare commits

...

22 Commits

Author SHA1 Message Date
Mohamed Boudra
b589599a8f chore(release): cut 0.2.2 2026-07-26 00:17:17 +02:00
Mohamed Boudra
9821729c75 fix(claude): preserve parsed context variants 2026-07-25 23:23:33 +02:00
Mohamed Boudra
7e599db6fe fix(claude): preserve catalog on version probe failure 2026-07-25 22:47:17 +02:00
Mohamed Boudra
b3bfec027f test(cli): compose Claude catalog expectations 2026-07-25 22:22:38 +02:00
Mohamed Boudra
6610dbdffe test(cli): expect Claude context variants 2026-07-25 22:20:57 +02:00
Mohamed Boudra
a27a1a07dc fix(claude): add Fable and Sonnet context variants 2026-07-25 22:12:52 +02:00
Mohamed Boudra
dcaed8008b fix(claude): select the correct Opus 5 context window
Bare Opus 5 IDs resolve to 200K behind third-party gateways. Expose explicit context variants and require a Claude Code version that recognizes the model.
2026-07-25 22:04:02 +02:00
Mohamed Boudra
36f38245ca chore(release): cut 0.2.1 2026-07-24 19:40:41 +02:00
Mohamed Boudra
782b341b1a docs: prepare 0.2.1 changelog 2026-07-24 19:37:33 +02:00
Mohamed Boudra
7ef3376b62 feat(claude): add Opus 5 model 2026-07-24 19:37:27 +02:00
Mohamed Boudra
d98c5e77f7 chore(release): cut 0.2.0 2026-07-24 13:27:13 +02:00
Mohamed Boudra
fbbdbdc571 docs: prepare 0.2.0 changelog 2026-07-24 13:24:33 +02:00
Mohamed Boudra
7e97ab4a9c chore(app): refresh ACP provider catalog 2026-07-24 13:24:23 +02:00
Mohamed Boudra
0cb9ecf44b Merge branch 'main' of github.com:getpaseo/paseo 2026-07-24 12:48:03 +02:00
Mohamed Boudra
afcd972dd0 Keep terminal pairing QR codes scannable (#2381)
* fix(pairing): keep terminal QR codes scannable

Prompt framing could wrap dense QR codes and alter long pairing links. Print pairing instructions directly, add the standard quiet zone, and suppress codes that cannot fit without auto-wrapping.

* fix(pairing): contain terminal QR background
2026-07-24 18:47:23 +08:00
Mohamed Boudra
48b14d27a5 fix(app): align and simplify provider rows 2026-07-24 12:46:46 +02:00
Mohamed Boudra
609f81bc11 Keep development tool versions consistent across version managers (#2390)
* chore(dev): centralize tool versions

* docs(android): clarify SDK version source

* fix(dev): keep mise setup installable
2026-07-24 18:46:10 +08:00
Mohamed Boudra
779a56ed36 fix(app): hide Markdown source toggle when editing is unavailable (#2382) 2026-07-24 18:14:08 +08:00
Mohamed Boudra
7bd4afe848 docs(providers): add Codex setup guide (#2389) 2026-07-24 09:57:20 +00:00
Mohamed Boudra
fc10c79e26 fix(app): synchronize chat submission and keyboard state
Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced.
2026-07-24 11:52:31 +02:00
Ethan Greenfeld
08c522c986 Render live omp system-notices as synthetic tool calls (#2218)
Route live OMP custom messages through the existing system-notice mapper so background notices render as task notifications while advisor, hidden, and ordinary custom-message behavior stays intact.
2026-07-24 11:02:52 +02:00
paseo-ai[bot]
4b5551d61c fix: update lockfile signatures and Nix hash [skip ci] 2026-07-23 22:25:59 +00:00
45 changed files with 861 additions and 326 deletions

View File

@@ -1,10 +1,10 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/cmdline-tools/21.0/bin",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/21.0/emulator",
]
[vars]
android_sdk_version = '{{ read_file(path=".tool-versions") | split(pat="android-sdk") | last | trim | split(pat="\n") | first | trim }}'
[tools]
java = "21"
[env]
ANDROID_HOME = "{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}"
_.path = [
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/cmdline-tools/{{ vars.android_sdk_version }}/bin",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/platform-tools",
"{{ env.HOME }}/.local/share/mise/installs/android-sdk/{{ vars.android_sdk_version }}/emulator",
]

View File

@@ -1,96 +1,48 @@
# Changelog
## 0.2.0-beta.4 - 2026-07-23
### Improved
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
## 0.2.2 - 2026-07-25
### Fixed
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- File edits preserve CRLF line endings and UTF-8 BOMs ([#2277](https://github.com/getpaseo/paseo/pull/2277) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi custom messages marked hidden stay hidden in live and restored chats ([#2280](https://github.com/getpaseo/paseo/pull/2280) by [@isac322](https://github.com/isac322))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Claude 5 models now use the correct context windows.
## 0.2.0-beta.3 - 2026-07-22
## 0.2.1 - 2026-07-24
### Added
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
- Claude Opus 5 is available
### Improved
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
### Fixed
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
## 0.2.0-beta.2 - 2026-07-22
### Added
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
### Improved
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
### Fixed
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
## 0.2.0-beta.1 - 2026-07-17
## 0.2.0 - 2026-07-24
### Added
- Work with pull requests and merge requests from GitLab, Gitea, Forgejo, and Codeberg ([#1913](https://github.com/getpaseo/paseo/pull/1913) by [@nllptrx](https://github.com/nllptrx))
- Use Oh My Pi as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Browse branch commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146) by [@adradr](https://github.com/adradr))
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309), [#2277](https://github.com/getpaseo/paseo/pull/2277), [#2382](https://github.com/getpaseo/paseo/pull/2382) by [@dwyanewang](https://github.com/dwyanewang))
- Oh My Pi (OMP) as a native agent provider ([#2067](https://github.com/getpaseo/paseo/pull/2067) by [@ebg1223](https://github.com/ebg1223))
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
- Browse workspace commit history and open individual commit diffs from Changes ([#1534](https://github.com/getpaseo/paseo/pull/1534), [#2146](https://github.com/getpaseo/paseo/pull/2146), [#2312](https://github.com/getpaseo/paseo/pull/2312) by [@adradr](https://github.com/adradr))
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
- Allow Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
- Open workspace files in more installed editors and file managers ([#2119](https://github.com/getpaseo/paseo/pull/2119))
- Remove individual custom providers from Settings ([#1951](https://github.com/getpaseo/paseo/pull/1951))
### Improved
- ACP provider catalog updated to the latest registry versions
- Improved model selection on mobile ([#2361](https://github.com/getpaseo/paseo/pull/2361))
- Selector popovers stay readable on iPad ([#2360](https://github.com/getpaseo/paseo/pull/2360) by [@yzim](https://github.com/yzim))
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
- CLI and MCP tools manage workspaces, agents, and schedules more consistently ([#2186](https://github.com/getpaseo/paseo/pull/2186))
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
- Workspace focus mode stays confined to the active workspace with a visible exit control ([#2151](https://github.com/getpaseo/paseo/pull/2151))
- Desktop installs the newest available update instead of a cached older release ([#2149](https://github.com/getpaseo/paseo/pull/2149))
- Remote daemon update failures now show specific recovery steps ([#2120](https://github.com/getpaseo/paseo/pull/2120))
@@ -98,12 +50,34 @@
### Fixed
- Terminal pairing QR codes remain scannable in narrow terminals ([#2381](https://github.com/getpaseo/paseo/pull/2381))
- Workspace creation stays responsive even with many active or archived workspaces ([#2355](https://github.com/getpaseo/paseo/pull/2355), [#2379](https://github.com/getpaseo/paseo/pull/2379))
- Failed agent starts no longer leave provider processes running ([#2348](https://github.com/getpaseo/paseo/pull/2348) by [@dwyanewang](https://github.com/dwyanewang))
- Completed OpenCode turns stay idle when late metadata updates arrive ([#2336](https://github.com/getpaseo/paseo/pull/2336) by [@mcowger](https://github.com/mcowger))
- ACP image prompts no longer appear twice ([#2363](https://github.com/getpaseo/paseo/pull/2363))
- Web chats stay pinned to the latest message at non-default browser zoom ([#2368](https://github.com/getpaseo/paseo/pull/2368))
- Grouped tool-call loading animations display correctly ([#2369](https://github.com/getpaseo/paseo/pull/2369))
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
- Oh My Pi background notices appear as task notifications instead of raw system text ([#2218](https://github.com/getpaseo/paseo/pull/2218) by [@ebg1223](https://github.com/ebg1223))
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
- Terminal panes no longer remain at 80x24 after focus or visibility changes ([#2059](https://github.com/getpaseo/paseo/pull/2059), [#2154](https://github.com/getpaseo/paseo/pull/2154) by [@cleiter](https://github.com/cleiter))
- Sign-in popups in the desktop browser now complete successfully ([#2137](https://github.com/getpaseo/paseo/pull/2137))
- Browser typing and shortcuts no longer submit the active Paseo prompt ([#1982](https://github.com/getpaseo/paseo/pull/1982))
- Agent browser tabs remain controllable after switching workspaces ([#2156](https://github.com/getpaseo/paseo/pull/2156))
- Archived workspaces now show the correct Unarchive or Restore action ([#2002](https://github.com/getpaseo/paseo/pull/2002))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123))
- Archived sessions can be reimported into the current workspace ([#2123](https://github.com/getpaseo/paseo/pull/2123), [#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
- Browser shortcuts no longer appear where browser tabs are unavailable ([#2116](https://github.com/getpaseo/paseo/pull/2116) by [@jasonhnd](https://github.com/jasonhnd))
## 0.1.110 - 2026-07-16

View File

@@ -27,13 +27,13 @@ The formula reserves three digits each for minor and patch. If either reaches `1
## Prerequisites (local dev)
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which sets `ANDROID_HOME` and puts `cmdline-tools/21.0/bin`, `platform-tools`, and `emulator` on `PATH`). With [mise](https://mise.jdx.dev):
Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in `.tool-versions` (`java 21`, `android-sdk 21.0`) and wired up by `.mise.toml` (which derives `ANDROID_HOME` and the command-line tool paths from the `android-sdk` entry). With [mise](https://mise.jdx.dev):
```bash
mise install # java 21 + android-sdk 21.0 command-line tools
```
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update the version in `.tool-versions` and in all four paths in `.mise.toml`.
> **Pin a real `android-sdk` version, not `latest`.** The mise `android-sdk` plugin's `latest` resolved to the ancient `1.0` bundle, whose `sdkmanager` (3.6.0) predates the `emulator` package and fails with `Failed to find package emulator`. `21.0` ships a current `sdkmanager`. If you bump it, update only the version in `.tool-versions`; `.mise.toml` derives its paths from that tool entry.
`mise install` only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:

View File

@@ -129,6 +129,13 @@ lockstep, no re-measurement needed. Do not call
can briefly report a stale nonzero height with closed progress, and the shared
provider is where that is normalized.
The provider also reconciles iOS from the controller's native `onEnd` event.
The controller's stock iOS shared values update at move start and during an
interactive move, but not at the terminal event, so JS contention can otherwise
leave the last height/progress pair stuck in either the open or closed state.
Keep that terminal reconciliation on the UI thread; a later focus or blur must
not be required to repair the offset.
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
to refresh the snapshot if the keyboard was mid-transition when the popover
opened.

View File

@@ -1 +1 @@
sha256-x6jiaw7zkCuBRlddLHD0tXQxolXwr7tzIQBfWu5xnUU=
sha256-t7FqvPj7U5YidGH9OZ1kFnD2dPTYUyjcxBlzAJU7Mak=

114
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35211,7 +35211,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -36236,12 +36236,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/server": "0.2.0-beta.4",
"@getpaseo/client": "0.2.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/server": "0.2.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36487,10 +36487,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36501,7 +36501,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36744,7 +36744,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37640,7 +37640,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37872,7 +37872,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37885,7 +37885,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38103,15 +38103,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/highlight": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/client": "0.2.2",
"@getpaseo/highlight": "0.2.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38155,22 +38155,22 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.214.tgz",
"integrity": "sha512-wt5ImwhU+p259Zt4K/Q9v5xVi6ruxYO5+KFICyJxnjs/QFEClAeSRqhcXx1J8jgGfatPW1faw09hkm66680UjA==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.220.tgz",
"integrity": "sha512-glc7SdwPkOkLw8oxwLo9PKTdLJGqW/PIR4urWXFoRtX9YllwozsEVc5Tc1+EvLSkfrsxPJqQWqOgpjUOQXf1oA==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.214",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.214"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.220",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.220"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -38179,9 +38179,9 @@
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.214.tgz",
"integrity": "sha512-vAAOeVtlXs3p7MpFVfvfu9ja32eaKtB+MDZnPxCbdzxJk5nofCHlNsHa1UJwXHOsP9lOnFYNIccYztsZ4DNaJA==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.220.tgz",
"integrity": "sha512-7VxlbEosK7DODiOnsjoVd0DSJzbnaPrM2jelMHI0y8zx1UnLS3WC6EFUXbvy74F2sXqEznh2tzn7EKWInaRN6Q==",
"cpu": [
"arm64"
],
@@ -38192,9 +38192,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.214.tgz",
"integrity": "sha512-NTbV8U2yucxCWqEiDC7L0MUehNmd8x3Op8OGzLZRhMF3xmQBy2DxpXiNZgSwwKWNDC3HdgKmJM5ZBbT7XrF/0g==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.220.tgz",
"integrity": "sha512-X9RwDsSmbF6ultKZroaip+DL8WRgC64gHbrAwrRlAFSPNZV7zmJyP2ur8rW7KrxqmtuehdMMkw8+SAC/6hD2PA==",
"cpu": [
"x64"
],
@@ -38205,9 +38205,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.214.tgz",
"integrity": "sha512-KBCf+BlusG0ZcgvpjjHwv1kh+6WiR8vJbPjpR2udwkrtcQgKLN+24l+FeaQEzO2TL+ExCmM1KC4tNPvnPpy+tw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.220.tgz",
"integrity": "sha512-WkROPwWskqhKR9XgnmseHQ6rLi9zM9qt57IWoToIjL/eXOqDWipp7JXZ1L5ud+LrA42dunHPZfBwD/vXZ+A7LA==",
"cpu": [
"arm64"
],
@@ -38218,9 +38218,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.214.tgz",
"integrity": "sha512-i06wQRsmevE7spY1ryYfs+NP+xdZ1FwAyTjDaF0k/xG+cgtzZYghTFUemdYF3GVwgWgpcava9GiCFDT3DoHI6w==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.220.tgz",
"integrity": "sha512-OHoZOZ8Cf2TBr6oXIXPwyvUxj9jrq2w8E4poA8dMpacXszcPSPiCQCMuuOh4aWJzfeJE1+TtWxhKMVb2csXyZQ==",
"cpu": [
"arm64"
],
@@ -38231,9 +38231,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.214.tgz",
"integrity": "sha512-vqadSceJkBKHaTUszxI35uTuECGWtsHWK/mOwIJ4b9DUtYYySz6EJEk4gHg4ccutisJ/oRVCpFXHIKFb+osrKw==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.220.tgz",
"integrity": "sha512-tkTJFnpR9VifvWX2fmkCAPkT6+8Wk/gVu8B5jsVekKZPiZoWRHmMXO30BnZn+f0TZhgYP+82PSX3S8crH1kn+w==",
"cpu": [
"x64"
],
@@ -38244,9 +38244,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.214.tgz",
"integrity": "sha512-948RstHDhs0E69h+2dKYWIAL1kcwEme4zvtgNUwP8XpKXzWOhrTKmd6MAokHn25zwfuSx5d+HE0XHfFH6R4hLg==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.220.tgz",
"integrity": "sha512-K+FWj+LcGhC1Z7wqeWoLxm1iemcba5xKpLLFVwYm4V6HyMx3ruYd/2r2TiQtjT+JWeNFWIys0ScHiItR6vWAiA==",
"cpu": [
"x64"
],
@@ -38257,9 +38257,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.214.tgz",
"integrity": "sha512-CEKlPFCPv+ee79utQMEDSsgeo2f27ulhNHpjrKIt1jXz5G04J7lAWN/QwvPDv9XaLBkWpyfqZWiPXlRcwuaO/w==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.220.tgz",
"integrity": "sha512-rIwgq0UwQExWl6KrHUyC4w5KwpL9l6nd95aUTx6RitexaAuEw//xtfTVLnuE4hDDQZFkzEwpdKc3nxDWoGcUbA==",
"cpu": [
"arm64"
],
@@ -38270,9 +38270,9 @@
]
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.214",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.214.tgz",
"integrity": "sha512-cJMJfFoR9IWBZWnTtt9PnEm89hGOsZjoDNjOCEOF3+x+g/ANIXAjMJTcaQYv2HKh6MylWZ0AkxBASI+twkukaQ==",
"version": "0.3.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.220.tgz",
"integrity": "sha512-MuOuXhbr66HlGaWXD2f3w0k2PsvmnbkwcUZ0dAe2poFLdl72GC2dapwwOBefxm9QmoNqk9+jmv/dSKGOVWyvLw==",
"cpu": [
"x64"
],
@@ -38648,7 +38648,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = context.status === "running";
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({

View File

@@ -333,6 +333,26 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("removes the optimistic prompt when the host rejects it", async () => {
const rejection = new Error("Host rejected prompt");
const client = createFakeSendClient({ rejection });
const stream = createFakeStream();
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "rejected prompt",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
}),
).rejects.toBe(rejection);
expect(stream.head.get("agent")).toBeUndefined();
expect(stream.tail.get("agent") ?? []).toEqual([]);
});
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();

View File

@@ -186,40 +186,50 @@ export async function dispatchComposerAgentMessage(
images: wirePayload.images,
attachments: wirePayload.attachments,
});
appendUserMessageToStream(input.agentId, userMessage, input.stream);
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
const rollbackOptimisticMessage = appendUserMessageToStream(
input.agentId,
userMessage,
input.stream,
);
try {
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
} catch (error) {
rollbackOptimisticMessage();
throw error;
}
}
function appendUserMessageToStream(
agentId: string,
userMessage: UserMessageItem,
stream: AgentStreamWriter,
): void {
): () => void {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
if (result.changedHead) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
const write = result.changedHead ? stream.setHead : stream.setTail;
const items = result.changedHead ? result.head : result.tail;
write((prev) => new Map(prev).set(agentId, items));
return () => {
write((prev) => {
const current = prev.get(agentId);
if (!current) return prev;
const nextItems = current.filter(
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
);
if (nextItems.length === current.length) return prev;
return new Map(prev).set(agentId, nextItems);
});
}
if (result.changedTail) {
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
});
}
};
}
export interface QueueComposerMessageInput {

View File

@@ -45,6 +45,7 @@ interface ComposerWorkspaceAttachmentBinding {
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
beginSubmit: (attachments: readonly ComposerAttachment[]) => void;
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
completeSubmit: (input: CompleteSubmitInput) => void;
resetSuppression: () => void;
@@ -196,12 +197,22 @@ function useWorkspaceAttachmentBinding({
setSuppressedKeys([]);
}, []);
const beginSubmit = useCallback((attachments: readonly ComposerAttachment[]) => {
const keys = attachments.filter(isWorkspaceAttachment).map(getAttachmentKey);
if (keys.length === 0) return;
setSuppressedKeys((current) => {
const next = new Set(current);
for (const key of keys) next.add(key);
return next.size === current.length ? current : Array.from(next);
});
}, []);
const completeSubmit = useCallback(
({ result, outgoingAttachments }: CompleteSubmitInput) => {
if (result === "submitted") {
clearSentAttachments(outgoingAttachments);
}
if (result === "queued" || result === "submitted") {
if (result === "queued" || result === "submitted" || result === "failed") {
resetSuppression();
}
},
@@ -213,6 +224,7 @@ function useWorkspaceAttachmentBinding({
buildOutgoingAttachments,
removeAttachment,
openAttachment,
beginSubmit,
clearSentAttachments,
completeSubmit,
resetSuppression,

View File

@@ -1095,6 +1095,7 @@ export function Composer({
buildOutgoingAttachments,
removeAttachment,
openAttachment,
beginSubmit,
clearSentAttachments,
completeSubmit,
resetSuppression,
@@ -1372,6 +1373,9 @@ export function Composer({
queueMessage(queuedText, queuedAttachments);
},
submitMessage: async ({ message: submitText, attachments: submitAttachments }) => {
if (submitBehavior !== "preserve-and-lock") {
beginSubmit(submitAttachments);
}
await submitMessage(submitText, submitAttachments);
},
clearDraft,
@@ -1393,6 +1397,7 @@ export function Composer({
},
[
allowEmptySubmit,
beginSubmit,
clearDraft,
completeSubmit,
hasExternalContent,

View File

@@ -159,10 +159,10 @@ const CATALOG_DATA = [
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.178.0",
version: "0.179.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.178.0", "exec", "--output-format", "acp-daemon"],
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",

View File

@@ -459,6 +459,7 @@ export function FilePane({
preview,
supportsEditing,
});
const canToggleMarkdownMode = isMarkdown && editable;
const lineCount =
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
@@ -471,8 +472,8 @@ export function FilePane({
preview={preview}
version={version}
filename={getFileNameFromPath(location.path) ?? location.path}
markdownMode={isMarkdown ? markdownMode : undefined}
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
markdownMode={canToggleMarkdownMode ? markdownMode : undefined}
onMarkdownModeChange={canToggleMarkdownMode ? setMarkdownMode : undefined}
lineCount={lineCount}
editable={editable}
disconnectedMessage={t("workspace.terminal.hostDisconnected")}

View File

@@ -9,7 +9,10 @@ import {
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import {
useGenericKeyboardHandler,
useReanimatedKeyboardAnimation,
} from "react-native-keyboard-controller";
import {
useAnimatedStyle,
useDerivedValue,
@@ -40,6 +43,19 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
useGenericKeyboardHandler(
{
onEnd: (event) => {
"worklet";
if (isIos) {
keyboardHeight.value = -event.height;
keyboardProgress.value = event.progress;
}
},
},
[isIos, keyboardHeight, keyboardProgress],
);
const shift = useDerivedValue(() => {
"worklet";
return resolveKeyboardShift({

View File

@@ -44,6 +44,7 @@ const { theme, snapshotState, configState, patchConfigMock, openProviderSettings
);
vi.mock("react-native", () => ({
Platform: { OS: "web" },
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
@@ -90,7 +91,7 @@ vi.mock("react-native-unistyles", () => ({
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
vi.mock("lucide-react-native", () => {

View File

@@ -10,6 +10,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
@@ -178,6 +179,7 @@ function ProviderRow({
}: ProviderRowProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const ProviderIcon = getProviderIcon(def.id);
const providerError =
enabled &&
@@ -229,10 +231,10 @@ function ProviderRow({
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{def.label}
</Text>
<Text style={styles.separator}>·</Text>
<StatusIndicator status={providerStatus} />
{!isCompact ? <Text style={styles.separator}>·</Text> : null}
<StatusIndicator status={providerStatus} compact={isCompact} />
</View>
{providerError ? (
{providerError && !isCompact ? (
<Text style={styles.errorText} numberOfLines={3}>
{providerError}
</Text>
@@ -246,18 +248,20 @@ function ProviderRow({
disabled={isToggling || isRemoving}
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
/>
{canRemove ? (
<ProviderActionsMenu
providerId={def.id}
providerLabel={def.label}
isRemoving={isRemoving}
iconSize={theme.iconSize.sm}
foregroundColor={theme.colors.foreground}
foregroundMutedColor={theme.colors.foregroundMuted}
dangerColor={theme.colors.statusDanger}
onRemove={onRemove}
/>
) : null}
<View style={styles.menuSlot}>
{canRemove ? (
<ProviderActionsMenu
providerId={def.id}
providerLabel={def.label}
isRemoving={isRemoving}
iconSize={theme.iconSize.sm}
foregroundColor={theme.colors.foreground}
foregroundMutedColor={theme.colors.foregroundMuted}
dangerColor={theme.colors.statusDanger}
onRemove={onRemove}
/>
) : null}
</View>
</View>
</>
)}
@@ -278,7 +282,7 @@ function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["t
}
}
function StatusIndicator({ status }: { status: ProviderStatus }) {
function StatusIndicator({ status, compact }: { status: ProviderStatus; compact: boolean }) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const dotStyle = useMemo(
@@ -293,15 +297,19 @@ function StatusIndicator({ status }: { status: ProviderStatus }) {
) : (
<View style={dotStyle} />
)}
<Text style={styles.statusLabel}>{status.label}</Text>
{status.modelCount !== null ? (
{!compact ? (
<>
<Text style={styles.separator}>·</Text>
<Text style={styles.statusLabel}>
{status.modelCount === 1
? t("settings.providers.models.one")
: t("settings.providers.models.many", { count: status.modelCount })}
</Text>
<Text style={styles.statusLabel}>{status.label}</Text>
{status.modelCount !== null ? (
<>
<Text style={styles.separator}>·</Text>
<Text style={styles.statusLabel}>
{status.modelCount === 1
? t("settings.providers.models.one")
: t("settings.providers.models.many", { count: status.modelCount })}
</Text>
</>
) : null}
</>
) : null}
</View>
@@ -536,6 +544,10 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
menuSlot: {
width: 32,
height: 32,
},
menuButtonHovered: {
backgroundColor: theme.colors.surface2,
},

View File

@@ -22,6 +22,36 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
describe("deriveStreamTurnTiming", () => {
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.isActive, true);
});
it("does not start elapsed time from an optimistic prompt", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.runningStartedAt, null);
});
it("uses the last user message as the running turn start", () => {
const firstUserAt = new Date("2026-05-15T00:00:00.000Z");
const secondUserAt = new Date("2026-05-15T00:01:00.000Z");

View File

@@ -9,6 +9,7 @@ export interface TurnTiming {
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
isActive: boolean;
}
export function deriveStreamTurnTiming(params: {
@@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: {
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentAuthoritativeUserAt: Date | null = null;
let currentUserIsOptimistic = false;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
@@ -39,6 +42,8 @@ export function deriveStreamTurnTiming(params: {
if (item.kind === "user_message") {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
currentUserIsOptimistic = item.optimistic === true;
currentLastItemAt = null;
currentAssistantIds = [];
return;
@@ -59,10 +64,8 @@ export function deriveStreamTurnTiming(params: {
visitItem(item);
}
const runningStartedAt =
params.agentStatus === "running"
? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
: null;
const isRunning = params.agentStatus === "running";
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
@@ -70,15 +73,6 @@ export function deriveStreamTurnTiming(params: {
return {
byAssistantId,
runningStartedAt,
isActive: isRunning || currentUserIsOptimistic,
};
}
function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
if (item?.kind === "user_message") {
return item.timestamp;
}
}
return null;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/server": "0.2.0-beta.4",
"@getpaseo/client": "0.2.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/server": "0.2.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -4,6 +4,7 @@ import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpas
import { tryConnectToDaemon } from "../../utils/client.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { addJsonOption } from "../../utils/command-options.js";
import { formatPairingInstructions } from "../../output/pairing.js";
interface PairOptions {
home?: string;
@@ -97,6 +98,11 @@ function outputPairingResult(
return;
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
process.stdout.write(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
columns: process.stdout.columns,
}),
);
}

View File

@@ -18,6 +18,7 @@ import {
type DaemonStartOptions,
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
import { formatPairingInstructions } from "../output/pairing.js";
interface OnboardOptions extends DaemonStartOptions {
timeout?: string;
@@ -520,11 +521,13 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
return;
}
renderNote(
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
"Scan to pair",
process.stdout.write(
formatPairingInstructions({
url: pairing.url,
qr: pairing.qr,
columns: process.stdout.columns,
}),
);
renderNote(pairing.url, "Pairing link");
printNextSteps(pairing.url, paseoHome, richUi);
if (richUi) {
outro("Paseo is ready!");

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { formatPairingInstructions } from "./pairing.js";
const QR = "\u001b[47m\u001b[30m \n ████ \n \u001b[0m";
const URL = "https://app.paseo.sh/#offer=pairing-offer";
describe("formatPairingInstructions", () => {
it("prints the QR and an unmodified pairing-link line when the terminal is wide enough", () => {
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 7 });
expect(output).toContain(QR);
expect(output.split("\n")).toContain(URL);
});
it("does not print a QR that would reach the terminal edge", () => {
const output = formatPairingInstructions({ qr: QR, url: URL, columns: 6 });
expect(output).not.toContain(QR);
expect(output).toContain("Resize the terminal to at least 7 columns");
expect(output.split("\n")).toContain(URL);
});
it("does not risk printing a QR when terminal width is unknown", () => {
const output = formatPairingInstructions({ qr: QR, url: URL });
expect(output).not.toContain(QR);
expect(output).toContain("terminal width could not be detected");
expect(output.split("\n")).toContain(URL);
});
});

View File

@@ -0,0 +1,37 @@
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
interface PairingInstructions {
url: string;
qr: string | null;
columns?: number;
}
function visibleWidth(value: string): number {
return Math.max(
...value
.replace(ANSI_PATTERN, "")
.split("\n")
.map((line) => line.length),
);
}
function formatQr(qr: string | null, columns: number | undefined): string {
if (!qr) {
return "QR code is unavailable. Use the pairing link below.";
}
if (columns === undefined) {
return "QR code not shown because terminal width could not be detected.";
}
const width = visibleWidth(qr);
if (columns <= width) {
return `QR code not shown. Resize the terminal to at least ${width + 1} columns, then run this command again.`;
}
return qr;
}
export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string {
return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n`;
}

View File

@@ -102,6 +102,38 @@ const EXPECTED_CLAUDE_MODELS = [
},
] as const;
const EXPECTED_CLAUDE_CONTEXT_MODELS = [
{
id: "claude-opus-5[1m]",
model: "Opus 5 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-opus-5",
model: "Opus 5",
descriptionFragment: "200K context window",
},
{
id: "claude-fable-5[1m]",
model: "Fable 5 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-sonnet-5[1m]",
model: "Sonnet 5 1M",
descriptionFragment: "1M context window",
},
] as const;
const EXPECTED_CLAUDE_CATALOG_MODELS = [
...new Map(
[...EXPECTED_CLAUDE_MODELS, ...EXPECTED_CLAUDE_CONTEXT_MODELS].map((model) => [
model.id,
model,
]),
).values(),
];
let claudeModelIdsFromJson: string[] = [];
let claudeModelsFromJson: ProviderModel[] = [];
@@ -134,18 +166,18 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
function assertClaudeModels(data: ProviderModel[]): void {
assert.strictEqual(
data.length,
EXPECTED_CLAUDE_MODELS.length,
EXPECTED_CLAUDE_CATALOG_MODELS.length,
"claude output should match the current catalog size",
);
const byId = new Map(data.map((model) => [model.id, model]));
const ids = [...byId.keys()].sort();
const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort();
const expectedIds = EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort();
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog");
for (const expectedModel of EXPECTED_CLAUDE_MODELS) {
for (const expectedModel of EXPECTED_CLAUDE_CATALOG_MODELS) {
const actualModel = byId.get(expectedModel.id);
assert(actualModel, `claude output should include ${expectedModel.id}`);
assert.strictEqual(
@@ -383,7 +415,7 @@ try {
const lines = result.stdout.trim().split("\n").filter(Boolean);
assert.strictEqual(
lines.length,
EXPECTED_CLAUDE_MODELS.length,
EXPECTED_CLAUDE_CATALOG_MODELS.length,
"should have one line per Claude catalog model",
);
assert.deepStrictEqual(
@@ -393,7 +425,7 @@ try {
);
assert.deepStrictEqual(
[...lines].sort(),
EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort(),
EXPECTED_CLAUDE_CATALOG_MODELS.map((model) => model.id).sort(),
"--quiet should print the current Claude catalog IDs",
);
assert(

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"zod": "^4.4.3"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,12 +65,12 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.2.0-beta.4",
"@getpaseo/highlight": "0.2.0-beta.4",
"@getpaseo/protocol": "0.2.0-beta.4",
"@getpaseo/relay": "0.2.0-beta.4",
"@getpaseo/client": "0.2.2",
"@getpaseo/highlight": "0.2.2",
"@getpaseo/protocol": "0.2.2",
"@getpaseo/relay": "0.2.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -11,6 +11,7 @@ import {
convertClaudeHistoryEntry,
normalizeClaudeAskUserQuestionRequestInput,
normalizeClaudeAskUserQuestionUpdatedInput,
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
@@ -405,6 +406,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
@@ -414,10 +416,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
});
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5[1m]",
"claude-opus-5",
"claude-fable-5[1m]",
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-5[1m]",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -433,7 +439,30 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
}
const defaultModel = models.find((m) => m.isDefault);
expect(defaultModel?.id).toBe("claude-opus-4-8");
expect(defaultModel?.id).toBe("claude-opus-5[1m]");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
}
});
test("preserves the catalog when Claude Code version detection fails", async () => {
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
try {
const client = new ClaudeAgentClient({
logger,
resolveVersion: async () => {
throw new Error("unrecognized version output");
},
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/claude-models",
force: false,
});
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5[1m]");
expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
}
@@ -445,6 +474,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
@@ -456,6 +486,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
};
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
@@ -473,6 +504,10 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("resolves the installed Claude Code version", async () => {
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
});
test("loads user, project, and local Claude settings", async () => {
const queryReturn = vi.fn();
queryReturn.mockResolvedValue(undefined);
@@ -2081,10 +2116,10 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("native 1M Claude models seed active context window usage from the catalog", async () => {
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
const session = await createSessionForTurns(
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
{ model: "claude-sonnet-5" },
{ model: "claude-sonnet-5[1m]" },
);
try {

View File

@@ -37,6 +37,7 @@ import {
import {
CLAUDE_DISABLED_THINKING_OPTION_ID,
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
@@ -363,6 +364,7 @@ interface ClaudeAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: () => Promise<string>;
configDir?: string;
}
@@ -1439,6 +1441,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: () => Promise<string>;
private readonly configDir?: string;
constructor(options: ClaudeAgentClientOptions) {
@@ -1447,6 +1450,8 @@ export class ClaudeAgentClient implements AgentClient {
this.runtimeSettings = options.runtimeSettings;
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
this.resolveVersion =
options.resolveVersion ?? (() => resolveClaudeCodeVersion(this.runtimeSettings));
this.configDir = options.configDir;
}
@@ -1498,7 +1503,17 @@ export class ClaudeAgentClient implements AgentClient {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
// Claude exposes a global catalog here; cwd/force are intentionally irrelevant.
const models = await getClaudeModelsWithSettings(this.logger, this.configDir);
let claudeCodeVersion: string | undefined;
try {
claudeCodeVersion = await this.resolveVersion();
} catch (error) {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const models = await getClaudeModelsWithSettings(
this.logger,
this.configDir,
claudeCodeVersion,
);
const modes = detectIneligibleAutoModeTransport(
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
)
@@ -1620,6 +1635,29 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P
);
}
export async function resolveClaudeCodeVersion(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
defaultBinary: "claude",
});
const availability = await checkProviderLaunchAvailable(launch);
if (!availability.available) {
throw new Error("Claude binary not found while resolving Claude Code version");
}
const executable = availability.resolvedPath ?? launch.command;
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000,
});
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
if (!version) {
throw new Error("Unable to parse Claude Code version from --version output");
}
return version.join(".");
}
async function resolveClaudeAuth(
launch: ResolvedProviderLaunch,
availability: { resolvedPath: string | null },

View File

@@ -6,7 +6,8 @@ interface ClaudeModelManifestEntry {
id: string;
label: string;
description: string;
isDefault?: boolean;
defaultPriority?: number;
minimumClaudeCodeVersion?: string;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
supportsThinkingDisabled?: boolean;
@@ -30,11 +31,39 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-5[1m]",
label: "Opus 5 1M",
description: "Opus 5 with 1M context window",
defaultPriority: 2,
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · 200K context window",
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-fable-5[1m]",
label: "Fable 5 1M",
description: "Fable 5 with 1M context window",
minimumClaudeCodeVersion: "2.1.169",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
minimumClaudeCodeVersion: "2.1.169",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
@@ -49,8 +78,8 @@ export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-4-8",
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
description: "Opus 4.8 · Previous release",
defaultPriority: 1,
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
@@ -60,6 +89,14 @@ export const CLAUDE_MODEL_MANIFEST = [
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-sonnet-5[1m]",
label: "Sonnet 5 1M",
description: "Sonnet 5 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
@@ -147,31 +184,73 @@ function buildThinkingOptions(
return options;
}
export function getClaudeManifestModels(): AgentModelDefinition[] {
return CLAUDE_MODEL_MANIFEST.map((model) => {
export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] {
const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter(
(model) => isModelAvailableInClaudeCode(model, claudeCodeVersion),
);
const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>(
(selected, candidate) =>
(candidate.defaultPriority ?? 0) > (selected?.defaultPriority ?? 0) ? candidate : selected,
undefined,
);
return availableModels.map((model) => {
const thinkingOptions = buildThinkingOptions(
"effortLevels" in model ? model.effortLevels : undefined,
"supportsThinkingDisabled" in model && model.supportsThinkingDisabled,
model.effortLevels,
model.supportsThinkingDisabled === true,
);
return {
const definition: AgentModelDefinition = {
provider: "claude",
id: model.id,
label: model.label,
description: model.description,
...("isDefault" in model && model.isDefault ? { isDefault: true } : {}),
...(model.contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens: model.contextWindowMaxTokens }
: {}),
...(thinkingOptions
? {
thinkingOptions,
defaultThinkingOptionId: "effortLevels" in model ? model.effortLevels?.[0] : undefined,
}
: {}),
};
if (model === defaultModel) {
definition.isDefault = true;
}
if (model.contextWindowMaxTokens !== undefined) {
definition.contextWindowMaxTokens = model.contextWindowMaxTokens;
}
if (thinkingOptions) {
definition.thinkingOptions = thinkingOptions;
definition.defaultThinkingOptionId = model.effortLevels?.[0];
}
return definition;
});
}
function isModelAvailableInClaudeCode(
model: ClaudeModelManifestEntry,
claudeCodeVersion: string | undefined,
): boolean {
if (!model.minimumClaudeCodeVersion || claudeCodeVersion === undefined) {
return true;
}
return compareVersions(claudeCodeVersion, model.minimumClaudeCodeVersion) >= 0;
}
function compareVersions(left: string, right: string): number {
const leftParts = parseClaudeCodeVersion(left);
const rightParts = parseClaudeCodeVersion(right);
if (!leftParts || !rightParts) {
return -1;
}
for (let index = 0; index < leftParts.length; index += 1) {
const difference = leftParts[index] - rightParts[index];
if (difference !== 0) {
return difference;
}
}
return 0;
}
export function parseClaudeCodeVersion(value: string): [number, number, number] | null {
const match =
value.match(/\b(\d+)\.(\d+)\.(\d+)\s+\(Claude Code\)/i) ??
value.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
}
export interface ClaudeDisabledThinkingResolution {
supported: boolean;
fallbackThinkingOptionId: string | undefined;
@@ -228,7 +307,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const singleSegmentMatch = trimmed.match(
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i,
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (singleSegmentMatch) {
return normalizeSingleSegmentClaudeModelId(
@@ -239,7 +318,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const runtimeMatch = trimmed.match(
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i,
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (!runtimeMatch) {
return null;
@@ -249,7 +328,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
Boolean(runtimeMatch[4]),
trimmed.toLowerCase().includes("[1m]"),
);
}
@@ -276,7 +355,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
Boolean(singleSegmentMatch[3]),
trimmed.toLowerCase().includes("[1m]"),
);
if (normalizedModelId) {
return normalizedModelId;
@@ -294,7 +373,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
Boolean(runtimeMatch[4]),
trimmed.toLowerCase().includes("[1m]"),
);
}

View File

@@ -10,6 +10,7 @@ import {
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
claudeManifestModelSupportsFastMode,
normalizeClaudeManifestModelId,
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
@@ -38,14 +39,25 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
return configDir;
}
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
});
}
describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5[1m]",
"claude-opus-5",
"claude-fable-5[1m]",
"claude-fable-5",
"claude-opus-4-8[1m]",
"claude-opus-4-8",
"claude-sonnet-5",
"claude-sonnet-5[1m]",
"claude-opus-4-7[1m]",
"claude-opus-4-7",
"claude-opus-4-6[1m]",
@@ -60,7 +72,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe("claude-opus-4-8");
expect(defaults[0].id).toBe("claude-opus-5[1m]");
});
it("defines context window sizes in the catalog", () => {
@@ -70,10 +82,14 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-fable-5", 1_000_000],
["claude-opus-5[1m]", 1_000_000],
["claude-opus-5", 200_000],
["claude-fable-5[1m]", 1_000_000],
["claude-fable-5", 200_000],
["claude-opus-4-8[1m]", 1_000_000],
["claude-opus-4-8", 200_000],
["claude-sonnet-5", 1_000_000],
["claude-sonnet-5", 200_000],
["claude-sonnet-5[1m]", 1_000_000],
["claude-opus-4-7[1m]", 1_000_000],
["claude-opus-4-7", 200_000],
["claude-opus-4-6[1m]", 1_000_000],
@@ -85,9 +101,32 @@ describe("getClaudeModels", () => {
);
});
it("filters models by their minimum Claude Code version", () => {
const oldVersionModels = getClaudeModels("2.1.218");
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5");
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5[1m]");
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]");
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5[1m]");
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
});
it("derives thinking options from model effort capabilities", () => {
const models = new Map(getClaudeModels().map((model) => [model.id, model]));
expect(models.get("claude-opus-5")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(models.get("claude-sonnet-5")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
"low",
@@ -104,6 +143,9 @@ describe("getClaudeModels", () => {
?.label,
).toBe("Ultra Code");
expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low");
expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual(
models.get("claude-sonnet-5")?.thinkingOptions,
);
expect(models.get("claude-opus-4-7")?.thinkingOptions?.map((option) => option.id)).toEqual([
CLAUDE_DISABLED_THINKING_OPTION_ID,
@@ -124,11 +166,17 @@ describe("getClaudeModels", () => {
expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain(
CLAUDE_DISABLED_THINKING_OPTION_ID,
);
expect(models.get("claude-fable-5[1m]")?.thinkingOptions).toEqual(
models.get("claude-fable-5")?.thinkingOptions,
);
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
});
it.each([
["claude-opus-5", true, "low"],
["claude-opus-5-20260724", true, "low"],
["claude-sonnet-5", true, "low"],
["claude-sonnet-5[1m]", true, "low"],
["claude-sonnet-5-20260101", true, "low"],
["claude-fable-5", false, "low"],
["claude-haiku-4-5", false, undefined],
@@ -162,7 +210,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
},
});
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = createCatalogClient();
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -215,7 +263,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-"));
createdClaudeConfigDirs.push(configDir);
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = createCatalogClient();
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -229,7 +277,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
it("falls back to hardcoded models when settings.json is malformed", async () => {
const configDir = await createClaudeConfigDirWithRawSettings("{ nope");
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = createCatalogClient();
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -249,7 +297,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
},
});
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = createCatalogClient();
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -269,7 +317,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
},
});
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const client = createCatalogClient();
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -282,12 +330,29 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
"glm-5.1",
]);
});
it("omits models that require a newer Claude Code version", async () => {
const client = createCatalogClient("2.1.218");
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: os.tmpdir(),
force: true,
});
expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
expect(models.map((model) => model.id)).not.toContain("claude-opus-5");
});
});
describe("normalizeClaudeRuntimeModelId", () => {
it("returns exact match for known model IDs", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
@@ -295,14 +360,23 @@ describe("normalizeClaudeRuntimeModelId", () => {
});
it("normalizes dated model IDs to base model", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724")).toBe("claude-opus-5");
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101")).toBe("claude-sonnet-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
"claude-sonnet-5[1m]",
);
});
it("preserves [1m] suffix from runtime model strings", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
});
@@ -331,6 +405,12 @@ describe("normalizeClaudeRuntimeModelId", () => {
});
});
describe("parseClaudeCodeVersion", () => {
it("prefers the Claude Code version over a wrapper banner", () => {
expect(parseClaudeCodeVersion("wrapper 1.0.0\n2.1.219 (Claude Code)")).toEqual([2, 1, 219]);
});
});
describe("findClaudeModel", () => {
it("resolves runtime model IDs to catalog entries", () => {
expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5");

View File

@@ -17,8 +17,8 @@ const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
export function getClaudeModels(): AgentModelDefinition[] {
return getClaudeManifestModels();
export function getClaudeModels(claudeCodeVersion?: string): AgentModelDefinition[] {
return getClaudeManifestModels(claudeCodeVersion);
}
export function findClaudeModel(
@@ -34,8 +34,9 @@ export function findClaudeModel(
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir?: string,
claudeCodeVersion?: string,
): Promise<AgentModelDefinition[]> {
const hardcodedModels = getClaudeModels();
const hardcodedModels = getClaudeModels(claudeCodeVersion);
const settingsModels = await readClaudeSettingsModels(logger, configDir);
if (settingsModels.length === 0) {
return hardcodedModels;

View File

@@ -277,6 +277,35 @@ describe("OMP agent client and session", () => {
]);
});
test("renders a live system-notice custom message as a synthetic tool call", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.runPrompt("hello OMP", "done");
omp
.runtime()
.acceptCustomMessage(
[
"<system-notice>",
"Background job DocsSmokeTwo has completed.",
'<task-result id="DocsSmokeTwo" agent="explore" status="completed" duration="21.6s">',
"<output>done</output>",
"</task-result>",
"</system-notice>",
].join("\n"),
);
omp.runtime().acceptCustomMessage("plain custom status text");
expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([
{ callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" },
]);
// Non-notice custom messages still fall through as assistant messages.
expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([
{ text: "done" },
{ text: "plain custom status text" },
]);
});
test("does not complete a queued model turn from OMP's local-only hint", async () => {
const omp = new OmpHarness();
await omp.start();

View File

@@ -68,6 +68,7 @@ export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-c
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
@@ -2068,12 +2069,14 @@ export class OmpAgentSession implements AgentSession {
if (shouldDisplayOmpCustomMessage(event.message)) {
const text = getUserMessageText(event.message.content);
if (text) {
const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text);
const item =
mapOmpAdvisorMessageToToolCall(event.message, text) ??
mapOmpSystemNoticeToToolCall(text);
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: advisorItem ?? { type: "assistant_message", text },
item: item ?? { type: "assistant_message", text },
});
}
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import * as QRCode from "qrcode";
import { renderPairingQr } from "./pairing-qr.js";
const ESCAPE = String.fromCharCode(0x1b);
const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-9;]*m`, "g");
describe("renderPairingQr", () => {
it("renders a theme-independent QR code with a four-module quiet zone", async () => {
const url = "https://app.paseo.sh/#offer=test-pairing-offer";
const qr = await renderPairingQr(url);
const styledLines = qr.split("\n");
const visibleLines = qr.replace(ANSI_PATTERN, "").split("\n");
const moduleCount = QRCode.create(url).modules.size;
expect(
styledLines.every(
(line) => line.startsWith(`${ESCAPE}[47m${ESCAPE}[30m`) && line.endsWith(`${ESCAPE}[0m`),
),
).toBe(true);
expect(visibleLines.every((line) => line.length === moduleCount + 8)).toBe(true);
expect(visibleLines.slice(0, 2).every((line) => line.trim() === "")).toBe(true);
expect(visibleLines.slice(-2).every((line) => line.trim() === "")).toBe(true);
expect(visibleLines.every((line) => line.startsWith(" ") && line.endsWith(" "))).toBe(
true,
);
});
});

View File

@@ -1,50 +1,18 @@
import * as QRCode from "qrcode";
import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode";
import type { Logger } from "pino";
import type { QRCodeToStringOptionsOther } from "qrcode";
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
return undefined;
}
function shouldPrintPairingQr(): boolean {
const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR);
if (env !== undefined) return env;
return process.stdout.isTTY ?? false;
}
const BLACK_ON_WHITE = "\u001b[47m\u001b[30m";
const RESET_COLORS = "\u001b[0m";
export async function renderPairingQr(url: string): Promise<string> {
const terminalOptions: QRCodeToStringOptionsTerminal = {
type: "terminal",
small: true,
};
const utf8Options: QRCodeToStringOptionsOther = {
type: "utf8",
margin: 4,
};
try {
return await QRCode.toString(url, terminalOptions);
} catch {
return await QRCode.toString(url, utf8Options);
}
}
export async function printPairingQrIfEnabled(args: {
url: string;
logger?: Logger;
}): Promise<void> {
if (!shouldPrintPairingQr()) return;
const qr = await renderPairingQr(args.url);
const out = `\nScan to pair:\n${qr}\n${args.url}\n`;
try {
process.stdout.write(out);
} catch (error) {
args.logger?.debug({ error }, "Failed to print pairing QR");
}
const qr = await QRCode.toString(url, utf8Options);
return qr
.split("\n")
.map((line) => `${BLACK_ON_WHITE}${line}${RESET_COLORS}`)
.join("\n");
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.2.0-beta.4",
"version": "0.2.2",
"private": true,
"type": "module",
"scripts": {

84
public-docs/codex.md Normal file
View File

@@ -0,0 +1,84 @@
---
title: Codex
description: Run Codex in Paseo using the official Codex CLI and your existing OpenAI account.
nav: Codex
order: 24
category: Providers
---
# Codex
Paseo runs Codex through the official `codex` CLI and its app-server interface.
## Does Codex cost extra in Paseo?
No. Paseo does not add a charge for Codex. Sign in to the Codex CLI with ChatGPT to use the access included with your ChatGPT plan, or sign in with an API key for usage billed through your OpenAI Platform account.
Your plan's normal Codex limits and OpenAI's standard API pricing still apply.
## Getting started
Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli) on the machine running Paseo:
```bash
npm install -g @openai/codex
```
Sign in with ChatGPT for subscription access:
```bash
codex login
```
Or sign in with an API key for usage billed through your OpenAI Platform account:
```bash
# macOS or Linux
printenv OPENAI_API_KEY | codex login --with-api-key
```
```powershell
# Windows PowerShell
$env:OPENAI_API_KEY | codex login --with-api-key
```
Then confirm the CLI starts:
```bash
codex
```
Paseo uses this installation and its existing authentication when you start a Codex agent.
## Codex is missing in Paseo
The ChatGPT desktop app and the Codex CLI are separate installs. Installing the desktop app does not make the `codex` command available to Paseo.
Check whether the CLI is on your `PATH`:
```bash
# macOS or Linux
which -a codex
# Windows
where.exe codex
```
If the command is not found:
1. Install the [Codex CLI](https://learn.chatgpt.com/docs/codex/cli).
2. Sign in with ChatGPT or an API key using the commands above. See [Codex authentication](https://learn.chatgpt.com/docs/auth).
3. Restart Paseo if its daemon was already running when you installed the CLI.
4. In Paseo, open **Settings → Providers → Codex** and select **Refresh**.
The provider should become available once Paseo can find and start the `codex` command.
## Use Codex in the Paseo terminal
Codex also works inside the Paseo terminal. Open a terminal in your workspace and run `codex` for the standard CLI experience while keeping access to your workspace, git changes, and other Paseo tools.
## See also
- [Supported providers](/docs/supported-providers), for other agents you can run alongside Codex.
- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Codex profiles.
- [Paseo vs Codex app](/alternatives/codex-app), for a feature comparison.

View File

@@ -15,7 +15,7 @@ For the concept and how Paseo manages providers, see [Providers](/docs/providers
Work out of the box once the underlying CLI is installed and authenticated.
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Anthropic's coding agent with MCP support, streaming, and deep reasoning.
- [Codex CLI](https://github.com/openai/codex). OpenAI's workspace agent with sandbox controls and optional network access.
- [Codex](/docs/codex). OpenAI's workspace agent with sandbox controls and optional network access.
- [OpenCode](https://opencode.ai/). Open-source coding assistant with multi-provider model support.
- [pi](https://github.com/svkozak/pi-acp). Minimal terminal-based coding agent with multi-provider LLM support.