Compare commits

..
7 Commits
Author SHA1 Message Date
Mohamed Boudra 0820ee14f1 ci: trigger nix build on highlight changes 2026-03-25 23:20:48 +07:00
José Albornoz 49ebc26cdb integrate with npm run build:daemon 2026-03-25 22:52:43 +07:00
José Albornoz 6bad956c8f fix hash update script, and update hash 2026-03-25 22:52:43 +07:00
José Albornoz 233b309fd9 better document npm workaround 2026-03-25 22:52:43 +07:00
José Albornoz ae4123be10 don't block PRs on nix failures 2026-03-25 22:52:43 +07:00
José Albornoz 6b0ff42748 feat: add Nix flake with package and NixOS module
Add a Nix flake that builds the Paseo daemon (server + CLI) and
provides a NixOS module for declarative deployment.

Package (nix/package.nix):
- Builds relay, server, and CLI workspaces
- Skips onnxruntime-node install script (sandbox-incompatible)
- Rebuilds only node-pty for native terminal support
- Source filter excludes app/website/desktop workspaces

NixOS module (nix/module.nix):
- Systemd service with configurable user, port, listen address
- allowedHosts for DNS rebinding protection
- relay.enable to toggle remote access via app.paseo.sh
- inheritUserEnvironment to expose user tools (git, ssh) to agents
- openFirewall and extra environment variables

ci: add Nix hash maintenance scripts and workflows

scripts/fix-lockfile.mjs:
  Adds missing resolved/integrity fields to package-lock.json for
  workspace-local overrides. Idempotent, uses `npm view`.

scripts/update-nix.sh:
  Runs fix-lockfile.mjs, prefetches deps, computes NAR hash, and
  updates npmDepsHash in nix/package.nix. Supports --check for CI.

.github/workflows/nix-build.yml:
  Builds the Nix package on push/PR and verifies the lockfile and
  hash are up to date.

.github/workflows/fix-nix-hash.yml:
  Auto-fixes lockfile signatures and Nix hash on dependabot PRs.

fix: update npmDepsHash after upstream sync

nix: allowlist workspace symlinks instead of blocklist

Prevents build failures when upstream adds new workspace packages.
2026-03-25 22:52:43 +07:00
José Albornoz 627dc21736 fix: add missing resolved/integrity fields to package-lock.json
npm omits resolved URLs and integrity hashes for workspace-local
node_modules overrides. This breaks offline installers like Nix's
npm ci. Add the missing fields for 25 workspace-hoisted packages.
2026-03-25 22:52:43 +07:00
244 changed files with 5653 additions and 17985 deletions
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-2
View File
@@ -9,8 +9,6 @@ on:
- 'package-lock.json'
- 'patches/**'
- '.github/workflows/deploy-website.yml'
release:
types: [published, edited]
workflow_dispatch:
jobs:
+3 -3
View File
@@ -124,7 +124,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -230,7 +230,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
@@ -339,7 +339,7 @@ jobs:
release_type="release"
fi
else
release_type="draft"
release_type="release"
fi
echo "RELEASE_TYPE=$release_type" >> "$GITHUB_ENV"
+2 -6
View File
@@ -1,11 +1,6 @@
name: Fix Nix hash
on:
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- 'package.json'
@@ -17,10 +12,11 @@ permissions:
jobs:
fix-nix-hash:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref }}
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
+1 -1
View File
@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: ubuntu-latest
continue-on-error: false
continue-on-error: true
steps:
- uses: actions/checkout@v4
-49
View File
@@ -1,54 +1,5 @@
# Changelog
## 0.1.39 - 2026-03-30
### Added
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
### Fixed
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
## 0.1.38 - 2026-03-30
### Fixed
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
## 0.1.37 - 2026-03-29
### Added
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
### Fixed
- Fixed broken PATH propagation and Claude binary resolution on Windows.
- Dictation errors now show a visible toast instead of failing silently.
## 0.1.36 - 2026-03-27
### Fixed
- Fixed Windows drive-letter path handling across the codebase.
- Fixed stale Nix hash with automatic lockfile-change detection.
### Added
- Added metrics collection and terminal performance tests.
## 0.1.35 - 2026-03-26
### Improved
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
- Codex file deletions now display correctly as removed lines in diffs.
- OpenCode questions are now surfaced in the permission UI.
### Fixed
- Fixed queued prompt dispatch after idle transition.
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
### Added
- Added support for Nix and NixOS installation.
## 0.1.34 - 2026-03-25
### Added
+10 -74
View File
@@ -4,100 +4,40 @@
<h1 align="center">Paseo</h1>
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
<p align="center">One interface for all your coding agents.</p>
<p align="center">
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
<p align="center">
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
</p>
---
Run agents in parallel on your own machines. Ship from your phone or your desk.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
- **Self-hosted** — Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider** — Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
- **Voice control** — Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device** — iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
## Getting Started
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
Download the desktop app from [paseo.sh](https://paseo.sh) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases) — it bundles the daemon so there's nothing else to install.
### Prerequisites
### Headless / server mode
You need at least one agent CLI installed and configured with your credentials:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
- [Codex](https://github.com/openai/codex)
- [OpenCode](https://github.com/anomalyco/opencode)
### Desktop app (recommended)
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
To connect from your phone, scan the QR code shown in Settings.
### CLI / headless
Install the CLI and start Paseo:
To run the daemon on a remote or headless machine:
```bash
npm install -g @getpaseo/cli
paseo
```
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
Then connect from the desktop app, mobile app, or CLI.
For full setup and configuration, see:
- [Docs](https://paseo.sh/docs)
- [Configuration reference](https://paseo.sh/docs/configuration)
## CLI
Everything you can do in the app, you can do from the terminal.
```bash
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task
# run on a remote daemon
paseo --host workstation.local:6767 run "run the full test suite"
```
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
## Orchestration skills (Unstable)
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
```bash
npx skills add getpaseo/paseo
```
Then use them in any agent conversation:
```bash
# Use handoff when you discuss something with an agent but want another one to implement.
# I use this to plan with Claude and then handoff to Codex to implement.
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
# Use loops when you have clear acceptance criteria (aka Ralph loops).
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
# Very opinionated and expects both Codex and Claude to work.
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
```
## Development
Quick monorepo package map:
@@ -117,12 +57,8 @@ npm run dev
# run individual surfaces
npm run dev:server
npm run dev:app
npm run dev:desktop
npm run dev:website
# build the daemon
npm run build:daemon
# repo-wide checks
npm run typecheck
```
+1 -1
View File
@@ -51,4 +51,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
-8
View File
@@ -31,14 +31,6 @@ Your code never leaves your machine. Paseo is local-first.
└───────────┘ └────────┘ └──────────┘
```
## Components at a glance
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
## Packages
### `packages/server` — The daemon
-109
View File
@@ -1,109 +0,0 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |
+3 -16
View File
@@ -15,8 +15,8 @@ If asked to "release paseo" without specifying major/minor, treat it as a patch
## Manual step-by-step
```bash
npm run release:check # Typecheck, build, dry-run pack
npm run version:all:patch # Bump version, create commit + tag
npm run release:check # Validate release
npm run release:publish # Publish to npm
npm run release:push # Push HEAD + tag (triggers CI workflows)
```
@@ -25,7 +25,6 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
```bash
npm run draft-release:patch # Bump, push tag, create draft GitHub Release
# ... test builds from the draft release assets ...
npm run release:finalize # Publish npm, promote draft to published
```
@@ -33,7 +32,6 @@ npm run release:finalize # Publish npm, promote draft to published
- `release:finalize` publishes npm and promotes the same draft release
- Use the same semver tag for both; don't cut a second tag
- Desktop assets now come from the Electron package at `packages/desktop`
- **Do NOT create a changelog entry for drafts.** The changelog entry is written only when finalizing. The website parses `CHANGELOG.md` to determine the latest published version for download links — adding an entry for a draft will point the homepage at untested assets.
## Fixing a failed release build
@@ -64,23 +62,12 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
- If `release:publish` partially fails, re-run it — npm skips already-published versions
- The website parses the first `## X.Y.Z` heading in `CHANGELOG.md` to determine the download version. This is why changelog entries must only be added at finalization, not during drafts.
## Changelog format
The website depends on the changelog to determine the latest download version. The heading format **must** be strictly followed:
```
## X.Y.Z - YYYY-MM-DD
```
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
- Website Mac download CTA URL derives from `packages/website/package.json` version at build time
## Completion checklist
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
- [ ] `npm run release:patch` (or `release:finalize` for drafts) completes successfully
- [ ] `npm run release:patch` completes successfully
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
- [ ] GitHub `Android APK Release` workflow for the same tag is green
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
+170
View File
@@ -0,0 +1,170 @@
# Agent Event Stream Redesign
Status: **Implemented** (2026-03-24)
## Problem
The Claude provider had three event paths delivering the same events to the agent-manager:
1. **Foreground stream** (`stream()` → `activeForegroundTurn.queue`)
2. **Live event pump** (`streamLiveEvents()` → `liveEventQueue`) fed by the query pump
3. **JSONL history poller** (`startLiveHistoryPolling()` → `routeSdkMessageFromPump()`)
Routing between paths was timing-based (`Boolean(activeForegroundTurn)`, `pendingRun`). This caused:
- **Duplicate user messages**: trailing SDK events routed to the live queue after `activeForegroundTurn` cleared
- **Stuck running state**: stale `turn_started` from the live path flipped lifecycle back to `running` after finalize set it to terminal
- **Fragile dedup**: `shouldSuppressLiveUserMessageEcho` checked `pendingRun` (already null) and `messageId` (Claude assigns its own UUID)
Codex and OpenCode were stable because they had ONE event path with no routing decision.
## Design
### Core principle
One event source per provider session. Identity-based turn ownership, not timing-based routing.
### Provider contract (`AgentSession`)
```typescript
interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
// Turn lifecycle
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
interrupt(): Promise<void>;
// Event delivery (push-based)
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
// History (hydration only — never live dispatch)
streamHistory(): AsyncGenerator<AgentStreamEvent>;
// Run (uses startTurn + subscribe internally)
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
// Session metadata (unchanged)
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;
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
```
### Method contracts
#### `startTurn(prompt, options?): Promise<{ turnId: string }>`
Initiates a foreground turn. The provider validates readiness, generates a unique `turnId`, submits the prompt to the runtime, and resolves once accepted. Resolving means the prompt was accepted — not that the turn has started processing.
Rejects if: session not connected, foreground turn already active, runtime rejects prompt.
#### `subscribe(callback): () => void`
Registers a callback that receives ALL provider events — foreground and autonomous — in provider order. Returns an unsubscribe function. Events carry `turnId` when they belong to a turn.
#### `streamHistory(): AsyncGenerator<AgentStreamEvent>`
Yields persisted timeline items from prior sessions. Hydration only. Does NOT yield live events.
#### `interrupt(): Promise<void>`
Cancels the active foreground turn. The resulting `turn_canceled` event arrives via `subscribe()`.
### Provider-side guarantees
1. **Per-session ordering**: callbacks invoked in provider event order
2. **No concurrent callback execution**: serialized delivery per session
3. **Subscribe-before-start safety**: manager subscribes at session creation, before any `startTurn()` call — no events missed
4. **Callback error isolation**: subscriber throws → provider logs and continues
5. **Deterministic cleanup**: `close()` stops all callbacks; `unsubscribe()` stops that specific callback
### Event tagging
All turn-scoped events carry `turnId: string`. Providers stamp turnId in `notifySubscribers()` from the active turn state (`activeForegroundTurnId` or `autonomousTurn.id`). The manager derives turn kind (foreground vs autonomous) by comparing against its own `activeForegroundTurnId`.
### User message dedup
Claude SDK assigns its own UUID to user messages (does not preserve ours). The provider deduplicates user_message echoes by text content against the most recent foreground prompt.
## Manager
### Single subscription per session
When a session is loaded, the manager subscribes once via `session.subscribe()`. This is the only live input path. Events flow through a single dispatcher that handles lifecycle projection, foreground turn waiters, and UI updates.
### Lifecycle projection from turn identity
- After `startTurn()` resolves: foreground turn is active
- On `turn_started` for active foreground turnId: lifecycle = `running`
- On terminal for active foreground turnId: lifecycle = `idle` or `error`, clear foreground turn
- On autonomous `turn_started`: lifecycle = `running`
- On autonomous terminal: lifecycle = `idle` or `error`
### `streamAgent()` as filtered view
```typescript
async *streamAgent(agentId, prompt, options) {
const { turnId } = await session.startTurn(prompt, options);
agent.activeForegroundTurnId = turnId;
// Foreground turn waiter yields events matching this turnId
// Ends when terminal event for turnId arrives
}
```
### State model
| Concept | Implementation |
|---------|---------------|
| Foreground turn tracking | `activeForegroundTurnId: string \| null` |
| Lifecycle projection | From turn events via turnId matching |
| Cancellation | `session.interrupt()` + await waiter settlement |
## What was deleted
- `stream()` from `AgentSession` interface and all providers
- `Pushable<T>` async queue from all providers
- `streamLiveEvents()` capability
- `activeForegroundTurn` + foreground queue in Claude provider
- `liveEventQueue` in Claude provider
- `routeSdkMessageFromPump()` timing-based routing (simplified to direct dispatch)
- `startLiveEventPump()` in manager
- `liveEventBacklog` + `flushLiveEventBacklog()` in manager
- `shouldSuppressLiveUserMessageEcho()` in manager
- `startLiveHistoryPolling()` for live dispatch
- `snapHistoryOffsetToEnd()`
- `pendingRun` as iterator reference
## Integration tests
All tests run against real Claude sessions with credentials from `.env.test`. No mocks.
File: `packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts`
| Test | What it verifies |
|------|-----------------|
| Basic foreground turn | startTurn → events via subscribe → terminal with matching turnId |
| No duplicate user_messages | Exactly ONE user_message per prompt, even after terminal |
| Lifecycle doesn't get stuck | No stale turn_started after terminal for same turnId |
| Autonomous run | sleep 5 in bg → idle → autonomous wake → idle (distinct turnIds) |
| Interruption | Start long task → interrupt → turn_canceled arrives |
| Sequential turns | Two turns produce distinct turnIds, no cross-contamination |
| Fast-fail | Quick error produces clean terminal, no stale events |
| User message dedup | Exactly one user_message with matching text in event log |
### Invariants (asserted on every test)
1. For each foreground turnId, exactly ONE `user_message` event
2. Every `turn_started` has exactly one matching terminal
3. After terminal for a foreground turnId, no later event with that turnId gets projected as autonomous
4. Autonomous turns between foreground turns are visible with distinct turnIds
+2 -2
View File
@@ -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-ohEbz3DFhuYD70GxXfIPxeSpxFfY7AgmJSUbBO+Fhn0=";
npmDepsHash = "sha256-qRknrMt2VBCCRGGedyTrPEBs3u+Z8lmDZuuvX7mE2Cg=";
# 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).
@@ -114,7 +114,7 @@ buildNpmPackage rec {
fi
done
# Copy server scripts (including supervisor-entrypoint) needed by CLI
# Copy server scripts (daemon-runner, supervisor) needed by CLI
if [ -d packages/server/dist/scripts ]; then
mkdir -p $out/lib/paseo/packages/server/dist/scripts
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
+94 -228
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.39",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.39",
"version": "0.1.34",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -14831,13 +14831,6 @@
"node": ">=10"
}
},
"node_modules/chroma-js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
"dev": true,
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/chrome-launcher": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -15862,33 +15855,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deep-rename-keys": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"kind-of": "^3.0.2",
"rename-keys": "^1.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-rename-keys/node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-buffer": "^1.1.5"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -16752,15 +16718,6 @@
"node": ">=8"
}
},
"node_modules/electron-log": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz",
"integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": {
"version": "26.8.1",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz",
@@ -18457,13 +18414,6 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"dev": true,
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -25167,15 +25117,6 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -25333,25 +25274,6 @@
"node": ">=10"
}
},
"node_modules/material-icon-theme": {
"version": "5.32.0",
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz",
"integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chroma-js": "^3.1.2",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"svgson": "^5.3.1"
},
"engines": {
"vscode": "^1.55.0"
},
"funding": {
"url": "https://github.com/sponsors/material-extensions"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -30216,16 +30138,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/rename-keys": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -32214,17 +32126,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svgson": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-rename-keys": "^0.2.1",
"xml-reader": "2.4.3"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -34631,16 +34532,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/xml-lexer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -34651,17 +34542,6 @@
"node": ">=12"
}
},
"node_modules/xml-reader": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0",
"xml-lexer": "^0.2.2"
}
},
"node_modules/xml2js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
@@ -34962,16 +34842,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.39",
"version": "0.1.34",
"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.39",
"@getpaseo/highlight": "0.1.39",
"@getpaseo/server": "0.1.39",
"@getpaseo/expo-two-way-audio": "0.1.34",
"@getpaseo/highlight": "0.1.34",
"@getpaseo/server": "0.1.34",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35049,7 +34929,6 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
@@ -35058,14 +34937,14 @@
},
"packages/app/node_modules/expo-clipboard": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q=="
},
"packages/app/node_modules/react-native-nitro-modules": {
"version": "0.33.8",
@@ -35079,20 +34958,20 @@
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.39",
"version": "0.1.34",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.39",
"@getpaseo/server": "0.1.39",
"@getpaseo/relay": "0.1.34",
"@getpaseo/server": "0.1.34",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35112,33 +34991,31 @@
},
"packages/cli/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.39",
"license": "AGPL-3.0-or-later",
"version": "0.1.34",
"dependencies": {
"@getpaseo/cli": "0.1.39",
"@getpaseo/server": "0.1.39",
"electron-log": "^5.4.3",
"@getpaseo/cli": "0.1.34",
"@getpaseo/server": "0.1.34",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35171,7 +35048,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.39",
"version": "0.1.34",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35372,7 +35249,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.39",
"version": "0.1.34",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35398,7 +35275,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.39",
"version": "0.1.34",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35414,14 +35291,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.39",
"version": "0.1.34",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.39",
"@getpaseo/relay": "0.1.39",
"@isaacs/ttlcache": "^2.1.4",
"@getpaseo/highlight": "0.1.34",
"@getpaseo/relay": "0.1.34",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
"@sctg/sentencepiece-js": "^1.1.0",
@@ -35464,19 +35340,8 @@
"vitest": "^3.2.4"
}
},
"packages/server/node_modules/@isaacs/ttlcache": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz",
"integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=12"
}
},
"packages/server/node_modules/@modelcontextprotocol/sdk": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA==",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.6",
@@ -35494,12 +35359,12 @@
},
"engines": {
"node": ">=18"
}
},
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.1.tgz",
"integrity": "sha512-j/P+yuxXfgxb+mW7OEoRCM3G47zCTDqUPivJo/VzpjbG8I9csTXtOprCf5FfOfHK4whOJny0aHuBEON+kS7CCA=="
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -35510,12 +35375,12 @@
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -35552,7 +35417,9 @@
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="
},
"packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
"version": "0.4.1",
@@ -35562,8 +35429,6 @@
},
"packages/server/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -35571,12 +35436,12 @@
},
"engines": {
"node": ">= 0.6"
}
},
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="
},
"packages/server/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -35587,24 +35452,24 @@
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="
},
"packages/server/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="
},
"packages/server/node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -35619,33 +35484,33 @@
},
"engines": {
"node": ">=18"
}
},
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="
},
"packages/server/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="
},
"packages/server/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="
},
"packages/server/node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -35657,58 +35522,60 @@
},
"engines": {
"node": ">= 0.8"
}
},
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="
},
"packages/server/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="
},
"packages/server/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
},
"packages/server/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="
},
"packages/server/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="
},
"packages/server/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="
},
"packages/server/node_modules/raw-body": {
"version": "3.0.2",
@@ -35743,8 +35610,6 @@
},
"packages/server/node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
@@ -35761,12 +35626,12 @@
},
"engines": {
"node": ">= 18"
}
},
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="
},
"packages/server/node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -35776,12 +35641,12 @@
},
"engines": {
"node": ">= 18"
}
},
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="
},
"packages/server/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -35791,12 +35656,12 @@
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="
},
"packages/server/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -35805,27 +35670,28 @@
},
"engines": {
"node": ">= 0.6"
}
},
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="
},
"packages/server/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.39",
"version": "0.1.34",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
@@ -35845,13 +35711,13 @@
},
"packages/website/node_modules/@types/node": {
"version": "22.19.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.6.tgz",
"integrity": "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="
},
"packages/website/node_modules/react": {
"version": "19.2.4",
+8 -16
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.39",
"version": "0.1.34",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
@@ -49,13 +49,13 @@
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:push": "node scripts/push-current-release-tag.mjs",
"draft-release:push": "node scripts/push-current-release-tag.mjs --draft-release",
"draft-release:patch": "npm run release:check && npm run version:all:patch && npm run draft-release:push",
"draft-release:minor": "npm run release:check && npm run version:all:minor && npm run draft-release:push",
"draft-release:major": "npm run release:check && npm run version:all:major && npm run draft-release:push",
"draft-release:patch": "npm run version:all:patch && npm run release:check && npm run draft-release:push",
"draft-release:minor": "npm run version:all:minor && npm run release:check && npm run draft-release:push",
"draft-release:major": "npm run version:all:major && npm run release:check && npm run draft-release:push",
"release:finalize": "node scripts/finalize-current-release.mjs",
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -68,7 +68,6 @@
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"homepage": "https://paseo.sh",
"keywords": [
"openai",
"realtime",
@@ -77,14 +76,7 @@
"development",
"mcp"
],
"author": {
"name": "Mohamed Boudra",
"email": "hello@moboudra.com"
},
"repository": {
"type": "git",
"url": "https://github.com/getpaseo/paseo.git"
},
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
+6 -9
View File
@@ -448,15 +448,12 @@ export default async function globalSetup() {
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
// Use OpenAI speech providers in e2e to avoid local model bootstrapping delays.
PASEO_DICTATION_ENABLED: "1",
PASEO_VOICE_MODE_ENABLED: "1",
PASEO_DICTATION_STT_PROVIDER: dictationProvider,
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
-228
View File
@@ -1,228 +0,0 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
createTerminal(
cwd: string,
name?: string,
): Promise<{
terminal: { id: string; name: string; cwd: string } | null;
error: string | null;
}>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
sendTerminalInput(
terminalId: string,
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
): void;
onTerminalStreamEvent(
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function loadDaemonClientConstructor(): Promise<
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
}) => TerminalPerfDaemonClient;
};
return mod.DaemonClient;
}
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
});
await client.connect();
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
return "";
}
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
return lines.join("\n");
});
}
export async function waitForTerminalContent(
page: Page,
predicate: (text: string) => boolean,
timeout: number,
): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const text = await getTerminalBufferText(page);
if (predicate(text)) {
return;
}
await page.waitForTimeout(50);
}
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
}
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
await page.goto(workspaceRoute);
// Wait for daemon connection (sidebar shows host label)
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
// The workspace should now query listTerminals and discover our terminal.
// Click the terminal tab if it auto-appeared, or wait for it.
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
if (!surfaceVisible) {
// Terminal tab might not be focused — look for it in the tab row and click it
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
if (tabExists) {
await terminalTab.click();
} else {
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
await newTerminalBtn.click();
}
}
// Wait for terminal surface to be visible
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
// Wait for loading overlay to disappear (terminal attached)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
}
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
const tag = sentinel ?? `READY_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
await page.waitForTimeout(300);
}
export type LatencySample = {
char: string;
latencyMs: number;
};
/**
* Measures keystroke echo round-trip latency.
*
* Starts a high-resolution timer on the browser keydown event (capture phase)
* and stops it when xterm.js finishes parsing the echoed write. This measures
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
throw new Error("__paseoTerminal not available");
}
const state = ((window as any).__perfKeystroke = {
promise: null as Promise<number> | null,
});
state.promise = new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
document.removeEventListener("keydown", onKeyDown, true);
reject(new Error("keystroke echo timeout (5s)"));
}, 5000);
function onKeyDown() {
document.removeEventListener("keydown", onKeyDown, true);
const start = performance.now();
const disposable = term.onWriteParsed(() => {
clearTimeout(timeout);
disposable.dispose();
resolve(performance.now() - start);
});
}
document.addEventListener("keydown", onKeyDown, true);
});
});
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
}
export function computePercentile(samples: number[], p: number): number {
const sorted = [...samples].sort((a, b) => a - b);
const index = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, index)];
}
export function round2(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -1,160 +0,0 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectTerminalClient,
navigateToTerminal,
setupDeterministicPrompt,
waitForTerminalContent,
measureKeystrokeLatency,
computePercentile,
round2,
type TerminalPerfDaemonClient,
type LatencySample,
} from "./helpers/terminal-perf";
const LINE_COUNT = 50_000;
const THROUGHPUT_BUDGET_MS = 30_000;
const KEYSTROKE_SAMPLE_COUNT = 20;
const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) {
await client.close();
}
if (tempRepo) {
await tempRepo.cleanup();
}
});
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const result = await client.createTerminal(tempRepo.path, "throughput");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
const terminal = page.locator('[data-testid="terminal-surface"]');
const startMs = Date.now();
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
const elapsedMs = Date.now() - startMs;
// seq 1 N outputs each number on its own line
const estimatedBytes = Array.from(
{ length: LINE_COUNT },
(_, i) => String(i + 1).length + 1,
).reduce((a, b) => a + b, 0);
const throughputMBps = estimatedBytes / (1024 * 1024) / (elapsedMs / 1000);
const report = {
lineCount: LINE_COUNT,
estimatedBytes,
elapsedMs,
throughputMBps: round2(throughputMBps),
};
await testInfo.attach("throughput-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
);
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
THROUGHPUT_BUDGET_MS,
);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "latency");
if (!result.terminal) {
throw new Error(`Failed to create terminal: ${result.error}`);
}
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
const terminal = page.locator('[data-testid="terminal-surface"]');
await terminal.press("Control+c");
await page.waitForTimeout(200);
const samples: LatencySample[] = [];
const chars = "abcdefghijklmnopqrst";
for (let i = 0; i < KEYSTROKE_SAMPLE_COUNT; i++) {
const char = chars[i % chars.length];
const latencyMs = await measureKeystrokeLatency(page, char);
samples.push({ char, latencyMs });
await page.waitForTimeout(50);
}
// Clean up typed characters
await terminal.press("Control+c");
const latencies = samples.map((s) => s.latencyMs);
const p50 = computePercentile(latencies, 50);
const p95 = computePercentile(latencies, 95);
const max = Math.max(...latencies);
const min = Math.min(...latencies);
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
const report = {
sampleCount: KEYSTROKE_SAMPLE_COUNT,
p50Ms: round2(p50),
p95Ms: round2(p95),
maxMs: round2(max),
minMs: round2(min),
avgMs: round2(avg),
samples: samples.map((s) => ({
char: s.char,
latencyMs: round2(s.latencyMs),
})),
};
await testInfo.attach("latency-report", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(
`[perf] Keystroke latency — p50: ${report.p50Ms}ms, p95: ${report.p95Ms}ms, max: ${report.maxMs}ms`,
);
expect(
p95,
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
+4 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.39",
"version": "0.1.34",
"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.39",
"@getpaseo/highlight": "0.1.39",
"@getpaseo/server": "0.1.39",
"@getpaseo/expo-two-way-audio": "0.1.34",
"@getpaseo/highlight": "0.1.34",
"@getpaseo/server": "0.1.34",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -111,7 +111,6 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
@@ -0,0 +1,36 @@
export const WELCOME_ROUTE = "/welcome";
export function shouldWaitOnStartupRace(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
isDesktopStartupRace: boolean;
daemonCount: number;
pathname: string;
}): boolean {
if (input.onlineServerId) {
return false;
}
if (input.pathname === WELCOME_ROUTE) {
return false;
}
if (input.hasTimedOut) {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
export function shouldRedirectToWelcome(input: {
onlineServerId: string | null;
hasTimedOut: boolean;
pathname: string;
isDesktopStartupRace: boolean;
daemonCount: number;
}): boolean {
if (input.onlineServerId || !input.hasTimedOut) {
return false;
}
if (input.pathname !== "/" && input.pathname !== "") {
return false;
}
return input.isDesktopStartupRace || input.daemonCount > 0;
}
+96 -136
View File
@@ -15,7 +15,7 @@ import { PortalProvider } from "@gorhom/portal";
import { VoiceProvider } from "@/contexts/voice-context";
import { useAppSettings } from "@/hooks/use-settings";
import { useFaviconStatus } from "@/hooks/use-favicon-status";
import { View, Text } from "react-native";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { darkTheme } from "@/styles/theme";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -25,14 +25,10 @@ import {
useHostMutations,
useHostRuntimeClient,
} from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { loadSettingsFromStorage } from "@/hooks/use-settings";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { SessionProvider } from "@/contexts/session-context";
import type { HostProfile } from "@/types/host-connection";
import {
createContext,
useCallback,
useContext,
useState,
useEffect,
@@ -69,7 +65,6 @@ import {
ensureOsNotificationPermission,
} from "@/utils/os-notifications";
import { getDesktopHost } from "@/desktop/host";
import { setDesktopTitleBarTheme } from "@/desktop/electron/window";
import { buildNotificationRoute } from "@/utils/notification-routing";
import {
buildHostRootRoute,
@@ -81,18 +76,7 @@ import {
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
phase: "starting-daemon",
error: null,
retry: () => {},
});
const HostRuntimeBootstrapContext = createContext(false);
function PushNotificationRouter() {
const router = useRouter();
@@ -220,99 +204,41 @@ function HostSessionManager() {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const [phase, setPhase] = useState<HostRuntimeBootstrapState["phase"]>("starting-daemon");
const [error, setError] = useState<string | null>(null);
const [retryToken, setRetryToken] = useState(0);
const retry = useCallback(() => {
setPhase("starting-daemon");
setError(null);
setRetryToken((current) => current + 1);
}, []);
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const shouldManageDesktop = shouldUseDesktopDaemon();
const store = getHostRuntimeStore();
const init = async () => {
const settings = await loadSettingsFromStorage();
const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon;
await store.loadFromStorage();
if (isDesktopManaged) {
setPhase("starting-daemon");
setError(null);
const bootstrapResult = await store.bootstrapDesktop();
if (!bootstrapResult.ok) {
if (!cancelled) {
setPhase("error");
setError(bootstrapResult.error);
}
return;
}
void store
.loadFromStorage()
.then(() => {
if (cancelled) {
return;
}
setPhase("connecting");
await store.addConnectionFromListenAndWaitForOnline({
listenAddress: bootstrapResult.listenAddress,
serverId: bootstrapResult.serverId,
hostname: bootstrapResult.hostname,
});
setReady(true);
void store.bootstrap();
})
.catch((error) => {
console.error("[HostRuntime] Failed to initialize store", error);
if (!cancelled) {
setPhase("online");
setError(null);
setReady(true);
}
} else {
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
if (!cancelled) {
setPhase("online");
setError(null);
}
}
};
void init().catch((bootstrapError) => {
console.error("[HostRuntime] Failed to initialize store", bootstrapError);
if (cancelled) {
return;
}
if (shouldManageDesktop) {
setPhase("error");
setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError));
return;
}
setPhase("online");
setError(null);
});
});
return () => {
cancelled = true;
};
}, [retryToken]);
const state = useMemo<HostRuntimeBootstrapState>(
() => ({
phase,
error,
retry,
}),
[error, phase, retry],
);
}, []);
return (
<HostRuntimeBootstrapContext.Provider value={state}>
<HostRuntimeBootstrapContext.Provider value={ready}>
{children}
</HostRuntimeBootstrapContext.Provider>
);
}
export function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext).phase === "online";
}
export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
function useStoreReady(): boolean {
return useContext(HostRuntimeBootstrapContext);
}
@@ -473,30 +399,24 @@ function MobileGestureWrapper({
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { settings, isLoading: settingsLoading } = useAppSettings();
const storeReady = useStoreReady();
const { upsertConnectionFromOfferUrl } = useHostMutations();
const systemColorScheme = useColorScheme();
const resolvedTheme = settings.theme === "auto" ? (systemColorScheme ?? "light") : settings.theme;
const isLoading = settingsLoading || !storeReady;
// Apply theme setting on mount and when it changes
useEffect(() => {
if (settingsLoading) return;
if (isLoading) return;
if (settings.theme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(settings.theme);
}
}, [settingsLoading, settings.theme]);
}, [isLoading, settings.theme]);
useEffect(() => {
if (settingsLoading || Platform.OS !== "web") {
return;
}
void setDesktopTitleBarTheme(resolvedTheme).catch((error) => {
console.warn("[DesktopWindow] Failed to update title bar theme", error);
});
}, [settingsLoading, resolvedTheme]);
if (isLoading) {
return <LoadingView />;
}
return (
<VoiceProvider>
@@ -599,38 +519,6 @@ function FaviconStatusSync() {
return null;
}
function RootStack() {
const storeReady = useStoreReady();
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="index" />
</Stack>
);
}
function NavigationActiveWorkspaceObserver() {
const navigationRef = useNavigationContainerRef();
@@ -651,6 +539,57 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function LoadingView({ message }: { message?: string } = {}) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
{message ? (
<Text
style={{
color: darkTheme.colors.foregroundMuted,
marginTop: 16,
fontSize: 14,
}}
>
{message}
</Text>
) : null}
</View>
);
}
function MissingDaemonView() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: darkTheme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
<Text
style={{
color: darkTheme.colors.foreground,
marginTop: 16,
textAlign: "center",
}}
>
No host configured. Open Settings to add a server URL.
</Text>
</View>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: darkTheme.colors.surface0 }}>
@@ -667,7 +606,28 @@ export default function RootLayout() {
<HorizontalScrollProvider>
<ToastProvider>
<AppWithSidebar>
<RootStack />
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: darkTheme.colors.surface0,
},
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>
</AppWithSidebar>
</ToastProvider>
</HorizontalScrollProvider>
+75 -27
View File
@@ -1,22 +1,20 @@
import { useEffect, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore, useState } from "react";
import { usePathname, useRouter } from "expo-router";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import {
useHostRuntimeBootstrapState,
useStoreReady,
} from "@/app/_layout";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHosts,
} from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import {
shouldRedirectToWelcome,
shouldWaitOnStartupRace,
WELCOME_ROUTE,
} from "@/app-support/index-startup";
const WELCOME_ROUTE = "/welcome";
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
const STARTUP_TIMEOUT_MS = 30_000;
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
@@ -35,31 +33,81 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
}
return firstOnlineServerId;
},
() => null,
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const storeReady = useStoreReady();
const hosts = useHosts();
const anyOnlineServerId = useAnyOnlineHostServerId(hosts.map((host) => host.serverId));
const daemons = useHosts();
const [hasTimedOut, setHasTimedOut] = useState(false);
const isDesktopStartupRace = shouldUseDesktopDaemon();
const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId));
useEffect(() => {
const timer = setTimeout(() => {
setHasTimedOut(true);
}, STARTUP_TIMEOUT_MS);
return () => {
clearTimeout(timer);
};
}, []);
useEffect(() => {
if (!storeReady) {
if (!onlineServerId) {
return;
}
if (pathname !== "/" && pathname !== "") {
return;
}
router.replace(buildHostRootRoute(onlineServerId) as any);
}, [onlineServerId, pathname, router]);
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
}, [anyOnlineServerId, pathname, router, storeReady]);
useEffect(() => {
if (
!shouldRedirectToWelcome({
onlineServerId,
hasTimedOut,
pathname,
isDesktopStartupRace,
daemonCount: daemons.length,
})
) {
return;
}
router.replace(WELCOME_ROUTE as any);
}, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
if (
shouldWaitOnStartupRace({
onlineServerId,
hasTimedOut,
isDesktopStartupRace,
daemonCount: daemons.length,
pathname,
})
) {
return <StartupSplashScreen />;
}
if (!onlineServerId) {
return <WelcomeScreen />;
}
return null;
}
@@ -8,7 +8,6 @@ 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 {
DropdownMenu,
DropdownMenuContent,
@@ -610,7 +609,6 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -711,17 +709,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: modelId,
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist model preference failed", error);
});
void client.setAgentModel(agentId, modelId).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
@@ -732,23 +719,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
if (!client) {
return;
}
const activeModelId = modelSelection.activeModelId;
if (activeModelId) {
void updatePreferences(
mergeProviderPreferences({
preferences,
provider: agent.provider,
updates: {
model: activeModelId,
thinkingByModel: {
[activeModelId]: thinkingOptionId,
},
},
}),
).catch((error) => {
console.warn("[AgentStatusBar] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
@@ -17,7 +17,6 @@ import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -337,13 +336,12 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -26,17 +26,18 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -64,7 +65,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" },
];
const INDENT_PER_LEVEL = 16;
const INDENT_PER_LEVEL = 12;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
@@ -383,6 +384,7 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry;
const depth = item.depth;
const displayKind = getEntryDisplayKind(entry);
const isDirectory = entry.kind === "directory";
const isExpanded = isDirectory && expandedPaths.has(entry.path);
const isSelected = selectedEntryPath === entry.path;
@@ -397,30 +399,16 @@ export function FileExplorerPane({
(hovered || pressed || isSelected) && styles.entryRowActive,
]}
>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
{loading ? (
<ActivityIndicator size="small" />
) : (
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
renderEntryIcon(isDirectory ? "directory" : displayKind, {
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -564,31 +552,27 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
@@ -625,6 +609,99 @@ export function FileExplorerPane({
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return colors.directoryOpen ? (
<FolderOpen size={18} color={colors.primary} />
) : (
<Folder size={18} color={colors.primary} />
);
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
@@ -765,38 +842,50 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
sortTrigger: {
paneHeaderLeft: {
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
sortButton: {
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: 24,
borderRadius: theme.borderRadius.base,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
sortTriggerText: {
fontSize: theme.fontSize.xs,
sortButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
treeList: {
flex: 1,
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
centerState: {
@@ -847,16 +936,8 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surface1,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
@@ -866,16 +947,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},
@@ -10,7 +10,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
interface MenuHeaderProps {
title?: string;
rightContent?: ReactNode;
borderless?: boolean;
}
interface SidebarMenuToggleProps {
@@ -76,7 +75,7 @@ export function SidebarMenuToggle({
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -91,7 +90,6 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
right={rightContent}
leftStyle={styles.left}
borderless={borderless}
/>
);
}
@@ -6,29 +6,33 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
getIsDesktopMac,
} from "@/constants/layout";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
interface ScreenHeaderProps {
left?: ReactNode;
right?: ReactNode;
leftStyle?: StyleProp<ViewStyle>;
rightStyle?: StyleProp<ViewStyle>;
borderless?: boolean;
}
/**
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const padding = useWindowControlsPadding("header");
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const trafficLightPadding = useTrafficLightPadding();
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarTrafficLightInset =
!isMobile && !desktopAgentListOpen && getIsDesktopMac() ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
@@ -38,11 +42,7 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
<View
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
{ paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset },
]}
{...dragHandlers}
>
@@ -83,7 +83,4 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
borderless: {
borderBottomColor: "transparent",
},
}));
+10 -9
View File
@@ -35,7 +35,7 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
@@ -520,7 +520,6 @@ function MobileSidebar({
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={closeToAgent}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
@@ -557,7 +556,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -582,7 +581,7 @@ function MobileSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -632,7 +631,7 @@ function DesktopSidebar({
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const padding = useWindowControlsPadding("sidebar");
const trafficLightPadding = useTrafficLightPadding();
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
@@ -682,7 +681,9 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
{trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
@@ -700,7 +701,6 @@ function DesktopSidebar({
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
/>
)}
@@ -736,7 +736,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -761,7 +761,7 @@ function DesktopSidebar({
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
@@ -809,6 +809,7 @@ const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
@@ -1,141 +0,0 @@
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record<string, string> = {
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
};
const EXTENSION_TO_ICON: Record<string, string> = {
"astro": "astro",
"bash": "console",
"c": "c",
"cfg": "settings",
"clj": "clojure",
"conf": "settings",
"cpp": "cpp",
"cs": "csharp",
"css": "css",
"dart": "dart",
"erl": "erlang",
"ex": "elixir",
"exs": "elixir",
"gif": "image",
"go": "go",
"gql": "graphql",
"gradle": "gradle",
"graphql": "graphql",
"groovy": "groovy",
"h": "h",
"hcl": "hcl",
"hpp": "hpp",
"hs": "haskell",
"html": "html",
"ico": "image",
"ini": "settings",
"java": "java",
"jpeg": "image",
"jpg": "image",
"js": "javascript",
"json": "json",
"jsx": "react",
"kt": "kotlin",
"less": "less",
"lock": "lock",
"lua": "lua",
"markdown": "markdown",
"md": "markdown",
"ml": "ocaml",
"nix": "nix",
"php": "php",
"png": "image",
"py": "python",
"r": "r",
"rb": "ruby",
"rs": "rust",
"scala": "scala",
"scss": "sass",
"sh": "console",
"sql": "database",
"svelte": "svelte",
"svg": "svg",
"swift": "swift",
"tf": "terraform",
"toml": "toml",
"ts": "typescript",
"tsx": "react_ts",
"txt": "document",
"vue": "vue",
"wasm": "webassembly",
"webp": "image",
"xml": "xml",
"yaml": "yaml",
"yml": "yaml",
"zig": "zig",
};
export function getFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName];
}
return SVG_ICONS["_default"];
}
function getExtension(name: string): string | null {
const idx = name.lastIndexOf(".");
if (idx === -1 || idx === name.length - 1) return null;
return name.slice(idx + 1).toLowerCase();
}
@@ -346,13 +346,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback(
(error: Error) => {
console.error("[MessageInput] Dictation error:", error);
toast.error(error.message);
},
[toast],
);
const handleDictationError = useCallback((error: Error) => {
console.error("[MessageInput] Dictation error:", error);
}, []);
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
@@ -36,7 +36,6 @@ import {
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -74,7 +73,6 @@ 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 { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -115,7 +113,6 @@ interface SidebarWorkspaceListProps {
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
onAddProject?: () => void;
listFooterComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
@@ -1634,7 +1631,6 @@ export function SidebarWorkspaceList({
isRefreshing = false,
onRefresh,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
}: SidebarWorkspaceListProps) {
@@ -1904,18 +1900,7 @@ export function SidebarWorkspaceList({
const content = (
<>
{projects.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyText}>Add a project to get started</Text>
<Button
variant="ghost"
size="sm"
leftIcon={Plus}
onPress={onAddProject}
>
Add project
</Button>
</View>
<Text style={styles.emptyText}>No projects yet</Text>
) : (
<DraggableList
testID="sidebar-project-list"
@@ -1978,27 +1963,11 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {},
emptyContainer: {
marginHorizontal: theme.spacing[2],
marginTop: theme.spacing[4],
paddingTop: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface0,
alignItems: "center",
gap: theme.spacing[3],
},
emptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
marginTop: theme.spacing[8],
marginHorizontal: theme.spacing[2],
},
projectRow: {
minHeight: 36,
@@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import { useTrafficLightPadding } from "@/utils/desktop-window";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -809,7 +809,8 @@ function SplitPaneView({
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const padding = useWindowControlsPadding("tabRow");
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
const trafficLightPadding = useTrafficLightPadding();
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
@@ -884,7 +885,8 @@ function SplitPaneView({
<View
style={[
styles.paneTabs,
{ paddingLeft: padding.left, paddingRight: padding.right },
isFocusModeEnabled &&
trafficLightPadding.left > 0 && { paddingLeft: trafficLightPadding.left },
]}
>
<WorkspaceDesktopTabsRow
@@ -565,18 +565,6 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
}
const hasSelection = Boolean(window.getSelection()?.toString());
void showContextMenu({
kind: "terminal",
hasSelection,
});
};
return (
<div
@@ -598,10 +586,6 @@ export default function TerminalEmulator({
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
>
<div
ref={hostRef}
+4 -49
View File
@@ -1,19 +1,11 @@
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import type { PropsWithChildren, ReactElement } from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
type ButtonSize = "sm" | "md" | "lg";
type LeftIcon =
| ReactElement
| ComponentType<{ color: string; size: number }>
| ((color: string) => ReactElement)
| null;
const ICON_SIZE: Record<ButtonSize, number> = { sm: 14, md: 16, lg: 20 };
const styles = StyleSheet.create((theme) => ({
base: {
flexDirection: "row",
@@ -75,12 +67,6 @@ const styles = StyleSheet.create((theme) => ({
textDestructive: {
color: theme.colors.palette.white,
},
textGhost: {
color: theme.colors.foregroundMuted,
},
textGhostHovered: {
color: theme.colors.foreground,
},
}));
export function Button({
@@ -97,14 +83,11 @@ export function Button({
Omit<PressableProps, "style"> & {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
leftIcon?: ReactElement | null;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
>) {
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
@@ -117,47 +100,19 @@ export function Button({
: styles.destructive;
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
function renderIcon() {
if (!leftIcon) return null;
// Pre-rendered element — pass through
if (typeof leftIcon === "object" && "type" in leftIcon) {
return <View>{leftIcon}</View>;
}
const color = variant === "ghost"
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
: theme.colors.foreground;
const iconSize = ICON_SIZE[size];
// Render function
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
}
// Component type
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
return <View><Icon color={color} size={iconSize} /></View>;
}
return (
<Pressable
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
@@ -167,7 +122,7 @@ export function Button({
style,
]}
>
{renderIcon()}
{leftIcon ? <View>{leftIcon}</View> : null}
<Text style={resolvedTextStyle}>{children}</Text>
</Pressable>
);
@@ -278,10 +278,7 @@ export function DropdownMenuContent({
setModalVisible(true);
setClosing(false);
} else if (modalVisible) {
// Avoid leaving an invisible full-screen Modal mounted on native when
// the exit animation callback does not fire.
setClosing(false);
setModalVisible(false);
setClosing(true);
}
}, [open, modalVisible]);
+3 -149
View File
@@ -1,17 +1,11 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useState } from "react";
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -85,39 +79,6 @@ const styles = StyleSheet.create((theme) => ({
actionTextPrimary: {
color: theme.colors.accentForeground,
},
hostList: {
width: "100%",
maxWidth: 420,
marginTop: theme.spacing[6],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[4],
gap: theme.spacing[2],
},
hostRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
hostLabel: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
hostStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
hostStatusError: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
},
versionLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -126,89 +87,6 @@ const styles = StyleSheet.create((theme) => ({
},
}));
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
}
return firstOnlineServerId;
},
);
}
function HostStatusRow({ serverId, label }: { serverId: string; label: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
const lastError = snapshot?.lastError ?? null;
let dotColor: string;
let statusText: string;
let isError = false;
switch (status) {
case "online":
dotColor = theme.colors.success;
statusText = "Online";
break;
case "connecting":
case "idle":
dotColor = theme.colors.foregroundMuted;
statusText = "Connecting…";
break;
case "offline":
dotColor = theme.colors.foregroundMuted;
statusText = "Offline";
break;
case "error":
dotColor = theme.colors.destructive;
statusText = lastError ? lastError.slice(0, 40) : "Connection error";
isError = true;
break;
}
return (
<View style={styles.hostRow}>
<View style={[styles.statusDot, { backgroundColor: dotColor }]} />
<Text style={styles.hostLabel} numberOfLines={1}>
{label}
</Text>
<Text style={isError ? styles.hostStatusError : styles.hostStatus} numberOfLines={1}>
{statusText}
</Text>
</View>
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
@@ -227,8 +105,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
@@ -243,16 +119,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
@@ -307,8 +173,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
},
];
const showHostList = hosts.length > 0 && !anyOnlineServerId;
return (
<ScrollView
style={{ flex: 1, backgroundColor: theme.colors.surface0 }}
@@ -322,9 +186,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<View style={styles.content}>
<PaseoLogo size={96} color={theme.colors.foreground} />
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
</Text>
<Text style={styles.subtitle}>Connect to your host to start</Text>
<View style={styles.actions}>
{actions.map((action) => {
@@ -347,14 +209,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
);
})}
</View>
{showHostList && (
<View style={styles.hostList}>
{hosts.map((host) => (
<HostStatusRow key={host.serverId} serverId={host.serverId} label={host.label} />
))}
</View>
)}
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
+11 -4
View File
@@ -19,10 +19,6 @@ export const MAX_CONTENT_WIDTH = 820;
export const DESKTOP_TRAFFIC_LIGHT_WIDTH = 78;
export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
// Windows/Linux window controls (minimize/maximize/close) — top-right
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
// Check if running in desktop app (any OS)
function isDesktopEnvironment(): boolean {
if (Platform.OS !== "web") return false;
@@ -60,3 +56,14 @@ export function getIsDesktop(): boolean {
}
return result;
}
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -4,10 +4,6 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -35,59 +31,55 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => {
@@ -49,7 +49,6 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -296,10 +295,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}, []);
useEffect(() => {
previousAgentStatusRef.current = reconcilePreviousAgentStatuses(
previousAgentStatusRef.current,
sessionAgents,
);
if (!sessionAgents) {
previousAgentStatusRef.current.clear();
return;
}
const nextStatuses = new Map<string, AgentLifecycleStatus>();
for (const nextAgent of sessionAgents.values()) {
nextStatuses.set(nextAgent.id, nextAgent.status);
}
previousAgentStatusRef.current = nextStatuses;
}, [sessionAgents]);
const hydrateWorkspaces = useCallback(
@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { reconcilePreviousAgentStatuses } from "./session-status-tracking";
function createAgent(status: Agent["status"]): Agent {
return {
serverId: "server-1",
id: "agent-1",
provider: "codex",
status,
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
lastActivityAt: new Date(0),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent",
cwd: "/tmp",
model: null,
labels: {},
projectPlacement: null,
};
}
describe("reconcilePreviousAgentStatuses", () => {
it("preserves previously seen status for existing agents", () => {
const previous = new Map([["agent-1", "running" as const]]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("seeds newly seen agents from the current snapshot", () => {
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(new Map(), sessionAgents);
expect(result).toEqual(new Map([["agent-1", "idle"]]));
});
it("removes agents that are no longer present", () => {
const previous = new Map([
["agent-1", "running" as const],
["agent-2", "idle" as const],
]);
const sessionAgents = new Map([["agent-1", createAgent("idle")]]);
const result = reconcilePreviousAgentStatuses(previous, sessionAgents);
expect(result).toEqual(new Map([["agent-1", "running"]]));
});
it("clears all tracked statuses when the session is unavailable", () => {
const previous = new Map([["agent-1", "running" as const]]);
const result = reconcilePreviousAgentStatuses(previous, undefined);
expect(result).toEqual(new Map());
});
});
@@ -1,29 +0,0 @@
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
sessionAgents: Map<string, Agent> | undefined,
): Map<string, AgentLifecycleStatus> {
if (!sessionAgents) {
return new Map();
}
const nextStatuses = new Map(previousStatuses);
const seenAgentIds = new Set<string>();
for (const agent of sessionAgents.values()) {
seenAgentIds.add(agent.id);
if (!nextStatuses.has(agent.id)) {
nextStatuses.set(agent.id, agent.status);
}
}
for (const agentId of nextStatuses.keys()) {
if (!seenAgentIds.has(agentId)) {
nextStatuses.delete(agentId);
}
}
return nextStatuses;
}
@@ -12,10 +12,6 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati
import { type GestureType } from "react-native-gesture-handler";
import { UnistylesRuntime } from "react-native-unistyles";
import { usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -42,53 +38,46 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
const backdropOpacity = useSharedValue(isOpen ? 1 : 0);
const isGesturing = useSharedValue(false);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen;
prevWindowWidth.current = windowWidth;
if (!didStateChange) {
// Skip if this is initial render or if we're mid-gesture
if (prevIsOpen.current === isOpen) {
return;
}
prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
translateX.value = withTiming(targets.translateX, {
if (isOpen) {
translateX.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(targets.backdropOpacity, {
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
} else {
translateX.value = withTiming(-windowWidth, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
const animateToOpen = useCallback(() => {
@@ -6,7 +6,7 @@ export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
export type DesktopDaemonStatus = {
serverId: string;
status: DesktopDaemonState;
listen: string | null;
listen: string;
hostname: string | null;
pid: number | null;
home: string;
@@ -82,7 +82,7 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus {
return {
serverId: toStringOrNull(raw.serverId) ?? "",
status: parseDesktopDaemonState(raw.status),
listen: toStringOrNull(raw.listen),
listen: toStringOrNull(raw.listen) ?? "",
hostname: toStringOrNull(raw.hostname),
pid: toNumberOrNull(raw.pid),
home: toStringOrNull(raw.home) ?? "",
@@ -27,12 +27,3 @@ export async function isDesktopFullscreen(): Promise<boolean> {
}
return await win.isFullscreen();
}
export async function setDesktopTitleBarTheme(theme: "light" | "dark"): Promise<void> {
const win = getDesktopWindow();
if (!win || typeof win.setTitleBarTheme !== "function") {
return;
}
await win.setTitleBarTheme(theme);
}
-9
View File
@@ -37,13 +37,6 @@ export interface DesktopOpenerBridge {
openUrl?: (url: string) => Promise<void>;
}
export interface DesktopMenuBridge {
showContextMenu?: (input?: {
kind?: "terminal";
hasSelection?: boolean;
}) => Promise<void>;
}
export interface DesktopWindowBridge {
label?: string;
startMove?: (screenX: number, screenY: number) => void;
@@ -51,7 +44,6 @@ export interface DesktopWindowBridge {
endMove?: () => void;
toggleMaximize?: () => Promise<void>;
isFullscreen?: () => Promise<boolean>;
setTitleBarTheme?: (theme: "light" | "dark") => Promise<void>;
onResized?: <TEvent = unknown>(
handler: (event: TEvent) => void,
) => Promise<() => void> | (() => void);
@@ -81,7 +73,6 @@ export interface DesktopHostBridge {
dialog?: DesktopDialogBridge;
notification?: DesktopNotificationBridge;
opener?: DesktopOpenerBridge;
menu?: DesktopMenuBridge;
}
declare global {
@@ -10,15 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
export function UpdateBanner() {
const { theme } = useUnistyles();
const {
isDesktop,
status,
availableUpdate,
errorMessage,
checkForUpdates,
installUpdate,
isInstalling,
} = useDesktopAppUpdater();
const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } =
useDesktopAppUpdater();
const [dismissed, setDismissed] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,36 +33,24 @@ export function UpdateBanner() {
if (!isDesktop) return null;
if (dismissed) return null;
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
return null;
if (status !== "available" && status !== "installed") return null;
const isInstalled = status === "installed";
const isError = status === "error";
function getTitle(): string {
if (isInstalled) return "Update installed";
if (isInstalling) return "Installing update";
if (isError) return "Update failed";
return "Update available";
}
function getSubtitle(): string {
if (isInstalled) return "Restart to use the new version.";
if (isInstalling) return "Downloading and installing...";
if (isError) return errorMessage ?? "Something went wrong.";
return `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`;
}
return (
<View style={styles.container} pointerEvents="box-none">
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.banner}>
<Pressable onPress={() => setDismissed(true)} hitSlop={8} style={styles.closeButton}>
<X size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.textSection}>
<Text style={styles.title}>{getTitle()}</Text>
<Text style={styles.subtitle}>{getSubtitle()}</Text>
<Text style={styles.title}>{isInstalled ? "Update installed" : "Update available"}</Text>
<Text style={styles.subtitle}>
{isInstalled
? "Restart to use the new version."
: `${availableUpdate?.latestVersion ? `v${availableUpdate.latestVersion.replace(/^v/i, "")} is ready` : "A new version is ready"} to install.`}
</Text>
</View>
<View style={styles.actions}>
@@ -80,7 +61,7 @@ export function UpdateBanner() {
<Text style={styles.outlineButtonText}>What's new</Text>
</Pressable>
{!isInstalled && !isError && (
{!isInstalled && (
<Pressable
onPress={() => void installUpdate()}
disabled={isInstalling}
@@ -95,15 +76,6 @@ export function UpdateBanner() {
</Text>
</Pressable>
)}
{isError && (
<Pressable
onPress={() => void checkForUpdates()}
style={({ pressed }) => [styles.primaryButton, pressed && styles.buttonPressed]}
>
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
)}
</View>
</View>
</View>
@@ -137,21 +109,15 @@ const styles = StyleSheet.create((theme) => ({
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
top: theme.spacing[2],
left: theme.spacing[2],
padding: theme.spacing[1],
zIndex: 1,
},
textSection: {
flex: 1,
gap: 2,
paddingTop: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
+19 -17
View File
@@ -13,7 +13,6 @@ import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
useFormPreferences,
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
@@ -694,23 +693,26 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
const nextPreferences = mergeProviderPreferences({
preferences: preferences ?? {},
provider: formState.provider,
updates: {
model: modelId || undefined,
mode: formState.modeId || undefined,
...(modelId && formState.thinkingOptionId
? {
thinkingByModel: {
[modelId]: formState.thinkingOptionId,
},
}
: {}),
} satisfies Partial<ProviderPreferences>,
});
const existingProviderPrefs = preferences?.providerPreferences?.[formState.provider];
await updatePreferences(nextPreferences);
const nextProviderPrefs: ProviderPreferences = {
model: modelId || undefined,
mode: formState.modeId || undefined,
thinkingByModel: {
...existingProviderPrefs?.thinkingByModel,
...(modelId && formState.thinkingOptionId
? { [modelId]: formState.thinkingOptionId }
: {}),
},
};
await updatePreferences({
provider: formState.provider,
providerPreferences: {
...preferences?.providerPreferences,
[formState.provider]: nextProviderPrefs,
},
});
}, [
availableModels,
formState.model,
@@ -1,57 +0,0 @@
import { describe, expect, it } from "vitest";
import { mergeProviderPreferences } from "./use-form-preferences";
describe("mergeProviderPreferences", () => {
it("stores the selected model for a provider", () => {
expect(
mergeProviderPreferences({
preferences: {},
provider: "claude",
updates: { model: "claude-opus-4-6" },
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-opus-4-6",
},
},
});
});
it("merges thinking preferences by model without dropping existing entries", () => {
expect(
mergeProviderPreferences({
preferences: {
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
},
},
},
},
provider: "claude",
updates: {
thinkingByModel: {
"claude-opus-4-6": "high",
},
},
}),
).toEqual({
provider: "claude",
providerPreferences: {
claude: {
model: "claude-sonnet-4-6",
thinkingByModel: {
"claude-sonnet-4-6": "medium",
"claude-opus-4-6": "high",
},
},
},
});
});
});
@@ -2,7 +2,6 @@ import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
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"];
@@ -36,36 +35,6 @@ export interface UseFormPreferencesReturn {
updatePreferences: (updates: Partial<FormPreferences>) => Promise<void>;
}
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
const existing = existingProviderPreferences[provider] ?? {};
const nextThinkingByModel =
updates.thinkingByModel === undefined
? existing.thinkingByModel
: {
...existing.thinkingByModel,
...updates.thinkingByModel,
};
return {
...preferences,
provider,
providerPreferences: {
...existingProviderPreferences,
[provider]: {
...existing,
...updates,
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
},
},
};
}
export function useFormPreferences(): UseFormPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
@@ -30,16 +30,6 @@ describe("use-settings", () => {
);
});
it("defaults theme to auto when storage is empty", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-settings");
const result = await mod.loadSettingsFromStorage();
expect(result.theme).toBe("auto");
});
it("loads persisted built-in daemon management state", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:app-settings") {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface AppSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
theme: "dark",
manageBuiltInDaemon: true,
};
+16 -166
View File
@@ -15,10 +15,7 @@ import {
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
startDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { getOrCreateClientId } from "@/utils/client-id";
@@ -36,10 +33,6 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRuntimeBootstrapResult =
| { ok: true; listenAddress: string; serverId: string; hostname: string | null }
| { ok: false; error: string };
export type ActiveConnection =
| { type: "directTcp"; endpoint: string; display: string }
| { type: "directSocket"; endpoint: string; display: "socket" }
@@ -1080,7 +1073,6 @@ const DEFAULT_LOCALHOST_ENDPOINT =
process.env.EXPO_PUBLIC_LOCAL_DAEMON?.trim() || "localhost:6767";
const DEFAULT_LOCALHOST_BOOTSTRAP_KEY = "@paseo:default-localhost-bootstrap-v1";
const DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS = 2500;
const CONNECTION_ONLINE_TIMEOUT_MS = 15_000;
const E2E_STORAGE_KEY = "@paseo:e2e";
export class HostRuntimeStore {
@@ -1145,7 +1137,7 @@ export class HostRuntimeStore {
}
}
async bootstrap(options?: { manageBuiltInDaemon?: boolean }): Promise<void> {
async bootstrap(): Promise<void> {
if (this.bootstrapAttempted) {
return;
}
@@ -1161,48 +1153,30 @@ export class HostRuntimeStore {
}
if (shouldUseDesktopDaemon()) {
if (options?.manageBuiltInDaemon ?? true) {
await this.bootstrapDesktop();
}
await this.bootstrapDesktop();
} else {
await this.bootstrapLocalhost();
}
}
async bootstrapDesktop(): Promise<HostRuntimeBootstrapResult> {
private async bootstrapDesktop(): Promise<void> {
try {
const daemon = await startDesktopDaemon();
const listenAddress = daemon.listen?.trim() ?? "";
const serverId = daemon.serverId.trim();
if (!listenAddress) {
return {
ok: false,
error: "Desktop daemon did not return a listen address.",
};
const connection = connectionFromListen(daemon.listen);
if (!connection) {
return;
}
if (!serverId) {
return {
ok: false,
error: "Desktop daemon did not return a server id.",
};
}
if (!connectionFromListen(listenAddress)) {
return {
ok: false,
error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`,
};
}
return {
ok: true,
listenAddress,
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
hostname: daemon.hostname,
};
label: hostname ?? daemon.hostname ?? undefined,
connection,
existingClient: client,
});
} catch (error) {
return {
ok: false,
error: toErrorMessage(error),
};
console.warn("[HostRuntime] Failed to bootstrap desktop daemon connection", error);
}
}
@@ -1308,36 +1282,6 @@ export class HostRuntimeStore {
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
listenAddress: string;
serverId: string;
hostname: string | null;
timeoutMs?: number;
}): Promise<HostProfile> {
const normalizedListenAddress = input.listenAddress.trim();
const serverId = input.serverId.trim();
const connection = connectionFromListen(normalizedListenAddress);
if (!connection) {
throw new Error(`Unsupported listen address: ${input.listenAddress}`);
}
if (!serverId) {
throw new Error("Desktop daemon did not return a server id.");
}
const profile = await this.upsertHostConnection({
serverId,
label: input.hostname ?? undefined,
connection,
});
await this.waitForConnectionOnline({
serverId,
connectionId: connection.id,
timeoutMs: input.timeoutMs,
});
return profile;
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
@@ -1499,100 +1443,6 @@ export class HostRuntimeStore {
}
}
private waitForConnectionOnline(input: {
serverId: string;
connectionId: string;
timeoutMs?: number;
}): Promise<void> {
const { serverId, connectionId } = input;
const timeoutMs = input.timeoutMs ?? CONNECTION_ONLINE_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const cleanup = (unsubscribe: (() => void) | null): void => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
unsubscribe?.();
};
const settle = (
unsubscribe: (() => void) | null,
outcome: { ok: true } | { ok: false; error: Error },
): void => {
if (settled) {
return;
}
settled = true;
cleanup(unsubscribe);
if (outcome.ok) {
resolve();
} else {
reject(outcome.error);
}
};
const readSnapshot = (): { ok: true } | { ok: false; error: Error } | null => {
const snapshot = this.getSnapshot(serverId);
if (!snapshot) {
return {
ok: false,
error: new Error(`Unknown host runtime for serverId ${serverId}`),
};
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "online"
) {
return { ok: true };
}
if (
snapshot.activeConnectionId === connectionId &&
snapshot.connectionStatus === "error"
) {
return {
ok: false,
error: new Error(snapshot.lastError ?? "Connection failed before coming online."),
};
}
return null;
};
const unsubscribe = this.subscribe(serverId, () => {
const outcome = readSnapshot();
if (outcome) {
settle(unsubscribe, outcome);
}
});
timeoutHandle = setTimeout(() => {
settle(unsubscribe, {
ok: false,
error: new Error(`Timed out waiting for connection ${connectionId} to come online.`),
});
}, timeoutMs);
const initialOutcome = readSnapshot();
if (initialOutcome) {
settle(unsubscribe, initialOutcome);
return;
}
void this.runProbeCycleNow(serverId).catch((error) => {
settle(unsubscribe, {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
});
}
private maybeAutoBootstrapAgentDirectory(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
@@ -1,22 +1,26 @@
import { useEffect } from "react";
import { View, Text } from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { MenuHeader } from "@/components/headers/menu-header";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { getIsDesktopMac } from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
export function OpenProjectScreen({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const trafficLightPadding = useTrafficLightPadding();
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
const openAgentList = usePanelStore((s) => s.openAgentList);
const openProjectPicker = useOpenProjectPicker(serverId);
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsDesktopMac();
const trafficLightInset = needsTrafficLightInset ? trafficLightPadding.left : 0;
const dragHandlers = useDesktopDragHandlers();
useEffect(() => {
@@ -26,25 +30,23 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content} {...dragHandlers}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
<View style={styles.headingGroup}>
<Text style={styles.heading}>What shall we build today?</Text>
{hasHydrated && !hasProjects ? (
<Text style={styles.subtitle}>
Add a project folder to start running agents on your codebase
</Text>
) : null}
</View>
<View style={styles.cta}>
<Button variant="default" leftIcon={FolderOpen} onPress={() => void openProjectPicker()} testID="open-project-submit">
Add a project
</Button>
</View>
<View style={styles.container} {...dragHandlers}>
<View style={[styles.menuToggle, { paddingTop: insets.top, paddingLeft: trafficLightInset }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
<Pressable
style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]}
onPress={() => {
void openProjectPicker();
}}
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
</View>
</View>
);
@@ -56,32 +58,43 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
userSelect: "none",
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
gap: 0,
gap: theme.spacing[6],
padding: theme.spacing[6],
},
logo: {
marginBottom: theme.spacing[8],
},
headingGroup: {
alignItems: "center",
gap: theme.spacing[3],
},
cta: {
marginTop: theme.spacing[12],
},
heading: {
color: theme.colors.foreground,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.normal,
textAlign: "center",
},
subtitle: {
openButton: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
openButtonHovered: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
openButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
+8 -36
View File
@@ -1,14 +1,10 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { View, Text } from "react-native";
import { View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export function SessionsScreen({ serverId }: { serverId: string }) {
const isFocused = useIsFocused();
@@ -48,26 +44,13 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
return (
<View style={styles.container}>
<MenuHeader title="Sessions" />
{sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No sessions yet</Text>
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
>
Back
</Button>
</View>
) : (
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
)}
<AgentList
agents={sortedAgents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
showAttentionIndicator={false}
/>
</View>
);
}
@@ -77,15 +60,4 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[6],
padding: theme.spacing[6],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
},
}));
@@ -896,6 +896,12 @@ export default function SettingsScreen() {
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
if (newTheme === "auto") {
UnistylesRuntime.setAdaptiveThemes(true);
} else {
UnistylesRuntime.setAdaptiveThemes(false);
UnistylesRuntime.setTheme(newTheme);
}
},
[updateSettings],
);
@@ -1,318 +1,29 @@
import { useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Platform, ScrollView, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { openExternalUrl } from "@/utils/open-external-url";
import { BookOpen, Check, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import {
getDesktopDaemonLogs,
type DesktopDaemonLogs,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
type StartupSplashScreenProps = {
bootstrapState?: {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
};
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const DOCS_URL = "https://paseo.sh/docs";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[8],
paddingVertical: theme.spacing[8],
},
containerError: {
justifyContent: "flex-start",
paddingTop: theme.spacing[16],
},
centeredContent: {
alignItems: "center",
justifyContent: "center",
maxWidth: 520,
width: "100%",
},
errorContent: {
alignItems: "stretch",
maxWidth: 720,
width: "100%",
gap: theme.spacing[6],
},
errorHeader: {
alignItems: "flex-start",
},
title: {
marginTop: theme.spacing[8],
color: theme.colors.foreground,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
titleError: {
textAlign: "left",
},
subtitleRow: {
marginTop: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
},
progressSteps: {
marginTop: theme.spacing[4],
gap: theme.spacing[3],
width: "100%",
},
progressStepRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
},
subtitle: {
status: {
marginTop: theme.spacing[8],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
subtitleInline: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.lg,
textAlign: "center",
},
errorDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
errorMessage: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
lineHeight: 20,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
logsContainer: {
height: 200,
borderRadius: theme.borderRadius.xl,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
},
logsScroll: {
flexGrow: 0,
},
logsContent: {
padding: theme.spacing[4],
},
logsText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
lineHeight: 18,
...(Platform.OS === "web"
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
actionRow: {
flexDirection: "row",
gap: theme.spacing[3],
flexWrap: "wrap",
},
}));
export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) {
const { theme } = useUnistyles();
export function StartupSplashScreen() {
const dragHandlers = useDesktopDragHandlers();
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const phase = bootstrapState?.phase;
const isError = phase === "error";
const isSimpleSplash = bootstrapState === undefined;
useEffect(() => {
if (!isError) {
setDaemonLogs(null);
setLogsError(null);
setIsLoadingLogs(false);
return;
}
let isCancelled = false;
setIsLoadingLogs(true);
setLogsError(null);
void getDesktopDaemonLogs()
.then((logs) => {
if (isCancelled) {
return;
}
setDaemonLogs(logs);
})
.catch((error) => {
if (isCancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setDaemonLogs(null);
setLogsError(`Unable to load daemon logs: ${message}`);
})
.finally(() => {
if (!isCancelled) {
setIsLoadingLogs(false);
}
});
return () => {
isCancelled = true;
};
}, [isError]);
const progressSteps =
phase === "starting-daemon"
? [{ key: "starting-daemon", label: "Starting local server...", status: "active" as const }]
: phase === "connecting"
? [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connecting to local server...", status: "active" as const },
]
: [
{ key: "starting-daemon", label: "Started local server", status: "complete" as const },
{ key: "connecting", label: "Connected to local server", status: "complete" as const },
];
const logsText = useMemo(() => {
if (isLoadingLogs) {
return "Loading daemon logs...";
}
if (daemonLogs?.contents) {
return daemonLogs.contents;
}
if (logsError) {
return logsError;
}
return "No daemon logs available.";
}, [daemonLogs?.contents, isLoadingLogs, logsError]);
const handleCopyLogs = () => {
const payload = daemonLogs?.logPath
? `${daemonLogs.logPath}\n\n${daemonLogs.contents}`
: logsText;
void Clipboard.setStringAsync(payload);
};
if (isSimpleSplash) {
return (
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.subtitle}>Starting up…</Text>
</View>
);
}
if (!isError) {
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.centeredContent}>
<PaseoLogo size={96} />
<Text style={styles.title}>Welcome to Paseo</Text>
<View style={styles.progressSteps}>
{progressSteps.map((step) => (
<View key={step.key} style={styles.progressStepRow}>
{step.status === "complete" ? (
<Check size={18} color={theme.colors.success} />
) : (
<ActivityIndicator color={theme.colors.accent} />
)}
<Text style={styles.subtitleInline}>{step.label}</Text>
</View>
))}
</View>
</View>
</View>
);
}
return (
<View style={[styles.container, styles.containerError]} {...dragHandlers}>
<View style={styles.errorContent}>
<View style={styles.errorHeader}>
<PaseoLogo size={64} />
<Text style={[styles.title, styles.titleError]}>Something went wrong</Text>
</View>
<Text style={styles.errorDescription}>
The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below.
</Text>
<Text style={styles.errorMessage}>
{bootstrapState.error}
</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
</View>
<View style={styles.actionRow}>
<Button
variant="secondary"
leftIcon={<Copy size={16} color={theme.colors.foreground} />}
onPress={handleCopyLogs}
>
Copy logs
</Button>
<Button
variant="outline"
leftIcon={<TriangleAlert size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(GITHUB_ISSUE_URL)}
>
Open GitHub issue
</Button>
<Button
variant="outline"
leftIcon={<BookOpen size={16} color={theme.colors.foreground} />}
onPress={() => void openExternalUrl(DOCS_URL)}
>
Docs
</Button>
<Button
variant="default"
leftIcon={<RotateCw size={16} color={theme.colors.palette.white} />}
onPress={bootstrapState.retry}
>
Retry
</Button>
</View>
</View>
<View style={styles.container} {...dragHandlers}>
<PaseoLogo size={96} />
<Text style={styles.status}>Starting up…</Text>
</View>
);
}
@@ -8,7 +8,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import {
type PendingTerminalModifiers,
isTerminalModifierDomKey,
@@ -169,12 +168,7 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void openExternalUrl(uri);
}),
);
terminal.loadAddon(new WebLinksAddon());
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
terminal.loadAddon(new ClipboardAddon());
try {
+8 -60
View File
@@ -2,15 +2,11 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
import {
getIsDesktopMac,
getIsDesktop,
DESKTOP_TRAFFIC_LIGHT_WIDTH,
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
DESKTOP_WINDOW_CONTROLS_WIDTH,
DESKTOP_WINDOW_CONTROLS_HEIGHT,
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
@@ -124,19 +120,11 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
type RawWindowControlsPadding = {
left: number;
right: number;
top: number;
};
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
function useRawWindowControlsPadding(): RawWindowControlsPadding {
export function useTrafficLightPadding(): { left: number; top: number } {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (Platform.OS !== "web" || !getIsDesktop()) return;
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
let disposed = false;
let cleanup: (() => void) | undefined;
@@ -187,52 +175,12 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
};
}, []);
return useMemo((): RawWindowControlsPadding => {
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0 };
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
return {
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
};
}, [isFullscreen]);
}
export function useWindowControlsPadding(
role: WindowControlsPaddingRole,
): { left: number; right: number; top: number } {
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const rawPadding = useRawWindowControlsPadding();
const sidebarClosed = !sidebarOpen;
let left = 0;
let right = 0;
let top = 0;
if (role === "sidebar") {
left = rawPadding.left;
top = rawPadding.top;
} else if (role === "header") {
left = sidebarClosed ? rawPadding.left : 0;
right = explorerOpen ? 0 : rawPadding.right;
} else if (role === "tabRow") {
left = sidebarClosed && focusModeEnabled ? rawPadding.left : 0;
right = focusModeEnabled && !explorerOpen ? rawPadding.right : 0;
} else if (role === "explorerSidebar") {
right = rawPadding.right;
if (!getIsDesktopMac() || isFullscreen) {
return { left: 0, top: 0 };
}
return useMemo(() => ({ left, right, top }), [left, right, top]);
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
shouldSyncSidebarAnimation,
} from "./sidebar-animation-state";
describe("sidebar-animation-state", () => {
it("requests a sync when the open state changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: true,
previousWindowWidth: 390,
nextWindowWidth: 390,
}),
).toBe(true);
});
it("requests a sync when the viewport width changes", () => {
expect(
shouldSyncSidebarAnimation({
previousIsOpen: false,
nextIsOpen: false,
previousWindowWidth: 390,
nextWindowWidth: 430,
}),
).toBe(true);
});
it("keeps the left sidebar fully off-screen when closed", () => {
expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: -430,
backdropOpacity: 0,
});
});
it("keeps the right sidebar fully off-screen when closed", () => {
expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({
translateX: 430,
backdropOpacity: 0,
});
});
});
@@ -1,41 +0,0 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
return (
input.previousIsOpen !== input.nextIsOpen ||
input.previousWindowWidth !== input.nextWindowWidth
);
}
export function getLeftSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : -input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
export function getRightSidebarAnimationTargets(
input: SidebarAnimationTargetInput,
): SidebarAnimationTargets {
return {
translateX: input.isOpen ? 0 : input.windowWidth,
backdropOpacity: input.isOpen ? 1 : 0,
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.39",
"version": "0.1.34",
"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.39",
"@getpaseo/server": "0.1.39",
"@getpaseo/relay": "0.1.34",
"@getpaseo/server": "0.1.34",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
-16
View File
@@ -2,13 +2,9 @@ import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
@@ -141,18 +137,6 @@ export function createCli(): Command {
// Daemon commands
program.addCommand(createDaemonCommand());
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
// Permission commands
program.addCommand(createPermitCommand());
+4 -2
View File
@@ -1,6 +1,5 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addDeleteOptions(cmd: Command): Command {
return cmd
@@ -70,9 +69,12 @@ export async function runDeleteCommand(
if (options.all) {
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
const fetchResult = await client.fetchAgent(id);
+5 -2
View File
@@ -3,7 +3,6 @@ import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
export function addLsOptions(cmd: Command): Command {
return cmd
@@ -194,7 +193,11 @@ export async function runLsCommand(
// Optional cwd filter.
if (options.cwd) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
}
// Apply label filtering only when explicitly requested.
+4 -2
View File
@@ -1,6 +1,5 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
import type {
CommandOptions,
SingleResult,
@@ -76,9 +75,12 @@ export async function runStopCommand(
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false;
return isSameOrDescendantPath(options.cwd!, a.cwd);
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
// Stop specific agent
-31
View File
@@ -1,31 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-69
View File
@@ -1,69 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
chat.command("ls").description("List chat rooms"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-23
View File
@@ -1,23 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}
-41
View File
@@ -1,41 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
resolveChatAuthorAgentId,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string;
}
export async function runPostCommand(
room: string,
body: string,
options: ChatPostOptions,
_command: Command,
): Promise<SingleResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.postChatMessage({
room,
body,
authorAgentId: resolveChatAuthorAgentId(),
replyToMessageId: options.replyTo,
});
const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
return {
type: "single",
data: message!,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
} finally {
await client.close().catch(() => {});
}
}
-60
View File
@@ -1,60 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseSinceValue,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatReadOptions extends ChatCommandOptions {
limit?: string;
since?: string;
agent?: string;
}
function parseLimit(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw {
code: "INVALID_LIMIT",
message: "Invalid --limit value",
details: "Use a non-negative integer.",
};
}
return parsed;
}
export async function runReadCommand(
room: string,
options: ChatReadOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.readChatMessages({
room,
limit: parseLimit(options.limit),
since: parseSinceValue(options.since),
authorAgentId: options.agent,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
-126
View File
@@ -1,126 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
export interface ChatRoomRow {
name: string;
id: string;
purpose: string;
messages: number;
lastMessageAt: string;
}
export interface ChatMessageRow {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
idField: "id",
columns: [
{ header: "NAME", field: "name", width: 22 },
{ header: "ID", field: "id", width: 36 },
{ header: "PURPOSE", field: "purpose", width: 30 },
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
],
};
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 36 },
{ header: "AUTHOR", field: "author", width: 16 },
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
{ header: "CREATED", field: "createdAt", width: 24 },
{ header: "REPLY TO", field: "replyTo", width: 36 },
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
{ header: "MESSAGE", field: "body", width: 60 },
],
renderHuman: renderChatTranscript,
};
function renderChatTranscript(
result: AnyCommandResult<ChatMessageRow>,
_options: OutputOptions,
): string {
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return "";
}
return data.map(renderChatMessageBlock).join("\n\n");
}
function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
const lines = [`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
}
if (message.mentionLabels.length > 0) {
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
}
lines.push("│");
const bodyLines = message.body.split(/\r?\n/);
for (const line of bodyLines) {
lines.push(`│ ${line}`);
}
lines.push("│");
lines.push("└─");
return lines.join("\n");
}
function formatTimestamp(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) {
return input;
}
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
}
export function toChatRoomRow(room: {
id: string;
name: string;
purpose: string | null;
messageCount: number;
lastMessageAt: string | null;
}): ChatRoomRow {
return {
id: room.id,
name: room.name,
purpose: room.purpose ?? "-",
messages: room.messageCount,
lastMessageAt: room.lastMessageAt ?? "-",
};
}
export function toChatMessageRow(message: {
id: string;
authorAgentId: string;
createdAt: string;
replyToMessageId: string | null;
mentionAgentIds: string[];
body: string;
}): ChatMessageRow {
return {
id: message.id,
author: message.authorAgentId,
authorName: null,
createdAt: message.createdAt,
replyTo: message.replyToMessageId ?? "-",
mentionAgentIds: message.mentionAgentIds,
mentionLabels: message.mentionAgentIds,
body: message.body,
};
}
-127
View File
@@ -1,127 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { parseDuration } from "../../utils/duration.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { ChatMessageRow } from "./schema.js";
export interface ChatCommandOptions extends CommandOptions {
host?: string;
}
export async function connectChatClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
agentIds.add(message.author);
for (const mentionId of message.mentionAgentIds) {
agentIds.add(mentionId);
}
}
if (agentIds.size === 0) {
return messages;
}
const payload = await client.fetchAgents({
filter: { includeArchived: true },
});
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
if (title) {
agentNames.set(entry.agent.id, title);
}
}
return messages.map((message) => ({
...message,
authorName: agentNames.get(message.author) ?? null,
mentionLabels: message.mentionAgentIds.map((agentId) => {
const name = agentNames.get(agentId);
return name ? `${name} (${agentId})` : agentId;
}),
}));
}
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export function parseSinceValue(input?: string): string | undefined {
if (!input) {
return undefined;
}
try {
const durationMs = parseDuration(input);
return new Date(Date.now() - durationMs).toISOString();
} catch {
const timestamp = new Date(input);
if (Number.isNaN(timestamp.getTime())) {
const error: CommandError = {
code: "INVALID_SINCE",
message: "Invalid --since value",
details: "Use a duration like 10m or an ISO timestamp.",
};
throw error;
}
return timestamp.toISOString();
}
}
export function parseTimeoutMs(input?: string): number | undefined {
if (!input) {
return undefined;
}
try {
const timeoutMs = parseDuration(input);
if (timeoutMs <= 0) {
throw new Error("Timeout must be positive");
}
return timeoutMs;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
};
throw error;
}
}
export function resolveChatAuthorAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
return agentId && agentId.length > 0 ? agentId : "manual";
}
-47
View File
@@ -1,47 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseTimeoutMs,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const latest = await client.readChatMessages({
room,
limit: 1,
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: parseTimeoutMs(options.timeout),
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -124,16 +124,11 @@ function resolveDaemonRunnerEntry(): string {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
const distRunner = path.join(
currentDir,
"dist",
"scripts",
"supervisor-entrypoint.js",
);
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
if (existsSync(distRunner)) {
return distRunner;
}
return path.join(currentDir, "scripts", "supervisor-entrypoint.ts");
return path.join(currentDir, "scripts", "daemon-runner.ts");
}
} catch {
// Continue searching up if package.json exists but is invalid.
@@ -299,8 +294,7 @@ export function resolveTcpHostFromListen(listen: string): string | null {
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\") ||
/^[A-Za-z]:[/\\]/.test(normalized)
normalized.startsWith("\\\\.\\pipe\\")
) {
return null;
}
@@ -1,5 +1,4 @@
import { spawnSync } from "node:child_process";
import { platform } from "node:os";
export interface NodePathFromPidResult {
nodePath: string | null;
@@ -13,14 +12,17 @@ function normalizeError(error: unknown): string {
return String(error);
}
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
return {
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
};
}
if ((result.status ?? 1) !== 0) {
@@ -32,82 +34,12 @@ function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
}
const resolved = result.stdout.trim();
return resolved ? { nodePath: resolved } : { nodePath: null, error: "ps returned an empty command path" };
}
function runProcessProbe(command: string, args: string[]): {
resolved: string | null;
error?: string;
} {
const result = spawnSync(command, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim();
if (!resolved) {
return {
resolved: null,
error: details ? `${command} failed: ${details}` : `${command} exited with code ${result.status ?? 1}`,
nodePath: null,
error: "ps returned an empty command path",
};
}
const resolved = result.stdout.trim();
return resolved ? { resolved } : { resolved: null, error: `${command} returned no executable path` };
}
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
const probes: Array<{ label: string; command: string; args: string[]; parseValue?: (stdout: string) => string | null }> = [
{
label: "powershell-cim",
command: "powershell",
args: [
"-NoProfile",
"-Command",
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").ExecutablePath`,
],
},
{
label: "powershell-process",
command: "powershell",
args: ["-NoProfile", "-Command", `(Get-Process -Id ${pid}).Path`],
},
{
label: "wmic",
command: "wmic",
args: ["process", "where", `ProcessId=${pid}`, "get", "ExecutablePath", "/VALUE"],
parseValue: (stdout) => {
const match = stdout.match(/ExecutablePath=(.+)/);
return match?.[1]?.trim() ?? null;
},
},
];
const errors: string[] = [];
for (const probe of probes) {
const result = runProcessProbe(probe.command, probe.args);
if (result.resolved) {
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
if (resolved) {
return { nodePath: resolved };
}
errors.push(`${probe.label} returned no executable path`);
continue;
}
if (result.error) {
errors.push(`${probe.label}: ${result.error}`);
}
}
return { nodePath: null, error: errors.join("; ") || "could not resolve executable path from PID" };
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
return platform() === "win32"
? resolveNodePathFromPidWindows(pid)
: resolveNodePathFromPidUnix(pid);
return { nodePath: resolved };
}
+24 -55
View File
@@ -15,8 +15,7 @@ interface ProviderBinaryStatus {
interface DaemonStatus {
serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed";
status: "running" | "stopped" | "unresponsive";
home: string;
listen: string;
hostname: string | null;
@@ -86,16 +85,11 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === "Local Daemon") {
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow";
return "red";
}
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
@@ -112,8 +106,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Local Daemon", value: status.localDaemon },
{ key: "Connected Daemon", value: status.connectedDaemon },
{ key: "Status", value: status.status },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
@@ -206,7 +199,6 @@ export async function runStatusCommand(
): Promise<StatusResult> {
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const host = resolveTcpHostFromListen(state.listen);
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
@@ -219,60 +211,38 @@ export async function runStatusCommand(
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath;
let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped";
let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed";
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
localDaemon = "stale_pid";
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
connectedDaemon = "reachable";
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
if (!state.running) {
daemonNode = "unknown (API reachable, PID unresolved)";
note = appendNote(
note,
state.pidInfo
? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale`
: `Connected daemon is reachable at ${host} but no local daemon PID file was found`,
);
if (state.running) {
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
} catch {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {});
}
} catch {
if (state.running) {
localDaemon = "unresponsive";
}
note = appendNote(
note,
state.running
? `Local daemon PID is running but API requests to ${host} failed`
: `Connected daemon websocket is reachable at ${host} but fetch_agents failed`,
);
} finally {
await client.close().catch(() => {});
} else {
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
}
} else if (state.running) {
connectedDaemon = "unreachable";
localDaemon = "unresponsive";
note = appendNote(
note,
`Local daemon PID is running but websocket at ${host} is not reachable`,
);
} else {
connectedDaemon = "unreachable";
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
} else {
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
const cliVersion = resolveCliVersion();
@@ -288,8 +258,7 @@ export async function runStatusCommand(
const daemonStatus: DaemonStatus = {
serverId,
localDaemon,
connectedDaemon,
status,
home: state.home,
listen: state.listen,
hostname: state.pidInfo?.hostname ?? null,
-34
View File
@@ -1,34 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
addJsonAndDaemonHostOptions(
addLoopRunOptions(loop.command("run")),
).action(withOutput(runLoopRunCommand));
addJsonAndDaemonHostOptions(
addLoopLsOptions(loop.command("ls")),
).action(withOutput(runLoopLsCommand));
addJsonAndDaemonHostOptions(
addLoopInspectOptions(loop.command("inspect")),
).action(withOutput(runLoopInspectCommand));
addDaemonHostOption(
addLoopLogsOptions(loop.command("logs")),
).action(runLoopLogsCommand);
addJsonAndDaemonHostOptions(
addLoopStopOptions(loop.command("stop")),
).action(withOutput(runLoopStopCommand));
return loop;
}
-118
View File
@@ -1,118 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
key: string;
value: string;
}
export interface LoopInspectOptions extends CommandOptions {}
export function addLoopInspectOptions(command: Command): Command {
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
}
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => loop,
};
}
function toRows(loop: LoopRecord): InspectRow[] {
return [
{ key: "Id", value: loop.id },
{ key: "Name", value: loop.name ?? "null" },
{ key: "Status", value: loop.status },
{ key: "Cwd", value: loop.cwd },
{ key: "Provider", value: loop.provider },
{ key: "Model", value: loop.model ?? "null" },
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
{ key: "VerifyChecks", value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]" },
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
{ key: "MaxIterations", value: loop.maxIterations === null ? "null" : String(loop.maxIterations) },
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
{
key: "Iterations",
value:
loop.iterations.length === 0
? "[]"
: loop.iterations
.map((iteration) => {
const summary = [
`#${iteration.index}`,
iteration.status,
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
]
.filter(Boolean)
.join(" ");
return summary;
})
.join(" | "),
},
];
}
export type LoopInspectResult = ListResult<InspectRow>;
export async function runLoopInspectCommand(
id: string,
options: LoopInspectOptions,
_command: Command,
): Promise<LoopInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopInspect(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "list",
data: toRows(payload.loop),
schema: createInspectSchema(payload.loop),
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_INSPECT_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-82
View File
@@ -1,82 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
export interface LoopLogsOptions extends CommandOptions {
pollInterval?: string;
}
export function addLoopLogsOptions(command: Command): Command {
return command
.description("Stream loop logs")
.argument("<id>", "Loop ID")
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
}
function parsePollInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_POLL_INTERVAL",
message: "--poll-interval must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function renderLogEntry(entry: LoopLogEntry): string {
const prefix = [
entry.timestamp,
entry.source,
entry.iteration === null ? null : `iteration=${entry.iteration}`,
entry.level === "error" ? "ERROR" : null,
]
.filter(Boolean)
.join(" ");
return `${prefix}\n${entry.text}`;
}
export async function runLoopLogsCommand(
id: string,
options: LoopLogsOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
let cursor = 0;
try {
for (;;) {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
cursor = payload.nextCursor;
for (const entry of payload.entries) {
console.log(renderLogEntry(entry));
}
if (payload.loop.status !== "running") {
await client.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Failed to stream loop logs: ${message}`);
process.exit(1);
}
}
-88
View File
@@ -1,88 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
ListResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
id: string;
name: string | null;
status: string;
cwd: string;
updated: string;
activeIteration: string;
}
export interface LoopLsOptions extends CommandOptions {}
export const loopLsSchema: OutputSchema<LoopListRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
{ header: "CWD", field: "cwd", width: 40 },
{ header: "UPDATED", field: "updated", width: 24 },
],
};
export function addLoopLsOptions(command: Command): Command {
return command.description("List loops");
}
function toRow(loop: LoopListItem): LoopListRow {
return {
id: loop.id,
name: loop.name,
status: loop.status,
cwd: loop.cwd,
updated: loop.updatedAt,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopLsResult = ListResult<LoopListRow>;
export async function runLoopLsCommand(
options: LoopLsOptions,
_command: Command,
): Promise<LoopLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopList();
await client.close();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.loops.map(toRow),
schema: loopLsSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_LIST_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-161
View File
@@ -1,161 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
export interface LoopRunRow {
id: string;
status: string;
name: string | null;
cwd: string;
}
export interface LoopRunOptions extends CommandOptions {
provider?: "claude" | "codex" | "opencode";
model?: string;
verifyProvider?: "claude" | "codex" | "opencode";
verifyModel?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
name?: string;
sleep?: string;
maxIterations?: string;
maxTime?: string;
}
export const loopRunSchema: OutputSchema<LoopRunRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CWD", field: "cwd", width: 40 },
],
};
export function addLoopRunOptions(command: Command): Command {
return command
.description("Start a loop")
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
"Shell command that must exit 0 (repeatable)",
collectMultiple,
[],
)
.option("--archive", "Archive worker and verifier agents after each iteration")
.option("--name <name>", "Optional loop name")
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
.option("--max-iterations <n>", "Maximum number of iterations")
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
}
function toRow(loop: LoopRecord): LoopRunRow {
return {
id: loop.id,
status: loop.status,
name: loop.name,
cwd: loop.cwd,
};
}
function parseMaxIterations(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_MAX_ITERATIONS",
message: "--max-iterations must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
const verifyPrompt = options.verify?.trim();
if (options.verify !== undefined && !verifyPrompt) {
throw {
code: "INVALID_VERIFY_PROMPT",
message: "--verify cannot be empty",
} satisfies CommandError;
}
return {
prompt,
cwd: process.cwd(),
...(options.provider ? { provider: options.provider } : {}),
...(options.model?.trim() ? { model: options.model.trim() } : {}),
...(options.verifyProvider ? { verifierProvider: options.verifyProvider } : {}),
...(options.verifyModel?.trim() ? { verifierModel: options.verifyModel.trim() } : {}),
...(verifyPrompt ? { verifyPrompt } : {}),
...(options.verifyCheck && options.verifyCheck.length > 0
? { verifyChecks: options.verifyCheck }
: {}),
...(options.archive ? { archive: true } : {}),
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(options.sleep ? { sleepMs: parseDuration(options.sleep) } : {}),
...(options.maxIterations ? { maxIterations: parseMaxIterations(options.maxIterations) } : {}),
...(options.maxTime ? { maxTimeMs: parseDuration(options.maxTime) } : {}),
};
}
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopRun(input);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? "Loop creation failed");
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopRunSchema,
};
} catch (error) {
await client.close().catch(() => {});
if (error && typeof error === "object" && "code" in error) {
throw error;
}
throw {
code: "LOOP_RUN_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-80
View File
@@ -1,80 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface LoopStopRow {
id: string;
status: string;
activeIteration: string;
}
export interface LoopStopOptions extends CommandOptions {}
export const loopStopSchema: OutputSchema<LoopStopRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
],
};
export function addLoopStopOptions(command: Command): Command {
return command.description("Stop a running loop").argument("<id>", "Loop ID");
}
function toRow(loop: LoopRecord): LoopStopRow {
return {
id: loop.id,
status: loop.status,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopStopResult = SingleResult<LoopStopRow>;
export async function runLoopStopCommand(
id: string,
options: LoopStopOptions,
_command: Command,
): Promise<LoopStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined });
let client;
try {
client = (await connectToDaemon({
host: options.host as string | undefined,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopStop(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopStopSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_STOP_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-147
View File
@@ -1,147 +0,0 @@
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
export interface LoopLogEntry {
seq: number;
timestamp: string;
iteration: number | null;
source: "loop" | "worker" | "verifier" | "verify-check";
level: "info" | "error";
text: string;
}
export interface LoopVerifyCheckResult {
command: string;
exitCode: number;
passed: boolean;
stdout: string;
stderr: string;
startedAt: string;
completedAt: string;
}
export interface LoopVerifyPromptResult {
passed: boolean;
reason: string;
verifierAgentId: string | null;
startedAt: string;
completedAt: string;
}
export interface LoopIterationRecord {
index: number;
workerAgentId: string | null;
workerStartedAt: string;
workerCompletedAt: string | null;
verifierAgentId: string | null;
status: "running" | "succeeded" | "failed" | "stopped";
workerOutcome: "completed" | "failed" | "canceled" | null;
failureReason: string | null;
verifyChecks: LoopVerifyCheckResult[];
verifyPrompt: LoopVerifyPromptResult | null;
}
export interface LoopRecord {
id: string;
name: string | null;
prompt: string;
cwd: string;
provider: "claude" | "codex" | "opencode";
model: string | null;
workerProvider: "claude" | "codex" | "opencode" | null;
workerModel: string | null;
verifierProvider: "claude" | "codex" | "opencode" | null;
verifierModel: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
sleepMs: number;
maxIterations: number | null;
maxTimeMs: number | null;
status: LoopStatus;
createdAt: string;
updatedAt: string;
startedAt: string;
completedAt: string | null;
stopRequestedAt: string | null;
iterations: LoopIterationRecord[];
logs: LoopLogEntry[];
nextLogSeq: number;
activeIteration: number | null;
activeWorkerAgentId: string | null;
activeVerifierAgentId: string | null;
}
export interface LoopListItem {
id: string;
name: string | null;
status: LoopStatus;
cwd: string;
createdAt: string;
updatedAt: string;
activeIteration: number | null;
}
export interface LoopLogsResult {
loop: LoopRecord;
entries: LoopLogEntry[];
nextCursor: number;
}
export interface LoopRunPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopListPayload {
requestId: string;
loops: LoopListItem[];
error: string | null;
}
export interface LoopInspectPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopLogsPayload {
requestId: string;
loop: LoopRecord | null;
entries: LoopLogEntry[];
nextCursor: number;
error: string | null;
}
export interface LoopStopPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: "claude" | "codex" | "opencode";
model?: string;
workerProvider?: "claude" | "codex" | "opencode";
workerModel?: string;
verifierProvider?: "claude" | "codex" | "opencode";
verifierModel?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
export interface LoopDaemonClient {
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
loopList(): Promise<LoopListPayload>;
loopInspect(id: string): Promise<LoopInspectPayload>;
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
loopStop(id: string): Promise<LoopStopPayload>;
close(): Promise<void>;
}
@@ -1,52 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
parseScheduleCreateInput,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}
export async function runCreateCommand(
prompt: string,
options: ScheduleCreateOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const input = parseScheduleCreateInput({
prompt,
every: options.every,
cron: options.cron,
name: options.name,
target: options.target,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleCreate(input);
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? "Schedule creation failed");
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_CREATE_FAILED", "create schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import type { OutputSchema } from "../../output/index.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
interface ScheduleDeleteRow {
id: string;
status: string;
}
const scheduleDeleteSchema: OutputSchema<ScheduleDeleteRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 12 },
],
};
export async function runDeleteCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleDeleteRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleDelete({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "single",
data: {
id: payload.scheduleId,
status: "deleted",
},
schema: scheduleDeleteSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_DELETE_FAILED", "delete schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,68 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runLogsCommand } from "./logs.js";
import { runPauseCommand } from "./pause.js";
import { runResumeCommand } from "./resume.js";
import { runDeleteCommand } from "./delete.js";
export function createScheduleCommand(): Command {
const schedule = new Command("schedule").description("Manage recurring schedules");
addJsonAndDaemonHostOptions(
schedule
.command("create")
.description("Create a schedule")
.argument("<prompt>", "Prompt to run on the schedule")
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
.option("--cron <expr>", "Cron cadence expression")
.option("--name <name>", "Optional schedule name")
.option("--target <self|new-agent|agent-id>", "Run target")
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
schedule.command("ls").description("List schedules"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("inspect")
.description("Inspect a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
schedule
.command("logs")
.description("Show recent schedule run logs")
.argument("<id>", "Schedule ID"),
).action(withOutput(runLogsCommand));
addJsonAndDaemonHostOptions(
schedule
.command("pause")
.description("Pause a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runPauseCommand));
addJsonAndDaemonHostOptions(
schedule
.command("resume")
.description("Resume a paused schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runResumeCommand));
addJsonAndDaemonHostOptions(
schedule
.command("delete")
.description("Delete a schedule")
.argument("<id>", "Schedule ID"),
).action(withOutput(runDeleteCommand));
return schedule;
}
@@ -1,32 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
createScheduleInspectRows,
createScheduleInspectSchema,
type ScheduleInspectRow,
} from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runInspectCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleInspectRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleInspect({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Schedule not found: ${id}`);
}
const rows = createScheduleInspectRows(payload.schedule);
return {
type: "list",
data: rows,
schema: createScheduleInspectSchema(payload.schedule),
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_INSPECT_FAILED", "inspect schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,27 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleLogSchema, toScheduleLogRow, type ScheduleLogRow } from "./schema.js";
import { connectScheduleClient, toScheduleCommandError, type ScheduleCommandOptions } from "./shared.js";
export async function runLogsCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleLogRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleLogs({ id });
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.runs.map(toScheduleLogRow),
schema: scheduleLogSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LOGS_FAILED", "read schedule logs", error);
} finally {
await client.close().catch(() => {});
}
}
-32
View File
@@ -1,32 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runLsCommand(
options: ScheduleCommandOptions,
_command: Command,
): Promise<ListResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleList();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.schedules.map(toScheduleRow),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_LIST_FAILED", "list schedules", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,33 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runPauseCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.schedulePause({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to pause schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_PAUSE_FAILED", "pause schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,33 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { scheduleSchema } from "./schema.js";
import {
connectScheduleClient,
toScheduleCommandError,
toScheduleRow,
type ScheduleCommandOptions,
type ScheduleRow,
} from "./shared.js";
export async function runResumeCommand(
id: string,
options: ScheduleCommandOptions,
_command: Command,
): Promise<SingleResult<ScheduleRow>> {
const { client } = await connectScheduleClient(options.host);
try {
const payload = await client.scheduleResume({ id });
if (payload.error || !payload.schedule) {
throw new Error(payload.error ?? `Failed to resume schedule: ${id}`);
}
return {
type: "single",
data: toScheduleRow(payload.schedule),
schema: scheduleSchema,
};
} catch (error) {
throw toScheduleCommandError("SCHEDULE_RESUME_FAILED", "resume schedule", error);
} finally {
await client.close().catch(() => {});
}
}
@@ -1,88 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import { formatTarget, type ScheduleRow } from "./shared.js";
import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
export const scheduleSchema: OutputSchema<ScheduleRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CADENCE", field: "cadence", width: 20 },
{ header: "TARGET", field: "target", width: 20 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "NEXT RUN", field: "nextRunAt", width: 24 },
],
};
export interface ScheduleInspectRow {
key: string;
value: string;
}
export function createScheduleInspectSchema(record: ScheduleRecord): OutputSchema<ScheduleInspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => record,
};
}
export interface ScheduleLogRow {
id: string;
status: string;
startedAt: string;
agentId: string | null;
output: string | null;
error: string | null;
}
export const scheduleLogSchema: OutputSchema<ScheduleLogRow> = {
idField: "id",
columns: [
{ header: "RUN ID", field: "id", width: 14 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "STARTED", field: "startedAt", width: 24 },
{ header: "AGENT", field: "agentId", width: 12 },
{ header: "OUTPUT", field: "output", width: 40 },
{ header: "ERROR", field: "error", width: 40 },
],
};
export function toScheduleLogRow(run: ScheduleRunRecord): ScheduleLogRow {
return {
id: run.id,
status: run.status,
startedAt: run.startedAt,
agentId: run.agentId ? run.agentId.slice(0, 7) : null,
output: run.output,
error: run.error,
};
}
export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleInspectRow[] {
return [
{ key: "Id", value: schedule.id },
{ key: "Name", value: schedule.name ?? "null" },
{ key: "Prompt", value: schedule.prompt },
{
key: "Cadence",
value:
schedule.cadence.type === "cron"
? `cron:${schedule.cadence.expression}`
: `every:${schedule.cadence.everyMs}ms`,
},
{ key: "Target", value: formatTarget(schedule.target) },
{ key: "Status", value: schedule.status },
{ key: "CreatedAt", value: schedule.createdAt },
{ key: "UpdatedAt", value: schedule.updatedAt },
{ key: "NextRunAt", value: schedule.nextRunAt ?? "null" },
{ key: "LastRunAt", value: schedule.lastRunAt ?? "null" },
{ key: "PausedAt", value: schedule.pausedAt ?? "null" },
{ key: "ExpiresAt", value: schedule.expiresAt ?? "null" },
{ key: "MaxRuns", value: schedule.maxRuns == null ? "null" : `${schedule.maxRuns}` },
{ key: "RunCount", value: `${schedule.runs.length}` },
];
}
@@ -1,195 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type {
CreateScheduleInput,
ScheduleCadence,
ScheduleDaemonClient,
ScheduleListItem,
ScheduleRecord,
ScheduleTarget,
} from "./types.js";
import { parseDuration } from "../../utils/duration.js";
export interface ScheduleCommandOptions extends CommandOptions {
host?: string;
}
export async function connectScheduleClient(
host: string | undefined,
): Promise<{ client: ScheduleDaemonClient; host: string }> {
const resolvedHost = getDaemonHost({ host });
try {
const client = (await connectToDaemon({
host,
})) as unknown as ScheduleDaemonClient;
return { client, host: resolvedHost };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${resolvedHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
}
export function toScheduleCommandError(
code: string,
action: string,
error: unknown,
): CommandError {
if (error && typeof error === "object" && "code" in error) {
return error as CommandError;
}
const message = error instanceof Error ? error.message : String(error);
return {
code,
message: `Failed to ${action}: ${message}`,
};
}
export function formatCadence(cadence: ScheduleCadence): string {
if (cadence.type === "cron") {
return `cron:${cadence.expression}`;
}
return `every:${formatDurationMs(cadence.everyMs)}`;
}
export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]): string {
if (target.type === "self") {
return `self:${target.agentId.slice(0, 7)}`;
}
if (target.type === "agent") {
return `agent:${target.agentId.slice(0, 7)}`;
}
return `new-agent:${target.config.provider}`;
}
export function formatDurationMs(durationMs: number): string {
const parts: string[] = [];
let remainingMs = durationMs;
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
if (hours > 0) {
parts.push(`${hours}h`);
remainingMs -= hours * 60 * 60 * 1000;
}
const minutes = Math.floor(remainingMs / (60 * 1000));
if (minutes > 0) {
parts.push(`${minutes}m`);
remainingMs -= minutes * 60 * 1000;
}
const seconds = Math.floor(remainingMs / 1000);
if (seconds > 0 || parts.length === 0) {
parts.push(`${seconds}s`);
}
return parts.join("");
}
export function parseScheduleCreateInput(options: {
prompt: string;
every?: string;
cron?: string;
name?: string;
target?: string;
maxRuns?: string;
expiresIn?: string;
}): CreateScheduleInput {
const prompt = options.prompt.trim();
if (!prompt) {
throw {
code: "INVALID_PROMPT",
message: "Schedule prompt cannot be empty",
} satisfies CommandError;
}
const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined);
if (cadenceCount !== 1) {
throw {
code: "INVALID_CADENCE",
message: "Specify exactly one of --every or --cron",
} satisfies CommandError;
}
const cadence: ScheduleCadence = options.every
? { type: "every", everyMs: parseDuration(options.every) }
: { type: "cron", expression: options.cron!.trim() };
const targetValue = options.target?.trim();
let target: ScheduleTarget;
if (!targetValue || targetValue === "self") {
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (currentAgentId) {
target = { type: "self", agentId: currentAgentId };
} else {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
}
} else if (targetValue === "new-agent") {
target = {
type: "new-agent",
config: {
provider: "claude",
cwd: process.cwd(),
},
};
} else {
target = {
type: "agent",
agentId: targetValue,
};
}
const maxRuns =
options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
const expiresAt =
options.expiresIn === undefined
? undefined
: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
return {
prompt,
cadence,
target,
...(options.name?.trim() ? { name: options.name.trim() } : {}),
...(maxRuns !== undefined ? { maxRuns } : {}),
...(expiresAt ? { expiresAt } : {}),
};
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
} satisfies CommandError;
}
return parsed;
}
export interface ScheduleRow {
id: string;
name: string | null;
cadence: string;
target: string;
status: string;
nextRunAt: string | null;
lastRunAt: string | null;
}
export function toScheduleRow(schedule: ScheduleListItem | ScheduleRecord): ScheduleRow {
return {
id: schedule.id,
name: schedule.name,
cadence: formatCadence(schedule.cadence),
target: formatTarget(schedule.target),
status: schedule.status,
nextRunAt: schedule.nextRunAt,
lastRunAt: schedule.lastRunAt,
};
}
-141
View File
@@ -1,141 +0,0 @@
export type ScheduleStatus = "active" | "paused" | "completed";
export type ScheduleCadence =
| {
type: "every";
everyMs: number;
}
| {
type: "cron";
expression: string;
};
export type ScheduleTarget =
| {
type: "self";
agentId: string;
}
| {
type: "agent";
agentId: string;
}
| {
type: "new-agent";
config: {
provider: "claude" | "codex" | "opencode";
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
};
};
export interface ScheduleRunRecord {
id: string;
scheduledFor: string;
startedAt: string;
endedAt: string | null;
status: "running" | "succeeded" | "failed";
agentId: string | null;
output: string | null;
error: string | null;
}
export interface ScheduleRecord {
id: string;
name: string | null;
prompt: string;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
createdAt: string;
updatedAt: string;
nextRunAt: string | null;
lastRunAt: string | null;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
runs: ScheduleRunRecord[];
}
export interface ScheduleListItem {
id: string;
name: string | null;
cadence: ScheduleCadence;
target: Exclude<ScheduleTarget, { type: "self" }>;
status: ScheduleStatus;
nextRunAt: string | null;
lastRunAt: string | null;
createdAt: string;
updatedAt: string;
pausedAt: string | null;
expiresAt: string | null;
maxRuns: number | null;
}
export interface CreateScheduleInput {
prompt: string;
name?: string;
cadence: ScheduleCadence;
target: ScheduleTarget;
maxRuns?: number;
expiresAt?: string;
}
export interface ScheduleCreatePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleListPayload {
requestId: string;
schedules: ScheduleListItem[];
error: string | null;
}
export interface ScheduleInspectPayload {
requestId: string;
schedule: ScheduleRecord | null;
error: string | null;
}
export interface ScheduleLogsPayload {
requestId: string;
runs: ScheduleRunRecord[];
error: string | null;
}
export interface SchedulePausePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleResumePayload {
requestId: string;
schedule: ScheduleListItem | null;
error: string | null;
}
export interface ScheduleDeletePayload {
requestId: string;
scheduleId: string;
error: string | null;
}
export interface ScheduleDaemonClient {
scheduleCreate(input: CreateScheduleInput): Promise<ScheduleCreatePayload>;
scheduleList(): Promise<ScheduleListPayload>;
scheduleInspect(input: { id: string }): Promise<ScheduleInspectPayload>;
scheduleLogs(input: { id: string }): Promise<ScheduleLogsPayload>;
schedulePause(input: { id: string }): Promise<SchedulePausePayload>;
scheduleResume(input: { id: string }): Promise<ScheduleResumePayload>;
scheduleDelete(input: { id: string }): Promise<ScheduleDeletePayload>;
close(): Promise<void>;
}
@@ -0,0 +1,63 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechDownloadRow {
modelId: string;
status: "downloaded";
}
const speechDownloadSchema: OutputSchema<SpeechDownloadRow> = {
idField: "modelId",
columns: [
{ header: "MODEL", field: "modelId", width: 36 },
{ header: "STATUS", field: "status", width: 12, color: () => "green" },
],
};
export type SpeechDownloadResult = ListResult<SpeechDownloadRow>;
export interface SpeechDownloadOptions extends CommandOptions {
host?: string;
model?: string[];
}
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command,
): Promise<SpeechDownloadResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.downloadSpeechModels({
modelIds: options.model && options.model.length > 0 ? options.model : undefined,
});
if (response.error) {
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: response.error,
};
throw commandError;
}
return {
type: "list",
data: response.downloadedModelIds.map((modelId) => ({
modelId,
status: "downloaded" as const,
})),
schema: speechDownloadSchema,
};
} catch (error) {
if (typeof error === "object" && error && "code" in error && "message" in error) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: `Failed to download speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}
+18 -1
View File
@@ -1,5 +1,22 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
export function createSpeechCommand(): Command {
return new Command("speech").description("Speech commands");
const speech = new Command("speech").description("Manage local speech models");
addJsonAndDaemonHostOptions(
speech.command("models").description("List local speech model download status"),
).action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
return speech;
}
@@ -0,0 +1,67 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechModelListItem {
id: string;
kind: string;
status: "downloaded" | "missing";
modelDir: string;
missingFiles: string;
}
const speechModelsSchema: OutputSchema<SpeechModelListItem> = {
idField: "id",
columns: [
{ header: "MODEL", field: "id", width: 36 },
{ header: "KIND", field: "kind", width: 12 },
{
header: "STATUS",
field: "status",
width: 12,
color: (value) => (value === "downloaded" ? "green" : "yellow"),
},
{ header: "MODEL DIR", field: "modelDir", width: 44 },
{ header: "MISSING FILES", field: "missingFiles", width: 40 },
],
};
export type SpeechModelsResult = ListResult<SpeechModelListItem>;
export interface SpeechModelsOptions extends CommandOptions {
host?: string;
}
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command,
): Promise<SpeechModelsResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.listSpeechModels();
const rows: SpeechModelListItem[] = response.models
.slice()
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id))
.map((model) => ({
id: model.id,
kind: model.kind,
status: model.isDownloaded ? "downloaded" : "missing",
modelDir: model.modelDir,
missingFiles: model.missingFiles?.join(", ") ?? "",
}));
return {
type: "list",
data: rows,
schema: speechModelsSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_LIST_FAILED",
message: `Failed to list speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}
@@ -1,99 +0,0 @@
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
export interface TerminalCaptureOptions extends TerminalCommandOptions {
start?: string;
end?: string;
scrollback?: boolean;
ansi?: boolean;
}
export async function runCaptureCommand(
terminalId: string,
_options: TerminalCaptureOptions,
command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalCaptureOptions;
try {
const payload = await executeCaptureCommand(terminalId, options);
if (options.json) {
process.stdout.write(
JSON.stringify(
{
terminalId: payload.terminalId,
lines: payload.lines,
totalLines: payload.totalLines,
},
null,
2,
) + "\n",
);
return;
}
if (payload.lines.length > 0) {
process.stdout.write(payload.lines.join("\n") + "\n");
}
} catch (err) {
const output = renderError(toCommandError(err), {
format: options.json ? "json" : "table",
noColor: options.color === false,
});
process.stderr.write(output + "\n");
process.exit(1);
}
}
async function executeCaptureCommand(
terminalId: string,
options: TerminalCaptureOptions,
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
if (!resolvedId) {
throw {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
}
const start = options.scrollback ? 0 : parseLineNumber("--start", options.start);
const end = parseLineNumber("--end", options.end);
return await client.captureTerminal(resolvedId, {
...(start === undefined ? {} : { start }),
...(end === undefined ? {} : { end }),
stripAnsi: !options.ansi,
});
} catch (err) {
throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err);
} finally {
await client.close().catch(() => {});
}
}
function parseLineNumber(flag: string, value?: string): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
};
}
return parsed;
}

Some files were not shown because too many files have changed in this diff Show More