Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 701480c11c Add /download page and link in nav
Desktop (macOS, Windows, Linux with AppImage/DEB/RPM), Mobile (Android/iOS),
and Web & CLI sections. Simplify homepage download button to a single link
with "All download options" text link below.
2026-03-25 23:06:35 +07:00
Mohamed Boudra 9ce6b45cf9 fix(desktop): make mac artifact names explicit and add deb/rpm 2026-03-25 23:05:17 +07:00
Mohamed Boudra 7eebee2de0 Fix Claude model catalog CI assertion 2026-03-25 22:49:14 +07:00
Mohamed Boudra ff2fc72581 chore(release): cut 0.1.34 2026-03-25 22:33:28 +07:00
Mohamed Boudra cdd7071885 docs(changelog): draft 0.1.34 notes 2026-03-25 22:33:04 +07:00
Mohamed Boudra 30cba9550e Fix attach button trigger and Claude interrupt test race 2026-03-25 22:31:24 +07:00
Mohamed Boudra 1318372e81 Remove dead agent-prompt and terminal-mcp code
Delete unused system-prompt.ts, agent-prompt.md, and the entire
terminal-mcp/ directory (tmux-based MCP server) — nothing imports
any of these. The codebase uses src/terminal/ (node-pty) instead.
2026-03-25 21:54:34 +07:00
Mohamed Boudra 185990f46b Update project row workspace action icon 2026-03-25 21:01:32 +07:00
Mohamed Boudra 508cd524a8 Tighten Claude partial tool input parsing 2026-03-25 20:38:43 +07:00
30 changed files with 893 additions and 3283 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## 0.1.34 - 2026-03-25
### Added
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
- Added a redesigned command autocomplete with a detail card and dropdown styling.
- Linked Android download surfaces to the Google Play Store.
### Improved
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
- Restored per-provider form preferences and removed the Auto model fallback.
- Improved Codex activity logs with clearer tool-call summaries.
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
- Improved chat transcript readability.
### Fixed
- Fixed `paseo send --no-wait` not taking effect.
- Fixed stale abort results contaminating replacement turns after an interrupt.
- Fixed Claude interrupt handling and autonomous wake reliability.
- Fixed nested Claude Code session detection and provider availability checks.
- Fixed agent input focus scoping across panels.
- Fixed terminal snapshot ordering when subscribing.
- Fixed `chat read --since` to accept message IDs.
- Fixed keyboard pane focus syncing with the active panel.
- Fixed assistant text selection on web.
- Fixed archived-agent notifications still appearing in chat rooms.
- Fixed the attach-images button interaction in the message composer.
- Pruned wrong-platform native binaries from Electron desktop builds.
## 0.1.33 - 2026-03-23
### Fixed
+19 -19
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.33",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.33",
"version": "0.1.34",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34842,16 +34842,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.33",
"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.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@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",
@@ -34963,11 +34963,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.33",
"version": "0.1.34",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/relay": "0.1.34",
"@getpaseo/server": "0.1.34",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35004,10 +35004,10 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.33",
"version": "0.1.34",
"dependencies": {
"@getpaseo/cli": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/cli": "0.1.34",
"@getpaseo/server": "0.1.34",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
@@ -35040,7 +35040,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.33",
"version": "0.1.34",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35241,7 +35241,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.33",
"version": "0.1.34",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35267,7 +35267,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.33",
"version": "0.1.34",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35283,13 +35283,13 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.33",
"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.33",
"@getpaseo/relay": "0.1.33",
"@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",
@@ -35637,7 +35637,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.33",
"version": "0.1.34",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.33",
"version": "0.1.34",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.33",
"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.33",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/server": "0.1.33",
"@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",
+15 -13
View File
@@ -950,18 +950,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -1203,7 +1205,7 @@ const styles = StyleSheet.create(((theme: any) => ({
leftButtonGroup: {
flexDirection: "row",
alignItems: "flex-end",
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
gap: theme.spacing[1],
},
rightButtonGroup: {
flexDirection: "row",
@@ -31,11 +31,11 @@ import {
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Monitor,
MoreVertical,
Plus,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -392,15 +392,15 @@ function NewWorktreeButton({
}}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`}
accessibilityLabel={`Create a new workspace for ${displayName}`}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ActivityIndicator size={14} color={theme.colors.foregroundMuted} />
) : (
<Plus
size={14}
<FolderPlus
size={15}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
@@ -411,7 +411,7 @@ function NewWorktreeButton({
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.33",
"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.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/relay": "0.1.34",
"@getpaseo/server": "0.1.34",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3
View File
@@ -14,6 +14,7 @@ publish:
owner: getpaseo
repo: paseo
mac:
artifactName: "Paseo-${version}-${arch}.${ext}"
category: public.app-category.developer-tools
icon: assets/icon.icns
hardenedRuntime: true
@@ -35,6 +36,8 @@ linux:
to: bin/paseo
target:
- AppImage
- deb
- rpm
- tar.gz
win:
icon: assets/icon.ico
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.33",
"version": "0.1.34",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.33",
"@getpaseo/server": "0.1.33",
"@getpaseo/cli": "0.1.34",
"@getpaseo/server": "0.1.34",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.33",
"version": "0.1.34",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.33",
"version": "0.1.34",
"type": "module",
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.33",
"version": "0.1.34",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {
-339
View File
@@ -1,339 +0,0 @@
# Voice Assistant System Prompt
## 1. Core Voice Rules (NON-NEGOTIABLE)
### Voice Context
You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS.
### Voice Message Envelope
Some user utterances will be wrapped in an XML tag like:
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
- Treat the inner text as what the user said (STT output).
- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”.
**Critical constraints:**
- User typically codes from their **phone** using voice
- **No visual feedback** - they can't see command output unless at laptop
- Input comes through **speech-to-text (STT)** which makes errors
- Output is spoken via **text-to-speech (TTS)**
- User may be mobile, away from desk, multitasking
### Communication Rules
**1-3 sentences maximum per response. Always.**
- **Plain speech only** - NO markdown (no bullets, bold, lists, headers)
- **Progressive disclosure** - answer what's asked, let user ask for more
- **Start high-level** - give the gist, not every detail
- **Natural pauses** - leave room for user to respond or redirect
**Good example:**
```
User: "List my agents"
You: "You have two agents. One working on authentication in the web app, another running tests in Faro."
User: "How's the auth agent doing?"
You: "It finished adding the login flow and is waiting for your approval on the database migration."
```
**Bad example:**
```
User: "List my agents"
You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..."
```
### Handling STT Errors
Speech-to-text makes mistakes. Fix them silently using context.
**Common errors:**
- Homophones: "list" → "missed", "code" → "load"
- Project names: "faro" → "pharaoh", "mcp" → "empty"
- Technical terms: "typescript" → "type script", "npm install" → "NPM in style"
**How to handle:**
1. Use context to fix obvious mistakes silently
2. Ask for clarification only when truly ambiguous
3. Never lecture about the error - just handle it
4. When clarifying, be brief: "Which project? Web, agent, or MCP?"
**Examples:**
- User: "Run empty install" → Interpret as "Run npm install"
- User: "Check the agent" → If only one agent, check that one; if multiple, ask which
### Immediate Silence Protocol
If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**:
- "I'm not talking to you"
- "Shut up" / "Be quiet" / "Stop talking"
- "Not you"
**Response: Complete silence. No acknowledgment. Wait for user to address you again.**
## 2. Delegation Pattern
### Core Rule: Always Work Through Coding Agents
Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately.
### Safe Operations (Execute Immediately)
These orchestration tools only read or summarize state:
- `list_agents()` – discover who exists before delegating
- `get_agent_activity()` – pull the curated activity/readout for a specific agent
- `wait_for_agent()` – block until an agent requests permission or completes the current run
Call them without asking when context requires it. Never fabricate their output.
### Delegated Operations (Announce + Execute)
- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately.
- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt.
- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions.
After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user.
### When to Ask vs Act
Ask only when the routing decision is truly ambiguous. Otherwise:
- Default to the most recently addressed agent.
- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch.
- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating).
### Tool Results Reporting
After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked.
## 4. Agent Integrations
### Your Role: Orchestrator
You orchestrate work. Agents execute.
**First action when agent work is mentioned: Call `list_agents()`**
Load the agent list before any agent interaction. Always.
#### Focus Management
- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent.
- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop.
- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?”
- Always narrate hand-offs: “Okay, handing that to Planner.”
- Every time you speak on behalf of an agent, prefix with `Agent <name> says …` so the user always knows who just responded and can redirect explicitly.
**Confirm before destructive agent operations:**
- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative)
- Killing agents: "Kill agent [id] working on [task]?"
**Delegate vs execute:**
- Everything touching code, git, or shell runs through agents
- Keep lightweight questions or summaries in-orchestrator when no action is needed
- Active agent context → Send prompt to that agent (respect focus)
### Available Agents (Source of Truth)
We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations.
**Claude Code (`claude`)**
- Default mode: `plan`
- Alternate mode: `bypassPermissions`
- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution.
**Codex (`codex`)**
- Default mode: `auto`
- Other modes: `read-only`, `full-access`
- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access.
### Creating Agents
**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask.
```javascript
// Claude Code with planning
create_agent({
cwd: "~/dev/paseo",
agentType: "claude",
initialPrompt: "add dark mode toggle to settings page",
initialMode: "plan"
})
// Codex for quick edits
create_agent({
cwd: "~/dev/paseo",
agentType: "codex",
initialPrompt: "clean up the logging",
initialMode: "auto"
})
```
If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error.
### Working with Agents
**Send prompts to agents:**
```javascript
// Send task (non-blocking by default)
send_agent_prompt({
agentId: "abc123",
prompt: "explain how authentication works"
})
// Returns immediately, agent processes in background
// Send task and wait for completion
send_agent_prompt({
agentId: "abc123",
prompt: "fix the bug in auth.ts",
maxWait: 60000 // Wait up to 60 seconds
})
// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access)
send_agent_prompt({
agentId: "abc123",
prompt: "implement user registration",
sessionMode: "bypassPermissions"
})
```
**Check agent status:**
```javascript
// Get current status
get_agent_status({ agentId: "abc123" })
// Returns: { status: "processing", info: {...} }
// Get agent activity (curated, human-readable)
get_agent_activity({
agentId: "abc123",
format: "curated" // Clean summary of what agent did
})
// List all agents
list_agents()
// Returns: { agents: [{id, status, createdAt, ...}, ...] }
```
**Control agents:**
```javascript
// Change session mode (safe, no confirmation needed)
set_agent_mode({
agentId: "abc123",
modeId: "plan"
})
// Cancel current task (safe, no confirmation needed)
cancel_agent({ agentId: "abc123" })
// Kill agent (REQUIRES confirmation first)
kill_agent({ agentId: "abc123" })
```
### Agent Workflow Pattern
```javascript
// 1. Load agents first
list_agents()
// 2. If creating new agent, confirm first
// You: "Create agent in ~/dev/project for authentication?"
// User: "yes"
// 3. Create with type + mode
create_agent({
cwd: "~/dev/project",
agentType: "claude",
initialPrompt: "add authentication",
initialMode: "plan"
})
// 4. Monitor or send follow-up tasks
get_agent_activity({ agentId })
send_agent_prompt({ agentId, prompt: "add tests" })
```
## 5. Git & GitHub
### Git Worktree Utilities
Custom utilities for safe worktree management:
**create-worktree:**
- Creates new git worktree with new branch
- Example: `create-worktree "feature"` creates `~/dev/repo-feature`
- Outputs WORKTREE_PATH for you to parse
**delete-worktree:**
- Preserves the branch, only deletes directory
- Safe to use - won't lose work
- Run from within worktree directory
### GitHub CLI (gh)
Already authenticated. Use for:
- Creating PRs: `gh pr create`
- Viewing PRs: `gh pr view`
- Managing issues: `gh issue list`
- Checking CI: `gh pr checks`
## 6. Projects & Context
### Project Locations
All projects in `~/dev`:
**paseo**
- Location: `~/dev/paseo`
- Packages: `voice-assistant`
**Faro** (Autonomous Competitive Intelligence)
- Bare repo: `~/dev/faro`
- Main checkout: `~/dev/faro/main`
**Blank.page** (Minimal browser text editor)
- Location: `~/dev/blank.page/editor`
### Decision Rules
**Agent work mentioned?**
1. Call `list_agents()` first
2. Reuse existing agent if task relates to its work
3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately.
**Creating/killing agents?**
- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit
- Ask: "Kill agent [id]?"
- Wait for "yes"
**Task routing:**
- All coding tasks → Delegate to an agent
- Active agent + related work → Delegate to that agent
- If the user explicitly mentions another agent, switch focus before delegating
**Context tracking:**
- Track active agents and their directories
- Use conversation context to resolve ambiguity
- Fix STT errors silently
- Maintain a recency-based focus pointer and narrate any focus change out loud
### Core Reminders
- Call actual tools, never just describe
- 1-3 sentences max per response
- Always report agent/tool results verbally (preface with "Agent X says …" when relaying)
- Default to action when context is clear
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.33",
"version": "0.1.34",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -13,8 +13,7 @@
"dist/scripts",
"src/server/speech/providers/local/sherpa/assets",
"README.md",
".env.example",
"agent-prompt.md"
".env.example"
],
"exports": {
".": {
@@ -64,8 +63,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/relay": "0.1.33",
"@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",
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function isBinaryInstalled(binary: string): boolean {
@@ -15,6 +16,16 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
return haystacks.some((text) => text.includes(family));
}
describe("provider model catalogs (e2e)", () => {
let ctx: DaemonTestContext;
@@ -32,11 +43,8 @@ describe("provider model catalogs (e2e)", () => {
expect(result.error).toBeNull();
expect(result.models.length).toBeGreaterThan(0);
const descriptions = result.models.map((model) =>
`${model.label} ${model.description ?? ""}`.toLowerCase(),
);
expect(descriptions.some((text) => text.includes("sonnet 4.5"))).toBe(true);
expect(descriptions.some((text) => text.includes("haiku"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "sonnet"))).toBe(true);
expect(result.models.some((model) => modelMatchesFamily(model, "haiku"))).toBe(true);
}, 180_000);
test.runIf(hasCodex)(
@@ -336,11 +336,7 @@ describe("Agent event stream redesign — integration", () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
const { turnId } = await handle.session.startTurn(
"write a very long essay about the history of computing",
);
// Use a single subscription to avoid missing events between unsubscribe/resubscribe
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
@@ -352,10 +348,14 @@ describe("Agent event stream redesign — integration", () => {
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
@@ -365,14 +365,37 @@ describe("Agent event stream redesign — integration", () => {
}
// Resolve when we get a terminal event for this turn
if (isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
@@ -441,7 +441,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("routes input_json_delta through partial parsing before buffered JSON is complete", async () => {
test("waits for complete JSON values before updating tool input from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -479,7 +479,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
partial_json: '{"command":"echo ',
},
},
expectedCommand: "echo ",
expectedCommand: "echo seed",
},
{
event: {
@@ -520,7 +520,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
}
});
test("surfaces canonical partial tool input from input_json_delta before JSON is complete", async () => {
test("does not surface incomplete string values from input_json_delta", async () => {
const session = await createSession();
const internal = session as unknown as {
mapPartialEvent: (event: Record<string, unknown>) => AgentTimelineItem[];
@@ -551,7 +551,6 @@ describe("ClaudeAgentSession redesign invariants", () => {
expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({
file_path: "src/message.tsx",
old_string: "before",
});
} finally {
await session.close();
@@ -13,33 +13,25 @@ describe("parsePartialJsonObject", () => {
});
});
it("keeps partial string values without field-specific logic", () => {
it("does not emit incomplete string values", () => {
expect(parsePartialJsonObject('{"command":"echo ')).toEqual({
value: {
command: "echo ",
},
value: {},
complete: false,
});
});
it("returns parsed prefix fields from incomplete objects", () => {
it("returns only complete prefix fields from incomplete objects", () => {
expect(parsePartialJsonObject('{"file_path":"src/message.tsx","old_string":"before')).toEqual({
value: {
file_path: "src/message.tsx",
old_string: "before",
},
complete: false,
});
});
it("parses nested partial values generically", () => {
it("does not emit incomplete nested values", () => {
expect(parsePartialJsonObject('{"payload":{"path":"src/index.ts","content":"hello')).toEqual({
value: {
payload: {
path: "src/index.ts",
content: "hello",
},
},
value: {},
complete: false,
});
});
@@ -271,17 +271,17 @@ function parsePartialObject(
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
if (!parsedMemberValue.complete) {
return {
value,
nextIndex: currentIndex,
nextIndex: skipWhitespace(input, parsedMemberValue.nextIndex),
complete: false,
};
}
value[parsedKey.value] = parsedMemberValue.value;
currentIndex = skipWhitespace(input, parsedMemberValue.nextIndex);
const delimiter = input[currentIndex];
if (delimiter === ",") {
currentIndex += 1;
@@ -1,22 +0,0 @@
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import path from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load system prompt from agent-prompt.md in package root
export function loadSystemPrompt(): string {
const promptPath = path.join(__dirname, "../../../agent-prompt.md");
return readFileSync(promptPath, "utf-8");
}
// Cache the loaded prompt
let cachedPrompt: string | null = null;
export function getSystemPrompt(): string {
if (!cachedPrompt) {
cachedPrompt = loadSystemPrompt();
}
return cachedPrompt;
}
@@ -1,129 +0,0 @@
```typescript
const server = new McpServer({
name: "my-app",
version: "1.0.0",
});
// Simple tool with parameters
server.registerTool(
"calculate-bmi",
{
title: "BMI Calculator",
description: "Calculate Body Mass Index",
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
outputSchema: { bmi: z.number() },
},
async ({ weightKg, heightM }) => {
const output = { bmi: weightKg / (heightM * heightM) };
return {
content: [
{
type: "text",
text: JSON.stringify(output),
},
],
structuredContent: output,
};
}
);
// Async tool with external API call
server.registerTool(
"fetch-weather",
{
title: "Weather Fetcher",
description: "Get weather data for a city",
inputSchema: { city: z.string() },
outputSchema: { temperature: z.number(), conditions: z.string() },
},
async ({ city }) => {
const response = await fetch(`https://api.weather.com/${city}`);
const data = await response.json();
const output = { temperature: data.temp, conditions: data.conditions };
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output,
};
}
);
// Tool that returns ResourceLinks
server.registerTool(
"list-files",
{
title: "List Files",
description: "List project files",
inputSchema: { pattern: z.string() },
outputSchema: {
count: z.number(),
files: z.array(z.object({ name: z.string(), uri: z.string() })),
},
},
async ({ pattern }) => {
const output = {
count: 2,
files: [
{ name: "README.md", uri: "file:///project/README.md" },
{ name: "index.ts", uri: "file:///project/src/index.ts" },
],
};
return {
content: [
{ type: "text", text: JSON.stringify(output) },
// ResourceLinks let tools return references without file content
{
type: "resource_link",
uri: "file:///project/README.md",
name: "README.md",
mimeType: "text/markdown",
description: "A README file",
},
{
type: "resource_link",
uri: "file:///project/src/index.ts",
name: "index.ts",
mimeType: "text/typescript",
description: "An index file",
},
],
structuredContent: output,
};
}
);
```
```typescript
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
bench('InMemoryTransport', async () => {
const client = new Client(
{
name: 'test client',
version: '1.0',
},
{
capabilities: {
tools: {},
},
},
);
const server = createServer();
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const tools = await client.listTools();
if (!tools.tools.length) throw new Error('No tools found');
await client.close();
});
});
```
@@ -1,7 +0,0 @@
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
export { TerminalManager } from "./terminal-manager.js";
export type {
TerminalInfo,
CreateTerminalParams,
CreateTerminalResult,
} from "./terminal-manager.js";
@@ -1,266 +0,0 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import { TerminalManager } from "./terminal-manager.js";
export interface TerminalMcpServerOptions {
sessionName: string;
}
/**
* Create and configure the Terminal MCP Server
* Multiple instances can run independently with different session names
*/
export async function createTerminalMcpServer(
options: TerminalMcpServerOptions,
): Promise<McpServer> {
const { sessionName } = options;
const terminalManager = new TerminalManager(sessionName);
// Initialize the session
await terminalManager.initialize();
const server = new McpServer({
name: "terminal-mcp",
version: "1.0.0",
});
// COMMAND-BASED TOOLS
// Tool: execute_command (disabled – commands must be run through coding agents)
// server.registerTool(
// "execute_command",
// {
// title: "Execute Command",
// description:
// "Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
// inputSchema: {
// command: z
// .string()
// .describe(
// "The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
// ),
// directory: z
// .string()
// .describe(
// "Absolute path to the working directory. Can use ~ for home directory."
// ),
// maxWait: z
// .number()
// .optional()
// .describe(
// "Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
// ),
// },
// outputSchema: {
// commandId: z.string(),
// output: z.string(),
// exitCode: z.number().nullable(),
// isDead: z.boolean(),
// },
// },
// async ({ command, directory, maxWait }) => {
// const result = await terminalManager.executeCommand(
// command,
// directory,
// maxWait
// );
// return {
// content: [],
// structuredContent: result,
// };
// }
// );
// Tool: list_commands
server.registerTool(
"list_commands",
{
title: "List Commands",
description:
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
inputSchema: {},
outputSchema: {
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
lastLines: z.string().nullable(),
}),
),
},
},
async () => {
const commands = await terminalManager.listCommands();
const output = { commands };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
// Tool: capture_command
server.registerTool(
"capture_command",
{
title: "Capture Command Output",
description:
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
inputSchema: {
commandId: z
.string()
.describe(
"Command ID (window ID like @123) returned from execute_command or list_commands",
),
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
},
outputSchema: {
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ commandId, lines }) => {
const result = await terminalManager.captureCommand(commandId, lines);
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_text_to_command
server.registerTool(
"send_text_to_command",
{
title: "Send Text to Command",
description:
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
inputSchema: {
commandId: z
.string()
.describe("Command ID (window ID like @123) from execute_command or list_commands"),
text: z.string().describe("Text to send to the command"),
pressEnter: z
.boolean()
.optional()
.describe("Press Enter after typing text (default: false)"),
return_output: z
.object({
lines: z.number().optional().describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe("Wait for output to stabilize before returning (default: true)"),
maxWait: z
.number()
.optional()
.describe("Maximum milliseconds to wait for stability (default: 120000)"),
})
.optional()
.describe("Capture output after sending text"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, text, pressEnter, return_output }) => {
const output = await terminalManager.sendTextToCommand(
commandId,
text,
pressEnter,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: send_keys_to_command
server.registerTool(
"send_keys_to_command",
{
title: "Send Keys to Command",
description:
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
inputSchema: {
commandId: z.string().describe("Command ID (window ID like @123)"),
keys: z
.string()
.describe("Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."),
repeat: z
.number()
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z.number().optional(),
waitForSettled: z.boolean().optional(),
maxWait: z.number().optional(),
})
.optional()
.describe("Capture output after sending keys"),
},
outputSchema: {
output: z.string().nullable(),
},
},
async ({ commandId, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeysToCommand(
commandId,
keys,
repeat,
return_output,
);
const result = { output: output || null };
return {
content: [],
structuredContent: ensureValidJson(result),
};
},
);
// Tool: kill_command
server.registerTool(
"kill_command",
{
title: "Kill Command",
description:
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
inputSchema: {
commandIds: z
.array(z.string())
.min(1)
.describe(
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs.",
),
},
outputSchema: {
success: z.boolean(),
killedCount: z.number(),
},
},
async ({ commandIds }) => {
let killedCount = 0;
for (const commandId of commandIds) {
await terminalManager.killCommand(commandId);
killedCount++;
}
const output = { success: true, killedCount };
return {
content: [],
structuredContent: ensureValidJson(output),
};
},
);
return server;
}
@@ -1,475 +0,0 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_REGEX, "");
}
function expectMissingPathMessage(output: string, missingPath: string): void {
const normalized = stripAnsi(output).toLowerCase();
expect(normalized).toContain(missingPath.toLowerCase());
expect(normalized).toMatch(/cannot access|no such file|not found/);
}
describe("TerminalManager - Command Execution", () => {
let manager: TerminalManager;
beforeAll(async () => {
manager = new TerminalManager(TEST_SESSION);
await manager.initialize();
});
afterAll(async () => {
// Cleanup test session
const session = await findSessionByName(TEST_SESSION);
if (session) {
await killSession(session.id);
}
});
describe("executeCommand", () => {
it("should execute a simple one-shot command and return exit code", async () => {
const result = await manager.executeCommand(
"echo 'Hello World'",
process.env.HOME || "~",
5000,
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain("Hello World");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle commands with pipes and operators", async () => {
const result = await manager.executeCommand(
"echo 'line1\nline2\nline3' | grep line2",
process.env.HOME || "~",
5000,
);
expect(result.output).toContain("line2");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should capture non-zero exit codes", async () => {
const result = await manager.executeCommand(
"ls /nonexistent-directory-test 2>&1",
process.env.HOME || "~",
5000,
);
expect(result.isDead).toBe(true);
expect(result.exitCode).not.toBe(0);
expectMissingPathMessage(result.output, "nonexistent-directory-test");
});
it("should handle directory changes in command", async () => {
const result = await manager.executeCommand("cd /tmp && pwd", process.env.HOME || "~", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should launch interactive command (Python REPL)", async () => {
const result = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain(">>>"); // Python prompt
expect(result.isDead).toBe(false); // Still running
expect(result.exitCode).toBeNull(); // No exit code yet
// Cleanup
await manager.killCommand(result.commandId);
});
it("should handle command with working directory", async () => {
const result = await manager.executeCommand("pwd", "/tmp", 5000);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle long-running command with timeout", async () => {
const result = await manager.executeCommand(
"sleep 0.5 && echo 'Done sleeping'",
process.env.HOME || "~",
3000, // Increased timeout to ensure command completes
);
expect(result.output).toContain("Done sleeping");
// Note: May still be alive if not enough time to detect completion
if (result.isDead) {
expect(result.exitCode).toBe(0);
}
});
});
describe("listCommands", () => {
it("should list all commands including dead ones", async () => {
// Execute a command that finishes
const result1 = await manager.executeCommand("echo 'test1'", process.env.HOME || "~", 5000);
// Execute a command that stays running
const result2 = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const commands = await manager.listCommands();
expect(commands.length).toBeGreaterThanOrEqual(2);
const cmd1 = commands.find((c) => c.id === result1.commandId);
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
const cmd2 = commands.find((c) => c.id === result2.commandId);
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(false);
expect(cmd2?.exitCode).toBeNull();
// Cleanup
await manager.killCommand(result2.commandId);
});
it("should preserve command and working directory info for dead commands", async () => {
const testDir1 = "/tmp";
const testCommand1 = "echo 'test message 1' && pwd";
const testDir2 = process.env.HOME || "~";
const testCommand2 = "echo 'test message 2'";
// Execute two different commands
const result1 = await manager.executeCommand(testCommand1, testDir1, 5000);
const result2 = await manager.executeCommand(testCommand2, testDir2, 5000);
expect(result1.isDead).toBe(true);
expect(result1.exitCode).toBe(0);
expect(result2.isDead).toBe(true);
expect(result2.exitCode).toBe(0);
// List commands - should show the original command and working directory for each
const commands = await manager.listCommands();
const cmd1 = commands.find((c) => c.id === result1.commandId);
const cmd2 = commands.find((c) => c.id === result2.commandId);
// Verify first command
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
expect(cmd1?.workingDirectory).toBe(testDir1);
expect(cmd1?.currentCommand).toBe(testCommand1);
// Verify second command (should have different values)
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(true);
expect(cmd2?.exitCode).toBe(0);
expect(cmd2?.workingDirectory).toBe(testDir2);
expect(cmd2?.currentCommand).toBe(testCommand2);
// Ensure they're actually different
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
// Cleanup
await manager.killCommand(result1.commandId);
await manager.killCommand(result2.commandId);
});
});
describe("captureCommand", () => {
it("should capture output from finished command", async () => {
const execResult = await manager.executeCommand(
"echo 'Capture test'",
process.env.HOME || "~",
5000,
);
const captureResult = await manager.captureCommand(execResult.commandId, 100);
expect(captureResult.output).toContain("Capture test");
expect(captureResult.isDead).toBe(true);
expect(captureResult.exitCode).toBe(0);
});
it("should capture output from running command", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
const captureResult = await manager.captureCommand(execResult.commandId, 50);
expect(captureResult.output).toContain(">>>");
expect(captureResult.isDead).toBe(false);
expect(captureResult.exitCode).toBeNull();
// Cleanup
await manager.killCommand(execResult.commandId);
});
});
describe("sendTextToCommand", () => {
it("should send text to running Python REPL", async () => {
// Launch Python
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send Python code
const output = await manager.sendTextToCommand(
execResult.commandId,
"print('Hello from Python')",
true, // press Enter
{ lines: 50, maxWait: 3000, waitForSettled: true },
);
expect(output).toContain("Hello from Python");
// Send exit
await manager.sendTextToCommand(execResult.commandId, "exit()", true, {
lines: 50,
maxWait: 2000,
});
// Verify command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle multiple sequential inputs to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
await manager.sendTextToCommand(execResult.commandId, "x = 5", true);
await manager.sendTextToCommand(execResult.commandId, "y = 3", true);
const output = await manager.sendTextToCommand(execResult.commandId, "print(x + y)", true, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("8");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
});
describe("sendKeysToCommand", () => {
it("should send Ctrl-C to interrupt running command", async () => {
// Start a long-running command
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000, // Short wait, won't finish
);
expect(execResult.isDead).toBe(false);
// Send Ctrl-C
await manager.sendKeysToCommand(execResult.commandId, "C-c", 1, { lines: 50, maxWait: 2000 });
// Wait a bit for signal to process
await new Promise((resolve) => setTimeout(resolve, 500));
// Check if command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should send Enter key to REPL", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Type without pressing Enter
await manager.sendTextToCommand(
execResult.commandId,
"print('test')",
false, // Don't press Enter
);
// Now send Enter via sendKeys
const output = await manager.sendKeysToCommand(execResult.commandId, "Enter", 1, {
lines: 50,
maxWait: 2000,
});
expect(output).toContain("test");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
it("should repeat key press multiple times", async () => {
const execResult = await manager.executeCommand("python3", process.env.HOME || "~", 5000);
// Send multiple Ctrl-C (should exit Python)
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
3, // Repeat 3 times
{ lines: 50, maxWait: 3000 },
);
// Wait longer for Python to exit
await new Promise((resolve) => setTimeout(resolve, 1500));
const captureResult = await manager.captureCommand(execResult.commandId);
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
expect(captureResult.output).toBeDefined();
// Cleanup if still running
if (!captureResult.isDead) {
await manager.killCommand(execResult.commandId);
}
});
});
describe("killCommand", () => {
it("should kill running command", async () => {
const execResult = await manager.executeCommand("sleep 100", process.env.HOME || "~", 1000);
expect(execResult.isDead).toBe(false);
// Kill the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
it("should kill finished command (cleanup)", async () => {
const execResult = await manager.executeCommand(
"echo 'cleanup test'",
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
// Kill/cleanup the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
});
describe("Complex workflows", () => {
it("should handle Node.js REPL workflow", async () => {
// Launch Node REPL
const execResult = await manager.executeCommand("node", process.env.HOME || "~", 5000);
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"console.log(x.map(n => n * 2).join(','))",
true,
{ lines: 50, maxWait: 2000 },
);
expect(output2).toContain("2,4,6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
// Verify exited
await new Promise((resolve) => setTimeout(resolve, 500));
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle command that produces streaming output", async () => {
const execResult = await manager.executeCommand(
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Line 1");
expect(execResult.output).toContain("Line 2");
expect(execResult.output).toContain("Line 3");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with stderr output", async () => {
const execResult = await manager.executeCommand(
"echo 'to stdout'; echo 'to stderr' >&2",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("to stdout");
expect(execResult.output).toContain("to stderr");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
});
describe("Edge cases", () => {
it("should handle empty command output", async () => {
const execResult = await manager.executeCommand(
"true", // Command that produces no output
process.env.HOME || "~",
5000,
);
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with tilde in directory", async () => {
const execResult = await manager.executeCommand("pwd", "~/", 5000);
expect(execResult.output).toContain(process.env.HOME || "");
expect(execResult.isDead).toBe(true);
});
it("should handle very long output", async () => {
const execResult = await manager.executeCommand(
"seq 1 500", // Generate 500 lines
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("1");
expect(execResult.output).toContain("500");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle special characters in command", async () => {
const execResult = await manager.executeCommand(
"echo 'Special: $test & | ; < >'",
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("Special:");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with quotes", async () => {
const execResult = await manager.executeCommand(
`echo 'double and single quotes'`,
process.env.HOME || "~",
5000,
);
expect(execResult.output).toContain("double");
expect(execResult.output).toContain("single");
expect(execResult.isDead).toBe(true);
});
});
});
@@ -1,502 +0,0 @@
import {
findSessionByName,
createSession,
listWindows,
createWindow,
listPanes,
findWindowByName,
capturePaneContent,
extractExitCodeMarkerFromOutput,
sendText as tmuxSendText,
sendKeys as tmuxSendKeys,
renameWindow,
killWindow,
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
getStoredWorkingDirectory,
getStoredCommand,
waitForPaneActivityToSettle,
executeCommand as tmuxExecuteCommand,
executeTmux,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
// Terminals are identified by their unique names, not IDs
export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines: string | null;
}
export interface CommandInfo {
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
lastLines: string | null;
}
export interface CreateTerminalParams {
name: string;
workingDirectory: string;
initialCommand?: string;
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput: string | null;
}
/**
* Terminal manager for a specific tmux session
* Multiple instances can coexist with different session names
*/
export class TerminalManager {
constructor(private sessionName: string) {}
/**
* Initialize the tmux session
* Creates it if it doesn't exist
*/
async initialize(): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
await createSession(this.sessionName);
}
}
/**
* List all terminals in the session
* Returns terminal info including name, active status, working directory, and current command
*/
async listTerminals(): Promise<TerminalInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const terminals: TerminalInfo[] = [];
for (const window of windows) {
// Get the first (and only) pane in this window
const paneId = `${window.id}.0`;
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
terminals.push({
name: window.name,
workingDirectory,
currentCommand,
lastLines: lastLines || null,
});
}
return terminals;
}
/**
* Create a new terminal (tmux window) with specified name and working directory
* Optionally execute an initial command
*/
async createTerminal(params: CreateTerminalParams): Promise<CreateTerminalResult> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
// Validate name uniqueness
const isUnique = await isWindowNameUnique(session.id, params.name);
if (!isUnique) {
throw new Error(
`Terminal with name '${params.name}' already exists. Please choose a unique name.`,
);
}
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand ?? null,
});
if (!windowResult) {
throw new Error(`Failed to create terminal '${params.name}'`);
}
const paneId = windowResult.paneId;
// Get terminal info
const workingDirectory = await getCurrentWorkingDirectory(paneId);
const currentCommand = await getCurrentCommand(paneId);
return {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output || null,
lastLines: null,
};
}
/**
* Capture output from a terminal by name
* Returns the last N lines of terminal content
* If maxWait is provided, waits for terminal activity to settle before capturing
*/
async captureTerminal(
terminalName: string,
lines: number = 200,
maxWait?: number,
): Promise<string> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
// Wait for activity to settle if maxWait is provided
if (maxWait) {
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
}
return capturePaneContent(pane.id, lines, false);
}
/**
* Send text to a terminal by name, optionally press Enter, optionally return output
*/
async sendText(
terminalName: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendText({
paneId: pane.id,
text,
pressEnter,
return_output,
});
}
/**
* Send special keys or key combinations to a terminal by name
* Useful for TUI navigation, control sequences, and interactive applications
*/
async sendKeys(
terminalName: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
// Get the first pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalName}`);
}
return tmuxSendKeys({
paneId: pane.id,
keys,
repeat,
return_output,
});
}
/**
* Rename a terminal by name
* Validates that the new name is unique
*/
async renameTerminal(terminalName: string, newName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// renameWindow handles uniqueness validation and name resolution internally
await renameWindow(session.id, terminalName, newName);
}
/**
* Kill (close/destroy) a terminal by name
*/
async killTerminal(terminalName: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
// Resolve terminal name to window
const window = await findWindowByName(session.id, terminalName);
if (!window) {
const windows = await listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
);
}
await killWindow(window.id);
}
/**
* Execute a command and return its output, exit code, and running status
* Commands are wrapped in bash -c to support all bash features
* Windows remain after command exits (remain-on-exit) for inspection
*/
async executeCommand(
command: string,
workingDirectory: string,
maxWait?: number,
): Promise<{
commandId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const result = await tmuxExecuteCommand({
sessionId: session.id,
command,
workingDirectory,
maxWait,
});
return {
commandId: result.windowId,
output: result.output,
exitCode: result.exitCode,
isDead: result.isDead,
};
}
/**
* List all commands (windows), including those that have exited
* Shows both running and dead commands for inspection
*/
async listCommands(): Promise<CommandInfo[]> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
}
const windows = await listWindows(session.id);
const commands: CommandInfo[] = [];
for (const window of windows) {
const paneId = `${window.id}.0`;
// Check if pane is dead first
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
// Use stored values for dead panes, current values for live ones
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
const currentCommand = await getStoredCommand(window.id, paneId);
const rawLastLines = await capturePaneContent(paneId, 5, false);
const lastLinesExtracted = extractExitCodeMarkerFromOutput(rawLastLines);
const lastLines = lastLinesExtracted.output;
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : lastLinesExtracted.exitCode;
}
commands.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
isDead,
exitCode,
lastLines: lastLines || null,
});
}
return commands;
}
/**
* Capture output from a command by ID (window ID)
*/
async captureCommand(
commandId: string,
lines: number = 200,
): Promise<{
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
const rawOutput = await capturePaneContent(paneId, lines, false);
const extracted = extractExitCodeMarkerFromOutput(rawOutput);
const output = extracted.output;
const deadStatus = await executeTmux(["display-message", "-p", "-t", paneId, "#{pane_dead}"]);
const isDead = deadStatus === "1";
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
const parsed = parseInt(exitCodeStr, 10);
exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode;
}
return {
output,
exitCode,
isDead,
};
}
/**
* Send text to a command by ID (window ID)
*/
async sendTextToCommand(
commandId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Send keys to a command by ID (window ID)
*/
async sendKeysToCommand(
commandId: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendKeys({
paneId,
keys,
repeat,
return_output,
});
}
/**
* Kill a command by ID (window ID)
*/
async killCommand(commandId: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
await killWindow(commandId);
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.33",
"version": "0.1.34",
"private": true,
"type": "module",
"scripts": {
@@ -255,6 +255,12 @@ function Nav() {
>
Changelog
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Download
</a>
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
@@ -515,7 +521,15 @@ function GetStarted() {
</a>
<ServerInstallButton />
</div>
<div className="flex items-center gap-2 pt-8">
<div className="pt-3">
<a
href="/download"
className="text-xs text-white/40 hover:text-white/70 transition-colors"
>
All download options
</a>
</div>
<div className="flex items-center gap-2 pt-6">
<span className="text-xs text-white/40">Supports</span>
<div className="flex items-center gap-1">
<AgentBadge name="Claude Code" icon={<ClaudeCodeIcon className="h-6 w-6" />} />
@@ -529,72 +543,19 @@ function GetStarted() {
function DownloadButton() {
const detectedPlatform = useDetectedPlatform();
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const primary = downloadOptions.find((o) => o.platform === detectedPlatform)!;
const secondaryOptions = downloadOptions.filter((o) => o.platform !== detectedPlatform);
const PrimaryIcon = primary.icon;
return (
<div className="relative" ref={ref}>
<div className="flex items-stretch">
<a
href={primary.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-l-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
>
<PrimaryIcon className="h-4 w-4" />
Download for {primary.label}
</a>
<button
onClick={() => setOpen(!open)}
className="inline-flex items-center justify-center rounded-r-lg border-l border-background/20 bg-foreground px-2 py-2 text-background hover:bg-foreground/90 transition-colors"
aria-label="More download options"
>
<ChevronDownIcon className="h-4 w-4" />
</button>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="absolute left-0 top-full mt-1 z-50 min-w-[200px] rounded-lg border border-white/20 bg-black/90 backdrop-blur-sm py-1"
>
{secondaryOptions.map((option) => {
const Icon = option.icon;
return (
<a
key={option.platform}
href={option.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-white/80 hover:bg-white/10 transition-colors"
>
<Icon className="h-4 w-4" />
{option.label}
</a>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
<a
href={primary.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
>
<PrimaryIcon className="h-4 w-4" />
Download for {primary.label}
</a>
);
}
+305 -284
View File
@@ -8,332 +8,352 @@
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as PrivacyRouteImport } from "./routes/privacy";
import { Route as OpencodeRouteImport } from "./routes/opencode";
import { Route as DocsRouteImport } from "./routes/docs";
import { Route as CodexRouteImport } from "./routes/codex";
import { Route as ClaudeCodeRouteImport } from "./routes/claude-code";
import { Route as ChangelogRouteImport } from "./routes/changelog";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as DocsIndexRouteImport } from "./routes/docs/index";
import { Route as DocsWorktreesRouteImport } from "./routes/docs/worktrees";
import { Route as DocsVoiceRouteImport } from "./routes/docs/voice";
import { Route as DocsUpdatesRouteImport } from "./routes/docs/updates";
import { Route as DocsSecurityRouteImport } from "./routes/docs/security";
import { Route as DocsConfigurationRouteImport } from "./routes/docs/configuration";
import { Route as DocsCliRouteImport } from "./routes/docs/cli";
import { Route as DocsBestPracticesRouteImport } from "./routes/docs/best-practices";
import { Route as rootRouteImport } from './routes/__root'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as OpencodeRouteImport } from './routes/opencode'
import { Route as DownloadRouteImport } from './routes/download'
import { Route as DocsRouteImport } from './routes/docs'
import { Route as CodexRouteImport } from './routes/codex'
import { Route as ClaudeCodeRouteImport } from './routes/claude-code'
import { Route as ChangelogRouteImport } from './routes/changelog'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DocsIndexRouteImport } from './routes/docs/index'
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
import { Route as DocsSecurityRouteImport } from './routes/docs/security'
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
const PrivacyRoute = PrivacyRouteImport.update({
id: "/privacy",
path: "/privacy",
id: '/privacy',
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const OpencodeRoute = OpencodeRouteImport.update({
id: "/opencode",
path: "/opencode",
id: '/opencode',
path: '/opencode',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const DownloadRoute = DownloadRouteImport.update({
id: '/download',
path: '/download',
getParentRoute: () => rootRouteImport,
} as any)
const DocsRoute = DocsRouteImport.update({
id: "/docs",
path: "/docs",
id: '/docs',
path: '/docs',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const CodexRoute = CodexRouteImport.update({
id: "/codex",
path: "/codex",
id: '/codex',
path: '/codex',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const ClaudeCodeRoute = ClaudeCodeRouteImport.update({
id: "/claude-code",
path: "/claude-code",
id: '/claude-code',
path: '/claude-code',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const ChangelogRoute = ChangelogRouteImport.update({
id: "/changelog",
path: "/changelog",
id: '/changelog',
path: '/changelog',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any);
} as any)
const DocsIndexRoute = DocsIndexRouteImport.update({
id: "/",
path: "/",
id: '/',
path: '/',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsWorktreesRoute = DocsWorktreesRouteImport.update({
id: "/worktrees",
path: "/worktrees",
id: '/worktrees',
path: '/worktrees',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsVoiceRoute = DocsVoiceRouteImport.update({
id: "/voice",
path: "/voice",
id: '/voice',
path: '/voice',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
id: "/updates",
path: "/updates",
id: '/updates',
path: '/updates',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsSecurityRoute = DocsSecurityRouteImport.update({
id: "/security",
path: "/security",
id: '/security',
path: '/security',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
id: "/configuration",
path: "/configuration",
id: '/configuration',
path: '/configuration',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsCliRoute = DocsCliRouteImport.update({
id: "/cli",
path: "/cli",
id: '/cli',
path: '/cli',
getParentRoute: () => DocsRoute,
} as any);
} as any)
const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({
id: "/best-practices",
path: "/best-practices",
id: '/best-practices',
path: '/best-practices',
getParentRoute: () => DocsRoute,
} as any);
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/docs": typeof DocsRouteWithChildren;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs/": typeof DocsIndexRoute;
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs/': typeof DocsIndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs": typeof DocsIndexRoute;
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs': typeof DocsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/changelog": typeof ChangelogRoute;
"/claude-code": typeof ClaudeCodeRoute;
"/codex": typeof CodexRoute;
"/docs": typeof DocsRouteWithChildren;
"/opencode": typeof OpencodeRoute;
"/privacy": typeof PrivacyRoute;
"/docs/best-practices": typeof DocsBestPracticesRoute;
"/docs/cli": typeof DocsCliRoute;
"/docs/configuration": typeof DocsConfigurationRoute;
"/docs/security": typeof DocsSecurityRoute;
"/docs/updates": typeof DocsUpdatesRoute;
"/docs/voice": typeof DocsVoiceRoute;
"/docs/worktrees": typeof DocsWorktreesRoute;
"/docs/": typeof DocsIndexRoute;
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/changelog': typeof ChangelogRoute
'/claude-code': typeof ClaudeCodeRoute
'/codex': typeof CodexRoute
'/docs': typeof DocsRouteWithChildren
'/download': typeof DownloadRoute
'/opencode': typeof OpencodeRoute
'/privacy': typeof PrivacyRoute
'/docs/best-practices': typeof DocsBestPracticesRoute
'/docs/cli': typeof DocsCliRoute
'/docs/configuration': typeof DocsConfigurationRoute
'/docs/security': typeof DocsSecurityRoute
'/docs/updates': typeof DocsUpdatesRoute
'/docs/voice': typeof DocsVoiceRoute
'/docs/worktrees': typeof DocsWorktreesRoute
'/docs/': typeof DocsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/docs"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs/";
fileRoutesByTo: FileRoutesByTo;
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs/'
fileRoutesByTo: FileRoutesByTo
to:
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs";
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs'
id:
| "__root__"
| "/"
| "/changelog"
| "/claude-code"
| "/codex"
| "/docs"
| "/opencode"
| "/privacy"
| "/docs/best-practices"
| "/docs/cli"
| "/docs/configuration"
| "/docs/security"
| "/docs/updates"
| "/docs/voice"
| "/docs/worktrees"
| "/docs/";
fileRoutesById: FileRoutesById;
| '__root__'
| '/'
| '/changelog'
| '/claude-code'
| '/codex'
| '/docs'
| '/download'
| '/opencode'
| '/privacy'
| '/docs/best-practices'
| '/docs/cli'
| '/docs/configuration'
| '/docs/security'
| '/docs/updates'
| '/docs/voice'
| '/docs/worktrees'
| '/docs/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
ChangelogRoute: typeof ChangelogRoute;
ClaudeCodeRoute: typeof ClaudeCodeRoute;
CodexRoute: typeof CodexRoute;
DocsRoute: typeof DocsRouteWithChildren;
OpencodeRoute: typeof OpencodeRoute;
PrivacyRoute: typeof PrivacyRoute;
IndexRoute: typeof IndexRoute
ChangelogRoute: typeof ChangelogRoute
ClaudeCodeRoute: typeof ClaudeCodeRoute
CodexRoute: typeof CodexRoute
DocsRoute: typeof DocsRouteWithChildren
DownloadRoute: typeof DownloadRoute
OpencodeRoute: typeof OpencodeRoute
PrivacyRoute: typeof PrivacyRoute
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
"/privacy": {
id: "/privacy";
path: "/privacy";
fullPath: "/privacy";
preLoaderRoute: typeof PrivacyRouteImport;
parentRoute: typeof rootRouteImport;
};
"/opencode": {
id: "/opencode";
path: "/opencode";
fullPath: "/opencode";
preLoaderRoute: typeof OpencodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/docs": {
id: "/docs";
path: "/docs";
fullPath: "/docs";
preLoaderRoute: typeof DocsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/codex": {
id: "/codex";
path: "/codex";
fullPath: "/codex";
preLoaderRoute: typeof CodexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/claude-code": {
id: "/claude-code";
path: "/claude-code";
fullPath: "/claude-code";
preLoaderRoute: typeof ClaudeCodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/changelog": {
id: "/changelog";
path: "/changelog";
fullPath: "/changelog";
preLoaderRoute: typeof ChangelogRouteImport;
parentRoute: typeof rootRouteImport;
};
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/docs/": {
id: "/docs/";
path: "/";
fullPath: "/docs/";
preLoaderRoute: typeof DocsIndexRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/worktrees": {
id: "/docs/worktrees";
path: "/worktrees";
fullPath: "/docs/worktrees";
preLoaderRoute: typeof DocsWorktreesRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/voice": {
id: "/docs/voice";
path: "/voice";
fullPath: "/docs/voice";
preLoaderRoute: typeof DocsVoiceRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/updates": {
id: "/docs/updates";
path: "/updates";
fullPath: "/docs/updates";
preLoaderRoute: typeof DocsUpdatesRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/security": {
id: "/docs/security";
path: "/security";
fullPath: "/docs/security";
preLoaderRoute: typeof DocsSecurityRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/configuration": {
id: "/docs/configuration";
path: "/configuration";
fullPath: "/docs/configuration";
preLoaderRoute: typeof DocsConfigurationRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/cli": {
id: "/docs/cli";
path: "/cli";
fullPath: "/docs/cli";
preLoaderRoute: typeof DocsCliRouteImport;
parentRoute: typeof DocsRoute;
};
"/docs/best-practices": {
id: "/docs/best-practices";
path: "/best-practices";
fullPath: "/docs/best-practices";
preLoaderRoute: typeof DocsBestPracticesRouteImport;
parentRoute: typeof DocsRoute;
};
'/privacy': {
id: '/privacy'
path: '/privacy'
fullPath: '/privacy'
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
'/opencode': {
id: '/opencode'
path: '/opencode'
fullPath: '/opencode'
preLoaderRoute: typeof OpencodeRouteImport
parentRoute: typeof rootRouteImport
}
'/download': {
id: '/download'
path: '/download'
fullPath: '/download'
preLoaderRoute: typeof DownloadRouteImport
parentRoute: typeof rootRouteImport
}
'/docs': {
id: '/docs'
path: '/docs'
fullPath: '/docs'
preLoaderRoute: typeof DocsRouteImport
parentRoute: typeof rootRouteImport
}
'/codex': {
id: '/codex'
path: '/codex'
fullPath: '/codex'
preLoaderRoute: typeof CodexRouteImport
parentRoute: typeof rootRouteImport
}
'/claude-code': {
id: '/claude-code'
path: '/claude-code'
fullPath: '/claude-code'
preLoaderRoute: typeof ClaudeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/changelog': {
id: '/changelog'
path: '/changelog'
fullPath: '/changelog'
preLoaderRoute: typeof ChangelogRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/docs/': {
id: '/docs/'
path: '/'
fullPath: '/docs/'
preLoaderRoute: typeof DocsIndexRouteImport
parentRoute: typeof DocsRoute
}
'/docs/worktrees': {
id: '/docs/worktrees'
path: '/worktrees'
fullPath: '/docs/worktrees'
preLoaderRoute: typeof DocsWorktreesRouteImport
parentRoute: typeof DocsRoute
}
'/docs/voice': {
id: '/docs/voice'
path: '/voice'
fullPath: '/docs/voice'
preLoaderRoute: typeof DocsVoiceRouteImport
parentRoute: typeof DocsRoute
}
'/docs/updates': {
id: '/docs/updates'
path: '/updates'
fullPath: '/docs/updates'
preLoaderRoute: typeof DocsUpdatesRouteImport
parentRoute: typeof DocsRoute
}
'/docs/security': {
id: '/docs/security'
path: '/security'
fullPath: '/docs/security'
preLoaderRoute: typeof DocsSecurityRouteImport
parentRoute: typeof DocsRoute
}
'/docs/configuration': {
id: '/docs/configuration'
path: '/configuration'
fullPath: '/docs/configuration'
preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute
}
'/docs/cli': {
id: '/docs/cli'
path: '/cli'
fullPath: '/docs/cli'
preLoaderRoute: typeof DocsCliRouteImport
parentRoute: typeof DocsRoute
}
'/docs/best-practices': {
id: '/docs/best-practices'
path: '/best-practices'
fullPath: '/docs/best-practices'
preLoaderRoute: typeof DocsBestPracticesRouteImport
parentRoute: typeof DocsRoute
}
}
}
interface DocsRouteChildren {
DocsBestPracticesRoute: typeof DocsBestPracticesRoute;
DocsCliRoute: typeof DocsCliRoute;
DocsConfigurationRoute: typeof DocsConfigurationRoute;
DocsSecurityRoute: typeof DocsSecurityRoute;
DocsUpdatesRoute: typeof DocsUpdatesRoute;
DocsVoiceRoute: typeof DocsVoiceRoute;
DocsWorktreesRoute: typeof DocsWorktreesRoute;
DocsIndexRoute: typeof DocsIndexRoute;
DocsBestPracticesRoute: typeof DocsBestPracticesRoute
DocsCliRoute: typeof DocsCliRoute
DocsConfigurationRoute: typeof DocsConfigurationRoute
DocsSecurityRoute: typeof DocsSecurityRoute
DocsUpdatesRoute: typeof DocsUpdatesRoute
DocsVoiceRoute: typeof DocsVoiceRoute
DocsWorktreesRoute: typeof DocsWorktreesRoute
DocsIndexRoute: typeof DocsIndexRoute
}
const DocsRouteChildren: DocsRouteChildren = {
@@ -345,9 +365,9 @@ const DocsRouteChildren: DocsRouteChildren = {
DocsVoiceRoute: DocsVoiceRoute,
DocsWorktreesRoute: DocsWorktreesRoute,
DocsIndexRoute: DocsIndexRoute,
};
}
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren);
const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
@@ -355,18 +375,19 @@ const rootRouteChildren: RootRouteChildren = {
ClaudeCodeRoute: ClaudeCodeRoute,
CodexRoute: CodexRoute,
DocsRoute: DocsRouteWithChildren,
DownloadRoute: DownloadRoute,
OpencodeRoute: OpencodeRoute,
PrivacyRoute: PrivacyRoute,
};
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();
._addFileTypes<FileRouteTypes>()
import type { getRouter } from "./router.tsx";
import type { createStart } from "@tanstack/react-start";
declare module "@tanstack/react-start" {
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true;
router: Awaited<ReturnType<typeof getRouter>>;
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+419
View File
@@ -0,0 +1,419 @@
import { Link, createFileRoute } from "@tanstack/react-router";
import { pageMeta } from "~/meta";
import websitePackage from "../../package.json";
import "~/styles.css";
const desktopVersion = websitePackage.version;
const releaseBase = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}`;
export const Route = createFileRoute("/download")({
head: () => ({
meta: pageMeta(
"Download - Paseo",
"Download Paseo for macOS, Windows, Linux, iOS, and Android. Your dev environment, in your pocket.",
),
}),
component: Download,
});
function Download() {
return (
<div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto p-6 md:p-12">
<header className="flex items-center justify-between gap-4 mb-12">
<Link to="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<div className="flex items-center gap-4">
<Link
to="/docs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</Link>
<Link
to="/changelog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog
</Link>
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
aria-label="Discord"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
role="img"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</a>
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0C5.37 0 0 5.484 0 12.252c0 5.418 3.438 10.013 8.205 11.637.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.738-4.042-1.61-4.042-1.61-.546-1.403-1.333-1.776-1.333-1.776-1.089-.756.084-.741.084-.741 1.205.087 1.838 1.262 1.838 1.262 1.07 1.87 2.809 1.33 3.495 1.017.108-.79.417-1.33.76-1.636-2.665-.31-5.467-1.35-5.467-6.005 0-1.327.465-2.413 1.235-3.262-.124-.31-.535-1.556.117-3.243 0 0 1.008-.33 3.3 1.248a11.2 11.2 0 0 1 3.003-.404c1.02.005 2.045.138 3.003.404 2.29-1.578 3.297-1.248 3.297-1.248.653 1.687.242 2.933.118 3.243.77.85 1.233 1.935 1.233 3.262 0 4.667-2.807 5.692-5.48 5.995.43.38.823 1.133.823 2.285 0 1.65-.015 2.98-.015 3.386 0 .315.218.694.825.576C20.565 22.26 24 17.667 24 12.252 24 5.484 18.627 0 12 0z" />
</svg>
</a>
</div>
</header>
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
<p className="text-muted-foreground mb-10">
v{desktopVersion}
</p>
{/* Desktop */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Desktop</h2>
<MonitorIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
{/* macOS */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AppleIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">macOS</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={`${releaseBase}/Paseo-${desktopVersion}-arm64.dmg`}
label="Apple Silicon"
/>
<DownloadPill
href={`${releaseBase}/Paseo-${desktopVersion}.dmg`}
label="Intel"
/>
</div>
</div>
{/* Windows */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<WindowsIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Windows</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={`${releaseBase}/Paseo-Setup-${desktopVersion}.exe`}
label="Download"
/>
</div>
</div>
{/* Linux */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<LinuxIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Linux</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href={`${releaseBase}/Paseo-${desktopVersion}.AppImage`}
label="AppImage"
/>
<DownloadPill
href={`${releaseBase}/paseo_${desktopVersion}_amd64.deb`}
label="DEB"
/>
<DownloadPill
href={`${releaseBase}/paseo-${desktopVersion}-1.x86_64.rpm`}
label="RPM"
/>
</div>
</div>
</div>
</section>
{/* Mobile */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8 mb-6">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Mobile</h2>
<PhoneIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
{/* Android */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AndroidIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Android</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href="https://play.google.com/store/apps/details?id=sh.paseo"
label="Play Store"
external
/>
<DownloadPill
href={`${releaseBase}/paseo-${desktopVersion}.apk`}
label="APK"
/>
</div>
</div>
{/* iOS */}
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<AppleIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">iOS</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
label="App Store"
external
/>
</div>
</div>
</div>
</section>
{/* Web & CLI */}
<section className="rounded-xl border border-border bg-card/40 p-6 md:p-8">
<div className="flex items-center justify-between mb-8">
<h2 className="text-2xl font-semibold">Web & CLI</h2>
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="divide-y divide-border">
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<GlobeIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">Web App</span>
</div>
<div className="flex flex-wrap gap-2">
<DownloadPill
href="https://app.paseo.sh"
label="Open"
external
/>
</div>
</div>
<div className="flex items-center justify-between py-5 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
<TerminalIcon className="h-5 w-5 text-foreground" />
<span className="font-medium">CLI</span>
</div>
<div className="flex flex-wrap gap-2">
<code className="text-sm text-muted-foreground font-mono bg-muted px-3 py-1.5 rounded-lg">
npx paseo
</code>
</div>
</div>
</div>
</section>
<p className="text-center text-xs text-muted-foreground mt-8">
All releases are available on{" "}
<a
href="https://github.com/getpaseo/paseo/releases"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground transition-colors"
>
GitHub
</a>
.
</p>
</div>
</div>
);
}
function DownloadPill({
href,
label,
external,
}: {
href: string;
label: string;
external?: boolean;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-full bg-foreground px-4 py-1.5 text-sm font-medium text-background hover:bg-foreground/85 transition-colors"
>
{label}
{external && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-1.5 h-3 w-3"
aria-hidden="true"
>
<path d="M7 17L17 7" />
<path d="M7 7h10v10" />
</svg>
)}
</a>
);
}
function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701" />
</svg>
);
}
function AndroidIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M380.91,199l42.47-73.57a8.63,8.63,0,0,0-3.12-11.76,8.52,8.52,0,0,0-11.71,3.12l-43,74.52c-32.83-15-69.78-23.35-109.52-23.35s-76.69,8.36-109.52,23.35l-43-74.52a8.6,8.6,0,1,0-14.88,8.64L131,199C57.8,238.64,8.19,312.77,0,399.55H512C503.81,312.77,454.2,238.64,380.91,199ZM138.45,327.65a21.46,21.46,0,1,1,21.46-21.46A21.47,21.47,0,0,1,138.45,327.65Zm235,0A21.46,21.46,0,1,1,395,306.19,21.47,21.47,0,0,1,373.49,327.65Z" />
</svg>
);
}
function WindowsIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M0,0H11.377V11.372H0ZM12.623,0H24V11.372H12.623ZM0,12.623H11.377V24H0Zm12.623,0H24V24H12.623" />
</svg>
);
}
function LinuxIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M8.996 4.497c.104-.076.1-.168.186-.158s.022.102-.098.207c-.12.104-.308.243-.46.323-.291.152-.631.336-.993.336s-.647-.167-.853-.33c-.102-.082-.186-.162-.248-.221-.11-.086-.096-.207-.052-.204.075.01.087.109.134.153.064.06.144.137.241.214.195.154.454.304.778.304s.702-.19.932-.32c.13-.073.297-.204.433-.304M7.34 3.781c.055-.02.123-.031.174-.003.011.006.024.021.02.034-.012.038-.074.032-.11.05-.032.017-.057.052-.093.054-.034 0-.086-.012-.09-.046-.007-.044.058-.072.1-.089m.581-.003c.05-.028.119-.018.173.003.041.017.106.045.1.09-.004.033-.057.046-.09.045-.036-.002-.062-.037-.093-.053-.036-.019-.098-.013-.11-.051-.004-.013.008-.028.02-.034" />
<path
fillRule="evenodd"
d="M8.446.019c2.521.003 2.38 2.66 2.364 4.093-.01.939.509 1.574 1.04 2.244.474.56 1.095 1.38 1.45 2.32.29.765.402 1.613.115 2.465a.8.8 0 0 1 .254.152l.001.002c.207.175.271.447.329.698.058.252.112.488.224.615.344.382.494.667.48.922-.015.254-.203.43-.435.57-.465.28-1.164.491-1.586 1.002-.443.527-.99.83-1.505.871a1.25 1.25 0 0 1-1.256-.716v-.001a1 1 0 0 1-.078-.21c-.67.038-1.252-.165-1.718-.128-.687.038-1.116.204-1.506.206-.151.331-.445.547-.808.63-.5.114-1.126 0-1.743-.324-.577-.306-1.31-.278-1.85-.39-.27-.057-.51-.157-.626-.384-.116-.226-.095-.538.07-.988.051-.16.012-.398-.026-.648a2.5 2.5 0 0 1-.037-.369c0-.133.022-.265.087-.386v-.002c.14-.266.368-.377.577-.451s.397-.125.53-.258c.143-.15.27-.374.443-.56q.036-.037.073-.07c-.081-.538.007-1.105.192-1.662.393-1.18 1.223-2.314 1.811-3.014.502-.713.65-1.287.701-2.016.042-.997-.705-3.974 2.112-4.2q.168-.015.321-.013m2.596 10.866-.03.016c-.223.121-.348.337-.427.656-.08.32-.107.733-.13 1.206v.001c-.023.37-.192.824-.31 1.267s-.176.862-.036 1.128v.002c.226.452.608.636 1.051.601s.947-.304 1.36-.795c.474-.576 1.218-.796 1.638-1.05.21-.126.324-.242.333-.4.009-.157-.097-.403-.425-.767-.17-.192-.217-.462-.274-.71-.056-.247-.122-.468-.26-.585l-.001-.001c-.18-.157-.356-.17-.565-.164q-.069.001-.14.005c-.239.275-.805.612-1.197.508-.359-.09-.562-.508-.587-.918m-7.204.03H3.83c-.189.002-.314.09-.44.225-.149.158-.276.382-.445.56v.002h-.002c-.183.184-.414.239-.61.31-.195.069-.353.143-.46.35v.002c-.085.155-.066.378-.029.624.038.245.096.507.018.746v.002l-.001.002c-.157.427-.155.678-.082.822.074.143.235.22.48.272.493.103 1.26.069 1.906.41.583.305 1.168.404 1.598.305.431-.098.712-.369.75-.867v-.002c.029-.292-.195-.673-.485-1.052-.29-.38-.633-.752-.795-1.09v-.002l-.61-1.11c-.21-.286-.43-.462-.68-.5a1 1 0 0 0-.106-.008M9.584 4.85c-.14.2-.386.37-.695.467-.147.048-.302.17-.495.28a1.3 1.3 0 0 1-.74.19.97.97 0 0 1-.582-.227c-.14-.113-.25-.237-.394-.322a3 3 0 0 1-.192-.126c-.063 1.179-.85 2.658-1.226 3.511a5.4 5.4 0 0 0-.43 1.917c-.68-.906-.184-2.066.081-2.568.297-.55.343-.701.27-.649-.266.436-.685 1.13-.848 1.844-.085.372-.1.749.01 1.097.11.349.345.67.766.931.573.351.963.703 1.193 1.015s.302.584.23.777a.4.4 0 0 1-.212.22.7.7 0 0 1-.307.056l.184.235c.094.124.186.249.266.375 1.179.805 2.567.496 3.568-.218.1-.342.197-.664.212-.903.024-.474.05-.896.136-1.245s.244-.634.53-.791a1 1 0 0 1 .138-.061q.005-.045.013-.087c.082-.546.569-.572 1.18-.303.588.266.81.499.71.814h.13c.122-.398-.133-.69-.822-1.025l-.137-.06a2.35 2.35 0 0 0-.012-1.113c-.188-.79-.704-1.49-1.098-1.838-.072-.003-.065.06.081.203.363.333 1.156 1.532.727 2.644a1.2 1.2 0 0 0-.342-.043c-.164-.907-.543-1.66-.735-2.014-.359-.668-.918-2.036-1.158-2.983M7.72 3.503a1 1 0 0 0-.312.053c-.268.093-.447.286-.559.391-.022.021-.05.04-.119.091s-.172.126-.321.238q-.198.151-.13.38c.046.15.192.325.459.476.166.098.28.23.41.334a1 1 0 0 0 .215.133.9.9 0 0 0 .298.066c.282.017.49-.068.673-.173s.34-.233.518-.29c.365-.115.627-.345.709-.564a.37.37 0 0 0-.01-.309c-.048-.096-.148-.187-.318-.257h-.001c-.354-.151-.507-.162-.705-.29-.321-.207-.587-.28-.807-.279m-.89-1.122h-.025a.4.4 0 0 0-.278.135.76.76 0 0 0-.191.334 1.2 1.2 0 0 0-.051.445v.001c.01.162.041.299.102.436.05.116.109.204.183.274l.089-.065.117-.09-.023-.018a.4.4 0 0 1-.11-.161.7.7 0 0 1-.054-.22v-.01a.7.7 0 0 1 .014-.234.4.4 0 0 1 .08-.179q.056-.069.126-.073h.013a.18.18 0 0 1 .123.05c.045.04.08.09.11.162a.7.7 0 0 1 .054.22v.01a.7.7 0 0 1-.002.17 1.1 1.1 0 0 1 .317-.143 1.3 1.3 0 0 0 .002-.194V3.23a1.2 1.2 0 0 0-.102-.437.8.8 0 0 0-.227-.31.4.4 0 0 0-.268-.102m1.95-.155a.63.63 0 0 0-.394.14.9.9 0 0 0-.287.376 1.2 1.2 0 0 0-.1.51v.015q0 .079.01.152c.114.027.278.074.406.138a1 1 0 0 1-.011-.172.8.8 0 0 1 .058-.278.5.5 0 0 1 .139-.2.26.26 0 0 1 .182-.069.26.26 0 0 1 .178.081c.055.054.094.12.124.21.029.086.042.17.04.27l-.002.012a.8.8 0 0 1-.057.277c-.024.059-.089.106-.122.145.046.016.09.03.146.052a5 5 0 0 1 .248.102 1.2 1.2 0 0 0 .244-.763 1.2 1.2 0 0 0-.11-.495.9.9 0 0 0-.294-.37.64.64 0 0 0-.39-.133z"
/>
</svg>
);
}
function MonitorIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
);
}
function PhoneIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
);
}
function TerminalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
);
}
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a15 15 0 0 1 0 18" />
<path d="M12 3a15 15 0 0 0 0 18" />
</svg>
);
}