mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
c3313ff82a | ||
|
|
1182092b94 | ||
|
|
8d1fb45f73 | ||
|
|
8f7de021f4 | ||
|
|
65573499af | ||
|
|
9154f8fc4d | ||
|
|
3d3e327378 | ||
|
|
42cfed514c | ||
|
|
99114ddd11 | ||
|
|
e73c332e4f | ||
|
|
1594e41602 | ||
|
|
4896cfe970 | ||
|
|
dcffe46f90 | ||
|
|
7b4b42068c | ||
|
|
100502ae1e | ||
|
|
178b4cd618 | ||
|
|
9a6a8ce497 | ||
|
|
325ab500b3 | ||
|
|
60bbfdde25 | ||
|
|
71ce95de90 | ||
|
|
255c2665cc | ||
|
|
176942bbf1 | ||
|
|
1ae2f9280e | ||
|
|
49402b854f | ||
|
|
9476b3fc1d | ||
|
|
29037abd54 | ||
|
|
23ffec5072 | ||
|
|
1e64dd5509 | ||
|
|
cacbbf4053 | ||
|
|
4ca04fc661 | ||
|
|
f924d33076 | ||
|
|
763cfa9333 | ||
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 |
5
.github/workflows/desktop-release.yml
vendored
5
.github/workflows/desktop-release.yml
vendored
@@ -130,6 +130,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.45 - 2026-04-04
|
||||
|
||||
### Added
|
||||
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
||||
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
||||
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
||||
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
||||
- Daemon status dialog in desktop settings for quick health checks.
|
||||
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
||||
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||
|
||||
### Improved
|
||||
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||
|
||||
### Fixed
|
||||
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||
- Pinned archived agents are no longer pruned when closing tabs.
|
||||
- OpenCode event stream starvation during slash command execution.
|
||||
- Duplicate workspaces when multiple git worktrees share the same root.
|
||||
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
||||
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
||||
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
||||
- Input event listener race condition in the web scrollbar hook.
|
||||
- Open-project screen content now vertically centered.
|
||||
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
||||
|
||||
## 0.1.44 - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||
- Disabled claude-acp and copilot providers from the agent registry.
|
||||
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
||||
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
||||
|
||||
## 0.1.43 - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
20
SECURITY.md
20
SECURITY.md
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
@@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages** — Each session derives fresh encryption keys
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
|
||||
### Trust model
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||
|
||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
## DNS rebinding protection
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
206
docs/MOBILE_TESTING.md
Normal file
206
docs/MOBILE_TESTING.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Mobile Testing
|
||||
|
||||
## Maestro
|
||||
|
||||
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||
|
||||
Run a flow:
|
||||
|
||||
```bash
|
||||
maestro test packages/app/maestro/my-flow.yaml
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||
|
||||
```bash
|
||||
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||
mkdir -p /tmp/maestro-out
|
||||
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||
```
|
||||
|
||||
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||
|
||||
### Element targeting
|
||||
|
||||
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||
|
||||
```tsx
|
||||
// Component
|
||||
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Flow
|
||||
- tapOn:
|
||||
id: "sidebar-sessions"
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
```
|
||||
|
||||
### Conditional steps
|
||||
|
||||
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||
|
||||
```yaml
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
```
|
||||
|
||||
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||
|
||||
### Don't use launchApp against a running dev app
|
||||
|
||||
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||
|
||||
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||
|
||||
### Swipe gestures
|
||||
|
||||
Use `start`/`end` with percentage coordinates for precise control:
|
||||
|
||||
```yaml
|
||||
# Edge swipe from left to open sidebar
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
```
|
||||
|
||||
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||
|
||||
### Assertions
|
||||
|
||||
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||
|
||||
For async elements, use `extendedWaitUntil`:
|
||||
|
||||
```yaml
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Online.*"
|
||||
timeout: 90000
|
||||
```
|
||||
|
||||
### Dev client handling
|
||||
|
||||
Two reusable flows handle Expo dev client screens after launch:
|
||||
|
||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||
|
||||
## Self-verification loops
|
||||
|
||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||
|
||||
This pattern also lets agents self-verify fixes without manual user testing.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Run baseline Maestro flow (confirm feature works)
|
||||
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||
3. Re-run Maestro flow (confirm feature still works)
|
||||
4. Repeat N iterations to catch intermittent failures
|
||||
|
||||
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||
|
||||
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||
```
|
||||
|
||||
Key elements of the script pattern:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
ITERATIONS="${1:-3}"
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
# Toggle system state
|
||||
xcrun simctl ui booted appearance light
|
||||
|
||||
# Wait for change to propagate
|
||||
sleep 1
|
||||
|
||||
# Run Maestro flow and capture result
|
||||
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
|
||||
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||
|
||||
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||
|
||||
### The fix
|
||||
|
||||
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||
|
||||
```tsx
|
||||
// BAD: Unistyles dynamic style on Animated.View
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||
```
|
||||
|
||||
```tsx
|
||||
// GOOD: static stylesheet + inline theme values
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<Animated.View
|
||||
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
/>
|
||||
```
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
# Screenshot
|
||||
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||
|
||||
# Dark/light mode
|
||||
xcrun simctl ui booted appearance # check current
|
||||
xcrun simctl ui booted appearance dark # set dark
|
||||
xcrun simctl ui booted appearance light # set light
|
||||
```
|
||||
|
||||
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||
@@ -122,8 +122,34 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
|
||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||
|
||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-0fzdnz2LQ0IRk2wbe0/wORylp7mgU0gl2fAs8my4Eok=";
|
||||
npmDepsHash = "sha256-KBkXlFzYesnNdRRchNzJtrDGQUsIFv9hYYf1TBnfWSA=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
99
package-lock.json
generated
99
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -3519,6 +3519,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -15434,6 +15441,24 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
@@ -28239,6 +28264,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pi-acp": {
|
||||
"version": "0.0.24",
|
||||
"resolved": "https://registry.npmjs.org/pi-acp/-/pi-acp-0.0.24.tgz",
|
||||
"integrity": "sha512-iFoQLH9nd3e2fpvemFV/0SUPeT9ecGHyhBiAe1JW7kHBWfmBQREeRn7O+hQyWA7heMVv+C9CObk4HDLVRy7JaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"bin": {
|
||||
"pi-acp": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-acp/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz",
|
||||
"integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pi-acp/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -34971,16 +35030,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.45",
|
||||
"@getpaseo/highlight": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35097,11 +35156,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35142,11 +35201,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/cli": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35180,7 +35239,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35381,7 +35440,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35407,7 +35466,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35423,14 +35482,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.45",
|
||||
"@getpaseo/relay": "0.1.45",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35446,6 +35505,7 @@
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pi-acp": "^0.0.24",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -35468,6 +35528,7 @@
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.8",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"playwright": "^1.56.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
@@ -35828,7 +35889,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
2
packages/app/maestro/.gitignore
vendored
Normal file
2
packages/app/maestro/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Maestro takeScreenshot artifacts
|
||||
*.png
|
||||
31
packages/app/maestro/sidebar-theme-repro.yaml
Normal file
31
packages/app/maestro/sidebar-theme-repro.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
appId: sh.paseo
|
||||
---
|
||||
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
|
||||
# Small pause for close animation
|
||||
- takeScreenshot: 00-sidebar-closed
|
||||
|
||||
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
|
||||
# Verify sidebar opened
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
- takeScreenshot: 01-sidebar-opened
|
||||
|
||||
# Close sidebar via swipe left
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
- takeScreenshot: 02-sidebar-closed-again
|
||||
62
packages/app/maestro/test-sidebar-theme.sh
Executable file
62
packages/app/maestro/test-sidebar-theme.sh
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verification loop for sidebar theme bug.
|
||||
#
|
||||
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
||||
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
||||
# still works. Runs N iterations to catch intermittent failures.
|
||||
#
|
||||
# Usage:
|
||||
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
||||
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
||||
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
||||
ITERATIONS="${1:-3}"
|
||||
WAIT_SECS="${2:-1}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "=== Sidebar Theme Bug Verification ==="
|
||||
echo "Output dir: $OUT_DIR"
|
||||
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
||||
|
||||
FAILURES=0
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
echo ""
|
||||
echo "========== Iteration $i / $ITERATIONS =========="
|
||||
|
||||
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
||||
echo "Current appearance: $CURRENT"
|
||||
|
||||
if [ "$CURRENT" = "dark" ]; then
|
||||
xcrun simctl ui booted appearance light
|
||||
echo "Switched to light mode"
|
||||
else
|
||||
xcrun simctl ui booted appearance dark
|
||||
echo "Switched to dark mode"
|
||||
fi
|
||||
|
||||
echo "Waiting ${WAIT_SECS}s..."
|
||||
sleep "$WAIT_SECS"
|
||||
|
||||
ITER_DIR="$OUT_DIR/iter-$i"
|
||||
mkdir -p "$ITER_DIR"
|
||||
|
||||
# Run maestro from the output dir so takeScreenshot artifacts land there
|
||||
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||
echo " -> PASS (iteration $i)"
|
||||
else
|
||||
echo " -> FAIL (iteration $i) — bug reproduced!"
|
||||
FAILURES=$((FAILURES + 1))
|
||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Restore to dark mode
|
||||
xcrun simctl ui booted appearance dark
|
||||
|
||||
echo ""
|
||||
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
||||
echo "Output: $OUT_DIR"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.45",
|
||||
"@getpaseo/highlight": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { loadSettingsFromStorage } from "@/hooks/use-settings";
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
ensureOsNotificationPermission,
|
||||
} from "@/utils/os-notifications";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { buildNotificationRoute } from "@/utils/notification-routing";
|
||||
@@ -230,6 +232,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cancelAnyOnline: (() => void) | null = null;
|
||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||
const store = getHostRuntimeStore();
|
||||
|
||||
@@ -240,28 +243,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
if (isDesktopManaged) {
|
||||
setPhase("starting-daemon");
|
||||
setError(null);
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
if (!cancelled) {
|
||||
setPhase("error");
|
||||
setError(bootstrapResult.error);
|
||||
|
||||
let raceSettled = false;
|
||||
|
||||
const anyOnline = store.waitForAnyConnectionOnline();
|
||||
cancelAnyOnline = anyOnline.cancel;
|
||||
|
||||
const bootstrapPromise = (async (): Promise<
|
||||
{ type: "online" } | { type: "error"; error: string }
|
||||
> => {
|
||||
try {
|
||||
const bootstrapResult = await store.bootstrapDesktop();
|
||||
if (!bootstrapResult.ok) {
|
||||
return { type: "error", error: bootstrapResult.error };
|
||||
}
|
||||
if (!cancelled && !raceSettled) {
|
||||
setPhase("connecting");
|
||||
}
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
return { type: "online" };
|
||||
} catch (err) {
|
||||
return {
|
||||
type: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const result = await Promise.race([
|
||||
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
||||
bootstrapPromise,
|
||||
]);
|
||||
|
||||
raceSettled = true;
|
||||
anyOnline.cancel();
|
||||
|
||||
setPhase("connecting");
|
||||
await store.addConnectionFromListenAndWaitForOnline({
|
||||
listenAddress: bootstrapResult.listenAddress,
|
||||
serverId: bootstrapResult.serverId,
|
||||
hostname: bootstrapResult.hostname,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
if (result.type === "online") {
|
||||
setPhase("online");
|
||||
setError(null);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setError(result.error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||
@@ -288,6 +316,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnyOnline?.();
|
||||
};
|
||||
}, [retryToken]);
|
||||
|
||||
@@ -393,14 +422,28 @@ function MobileGestureWrapper({
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useSidebarAnimation();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
openAgentList();
|
||||
}, [openAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(openGestureEnabled)
|
||||
.manualActivation(true)
|
||||
.failOffsetY([-10, 10])
|
||||
@@ -443,7 +486,7 @@ function MobileGestureWrapper({
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(openAgentList)();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
@@ -458,8 +501,9 @@ function MobileGestureWrapper({
|
||||
backdropOpacity,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
openAgentList,
|
||||
handleGestureOpen,
|
||||
isGesturing,
|
||||
openGestureRef,
|
||||
horizontalScroll?.isAnyScrolledRight,
|
||||
touchStartX,
|
||||
],
|
||||
@@ -555,6 +599,84 @@ function OfferLinkListener({
|
||||
return null;
|
||||
}
|
||||
|
||||
interface OpenProjectEventPayload {
|
||||
path?: unknown;
|
||||
}
|
||||
|
||||
function OpenProjectListener() {
|
||||
const hosts = useHosts();
|
||||
const serverId = hosts[0]?.serverId ?? null;
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const openProject = useOpenProject(serverId);
|
||||
const pendingPathRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
const maybeOpenProject = (inputPath: string) => {
|
||||
const nextPath = inputPath.trim();
|
||||
if (!nextPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPathRef.current = nextPath;
|
||||
|
||||
if (!serverId || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathToOpen = pendingPathRef.current;
|
||||
pendingPathRef.current = null;
|
||||
if (!pathToOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void openProject(pathToOpen).catch(() => undefined);
|
||||
};
|
||||
|
||||
// Pull any path that was passed on cold start (before the listener existed).
|
||||
// Store in the ref even if this effect instance is disposed — the next
|
||||
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
|
||||
void getDesktopHost()
|
||||
?.getPendingOpenProject?.()
|
||||
?.then((pending) => {
|
||||
if (pending) {
|
||||
pendingPathRef.current = pending;
|
||||
}
|
||||
if (!disposed && pending) {
|
||||
maybeOpenProject(pending);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
// Listen for hot-start paths relayed via the second-instance event.
|
||||
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const nextPath = typeof payload?.path === "string" ? payload.path.trim() : "";
|
||||
maybeOpenProject(nextPath);
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unlisten = dispose;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
maybeOpenProject(pendingPathRef.current ?? "");
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [client, openProject, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -674,6 +796,7 @@ export default function RootLayout() {
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<ToastProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature metadata helpers", () => {
|
||||
it("prefers explicit feature tooltip copy", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Plan",
|
||||
tooltip: "Toggle plan mode",
|
||||
}),
|
||||
).toBe("Toggle plan mode");
|
||||
});
|
||||
|
||||
it("falls back to the feature label when no tooltip is provided", () => {
|
||||
expect(
|
||||
getFeatureTooltip({
|
||||
label: "Custom",
|
||||
}),
|
||||
).toBe("Custom");
|
||||
});
|
||||
|
||||
it("maps feature highlight colors by feature id", () => {
|
||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeModelId", () => {
|
||||
it("treats empty values as unset", () => {
|
||||
expect(normalizeModelId("")).toBeNull();
|
||||
|
||||
@@ -3,7 +3,16 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
|
||||
import {
|
||||
Brain,
|
||||
ChevronDown,
|
||||
ListTodo,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -24,6 +33,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
@@ -36,6 +46,8 @@ import {
|
||||
type AgentModeIcon,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
@@ -46,7 +58,7 @@ type StatusOption = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
@@ -73,6 +85,8 @@ type ControlledAgentStatusBarProps = {
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -92,6 +106,8 @@ export interface DraftAgentStatusBarProps {
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
selectedThinkingOptionId: string;
|
||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -112,6 +128,38 @@ function findOptionLabel(
|
||||
return selected?.label ?? fallback;
|
||||
}
|
||||
|
||||
const FEATURE_ICONS: Record<string, typeof Zap> = {
|
||||
"list-todo": ListTodo,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
function getFeatureIcon(icon?: string) {
|
||||
return (icon && FEATURE_ICONS[icon]) || Settings2;
|
||||
}
|
||||
|
||||
function getFeatureIconColor(
|
||||
featureId: string,
|
||||
enabled: boolean,
|
||||
palette: {
|
||||
blue: { 400: string };
|
||||
yellow: { 400: string };
|
||||
},
|
||||
foregroundMuted: string,
|
||||
): string {
|
||||
if (!enabled) {
|
||||
return foregroundMuted;
|
||||
}
|
||||
|
||||
switch (getFeatureHighlightColor(featureId)) {
|
||||
case "blue":
|
||||
return palette.blue[400];
|
||||
case "yellow":
|
||||
return palette.yellow[400];
|
||||
default:
|
||||
return foregroundMuted;
|
||||
}
|
||||
}
|
||||
|
||||
const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
@@ -162,6 +210,8 @@ function ControlledStatusBar({
|
||||
canSelectModelProvider,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavoriteModel,
|
||||
features,
|
||||
onSetFeature,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -203,7 +253,8 @@ function ControlledStatusBar({
|
||||
Boolean(providerOptions?.length) ||
|
||||
Boolean(modeOptions?.length) ||
|
||||
canSelectModel ||
|
||||
Boolean(thinkingOptions?.length);
|
||||
Boolean(thinkingOptions?.length) ||
|
||||
Boolean(features?.length);
|
||||
|
||||
if (!hasAnyControl) {
|
||||
return null;
|
||||
@@ -455,6 +506,107 @@ function ControlledStatusBar({
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{features?.map((feature) => {
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
return (
|
||||
<Tooltip
|
||||
key={`feature-${feature.id}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
pressed && styles.modeBadgePressed,
|
||||
disabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
/>
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (feature.type === "select") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
return (
|
||||
<DropdownMenu
|
||||
key={`feature-${feature.id}`}
|
||||
open={openSelector === `feature-${feature.id}`}
|
||||
onOpenChange={(open) =>
|
||||
setOpenSelector(open ? `feature-${feature.id}` : null)
|
||||
}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === `feature-${feature.id}`) &&
|
||||
styles.modeBadgePressed,
|
||||
disabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{selectedOption?.label ?? feature.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{feature.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
selected={option.id === feature.value}
|
||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -593,6 +745,85 @@ function ControlledStatusBar({
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{features?.map((feature) => {
|
||||
if (feature.type === "toggle") {
|
||||
const FeatureIcon = getFeatureIcon(feature.icon);
|
||||
return (
|
||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={() => onSetFeature?.(feature.id, !feature.value)}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
disabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<FeatureIcon
|
||||
size={theme.iconSize.md}
|
||||
color={getFeatureIconColor(
|
||||
feature.id,
|
||||
feature.value,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
/>
|
||||
<Text style={styles.sheetSelectText}>{feature.label}</Text>
|
||||
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (feature.type === "select") {
|
||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
||||
return (
|
||||
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === `feature-${feature.id}`}
|
||||
onOpenChange={(open) =>
|
||||
setOpenSelector(open ? `feature-${feature.id}` : null)
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
disabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>
|
||||
{selectedOption?.label ?? feature.label}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{feature.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
selected={option.id === feature.value}
|
||||
onSelect={() => onSetFeature?.(feature.id, option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
)}
|
||||
@@ -614,6 +845,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
currentModeId: currentAgent.currentModeId,
|
||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||
model: currentAgent.model,
|
||||
features: currentAgent.features,
|
||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||
}
|
||||
: null;
|
||||
@@ -782,6 +1014,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
features={agent.features}
|
||||
onSetFeature={(featureId, value) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -805,6 +1046,8 @@ export function DraftAgentStatusBar({
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption,
|
||||
features,
|
||||
onSetFeature,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -858,6 +1101,8 @@ export function DraftAgentStatusBar({
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
@@ -890,6 +1135,8 @@ export function DraftAgentStatusBar({
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
|
||||
export type FeatureHighlightColor = "blue" | "default" | "yellow";
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
||||
return feature.tooltip ?? feature.label;
|
||||
}
|
||||
|
||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||
switch (featureId) {
|
||||
case "fast_mode":
|
||||
return "yellow";
|
||||
case "plan_mode":
|
||||
return "blue";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentModelSelection(input: {
|
||||
models: AgentModelDefinition[] | null;
|
||||
runtimeModelId: string | null | undefined;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
} from "./message";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
@@ -59,9 +59,7 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -252,10 +250,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
if (
|
||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||
isToolSequenceItem(belowItem)
|
||||
) {
|
||||
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||
@@ -368,7 +363,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
);
|
||||
|
||||
case "thought": {
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
@@ -755,90 +749,6 @@ function PermissionRequestCard({
|
||||
return undefined;
|
||||
}, [request]);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
return {
|
||||
text: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
textgroup: (
|
||||
node: any,
|
||||
children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
code_block: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
fence: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: React.ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const permissionMutation = useMutation({
|
||||
mutationFn: async (input: {
|
||||
agentId: string;
|
||||
@@ -891,50 +801,8 @@ function PermissionRequestCard({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
permissionStyles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
|
||||
|
||||
{description ? (
|
||||
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{planMarkdown ? (
|
||||
<View style={permissionStyles.section}>
|
||||
{!isPlanRequest ? (
|
||||
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
|
||||
Proposed plan
|
||||
</Text>
|
||||
) : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{planMarkdown}
|
||||
</Markdown>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
detail={
|
||||
request.detail ?? {
|
||||
type: "unknown",
|
||||
input: request.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
}
|
||||
maxHeight={200}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Text
|
||||
testID="permission-request-question"
|
||||
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
|
||||
@@ -1007,6 +875,55 @@ function PermissionRequestCard({
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isPlanRequest && planMarkdown) {
|
||||
return (
|
||||
<PlanCard
|
||||
title={title}
|
||||
description={description}
|
||||
text={planMarkdown}
|
||||
footer={footer}
|
||||
disableOuterSpacing
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
permissionStyles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
|
||||
|
||||
{description ? (
|
||||
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
|
||||
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
detail={
|
||||
request.detail ?? {
|
||||
type: "unknown",
|
||||
input: request.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
}
|
||||
maxHeight={200}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{footer}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
|
||||
});
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)("swipe-close-gesture");
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
|
||||
backdropOpacity,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleClose,
|
||||
handleCloseFromGesture,
|
||||
isGesturing,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable
|
||||
style={styles.backdropPressable}
|
||||
onPress={() => handleClose("backdrop-press")}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top },
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
]}
|
||||
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("desktop-close-button")}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={() => handleClose("desktop-close-button")}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -400,24 +410,28 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebarBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
19
packages/app/src/components/icons/pi-icon.tsx
Normal file
19
packages/app/src/components/icons/pi-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface PiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function PiIcon({ size = 16, color = "currentColor" }: PiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 800 800" fill={color}>
|
||||
<Path
|
||||
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
|
||||
fill={color}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<Path d="M517.36 400 H634.72 V634.72 H517.36 Z" fill={color} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -370,14 +377,16 @@ function MobileSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
@@ -452,7 +461,7 @@ function MobileSidebar({
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
animateToClose();
|
||||
runOnJS(handleClose)();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
animateToOpen();
|
||||
}
|
||||
@@ -471,7 +480,7 @@ function MobileSidebar({
|
||||
backdropOpacity,
|
||||
animateToClose,
|
||||
animateToOpen,
|
||||
handleClose,
|
||||
handleCloseFromGesture,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -498,13 +507,11 @@ function MobileSidebar({
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
||||
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -526,7 +533,7 @@ function MobileSidebar({
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={closeToAgent}
|
||||
onWorkspacePress={() => closeToAgent()}
|
||||
onAddProject={handleOpenProject}
|
||||
parentGestureRef={closeGestureRef}
|
||||
/>
|
||||
@@ -688,7 +695,8 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
@@ -796,32 +804,37 @@ function DesktopSidebar({
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...RNStyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
backdropPressable: {
|
||||
flex: 1,
|
||||
},
|
||||
mobileSidebar: {
|
||||
position: "absolute",
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
overflow: "hidden",
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
desktopSidebarBorder: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -1884,6 +1885,12 @@ export const ToolCall = memo(function ToolCall({
|
||||
);
|
||||
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
|
||||
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
|
||||
154
packages/app/src/components/plan-card.tsx
Normal file
154
packages/app/src/components/plan-card.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
|
||||
function createPlanMarkdownRules() {
|
||||
return {
|
||||
text: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
textgroup: (
|
||||
node: any,
|
||||
children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
|
||||
{children}
|
||||
</Text>
|
||||
),
|
||||
code_block: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
fence: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
code_inline: (
|
||||
node: any,
|
||||
_children: ReactNode[],
|
||||
_parent: any,
|
||||
styles: any,
|
||||
inheritedStyles: any = {},
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanCard({
|
||||
title = "Plan",
|
||||
description,
|
||||
text,
|
||||
footer,
|
||||
disableOuterSpacing = false,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
text: string;
|
||||
footer?: ReactNode;
|
||||
disableOuterSpacing?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const markdownStyles = createMarkdownStyles(theme);
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
disableOuterSpacing && styles.containerCompact,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{text}
|
||||
</Markdown>
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginVertical: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
containerCompact: {
|
||||
marginVertical: 0,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
description: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
@@ -3,13 +3,14 @@ import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
"claude-acp": ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
copilot: CopilotIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
pi: PiIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
@@ -53,13 +54,7 @@ import {
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -105,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
};
|
||||
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return theme.colors.palette.purple[500];
|
||||
case "open":
|
||||
return theme.colors.palette.green[500];
|
||||
case "closed":
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
projects: SidebarProjectEntry[];
|
||||
@@ -145,6 +145,8 @@ interface ProjectHeaderRowProps {
|
||||
isDragging: boolean;
|
||||
isArchiving?: boolean;
|
||||
menuController: ReturnType<typeof useContextMenu> | null;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
@@ -172,7 +174,8 @@ interface WorkspaceRowInnerProps {
|
||||
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconColor = getWorkspacePrIconColor(theme, hint.state);
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -200,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
pressed && styles.workspacePrBadgePressed,
|
||||
]}
|
||||
>
|
||||
<GitPullRequest size={12} color={activeColor} />
|
||||
<GitPullRequest size={12} color={iconColor} />
|
||||
<Text
|
||||
style={[
|
||||
styles.workspacePrBadgeText,
|
||||
{ color: activeColor },
|
||||
{ color: textColor },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
|
||||
#{hint.number}
|
||||
</Text>
|
||||
{isHovered && <ExternalLink size={10} color={activeColor} />}
|
||||
{isHovered && <ExternalLink size={10} color={textColor} />}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -704,8 +707,11 @@ function ProjectHeaderRow({
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
onRemoveProject,
|
||||
removeProjectStatus = "idle",
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint = isCompactFormFactor();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
@@ -792,16 +798,55 @@ function ProjectHeaderRow({
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
<View
|
||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.projectKebabButton,
|
||||
hovered && styles.projectKebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${project.projectKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${project.projectKey}`}
|
||||
leading={<Trash2 size={14} color={theme.colors.foregroundMuted} />}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
@@ -1209,160 +1254,6 @@ function WorkspaceRowWithMenu({
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenuContent({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
activeWorkspaceSelection?.serverId !== workspace.serverId ||
|
||||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
redirectAfterArchive();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
isArchivingWorkspace,
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron={null}
|
||||
onPress={onPress}
|
||||
serverId={null}
|
||||
canCreateWorktree={false}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
menuController={contextMenu}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
mobileMode="sheet"
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
|
||||
>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
|
||||
status={isArchivingWorkspace ? "pending" : "idle"}
|
||||
pendingLabel="Hiding..."
|
||||
destructive
|
||||
onSelect={handleArchiveWorkspace}
|
||||
>
|
||||
Hide from sidebar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NonGitProjectRowWithMenu(props: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
iconDataUri: string | null;
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<NonGitProjectRowWithMenuContent {...props} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FlattenedProjectRow({
|
||||
project,
|
||||
displayName,
|
||||
@@ -1378,6 +1269,8 @@ function FlattenedProjectRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
isProjectActive = false,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1393,25 +1286,9 @@ function FlattenedProjectRow({
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
@@ -1431,6 +1308,8 @@ function FlattenedProjectRow({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
menuController={null}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
);
|
||||
@@ -1601,6 +1480,48 @@ function ProjectBlock({
|
||||
[onWorkspaceReorder, project.projectKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
|
||||
const handleRemoveProject = useCallback(() => {
|
||||
if (isRemovingProject || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Remove project?",
|
||||
message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Remove",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRemovingProject(true);
|
||||
try {
|
||||
for (const ws of project.workspaces) {
|
||||
const payload = await client.archiveWorkspace(ws.workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to remove project");
|
||||
} finally {
|
||||
setIsRemovingProject(false);
|
||||
}
|
||||
})();
|
||||
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{rowModel.kind === "workspace_link" ? (
|
||||
@@ -1625,6 +1546,8 @@ function ProjectBlock({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={isProjectActive}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1643,7 +1566,10 @@ function ProjectBlock({
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
|
||||
@@ -2144,6 +2070,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectKebabButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectKebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
|
||||
@@ -77,7 +77,6 @@ export function useWebElementScrollbar(
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", update, { passive: true });
|
||||
element.addEventListener("input", update, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(element);
|
||||
@@ -90,7 +89,6 @@ export function useWebElementScrollbar(
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", update);
|
||||
element.removeEventListener("input", update);
|
||||
resizeObserver.disconnect();
|
||||
element.removeAttribute("data-hide-scrollbar");
|
||||
(element.style as any).scrollbarWidth = "";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
@@ -118,6 +119,25 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
setupHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing[6],
|
||||
lineHeight: theme.fontSize.sm * 1.5,
|
||||
},
|
||||
setupLink: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
setupLinkText: {
|
||||
color: theme.colors.accent,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
versionLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -326,6 +346,21 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
{!showHostList && Platform.OS !== "web" && (
|
||||
<>
|
||||
<Text style={styles.setupHint}>
|
||||
You need the Paseo desktop app or server running on your computer first.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.setupLink}
|
||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||
>
|
||||
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
@@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const gestureAnimatingRef = useRef(false);
|
||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
return;
|
||||
}
|
||||
|
||||
if (gestureAnimatingRef.current) {
|
||||
gestureAnimatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
|
||||
animateToOpen: () => void;
|
||||
animateToClose: () => void;
|
||||
isGesturing: SharedValue<boolean>;
|
||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
const translateX = useSharedValue(initialTargets.translateX);
|
||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||
const isGesturing = useSharedValue(false);
|
||||
const gestureAnimatingRef = useRef(false);
|
||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||
|
||||
// Track previous isOpen to detect changes
|
||||
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
return;
|
||||
}
|
||||
|
||||
// Gesture onEnd already started the animation on the UI thread — skip to avoid
|
||||
// a second competing withTiming that can desync translateX and backdropOpacity
|
||||
// after a provider remount (e.g. theme change).
|
||||
if (gestureAnimatingRef.current) {
|
||||
gestureAnimatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||
if (isGesturing.value) {
|
||||
return;
|
||||
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
}),
|
||||
[
|
||||
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
closeGestureRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -10,19 +10,19 @@ import {
|
||||
Play,
|
||||
Pause,
|
||||
RotateCw,
|
||||
Terminal,
|
||||
Copy,
|
||||
FileText,
|
||||
Smartphone,
|
||||
Activity,
|
||||
} from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliSymlinkInstructions,
|
||||
getCliDaemonStatus,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonPairing,
|
||||
getDesktopDaemonStatus,
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
type CliSymlinkInstructions,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopPairingOffer,
|
||||
@@ -50,28 +49,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusMessage, setCliStatusMessage] = useState<string | null>(null);
|
||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
|
||||
const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false);
|
||||
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
|
||||
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
|
||||
const [cliSymlinkInstructions, setCliSymlinkInstructions] =
|
||||
useState<CliSymlinkInstructions | null>(null);
|
||||
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
|
||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||
|
||||
const loadDaemonData = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()])
|
||||
.then(([status, logs, version]) => {
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setDaemonStatus(status);
|
||||
setDaemonLogs(logs);
|
||||
setDaemonVersion(version.version);
|
||||
setDaemonVersion(status.version);
|
||||
setStatusError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -218,40 +215,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
updateSettings,
|
||||
]);
|
||||
|
||||
const handleOpenCliSymlinkInstructions = useCallback(() => {
|
||||
if (!showSection || isLoadingCliSymlinkInstructions) {
|
||||
return;
|
||||
}
|
||||
setIsLoadingCliSymlinkInstructions(true);
|
||||
setCliStatusMessage(null);
|
||||
void getCliSymlinkInstructions()
|
||||
.then((instructions) => {
|
||||
setCliSymlinkInstructions(instructions);
|
||||
setIsCliSymlinkModalOpen(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCliSymlinkInstructions(false);
|
||||
});
|
||||
}, [isLoadingCliSymlinkInstructions, showSection]);
|
||||
|
||||
const handleCopyCliSymlinkCommands = useCallback(() => {
|
||||
if (!cliSymlinkInstructions?.commands) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(cliSymlinkInstructions.commands)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "CLI symlink commands copied.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy CLI symlink commands", error);
|
||||
Alert.alert("Error", "Unable to copy CLI symlink commands.");
|
||||
});
|
||||
}, [cliSymlinkInstructions?.commands]);
|
||||
|
||||
const handleCopyLogPath = useCallback(() => {
|
||||
const logPath = daemonLogs?.logPath;
|
||||
if (!logPath) {
|
||||
@@ -315,20 +278,47 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
});
|
||||
}, [pairingOffer?.url]);
|
||||
|
||||
const handleOpenCliStatus = useCallback(async () => {
|
||||
setIsLoadingCliStatus(true);
|
||||
try {
|
||||
setCliStatusOutput(await getCliDaemonStatus());
|
||||
setIsCliStatusModalOpen(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setCliStatusOutput(`Failed to fetch daemon status: ${message}`);
|
||||
setIsCliStatusModalOpen(true);
|
||||
} finally {
|
||||
setIsLoadingCliStatus(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopyCliStatus = useCallback(() => {
|
||||
if (!cliStatusOutput) {
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(cliStatusOutput)
|
||||
.then(() => {
|
||||
Alert.alert("Copied", "Status copied to clipboard.");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to copy daemon status", error);
|
||||
});
|
||||
}, [cliStatusOutput]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Built-in daemon</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Built-in daemon</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={styles.sectionLinkText}
|
||||
style={styles.sectionLink}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL)}
|
||||
accessibilityLabel="Open advanced daemon settings"
|
||||
>
|
||||
@@ -336,10 +326,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Status</Text>
|
||||
<Text style={styles.hintText}>Only the built-in desktop daemon is shown here.</Text>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
@@ -348,10 +338,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
@@ -379,10 +369,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
: "Pause"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
@@ -401,26 +391,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
<Text style={styles.hintText}>Shows the command to add `paseo` to your terminal.</Text>
|
||||
{cliStatusMessage ? <Text style={styles.statusText}>{cliStatusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenCliSymlinkInstructions}
|
||||
disabled={isLoadingCliSymlinkInstructions}
|
||||
>
|
||||
{isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Log file</Text>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
@@ -444,10 +418,10 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Pair device</Text>
|
||||
<Text style={styles.hintText}>Connect your phone to this computer.</Text>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Pair device</Text>
|
||||
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -458,6 +432,23 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
Pair device
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
@@ -469,33 +460,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliSymlinkModalOpen}
|
||||
onClose={() => setIsCliSymlinkModalOpen(false)}
|
||||
title="Add paseo to your shell"
|
||||
testID="managed-daemon-cli-symlink-dialog"
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
Paseo does not add the command for you. Run the command below in your terminal.
|
||||
</Text>
|
||||
{cliSymlinkInstructions?.detail ? (
|
||||
<Text style={styles.hintText}>{cliSymlinkInstructions.detail}</Text>
|
||||
) : null}
|
||||
<Text style={styles.codeBlock} selectable>
|
||||
{cliSymlinkInstructions?.commands ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliSymlinkModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliSymlinkCommands}>
|
||||
Copy commands
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isPairingModalOpen}
|
||||
onClose={() => setIsPairingModalOpen(false)}
|
||||
@@ -518,12 +482,34 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isCliStatusModalOpen}
|
||||
onClose={() => setIsCliStatusModalOpen(false)}
|
||||
title="Daemon status"
|
||||
testID="daemon-cli-status-dialog"
|
||||
snapPoints={["60%", "85%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{cliStatusOutput ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={() => setIsCliStatusModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onPress={handleCopyCliStatus}>
|
||||
Copy
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -581,7 +567,7 @@ function PairingOfferDialogContent(input: {
|
||||
return (
|
||||
<View style={styles.pairingState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.hintText}>Loading pairing offer…</Text>
|
||||
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -589,7 +575,7 @@ function PairingOfferDialogContent(input: {
|
||||
if (statusMessage) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>{statusMessage}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -597,21 +583,21 @@ function PairingOfferDialogContent(input: {
|
||||
if (!pairingOffer?.url) {
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>Pairing offer unavailable.</Text>
|
||||
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.hintText}>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Scan this QR code in Paseo, or copy the pairing link below.
|
||||
</Text>
|
||||
<View style={styles.qrCard}>
|
||||
{qrDataUrl ? (
|
||||
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
|
||||
) : qrError ? (
|
||||
<Text style={styles.hintText}>QR unavailable: {qrError}</Text>
|
||||
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
|
||||
) : (
|
||||
<ActivityIndicator size="small" />
|
||||
)}
|
||||
@@ -632,37 +618,6 @@ function PairingOfferDialogContent(input: {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
actionGroup: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
@@ -673,10 +628,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
valueText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -686,11 +637,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
hintText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
189
packages/app/src/desktop/components/integrations-section.tsx
Normal file
189
packages/app/src/desktop/components/integrations-section.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
getCliInstallStatus,
|
||||
installCli,
|
||||
getSkillsInstallStatus,
|
||||
installSkills,
|
||||
type InstallStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
|
||||
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
|
||||
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
|
||||
const [isInstallingCli, setIsInstallingCli] = useState(false);
|
||||
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
if (!showSection) return;
|
||||
void getCliInstallStatus()
|
||||
.then(setCliStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to load CLI status", error);
|
||||
});
|
||||
void getSkillsInstallStatus()
|
||||
.then(setSkillsStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to load skills status", error);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) return undefined;
|
||||
loadStatus();
|
||||
return undefined;
|
||||
}, [loadStatus, showSection]),
|
||||
);
|
||||
|
||||
const handleInstallCli = useCallback(() => {
|
||||
if (isInstallingCli) return;
|
||||
setIsInstallingCli(true);
|
||||
void installCli()
|
||||
.then(setCliStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to install CLI", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingCli(false);
|
||||
});
|
||||
}, [isInstallingCli]);
|
||||
|
||||
const handleInstallSkills = useCallback(() => {
|
||||
if (isInstallingSkills) return;
|
||||
setIsInstallingSkills(true);
|
||||
void installSkills()
|
||||
.then(setSkillsStatus)
|
||||
.catch((error) => {
|
||||
console.error("[Integrations] Failed to install skills", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsInstallingSkills(false);
|
||||
});
|
||||
}, [isInstallingSkills]);
|
||||
|
||||
if (!showSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Integrations</Text>
|
||||
<View style={styles.headerLinks}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||
accessibilityLabel="Open CLI documentation"
|
||||
>
|
||||
CLI docs
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||
accessibilityLabel="Open skills documentation"
|
||||
>
|
||||
Skills docs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Terminal size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Command line</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Control and script agents from your terminal.
|
||||
</Text>
|
||||
</View>
|
||||
{cliStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallCli}
|
||||
disabled={isInstallingCli}
|
||||
>
|
||||
{isInstallingCli ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<Blocks size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
<Text style={settingsStyles.rowTitle}>Orchestration skills</Text>
|
||||
</View>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Teach your agents to orchestrate through the CLI.
|
||||
</Text>
|
||||
</View>
|
||||
{skillsStatus?.installed ? (
|
||||
<View style={styles.installedLabel}>
|
||||
<Check size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.mutedText}>Installed</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleInstallSkills}
|
||||
disabled={isInstallingSkills}
|
||||
>
|
||||
{isInstallingSkills ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerLinks: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[0],
|
||||
},
|
||||
rowTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
installedLabel: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
mutedText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -10,6 +10,8 @@ export type DesktopDaemonStatus = {
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
home: string;
|
||||
version: string | null;
|
||||
desktopManaged: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
@@ -24,12 +26,6 @@ export type DesktopPairingOffer = {
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
export type CliSymlinkInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
export type LocalTransportTarget = {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
@@ -86,6 +82,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
|
||||
hostname: toStringOrNull(raw.hostname),
|
||||
pid: toNumberOrNull(raw.pid),
|
||||
home: toStringOrNull(raw.home) ?? "",
|
||||
version: toStringOrNull(raw.version),
|
||||
desktopManaged: raw.desktopManaged === true,
|
||||
error: toStringOrNull(raw.error),
|
||||
};
|
||||
}
|
||||
@@ -111,17 +109,6 @@ function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer {
|
||||
};
|
||||
}
|
||||
|
||||
function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null {
|
||||
if (!isRecord(raw)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: toStringOrNull(raw.title) ?? "",
|
||||
detail: toStringOrNull(raw.detail) ?? "",
|
||||
commands: toStringOrNull(raw.commands) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isElectronRuntime();
|
||||
}
|
||||
@@ -150,16 +137,12 @@ export async function getDesktopDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing"));
|
||||
}
|
||||
|
||||
export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions {
|
||||
const instructions = parseCliSymlinkInstructionsInternal(raw);
|
||||
if (!instructions) {
|
||||
throw new Error("Unexpected CLI symlink instructions response.");
|
||||
export async function getCliDaemonStatus(): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
throw new Error("Unexpected CLI daemon status response.");
|
||||
}
|
||||
return instructions;
|
||||
}
|
||||
|
||||
export async function getCliSymlinkInstructions(): Promise<CliSymlinkInstructions> {
|
||||
return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions"));
|
||||
return raw;
|
||||
}
|
||||
|
||||
export type LocalTransportEventUnlisten = () => void;
|
||||
@@ -212,3 +195,34 @@ export async function sendLocalTransportMessage(input: {
|
||||
export async function closeLocalTransportSession(sessionId: string): Promise<void> {
|
||||
await invokeDesktopCommand("close_local_daemon_transport", { sessionId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
function parseInstallStatus(raw: unknown): InstallStatus {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected install status response.");
|
||||
}
|
||||
return { installed: raw.installed === true };
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_cli_install_status"));
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_cli"));
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("get_skills_install_status"));
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
return parseInstallStatus(await invokeDesktopCommand("install_skills"));
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
|
||||
export interface DesktopHostBridge {
|
||||
platform?: string;
|
||||
invoke?: DesktopInvokeBridge["invoke"];
|
||||
getPendingOpenProject?: () => Promise<string | null>;
|
||||
events?: DesktopEventsBridge;
|
||||
window?: DesktopWindowModuleBridge;
|
||||
dialog?: DesktopDialogBridge;
|
||||
|
||||
@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
|
||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||
const model = draftConfig.model?.trim() ?? "";
|
||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||
const featureValues = draftConfig.featureValues;
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
|
||||
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
|
||||
draftConfig?.modeId ?? null,
|
||||
draftConfig?.model ?? null,
|
||||
draftConfig?.thinkingOptionId ?? null,
|
||||
draftConfig?.featureValues ?? null,
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const availableModels = providerModelsQuery.data ?? null;
|
||||
|
||||
const providerModesQuery = useQuery({
|
||||
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
formState.serverId &&
|
||||
client &&
|
||||
isConnected &&
|
||||
providerDefinitionMap.has(formState.provider),
|
||||
),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderModes(formState.provider, {
|
||||
cwd: debouncedCwd,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.modes ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: providerDefinitions.map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
@@ -722,7 +747,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const modeOptions = providerModesQuery.data ?? agentDefinition?.modes ?? [];
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
|
||||
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal file
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
function pruneFeatureValues(
|
||||
featureValues: Record<string, unknown>,
|
||||
features: AgentFeature[],
|
||||
): Record<string, unknown> {
|
||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
||||
if (!allowedFeatureIds.has(featureId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[featureId] = value;
|
||||
}
|
||||
|
||||
return changed ? next : featureValues;
|
||||
}
|
||||
|
||||
function applyFeatureValues(
|
||||
features: AgentFeature[],
|
||||
featureValues: Record<string, unknown>,
|
||||
): AgentFeature[] {
|
||||
if (Object.keys(featureValues).length === 0) {
|
||||
return features;
|
||||
}
|
||||
|
||||
return features.map((feature) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
return {
|
||||
...feature,
|
||||
value: featureValues[feature.id],
|
||||
} as AgentFeature;
|
||||
});
|
||||
}
|
||||
|
||||
type DraftFeatureConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
|
||||
export function useDraftAgentFeatures(input: {
|
||||
serverId: string | null | undefined;
|
||||
provider: AgentProvider;
|
||||
cwd: string | null | undefined;
|
||||
modeId: string | null | undefined;
|
||||
modelId: string | null | undefined;
|
||||
thinkingOptionId: string | null | undefined;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
if (!normalizedCwd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
cwd: normalizedCwd,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(modelId ? { model: modelId } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
||||
|
||||
const featuresQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerFeatures",
|
||||
serverId ?? null,
|
||||
provider,
|
||||
normalizedCwd || null,
|
||||
modeId ?? null,
|
||||
modelId ?? null,
|
||||
thinkingOptionId ?? null,
|
||||
],
|
||||
enabled: Boolean(serverId && client && isConnected && draftConfig),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !draftConfig) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.listProviderFeatures(draftConfig);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.features ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const features = useMemo(() => {
|
||||
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
|
||||
}, [featureValues, featuresQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = pruneFeatureValues(featureValues, features);
|
||||
if (next !== featureValues) {
|
||||
setFeatureValues(next);
|
||||
}
|
||||
}, [featureValues, features]);
|
||||
|
||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
||||
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
|
||||
setFeatureValues((current) => {
|
||||
if (Object.is(current[featureId], value)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, [featureId]: value };
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features,
|
||||
featureValues: effectiveFeatureValues,
|
||||
isLoading: featuresQuery.isLoading,
|
||||
setFeatureValue,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
|
||||
}
|
||||
|
||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
||||
useExplorerSidebarAnimation();
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
onOpen();
|
||||
}, [onOpen, gestureAnimatingRef]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(openGestureRef)
|
||||
.enabled(enabled)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(onOpen)();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
onOpen,
|
||||
openGestureRef,
|
||||
handleGestureOpen,
|
||||
touchStartX,
|
||||
touchStartY,
|
||||
],
|
||||
|
||||
80
packages/app/src/keyboard/focus-scope.test.ts
Normal file
80
packages/app/src/keyboard/focus-scope.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveKeyboardFocusScope } from "./focus-scope";
|
||||
|
||||
class FakeNode {
|
||||
parentElement: FakeElement | null = null;
|
||||
}
|
||||
|
||||
class FakeElement extends FakeNode {
|
||||
tagName: string;
|
||||
isContentEditable = false;
|
||||
private selectors: Set<string>;
|
||||
|
||||
constructor(input?: { tagName?: string; selectors?: string[]; isContentEditable?: boolean }) {
|
||||
super();
|
||||
this.tagName = (input?.tagName ?? "div").toUpperCase();
|
||||
this.selectors = new Set(input?.selectors ?? []);
|
||||
if (input?.isContentEditable) {
|
||||
this.isContentEditable = true;
|
||||
}
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (this.selectors.has(selector)) {
|
||||
return this;
|
||||
}
|
||||
return this.parentElement?.closest(selector) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveKeyboardFocusScope", () => {
|
||||
const globalRef = globalThis as {
|
||||
Element?: unknown;
|
||||
Node?: unknown;
|
||||
document?: { activeElement?: unknown };
|
||||
};
|
||||
const originalElement = globalRef.Element;
|
||||
const originalNode = globalRef.Node;
|
||||
const originalDocument = globalRef.document;
|
||||
|
||||
beforeEach(() => {
|
||||
globalRef.Element = FakeElement;
|
||||
globalRef.Node = FakeNode;
|
||||
globalRef.document = { activeElement: null };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalRef.Element = originalElement;
|
||||
globalRef.Node = originalNode;
|
||||
globalRef.document = originalDocument;
|
||||
});
|
||||
|
||||
it("resolves terminal scope from the direct keyboard event target", () => {
|
||||
const target = new FakeElement({ selectors: [".xterm"] });
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: target as unknown as EventTarget,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("terminal");
|
||||
});
|
||||
|
||||
it("falls back to activeElement when target is not an Element", () => {
|
||||
const activeElement = new FakeElement({ selectors: [".xterm"] });
|
||||
globalRef.document = { activeElement };
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: null,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("terminal");
|
||||
});
|
||||
|
||||
it("detects editable scope from activeElement fallback", () => {
|
||||
const activeElement = new FakeElement({ tagName: "input" });
|
||||
globalRef.document = { activeElement };
|
||||
const scope = resolveKeyboardFocusScope({
|
||||
target: null,
|
||||
commandCenterOpen: false,
|
||||
});
|
||||
expect(scope).toBe("editable");
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,77 @@
|
||||
import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
|
||||
function isElement(value: unknown): value is Element {
|
||||
return typeof Element !== "undefined" && value instanceof Element;
|
||||
}
|
||||
|
||||
function getFocusCandidateElements(target: EventTarget | null): Element[] {
|
||||
const candidates: Element[] = [];
|
||||
const pushUnique = (element: Element | null) => {
|
||||
if (!element || candidates.includes(element)) {
|
||||
return;
|
||||
}
|
||||
candidates.push(element);
|
||||
};
|
||||
|
||||
if (isElement(target)) {
|
||||
pushUnique(target);
|
||||
}
|
||||
|
||||
if (typeof Node !== "undefined" && target instanceof Node) {
|
||||
pushUnique(isElement(target.parentElement) ? target.parentElement : null);
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && isElement(document.activeElement)) {
|
||||
pushUnique(document.activeElement);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveKeyboardFocusScope(input: {
|
||||
target: EventTarget | null;
|
||||
commandCenterOpen: boolean;
|
||||
}): KeyboardFocusScope {
|
||||
const { target, commandCenterOpen } = input;
|
||||
if (!(target instanceof Element)) {
|
||||
const candidates = getFocusCandidateElements(target);
|
||||
if (candidates.length === 0) {
|
||||
return commandCenterOpen ? "command-center" : "other";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) {
|
||||
if (
|
||||
candidates.some((element) =>
|
||||
Boolean(element.closest("[data-testid='terminal-surface']") || element.closest(".xterm")),
|
||||
)
|
||||
) {
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
if (
|
||||
commandCenterOpen &&
|
||||
(target.closest("[data-testid='command-center-panel']") ||
|
||||
target.closest("[data-testid='command-center-input']"))
|
||||
candidates.some((element) =>
|
||||
Boolean(
|
||||
element.closest("[data-testid='command-center-panel']") ||
|
||||
element.closest("[data-testid='command-center-input']"),
|
||||
),
|
||||
)
|
||||
) {
|
||||
return "command-center";
|
||||
}
|
||||
|
||||
if (target.closest("[data-testid='message-input-root']")) {
|
||||
if (candidates.some((element) => Boolean(element.closest("[data-testid='message-input-root']")))) {
|
||||
return "message-input";
|
||||
}
|
||||
|
||||
const editable = target as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") {
|
||||
if (
|
||||
candidates.some((element) => {
|
||||
const editable = element as HTMLElement;
|
||||
if (editable.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
const tag = element.tagName.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
})
|
||||
) {
|
||||
return commandCenterOpen ? "command-center" : "editable";
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type HostProfile,
|
||||
} from "@/types/host-connection";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import {
|
||||
shouldUseDesktopDaemon,
|
||||
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
suppressSendErrors: true,
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
runtimeGeneration,
|
||||
};
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
@@ -1681,6 +1683,41 @@ export class HostRuntimeStore {
|
||||
};
|
||||
}
|
||||
|
||||
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const isAnyOnline = (): boolean => {
|
||||
for (const host of this.hosts) {
|
||||
const snapshot = this.getSnapshot(host.serverId);
|
||||
if (snapshot?.connectionStatus === "online") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
if (isAnyOnline()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = this.subscribeAll(() => {
|
||||
if (isAnyOnline()) {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ensureConnectedAll(): void {
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.ensureConnected();
|
||||
|
||||
@@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -757,6 +758,18 @@ function DraftAgentScreenContent({
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const {
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
setFeatureValue: setDraftFeatureValue,
|
||||
} = useDraftAgentFeatures({
|
||||
serverId: selectedServerId,
|
||||
provider: selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeId: selectedMode,
|
||||
modelId: effectiveDraftModelId,
|
||||
thinkingOptionId: effectiveDraftThinkingOptionId,
|
||||
});
|
||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||
const cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
@@ -773,8 +786,10 @@ function DraftAgentScreenContent({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
}, [
|
||||
draftFeatureValues,
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
@@ -878,6 +893,7 @@ function DraftAgentScreenContent({
|
||||
title: "New agent",
|
||||
cwd,
|
||||
model,
|
||||
features: draftFeatures,
|
||||
thinkingOptionId,
|
||||
labels: {},
|
||||
};
|
||||
@@ -896,6 +912,7 @@ function DraftAgentScreenContent({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
|
||||
const effectiveBaseBranch = baseBranch.trim();
|
||||
@@ -1248,6 +1265,8 @@ function DraftAgentScreenContent({
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -59,11 +59,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
content: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
padding: theme.spacing[6],
|
||||
paddingBottom: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
marginBottom: theme.spacing[8],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Stethoscope,
|
||||
Info,
|
||||
Shield,
|
||||
Puzzle,
|
||||
} from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
@@ -69,6 +71,7 @@ type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "integrations"
|
||||
| "diagnostics"
|
||||
| "about"
|
||||
| "permissions"
|
||||
@@ -85,17 +88,21 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
];
|
||||
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
@@ -520,6 +527,8 @@ function SettingsSectionContent({
|
||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||
case "about":
|
||||
return <AboutSection {...aboutProps} />;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "daemon":
|
||||
@@ -570,31 +579,35 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
|
||||
{sections.map((section) => {
|
||||
const isSelected = section.id === selectedSectionId;
|
||||
const IconComponent = section.icon;
|
||||
const showSeparator =
|
||||
section.id === "integrations" || section.id === "diagnostics";
|
||||
return (
|
||||
<Pressable
|
||||
key={section.id}
|
||||
style={[
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
<View key={section.id}>
|
||||
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
|
||||
<Pressable
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
desktopStyles.sidebarItem,
|
||||
isSelected && { backgroundColor: theme.colors.surface2 },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
onPress={() => setSelectedSectionId(section.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
desktopStyles.sidebarLabel,
|
||||
isSelected && { color: theme.colors.foreground },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -1893,6 +1906,12 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sidebarSeparator: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
marginVertical: theme.spacing[2],
|
||||
marginHorizontal: theme.spacing[3],
|
||||
},
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -192,8 +192,8 @@ export function KeyboardShortcutsSection() {
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<View style={settingsStyles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionHeaderTitle}>Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -246,11 +246,6 @@ export function KeyboardShortcutsSection() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
subsectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ImageAttachment } from "@/components/message-input";
|
||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -110,6 +111,18 @@ export function WorkspaceDraftAgentTab({
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const {
|
||||
features: draftFeatures,
|
||||
featureValues: draftFeatureValues,
|
||||
setFeatureValue: setDraftFeatureValue,
|
||||
} = useDraftAgentFeatures({
|
||||
serverId,
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
modeId: selectedMode,
|
||||
modelId: effectiveDraftModelId,
|
||||
thinkingOptionId: effectiveDraftThinkingOptionId,
|
||||
});
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -168,6 +181,7 @@ export function WorkspaceDraftAgentTab({
|
||||
title: "Agent",
|
||||
cwd: workspaceId,
|
||||
model,
|
||||
features: draftFeatures,
|
||||
thinkingOptionId,
|
||||
labels: {},
|
||||
};
|
||||
@@ -186,6 +200,7 @@ export function WorkspaceDraftAgentTab({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
@@ -215,8 +230,10 @@ export function WorkspaceDraftAgentTab({
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
|
||||
};
|
||||
}, [
|
||||
draftFeatureValues,
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
modeOptions.length,
|
||||
@@ -297,6 +314,8 @@ export function WorkspaceDraftAgentTab({
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
features: draftFeatures,
|
||||
onSetFeature: setDraftFeatureValue,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -983,6 +983,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (
|
||||
canPruneAgentTabs &&
|
||||
tab.target.kind === "agent" &&
|
||||
!pinnedAgentIds.has(tab.target.agentId) &&
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: tab.target.agentId,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPermissionRequest,
|
||||
AgentSessionConfig,
|
||||
AgentFeature,
|
||||
AgentProvider,
|
||||
AgentMode,
|
||||
AgentCapabilityFlags,
|
||||
@@ -99,6 +100,7 @@ export interface Agent {
|
||||
title: string | null;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
features?: AgentFeature[];
|
||||
thinkingOptionId?: string | null;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
|
||||
@@ -4,6 +4,13 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
section: {
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -11,6 +18,20 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
sectionHeaderLink: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
sectionHeaderLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -18,4 +39,28 @@ export const settingsStyles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
rowTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
rowHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -286,6 +286,25 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||
if (key === "c" && terminal.hasSelection()) {
|
||||
void navigator.clipboard.writeText(terminal.getSelection());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste from clipboard into terminal
|
||||
if (key === "v") {
|
||||
event.preventDefault();
|
||||
void navigator.clipboard.readText().then((text) => {
|
||||
if (text) {
|
||||
terminal.paste(text);
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model ?? null,
|
||||
features: snapshot.features,
|
||||
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
||||
requiresAttention: snapshot.requiresAttention ?? false,
|
||||
attentionReason: snapshot.attentionReason ?? null,
|
||||
|
||||
@@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => {
|
||||
it("returns the main repo root for paseo-owned worktrees", () => {
|
||||
const checkout = {
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: "/repo/.paseo/worktrees/feature",
|
||||
mainRepoRoot: "/repo/main",
|
||||
} as CheckoutStatusPayload;
|
||||
} as unknown as CheckoutStatusPayload;
|
||||
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
|
||||
"/repo/main",
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("project-placement", () => {
|
||||
isGit: true as const,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
worktreeRoot: "/Users/test/repo",
|
||||
isPaseoOwnedWorktree: false as const,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
|
||||
@@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
||||
);
|
||||
case "plain_text":
|
||||
return Boolean(detail.label || detail.text);
|
||||
case "plan":
|
||||
return detail.text.trim().length > 0;
|
||||
case "unknown":
|
||||
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
worktree_setup: SquareTerminal,
|
||||
sub_agent: Bot,
|
||||
plain_text: Wrench,
|
||||
plan: Brain,
|
||||
unknown: Wrench,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
188
packages/cli/src/classify.test.ts
Normal file
188
packages/cli/src/classify.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js";
|
||||
|
||||
const knownCommands = new Set(["ls", "run", "status"]);
|
||||
|
||||
describe("classifyInvocation", () => {
|
||||
it("classifies no args as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: [] });
|
||||
});
|
||||
|
||||
it("classifies flags as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["--version"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: ["--version"] });
|
||||
});
|
||||
|
||||
it("classifies known commands as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["ls", "--json"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({ kind: "cli", argv: ["ls", "--json"] });
|
||||
});
|
||||
|
||||
it("classifies '.' as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["."],
|
||||
knownCommands,
|
||||
cwd: projectDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies '..' as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-"));
|
||||
const childDir = path.join(parentDir, "child");
|
||||
mkdirSync(childDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [".."],
|
||||
knownCommands,
|
||||
cwd: childDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: parentDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies './myproject' as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-"));
|
||||
const projectDir = path.join(parentDir, "myproject");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["./myproject"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an absolute path as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [projectDir],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a home-relative path as an open-project invocation", () => {
|
||||
const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-"));
|
||||
const relativeToHome = `~/${path.basename(projectDir)}`;
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: [relativeToHome],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies an existing directory name as an open-project invocation", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-"));
|
||||
const projectDir = path.join(parentDir, "myproject");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["myproject"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "open-project",
|
||||
resolvedPath: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps known commands in CLI mode even when a matching directory exists", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-"));
|
||||
mkdirSync(path.join(parentDir, "status"));
|
||||
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["status"],
|
||||
knownCommands,
|
||||
cwd: parentDir,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "cli",
|
||||
argv: ["status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies nonexistent args as CLI mode", () => {
|
||||
expect(
|
||||
classifyInvocation({
|
||||
argv: ["nonexistent"],
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "cli",
|
||||
argv: ["nonexistent"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("path helpers", () => {
|
||||
it("detects path-like prefixes", () => {
|
||||
expect(isPathLikeArg(".")).toBe(true);
|
||||
expect(isPathLikeArg("..")).toBe(true);
|
||||
expect(isPathLikeArg("./project")).toBe(true);
|
||||
expect(isPathLikeArg("../project")).toBe(true);
|
||||
expect(isPathLikeArg("/tmp/project")).toBe(true);
|
||||
expect(isPathLikeArg("~")).toBe(true);
|
||||
expect(isPathLikeArg("~/project")).toBe(true);
|
||||
expect(isPathLikeArg("C:\\project")).toBe(true);
|
||||
expect(isPathLikeArg("status")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects existing directories relative to cwd", () => {
|
||||
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-"));
|
||||
const projectDir = path.join(parentDir, "project");
|
||||
mkdirSync(projectDir);
|
||||
|
||||
expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true);
|
||||
expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false);
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/classify.ts
Normal file
70
packages/cli/src/classify.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type CliInvocation =
|
||||
| { kind: "cli"; argv: string[] }
|
||||
| { kind: "open-project"; resolvedPath: string };
|
||||
|
||||
export function isPathLikeArg(arg: string): boolean {
|
||||
return (
|
||||
arg === "." ||
|
||||
arg === ".." ||
|
||||
arg.startsWith("./") ||
|
||||
arg.startsWith("../") ||
|
||||
arg.startsWith("/") ||
|
||||
arg === "~" ||
|
||||
arg.startsWith("~/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(arg)
|
||||
);
|
||||
}
|
||||
|
||||
export function expandUserPath(inputPath: string): string {
|
||||
if (inputPath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
|
||||
if (inputPath.startsWith("~/")) {
|
||||
return path.join(homedir(), inputPath.slice(2));
|
||||
}
|
||||
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean {
|
||||
const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg));
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return statSync(resolvedPath).isDirectory();
|
||||
}
|
||||
|
||||
export function classifyInvocation(input: {
|
||||
argv: string[];
|
||||
knownCommands: ReadonlySet<string>;
|
||||
cwd: string;
|
||||
}): CliInvocation {
|
||||
const [firstArg] = input.argv;
|
||||
if (!firstArg) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (firstArg.startsWith("-")) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (input.knownCommands.has(firstArg)) {
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
|
||||
if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) {
|
||||
return {
|
||||
kind: "open-project",
|
||||
resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "cli", argv: input.argv };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface LocalDaemonPidInfo {
|
||||
hostname?: string;
|
||||
uid?: number;
|
||||
listen?: string;
|
||||
desktopManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalDaemonState {
|
||||
@@ -173,6 +174,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
|
||||
: typeof parsed.sockPath === "string"
|
||||
? parsed.sockPath
|
||||
: undefined,
|
||||
desktopManaged: parsed.desktopManaged === true ? true : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -34,6 +34,8 @@ interface DaemonStatus {
|
||||
daemonNode: string;
|
||||
cliNode: string;
|
||||
cliVersion: string;
|
||||
daemonVersion: string | null;
|
||||
desktopManaged: boolean;
|
||||
providers: ProviderBinaryStatus[];
|
||||
note?: string;
|
||||
}
|
||||
@@ -129,6 +131,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
|
||||
{ key: "Daemon Node", value: status.daemonNode },
|
||||
{ key: "CLI Node", value: status.cliNode },
|
||||
{ key: "CLI", value: status.cliVersion },
|
||||
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
|
||||
];
|
||||
|
||||
if (status.runningAgents !== null && status.idleAgents !== null) {
|
||||
@@ -229,6 +232,7 @@ export async function runStatusCommand(
|
||||
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
|
||||
let runningAgents: number | null = null;
|
||||
let idleAgents: number | null = null;
|
||||
let daemonVersion: string | null = null;
|
||||
let note: string | undefined;
|
||||
|
||||
if (!state.running && state.stalePidFile && state.pidInfo) {
|
||||
@@ -240,6 +244,7 @@ export async function runStatusCommand(
|
||||
const client = await tryConnectToDaemon({ host, timeout: 1500 });
|
||||
if (client) {
|
||||
connectedDaemon = "reachable";
|
||||
daemonVersion = client.getLastServerInfoMessage()?.version ?? null;
|
||||
try {
|
||||
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
@@ -308,6 +313,8 @@ export async function runStatusCommand(
|
||||
daemonNode,
|
||||
cliNode,
|
||||
cliVersion,
|
||||
daemonVersion,
|
||||
desktopManaged: state.pidInfo?.desktopManaged === true,
|
||||
providers,
|
||||
note,
|
||||
};
|
||||
|
||||
99
packages/cli/src/commands/open.ts
Normal file
99
packages/cli/src/commands/open.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function findDesktopApp(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
const candidates = [
|
||||
"/Applications/Paseo.app",
|
||||
path.join(homedir(), "Applications", "Paseo.app"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
const candidates = [
|
||||
"/usr/bin/Paseo",
|
||||
"/opt/Paseo/Paseo",
|
||||
path.join(homedir(), "Applications", "Paseo.AppImage"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = path.join(localAppData, "Programs", "Paseo", "Paseo.exe");
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
// The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned
|
||||
// desktop process inherits the env directly, so we must strip it or the
|
||||
// desktop app would start as a bare Node process instead of Electron.
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
return env;
|
||||
}
|
||||
|
||||
function spawnDetached(command: string, args: string[]): void {
|
||||
spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: cleanEnvForDesktopLaunch(),
|
||||
}).unref();
|
||||
}
|
||||
|
||||
export async function openDesktopWithProject(projectPath: string): Promise<void> {
|
||||
try {
|
||||
if (process.env.PASEO_DESKTOP_CLI === "1") {
|
||||
throw new Error(
|
||||
"Cannot open a desktop project while running in desktop CLI passthrough mode.",
|
||||
);
|
||||
}
|
||||
|
||||
const desktopApp = findDesktopApp();
|
||||
if (!desktopApp) {
|
||||
throw new Error(
|
||||
"Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
// -n forces a new instance even if the app is already running.
|
||||
// The new instance hits requestSingleInstanceLock(), fails, and relays
|
||||
// the argv to the first instance via the second-instance event.
|
||||
// -g keeps the terminal in the foreground (better CLI UX).
|
||||
// Without -n, macOS just activates the existing window and drops --args.
|
||||
spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
spawnDetached(desktopApp, [projectPath]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
import { createCli } from "./cli.js";
|
||||
import { classifyInvocation } from "./classify.js";
|
||||
import { openDesktopWithProject } from "./commands/open.js";
|
||||
|
||||
const program = createCli();
|
||||
if (process.argv.length <= 2) {
|
||||
process.argv.push("onboard");
|
||||
const knownCommands = new Set(program.commands.map((command) => command.name()));
|
||||
|
||||
const invocation = classifyInvocation({
|
||||
argv: process.argv.slice(2),
|
||||
knownCommands,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
switch (invocation.kind) {
|
||||
case "cli": {
|
||||
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
|
||||
if (invocation.argv.length === 0) {
|
||||
argv.push("onboard");
|
||||
}
|
||||
program.parse(argv, { from: "node" });
|
||||
break;
|
||||
}
|
||||
case "open-project":
|
||||
await openDesktopWithProject(invocation.resolvedPath);
|
||||
break;
|
||||
}
|
||||
program.parse();
|
||||
|
||||
74
packages/cli/tests/32-open-project.test.ts
Normal file
74
packages/cli/tests/32-open-project.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env npx zx
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
classifyInvocation,
|
||||
isExistingDirectory,
|
||||
isPathLikeArg,
|
||||
} from "../src/classify.ts";
|
||||
import { openDesktopWithProject } from "../src/commands/open.ts";
|
||||
|
||||
console.log("📋 Phase 32: Open Project CLI Tests\n");
|
||||
|
||||
console.log(" Testing path-like detection exports...");
|
||||
assert.equal(isPathLikeArg("."), true);
|
||||
assert.equal(isPathLikeArg("./app"), true);
|
||||
assert.equal(isPathLikeArg("/tmp/app"), true);
|
||||
assert.equal(isPathLikeArg("~/app"), true);
|
||||
assert.equal(isPathLikeArg("run"), false);
|
||||
assert.equal(isPathLikeArg("foo"), false);
|
||||
console.log(" ✅ path-like detection matches the expected prefixes");
|
||||
|
||||
console.log(" Testing existing directory detection and command precedence...");
|
||||
const existingProject = join(await mkdtemp(join(tmpdir(), "paseo-open-project-")), "project");
|
||||
await mkdir(existingProject);
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(join(existingProject, ".."));
|
||||
|
||||
assert.equal(isExistingDirectory({ pathArg: "project", cwd: process.cwd() }), true);
|
||||
assert.equal(
|
||||
classifyInvocation({
|
||||
argv: ["project"],
|
||||
knownCommands: new Set(["run", "status"]),
|
||||
cwd: process.cwd(),
|
||||
}).kind,
|
||||
"open-project",
|
||||
);
|
||||
assert.equal(
|
||||
classifyInvocation({
|
||||
argv: ["run"],
|
||||
knownCommands: new Set(["run", "status"]),
|
||||
cwd: process.cwd(),
|
||||
}).kind,
|
||||
"cli",
|
||||
);
|
||||
|
||||
process.chdir(originalCwd);
|
||||
console.log(" ✅ existing directories open as projects, but known commands still win");
|
||||
|
||||
console.log(" Testing desktop CLI passthrough guard...");
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const stderrChunks: string[] = [];
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const previousExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const previousDesktopCli = process.env.PASEO_DESKTOP_CLI;
|
||||
process.env.PASEO_DESKTOP_CLI = "1";
|
||||
|
||||
await openDesktopWithProject(existingProject);
|
||||
|
||||
process.stderr.write = originalWrite;
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.match(stderrChunks.join(""), /desktop CLI passthrough mode/);
|
||||
process.exitCode = previousExitCode;
|
||||
process.env.PASEO_DESKTOP_CLI = previousDesktopCli;
|
||||
console.log(" ✅ desktop CLI passthrough mode is rejected");
|
||||
|
||||
console.log("\n✅ Phase 32: Open Project CLI Tests PASSED");
|
||||
@@ -31,4 +31,7 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
|
||||
RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js"
|
||||
CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"
|
||||
|
||||
@@ -10,5 +10,5 @@ if not exist "%APP_EXECUTABLE%" (
|
||||
)
|
||||
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
|
||||
exit /b %errorlevel%
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
npmRebuild: false
|
||||
appId: sh.paseo.desktop
|
||||
productName: Paseo
|
||||
executableName: Paseo
|
||||
@@ -6,9 +7,13 @@ directories:
|
||||
output: release
|
||||
files:
|
||||
- dist/**/*
|
||||
asarUnpack:
|
||||
- dist/daemon/node-entrypoint-runner.js
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
- from: ../../skills
|
||||
to: skills
|
||||
publish:
|
||||
provider: github
|
||||
owner: getpaseo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.43",
|
||||
"@getpaseo/server": "0.1.43",
|
||||
"@getpaseo/cli": "0.1.45",
|
||||
"@getpaseo/server": "0.1.45",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { app, ipcMain } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolvePaseoHome, getOrCreateServerId } from "@getpaseo/server";
|
||||
import { resolvePaseoHome } from "@getpaseo/server";
|
||||
import {
|
||||
copyAttachmentFileToManagedStorage,
|
||||
deleteManagedAttachmentFile,
|
||||
@@ -12,15 +12,25 @@ import {
|
||||
writeAttachmentBase64,
|
||||
} from "../features/attachments.js";
|
||||
import { checkForAppUpdate, downloadAndInstallUpdate } from "../features/auto-updater.js";
|
||||
import {
|
||||
installCli,
|
||||
getCliInstallStatus,
|
||||
installSkills,
|
||||
getSkillsInstallStatus,
|
||||
} from "../integrations/integrations-manager.js";
|
||||
import {
|
||||
openLocalTransportSession,
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import {
|
||||
createNodeEntrypointInvocation,
|
||||
resolveDaemonRunnerEntrypoint,
|
||||
runCliJsonCommand,
|
||||
runCliTextCommand,
|
||||
} from "./runtime-paths.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
const PID_POLL_INTERVAL_MS = 100;
|
||||
const STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const STARTUP_POLL_MAX_ATTEMPTS = 150;
|
||||
@@ -37,6 +47,8 @@ type DesktopDaemonStatus = {
|
||||
hostname: string | null;
|
||||
pid: number | null;
|
||||
home: string;
|
||||
version: string | null;
|
||||
desktopManaged: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
@@ -51,12 +63,6 @@ type DesktopPairingOffer = {
|
||||
qr: string | null;
|
||||
};
|
||||
|
||||
type CliSymlinkInstructions = {
|
||||
title: string;
|
||||
detail: string;
|
||||
commands: string;
|
||||
};
|
||||
|
||||
type DesktopCommandHandler = (args?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,10 +73,6 @@ function getPaseoHome(): string {
|
||||
return resolvePaseoHome(process.env);
|
||||
}
|
||||
|
||||
function pidFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_PID_FILENAME);
|
||||
}
|
||||
|
||||
function logFilePath(): string {
|
||||
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
|
||||
}
|
||||
@@ -160,41 +162,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveTcpHostFromListen(listen: string): string | null {
|
||||
const normalized = listen.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith("/") ||
|
||||
normalized.startsWith("unix://") ||
|
||||
normalized.startsWith("pipe://") ||
|
||||
normalized.startsWith("\\\\.\\pipe\\") ||
|
||||
/^[A-Za-z]:[/\\]/.test(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return `127.0.0.1:${normalized}`;
|
||||
}
|
||||
|
||||
if (normalized.includes(":")) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDaemonHttpBaseUrl(listen: string): string | null {
|
||||
const endpoint = resolveTcpHostFromListen(listen);
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
return new URL(`http://${endpoint}`).toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function resolveDesktopAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
@@ -219,57 +186,69 @@ function resolveDesktopAppVersion(): string {
|
||||
// Daemon lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveStatus(): DesktopDaemonStatus {
|
||||
async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
||||
const home = getPaseoHome();
|
||||
const pidPath = pidFilePath();
|
||||
|
||||
let pid: number | null = null;
|
||||
let hostname: string | null = null;
|
||||
let listen: string | null = null;
|
||||
|
||||
try {
|
||||
if (existsSync(pidPath)) {
|
||||
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
|
||||
const pidValue = parsed.pid;
|
||||
if (typeof pidValue === "number" && Number.isInteger(pidValue) && pidValue > 0) {
|
||||
pid = pidValue;
|
||||
hostname = typeof parsed.hostname === "string" ? parsed.hostname : null;
|
||||
const pidListen =
|
||||
typeof parsed.listen === "string"
|
||||
? parsed.listen
|
||||
: typeof parsed.sockPath === "string"
|
||||
? (parsed.sockPath as string)
|
||||
: null;
|
||||
listen = pidListen;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PID file missing or malformed — treat as stopped.
|
||||
const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||
const running = localDaemon === "running";
|
||||
|
||||
return {
|
||||
serverId: typeof payload.serverId === "string" ? payload.serverId : "",
|
||||
status: running ? "running" : "stopped",
|
||||
listen: typeof payload.listen === "string" ? payload.listen : null,
|
||||
hostname: running && typeof payload.hostname === "string" ? payload.hostname : null,
|
||||
pid: running && typeof payload.pid === "number" ? payload.pid : null,
|
||||
home,
|
||||
version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null,
|
||||
desktopManaged: payload.desktopManaged === true,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
serverId: "",
|
||||
status: "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home,
|
||||
version: null,
|
||||
desktopManaged: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const running = pid !== null && isProcessRunning(pid);
|
||||
|
||||
let serverId = "";
|
||||
try {
|
||||
serverId = getOrCreateServerId(home);
|
||||
} catch {
|
||||
// Ignore — server-id may not exist yet.
|
||||
}
|
||||
|
||||
return {
|
||||
serverId,
|
||||
status: running ? "running" : "stopped",
|
||||
listen,
|
||||
hostname: running ? hostname : null,
|
||||
pid: running ? pid : null,
|
||||
home,
|
||||
error: null,
|
||||
};
|
||||
function normalizeVersion(version: string | null): string | null {
|
||||
const trimmed = version?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/^v/i, "");
|
||||
}
|
||||
|
||||
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const current = resolveStatus();
|
||||
if (current.status === "running") return current;
|
||||
const current = await resolveStatus();
|
||||
if (current.status === "running") {
|
||||
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
||||
const daemonVersion = normalizeVersion(current.version);
|
||||
if (
|
||||
current.desktopManaged &&
|
||||
appVersion &&
|
||||
daemonVersion &&
|
||||
appVersion !== daemonVersion
|
||||
) {
|
||||
logDesktopDaemonLifecycle("daemon version mismatch, restarting", {
|
||||
appVersion,
|
||||
daemonVersion,
|
||||
});
|
||||
await stopDaemon();
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
const daemonRunner = resolveDaemonRunnerEntrypoint();
|
||||
const invocation = createNodeEntrypointInvocation({
|
||||
@@ -292,7 +271,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
invocation.args,
|
||||
{
|
||||
detached: true,
|
||||
env: invocation.env,
|
||||
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
@@ -344,7 +323,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
// Poll for PID file with server ID
|
||||
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
|
||||
const status = resolveStatus();
|
||||
const status = await resolveStatus();
|
||||
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
|
||||
logDesktopDaemonLifecycle("polling daemon status after detached start", {
|
||||
attempt: attempt + 1,
|
||||
@@ -358,11 +337,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
await sleep(STARTUP_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
return resolveStatus();
|
||||
return await resolveStatus();
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const status = resolveStatus();
|
||||
const status = await resolveStatus();
|
||||
if (status.status !== "running" || !status.pid) return status;
|
||||
|
||||
const pid = status.pid;
|
||||
@@ -378,7 +357,7 @@ async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
||||
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
|
||||
}
|
||||
|
||||
return resolveStatus();
|
||||
return await resolveStatus();
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||
@@ -394,8 +373,12 @@ function getDaemonLogs(): DesktopDaemonLogs {
|
||||
};
|
||||
}
|
||||
|
||||
async function getCliDaemonStatus(): Promise<string> {
|
||||
return await runCliTextCommand(["daemon", "status"]);
|
||||
}
|
||||
|
||||
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
const status = resolveStatus();
|
||||
const status = await resolveStatus();
|
||||
if (status.status !== "running") {
|
||||
return {
|
||||
relayEnabled: false,
|
||||
@@ -405,20 +388,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!status.listen) {
|
||||
throw new Error("Daemon listen target is unavailable.");
|
||||
}
|
||||
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
|
||||
if (!baseUrl) {
|
||||
throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/pairing`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Daemon pairing request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown;
|
||||
const payload = await runCliJsonCommand(["daemon", "pair", "--json"]);
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Daemon pairing response was not an object.");
|
||||
}
|
||||
@@ -437,86 +407,21 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLocalDaemonVersion(): Promise<{
|
||||
version: string | null;
|
||||
error: string | null;
|
||||
}> {
|
||||
const status = resolveStatus();
|
||||
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
|
||||
const status = await resolveStatus();
|
||||
if (status.status !== "running") {
|
||||
return {
|
||||
version: null,
|
||||
error: "Daemon is not running.",
|
||||
};
|
||||
return { version: null, error: "Daemon is not running." };
|
||||
}
|
||||
|
||||
if (!status.listen) {
|
||||
return { version: null, error: "Daemon listen target is unavailable." };
|
||||
}
|
||||
|
||||
const baseUrl = buildDaemonHttpBaseUrl(status.listen);
|
||||
if (!baseUrl) {
|
||||
return { version: null, error: `Daemon listen target is not a TCP endpoint: ${status.listen}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/status`);
|
||||
if (!response.ok) {
|
||||
return { version: null, error: `Daemon status request failed with ${response.status}` };
|
||||
}
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const version = typeof payload.version === "string" ? payload.version.trim() : null;
|
||||
return {
|
||||
version: version && version.length > 0 ? version : null,
|
||||
error: version ? null : "Running daemon did not report a version.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
version: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCurrentUpdateVersion(): Promise<string> {
|
||||
const daemonVersion = await getLocalDaemonVersion();
|
||||
if (daemonVersion.version) {
|
||||
return daemonVersion.version;
|
||||
}
|
||||
return resolveDesktopAppVersion();
|
||||
}
|
||||
|
||||
function getCliSymlinkInstructions(): CliSymlinkInstructions {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
const cliPath = path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your PATH",
|
||||
detail: "Add the Paseo installation directory to your system PATH so paseo.cmd is available.",
|
||||
commands: `setx PATH "%PATH%;${path.dirname(cliPath)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Linux
|
||||
const cliPath = path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
return {
|
||||
title: "Add paseo to your shell",
|
||||
detail: "Create a symlink to the bundled Paseo CLI shim.",
|
||||
commands: `sudo ln -sf "${cliPath}" /usr/local/bin/paseo`,
|
||||
version: status.version,
|
||||
error: status.version ? null : "Running daemon did not report a version.",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentUpdateVersion(): string {
|
||||
return resolveDesktopAppVersion();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC registration
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -529,7 +434,7 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
restart_desktop_daemon: () => restartDaemon(),
|
||||
desktop_daemon_logs: () => getDaemonLogs(),
|
||||
desktop_daemon_pairing: () => getDaemonPairing(),
|
||||
cli_symlink_instructions: () => getCliSymlinkInstructions(),
|
||||
cli_daemon_status: () => getCliDaemonStatus(),
|
||||
write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}),
|
||||
copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}),
|
||||
read_file_base64: (args) => readManagedFileBase64(args ?? {}),
|
||||
@@ -557,9 +462,15 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
||||
},
|
||||
install_app_update: async () => {
|
||||
const currentVersion = await resolveCurrentUpdateVersion();
|
||||
return downloadAndInstallUpdate(currentVersion);
|
||||
return downloadAndInstallUpdate(currentVersion, async () => {
|
||||
await stopDaemon();
|
||||
});
|
||||
},
|
||||
get_local_daemon_version: () => getLocalDaemonVersion(),
|
||||
install_cli: () => installCli(),
|
||||
get_cli_install_status: () => getCliInstallStatus(),
|
||||
install_skills: () => installSkills(),
|
||||
get_skills_install_status: () => getSkillsInstallStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,20 @@ describe("node-entrypoint-launcher", () => {
|
||||
).toEqual(["--version"]);
|
||||
});
|
||||
|
||||
it("passes --open-project through as a normal CLI arg", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: [
|
||||
"/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
"--open-project",
|
||||
"/tmp/project",
|
||||
],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toEqual(["--open-project", "/tmp/project"]);
|
||||
});
|
||||
|
||||
it("forces CLI mode for shim launches even without args", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
@@ -81,7 +95,7 @@ describe("node-entrypoint-launcher", () => {
|
||||
isPackaged: true,
|
||||
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
entrypoint: CLI_ENTRYPOINT,
|
||||
argvMode: "bare",
|
||||
argvMode: "node-script",
|
||||
args: ["ls", "--json"],
|
||||
baseEnv: { PATH: "/usr/bin" },
|
||||
}),
|
||||
@@ -89,7 +103,7 @@ describe("node-entrypoint-launcher", () => {
|
||||
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
args: [
|
||||
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
"bare",
|
||||
"node-script",
|
||||
"/tmp/paseo-cli.js",
|
||||
"ls",
|
||||
"--json",
|
||||
@@ -108,7 +122,7 @@ describe("node-entrypoint-launcher", () => {
|
||||
isPackaged: false,
|
||||
packagedRunnerPath: null,
|
||||
entrypoint: CLI_ENTRYPOINT,
|
||||
argvMode: "bare",
|
||||
argvMode: "node-script",
|
||||
args: ["ls"],
|
||||
baseEnv: { PATH: "/usr/bin" },
|
||||
}),
|
||||
|
||||
@@ -42,9 +42,14 @@ export function parseCliPassthroughArgsFromArgv(
|
||||
input: ParseCliPassthroughArgsFromArgvInput,
|
||||
): string[] | null {
|
||||
const startIndex = input.isDefaultApp ? 2 : 1;
|
||||
const effective = input.argv
|
||||
.slice(startIndex)
|
||||
.filter((arg) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p)));
|
||||
const effective: string[] = [];
|
||||
|
||||
for (const arg of input.argv.slice(startIndex)) {
|
||||
if (IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
effective.push(arg);
|
||||
}
|
||||
|
||||
if (input.forceCli) {
|
||||
return effective;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
@@ -76,7 +76,7 @@ function resolvePackagedAsarPath(): string {
|
||||
}
|
||||
|
||||
function resolvePackagedNodeEntrypointRunnerPath(): string {
|
||||
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
|
||||
return path.join(process.resourcesPath, "app.asar.unpacked", "dist", "daemon", "node-entrypoint-runner.js");
|
||||
}
|
||||
|
||||
function assertPathExists(input: { label: string; filePath: string }): string {
|
||||
@@ -219,23 +219,22 @@ export function createNodeEntrypointInvocation(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
|
||||
function createCliInvocation(args: string[]): NodeEntrypointInvocation {
|
||||
const cli = resolveCliEntrypoint();
|
||||
const invocation = createNodeEntrypointInvocation({
|
||||
return createNodeEntrypointInvocation({
|
||||
entrypoint: cli,
|
||||
argvMode: "bare",
|
||||
args,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
export function runCliPassthroughCommand(args: string[]): number {
|
||||
const result = spawnCliProcess(args);
|
||||
const invocation = createCliInvocation(args);
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
@@ -246,3 +245,70 @@ export function runCliPassthroughCommand(args: string[]): number {
|
||||
|
||||
return result.signal ? 1 : 0;
|
||||
}
|
||||
|
||||
function spawnAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { env: NodeJS.ProcessEnv },
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => {
|
||||
resolve({ stdout, stderr, exitCode });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCliTextCommand(args: string[]): Promise<string> {
|
||||
const invocation = createCliInvocation(args);
|
||||
const result = await spawnAsync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
||||
}
|
||||
|
||||
return result.stdout.trimEnd();
|
||||
}
|
||||
|
||||
export async function runCliJsonCommand(args: string[]): Promise<unknown> {
|
||||
const invocation = createCliInvocation(args);
|
||||
const result = await spawnAsync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
||||
}
|
||||
|
||||
const stdout = result.stdout.trim();
|
||||
if (stdout.length === 0) {
|
||||
throw new Error("CLI command did not produce JSON output.");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stdout) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export async function checkForAppUpdate(currentVersion: string): Promise<AppUpda
|
||||
|
||||
export async function downloadAndInstallUpdate(
|
||||
currentVersion: string,
|
||||
onBeforeQuit?: () => Promise<void>,
|
||||
): Promise<AppUpdateInstallResult> {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
@@ -131,8 +132,9 @@ export async function downloadAndInstallUpdate(
|
||||
await autoUpdater.downloadUpdate();
|
||||
// quitAndInstall restarts the app with the new version.
|
||||
// Use a short delay to allow the renderer to receive the response.
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (onBeforeQuit) await onBeforeQuit();
|
||||
autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true);
|
||||
} catch (error) {
|
||||
console.error("[auto-updater] quitAndInstall failed:", error);
|
||||
|
||||
47
packages/desktop/src/integrations/cli-install-path.test.ts
Normal file
47
packages/desktop/src/integrations/cli-install-path.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path";
|
||||
|
||||
describe("cli-install-path", () => {
|
||||
it("uses the packaged executable on supported unix platforms", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "darwin",
|
||||
isPackaged: true,
|
||||
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
|
||||
}),
|
||||
).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo");
|
||||
});
|
||||
|
||||
it("prefers the original AppImage path on linux", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "linux",
|
||||
isPackaged: true,
|
||||
executablePath: "/tmp/.mount_paseo123/paseo",
|
||||
shimPath: "/tmp/.mount_paseo123/resources/bin/paseo",
|
||||
appImagePath: "/home/user/Applications/Paseo.AppImage",
|
||||
}),
|
||||
).toBe("/home/user/Applications/Paseo.AppImage");
|
||||
});
|
||||
|
||||
it("falls back to the shim on windows and in development", () => {
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "win32",
|
||||
isPackaged: true,
|
||||
executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
|
||||
shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd",
|
||||
}),
|
||||
).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd");
|
||||
|
||||
expect(
|
||||
resolveCliInstallSourcePath({
|
||||
platform: "linux",
|
||||
isPackaged: false,
|
||||
executablePath: "/opt/Paseo/paseo",
|
||||
shimPath: "/opt/Paseo/resources/bin/paseo",
|
||||
}),
|
||||
).toBe("/opt/Paseo/resources/bin/paseo");
|
||||
});
|
||||
});
|
||||
24
packages/desktop/src/integrations/cli-install-path.ts
Normal file
24
packages/desktop/src/integrations/cli-install-path.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export function resolveCliInstallSourcePath(input: {
|
||||
platform: NodeJS.Platform;
|
||||
isPackaged: boolean;
|
||||
executablePath: string;
|
||||
shimPath: string;
|
||||
appImagePath?: string | null;
|
||||
}): string {
|
||||
if (input.platform === "win32") {
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
if (!input.isPackaged) {
|
||||
return input.shimPath;
|
||||
}
|
||||
|
||||
if (input.platform === "linux") {
|
||||
const appImagePath = input.appImagePath?.trim();
|
||||
if (appImagePath) {
|
||||
return appImagePath;
|
||||
}
|
||||
}
|
||||
|
||||
return input.executablePath;
|
||||
}
|
||||
283
packages/desktop/src/integrations/integrations-manager.ts
Normal file
283
packages/desktop/src/integrations/integrations-manager.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { app } from "electron";
|
||||
import log from "electron-log/main";
|
||||
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InstallStatus {
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_NAMES = [
|
||||
"paseo",
|
||||
"paseo-loop",
|
||||
"paseo-handoff",
|
||||
"paseo-orchestrator",
|
||||
"paseo-chat",
|
||||
"paseo-committee",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pathOrSymlinkExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getLocalBinDir(): string {
|
||||
return path.join(os.homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
function getCliTargetPath(): string {
|
||||
const filename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
return path.join(getLocalBinDir(), filename);
|
||||
}
|
||||
|
||||
function getBundledCliShimPath(): string {
|
||||
const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, "");
|
||||
return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
// Linux
|
||||
const electronExePath = app.getPath("exe");
|
||||
return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename);
|
||||
}
|
||||
|
||||
function getBundledSkillsDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "skills");
|
||||
}
|
||||
return path.join(__dirname, "..", "..", "..", "..", "skills");
|
||||
}
|
||||
|
||||
function getAgentsSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
function getClaudeSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
function getCodexSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".codex", "skills");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell PATH helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ShellRcInfo {
|
||||
shell: string;
|
||||
rcFile: string;
|
||||
pathCheckPattern: RegExp;
|
||||
exportLine: string;
|
||||
}
|
||||
|
||||
function detectShellRcInfo(): ShellRcInfo | null {
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
const shell = process.env.SHELL;
|
||||
if (!shell) return null;
|
||||
|
||||
const shellName = path.basename(shell);
|
||||
|
||||
if (shellName === "zsh") {
|
||||
return {
|
||||
shell: "zsh",
|
||||
rcFile: path.join(os.homedir(), ".zshrc"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "bash") {
|
||||
const rcFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(os.homedir(), ".bash_profile")
|
||||
: path.join(os.homedir(), ".bashrc");
|
||||
return {
|
||||
shell: "bash",
|
||||
rcFile,
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: 'export PATH="$HOME/.local/bin:$PATH"',
|
||||
};
|
||||
}
|
||||
|
||||
if (shellName === "fish") {
|
||||
return {
|
||||
shell: "fish",
|
||||
rcFile: path.join(os.homedir(), ".config", "fish", "config.fish"),
|
||||
pathCheckPattern: /\.local\/bin/,
|
||||
exportLine: "fish_add_path $HOME/.local/bin",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathAlreadyContainsLocalBin(): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const localBin = path.join(os.homedir(), ".local", "bin");
|
||||
return pathEnv.split(path.delimiter).some((p) => p === localBin || p === "~/.local/bin");
|
||||
}
|
||||
|
||||
async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
|
||||
if (pathAlreadyContainsLocalBin()) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
const info = detectShellRcInfo();
|
||||
if (!info) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await pathOrSymlinkExists(info.rcFile);
|
||||
if (exists) {
|
||||
const content = await fs.readFile(info.rcFile, "utf-8");
|
||||
if (info.pathCheckPattern.test(content)) {
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(info.rcFile), { recursive: true });
|
||||
await fs.appendFile(info.rcFile, `\n# Added by Paseo\n${info.exportLine}\n`);
|
||||
|
||||
return { shellUpdated: true };
|
||||
} catch (err) {
|
||||
log.warn("[integrations] Failed to update shell rc file", { rcFile: info.rcFile, err });
|
||||
return { shellUpdated: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function installCli(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
const shimPath = getBundledCliShimPath();
|
||||
const installSourcePath = resolveCliInstallSourcePath({
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
executablePath: app.getPath("exe"),
|
||||
shimPath,
|
||||
appImagePath: process.env.APPIMAGE,
|
||||
});
|
||||
const binDir = getLocalBinDir();
|
||||
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.copyFile(installSourcePath, targetPath);
|
||||
} else {
|
||||
if (await pathOrSymlinkExists(targetPath)) {
|
||||
await fs.unlink(targetPath);
|
||||
}
|
||||
await fs.symlink(installSourcePath, targetPath);
|
||||
}
|
||||
|
||||
const { shellUpdated } = await ensurePathInShellRc();
|
||||
if (shellUpdated) {
|
||||
log.info("[integrations] Updated shell rc with ~/.local/bin PATH");
|
||||
}
|
||||
|
||||
return getCliInstallStatus();
|
||||
}
|
||||
|
||||
export async function getCliInstallStatus(): Promise<InstallStatus> {
|
||||
const targetPath = getCliTargetPath();
|
||||
return { installed: await pathOrSymlinkExists(targetPath) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skills Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function copySkillFile(sourceFile: string, destDir: string, skillName: string): Promise<void> {
|
||||
const destSkillDir = path.join(destDir, skillName);
|
||||
await fs.mkdir(destSkillDir, { recursive: true });
|
||||
await fs.copyFile(sourceFile, path.join(destSkillDir, "SKILL.md"));
|
||||
}
|
||||
|
||||
async function symlinkSkillDir(skillName: string, targetDir: string, linkParentDir: string): Promise<void> {
|
||||
await fs.mkdir(linkParentDir, { recursive: true });
|
||||
const target = path.join(targetDir, skillName);
|
||||
const linkPath = path.join(linkParentDir, skillName);
|
||||
|
||||
if (await pathOrSymlinkExists(linkPath)) {
|
||||
await fs.rm(linkPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
await fs.symlink(target, linkPath, "junction");
|
||||
} catch {
|
||||
await copySkillFile(path.join(target, "SKILL.md"), linkParentDir, skillName);
|
||||
}
|
||||
} else {
|
||||
await fs.symlink(target, linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkills(): Promise<InstallStatus> {
|
||||
const sourceDir = getBundledSkillsDir();
|
||||
const agentsDir = getAgentsSkillsDir();
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
const codexDir = getCodexSkillsDir();
|
||||
|
||||
log.info("[integrations] installSkills", { sourceDir, agentsDir, claudeDir, codexDir });
|
||||
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const sourceFile = path.join(sourceDir, skillName, "SKILL.md");
|
||||
await copySkillFile(sourceFile, agentsDir, skillName);
|
||||
await symlinkSkillDir(skillName, agentsDir, claudeDir);
|
||||
await copySkillFile(sourceFile, codexDir, skillName);
|
||||
}
|
||||
|
||||
return getSkillsInstallStatus();
|
||||
}
|
||||
|
||||
export async function getSkillsInstallStatus(): Promise<InstallStatus> {
|
||||
const claudeDir = getClaudeSkillsDir();
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillFile = path.join(claudeDir, skillName, "SKILL.md");
|
||||
try {
|
||||
await fs.access(skillFile);
|
||||
} catch {
|
||||
return { installed: false };
|
||||
}
|
||||
}
|
||||
return { installed: true };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { app, BrowserWindow, nativeImage, net, protocol } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
|
||||
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||
import {
|
||||
parseCliPassthroughArgsFromArgv,
|
||||
@@ -27,11 +27,31 @@ import {
|
||||
} from "./features/notifications.js";
|
||||
import { registerOpenerHandlers } from "./features/opener.js";
|
||||
import { setupApplicationMenu } from "./features/menu.js";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
|
||||
|
||||
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
|
||||
const APP_SCHEME = "paseo";
|
||||
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
|
||||
app.setName("Paseo");
|
||||
|
||||
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
|
||||
argv: process.argv,
|
||||
isDefaultApp: process.defaultApp,
|
||||
});
|
||||
|
||||
log.info("[open-project] argv:", process.argv);
|
||||
log.info("[open-project] isDefaultApp:", process.defaultApp);
|
||||
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
|
||||
|
||||
// The renderer pulls the pending path on mount via IPC — this avoids
|
||||
// a race where the push event arrives before React registers its listener.
|
||||
ipcMain.handle("paseo:get-pending-open-project", () => {
|
||||
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
|
||||
const result = pendingOpenProjectPath;
|
||||
pendingOpenProjectPath = null;
|
||||
return result;
|
||||
});
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
|
||||
]);
|
||||
@@ -126,6 +146,21 @@ async function createMainWindow(): Promise<void> {
|
||||
await mainWindow.loadURL(`${APP_SCHEME}://app/`);
|
||||
}
|
||||
|
||||
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
|
||||
const send = () => {
|
||||
log.info("[open-project] sending event to renderer:", projectPath);
|
||||
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
|
||||
};
|
||||
|
||||
if (win.webContents.isLoadingMainFrame()) {
|
||||
log.info("[open-project] waiting for did-finish-load before sending event");
|
||||
win.webContents.once("did-finish-load", send);
|
||||
return;
|
||||
}
|
||||
|
||||
send();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,12 +172,21 @@ function setupSingleInstanceLock(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on("second-instance", () => {
|
||||
app.on("second-instance", (_event, commandLine) => {
|
||||
log.info("[open-project] second-instance commandLine:", commandLine);
|
||||
const openProjectPath = parseOpenProjectPathFromArgv({
|
||||
argv: commandLine,
|
||||
isDefaultApp: false,
|
||||
});
|
||||
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
if (win) {
|
||||
win.show();
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
if (openProjectPath) {
|
||||
sendOpenProjectEvent(win, openProjectPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,7 +212,7 @@ async function runCliPassthroughIfRequested(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (await runCliPassthroughIfRequested()) {
|
||||
if (!pendingOpenProjectPath && (await runCliPassthroughIfRequested())) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
69
packages/desktop/src/open-project-routing.test.ts
Normal file
69
packages/desktop/src/open-project-routing.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mkdtempSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOpenProjectPathFromArgv } from "./open-project-routing";
|
||||
|
||||
describe("open-project-routing", () => {
|
||||
it("returns a bare absolute path argument", () => {
|
||||
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
|
||||
|
||||
expect(
|
||||
parseOpenProjectPathFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", projectPath],
|
||||
isDefaultApp: false,
|
||||
}),
|
||||
).toBe(projectPath);
|
||||
});
|
||||
|
||||
it("finds a bare absolute path even when Chromium noise args appear first", () => {
|
||||
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
|
||||
|
||||
expect(
|
||||
parseOpenProjectPathFromArgv({
|
||||
argv: [
|
||||
"/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
"--allow-file-access-from-files",
|
||||
"--no-sandbox",
|
||||
projectPath,
|
||||
],
|
||||
isDefaultApp: false,
|
||||
}),
|
||||
).toBe(projectPath);
|
||||
});
|
||||
|
||||
it("does not treat flags as project paths", () => {
|
||||
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
|
||||
const flagLikeDirectory = path.join(projectPath, "--version");
|
||||
mkdirSync(flagLikeDirectory);
|
||||
|
||||
expect(
|
||||
parseOpenProjectPathFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version", flagLikeDirectory],
|
||||
isDefaultApp: false,
|
||||
}),
|
||||
).toBe(flagLikeDirectory);
|
||||
|
||||
expect(
|
||||
parseOpenProjectPathFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
|
||||
isDefaultApp: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the path from an explicit --open-project flag for backward compatibility", () => {
|
||||
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
|
||||
|
||||
expect(
|
||||
parseOpenProjectPathFromArgv({
|
||||
argv: [
|
||||
"/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
"--open-project",
|
||||
projectPath,
|
||||
],
|
||||
isDefaultApp: false,
|
||||
}),
|
||||
).toBe(projectPath);
|
||||
});
|
||||
});
|
||||
45
packages/desktop/src/open-project-routing.ts
Normal file
45
packages/desktop/src/open-project-routing.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const OPEN_PROJECT_FLAG = "--open-project";
|
||||
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
|
||||
|
||||
function isExistingDirectoryAbsolutePath(candidate: string): boolean {
|
||||
if (!path.isAbsolute(candidate) || !existsSync(candidate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return statSync(candidate).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenProjectPathFromArgv(input: {
|
||||
argv: string[];
|
||||
isDefaultApp: boolean;
|
||||
}): string | null {
|
||||
const effectiveArgs = input.argv
|
||||
.slice(input.isDefaultApp ? 2 : 1)
|
||||
.filter(
|
||||
(arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)),
|
||||
);
|
||||
|
||||
const positionalProjectPath = effectiveArgs.find(
|
||||
(arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg),
|
||||
);
|
||||
if (positionalProjectPath) {
|
||||
return positionalProjectPath;
|
||||
}
|
||||
|
||||
const openProjectIndex = effectiveArgs.indexOf(OPEN_PROJECT_FLAG);
|
||||
if (openProjectIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flaggedProjectPath = effectiveArgs[openProjectIndex + 1];
|
||||
return flaggedProjectPath && isExistingDirectoryAbsolutePath(flaggedProjectPath)
|
||||
? flaggedProjectPath
|
||||
: null;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
platform: process.platform,
|
||||
invoke: (command: string, args?: Record<string, unknown>) =>
|
||||
ipcRenderer.invoke("paseo:invoke", command, args),
|
||||
getPendingOpenProject: () =>
|
||||
ipcRenderer.invoke("paseo:get-pending-open-project") as Promise<string | null>,
|
||||
events: {
|
||||
on: (event: string, handler: EventHandler): Promise<() => void> => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.43",
|
||||
"version": "0.1.45",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -32,13 +32,13 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "NODE_ENV=production node dist/server/server/index.js",
|
||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
||||
"typecheck": "tsc -p tsconfig.server.typecheck.json --noEmit",
|
||||
"generate:config-schema": "tsx scripts/generate-config-schema.ts",
|
||||
"speech:models": "tsx scripts/list-speech-models.ts",
|
||||
@@ -64,8 +64,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.43",
|
||||
"@getpaseo/relay": "0.1.43",
|
||||
"@getpaseo/highlight": "0.1.45",
|
||||
"@getpaseo/relay": "0.1.45",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -81,6 +81,7 @@
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"pi-acp": "^0.0.24",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -103,6 +104,7 @@
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/ws": "^8.5.8",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"cross-env": "^10.1.0",
|
||||
"playwright": "^1.56.1",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.2.2",
|
||||
|
||||
@@ -37,7 +37,9 @@ import type {
|
||||
OpenProjectResponseMessage,
|
||||
ArchiveWorkspaceResponseMessage,
|
||||
ListCommandsResponse,
|
||||
ListProviderFeaturesResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
ListProviderModesResponseMessage,
|
||||
ListAvailableProvidersResponse,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
@@ -153,6 +155,7 @@ export type DaemonClientConfig = {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType?: "mobile" | "browser" | "cli" | "mcp";
|
||||
appVersion?: string;
|
||||
runtimeGeneration?: number | null;
|
||||
authHeader?: string;
|
||||
suppressSendErrors?: boolean;
|
||||
@@ -215,12 +218,14 @@ type CreatePaseoWorktreePayload = Extract<
|
||||
>["payload"];
|
||||
type FileExplorerPayload = FileExplorerResponse["payload"];
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||
type ListCommandsDraftConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
|
||||
>;
|
||||
type ListCommandsOptions = {
|
||||
requestId?: string;
|
||||
@@ -1400,7 +1405,7 @@ export class DaemonClient {
|
||||
const status = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
timeout: 60000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "status") {
|
||||
@@ -1705,6 +1710,35 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "set_agent_feature_request",
|
||||
agentId,
|
||||
featureId,
|
||||
value,
|
||||
requestId,
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "set_agent_feature_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
if (!payload.accepted) {
|
||||
throw new Error(payload.error ?? "setAgentFeature rejected");
|
||||
}
|
||||
}
|
||||
|
||||
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
@@ -2474,6 +2508,37 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listProviderModes(
|
||||
provider: AgentProvider,
|
||||
options?: { cwd?: string; requestId?: string },
|
||||
): Promise<ListProviderModesPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "list_provider_modes_request",
|
||||
provider,
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
responseType: "list_provider_modes_response",
|
||||
timeout: 45000,
|
||||
});
|
||||
}
|
||||
|
||||
async listProviderFeatures(
|
||||
draftConfig: ListCommandsDraftConfig,
|
||||
options?: { requestId?: string },
|
||||
): Promise<ListProviderFeaturesPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "list_provider_features_request",
|
||||
draftConfig,
|
||||
},
|
||||
responseType: "list_provider_features_response",
|
||||
timeout: 45000,
|
||||
});
|
||||
}
|
||||
|
||||
async listAvailableProviders(options?: {
|
||||
requestId?: string;
|
||||
}): Promise<ListAvailableProvidersPayload> {
|
||||
@@ -3221,6 +3286,7 @@ export class DaemonClient {
|
||||
clientId: this.config.clientId,
|
||||
clientType: this.config.clientType ?? "cli",
|
||||
protocolVersion: 1,
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,13 +15,16 @@
|
||||
* This pattern is used in claude-agent.ts listModels().
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
query,
|
||||
type Query,
|
||||
type SlashCommand,
|
||||
type SDKUserMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { isCommandAvailable } from "../utils/executable.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||
|
||||
// Pattern from claude-agent.ts listModels():
|
||||
// Use an empty async generator when you just need control methods
|
||||
@@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
|
||||
describe("Claude Agent SDK Commands POC", () => {
|
||||
describe("supportedCommands() API", () => {
|
||||
it("should return an array of SlashCommand objects", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
|
||||
// Use the pattern from claude-agent.ts:
|
||||
// Create a query with empty prompt generator for control methods
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
@@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("should have valid SlashCommand structure for all commands", async () => {
|
||||
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
|
||||
const emptyPrompt = createEmptyPrompt();
|
||||
|
||||
const claudeQuery = query({
|
||||
@@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => {
|
||||
});
|
||||
|
||||
describe("Command Execution", () => {
|
||||
it("should explain that commands are prompts with / prefix", () => {
|
||||
test("should explain that commands are prompts with / prefix", () => {
|
||||
// This is a documentation test - commands ARE just prompts with / prefix
|
||||
// To execute a command:
|
||||
// 1. Create a user message with content: "/{commandName}"
|
||||
|
||||
@@ -7,8 +7,10 @@ import { randomUUID } from "node:crypto";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { buildConfigOverrides } from "../persistence-hooks.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentRunResult,
|
||||
@@ -197,6 +199,16 @@ class TestAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentManager", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -481,6 +493,101 @@ describe("AgentManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("resumeAgentFromPersistence passes featureValues through to the resumed session config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class ResumeFeatureCaptureClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
lastResumeOverrides: Partial<AgentSessionConfig> | undefined;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
this.lastResumeOverrides = overrides;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
return new TestAgentSession({
|
||||
...metadata,
|
||||
...overrides,
|
||||
provider: "codex",
|
||||
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await storage.upsert({
|
||||
id: "00000000-0000-4000-8000-000000000138",
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: "plan",
|
||||
config: {
|
||||
model: "gpt-5.1",
|
||||
modeId: "plan",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "resume-feature-session",
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const record = await storage.get("00000000-0000-4000-8000-000000000138");
|
||||
expect(record).not.toBeNull();
|
||||
|
||||
const client = new ResumeFeatureCaptureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const resumed = await manager.resumeAgentFromPersistence(
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "resume-feature-session",
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
},
|
||||
},
|
||||
buildConfigOverrides(record!),
|
||||
record!.id,
|
||||
);
|
||||
|
||||
expect(client.lastResumeOverrides?.featureValues).toEqual({
|
||||
fast_mode: true,
|
||||
});
|
||||
expect(resumed.config.featureValues).toEqual({
|
||||
fast_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("reloadAgentSession preserves timeline and does not force history replay", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -629,6 +736,119 @@ describe("AgentManager", () => {
|
||||
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
|
||||
});
|
||||
|
||||
test("setAgentFeature calls session.setFeature and persists featureValues in config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-feature-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class FeatureSession extends TestAgentSession {
|
||||
readonly features: AgentFeature[] = [createFeature()];
|
||||
readonly setFeature = vi.fn(async (featureId: string, value: unknown) => {
|
||||
const feature = this.features.find((item) => item.id === featureId);
|
||||
if (feature?.type === "toggle") {
|
||||
feature.value = Boolean(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class FeatureClient extends TestAgentClient {
|
||||
session: FeatureSession | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new FeatureSession(config);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new FeatureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000128",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
await manager.setAgentFeature(agent.id, "fast_mode", true);
|
||||
|
||||
expect(client.session?.setFeature).toHaveBeenCalledWith("fast_mode", true);
|
||||
expect(manager.getAgent(agent.id)?.config.featureValues).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
test("setAgentFeature throws when session does not support setFeature", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-feature-unsupported-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000129",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
await expect(manager.setAgentFeature(agent.id, "fast_mode", true)).rejects.toThrow(
|
||||
"Agent session does not support setting features",
|
||||
);
|
||||
});
|
||||
|
||||
test("emitState syncs features from session to agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-emit-state-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class FeatureSession extends TestAgentSession {
|
||||
readonly features: AgentFeature[] = [createFeature()];
|
||||
}
|
||||
|
||||
class FeatureClient extends TestAgentClient {
|
||||
session: FeatureSession | null = null;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new FeatureSession(config);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new FeatureClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000130",
|
||||
});
|
||||
|
||||
const events: AgentFeature[][] = [];
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
manager.subscribe((event) => {
|
||||
if (event.type !== "agent_state" || event.agent.id !== agent.id) {
|
||||
return;
|
||||
}
|
||||
events.push(event.agent.features ?? []);
|
||||
});
|
||||
|
||||
if (client.session?.features[0]?.type === "toggle") {
|
||||
client.session.features[0].value = true;
|
||||
}
|
||||
|
||||
manager.notifyAgentState(agent.id);
|
||||
|
||||
expect(manager.getAgent(agent.id)?.features).toEqual([createFeature({ value: true })]);
|
||||
expect(events.at(-1)).toEqual([createFeature({ value: true })]);
|
||||
});
|
||||
|
||||
test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentSlashCommand,
|
||||
AgentMode,
|
||||
@@ -168,6 +169,7 @@ type ManagedAgentBase = {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
pendingReplacement: boolean;
|
||||
@@ -548,6 +550,31 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const normalizedConfig = await this.normalizeConfig(config);
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
throw new Error(
|
||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
try {
|
||||
return session.features ?? [];
|
||||
} finally {
|
||||
try {
|
||||
await session.close();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, provider: normalizedConfig.provider },
|
||||
"Failed to close draft feature listing session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAgent(id: string): ManagedAgent | null {
|
||||
const agent = this.agents.get(id);
|
||||
return agent ? { ...agent } : null;
|
||||
@@ -940,6 +967,19 @@ export class AgentManager {
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
|
||||
if (!agent.session.setFeature) {
|
||||
throw new Error("Agent session does not support setting features");
|
||||
}
|
||||
|
||||
await agent.session.setFeature(featureId, value);
|
||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedTitle = title.trim();
|
||||
@@ -2325,6 +2365,10 @@ export class AgentManager {
|
||||
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||
this.checkAndSetAttention(agent);
|
||||
|
||||
if (agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
agent: { ...agent },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus } from "./agent-manager.js";
|
||||
import { toAgentPayload, toStoredAgentRecord, type ManagedAgent } from "./agent-projections.js";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
AgentSessionConfig,
|
||||
@@ -111,6 +112,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
@@ -291,4 +302,13 @@ describe("toAgentPayload", () => {
|
||||
const payload = toAgentPayload(agent);
|
||||
expect(payload).not.toHaveProperty("lastUsage");
|
||||
});
|
||||
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.features).toEqual(features);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ export function toAgentPayload(
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
features: agent.features,
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: sanitizePersistenceHandle(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
@@ -139,6 +140,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.thinkingOptionId) {
|
||||
serializable.thinkingOptionId = config.thinkingOptionId;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
|
||||
const featureValues = sanitizeMetadata(config.featureValues);
|
||||
if (featureValues !== undefined) {
|
||||
serializable.featureValues = featureValues;
|
||||
}
|
||||
}
|
||||
const extra = sanitizeMetadata(config.extra);
|
||||
if (extra !== undefined) {
|
||||
serializable.extra = extra;
|
||||
|
||||
@@ -64,6 +64,29 @@ export type AgentSelectOption = {
|
||||
metadata?: AgentMetadata;
|
||||
};
|
||||
|
||||
export type AgentFeatureToggle = {
|
||||
type: "toggle";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
export type AgentFeatureSelect = {
|
||||
type: "select";
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
value: string | null;
|
||||
options: AgentSelectOption[];
|
||||
};
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export type AgentCapabilityFlags = {
|
||||
supportsStreaming: boolean;
|
||||
supportsSessionPersistence: boolean;
|
||||
@@ -201,6 +224,10 @@ export type ToolCallDetail =
|
||||
text?: string;
|
||||
icon?: ToolCallIconName;
|
||||
}
|
||||
| {
|
||||
type: "plan";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: "unknown";
|
||||
input: unknown | null;
|
||||
@@ -367,6 +394,7 @@ export type AgentSessionConfig = {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
approvalPolicy?: string;
|
||||
sandboxMode?: string;
|
||||
@@ -392,6 +420,7 @@ export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
@@ -408,12 +437,17 @@ export interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface ListModesOptions {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
@@ -427,6 +461,7 @@ export interface AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
/**
|
||||
* Check if this provider is available (CLI binary is installed).
|
||||
|
||||
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -41,6 +42,9 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
systemPrompt: configOverrides.systemPrompt,
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||
config.featureValues = configOverrides.featureValues;
|
||||
}
|
||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||
const activeForegroundTurnId =
|
||||
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
@@ -150,6 +154,62 @@ describe("AgentStorage", () => {
|
||||
expect(persisted.config?.extra?.claude).toMatchObject({ maxThinkingTokens: 1024 });
|
||||
});
|
||||
|
||||
test("applySnapshot stores and reloads featureValues when present", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-feature-values",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-feature-values");
|
||||
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
test("applySnapshot keeps featureValues absent when they were never set", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-no-feature-values",
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-no-feature-values");
|
||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||
});
|
||||
|
||||
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-resume-overrides",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-resume-overrides");
|
||||
expect(record).not.toBeNull();
|
||||
expect(buildConfigOverrides(record!)).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
const agentId = "agent-created-at";
|
||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
@@ -15,6 +15,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
featureValues: z.record(z.unknown()).nullable().optional(),
|
||||
extra: z.record(z.any()).nullable().optional(),
|
||||
systemPrompt: z.string().nullable().optional(),
|
||||
mcpServers: z.record(z.any()).nullable().optional(),
|
||||
@@ -65,7 +66,14 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
|
||||
export type SerializableAgentConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers"
|
||||
| "title"
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "featureValues"
|
||||
| "extra"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
resolveProviderCommandPrefix,
|
||||
applyProviderEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
|
||||
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
|
||||
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
shell: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let findExecutableDependencies: FindExecutableDependencies;
|
||||
|
||||
beforeEach(() => {
|
||||
findExecutableDependencies = createFindExecutableDependencies();
|
||||
});
|
||||
|
||||
describe("resolveProviderCommandPrefix", () => {
|
||||
test("uses resolved default command in default mode", () => {
|
||||
const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
|
||||
@@ -134,161 +113,3 @@ describe("applyProviderEnv", () => {
|
||||
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExecutable", () => {
|
||||
test("on Windows, resolves executables using current machine and user PATH entries", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
process.env.Path = "C:\\Windows\\System32";
|
||||
findExecutableDependencies.execFileSync.mockImplementation(
|
||||
((command: string, args?: string[]) => {
|
||||
if (command === "powershell") {
|
||||
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
|
||||
}
|
||||
if (command === "where.exe") {
|
||||
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
|
||||
}
|
||||
throw new Error(`unexpected command ${command}`);
|
||||
}) as any,
|
||||
);
|
||||
|
||||
expect(findExecutable("claude", findExecutableDependencies)).toBe(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
|
||||
);
|
||||
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
|
||||
expect(powershellCall?.[0]).toBe("powershell");
|
||||
expect(powershellCall?.[1]).toContain("-NoProfile");
|
||||
expect(powershellCall?.[1]).toContain("-NonInteractive");
|
||||
expect(powershellCall?.[1]).toContain(
|
||||
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
|
||||
);
|
||||
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
|
||||
expect(whereCall?.[0]).toBe("where.exe");
|
||||
expect(whereCall?.[1]).toEqual(["claude"]);
|
||||
expect(whereCall?.[2]?.encoding).toBe("utf8");
|
||||
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
|
||||
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
|
||||
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
|
||||
});
|
||||
|
||||
test("on Windows, preserves the first where.exe match", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
process.env.Path = "C:\\Windows\\System32";
|
||||
findExecutableDependencies.execFileSync.mockImplementation(
|
||||
((command: string) => {
|
||||
if (command === "powershell") {
|
||||
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
|
||||
}
|
||||
if (command === "where.exe") {
|
||||
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
|
||||
}
|
||||
throw new Error(`unexpected command ${command}`);
|
||||
}) as any,
|
||||
);
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("uses the last line from login-shell which output", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue(
|
||||
"echo from profile\n/usr/local/bin/codex\n",
|
||||
);
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
|
||||
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns direct paths when they exist", () => {
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex",
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsCommand", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteWindowsArgument", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
setPlatform("win32");
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
setPlatform("darwin");
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { shellEnvSync } from "shell-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
isCommandAvailable,
|
||||
resolveShellEnv,
|
||||
} from "../../utils/executable.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import { AgentProviderSchema } from "./provider-manifest.js";
|
||||
|
||||
@@ -57,66 +56,6 @@ export type ProviderCommandPrefix = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
interface FindExecutableDependencies {
|
||||
execSync: typeof execSync;
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
platform: typeof platform;
|
||||
shell: string | undefined;
|
||||
}
|
||||
|
||||
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
|
||||
try {
|
||||
const output = deps.execFileSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
[
|
||||
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")',
|
||||
'$user = [Environment]::GetEnvironmentVariable("Path", "User")',
|
||||
"if ($machine) { Write-Output $machine }",
|
||||
"if ($user) { Write-Output $user }",
|
||||
].join("; "),
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return output
|
||||
.split(/\r?\n/)
|
||||
.flatMap((line) => line.split(";"))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutableFromWhichOutput(
|
||||
name: string,
|
||||
output: string,
|
||||
source: "login-shell" | "which",
|
||||
): string | null {
|
||||
const lines = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const candidate = lines.at(-1);
|
||||
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
console.warn(
|
||||
`[findExecutable] Ignoring non-absolute ${source} output for '${name}': ${JSON.stringify(candidate)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function resolveProviderCommandPrefix(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string,
|
||||
@@ -141,18 +80,6 @@ export function resolveProviderCommandPrefix(
|
||||
};
|
||||
}
|
||||
|
||||
let cachedShellEnv: Record<string, string> | null = null;
|
||||
|
||||
export function resolveShellEnv(): Record<string, string> {
|
||||
if (cachedShellEnv) return cachedShellEnv;
|
||||
try {
|
||||
cachedShellEnv = shellEnvSync();
|
||||
} catch {
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
}
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
// Env vars that indicate a running Claude Code session. If the daemon itself is
|
||||
// launched from inside Claude Code (e.g. by a Paseo agent), these leak into
|
||||
// child processes and cause "cannot be launched inside another session" errors.
|
||||
@@ -180,128 +107,6 @@ export function applyProviderEnv(
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an executable name to its absolute path the way the user's shell would.
|
||||
*
|
||||
* On Unix we first try `$SHELL -lic "which <name>"` so that rc-file PATH
|
||||
* additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the
|
||||
* user opened a terminal and typed the command. If that fails (e.g. the login
|
||||
* shell itself errors) we fall back to a plain `which`.
|
||||
*
|
||||
* On Windows we augment the daemon PATH with machine/user registry PATH values
|
||||
* and return the first `where.exe` match. Launch-time execution decides whether
|
||||
* the resolved path needs `cmd.exe` semantics (for example npm shims under
|
||||
* nvm4w such as `C:\nvm4w\nodejs\codex`).
|
||||
*/
|
||||
export function findExecutable(
|
||||
name: string,
|
||||
dependencies?: FindExecutableDependencies,
|
||||
): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deps: FindExecutableDependencies = {
|
||||
execSync,
|
||||
execFileSync,
|
||||
existsSync,
|
||||
platform,
|
||||
shell: process.env["SHELL"],
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return deps.existsSync(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
if (deps.platform() === "win32") {
|
||||
try {
|
||||
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? "";
|
||||
const resolvedPath = [
|
||||
...inheritedPath.split(";"),
|
||||
...resolveWindowsPathEntries(deps),
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.filter((entry, index, entries) => entries.indexOf(entry) === index)
|
||||
.join(";");
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: resolvedPath,
|
||||
Path: resolvedPath,
|
||||
};
|
||||
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
|
||||
return (
|
||||
out
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: try the user's login shell so rc-file PATH entries are visible.
|
||||
const shell = deps.shell;
|
||||
if (shell) {
|
||||
try {
|
||||
const out = deps
|
||||
.execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim();
|
||||
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
} catch {
|
||||
// Login shell failed (broken rc, etc.) — fall through to plain which.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
"which",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When spawning with `shell: true` on Windows, the command is passed to
|
||||
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
|
||||
* command path with spaces (e.g. `C:\Program Files\...`) is split at the
|
||||
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
|
||||
*/
|
||||
export function quoteWindowsCommand(command: string): string {
|
||||
if (process.platform !== "win32") return command;
|
||||
if (!command.includes(" ")) return command;
|
||||
if (command.startsWith('"') && command.endsWith('"')) return command;
|
||||
return `"${command}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `spawn(..., { shell: true })` on Windows also passes argv through `cmd.exe`.
|
||||
* Any argument containing spaces must be quoted or it will be split before the
|
||||
* child process sees it.
|
||||
*/
|
||||
export function quoteWindowsArgument(argument: string): string {
|
||||
if (process.platform !== "win32") return argument;
|
||||
if (!argument.includes(" ")) return argument;
|
||||
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
|
||||
return `"${argument}"`;
|
||||
}
|
||||
|
||||
export function isCommandAvailable(command: string): boolean {
|
||||
return findExecutable(command) !== null;
|
||||
}
|
||||
|
||||
export function isProviderCommandAvailable(
|
||||
commandConfig: ProviderCommand | undefined,
|
||||
resolveDefaultCommand: () => string,
|
||||
|
||||
@@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
|
||||
];
|
||||
|
||||
const CODEX_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "read-only",
|
||||
label: "Read Only",
|
||||
description:
|
||||
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Edit files and run commands but still request approval before escalating scope.",
|
||||
label: "Default Permissions",
|
||||
description: "Edit files and run commands with Codex's default approval flow.",
|
||||
icon: "ShieldAlert",
|
||||
colorTier: "moderate",
|
||||
},
|
||||
@@ -137,18 +129,6 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModel: "haiku",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "claude-acp",
|
||||
label: "Claude ACP",
|
||||
description: "Claude Code via Agent Client Protocol with streaming, permissions, and session resume",
|
||||
defaultModeId: "default",
|
||||
modes: CLAUDE_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
defaultModel: "haiku",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
@@ -157,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
modes: CODEX_MODES,
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "read-only",
|
||||
defaultModeId: "auto",
|
||||
defaultModel: "gpt-5.1-codex-mini",
|
||||
},
|
||||
},
|
||||
@@ -179,6 +159,13 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModeId: "build",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
label: "Pi",
|
||||
description: "Minimal terminal-based coding agent with multi-provider LLM support",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
|
||||
@@ -189,12 +176,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
|
||||
return definition;
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
|
||||
string,
|
||||
...string[],
|
||||
];
|
||||
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
|
||||
|
||||
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
|
||||
export const AgentProviderSchema = z.string();
|
||||
|
||||
export function isValidAgentProvider(value: string): boolean {
|
||||
return AGENT_PROVIDER_IDS.includes(value);
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ListModelsOptions,
|
||||
ListModesOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||
import { ClaudeACPAgentClient } from "./providers/claude-acp-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
|
||||
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
|
||||
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
@@ -26,6 +28,7 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
createClient: (logger: Logger) => AgentClient;
|
||||
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
|
||||
}
|
||||
|
||||
type BuildProviderRegistryOptions = {
|
||||
@@ -43,11 +46,6 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
}),
|
||||
"claude-acp": (logger, runtimeSettings) =>
|
||||
new ClaudeACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["claude-acp"],
|
||||
}),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
copilot: (logger, runtimeSettings) =>
|
||||
new CopilotACPAgentClient({
|
||||
@@ -56,6 +54,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
}),
|
||||
opencode: (logger, runtimeSettings) =>
|
||||
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
|
||||
};
|
||||
|
||||
function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
@@ -81,6 +80,10 @@ export function buildProviderRegistry(
|
||||
...definition,
|
||||
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
||||
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
||||
fetchModes: (listOptions?: ListModesOptions) =>
|
||||
modelClient.listModes
|
||||
? modelClient.listModes(listOptions)
|
||||
: Promise.resolve(definition.modes),
|
||||
} satisfies ProviderDefinition,
|
||||
];
|
||||
}),
|
||||
|
||||
@@ -17,7 +17,7 @@ import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { isCommandAvailable } from "../../provider-launch-config.js";
|
||||
import { isCommandAvailable } from "../../../../utils/executable.js";
|
||||
import { ClaudeAgentClient } from "../claude-agent.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ACPAgentClient,
|
||||
ACPAgentSession,
|
||||
type SessionStateResponse,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
mapACPUsage,
|
||||
} from "./acp-agent.js";
|
||||
import { transformPiModels, transformPiSessionResponse, wrapPiSession } from "./pi-acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
function createSession(): ACPAgentSession {
|
||||
@@ -98,6 +101,32 @@ describe("deriveModesFromACP", () => {
|
||||
currentModeId: "acceptEdits",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
|
||||
const result = deriveModesFromACP(
|
||||
[],
|
||||
null,
|
||||
[
|
||||
{
|
||||
id: "thought_level",
|
||||
name: "Thinking",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "medium",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
modes: [],
|
||||
currentModeId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
@@ -154,7 +183,427 @@ describe("deriveModelDefinitionsFromACP", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient modelTransformer", () => {
|
||||
test("applies modelTransformer after deriving ACP models", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
models: {
|
||||
availableModels: [
|
||||
{
|
||||
modelId: "openrouter/openai/gpt-4.1-mini",
|
||||
name: "openrouter/openai/gpt-4.1-mini",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
currentModelId: "openrouter/openai/gpt-4.1-mini",
|
||||
},
|
||||
configOptions: [],
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
modelTransformer: transformPiModels,
|
||||
});
|
||||
|
||||
await expect(client.listModels()).resolves.toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
const response: SessionStateResponse = {
|
||||
sessionId: "session-1",
|
||||
modes: {
|
||||
availableModes: [
|
||||
{ id: "off", name: "Thinking: Off", description: "No extra reasoning" },
|
||||
{ id: "medium", name: "Thinking: Medium", description: "Balanced reasoning" },
|
||||
{ id: "high", name: "Thinking: High", description: "Deeper reasoning" },
|
||||
],
|
||||
currentModeId: "medium",
|
||||
},
|
||||
models: {
|
||||
availableModels: [
|
||||
{
|
||||
modelId: "openrouter/openai/gpt-4.1-mini",
|
||||
name: "openrouter/openai/gpt-4.1-mini",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
currentModelId: "openrouter/openai/gpt-4.1-mini",
|
||||
},
|
||||
configOptions: [],
|
||||
};
|
||||
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue(response),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
test("remaps Pi thinking modes into thinking options for list probes", async () => {
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
defaultModes: [],
|
||||
modelTransformer: transformPiModels,
|
||||
sessionResponseTransformer: transformPiSessionResponse,
|
||||
});
|
||||
|
||||
await expect(client.listModes()).resolves.toEqual([]);
|
||||
await expect(client.listModels()).resolves.toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
description: "No extra reasoning",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "medium",
|
||||
label: "Medium",
|
||||
description: "Balanced reasoning",
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "high",
|
||||
label: "High",
|
||||
description: "Deeper reasoning",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentClient listModes", () => {
|
||||
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<any> {
|
||||
return {
|
||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||
connection: {
|
||||
newSession: vi.fn().mockResolvedValue({
|
||||
modes: null,
|
||||
configOptions: [
|
||||
{
|
||||
id: "thought_level",
|
||||
name: "Thinking",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "medium",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
initialize: { agentCapabilities: {} },
|
||||
};
|
||||
}
|
||||
|
||||
protected override async closeProbe(): Promise<void> {}
|
||||
}
|
||||
|
||||
const client = new TestACPAgentClient({
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
defaultModes: [],
|
||||
});
|
||||
|
||||
await expect(client.listModes()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformPiModels", () => {
|
||||
test("keeps slash-free labels unchanged", () => {
|
||||
expect(
|
||||
transformPiModels([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "gpt-4.1-mini",
|
||||
label: "GPT 4.1 Mini",
|
||||
description: "Fast",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "gpt-4.1-mini",
|
||||
label: "GPT 4.1 Mini",
|
||||
description: "Fast",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the last path segment as label and preserves existing descriptions", () => {
|
||||
expect(
|
||||
transformPiModels([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "openrouter/openai/gpt-4.1-mini",
|
||||
description: undefined,
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
label: "anthropic/claude-sonnet-4",
|
||||
description: "Balanced",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
label: "claude-sonnet-4",
|
||||
description: "Balanced",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession slash commands", () => {
|
||||
test("caches ACP available commands for listCommands", async () => {
|
||||
const session = createSession();
|
||||
|
||||
expect(await session.listCommands()).toEqual([]);
|
||||
|
||||
(session as any).translateSessionUpdate({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await session.listCommands()).toEqual([
|
||||
{
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
argumentHint: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
test("applies sessionResponseTransformer before deriving modes and thinking state", () => {
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
defaultModes: [],
|
||||
modelTransformer: transformPiModels,
|
||||
sessionResponseTransformer: transformPiSessionResponse,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
(session as any).applySessionState({
|
||||
sessionId: "session-1",
|
||||
modes: {
|
||||
availableModes: [
|
||||
{ id: "low", name: "Thinking: Low", description: "Faster" },
|
||||
{ id: "medium", name: "Thinking: Medium", description: "Balanced" },
|
||||
],
|
||||
currentModeId: "medium",
|
||||
},
|
||||
models: {
|
||||
availableModels: [
|
||||
{
|
||||
modelId: "openrouter/openai/gpt-4.1-mini",
|
||||
name: "openrouter/openai/gpt-4.1-mini",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
currentModelId: "openrouter/openai/gpt-4.1-mini",
|
||||
},
|
||||
configOptions: [],
|
||||
} satisfies SessionStateResponse);
|
||||
|
||||
expect((session as any).availableModes).toEqual([]);
|
||||
expect((session as any).thinkingOptionId).toBe("medium");
|
||||
expect((session as any).availableModels).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/openai/gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini",
|
||||
description: "openrouter/openai/gpt-4.1-mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{
|
||||
id: "low",
|
||||
label: "Low",
|
||||
description: "Faster",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "medium",
|
||||
label: "Medium",
|
||||
description: "Balanced",
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Pi session wrapper hides synthetic modes and exposes them as thinking", async () => {
|
||||
const wrapped = wrapPiSession(
|
||||
{
|
||||
provider: "pi",
|
||||
id: "session-1",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
features: [
|
||||
{
|
||||
type: "select",
|
||||
id: "thought_level",
|
||||
label: "Thinking",
|
||||
value: "medium",
|
||||
options: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: vi.fn(),
|
||||
startTurn: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
streamHistory: async function* () {},
|
||||
getRuntimeInfo: vi.fn(async () => ({
|
||||
provider: "pi",
|
||||
sessionId: "session-1",
|
||||
model: "gpt-4.1-mini",
|
||||
thinkingOptionId: null,
|
||||
modeId: "xhigh",
|
||||
})),
|
||||
getAvailableModes: vi.fn(async () => [
|
||||
{ id: "xhigh", label: "xhigh" },
|
||||
]),
|
||||
getCurrentMode: vi.fn(async () => "xhigh"),
|
||||
setMode: vi.fn(),
|
||||
getPendingPermissions: vi.fn(() => []),
|
||||
respondToPermission: vi.fn(),
|
||||
describePersistence: vi.fn(() => null),
|
||||
interrupt: vi.fn(),
|
||||
close: vi.fn(),
|
||||
setThinkingOption: vi.fn(),
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
await expect(wrapped.getAvailableModes()).resolves.toEqual([]);
|
||||
await expect(wrapped.getCurrentMode()).resolves.toBeNull();
|
||||
await expect(wrapped.getRuntimeInfo()).resolves.toEqual({
|
||||
provider: "pi",
|
||||
sessionId: "session-1",
|
||||
model: "gpt-4.1-mini",
|
||||
thinkingOptionId: "xhigh",
|
||||
modeId: null,
|
||||
});
|
||||
expect(wrapped.features).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: "thought_level",
|
||||
label: "Thinking",
|
||||
value: "xhigh",
|
||||
options: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
||||
|
||||
@@ -71,9 +71,11 @@ import type {
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListModesOptions,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
@@ -83,10 +85,10 @@ import type {
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
|
||||
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -111,6 +113,14 @@ type ACPAgentClientOptions = {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
defaultCommand: [string, ...string[]];
|
||||
defaultModes?: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
};
|
||||
|
||||
@@ -120,6 +130,14 @@ type ACPAgentSessionOptions = {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
defaultCommand: [string, ...string[]];
|
||||
defaultModes: AgentMode[];
|
||||
modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
sessionResponseTransformer?: (response: SessionStateResponse) => SessionStateResponse;
|
||||
toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
handle?: AgentPersistenceHandle;
|
||||
launchEnv?: Record<string, string>;
|
||||
@@ -131,7 +149,7 @@ type SpawnedACPProcess = {
|
||||
initialize: InitializeResponse;
|
||||
};
|
||||
|
||||
type ACPToolSnapshot = {
|
||||
export type ACPToolSnapshot = {
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
kind?: ToolKind | null;
|
||||
@@ -154,7 +172,7 @@ type MessageAssemblyState = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
|
||||
export type SessionStateResponse = NewSessionResponse | LoadSessionResponse | ResumeSessionResponse;
|
||||
|
||||
type TerminalExit = {
|
||||
exitCode?: number | null;
|
||||
@@ -271,6 +289,16 @@ export class ACPAgentClient implements AgentClient {
|
||||
protected readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
protected readonly defaultCommand: [string, ...string[]];
|
||||
protected readonly defaultModes: AgentMode[];
|
||||
private readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
@@ -279,6 +307,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.defaultCommand = options.defaultCommand;
|
||||
this.defaultModes = options.defaultModes ?? [];
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -294,6 +326,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
launchEnv: launchContext?.env,
|
||||
},
|
||||
@@ -329,6 +365,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
defaultCommand: this.defaultCommand,
|
||||
defaultModes: this.defaultModes,
|
||||
modelTransformer: this.modelTransformer,
|
||||
sessionResponseTransformer: this.sessionResponseTransformer,
|
||||
toolSnapshotTransformer: this.toolSnapshotTransformer,
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
handle,
|
||||
launchEnv: launchContext?.env,
|
||||
@@ -345,7 +385,33 @@ export class ACPAgentClient implements AgentClient {
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
return deriveModelDefinitionsFromACP(this.provider, response.models, response.configOptions);
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
const models = deriveModelDefinitionsFromACP(
|
||||
this.provider,
|
||||
transformed.models,
|
||||
transformed.configOptions,
|
||||
);
|
||||
return this.modelTransformer ? this.modelTransformer(models) : models;
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
}
|
||||
|
||||
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
const probe = await this.spawnProcess(undefined);
|
||||
try {
|
||||
const response = await probe.connection.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
const modeInfo = deriveModesFromACP(
|
||||
this.defaultModes,
|
||||
transformed.modes,
|
||||
transformed.configOptions,
|
||||
);
|
||||
return modeInfo.modes;
|
||||
} finally {
|
||||
await this.closeProbe(probe);
|
||||
}
|
||||
@@ -502,6 +568,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
throw new Error(`Expected ${this.provider} config, received ${config.provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected transformSessionResponse(response: SessionStateResponse): SessionStateResponse {
|
||||
return this.sessionResponseTransformer ? this.sessionResponseTransformer(response) : response;
|
||||
}
|
||||
}
|
||||
|
||||
export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
@@ -512,6 +582,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly defaultCommand: [string, ...string[]];
|
||||
private readonly defaultModes: AgentMode[];
|
||||
protected readonly modelTransformer?: (models: AgentModelDefinition[]) => AgentModelDefinition[];
|
||||
private readonly sessionResponseTransformer?: (
|
||||
response: SessionStateResponse,
|
||||
) => SessionStateResponse;
|
||||
private readonly toolSnapshotTransformer?: (snapshot: ACPToolSnapshot) => ACPToolSnapshot;
|
||||
private readonly thinkingOptionWriter?: (
|
||||
connection: ClientSideConnection,
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
||||
@@ -528,11 +608,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private sessionId: string | null = null;
|
||||
private currentMode: string | null = null;
|
||||
private availableModes: AgentMode[];
|
||||
private availableModels: AgentModelDefinition[] = [];
|
||||
private currentModel: string | null = null;
|
||||
private thinkingOptionId: string | null = null;
|
||||
private currentTitle: string | null = null;
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -549,6 +631,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.defaultCommand = options.defaultCommand;
|
||||
this.defaultModes = options.defaultModes;
|
||||
this.modelTransformer = options.modelTransformer;
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.availableModes = options.defaultModes;
|
||||
this.launchEnv = options.launchEnv;
|
||||
this.initialHandle = options.handle;
|
||||
@@ -788,6 +874,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
@@ -825,7 +915,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.agentCapabilities?.sessionCapabilities && "unstable_setSessionModel" in this.connection) {
|
||||
const modelExists = this.availableModels.some((model) => model.id === modelId);
|
||||
if (!modelExists && this.availableModels.length > 0) {
|
||||
throw new Error(`Unknown ${this.provider} model '${modelId}'`);
|
||||
}
|
||||
|
||||
if ("unstable_setSessionModel" in this.connection) {
|
||||
try {
|
||||
await this.connection.unstable_setSessionModel({
|
||||
sessionId: this.sessionId,
|
||||
@@ -859,6 +954,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.thinkingOptionWriter) {
|
||||
await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId);
|
||||
this.thinkingOptionId = thinkingOptionId;
|
||||
return;
|
||||
}
|
||||
|
||||
const option = this.getSelectConfigOption("thought_level");
|
||||
if (!option) {
|
||||
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
|
||||
@@ -984,11 +1085,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const requestId = randomUUID();
|
||||
let toolSnapshot =
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ??
|
||||
mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
|
||||
}
|
||||
const request = mapPermissionRequest(
|
||||
this.provider,
|
||||
requestId,
|
||||
params,
|
||||
this.toolCalls.get(params.toolCall.toolCallId) ?? mergeToolSnapshot(params.toolCall.toolCallId, params.toolCall),
|
||||
toolSnapshot,
|
||||
);
|
||||
|
||||
const promise = new Promise<RequestPermissionResponse>((resolve, reject) => {
|
||||
@@ -1182,13 +1289,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private applySessionState(response: SessionStateResponse): void {
|
||||
this.configOptions = response.configOptions ?? [];
|
||||
const transformed = this.sessionResponseTransformer
|
||||
? this.sessionResponseTransformer(response)
|
||||
: response;
|
||||
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, response.modes, this.configOptions);
|
||||
this.availableModes = modeInfo.modes.length > 0 ? modeInfo.modes : this.defaultModes;
|
||||
this.configOptions = transformed.configOptions ?? [];
|
||||
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
|
||||
this.availableModes = modeInfo.modes;
|
||||
this.availableModels = this.deriveAvailableModels(transformed.models);
|
||||
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
|
||||
|
||||
this.currentModel = response.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
|
||||
this.currentModel =
|
||||
transformed.models?.currentModelId ?? deriveCurrentConfigValue(this.configOptions, "model");
|
||||
this.thinkingOptionId =
|
||||
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
|
||||
}
|
||||
@@ -1231,13 +1344,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return item ? [this.wrapTimeline(item)] : [];
|
||||
}
|
||||
case "tool_call": {
|
||||
const snapshot = mergeToolSnapshot(update.toolCallId, update);
|
||||
let snapshot = mergeToolSnapshot(update.toolCallId, update);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(update.toolCallId, snapshot);
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
case "tool_call_update": {
|
||||
const previous = this.toolCalls.get(update.toolCallId);
|
||||
const snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
|
||||
let snapshot = mergeToolSnapshot(update.toolCallId, update, previous);
|
||||
if (this.toolSnapshotTransformer) {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(update.toolCallId, snapshot);
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
@@ -1256,6 +1375,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.handleUsageUpdate(update);
|
||||
return [];
|
||||
case "available_commands_update":
|
||||
this.cachedCommands = update.availableCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
}));
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
@@ -1298,15 +1422,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): void {
|
||||
this.configOptions = update.configOptions;
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||
if (modeInfo.modes.length > 0) {
|
||||
this.availableModes = modeInfo.modes;
|
||||
}
|
||||
this.availableModes = modeInfo.modes;
|
||||
this.availableModels = this.deriveAvailableModels(null);
|
||||
this.currentMode = modeInfo.currentModeId ?? this.currentMode;
|
||||
this.currentModel = deriveCurrentConfigValue(this.configOptions, "model") ?? this.currentModel;
|
||||
this.thinkingOptionId =
|
||||
deriveCurrentConfigValue(this.configOptions, "thought_level") ?? this.thinkingOptionId;
|
||||
}
|
||||
|
||||
private deriveAvailableModels(
|
||||
models: SessionModelState | null | undefined,
|
||||
): AgentModelDefinition[] {
|
||||
const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
|
||||
return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
|
||||
}
|
||||
|
||||
private handleSessionInfoUpdate(update: SessionInfoUpdate): void {
|
||||
if ("title" in update) {
|
||||
this.currentTitle = update.title ?? null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user