Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-07-24 12:48:03 +02:00
13 changed files with 252 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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