mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 | ||
|
|
9bd5f852e7 | ||
|
|
48516f0b9c | ||
|
|
0bf8e8b5b2 | ||
|
|
994ee488b9 | ||
|
|
a854096c35 | ||
|
|
5d89f9444a | ||
|
|
63905950cc | ||
|
|
ffd07ec17c | ||
|
|
2d63bc3893 | ||
|
|
55acb8a539 | ||
|
|
4c52f272fd | ||
|
|
a91f79053c | ||
|
|
7b4db04a81 | ||
|
|
ac9c2c5642 | ||
|
|
99200eabba | ||
|
|
5f2bb87a17 |
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"
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||
- Searchable model favorites — quickly find and pin preferred models.
|
||||
- Slash command support for OpenCode agents.
|
||||
|
||||
### Improved
|
||||
- Refined model selector UX with better mobile sheet behavior.
|
||||
- Workspace status now uses amber alert styling for "needs input" state.
|
||||
- Themed scrollbar on message input for consistent styling.
|
||||
|
||||
### Fixed
|
||||
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||
- Shell arguments with spaces are now properly quoted on Windows.
|
||||
- Claude models with 1M context support are now correctly reported.
|
||||
|
||||
## 0.1.42 - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
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).
|
||||
|
||||
359
docs/PROVIDERS.md
Normal file
359
docs/PROVIDERS.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Adding a New Provider to Paseo
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
### 1. Create the provider class
|
||||
|
||||
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
||||
|
||||
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
||||
|
||||
```ts
|
||||
import type { Logger } from "pino";
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const MY_PROVIDER_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Standard agent mode",
|
||||
},
|
||||
// Add more modes as needed
|
||||
];
|
||||
|
||||
type MyProviderClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: MyProviderClientOptions) {
|
||||
super({
|
||||
provider: "my-provider", // Must match the ID used everywhere else
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||
defaultModes: MY_PROVIDER_MODES,
|
||||
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
// Override isAvailable() if the provider needs specific auth/env vars
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
if (!(await super.isAvailable())) {
|
||||
return false; // Binary not found
|
||||
}
|
||||
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
||||
|
||||
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
||||
|
||||
```ts
|
||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: CopilotACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "copilot",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return super.isAvailable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add to the provider manifest
|
||||
|
||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
|
||||
First, define the modes with visual metadata:
|
||||
|
||||
```ts
|
||||
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Standard agent mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "autonomous",
|
||||
label: "Autonomous",
|
||||
description: "Runs without prompting",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
||||
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
||||
|
||||
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
||||
|
||||
```ts
|
||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
// ... existing providers ...
|
||||
{
|
||||
id: "my-provider",
|
||||
label: "My Provider",
|
||||
description: "Short description of the provider",
|
||||
defaultModeId: "default",
|
||||
modes: MY_PROVIDER_MODES,
|
||||
// Optional: enable voice
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
defaultModel: "some-model",
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 3. Add the factory to the provider registry
|
||||
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
// ... existing factories ...
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Add a provider icon (app)
|
||||
|
||||
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||
|
||||
```tsx
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface MyProviderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="..." />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
|
||||
```ts
|
||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
||||
|
||||
```ts
|
||||
export const agentConfigs = {
|
||||
// ... existing configs ...
|
||||
"my-provider": {
|
||||
provider: "my-provider",
|
||||
model: "default-model-id",
|
||||
modes: {
|
||||
full: "autonomous", // Mode with no permission prompts
|
||||
ask: "default", // Mode that requires permission approval
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array:
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
|
||||
### 6. Run typecheck
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
This is required after every change per project rules.
|
||||
|
||||
---
|
||||
|
||||
## Direct Provider Checklist
|
||||
|
||||
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(config: AgentSessionConfig, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>, launchContext?: AgentLaunchContext): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentSession`** -- a running agent conversation:
|
||||
|
||||
```ts
|
||||
interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
// Optional:
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Steps
|
||||
|
||||
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||
2. Add to the provider manifest (same as ACP step 2 above)
|
||||
3. Add factory to the registry (same as ACP step 3 above)
|
||||
4. Add icon (same as ACP step 4 above)
|
||||
5. Add E2E config (same as ACP step 5 above)
|
||||
6. Run typecheck
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual testing with the CLI
|
||||
|
||||
Start the daemon if not already running, then:
|
||||
|
||||
```bash
|
||||
# Launch an agent with your provider
|
||||
paseo run --provider my-provider
|
||||
|
||||
# Launch with a specific model and mode
|
||||
paseo run --provider my-provider --model some-model --mode default
|
||||
|
||||
# List running agents
|
||||
paseo ls -a -g
|
||||
|
||||
# Check if the provider reports models
|
||||
paseo models --provider my-provider
|
||||
```
|
||||
|
||||
### E2E test patterns
|
||||
|
||||
The E2E configs in `agent-configs.ts` expose two helpers:
|
||||
|
||||
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
||||
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
||||
|
||||
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
||||
|
||||
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
||||
|
||||
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
||||
|
||||
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
||||
|
||||
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
||||
|
||||
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||
|
||||
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||
@@ -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-RqH1tJ6+pS0XJ4yxxfQ6BRQJfSdsPcxXwCpy+BJ/dhY=";
|
||||
npmDepsHash = "sha256-v8ArSIil8F9dalo+Z+IKcYvGVVgDlLMsSJemI7HT14Q=";
|
||||
|
||||
# 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).
|
||||
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -48,6 +48,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.17.1.tgz",
|
||||
"integrity": "sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.1.tgz",
|
||||
@@ -3510,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",
|
||||
@@ -15425,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",
|
||||
@@ -34962,16 +34996,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"@getpaseo/highlight": "0.1.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.44",
|
||||
"@getpaseo/highlight": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35088,11 +35122,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/relay": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35133,11 +35167,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/cli": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35171,7 +35205,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35372,7 +35406,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35398,7 +35432,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35414,13 +35448,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"@getpaseo/relay": "0.1.42",
|
||||
"@getpaseo/highlight": "0.1.44",
|
||||
"@getpaseo/relay": "0.1.44",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35458,6 +35493,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",
|
||||
@@ -35818,7 +35854,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"@getpaseo/highlight": "0.1.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.44",
|
||||
"@getpaseo/highlight": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -8,7 +8,12 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
toggleFavoriteModel,
|
||||
useFormPreferences,
|
||||
} from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,6 +30,7 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
@@ -42,6 +48,10 @@ type StatusOption = {
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking";
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string;
|
||||
providerOptions?: StatusOption[];
|
||||
@@ -58,6 +68,11 @@ type ControlledAgentStatusBarProps = {
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions?: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
};
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
@@ -142,6 +157,11 @@ function ControlledStatusBar({
|
||||
onSelectThinkingOption,
|
||||
disabled = false,
|
||||
isModelLoading = false,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
canSelectModelProvider,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavoriteModel,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
@@ -205,6 +225,26 @@ function ControlledStatusBar({
|
||||
() => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[modelOptions],
|
||||
);
|
||||
const fallbackAllProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (!modelOptions || modelOptions.length === 0) {
|
||||
return map;
|
||||
}
|
||||
|
||||
map.set(
|
||||
provider,
|
||||
modelOptions.map((option) => ({
|
||||
provider: provider as AgentProvider,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
})),
|
||||
);
|
||||
return map;
|
||||
}, [modelOptions, provider]);
|
||||
const effectiveProviderDefinitions = providerDefinitions ??
|
||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
() => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })),
|
||||
[thinkingOptions],
|
||||
@@ -289,49 +329,36 @@ function ControlledStatusBar({
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Tooltip
|
||||
key={`model-${openSelector === "model" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
<Tooltip
|
||||
key={`model-${displayModel}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId === provider) {
|
||||
onSelectModel?.(modelId);
|
||||
}
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => handleSelectorPress("model")}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === "model") && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ""}
|
||||
onSelect={(id) => onSelectModel?.(id)}
|
||||
searchable={comboboxModelOptions.length > SEARCH_THRESHOLD}
|
||||
open={openSelector === "model"}
|
||||
onOpenChange={handleOpenChange("model")}
|
||||
anchorRef={modelAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
/>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint("model")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
@@ -454,73 +481,38 @@ function ControlledStatusBar({
|
||||
stackBehavior="replace"
|
||||
testID="agent-preferences-sheet"
|
||||
>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === "provider"}
|
||||
onOpenChange={handleOpenChange("provider")}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent provider"
|
||||
testID="agent-preferences-provider"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{providerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
selected={provider.id === selectedProviderId}
|
||||
onSelect={() => onSelectProvider?.(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu
|
||||
open={openSelector === "model"}
|
||||
onOpenChange={handleOpenChange("model")}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{(modelOptions ?? []).map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
selected={model.id === selectedModelId}
|
||||
onSelect={() => onSelectModel?.(model.id)}
|
||||
>
|
||||
{model.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={effectiveProviderDefinitions}
|
||||
allProviderModels={effectiveAllProviderModels}
|
||||
selectedProvider={provider}
|
||||
selectedModel={selectedModelId ?? ""}
|
||||
canSelectProvider={canSelectProviderInModelMenu}
|
||||
onSelect={(selectedProviderId, modelId) => {
|
||||
if (selectedProviderId !== provider) {
|
||||
onSelectProvider?.(selectedProviderId);
|
||||
}
|
||||
onSelectModel?.(modelId);
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={onToggleFavoriteModel}
|
||||
isLoading={isModelLoading}
|
||||
disabled={modelDisabled}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.sheetSelect,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -650,6 +642,35 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
|
||||
const agentProviderModelQuery = useQuery({
|
||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
if (!client || !agent) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return payload.models ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && agentProviderModelQuery.data) {
|
||||
map.set(agent.provider, agentProviderModelQuery.data);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
||||
|
||||
const models = modelsQuery.data ?? null;
|
||||
|
||||
const displayMode =
|
||||
@@ -674,6 +695,10 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const modelOptions = useMemo<StatusOption[]>(() => {
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
}, [models]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
const thinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return (modelSelection.thinkingOptions ?? []).map((option) => ({
|
||||
@@ -693,6 +718,8 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
providerDefinitions={agentProviderDefinitions}
|
||||
allProviderModels={agentProviderModels}
|
||||
onSelectMode={(modeId) => {
|
||||
if (!client) {
|
||||
return;
|
||||
@@ -722,6 +749,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
});
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={(thinkingOptionId) => {
|
||||
@@ -775,6 +808,7 @@ export function DraftAgentStatusBar({
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
@@ -789,6 +823,10 @@ export function DraftAgentStatusBar({
|
||||
const mappedThinkingOptions = useMemo<StatusOption[]>(() => {
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||
}, [thinkingOptions]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
@@ -803,6 +841,12 @@ export function DraftAgentStatusBar({
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
isLoading={isAllModelsLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -820,29 +864,29 @@ export function DraftAgentStatusBar({
|
||||
);
|
||||
}
|
||||
|
||||
const providerOptions = providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
const modelOptions: StatusOption[] = models.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
}));
|
||||
|
||||
const modelOptions: StatusOption[] = [];
|
||||
for (const model of models) {
|
||||
modelOptions.push({ id: model.id, label: model.label });
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerOptions={providerOptions}
|
||||
selectedProviderId={selectedProvider}
|
||||
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
selectedModelId={selectedModel}
|
||||
onSelectModel={onSelectModel}
|
||||
isModelLoading={isModelLoading}
|
||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
|
||||
63
packages/app/src/components/combined-model-selector.test.ts
Normal file
63
packages/app/src/components/combined-model-selector.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
describe("combined model selector helpers", () => {
|
||||
const providerDefinitions = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Claude provider",
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex provider",
|
||||
defaultModeId: "auto",
|
||||
modes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const claudeModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "sonnet-4.6",
|
||||
label: "Sonnet 4.6",
|
||||
},
|
||||
];
|
||||
|
||||
const codexModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
},
|
||||
];
|
||||
|
||||
it("keeps enough data to search by model and provider name", async () => {
|
||||
const rows = buildModelRows(providerDefinitions, new Map([
|
||||
["claude", claudeModels],
|
||||
["codex", codexModels],
|
||||
]));
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
|
||||
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,39 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Platform } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
|
||||
const INLINE_MODEL_THRESHOLD = 8;
|
||||
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
|
||||
|
||||
type DrillDownView = { provider: string };
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
type SelectorView =
|
||||
| { kind: "all" }
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
@@ -25,9 +42,408 @@ interface CombinedModelSelectorProps {
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
isLoading: boolean;
|
||||
canSelectProvider?: (provider: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
renderTrigger?: (input: {
|
||||
selectedModelLabel: string;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
|
||||
if (!models || models.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model";
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function partitionRows(
|
||||
rows: SelectorModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } {
|
||||
const favoriteRows: SelectorModelRow[] = [];
|
||||
const regularRows: SelectorModelRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favoriteRows.push(row);
|
||||
continue;
|
||||
}
|
||||
regularRows.push(row);
|
||||
}
|
||||
|
||||
return { favoriteRows, regularRows };
|
||||
}
|
||||
|
||||
function groupRowsByProvider(
|
||||
rows: SelectorModelRow[],
|
||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||
const grouped = new Map<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.provider);
|
||||
if (existing) {
|
||||
existing.rows.push(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(row.provider, {
|
||||
providerId: row.provider,
|
||||
providerLabel: row.providerLabel,
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
row,
|
||||
isSelected,
|
||||
isFavorite,
|
||||
disabled = false,
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
row: SelectorModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
onToggleFavorite?.(row.provider, row.modelId);
|
||||
},
|
||||
[onToggleFavorite, row.modelId, row.provider],
|
||||
);
|
||||
|
||||
const item = (
|
||||
<ComboboxItem
|
||||
label={row.modelLabel}
|
||||
selected={isSelected}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailingSlot={
|
||||
onToggleFavorite && !disabled ? (
|
||||
<Pressable
|
||||
onPress={handleToggleFavorite}
|
||||
hitSlop={8}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.favoriteButton,
|
||||
hovered && styles.favoriteButtonHovered,
|
||||
pressed && styles.favoriteButtonPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Star
|
||||
size={16}
|
||||
color={
|
||||
isFavorite
|
||||
? theme.colors.palette.amber[500]
|
||||
: hovered
|
||||
? theme.colors.foregroundMuted
|
||||
: theme.colors.border
|
||||
}
|
||||
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isWeb || !row.description) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<View>{item}</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center" offset={4}>
|
||||
<Text style={styles.tooltipText}>{row.description}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function FavoritesSection({
|
||||
favoriteRows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
favoriteRows: SelectorModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (favoriteRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||
</View>
|
||||
{favoriteRows.map((row) => (
|
||||
<ModelRow
|
||||
key={row.favoriteKey}
|
||||
row={row}
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
onPress={() => onSelect(row.provider, row.modelId)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedProviderRows({
|
||||
providerDefinitions,
|
||||
groupedRows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||
const ProvIcon = getProviderIcon(group.providerId);
|
||||
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD;
|
||||
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>
|
||||
{providerDefinition?.label ?? group.providerLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{group.rows.map((row) => (
|
||||
<ModelRow
|
||||
key={row.favoriteKey}
|
||||
row={row}
|
||||
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
||||
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
||||
disabled={!canSelectProvider(row.provider)}
|
||||
onPress={() => onSelect(row.provider, row.modelId)}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{group.rows.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
onBack,
|
||||
}: SelectorContentProps) {
|
||||
const allRows = useMemo(
|
||||
() => buildModelRows(providerDefinitions, allProviderModels),
|
||||
[allProviderModels, providerDefinitions],
|
||||
);
|
||||
|
||||
const scopedRows = useMemo(() => {
|
||||
if (view.kind === "provider") {
|
||||
return allRows.filter((row) => row.provider === view.providerId);
|
||||
}
|
||||
return allRows;
|
||||
}, [allRows, view]);
|
||||
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
|
||||
[normalizedQuery, scopedRows],
|
||||
);
|
||||
|
||||
const { favoriteRows, regularRows } = useMemo(
|
||||
() => partitionRows(visibleRows, favoriteKeys),
|
||||
[favoriteKeys, visibleRows],
|
||||
);
|
||||
|
||||
const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{view.kind === "provider" ? (
|
||||
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} />
|
||||
) : null}
|
||||
|
||||
<SearchInput
|
||||
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
|
||||
<FavoritesSection
|
||||
favoriteRows={favoriteRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
|
||||
{groupedRegularRows.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providerDefinitions={providerDefinitions}
|
||||
groupedRows={groupedRegularRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={16} color="#777" />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderBackButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
onBack,
|
||||
}: {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
|
||||
if (!onBack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombinedModelSelector({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
@@ -35,48 +451,80 @@ export function CombinedModelSelector({
|
||||
selectedModel,
|
||||
onSelect,
|
||||
isLoading,
|
||||
canSelectProvider = () => true,
|
||||
favoriteKeys = new Set<string>(),
|
||||
onToggleFavorite,
|
||||
renderTrigger,
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState<"groups" | DrillDownView>("groups");
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (models && models.length > INLINE_MODEL_THRESHOLD) {
|
||||
setView({ provider: selectedProvider });
|
||||
}
|
||||
} else {
|
||||
setView("groups");
|
||||
setView({ kind: "all" });
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
}
|
||||
},
|
||||
[allProviderModels, selectedProvider],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
setIsOpen(false);
|
||||
setView("groups");
|
||||
setView({ kind: "all" });
|
||||
setSearchQuery("");
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const selectedProviderLabel = useMemo(
|
||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
||||
[providerDefinitions, selectedProvider],
|
||||
);
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (!models) return isLoading ? "Loading..." : "Select model";
|
||||
const model = models.find((m) => m.id === selectedModel);
|
||||
if (!models) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, selectedProvider, selectedModel, isLoading]);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
return selectedModelLabel;
|
||||
}
|
||||
|
||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
||||
}, [selectedModelLabel, selectedProviderLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
setIsContentReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setIsContentReady(true);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, isWeb]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -90,14 +538,26 @@ export function CombinedModelSelector({
|
||||
hovered && styles.triggerHovered,
|
||||
(pressed || isOpen) && styles.triggerPressed,
|
||||
disabled && styles.triggerDisabled,
|
||||
renderTrigger ? styles.customTriggerWrapper : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
{renderTrigger ? (
|
||||
renderTrigger({
|
||||
selectedModelLabel: triggerLabel,
|
||||
onPress: () => handleOpenChange(!isOpen),
|
||||
disabled,
|
||||
isOpen,
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={[]}
|
||||
@@ -105,184 +565,46 @@ export function CombinedModelSelector({
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
stackBehavior="push"
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
title="Select model"
|
||||
>
|
||||
{view === "groups" ? (
|
||||
<GroupsView
|
||||
{isContentReady ? (
|
||||
<SelectorContent
|
||||
view={view}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={handleSelect}
|
||||
onDrillDown={(provider) => {
|
||||
setView({ provider });
|
||||
setSearchQuery("");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DrillDownModelView
|
||||
provider={view.provider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
models={allProviderModels.get(view.provider) ?? []}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={handleSelect}
|
||||
onBack={() => {
|
||||
setView("groups");
|
||||
setSearchQuery("");
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={(providerId, providerLabel) => {
|
||||
setView({ kind: "provider", providerId, providerLabel });
|
||||
}}
|
||||
onBack={
|
||||
view.kind === "provider"
|
||||
? () => {
|
||||
setView({ kind: "all" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
||||
</View>
|
||||
)}
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupsView({
|
||||
providerDefinitions,
|
||||
allProviderModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (provider: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{providerDefinitions.map((def, index) => {
|
||||
const models = allProviderModels.get(def.id) ?? [];
|
||||
const isInline = models.length <= INLINE_MODEL_THRESHOLD;
|
||||
const ProvIcon = getProviderIcon(def.id);
|
||||
|
||||
return (
|
||||
<View key={def.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
|
||||
{isInline ? (
|
||||
<>
|
||||
<View style={styles.sectionHeading}>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sectionHeadingText}>{def.label}</Text>
|
||||
</View>
|
||||
{models.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
selected={model.id === selectedModel && def.id === selectedProvider}
|
||||
onPress={() => onSelect(def.id, model.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => onDrillDown(def.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.drillDownRow,
|
||||
hovered && styles.drillDownRowHovered,
|
||||
pressed && styles.drillDownRowPressed,
|
||||
]}
|
||||
>
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{def.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>{models.length}</Text>
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DrillDownModelView({
|
||||
provider,
|
||||
providerDefinitions,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelect,
|
||||
onBack,
|
||||
}: {
|
||||
provider: string;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
models: AgentModelDefinition[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(provider);
|
||||
const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider;
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return models;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return models.filter(
|
||||
(m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q),
|
||||
);
|
||||
}, [models, searchQuery]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.backButton,
|
||||
hovered && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
]}
|
||||
>
|
||||
<ArrowLeft size={14} color={theme.colors.foregroundMuted} />
|
||||
<ProvIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
|
||||
<SearchInput
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChangeText={onSearchChange}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
/>
|
||||
|
||||
{filteredModels.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.id}
|
||||
label={model.label}
|
||||
description={model.description}
|
||||
selected={model.id === selectedModel && provider === selectedProvider}
|
||||
onPress={() => onSelect(provider, model.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredModels.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
@@ -307,6 +629,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
customTriggerWrapper: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
height: "auto",
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
@@ -374,9 +701,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
favoriteButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
favoriteButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
favoriteButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sheetLoadingState: {
|
||||
minHeight: 160,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sheetLoadingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
50
packages/app/src/components/combined-model-selector.utils.ts
Normal file
50
packages/app/src/components/combined-model-selector.utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow;
|
||||
|
||||
export function resolveProviderLabel(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
providerId: string,
|
||||
): string {
|
||||
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function buildModelRows(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): SelectorModelRow[] {
|
||||
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
|
||||
const rows: SelectorModelRow[] = [];
|
||||
|
||||
for (const definition of providerDefinitions) {
|
||||
const providerLabel = providerLabelMap.get(definition.id) ?? definition.label;
|
||||
for (const model of allProviderModels.get(definition.id) ?? []) {
|
||||
rows.push({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||
provider: definition.id,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [row.modelLabel, row.modelId, row.providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } from "./web-desktop-scrollbar";
|
||||
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -133,8 +133,11 @@ export function DraggableList<T>({
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||
enabled: showCustomScrollbar,
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -177,7 +180,6 @@ export function DraggableList<T>({
|
||||
);
|
||||
|
||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const wrapperStyle = [
|
||||
{ position: "relative" as const },
|
||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||
@@ -193,12 +195,10 @@ export function DraggableList<T>({
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
onScroll={scrollbar.onScroll}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{ListHeaderComponent}
|
||||
{items.length === 0 && ListEmptyComponent}
|
||||
@@ -259,13 +259,7 @@ export function DraggableList<T>({
|
||||
{ListFooterComponent}
|
||||
</>
|
||||
)}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showCustomScrollbar}
|
||||
metrics={scrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
scrollViewRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
@@ -53,10 +50,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
@@ -152,7 +146,9 @@ export function FileExplorerPane({
|
||||
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||
const treeScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -502,24 +498,6 @@ export function FileExplorerPane({
|
||||
});
|
||||
}, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
|
||||
|
||||
const handleTreeListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (showDesktopWebScrollbar) {
|
||||
treeScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||
);
|
||||
|
||||
const handleTreeListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (showDesktopWebScrollbar) {
|
||||
treeScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[showDesktopWebScrollbar, treeScrollbarMetrics],
|
||||
);
|
||||
|
||||
if (!hasWorkspaceScope) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
@@ -598,27 +576,16 @@ export function FileExplorerPane({
|
||||
keyExtractor={(row) => row.entry.path}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={showDesktopWebScrollbar ? handleTreeListLayout : undefined}
|
||||
onScroll={showDesktopWebScrollbar ? handleTreeListScroll : undefined}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar ? treeScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={showDesktopWebScrollbar ? 16 : undefined}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={treeScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
treeListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -7,17 +7,11 @@ import {
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
@@ -123,9 +117,10 @@ function FilePreviewBody({
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
|
||||
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text") {
|
||||
@@ -140,24 +135,6 @@ function FilePreviewBody({
|
||||
return lineNumberGutterWidth(highlightedLines.length);
|
||||
}, [highlightedLines]);
|
||||
|
||||
const handlePreviewScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||
);
|
||||
|
||||
const handlePreviewLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
if (enablePreviewDesktopScrollbar) {
|
||||
previewScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
},
|
||||
[enablePreviewDesktopScrollbar, previewScrollbarMetrics],
|
||||
);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
@@ -197,13 +174,11 @@ function FilePreviewBody({
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
@@ -218,13 +193,7 @@ function FilePreviewBody({
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -236,13 +205,11 @@ function FilePreviewBody({
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
||||
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
||||
onContentSizeChange={
|
||||
enablePreviewDesktopScrollbar ? previewScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
||||
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
@@ -252,13 +219,7 @@ function FilePreviewBody({
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={enablePreviewDesktopScrollbar}
|
||||
metrics={previewScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
||||
}}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,10 +62,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
} from "@/components/web-desktop-scrollbar";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
@@ -429,7 +426,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const diffScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
const diffListViewportHeightRef = useRef(0);
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
@@ -515,11 +514,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onScroll(event);
|
||||
}
|
||||
scrollbar.onScroll(event);
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar],
|
||||
[scrollbar.onScroll],
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback(
|
||||
@@ -529,11 +526,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
if (showDesktopWebScrollbar) {
|
||||
diffScrollbarMetrics.onLayout(event);
|
||||
}
|
||||
scrollbar.onLayout(event);
|
||||
},
|
||||
[diffScrollbarMetrics, showDesktopWebScrollbar],
|
||||
[scrollbar.onLayout],
|
||||
);
|
||||
|
||||
const computeHeaderOffset = useCallback(
|
||||
@@ -844,9 +839,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={
|
||||
showDesktopWebScrollbar ? diffScrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -1085,16 +1078,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
|
||||
<View style={styles.diffContainer}>
|
||||
{bodyContent}
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar && hasChanges}
|
||||
metrics={diffScrollbarMetrics}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
diffListRef.current?.scrollToOffset({
|
||||
offset: nextOffset,
|
||||
animated: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{hasChanges ? scrollbar.overlay : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
18
packages/app/src/components/icons/copilot-icon.tsx
Normal file
18
packages/app/src/components/icons/copilot-icon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface CopilotIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function CopilotIcon({ size = 16, color = "currentColor" }: CopilotIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 512 416" fill={color}>
|
||||
<Path
|
||||
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
<Path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
19
packages/app/src/components/icons/opencode-icon.tsx
Normal file
19
packages/app/src/components/icons/opencode-icon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenCodeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||
<Path d="M320 224V352H192V224H320Z" opacity={0.4} />
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,15 @@ import {
|
||||
Platform,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||
@@ -33,6 +41,7 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import {
|
||||
@@ -570,6 +579,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (IS_WEB) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
}
|
||||
}, [getWebTextArea]);
|
||||
|
||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
|
||||
});
|
||||
|
||||
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
||||
const ref = target === "root" ? rootRef.current : inputWrapperRef.current;
|
||||
if (!ref) return null;
|
||||
@@ -911,42 +932,45 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.surface4}
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true;
|
||||
onFocusChange?.(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false;
|
||||
onFocusChange?.(false);
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
IS_WEB
|
||||
? {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
}
|
||||
: {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={IS_WEB && autoFocus}
|
||||
/>
|
||||
<View style={styles.textInputScrollWrapper}>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={value}
|
||||
onChangeText={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.surface4}
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true;
|
||||
onFocusChange?.(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false;
|
||||
onFocusChange?.(false);
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
IS_WEB
|
||||
? {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
}
|
||||
: {
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
maxHeight: MAX_INPUT_HEIGHT,
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={IS_WEB && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
</View>
|
||||
|
||||
{/* Button row */}
|
||||
<View style={styles.buttonRow}>
|
||||
@@ -1187,6 +1211,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
removeImageButtonVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
textInput: {
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
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";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Archive,
|
||||
CircleAlert,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
@@ -73,7 +74,7 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -100,6 +101,10 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
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",
|
||||
@@ -238,6 +243,14 @@ function WorkspaceStatusIndicator({
|
||||
);
|
||||
}
|
||||
|
||||
if (bucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
@@ -247,6 +260,13 @@ function WorkspaceStatusIndicator({
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
@@ -258,6 +278,10 @@ function WorkspaceStatusIndicator({
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -327,11 +351,26 @@ function ProjectLeadingVisual({
|
||||
);
|
||||
}
|
||||
|
||||
if (activeWorkspace.statusBucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<CircleAlert size={14} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const dotColor = getStatusDotColor({
|
||||
theme,
|
||||
bucket: activeWorkspace.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
@@ -343,6 +382,10 @@ function ProjectLeadingVisual({
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -2184,10 +2227,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 7,
|
||||
height: 7,
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: DEFAULT_STATUS_DOT_SIZE,
|
||||
height: DEFAULT_STATUS_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@@ -23,21 +23,7 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
|
||||
const USER_SCROLL_DELTA_EPSILON = 1;
|
||||
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
|
||||
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style";
|
||||
const WEB_STREAM_SCROLLBAR_STYLE = `
|
||||
#agent-chat-scroll-web-dom-scroll,
|
||||
#agent-chat-scroll-web-dom-virtualized {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
#agent-chat-scroll-web-dom-scroll::-webkit-scrollbar,
|
||||
#agent-chat-scroll-web-dom-virtualized::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`;
|
||||
import { useWebElementScrollbar } from "./use-web-scrollbar";
|
||||
|
||||
function logWebStickyBottom(_event: string, _details: Record<string, unknown>): void {
|
||||
// Intentionally disabled: this path is too noisy during voice debugging.
|
||||
@@ -119,8 +105,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } =
|
||||
require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar");
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
const [followOutput, setFollowOutputr] = useState(true);
|
||||
@@ -142,8 +126,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
const streamScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
||||
const showDesktopWebScrollbar = !isMobileBreakpoint;
|
||||
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
contentRef,
|
||||
});
|
||||
const shouldUseVirtualizer = segments.historyVirtualized.length > 0;
|
||||
const {
|
||||
renderHistoryVirtualizedRow,
|
||||
@@ -271,33 +258,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearBottomChange(true);
|
||||
return;
|
||||
}
|
||||
streamScrollbarMetrics.onContentSizeChange(
|
||||
scrollContainer.clientWidth,
|
||||
scrollContainer.scrollHeight,
|
||||
);
|
||||
streamScrollbarMetrics.onLayout({
|
||||
nativeEvent: {
|
||||
layout: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.clientHeight,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
streamScrollbarMetrics.onScroll({
|
||||
nativeEvent: {
|
||||
contentOffset: { x: 0, y: scrollContainer.scrollTop },
|
||||
contentSize: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.scrollHeight,
|
||||
},
|
||||
layoutMeasurement: {
|
||||
width: scrollContainer.clientWidth,
|
||||
height: scrollContainer.clientHeight,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||
const currentMetrics = {
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
@@ -323,7 +283,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
...currentMetrics,
|
||||
});
|
||||
}
|
||||
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics]);
|
||||
}, [onNearBottomChange, props.agentId]);
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
@@ -711,7 +671,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
return (
|
||||
<>
|
||||
<style id={WEB_STREAM_SCROLLBAR_STYLE_ID}>{WEB_STREAM_SCROLLBAR_STYLE}</style>
|
||||
<div
|
||||
ref={(node) => {
|
||||
scrollContainerRef.current = node;
|
||||
@@ -759,20 +718,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
</div>
|
||||
</div>
|
||||
<WebDesktopScrollbarOverlay
|
||||
enabled={showDesktopWebScrollbar}
|
||||
metrics={streamScrollbarMetrics}
|
||||
inverted={false}
|
||||
onScrollToOffset={(nextOffset) => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" });
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
updateScrollMetrics();
|
||||
}}
|
||||
/>
|
||||
{scrollbarOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ComboboxProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
enableDismissOnClose?: boolean;
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
/**
|
||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||
@@ -145,8 +146,10 @@ export interface ComboboxItemProps {
|
||||
description?: string;
|
||||
kind?: "directory" | "file";
|
||||
leadingSlot?: ReactNode;
|
||||
trailingSlot?: ReactNode;
|
||||
selected?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -156,8 +159,10 @@ export function ComboboxItem({
|
||||
description,
|
||||
kind,
|
||||
leadingSlot,
|
||||
trailingSlot,
|
||||
selected,
|
||||
active,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: ComboboxItemProps): ReactElement {
|
||||
@@ -178,12 +183,14 @@ export function ComboboxItem({
|
||||
return (
|
||||
<Pressable
|
||||
testID={testID}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.comboboxItem,
|
||||
hovered && styles.comboboxItemHovered,
|
||||
pressed && styles.comboboxItemPressed,
|
||||
active && styles.comboboxItemActive,
|
||||
disabled && styles.comboboxItemDisabled,
|
||||
]}
|
||||
>
|
||||
{leadingContent}
|
||||
@@ -197,9 +204,12 @@ export function ComboboxItem({
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{selected ? (
|
||||
<View style={styles.comboboxItemTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
{selected || trailingSlot ? (
|
||||
<View style={styles.comboboxItemTrailingContainer}>
|
||||
<View style={styles.comboboxItemTrailingSlot}>
|
||||
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
|
||||
</View>
|
||||
{trailingSlot}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -233,6 +243,7 @@ export function Combobox({
|
||||
open,
|
||||
onOpenChange,
|
||||
enableDismissOnClose,
|
||||
stackBehavior,
|
||||
desktopPlacement = "top-start",
|
||||
desktopPreventInitialFlash = true,
|
||||
anchorRef,
|
||||
@@ -642,6 +653,7 @@ export function Combobox({
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose={enableDismissOnClose}
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
@@ -777,10 +789,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
comboboxItemDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
comboboxItemTrailingSlot: {
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
comboboxItemTrailingContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginLeft: "auto",
|
||||
},
|
||||
comboboxItemContent: {
|
||||
|
||||
155
packages/app/src/components/use-web-scrollbar.tsx
Normal file
155
packages/app/src/components/use-web-scrollbar.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
Platform,
|
||||
type FlatList,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ScrollView,
|
||||
} from "react-native";
|
||||
import {
|
||||
WebDesktopScrollbarOverlay,
|
||||
useWebDesktopScrollbarMetrics,
|
||||
type ScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||
|
||||
function ensureHideScrollbarStyle(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = HIDE_SCROLLBAR_STYLE_ID;
|
||||
style.textContent =
|
||||
"[data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; }";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
|
||||
return (
|
||||
Math.abs(a.offset - b.offset) > METRICS_EPSILON ||
|
||||
Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON ||
|
||||
Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// ── DOM element scrollbar ────────────────────────────────────────────
|
||||
// Fully automatic: listens to scroll/input/resize events on the element,
|
||||
// hides the native scrollbar, and returns a themed overlay or null.
|
||||
|
||||
export function useWebElementScrollbar(
|
||||
elementRef: RefObject<HTMLElement | null>,
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
): ReactNode {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const contentRef = options?.contentRef;
|
||||
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
element.setAttribute("data-hide-scrollbar", "");
|
||||
(element.style as any).scrollbarWidth = "none";
|
||||
(element.style as any).msOverflowStyle = "none";
|
||||
ensureHideScrollbarStyle();
|
||||
|
||||
function update() {
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
const next: ScrollbarMetrics = {
|
||||
offset: el.scrollTop,
|
||||
viewportSize: el.clientHeight,
|
||||
contentSize: el.scrollHeight,
|
||||
};
|
||||
setMetrics((prev) => (metricsChanged(prev, next) ? next : prev));
|
||||
}
|
||||
|
||||
element.addEventListener("scroll", update, { passive: true });
|
||||
element.addEventListener("input", update, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(element);
|
||||
const contentElement = contentRef?.current;
|
||||
if (contentElement) {
|
||||
resizeObserver.observe(contentElement);
|
||||
}
|
||||
|
||||
update();
|
||||
|
||||
return () => {
|
||||
element.removeEventListener("scroll", update);
|
||||
element.removeEventListener("input", update);
|
||||
resizeObserver.disconnect();
|
||||
element.removeAttribute("data-hide-scrollbar");
|
||||
(element.style as any).scrollbarWidth = "";
|
||||
(element.style as any).msOverflowStyle = "";
|
||||
};
|
||||
}, [contentRef, elementRef, enabled]);
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
(offset: number) => {
|
||||
elementRef.current?.scrollTo({ top: offset, behavior: "auto" });
|
||||
},
|
||||
[elementRef],
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
|
||||
}
|
||||
|
||||
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
||||
// Returns event handlers to wire onto your ScrollView/FlatList plus
|
||||
// a renderable overlay. The overlay is null when disabled.
|
||||
|
||||
interface WebScrollViewScrollbar {
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
overlay: ReactNode;
|
||||
}
|
||||
|
||||
export function useWebScrollViewScrollbar(
|
||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||
options?: { enabled?: boolean },
|
||||
): WebScrollViewScrollbar {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
(offset: number) => {
|
||||
const scrollable = scrollableRef.current;
|
||||
if (!scrollable) return;
|
||||
if ("scrollToOffset" in scrollable) {
|
||||
(scrollable as FlatList).scrollToOffset({ offset, animated: false });
|
||||
} else {
|
||||
(scrollable as ScrollView).scrollTo({ y: offset, animated: false });
|
||||
}
|
||||
},
|
||||
[scrollableRef],
|
||||
);
|
||||
|
||||
const overlay: ReactNode = enabled ? (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metricsHook} onScrollToOffset={onScrollToOffset} />
|
||||
) : null;
|
||||
|
||||
return {
|
||||
onScroll: metricsHook.onScroll,
|
||||
onLayout: metricsHook.onLayout,
|
||||
onContentSizeChange: metricsHook.onContentSizeChange,
|
||||
overlay,
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,7 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
export type ScrollbarMetrics = {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mergeProviderPreferences } from "./use-form-preferences";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
isFavoriteModel,
|
||||
mergeProviderPreferences,
|
||||
toggleFavoriteModel,
|
||||
} from "./use-form-preferences";
|
||||
|
||||
describe("mergeProviderPreferences", () => {
|
||||
it("stores the selected model for a provider", () => {
|
||||
@@ -55,3 +60,92 @@ describe("mergeProviderPreferences", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("favorite model preferences", () => {
|
||||
it("builds a stable favorite key from provider and model", () => {
|
||||
expect(buildFavoriteModelKey({ provider: "claude", modelId: "sonnet-4.6" })).toBe(
|
||||
"claude:sonnet-4.6",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a model to favorites without dropping other preferences", () => {
|
||||
expect(
|
||||
toggleFavoriteModel({
|
||||
preferences: {
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "claude",
|
||||
providerPreferences: {
|
||||
claude: {
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
favoriteModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a model from favorites when toggled again", () => {
|
||||
expect(
|
||||
toggleFavoriteModel({
|
||||
preferences: {
|
||||
favoriteModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
},
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
).toEqual({
|
||||
favoriteModels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports whether a model is favorited", () => {
|
||||
expect(
|
||||
isFavoriteModel({
|
||||
preferences: {
|
||||
favoriteModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
},
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isFavoriteModel({
|
||||
preferences: {
|
||||
favoriteModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
},
|
||||
],
|
||||
},
|
||||
provider: "claude",
|
||||
modelId: "sonnet-4.6",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,20 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
|
||||
export interface FavoriteModelPreference {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface FavoriteModelRow {
|
||||
favoriteKey: string;
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
modelId: string;
|
||||
modelLabel: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
@@ -16,6 +30,12 @@ const providerPreferencesSchema = z.object({
|
||||
const formPreferencesSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
providerPreferences: z.record(providerPreferencesSchema).optional(),
|
||||
favoriteModels: z.array(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
modelId: z.string(),
|
||||
}),
|
||||
).optional(),
|
||||
});
|
||||
|
||||
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
|
||||
@@ -66,6 +86,41 @@ export function mergeProviderPreferences(args: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFavoriteModelKey(input: FavoriteModelPreference): string {
|
||||
return `${input.provider}:${input.modelId}`;
|
||||
}
|
||||
|
||||
export function isFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): boolean {
|
||||
const favoriteKey = buildFavoriteModelKey({ provider: args.provider, modelId: args.modelId });
|
||||
return (args.preferences.favoriteModels ?? []).some(
|
||||
(favorite) => buildFavoriteModelKey(favorite) === favoriteKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleFavoriteModel(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): FormPreferences {
|
||||
const favorite = { provider: args.provider, modelId: args.modelId };
|
||||
const favoriteKey = buildFavoriteModelKey(favorite);
|
||||
const existingFavorites = args.preferences.favoriteModels ?? [];
|
||||
const hasFavorite = existingFavorites.some(
|
||||
(entry) => buildFavoriteModelKey(entry) === favoriteKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...args.preferences,
|
||||
favoriteModels: hasFavorite
|
||||
? existingFavorites.filter((entry) => buildFavoriteModelKey(entry) !== favoriteKey)
|
||||
: [...existingFavorites, favorite],
|
||||
};
|
||||
}
|
||||
|
||||
export function useFormPreferences(): UseFormPreferencesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending } = useQuery({
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,8 @@ import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import {
|
||||
@@ -51,16 +50,14 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (provider === "claude") {
|
||||
return "Claude";
|
||||
}
|
||||
if (provider === "codex") {
|
||||
return "Codex";
|
||||
}
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||
@@ -96,7 +93,7 @@ function useAgentPanelDescriptor(
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
const icon = getProviderIcon(provider) ?? Bot;
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
|
||||
export interface WorkspaceTabPresentation {
|
||||
@@ -21,6 +21,11 @@ export interface WorkspaceTabPresentation {
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = -2;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -3;
|
||||
|
||||
type WorkspaceTabPresentationResolverProps = {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
serverId: string;
|
||||
@@ -114,6 +119,13 @@ export function WorkspaceTabIcon({
|
||||
bucket: presentation.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket)
|
||||
? EMPHASIZED_STATUS_DOT_SIZE
|
||||
: DEFAULT_STATUS_DOT_SIZE;
|
||||
const statusDotOffset =
|
||||
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
const shouldShowLoader = shouldRenderSyncedStatusLoader({
|
||||
bucket: presentation.statusBucket,
|
||||
});
|
||||
@@ -137,6 +149,10 @@ export function WorkspaceTabIcon({
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? theme.colors.surface0,
|
||||
width: statusDotSize,
|
||||
height: statusDotSize,
|
||||
right: statusDotOffset,
|
||||
bottom: statusDotOffset,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -199,10 +215,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 7,
|
||||
height: 7,
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: DEFAULT_STATUS_DOT_SIZE,
|
||||
height: DEFAULT_STATUS_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@@ -193,7 +193,7 @@ const darkSemanticColors = {
|
||||
foregroundMuted: "#A1A5A4",
|
||||
|
||||
// Controls
|
||||
scrollbarHandle: "#71717a", // zinc-500
|
||||
scrollbarHandle: "#717574", // zinc-500 w/ teal tint
|
||||
|
||||
// Borders
|
||||
border: "#252B2A",
|
||||
|
||||
@@ -72,6 +72,11 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/Mac/i.test((navigator as any).platform ?? ""));
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
@@ -280,6 +285,10 @@ export class TerminalEmulatorRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||
return true;
|
||||
|
||||
@@ -25,3 +25,9 @@ export function getStatusDotColor(input: {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isEmphasizedStatusDotBucket(
|
||||
bucket: SidebarStateBucket | null | undefined,
|
||||
): boolean {
|
||||
return bucket === "needs_input" || bucket === "attention";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/relay": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -412,7 +412,7 @@ export async function runRunCommand(
|
||||
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
|
||||
if (!structuredAgent) {
|
||||
structuredAgent = await client.createAgent({
|
||||
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
@@ -511,7 +511,7 @@ export async function runRunCommand(
|
||||
|
||||
// Create the agent
|
||||
const agent = await client.createAgent({
|
||||
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd,
|
||||
title: options.name,
|
||||
modeId: options.mode,
|
||||
|
||||
@@ -18,9 +18,9 @@ export interface LoopRunRow {
|
||||
}
|
||||
|
||||
export interface LoopRunOptions extends CommandOptions {
|
||||
provider?: "claude" | "codex" | "opencode";
|
||||
provider?: string;
|
||||
model?: string;
|
||||
verifyProvider?: "claude" | "codex" | "opencode";
|
||||
verifyProvider?: string;
|
||||
verifyModel?: string;
|
||||
verify?: string;
|
||||
verifyCheck?: string[];
|
||||
|
||||
@@ -45,11 +45,11 @@ export interface LoopRecord {
|
||||
name: string | null;
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
provider: string;
|
||||
model: string | null;
|
||||
workerProvider: "claude" | "codex" | "opencode" | null;
|
||||
workerProvider: string | null;
|
||||
workerModel: string | null;
|
||||
verifierProvider: "claude" | "codex" | "opencode" | null;
|
||||
verifierProvider: string | null;
|
||||
verifierModel: string | null;
|
||||
verifyPrompt: string | null;
|
||||
verifyChecks: string[];
|
||||
@@ -122,11 +122,11 @@ export interface LoopStopPayload {
|
||||
export interface LoopRunInput {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
provider?: "claude" | "codex" | "opencode";
|
||||
provider?: string;
|
||||
model?: string;
|
||||
workerProvider?: "claude" | "codex" | "opencode";
|
||||
workerProvider?: string;
|
||||
workerModel?: string;
|
||||
verifierProvider?: "claude" | "codex" | "opencode";
|
||||
verifierProvider?: string;
|
||||
verifierModel?: string;
|
||||
verifyPrompt?: string;
|
||||
verifyChecks?: string[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/server";
|
||||
|
||||
/** Provider list item for display */
|
||||
export interface ProviderListItem {
|
||||
@@ -9,27 +10,13 @@ export interface ProviderListItem {
|
||||
modes: string;
|
||||
}
|
||||
|
||||
/** Static provider data - providers are built-in and don't require daemon */
|
||||
const PROVIDERS: ProviderListItem[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
status: "available",
|
||||
defaultMode: "auto",
|
||||
modes: "read-only, auto, full-access",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
},
|
||||
];
|
||||
/** Derive provider list from the manifest — single source of truth */
|
||||
const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({
|
||||
provider: def.id,
|
||||
status: "available",
|
||||
defaultMode: def.defaultModeId ?? "default",
|
||||
modes: def.modes.map((m) => m.label).join(", "),
|
||||
}));
|
||||
|
||||
/** Schema for provider ls output */
|
||||
export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ScheduleTarget =
|
||||
| {
|
||||
type: "new-agent";
|
||||
config: {
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
provider: string;
|
||||
cwd: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
|
||||
@@ -54,7 +54,7 @@ let claudeModelsFromJson: ProviderModel[] = [];
|
||||
const ctx = await createE2ETestContext({ timeout: 120000 });
|
||||
|
||||
async function runProviderModelsJson(
|
||||
provider: "claude" | "codex" | "opencode",
|
||||
provider: string,
|
||||
): Promise<ProviderModel[]> {
|
||||
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async function cleanup(): Promise<void> {
|
||||
}
|
||||
|
||||
async function runProviderCase(input: {
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
provider: string;
|
||||
mode: string;
|
||||
model: string;
|
||||
}): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
npmRebuild: false
|
||||
appId: sh.paseo.desktop
|
||||
productName: Paseo
|
||||
executableName: Paseo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"@getpaseo/server": "0.1.42",
|
||||
"@getpaseo/cli": "0.1.44",
|
||||
"@getpaseo/server": "0.1.44",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import {
|
||||
createNodeEntrypointInvocation,
|
||||
resolveDaemonRunnerEntrypoint,
|
||||
runCliJsonCommand,
|
||||
} from "./runtime-paths.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
@@ -405,20 +409,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 = runCliJsonCommand(["daemon", "pair", "--json"]);
|
||||
if (!isRecord(payload)) {
|
||||
throw new Error("Daemon pairing response was not an object.");
|
||||
}
|
||||
@@ -557,7 +548,9 @@ 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(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
@@ -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,34 @@ export function runCliPassthroughCommand(args: string[]): number {
|
||||
|
||||
return result.signal ? 1 : 0;
|
||||
}
|
||||
|
||||
export function runCliJsonCommand(args: string[]): unknown {
|
||||
const invocation = createCliInvocation(args);
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
const stdout = typeof result.stdout === "string" ? 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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.42",
|
||||
"version": "0.1.44",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"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",
|
||||
@@ -60,11 +60,12 @@
|
||||
"test:e2e:ui": "vitest --ui e2e.test.ts"
|
||||
},
|
||||
"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.42",
|
||||
"@getpaseo/relay": "0.1.42",
|
||||
"@getpaseo/highlight": "0.1.44",
|
||||
"@getpaseo/relay": "0.1.44",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -102,6 +103,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",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
resolveProviderCommandPrefix,
|
||||
applyProviderEnv,
|
||||
@@ -255,3 +256,39 @@ describe("quoteWindowsCommand", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,6 +286,18 @@ export function quoteWindowsCommand(command: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface AgentModeVisuals {
|
||||
|
||||
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
|
||||
|
||||
// TODO: `modes` should not be static. Providers (especially ACP) report their
|
||||
// own modes at runtime via session/new. We should fetch modes from the provider
|
||||
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
|
||||
export interface AgentProviderDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -30,37 +30,49 @@ type BuildProviderRegistryOptions = {
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap;
|
||||
};
|
||||
|
||||
type ProviderClientFactory = (
|
||||
logger: Logger,
|
||||
runtimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
) => AgentClient;
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
claude: (logger, runtimeSettings) =>
|
||||
new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
}),
|
||||
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
opencode: (logger, runtimeSettings) =>
|
||||
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
};
|
||||
|
||||
function getProviderClientFactory(provider: string): ProviderClientFactory {
|
||||
const factory = PROVIDER_CLIENT_FACTORIES[provider];
|
||||
if (!factory) {
|
||||
throw new Error(`No provider client factory registered for '${provider}'`);
|
||||
}
|
||||
return factory;
|
||||
}
|
||||
|
||||
export function buildProviderRegistry(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, ProviderDefinition> {
|
||||
const runtimeSettings = options?.runtimeSettings;
|
||||
const claudeClient = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
});
|
||||
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
|
||||
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
|
||||
|
||||
return {
|
||||
claude: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "claude")!,
|
||||
createClient: (logger: Logger) =>
|
||||
new ClaudeAgentClient({ logger, runtimeSettings: runtimeSettings?.claude }),
|
||||
fetchModels: (options) => claudeClient.listModels(options),
|
||||
},
|
||||
codex: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
|
||||
createClient: (logger: Logger) =>
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
fetchModels: (options) => codexClient.listModels(options),
|
||||
},
|
||||
opencode: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
|
||||
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
fetchModels: (options) => opencodeClient.listModels(options),
|
||||
},
|
||||
};
|
||||
return Object.fromEntries(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => {
|
||||
const createClient = getProviderClientFactory(definition.id);
|
||||
const modelClient = createClient(logger, runtimeSettings);
|
||||
return [
|
||||
definition.id,
|
||||
{
|
||||
...definition,
|
||||
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
|
||||
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
|
||||
} satisfies ProviderDefinition,
|
||||
];
|
||||
}),
|
||||
) as Record<AgentProvider, ProviderDefinition>;
|
||||
}
|
||||
|
||||
// Deprecated: Use buildProviderRegistry instead
|
||||
@@ -71,11 +83,12 @@ export function createAllClients(
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
const registry = buildProviderRegistry(logger, options);
|
||||
return {
|
||||
claude: registry.claude.createClient(logger),
|
||||
codex: registry.codex.createClient(logger),
|
||||
opencode: registry.opencode.createClient(logger),
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(registry).map(([provider, definition]) => [
|
||||
provider,
|
||||
definition.createClient(logger),
|
||||
]),
|
||||
) as Record<AgentProvider, AgentClient>;
|
||||
}
|
||||
|
||||
export async function shutdownProviders(
|
||||
|
||||
301
packages/server/src/server/agent/providers/acp-agent.test.ts
Normal file
301
packages/server/src/server/agent/providers/acp-agent.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ACPAgentSession,
|
||||
deriveModelDefinitionsFromACP,
|
||||
deriveModesFromACP,
|
||||
mapACPUsage,
|
||||
} from "./acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
function createSession(): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "claude-acp",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "claude-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["claude", "--acp"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("mapACPUsage", () => {
|
||||
test("maps ACP usage fields into Paseo usage", () => {
|
||||
expect(
|
||||
mapACPUsage({
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
totalTokens: 18,
|
||||
cachedReadTokens: 5,
|
||||
}),
|
||||
).toEqual({
|
||||
inputTokens: 11,
|
||||
outputTokens: 7,
|
||||
cachedInputTokens: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModesFromACP", () => {
|
||||
test("prefers explicit ACP mode state", () => {
|
||||
const result = deriveModesFromACP(
|
||||
[{ id: "fallback", label: "Fallback" }],
|
||||
{
|
||||
availableModes: [
|
||||
{ id: "default", name: "Always Ask", description: "Prompt before tools" },
|
||||
{ id: "plan", name: "Plan", description: "Read only" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", description: "Prompt before tools" },
|
||||
{ id: "plan", label: "Plan", description: "Read only" },
|
||||
],
|
||||
currentModeId: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to config options when explicit mode state is absent", () => {
|
||||
const result = deriveModesFromACP(
|
||||
[{ id: "fallback", label: "Fallback" }],
|
||||
null,
|
||||
[
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: "acceptEdits",
|
||||
options: [
|
||||
{ value: "default", name: "Always Ask" },
|
||||
{ value: "acceptEdits", name: "Accept File Edits" },
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
modes: [
|
||||
{ id: "default", label: "Always Ask", description: undefined },
|
||||
{ id: "acceptEdits", label: "Accept File Edits", description: undefined },
|
||||
],
|
||||
currentModeId: "acceptEdits",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
test("attaches shared thinking options to ACP model state", () => {
|
||||
const result = deriveModelDefinitionsFromACP("claude-acp", {
|
||||
availableModels: [
|
||||
{ modelId: "haiku", name: "Haiku", description: "Fast" },
|
||||
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
|
||||
],
|
||||
currentModelId: "haiku",
|
||||
}, [
|
||||
{
|
||||
id: "reasoning",
|
||||
name: "Reasoning",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: "medium",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
provider: "claude-acp",
|
||||
id: "haiku",
|
||||
label: "Haiku",
|
||||
description: "Fast",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
|
||||
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
|
||||
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{
|
||||
provider: "claude-acp",
|
||||
id: "sonnet",
|
||||
label: "Sonnet",
|
||||
description: "Balanced",
|
||||
isDefault: false,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
|
||||
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
|
||||
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession", () => {
|
||||
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 } }> = [];
|
||||
(session as any).sessionId = "session-1";
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; item?: { type: string; text?: string } });
|
||||
});
|
||||
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: "Hey!" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "assistant-1",
|
||||
content: { type: "text", text: " How are you?" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: "Thinking" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: "thought-1",
|
||||
content: { type: "text", text: " more" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "hel" },
|
||||
} as any,
|
||||
});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "user-1",
|
||||
content: { type: "text", text: "lo" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const timeline = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
.map((event) => event.item)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(timeline).toEqual([
|
||||
{ type: "assistant_message", text: "Hey!" },
|
||||
{ type: "assistant_message", text: " How are you?" },
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "reasoning", text: " more" },
|
||||
{ type: "user_message", text: "hel", messageId: "user-1" },
|
||||
{ type: "user_message", text: "hello", messageId: "user-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string }> = [];
|
||||
let resolvePrompt!: (value: any) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePrompt = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string });
|
||||
});
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
|
||||
expect(prompt).toHaveBeenCalledOnce();
|
||||
expect(events.find((event) => event.type === "turn_started")).toMatchObject({
|
||||
type: "turn_started",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBe(turnId);
|
||||
|
||||
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(events.find((event) => event.type === "turn_completed")).toMatchObject({
|
||||
type: "turn_completed",
|
||||
turnId,
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
|
||||
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
||||
const session = createSession();
|
||||
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
|
||||
let rejectPrompt!: (error: Error) => void;
|
||||
const prompt = vi.fn(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
rejectPrompt = reject;
|
||||
}),
|
||||
);
|
||||
|
||||
(session as any).sessionId = "session-1";
|
||||
(session as any).connection = { prompt };
|
||||
|
||||
session.subscribe((event) => {
|
||||
events.push(event as { type: string; turnId?: string; error?: string });
|
||||
});
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
|
||||
rejectPrompt(new Error("prompt failed"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const turnFailedEvent = events.find((event) => event.type === "turn_failed");
|
||||
expect(turnFailedEvent).toMatchObject({
|
||||
type: "turn_failed",
|
||||
turnId,
|
||||
error: "prompt failed",
|
||||
});
|
||||
expect((session as any).activeForegroundTurnId).toBeNull();
|
||||
});
|
||||
});
|
||||
1936
packages/server/src/server/agent/providers/acp-agent.ts
Normal file
1936
packages/server/src/server/agent/providers/acp-agent.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const CLAUDE_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const CLAUDE_ACP_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Always Ask",
|
||||
description: "Prompts for permission the first time a tool is used",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
label: "Accept File Edits",
|
||||
description: "Automatically approves edit-focused tools without prompting",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
label: "Plan Mode",
|
||||
description: "Analyze the codebase without executing tools or edits",
|
||||
},
|
||||
{
|
||||
id: "bypassPermissions",
|
||||
label: "Bypass",
|
||||
description: "Skip all permission prompts (use with caution)",
|
||||
},
|
||||
];
|
||||
|
||||
type ClaudeACPAgentClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class ClaudeACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: ClaudeACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "claude-acp",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["npx", "-y", "@agentclientprotocol/claude-agent-acp"],
|
||||
defaultModes: CLAUDE_ACP_MODES,
|
||||
capabilities: CLAUDE_ACP_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
if (!(await super.isAvailable())) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(process.env["CLAUDE_CODE_OAUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"]);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
@@ -242,119 +241,23 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
describe("ClaudeAgentClient.listModels", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
function createSupportedModelsQueryMock(models: ModelInfo[]) {
|
||||
return {
|
||||
supportedModels: vi.fn(async () => models),
|
||||
return: vi.fn(async () => ({ done: true, value: undefined })),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns models with required fields", async () => {
|
||||
test("returns hardcoded claude models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const models = await client.listModels();
|
||||
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
|
||||
for (const model of models) {
|
||||
expect(model.provider).toBe("claude");
|
||||
expect(typeof model.id).toBe("string");
|
||||
expect(model.id.length).toBeGreaterThan(0);
|
||||
expect(typeof model.label).toBe("string");
|
||||
expect(model.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const modelIds = models.map((model) => model.id);
|
||||
expect(
|
||||
modelIds.some(
|
||||
(id) =>
|
||||
id.includes("claude") ||
|
||||
id.includes("sonnet") ||
|
||||
id.includes("opus") ||
|
||||
id.includes("haiku"),
|
||||
),
|
||||
).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("prefers provider-discovered Claude defaults and effort levels", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const queryFactory = vi.fn(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.supportedModels).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "claude-sonnet-4-6",
|
||||
isDefault: true,
|
||||
label: "Sonnet 4.6",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves SDK ids even when descriptions are weak", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: vi.fn(() => queryMock) as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "default",
|
||||
label: "Default (recommended)",
|
||||
description: "Recommended model",
|
||||
}),
|
||||
]);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
const defaultModel = models.find((m) => m.isDefault);
|
||||
expect(defaultModel?.id).toBe("claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type ModelInfo,
|
||||
|
||||
type Options,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
mapTaskNotificationUserContentToToolCall,
|
||||
} from "./claude/task-notification-tool-call.js";
|
||||
import {
|
||||
normalizeClaudeModelIdFromText,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./claude/sdk-model-resolver.js";
|
||||
getClaudeModels,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
} from "./claude/claude-models.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
@@ -214,7 +215,10 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
const isDefaultRuntime =
|
||||
resolved.command === "node" || resolved.command === "bun";
|
||||
const command = isDefaultRuntime ? process.execPath : resolved.command;
|
||||
const child = spawn(quoteWindowsCommand(command), resolved.args, {
|
||||
const child = spawn(
|
||||
quoteWindowsCommand(command),
|
||||
resolved.args.map((argument) => quoteWindowsArgument(argument)),
|
||||
{
|
||||
cwd: spawnOptions.cwd,
|
||||
env: {
|
||||
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||
@@ -223,7 +227,8 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
shell: process.platform === "win32",
|
||||
signal: spawnOptions.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
},
|
||||
);
|
||||
if (typeof options.stderr === "function") {
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
options.stderr?.(chunk.toString());
|
||||
@@ -234,10 +239,6 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "max";
|
||||
}
|
||||
@@ -1045,33 +1046,8 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const claudeQuery = this.queryFactory({
|
||||
prompt: createEmptyClaudePrompt(),
|
||||
options: applyRuntimeSettingsToClaudeOptions(
|
||||
{
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
permissionMode: "plan",
|
||||
includePartialMessages: false,
|
||||
settingSources: CLAUDE_SETTING_SOURCES,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
),
|
||||
});
|
||||
|
||||
try {
|
||||
const supportedModels = await claudeQuery.supportedModels();
|
||||
return resolveClaudeModelsFromSdkModels(supportedModels as ModelInfo[]);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
|
||||
throw error;
|
||||
} finally {
|
||||
try {
|
||||
await claudeQuery.return?.();
|
||||
} catch {
|
||||
// ignore control-plane shutdown errors
|
||||
}
|
||||
}
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return getClaudeModels();
|
||||
|
||||
}
|
||||
|
||||
@@ -2693,7 +2669,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.currentMode = message.permissionMode;
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const normalizedRuntimeModel = normalizeClaudeModelIdFromText(message.model);
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
this.logger.debug(
|
||||
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||
"Captured runtime model from SDK init",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude-models.js";
|
||||
|
||||
describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks exactly one model as default", () => {
|
||||
const models = getClaudeModels();
|
||||
const defaults = models.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0]!.id).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("returns fresh copies each call", () => {
|
||||
const a = getClaudeModels();
|
||||
const b = getClaudeModels();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a[0]).not.toBe(b[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
it("returns exact match for known model IDs", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("normalizes dated model IDs to base model", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
|
||||
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("preserves [1m] suffix from runtime model strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
|
||||
});
|
||||
|
||||
it("returns null for empty/null/undefined", () => {
|
||||
expect(normalizeClaudeRuntimeModelId(null)).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId(undefined)).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId("")).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unrecognized strings", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("gpt-5")).toBeNull();
|
||||
expect(normalizeClaudeRuntimeModelId("random")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
|
||||
|
||||
const CLAUDE_THINKING_OPTIONS = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
] as const;
|
||||
|
||||
const CLAUDE_MODELS: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6[1m]",
|
||||
label: "Opus 4.6 1M",
|
||||
description: "Opus 4.6 with 1M context window",
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
isDefault: true,
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
export function getClaudeModels(): AgentModelDefinition[] {
|
||||
return CLAUDE_MODELS.map((model) => ({ ...model }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a runtime model string (from SDK init message) to a known model ID.
|
||||
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
|
||||
*/
|
||||
export function normalizeClaudeRuntimeModelId(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for exact match first (handles claude-opus-4-6[1m] directly)
|
||||
if (CLAUDE_MODELS.some((model) => model.id === trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
|
||||
const runtimeMatch = trimmed.match(
|
||||
/(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = runtimeMatch[1]!.toLowerCase();
|
||||
const major = runtimeMatch[2]!;
|
||||
const minor = runtimeMatch[3]!;
|
||||
const suffix = runtimeMatch[4] ?? "";
|
||||
return `claude-${family}-${major}-${minor}${suffix}`;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
parseClaudeSdkModelDescriptorForTest,
|
||||
resolveClaudeModelsFromSdkModels,
|
||||
} from "./sdk-model-resolver.js";
|
||||
|
||||
describe("resolveClaudeModelsFromSdkModels", () => {
|
||||
const sdkModels: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "Opus 4.6 · Most capable for complex work",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
supportsAdaptiveThinking: true,
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Haiku 4.5 · Fastest for quick answers",
|
||||
},
|
||||
];
|
||||
|
||||
it("parses family and version from SDK descriptions", () => {
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[0]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[1]!)).toEqual({
|
||||
family: "opus",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[2]!)).toEqual({
|
||||
family: "sonnet",
|
||||
version: "4.6",
|
||||
});
|
||||
expect(parseClaudeSdkModelDescriptorForTest(sdkModels[3]!)).toEqual({
|
||||
family: "haiku",
|
||||
version: "4.5",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SDK models to parsed Claude model ids", () => {
|
||||
const models = resolveClaudeModelsFromSdkModels(sdkModels);
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-sonnet-4-6",
|
||||
label: "Sonnet 4.6",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-opus-4-6",
|
||||
label: "Opus 4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
provider: "claude",
|
||||
id: "claude-haiku-4-5",
|
||||
label: "Haiku 4.5",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
|
||||
|
||||
type ParsedClaudeSdkModelDescriptor = {
|
||||
family: "opus" | "sonnet" | "haiku";
|
||||
version: string;
|
||||
};
|
||||
|
||||
// Claude may advertise effort levels that are not usable for all account types.
|
||||
const DISABLED_CLAUDE_THINKING_EFFORT_LEVELS: readonly string[] = ["max"];
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeClaudeVersionId(version: string): string {
|
||||
return version.replace(/\./g, "-");
|
||||
}
|
||||
|
||||
function buildClaudeModelId(parsed: ParsedClaudeSdkModelDescriptor): string {
|
||||
return `claude-${parsed.family}-${normalizeClaudeVersionId(parsed.version)}`;
|
||||
}
|
||||
|
||||
function parseClaudeSdkDescriptor(model: ModelInfo): ParsedClaudeSdkModelDescriptor | null {
|
||||
const description = normalizeWhitespace(model.description ?? "");
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = description.match(/\b(opus|sonnet|haiku)\s+(\d+(?:\.\d+)*)\b/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = match[1].toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = match[2]!;
|
||||
return { family, version };
|
||||
}
|
||||
|
||||
export function normalizeClaudeModelIdFromText(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeWhitespace(value ?? "");
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeMatch = normalized.match(/\b(opus|sonnet|haiku)[-_ ]+(\d+(?:[-.]\d+)*)\b/i);
|
||||
if (!runtimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const family = runtimeMatch[1]!.toLowerCase() as ParsedClaudeSdkModelDescriptor["family"];
|
||||
const version = runtimeMatch[2]!.replace(/-/g, ".");
|
||||
return buildClaudeModelId({ family, version });
|
||||
}
|
||||
|
||||
function buildModelLabel(model: ModelInfo): string {
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
if (!parsed) {
|
||||
return normalizeWhitespace(model.displayName || model.value);
|
||||
}
|
||||
return `${titleCase(parsed.family)} ${parsed.version}`;
|
||||
}
|
||||
|
||||
function buildThinkingOptions(model: ModelInfo): {
|
||||
thinkingOptions?: AgentSelectOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
} {
|
||||
const effortLevels = (model.supportedEffortLevels ?? []).filter(
|
||||
(level) => !DISABLED_CLAUDE_THINKING_EFFORT_LEVELS.includes(level),
|
||||
);
|
||||
if (!model.supportsEffort || effortLevels.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const thinkingOptions: AgentSelectOption[] = effortLevels.map((level) => ({
|
||||
id: level,
|
||||
label: titleCase(level),
|
||||
}));
|
||||
|
||||
return {
|
||||
thinkingOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClaudeModelsFromSdkModels(models: ModelInfo[]): AgentModelDefinition[] {
|
||||
const resolved = new Map<string, AgentModelDefinition>();
|
||||
|
||||
for (const model of models) {
|
||||
const thinking = buildThinkingOptions(model);
|
||||
const parsed = parseClaudeSdkDescriptor(model);
|
||||
const id = parsed ? buildClaudeModelId(parsed) : model.value;
|
||||
const existing = resolved.get(id);
|
||||
resolved.set(id, {
|
||||
provider: "claude",
|
||||
id,
|
||||
label: buildModelLabel(model),
|
||||
description: normalizeWhitespace(model.description ?? model.displayName ?? model.value),
|
||||
isDefault:
|
||||
existing?.isDefault === true || model.value.trim().toLowerCase() === "default" || undefined,
|
||||
...(thinking.thinkingOptions || existing?.thinkingOptions
|
||||
? { thinkingOptions: thinking.thinkingOptions ?? existing?.thinkingOptions }
|
||||
: {}),
|
||||
...(thinking.defaultThinkingOptionId || existing?.defaultThinkingOptionId
|
||||
? {
|
||||
defaultThinkingOptionId:
|
||||
thinking.defaultThinkingOptionId ?? existing?.defaultThinkingOptionId,
|
||||
}
|
||||
: {}),
|
||||
metadata: {
|
||||
sdkValues: Array.from(
|
||||
new Set([...(Array.isArray(existing?.metadata?.sdkValues) ? existing.metadata.sdkValues : []), model.value]),
|
||||
),
|
||||
sdkDisplayNames: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDisplayNames) ? existing.metadata.sdkDisplayNames : []),
|
||||
model.displayName,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
sdkDescriptions: Array.from(
|
||||
new Set([
|
||||
...(Array.isArray(existing?.metadata?.sdkDescriptions) ? existing.metadata.sdkDescriptions : []),
|
||||
model.description,
|
||||
].filter((entry): entry is string => typeof entry === "string" && entry.length > 0)),
|
||||
),
|
||||
supportsEffort: model.supportsEffort === true,
|
||||
supportedEffortLevels: model.supportedEffortLevels,
|
||||
supportsAdaptiveThinking: model.supportsAdaptiveThinking === true,
|
||||
supportsFastMode: model.supportsFastMode === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(resolved.values());
|
||||
}
|
||||
|
||||
export function parseClaudeSdkModelDescriptorForTest(
|
||||
model: ModelInfo,
|
||||
): ParsedClaudeSdkModelDescriptor | null {
|
||||
return parseClaudeSdkDescriptor(model);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
@@ -3420,12 +3421,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
},
|
||||
"Spawning Codex app server",
|
||||
);
|
||||
return spawn(quoteWindowsCommand(launchPrefix.command), [...launchPrefix.args, "app-server"], {
|
||||
detached: process.platform !== "win32",
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
});
|
||||
return spawn(
|
||||
quoteWindowsCommand(launchPrefix.command),
|
||||
[...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)),
|
||||
{
|
||||
detached: process.platform !== "win32",
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const COPILOT_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
label: "Agent",
|
||||
description: "Default agent mode for conversational interactions",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
||||
label: "Plan",
|
||||
description: "Plan mode for creating and executing multi-step plans",
|
||||
},
|
||||
{
|
||||
id: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
label: "Autopilot",
|
||||
description: "Autonomous mode that runs until task completion without user interaction",
|
||||
},
|
||||
];
|
||||
|
||||
type CopilotACPAgentClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: CopilotACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "copilot",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return super.isAvailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
|
||||
import { getFullAccessConfig } from "../../daemon-e2e/agent-configs.js";
|
||||
|
||||
describe("opencode agent commands E2E", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
test("lists available slash commands for an opencode agent", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("opencode"),
|
||||
cwd: "/tmp",
|
||||
title: "OpenCode Commands Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.provider).toBe("opencode");
|
||||
expect(agent.status).toBe("idle");
|
||||
|
||||
const result = await ctx.client.listCommands(agent.id);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.commands.length).toBeGreaterThan(0);
|
||||
|
||||
for (const cmd of result.commands) {
|
||||
expect(cmd.name).toBeTruthy();
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(typeof cmd.argumentHint).toBe("string");
|
||||
expect(cmd.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("returns error for non-existent agent", async () => {
|
||||
const result = await ctx.client.listCommands("non-existent-agent-id");
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error).toContain("Agent not found");
|
||||
expect(result.commands).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import { isCommandAvailable } from "../provider-launch-config.js";
|
||||
import type { AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
|
||||
describe("opencode agent commands contract (real)", () => {
|
||||
test("lists slash commands with the expected contract", async () => {
|
||||
expect(isCommandAvailable("opencode")).toBe(true);
|
||||
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
const commands = await session.listCommands!();
|
||||
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
for (const command of commands) {
|
||||
const typed = command as AgentSlashCommand;
|
||||
expect(typeof typed.name).toBe("string");
|
||||
expect(typed.name.length).toBeGreaterThan(0);
|
||||
expect(typed.name.startsWith("/")).toBe(false);
|
||||
expect(typeof typed.description).toBe("string");
|
||||
expect(typeof typed.argumentHint).toBe("string");
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
@@ -28,10 +29,12 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
@@ -199,6 +202,11 @@ function stringifyUnknownError(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTurnFailureError(error: unknown): string {
|
||||
const normalized = stringifyUnknownError(error).trim();
|
||||
return normalized.length > 0 ? normalized : "Unknown error";
|
||||
}
|
||||
|
||||
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||
@@ -335,7 +343,9 @@ export class OpenCodeServerManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = spawn(
|
||||
quoteWindowsCommand(launchPrefix.command),
|
||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
||||
[...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) =>
|
||||
quoteWindowsArgument(argument),
|
||||
),
|
||||
{
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
@@ -442,7 +452,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
throw new Error("OpenCode session creation returned no data");
|
||||
}
|
||||
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, session.id);
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
@@ -467,7 +477,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId);
|
||||
return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger);
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
@@ -951,11 +961,10 @@ export function translateOpenCodeEvent(
|
||||
if (sessionId === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
const error = props.error as string | undefined;
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: error ?? "Unknown error",
|
||||
error: normalizeTurnFailureError(props.error),
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -972,6 +981,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly config: OpenCodeAgentConfig;
|
||||
private readonly client: OpencodeClient;
|
||||
private readonly sessionId: string;
|
||||
private readonly logger: Logger;
|
||||
private currentMode: string = "default";
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private abortController: AbortController | null = null;
|
||||
@@ -990,11 +1000,18 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||
|
||||
constructor(config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string) {
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger;
|
||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||
}
|
||||
|
||||
@@ -1101,11 +1118,19 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.abortController?.abort();
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
const turnAbortController = this.abortController;
|
||||
turnAbortController?.abort();
|
||||
await this.client.session.abort({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
if (turnId) {
|
||||
this.finishForegroundTurn(
|
||||
{ type: "turn_canceled", provider: "opencode", reason: "interrupted" },
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
@@ -1116,7 +1141,9 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.runningToolCalls.clear();
|
||||
const turnAbortController = new AbortController();
|
||||
this.abortController = turnAbortController;
|
||||
await this.ensureMcpServersConfigured();
|
||||
|
||||
const parts = this.buildPromptParts(prompt);
|
||||
@@ -1126,26 +1153,40 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
|
||||
const effectiveMode = normalizeOpenCodeModeId(this.currentMode);
|
||||
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
parts,
|
||||
...(options?.outputSchema
|
||||
? {
|
||||
format: {
|
||||
type: "json_schema" as const,
|
||||
schema: options.outputSchema as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
let promptResponse;
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
if (slashCommand) {
|
||||
promptResponse = await this.client.session.command({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
command: slashCommand.commandName,
|
||||
arguments: slashCommand.args,
|
||||
...(this.config.model ? { model: this.config.model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
} else {
|
||||
promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
parts,
|
||||
...(options?.outputSchema
|
||||
? {
|
||||
format: {
|
||||
type: "json_schema" as const,
|
||||
schema: options.outputSchema as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (promptResponse.error) {
|
||||
const errorMsg = JSON.stringify(promptResponse.error);
|
||||
const errorMsg = normalizeTurnFailureError(promptResponse.error);
|
||||
this.notifySubscribers({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
@@ -1156,7 +1197,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
void this.consumeEventStream();
|
||||
void this.consumeEventStream(turnId, turnAbortController);
|
||||
|
||||
return { turnId };
|
||||
}
|
||||
@@ -1168,40 +1209,118 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private async consumeEventStream(): Promise<void> {
|
||||
private async consumeEventStream(
|
||||
turnId: string,
|
||||
turnAbortController: AbortController,
|
||||
): Promise<void> {
|
||||
const eventsResult = await this.client.event.subscribe({
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const event of eventsResult.stream) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const translated = this.translateEvent(event);
|
||||
for (const e of translated) {
|
||||
this.notifySubscribers(e);
|
||||
if (e.type === "turn_completed" || e.type === "turn_failed") {
|
||||
this.activeForegroundTurnId = null;
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (e.type === "timeline" && e.item.type === "tool_call") {
|
||||
this.trackToolCall(e.item);
|
||||
}
|
||||
if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") {
|
||||
if (e.type === "turn_failed") {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(e.error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
} else {
|
||||
this.finishForegroundTurn(e, turnId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(e, turnId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.abortController?.signal.aborted) {
|
||||
this.notifySubscribers({
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: error instanceof Error ? error.message : "Stream error",
|
||||
});
|
||||
this.activeForegroundTurnId = null;
|
||||
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_failed",
|
||||
provider: "opencode",
|
||||
error: normalizeTurnFailureError(error),
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (turnAbortController.signal.aborted) {
|
||||
this.finishForegroundTurn(
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
if (this.abortController === turnAbortController && this.activeForegroundTurnId !== turnId) {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
private finishForegroundTurn(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
turnId: string,
|
||||
): void {
|
||||
if (this.activeForegroundTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "turn_canceled" || event.type === "turn_failed") {
|
||||
this.synthesizeInterruptedToolCalls(turnId);
|
||||
} else {
|
||||
this.runningToolCalls.clear();
|
||||
}
|
||||
this.activeForegroundTurnId = null;
|
||||
this.notifySubscribers(event, turnId);
|
||||
}
|
||||
|
||||
private trackToolCall(item: ToolCallTimelineItem): void {
|
||||
if (item.status === "running") {
|
||||
this.runningToolCalls.set(item.callId, item);
|
||||
return;
|
||||
}
|
||||
this.runningToolCalls.delete(item.callId);
|
||||
}
|
||||
|
||||
private synthesizeInterruptedToolCalls(turnId: string): void {
|
||||
for (const item of this.runningToolCalls.values()) {
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: {
|
||||
...item,
|
||||
status: "failed",
|
||||
error: { message: "Tool execution aborted" },
|
||||
},
|
||||
},
|
||||
turnId,
|
||||
);
|
||||
}
|
||||
this.runningToolCalls.clear();
|
||||
}
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
|
||||
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
for (const callback of this.subscribers) {
|
||||
try {
|
||||
@@ -1332,6 +1451,20 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
const result = await this.client.command.list({
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return [];
|
||||
}
|
||||
return result.data.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "",
|
||||
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||
}));
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.currentMode = normalizeOpenCodeModeId(modeId);
|
||||
}
|
||||
@@ -1415,6 +1548,45 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
.map((p) => ({ type: "text", text: p.text }));
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
const withoutPrefix = trimmed.slice(1);
|
||||
const firstWhitespaceIdx = withoutPrefix.search(/\s/);
|
||||
const commandName =
|
||||
firstWhitespaceIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, firstWhitespaceIdx);
|
||||
if (!commandName || commandName.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
const rawArgs =
|
||||
firstWhitespaceIdx === -1 ? "" : withoutPrefix.slice(firstWhitespaceIdx + 1).trim();
|
||||
return rawArgs.length > 0 ? { commandName, args: rawArgs } : { commandName };
|
||||
}
|
||||
|
||||
private async resolveSlashCommandInvocation(
|
||||
prompt: AgentPromptInput,
|
||||
): Promise<{ commandName: string; args?: string } | null> {
|
||||
if (typeof prompt !== "string") {
|
||||
return null;
|
||||
}
|
||||
const parsed = this.parseSlashCommandInput(prompt);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const commands = await this.listCommands();
|
||||
return commands.some((command) => command.name === parsed.commandName) ? parsed : null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, commandName: parsed.commandName },
|
||||
"Failed to resolve slash command; falling back to plain prompt input",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseModel(model?: string): { providerID: string; modelID: string } | undefined {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
|
||||
@@ -106,7 +106,6 @@ import { ScheduleService } from "./schedule/service.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
import { generateLocalPairingOffer } from "./pairing-offer.js";
|
||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||
import { getOrCreateServerId } from "./server-id.js";
|
||||
import { resolveDaemonVersion } from "./daemon-version.js";
|
||||
@@ -282,27 +281,6 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/pairing", async (_req, res) => {
|
||||
try {
|
||||
const offer = await generateLocalPairingOffer({
|
||||
paseoHome: config.paseoHome,
|
||||
relayEnabled: config.relayEnabled,
|
||||
relayEndpoint: config.relayEndpoint,
|
||||
relayPublicEndpoint: config.relayPublicEndpoint,
|
||||
appBaseUrl: config.appBaseUrl,
|
||||
logger,
|
||||
});
|
||||
res.json(offer);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to generate pairing offer");
|
||||
res.status(500).json({
|
||||
relayEnabled: false,
|
||||
url: null,
|
||||
qr: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/files/download", async (req, res) => {
|
||||
const token =
|
||||
typeof req.query.token === "string" && req.query.token.trim().length > 0
|
||||
|
||||
@@ -65,7 +65,7 @@ export function loadConfig(
|
||||
options?.cli?.allowedHosts,
|
||||
]);
|
||||
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true;
|
||||
const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false;
|
||||
|
||||
const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
|
||||
|
||||
export interface AgentTestConfig {
|
||||
provider: "claude" | "codex" | "opencode";
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
modes: {
|
||||
@@ -32,6 +32,14 @@ export const agentConfigs = {
|
||||
ask: "default",
|
||||
},
|
||||
},
|
||||
"claude-acp": {
|
||||
provider: "claude-acp",
|
||||
model: "haiku",
|
||||
modes: {
|
||||
full: "bypassPermissions",
|
||||
ask: "default",
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
@@ -41,6 +49,14 @@ export const agentConfigs = {
|
||||
ask: "auto",
|
||||
},
|
||||
},
|
||||
copilot: {
|
||||
provider: "copilot",
|
||||
model: "claude-haiku-4.5",
|
||||
modes: {
|
||||
full: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
||||
ask: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
},
|
||||
},
|
||||
opencode: {
|
||||
provider: "opencode",
|
||||
model: "opencode/glm-5-free",
|
||||
@@ -96,11 +112,15 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
|
||||
isCommandAvailable("claude") &&
|
||||
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
|
||||
);
|
||||
case "claude-acp":
|
||||
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY);
|
||||
case "codex":
|
||||
return (
|
||||
isCommandAvailable("codex") &&
|
||||
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
|
||||
);
|
||||
case "copilot":
|
||||
return isCommandAvailable("copilot");
|
||||
case "opencode":
|
||||
return isCommandAvailable("opencode");
|
||||
}
|
||||
@@ -109,4 +129,10 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
|
||||
/**
|
||||
* Helper to run a test for each provider.
|
||||
*/
|
||||
export const allProviders: AgentProvider[] = ["claude", "codex", "opencode"];
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { ClaudeACPAgentClient } from "../agent/providers/claude-acp-agent.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getAskModeConfig, getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-claude-acp-"));
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude-acp)", () => {
|
||||
test.runIf(isProviderAvailable("claude-acp"))(
|
||||
"smoke test in full-access mode",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-acp-real-smoke" },
|
||||
});
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-acp-real-smoke",
|
||||
...getFullAccessConfig("claude-acp"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"Reply with exactly: PINEAPPLE",
|
||||
);
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 240_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.persistence).toBeTruthy();
|
||||
expect(finish.final?.persistence?.provider).toBe("claude-acp");
|
||||
expect(finish.final?.persistence?.sessionId).toBeTruthy();
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const assistantText = timeline.entries
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is typeof entry & {
|
||||
item: { type: "assistant_message"; text: string };
|
||||
} => entry.item.type === "assistant_message",
|
||||
)
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
|
||||
expect(assistantText).toContain("PINEAPPLE");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("claude-acp"))(
|
||||
"permission flow in ask mode",
|
||||
async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { "claude-acp": new ClaudeACPAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const targetFile = path.join(cwd, "permission-target.txt");
|
||||
|
||||
try {
|
||||
writeFileSync(targetFile, "ACP_PERMISSION_CONTENT\n", "utf8");
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({
|
||||
subscribe: { subscriptionId: "claude-acp-real-permission" },
|
||||
});
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "claude-acp-real-permission",
|
||||
...getAskModeConfig("claude-acp"),
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
`Use the Bash tool to run exactly: cat ${JSON.stringify(targetFile)}.`,
|
||||
"If approval is required, wait for approval.",
|
||||
"After the command succeeds, reply with exactly: ACP_PERMISSION_DONE",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
const permissionState = await client.waitForFinish(agent.id, 30_000);
|
||||
expect(permissionState.status).toBe("permission");
|
||||
expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0);
|
||||
|
||||
const permission = permissionState.final!.pendingPermissions[0]!;
|
||||
await client.respondToPermission(agent.id, permission.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
|
||||
const finalState = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
const hasPermissionResolved = messages.some((message) => {
|
||||
if (message.type !== "agent_stream") {
|
||||
return false;
|
||||
}
|
||||
if (message.payload.agentId !== agent.id) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
message.payload.event.type === "permission_resolved" &&
|
||||
message.payload.event.requestId === permission.id &&
|
||||
message.payload.event.resolution.behavior === "allow"
|
||||
);
|
||||
});
|
||||
expect(hasPermissionResolved).toBe(true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
420_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient, type WaitForFinishResult } from "../test-utils/daemon-client.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
import type { AgentPermissionRequest } from "../agent/agent-sdk-types.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
const SYSTEM_ERROR_SNIPPET = "A foreground turn is already active";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-send-interrupt-"));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function pickOpenCodeModel(
|
||||
models: Array<{ id: string }>,
|
||||
preferences: string[] = [
|
||||
"minimax-m2.5-free",
|
||||
"kimi-k2.5-free",
|
||||
"glm-5-free",
|
||||
"free",
|
||||
"mini",
|
||||
"gpt-5-nano",
|
||||
],
|
||||
): string {
|
||||
const preferred = models.find((model) =>
|
||||
preferences.some((fragment) => model.id.includes(fragment)),
|
||||
);
|
||||
return preferred?.id ?? models[0]!.id;
|
||||
}
|
||||
|
||||
function hasRunningBashToolCall(messages: SessionOutboundMessage[], agentId: string): boolean {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "tool_call" &&
|
||||
message.payload.event.item.status === "running" &&
|
||||
["bash", "shell"].includes(message.payload.event.item.name.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
|
||||
return messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "assistant_message",
|
||||
)
|
||||
.map((message) => message.payload.event.item.text);
|
||||
}
|
||||
|
||||
function findSystemErrorText(texts: string[]): string | null {
|
||||
return texts.find((text) => text.includes("[System Error]")) ?? null;
|
||||
}
|
||||
|
||||
function getTimelineAssistantTexts(
|
||||
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
|
||||
): string[] {
|
||||
return timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text);
|
||||
}
|
||||
|
||||
function findSleepToolCall(
|
||||
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
|
||||
): { status: "running" | "completed" | "failed" | "canceled"; callId: string } | null {
|
||||
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
|
||||
const entry = timeline.entries[idx];
|
||||
if (entry?.item.type !== "tool_call") {
|
||||
continue;
|
||||
}
|
||||
if (entry.item.detail.type !== "shell") {
|
||||
continue;
|
||||
}
|
||||
if (!entry.item.detail.command.includes("sleep 60")) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
status: entry.item.status,
|
||||
callId: entry.item.callId,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function allowPermission(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
permission: AgentPermissionRequest,
|
||||
): Promise<void> {
|
||||
if (permission.kind === "question") {
|
||||
throw new Error(
|
||||
`Unexpected question permission while waiting for tool call: ${permission.id} ${permission.title}`,
|
||||
);
|
||||
}
|
||||
await client.respondToPermission(agentId, permission.id, {
|
||||
behavior: "allow",
|
||||
message: "Approved by integration test",
|
||||
});
|
||||
}
|
||||
|
||||
async function approvePendingPermissions(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
handledPermissionIds: Set<string>,
|
||||
): Promise<void> {
|
||||
const snapshot = await client.fetchAgent(agentId).catch(() => null);
|
||||
const pending = snapshot?.agent.pendingPermissions ?? [];
|
||||
for (const permission of pending) {
|
||||
if (handledPermissionIds.has(permission.id)) {
|
||||
continue;
|
||||
}
|
||||
handledPermissionIds.add(permission.id);
|
||||
await allowPermission(client, agentId, permission);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunningBashToolCall(
|
||||
client: DaemonClient,
|
||||
collector: ReturnType<typeof createMessageCollector>,
|
||||
agentId: string,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const handledPermissionIds = new Set<string>();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await approvePendingPermissions(client, agentId, handledPermissionIds);
|
||||
|
||||
const streamSystemError = findSystemErrorText(getAssistantTexts(collector.messages, agentId));
|
||||
if (streamSystemError) {
|
||||
throw new Error(`OpenCode failed before tool call started: ${streamSystemError}`);
|
||||
}
|
||||
|
||||
if (hasRunningBashToolCall(collector.messages, agentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
|
||||
const timelineSystemError = timeline
|
||||
? findSystemErrorText(getTimelineAssistantTexts(timeline).slice(-8))
|
||||
: null;
|
||||
if (timelineSystemError) {
|
||||
throw new Error(`OpenCode failed before tool call started: ${timelineSystemError}`);
|
||||
}
|
||||
if (
|
||||
timeline?.entries.some(
|
||||
(entry) =>
|
||||
entry.item.type === "tool_call" &&
|
||||
entry.item.status === "running" &&
|
||||
["bash", "shell"].includes(entry.item.name.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 120 }).catch(() => null);
|
||||
const recentToolCalls =
|
||||
timeline?.entries
|
||||
.filter((entry) => entry.item.type === "tool_call")
|
||||
.slice(-10)
|
||||
.map((entry) => ({
|
||||
name: entry.item.name,
|
||||
status: entry.item.status,
|
||||
callId: entry.item.callId,
|
||||
})) ?? [];
|
||||
const recentAssistantTexts = timeline ? getTimelineAssistantTexts(timeline).slice(-6) : [];
|
||||
throw new Error(
|
||||
`Timed out waiting for running bash/shell tool call. recentToolCalls=${JSON.stringify(recentToolCalls)} recentAssistantTexts=${JSON.stringify(recentAssistantTexts)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSleepToolCallTerminal(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<{ status: "completed" | "failed" | "canceled"; callId: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 });
|
||||
const sleepToolCall = findSleepToolCall(timeline);
|
||||
if (sleepToolCall && sleepToolCall.status !== "running") {
|
||||
return {
|
||||
status: sleepToolCall.status,
|
||||
callId: sleepToolCall.callId,
|
||||
};
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 200 }).catch(() => null);
|
||||
const recentToolCalls =
|
||||
timeline?.entries
|
||||
.filter((entry) => entry.item.type === "tool_call")
|
||||
.slice(-10)
|
||||
.map((entry) => ({
|
||||
callId: entry.item.callId,
|
||||
name: entry.item.name,
|
||||
status: entry.item.status,
|
||||
})) ?? [];
|
||||
throw new Error(
|
||||
`Timed out waiting for interrupted sleep tool call to become terminal. recentToolCalls=${JSON.stringify(recentToolCalls)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForIdleResolvingPermissions(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<WaitForFinishResult> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const handledPermissionIds = new Set<string>();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const remaining = Math.max(1, deadline - Date.now());
|
||||
const result = await client.waitForFinish(agentId, Math.min(remaining, 45_000));
|
||||
if (result.status !== "permission") {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pendingPermissions = result.final?.pendingPermissions ?? [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
throw new Error("waitForFinish reported permission but no pending permissions were present");
|
||||
}
|
||||
|
||||
let resolvedAny = false;
|
||||
for (const permission of pendingPermissions) {
|
||||
if (handledPermissionIds.has(permission.id)) {
|
||||
continue;
|
||||
}
|
||||
handledPermissionIds.add(permission.id);
|
||||
await allowPermission(client, agentId, permission);
|
||||
resolvedAny = true;
|
||||
}
|
||||
|
||||
if (!resolvedAny) {
|
||||
throw new Error(
|
||||
"Permission wait loop made no progress; all permissions were already handled",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "timeout",
|
||||
final: null,
|
||||
error: `Timed out waiting for idle after ${timeoutMs}ms`,
|
||||
lastMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
client: DaemonClient;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } });
|
||||
return { client, daemon };
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - send while working and interrupt", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"send_message while sleep tool call is running starts a clean replacement turn",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
const collector = createMessageCollector(client);
|
||||
const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK";
|
||||
|
||||
try {
|
||||
const modelList = await client.listProviderModels("opencode");
|
||||
expect(modelList.models.length).toBeGreaterThan(0);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode send while working",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 60",
|
||||
"Do not run it in the background.",
|
||||
"Do not do anything after starting the command.",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
90_000,
|
||||
);
|
||||
await waitForRunningBashToolCall(client, collector, agent.id);
|
||||
|
||||
collector.clear();
|
||||
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
|
||||
|
||||
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id);
|
||||
expect(
|
||||
postSendAssistantTexts.some((text) => text.includes("[System Error]")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
|
||||
).toBe(false);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
|
||||
const assistantTexts = getTimelineAssistantTexts(timeline);
|
||||
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
|
||||
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
|
||||
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
|
||||
} finally {
|
||||
collector.unsubscribe();
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
360_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"explicit interrupt during sleep tool call still allows the next turn to complete",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
const collector = createMessageCollector(client);
|
||||
const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK";
|
||||
|
||||
try {
|
||||
const modelList = await client.listProviderModels("opencode");
|
||||
expect(modelList.models.length).toBeGreaterThan(0);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode explicit interrupt",
|
||||
model: pickOpenCodeModel(modelList.models),
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
[
|
||||
"Use the Bash tool.",
|
||||
"Run exactly: sleep 60",
|
||||
"Do not run it in the background.",
|
||||
"Do not do anything after starting the command.",
|
||||
].join(" "),
|
||||
);
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
90_000,
|
||||
);
|
||||
await waitForRunningBashToolCall(client, collector, agent.id);
|
||||
|
||||
await client.cancelAgent(agent.id);
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
|
||||
90_000,
|
||||
);
|
||||
const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000);
|
||||
expect(interruptedToolCall.status).toBe("failed");
|
||||
|
||||
collector.clear();
|
||||
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
|
||||
|
||||
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id);
|
||||
expect(
|
||||
postInterruptAssistantTexts.some((text) => text.includes("[System Error]")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
|
||||
).toBe(false);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
|
||||
const assistantTexts = getTimelineAssistantTexts(timeline);
|
||||
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
|
||||
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
|
||||
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
|
||||
} finally {
|
||||
collector.unsubscribe();
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
360_000,
|
||||
);
|
||||
});
|
||||
@@ -33,9 +33,16 @@ export {
|
||||
export {
|
||||
findExecutable,
|
||||
applyProviderEnv,
|
||||
quoteWindowsArgument,
|
||||
quoteWindowsCommand,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
|
||||
// Provider manifest (source of truth for provider definitions)
|
||||
export {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} from "./agent/provider-manifest.js";
|
||||
|
||||
// Agent SDK types for CLI commands
|
||||
export type {
|
||||
AgentMode,
|
||||
|
||||
@@ -96,4 +96,25 @@ describe("file explorer service", () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked files that resolve outside the workspace", async () => {
|
||||
const root = await createTempDir("paseo-file-explorer-");
|
||||
const outsideRoot = await createTempDir("paseo-file-explorer-outside-");
|
||||
|
||||
try {
|
||||
const externalFile = path.join(outsideRoot, "secret.txt");
|
||||
await writeFile(externalFile, "top secret\n", "utf-8");
|
||||
await symlink(externalFile, path.join(root, "secret-link.txt"));
|
||||
|
||||
await expect(
|
||||
readExplorerFile({
|
||||
root,
|
||||
relativePath: "secret-link.txt",
|
||||
}),
|
||||
).rejects.toThrow("Access outside of workspace is not allowed");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outsideRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,11 +210,25 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams)
|
||||
const requestedPath = path.resolve(normalizedRoot, relativePath);
|
||||
const relative = path.relative(normalizedRoot, requestedPath);
|
||||
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
return requestedPath;
|
||||
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
}
|
||||
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
const realRoot = await fs.realpath(normalizedRoot);
|
||||
|
||||
try {
|
||||
const realPath = await fs.realpath(requestedPath);
|
||||
const realRelative = path.relative(realRoot, realPath);
|
||||
if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) {
|
||||
throw new Error("Access outside of workspace is not allowed");
|
||||
}
|
||||
return requestedPath;
|
||||
} catch (error) {
|
||||
if (isMissingEntryError(error)) {
|
||||
return requestedPath;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildEntryPayload({
|
||||
|
||||
@@ -71,11 +71,11 @@ const LoopRecordSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
prompt: z.string(),
|
||||
cwd: z.string(),
|
||||
provider: z.enum(["claude", "codex", "opencode"]),
|
||||
provider: z.string(),
|
||||
model: z.string().nullable(),
|
||||
workerProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
|
||||
workerProvider: z.string().nullable(),
|
||||
workerModel: z.string().nullable(),
|
||||
verifierProvider: z.enum(["claude", "codex", "opencode"]).nullable(),
|
||||
verifierProvider: z.string().nullable(),
|
||||
verifierModel: z.string().nullable(),
|
||||
verifyPrompt: z.string().nullable(),
|
||||
verifyChecks: z.array(z.string()),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
@@ -14,10 +15,6 @@ function getLogger(logger: LoggerLike): LoggerLike {
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
return provider === "claude" || provider === "codex" || provider === "opencode";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||
* agent_state snapshot is flushed to disk.
|
||||
@@ -54,7 +51,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
}
|
||||
|
||||
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (!isKnownProvider(record.provider)) {
|
||||
if (!isValidAgentProvider(record.provider)) {
|
||||
throw new Error(`Unknown provider '${record.provider}'`);
|
||||
}
|
||||
const overrides = buildConfigOverrides(record);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readFile, stat } from "fs/promises";
|
||||
import { exec } from "child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { promisify } from "util";
|
||||
import { join, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -185,6 +185,7 @@ import {
|
||||
} from "./worktree-session.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
@@ -3035,6 +3036,9 @@ export class Session {
|
||||
}
|
||||
|
||||
private assertSafeGitRef(ref: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
|
||||
throw new Error(`Invalid ${label}: ${ref}`);
|
||||
}
|
||||
assertWorktreeSafeGitRef(ref, label);
|
||||
}
|
||||
|
||||
@@ -3206,7 +3210,7 @@ export class Session {
|
||||
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Branch not found: ${branch}`);
|
||||
}
|
||||
@@ -3220,7 +3224,7 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execAsync(`git checkout ${branch}`, { cwd });
|
||||
await execFileAsync("git", ["checkout", branch], { cwd });
|
||||
}
|
||||
|
||||
private async createBranchFromBase(params: {
|
||||
@@ -3230,9 +3234,10 @@ export class Session {
|
||||
}): Promise<void> {
|
||||
const { cwd, baseBranch, newBranchName } = params;
|
||||
this.assertSafeGitRef(baseBranch, "base branch");
|
||||
this.assertSafeGitRef(newBranchName, "new branch");
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
|
||||
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||
}
|
||||
@@ -3243,14 +3248,15 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
|
||||
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
|
||||
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd,
|
||||
});
|
||||
return true;
|
||||
@@ -3653,10 +3659,11 @@ export class Session {
|
||||
|
||||
try {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
this.assertSafeGitRef(branchName, "branch");
|
||||
|
||||
// Try local branch first
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -3677,7 +3684,7 @@ export class Session {
|
||||
|
||||
// Try remote branch (origin/{branchName})
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${branchName}`, {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
|
||||
const speechTest = shouldRun ? test : test.skip;
|
||||
|
||||
type VoiceRoundtripProvider = "claude" | "codex" | "opencode";
|
||||
type VoiceRoundtripProvider = string;
|
||||
|
||||
function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): {
|
||||
provider: VoiceRoundtripProvider;
|
||||
|
||||
@@ -609,7 +609,7 @@ program
|
||||
// Agent runner
|
||||
|
||||
interface AgentConfig {
|
||||
cli: "claude" | "codex";
|
||||
cli: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type TaskStatus = "draft" | "open" | "in_progress" | "done" | "failed";
|
||||
|
||||
export type AgentType = "claude" | "codex";
|
||||
export type AgentType = string;
|
||||
|
||||
export type ModelName = "haiku" | "sonnet" | "opus" | `gpt-${string}`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.42",
|
||||
"version": "0.1.44",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 172 KiB |
@@ -171,7 +171,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"propertyNames": {
|
||||
"enum": ["claude", "codex", "opencode"]
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -223,8 +223,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["claude", "codex", "opencode"]
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
|
||||
Reference in New Issue
Block a user