Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 41ac23016d test(app): measure sidebar rows one at a time
After a reload the developer error toast appears while expectVerticalOrder
measures every row at once. Each concurrent boundingBox runs the toast
locator handler, and only one of them resumes, so the poll timed out on
every attempt since #7424. Measuring sequentially keeps one action in flight.
2026-10-20 09:37:54 +02:00
Mohamed Boudra 6971bb5788 style(server): format manifest test 2026-10-20 08:41:24 +02:00
Mohamed Boudra 93ceb713b6 docs(plugins): describe the registry's final overview translation discovery 2026-10-20 08:41:10 +02:00
Mohamed Boudra 7f6a0651e8 fix(server): leave plugin listing translations to the registry
The daemon rejected manifests whose translations the registry would refuse,
so a plugin main installs could fail to load here. Paseo never reads
translations; the registry validates them at publish. Docs now say which
checks are automated at publish and which are human review.
2026-10-20 08:39:50 +02:00
Mohamed Boudra 778b0ce773 docs(website): translate main's new docs and 0.12.0 release notes
Main added Keep run workspaces to the schedules docs, rewrote plugin icons
around inline SVG, and promoted 0.12.0 with new entries. Bring the Chinese
pages and changelog in line so no section falls back to English.
2026-10-20 08:21:48 +02:00
Mohamed Boudra b4f7536788 Merge remote-tracking branch 'origin/main' into feat/website-plugin-listing-translations 2026-10-20 08:19:59 +02:00
Mohamed Boudra 5f18e75fb1 feat(website): replace the native language select with an EN/ZH menu
The header showed an OS-native select whose popup did not match the site.
The trigger now shows the language abbreviated (EN, ZH) and opens a menu in
the app's dropdown style that names each language in itself, with its name
in the page language underneath. Rows are real links to the same page in
that language; arrows, Escape, outside presses and taps behave as a menu
button, and the menu keeps itself on screen when a narrow header pushes the
trigger toward an edge.

Also drops the stray space after the full-width comma in the Chinese
homepage subtitle.
2026-10-20 08:14:59 +02:00
Mohamed Boudra d87998bc8c feat(website): serve paseo.sh in Simplified Chinese
Every page of the website now has a Simplified Chinese version under
/zh-CN: the interface, docs, blog posts, comparison pages, legal pages,
all changelog releases and the homepage mockup. English URLs are unchanged.

The URL alone decides the language. There is no browser-language redirect
and no stored preference, because either would have to redirect the
unprefixed English URLs that people share and search engines index.
Internal links carry the page's language instead, and hreflang alternates
point Chinese search results at /zh-CN.

Translated headings pin the English anchor with {#id}, so section links
work in both languages. content-translations.test.ts fails when a page
has no translation or a translation's headings drift from the English.
Changelog releases and plugin listings without a translation render
whole in English under a notice, so a release never waits on one.

Plugin manifests and the registry gain optional translations and
OVERVIEW.zh-CN.md fields; older clients and registries ignore them.
2026-10-19 08:30:45 +02:00
265 changed files with 22338 additions and 3865 deletions
+3238
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -41,6 +41,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+7 -7
View File
@@ -59,8 +59,8 @@
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
</tr>
</table>
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
### 桌面 app(推荐)
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
如果要从手机连接,在 Settings 中扫描显示的二维码。
@@ -97,8 +97,8 @@ paseo
完整安装和配置见:
- [文档](https://paseo.sh/docs)
- [配置参考](https://paseo.sh/docs/configuration)
- [文档](https://paseo.sh/zh-CN/docs)
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
## CLI
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
paseo --host workstation.local:6767 run "run the full test suite"
```
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
## Skills
@@ -164,7 +164,7 @@ npm run typecheck
## 支持 Paseo
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
+1 -1
View File
@@ -1,6 +1,6 @@
# I18n
Paseo client UI translations live in `packages/app/src/i18n`.
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
## Supported Locales
+8
View File
@@ -533,6 +533,14 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
## Changelog translations
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
+51
View File
@@ -0,0 +1,51 @@
# Website translations
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
## URLs own the language
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
## Where copy lives
| Content | English | Chinese |
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
## Editing a translated doc
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
```md
## 运行 Agent {#running-agents}
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
```
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
## Chinese style
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
+2
View File
@@ -43338,9 +43338,11 @@
"@tanstack/react-start": "^1.168.26",
"framer-motion": "^12.35.2",
"github-slugger": "^2.0.0",
"i18next": "^26.3.0",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.3",
@@ -149,9 +149,10 @@ async function expectVerticalOrder<Key extends string>(
await expect
.poll(
async () => {
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
// One row at a time: concurrent actions that all run the developer toast handler do not
// all resume, and the poll then waits out its timeout.
const measured: { key: Key; top: number | null }[] = [];
for (const key of keys) measured.push({ key, top: await rowTop(locate(key)) });
if (!measured.every((entry): entry is { key: Key; top: number } => entry.top !== null))
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
@@ -55,18 +55,19 @@ describe("plugin scaffold", () => {
expect(await readdir(directory)).toContain("paseo-plugin.json");
});
it("includes the author overview in the packed npm artifact", async () => {
it("includes the author overview and its translations in the packed npm artifact", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "packed-plugin");
await scaffoldPluginDirectory(directory);
await writeFile(path.join(directory, "OVERVIEW.zh-CN.md"), "插件概览\n");
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
cwd: directory,
});
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
expect(artifact.files.map((file) => file.path)).toEqual(
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
expect.arrayContaining(["OVERVIEW.md", "OVERVIEW.zh-CN.md", "paseo-plugin.json"]),
);
});
@@ -105,6 +106,7 @@ describe("plugin scaffold", () => {
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"OVERVIEW.*.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -28,6 +28,9 @@ when they help. Use sentence case and plain factual language, with no em dashes.
Omit installation commands, badges, changelog, contributing or license sections,
marketing, and unsupported claims. Avoid implementation filler such as empty
cleanup functions, theme-token field lists, or lists of absent features.
To translate the overview, add a whole copy beside this file named for the
language, such as OVERVIEW.zh-CN.md. Readers in that language see it instead.
-->
`;
@@ -184,6 +187,7 @@ export async function scaffoldPluginDirectory(
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"OVERVIEW.*.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
+66 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { pluginMediaKind } from "./plugin-registry.js";
import {
PluginRegistryIndexSchema,
PublishedPluginDetailSchema,
pluginMediaKind,
} from "./plugin-registry.js";
describe("plugin media kind", () => {
it.each([
@@ -16,3 +20,64 @@ describe("plugin media kind", () => {
expect(pluginMediaKind(url)).toBe(kind);
});
});
const LEGACY_PLUGIN = {
id: "acme/theme",
name: "Acme theme",
description: "A dark theme.",
categories: ["themes"],
author: { github: "acme" },
repository: { url: "https://github.com/acme/theme" },
artifact: { kind: "git", remote: "https://github.com/acme/theme", commit: "a".repeat(40) },
media: [],
submittedAt: "2026-01-01T00:00:00.000Z",
reviewedAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
publishedAt: "2026-01-02T00:00:00.000Z",
};
const LEGACY_INDEX = {
schemaVersion: 1,
registry: { name: "Paseo", url: "https://plugins.paseo.sh" },
categories: [{ slug: "themes", label: "Themes" }],
plugins: [LEGACY_PLUGIN],
featured: [],
generatedAt: "2026-01-02T00:00:00.000Z",
};
describe("plugin listing translations", () => {
it("parses an index published before translations existed", () => {
const index = PluginRegistryIndexSchema.parse(LEGACY_INDEX);
expect(index.plugins[0].translations).toBeUndefined();
});
it("keeps per-language name and description, each optional", () => {
const translations = { "zh-CN": { description: "深色主题。" } };
const index = PluginRegistryIndexSchema.parse({
...LEGACY_INDEX,
plugins: [{ ...LEGACY_PLUGIN, translations }],
});
expect(index.plugins[0].translations).toEqual(translations);
});
it("keeps translated overviews on the detail document beside the English readme", () => {
const detail = PublishedPluginDetailSchema.parse({
...LEGACY_PLUGIN,
readme: "# Acme theme",
overviewTranslations: { "zh-CN": "# Acme 主题" },
});
expect(detail.readme).toBe("# Acme theme");
expect(detail.overviewTranslations).toEqual({ "zh-CN": "# Acme 主题" });
expect(PublishedPluginDetailSchema.parse({ ...LEGACY_PLUGIN, readme: "" })).not.toHaveProperty(
"overviewTranslations",
);
});
it("still requires the English name and description", () => {
const { name: _name, ...nameless } = LEGACY_PLUGIN;
const result = PluginRegistryIndexSchema.safeParse({
...LEGACY_INDEX,
plugins: [{ ...nameless, translations: { "zh-CN": { name: "Acme 主题" } } }],
});
expect(result.success).toBe(false);
});
});
+14 -1
View File
@@ -29,10 +29,19 @@ export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [
.optional(),
}),
]);
/**
* Listing text in other languages, keyed by language tag such as `zh-CN`. A missing field falls back
* to the English `name` or `description`, which stay required so older clients keep working.
*/
export const PluginListingTranslationsSchema = z.record(
z.string(),
z.object({ name: z.string().optional(), description: z.string().optional() }),
);
export const PublishedPluginSchema = z.object({
id: PluginRegistryIdSchema,
name: z.string(),
description: z.string(),
translations: PluginListingTranslationsSchema.optional(),
license: z.string().optional(),
categories: z.array(z.string()),
author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }),
@@ -54,7 +63,11 @@ export function pluginMediaKind(url: string): "image" | "video" {
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
}
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({
readme: z.string(),
/** The overview in other languages, keyed like `translations`. Each one replaces `readme` whole. */
overviewTranslations: z.record(z.string(), z.string()).optional(),
});
export const PluginRegistryIndexSchema = z.object({
schemaVersion: z.literal(1),
registry: z.object({ name: z.string(), url: z.string().url() }),
@@ -128,6 +128,21 @@ describe("plugin manifest", () => {
await expect(readPluginManifest(directory)).rejects.toThrow();
});
// Listing translations are read by the registry when it publishes, never by the daemon, so a
// value the registry would refuse must not stop a plugin from installing.
it.each([{ "zh-cn": { name: "示例" } }, { "zh-CN": { desc: "示例" } }, "zh-CN"])(
"ignores listing translations: %j",
async (translations) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "described", translations }),
);
await expect(readPluginManifest(directory)).resolves.toEqual({ id: "described" });
},
);
it("accepts only non-empty argv arrays for build commands", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
+185
View File
@@ -0,0 +1,185 @@
import { expect, test, type Page } from "playwright/test";
function languageButton(page: Page) {
return page.getByRole("button", { name: /^(Language|语言)/ }).first();
}
async function chooseLanguage(page: Page, language: string) {
await languageButton(page).click();
await page.getByRole("menuitemradio", { name: language }).click();
}
function describeAlternates(links: Element[]): string[] {
return links.map((link) => `${link.getAttribute("hreflang")} ${link.getAttribute("href")}`);
}
test.describe("server-rendered Chinese pages", () => {
test.use({ javaScriptEnabled: false });
test("render in Chinese with matching language metadata before any script runs", async ({
page,
}) => {
await page.goto("/zh-CN/docs/cli");
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
await expect(page).toHaveTitle("CLI 参考 - Paseo 文档");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI 参考$/);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
"href",
"https://paseo.sh/zh-CN/docs/cli",
);
const alternates = await page
.locator('link[rel="alternate"][hreflang]')
.evaluateAll(describeAlternates);
expect(alternates).toEqual([
"en https://paseo.sh/docs/cli",
"zh-CN https://paseo.sh/zh-CN/docs/cli",
"x-default https://paseo.sh/docs/cli",
]);
await expect(page.locator('meta[property="og:locale"]')).toHaveAttribute("content", "zh_CN");
});
test("keep English pages at their unprefixed URLs", async ({ page }) => {
await page.goto("/docs/cli");
await expect(page.locator("html")).toHaveAttribute("lang", "en");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/CLI reference$/);
});
test("land translated anchors on the English section ids", async ({ page }) => {
await page.goto("/zh-CN/docs/cli#running-agents");
await expect(page.locator("#running-agents")).toHaveText(/运行 Agent/);
await expect(page.locator("main")).not.toContainText("{#");
});
test("show the app's Chinese labels in the homepage mockup", async ({ page }) => {
await page.goto("/zh-CN");
const mockup = page.getByRole("img", {
name: "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
});
await expect(mockup).toContainText("新建工作区");
await expect(mockup).toContainText("待查看");
await expect(mockup).toContainText("给 Agent 发消息");
await expect(mockup).not.toContainText("Ready to review");
});
test("keep no space after full-width punctuation in the homepage subtitle", async ({ page }) => {
await page.goto("/zh-CN");
await expect(page.getByText(/^同时运行多个编程 Agent/)).toHaveText(
"同时运行多个编程 Agent,在任何机器上。在桌前,也在手机上。",
);
});
});
test("switches language on the same page, keeping the query and then every link", async ({
page,
}) => {
await page.goto("/plugins/all?q=dark");
await chooseLanguage(page, "简体中文");
await expect(page).toHaveURL(/\/zh-CN\/plugins\/all\?q=dark$/);
await expect(page.locator("html")).toHaveAttribute("lang", "zh-CN");
await expect(page.getByRole("searchbox", { name: "搜索插件" })).toHaveValue("dark");
await page.getByRole("link", { name: "文档", exact: true }).first().click();
await expect(page).toHaveURL(/\/zh-CN\/docs$/);
await chooseLanguage(page, "English");
await expect(page).toHaveURL(/\/docs$/);
await expect(page.locator("html")).toHaveAttribute("lang", "en");
});
test("shows the language as an abbreviation and lists every language by name", async ({ page }) => {
await page.goto("/docs/cli#running-agents");
const button = languageButton(page);
await expect(button).toHaveText("EN");
await expect(button).toHaveAccessibleName("Language: English");
await button.click();
await expect(page.getByRole("menuitemradio", { name: "English" })).toHaveAttribute(
"aria-checked",
"true",
);
const chinese = page.getByRole("menuitemradio", { name: /简体中文/ });
await expect(chinese).toHaveAttribute("aria-checked", "false");
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
await expect(chinese).toHaveAttribute("href", "/zh-CN/docs/cli#running-agents");
await page.mouse.click(5, 400);
await expect(page.getByRole("menu")).toHaveCount(0);
});
test("switches language from the keyboard", async ({ page }) => {
await page.goto("/");
await languageButton(page).focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("menuitemradio", { name: "English" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(languageButton(page)).toBeFocused();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(page.getByRole("menuitemradio", { name: /简体中文/ })).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/zh-CN$/);
await expect(languageButton(page)).toHaveText("ZH");
await expect(languageButton(page)).toBeFocused();
});
test.describe("on a phone", () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
const cases = [
{ from: "/", choose: "简体中文", to: "/zh-CN", abbreviation: "ZH" },
{ from: "/zh-CN", choose: "English", to: "/", abbreviation: "EN" },
{ from: "/docs/cli", choose: "简体中文", to: "/zh-CN/docs/cli", abbreviation: "ZH" },
];
for (const { from, choose, to, abbreviation } of cases) {
test(`switches from ${from} to ${to} with taps`, async ({ page, baseURL }) => {
await page.goto(from);
await languageButton(page).tap();
await expect(page.getByRole("menu")).toBeInViewport({ ratio: 1 });
await page.getByRole("menuitemradio", { name: choose }).tap();
await expect(page).toHaveURL(new URL(to, baseURL).href);
await expect(languageButton(page)).toHaveText(abbreviation);
});
}
});
test("finds plugins by their Chinese and English listing text", async ({ page }) => {
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("深色")}`);
await expect(page.getByRole("link", { name: /Dracula/ })).toBeVisible();
await page.goto(`/zh-CN/plugins/all?q=${encodeURIComponent("传令官")}`);
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
await page.goto("/zh-CN/plugins/all?q=herald");
await expect(page.getByRole("link", { name: /传令官/ })).toBeVisible();
});
test("shows a translated plugin overview, or the English one under a notice", async ({ page }) => {
await page.goto("/zh-CN/plugins/omercnet/dracula");
await expect(page.getByRole("heading", { name: "行为" })).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
await page.goto("/zh-CN/plugins/gpambrozio/herald");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("传令官");
await expect(page.getByRole("note")).toHaveText(
"该插件的概览尚未翻译为你的语言,因此以英文显示。",
);
await expect(page.getByRole("heading", { name: "Behavior" })).toBeVisible();
});
test("shows the changelog in Chinese", async ({ page }) => {
await page.goto("/zh-CN/changelog");
await expect(page.getByRole("heading", { name: "新增" }).first()).toBeVisible();
await expect(page.getByRole("note")).toHaveCount(0);
});
+2 -1
View File
@@ -2,6 +2,7 @@ import securityPlugin from "./overview.fixture.json" with { type: "json" };
import { expect, test, type Page, type Locator } from "playwright/test";
import registry from "./registry.fixture.json" with { type: "json" };
import { CATEGORIES } from "../src/plugins/categories";
import copy from "../src/i18n/resources/en.json" with { type: "json" };
async function openPlugins(page: Page) {
await page.goto("/plugins");
@@ -635,7 +636,7 @@ test.describe("registry fixture layout", () => {
await openPlugins(page);
const categories = page.getByRole("region", { name: "Categories" });
await expect(categories.getByRole("link")).toHaveText(
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
CATEGORIES.map((slug) => new RegExp(`^${copy.plugins.categories[slug].label}\\s*\\d+$`)),
);
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
await expect(pluginCards(page, "What’s new")).toHaveText([
+16 -2
View File
@@ -187,7 +187,13 @@
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"installs": 0,
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel",
"translations": {
"zh-CN": {
"name": "传令官",
"description": "Paseo 插件:当 Agent 需要你时朗读一句话,并在侧边栏列出所有等待中的事项"
}
}
},
{
"id": "gpambrozio/launchd-jobs",
@@ -413,7 +419,15 @@
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"installs": 0,
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo.",
"translations": {
"zh-CN": {
"description": "适用于 Paseo 的 Dracula Classic 和 Alucard Classic 深色主题。"
}
},
"overviewTranslations": {
"zh-CN": "# Dracula\n\n## 行为\n\n适用于 Paseo 的 Dracula Classic 和 Alucard Classic 主题。"
}
},
{
"id": "omercnet/fresh-worktrees",
+99
View File
@@ -0,0 +1,99 @@
---
title: "Privacy Policy"
description: "What stays on your machines, what the encrypted relay can see, and what Paseo Hub stores."
lastUpdated: "2026-08-29"
---
Paseo is local-first. Installing or using the open-source software does not send us your code, prompts, files, terminal output, or agent conversations. This policy explains the separate data boundaries for local Paseo, the optional official relay, the hosted Paseo Hub, and paseo.sh.
## Who is responsible
**Mohamed Boudra Ziani**, operating as Paseo\
NIF/VAT ID: ES26617095T\
Roc Boronat 48, Bajos 2\
08005 Barcelona, Spain\
Email: [hello@moboudra.com](mailto:hello@moboudra.com)
Mohamed Boudra Ziani is the data controller for personal data processed through the official Paseo website, relay, and hosted Hub. Independently self-hosted daemons, Hubs, and relays are controlled by their operators and are not covered by this policy.
## Local Paseo apps and daemons
Paseo runs on your machines. It does not send us analytics, telemetry, advertising identifiers, or crash reports.
Packaged desktop apps check GitHub Releases for updates. GitHub receives the ordinary network information needed to answer that request under its own privacy policy.
Agents such as Claude Code, Codex, and OpenCode communicate with their providers using credentials on your machine. Paseo does not manage or intercept those provider API calls.
## The official relay
The relay is optional. To connect your client and daemon, it processes:
- IP addresses and connection timing
- Session identifiers and public handshake keys
- Message sizes and aggregate bandwidth
- Temporary connection and routing state
Your client and daemon encrypt application traffic end-to-end with NaCl box encryption. The relay carries ciphertext and cannot read your code, prompts, terminal output, or agent conversations. Payloads exist in relay memory only while being forwarded. We do not store message contents. Infrastructure may retain limited operational logs and aggregate metrics for security, capacity planning, and troubleshooting.
## Paseo Hub
When you create or use a hosted Hub account, we process:
- Your name, email, account credentials, sessions, IP address, and user agent
- Your organization, members, roles, invitations, and daemon registrations
- Identifiers and credentials for services you connect
- Webhook events, messages, comments, attachment metadata, and related context received from those services
- Workflow configurations, trigger inputs, outputs, execution state, activity, and audit records
- Stripe customer identifiers and subscription information needed to provide access
Your repositories, local files, and agent-provider credentials remain on your infrastructure unless a workflow explicitly sends information to Hub or a connected service. Hub does not provide AI inference.
## Who controls workflow data
Paseo controls account, billing, security, and service-operation data. When an organization uses Hub to process personal data in its workflows, that organization decides why the data is processed and Paseo processes it on the organization's behalf.
## Why we process data
We process data to:
- Provide accounts, Hub workflows, relay connectivity, billing, and support
- Authenticate users, daemons, and connected services
- Prevent abuse and protect the services
- Maintain operational and audit records
- Meet accounting, tax, and other legal obligations
The legal bases are performance of our contract with you, our legitimate interests in operating and protecting the services, and compliance with legal obligations.
## Service providers
We use Fly.io for hosted infrastructure, Stripe for subscriptions and payments, and GitHub for software releases and connected GitHub features. Slack, Discord, Linear, and other services receive data only when you choose to connect or use them.
Some providers may process data outside the European Economic Area. Where required, we use appropriate contractual safeguards for those transfers.
We do not sell personal data, share it with advertisers, or use it to train AI models.
## Retention and deletion
We keep account and organization data while your account remains active. We retain operational, workflow, and audit records while needed to provide and protect the service. Billing records may be kept for legally required accounting and tax periods. Short-lived authorization codes and sessions expire automatically.
You can request account deletion by emailing us. Some records may remain where the law requires it or where they form part of another organization's legitimate audit history.
## Cookies
The marketing website does not use analytics or advertising cookies. Hub uses only the session and security cookies needed to sign you in and operate your account.
## Your rights
Depending on applicable law, you may request access, correction, deletion, restriction, objection, or portability of your personal data. Email [hello@moboudra.com](mailto:hello@moboudra.com). You may also complain to the [Spanish Data Protection Agency](https://www.aepd.es/).
## Security
We use access controls, encrypted transport, and limited service permissions. No online service can guarantee absolute security. Read Paseo's [security model](https://github.com/getpaseo/paseo/blob/main/SECURITY.md) or report a vulnerability privately to [hello@moboudra.com](mailto:hello@moboudra.com).
## Children
The official services are for professional developers and are not directed at children under 16.
## Changes
We will update this page and its date when our services or data practices materially change.
+101
View File
@@ -0,0 +1,101 @@
---
title: "隐私政策"
description: "哪些数据留在你的机器上、加密中继能看到什么,以及 Paseo Hub 存储哪些数据。"
lastUpdated: "2026-08-29"
---
本中文版本为英文原文的翻译,仅供参考。如两者存在任何不一致,以英文版本为准。
Paseo 是本地优先的。安装或使用这款开源软件不会向我们发送你的代码、提示词、文件、终端输出或 Agent 对话。本政策说明本地 Paseo、可选的官方中继、托管的 Paseo Hub 以及 paseo.sh 各自独立的数据边界。
## 谁负责
**Mohamed Boudra Ziani**,以 Paseo 名义经营\
NIF/VAT ID:ES26617095T\
Roc Boronat 48, Bajos 2\
08005 Barcelona, Spain\
邮箱:[hello@moboudra.com](mailto:hello@moboudra.com)
Mohamed Boudra Ziani 是通过官方 Paseo 网站、中继和托管 Hub 处理的个人数据的数据控制者。独立自托管的 daemon、Hub 和中继由其运营者控制,不受本政策约束。
## 本地 Paseo 应用和 daemon
Paseo 在你的机器上运行。它不会向我们发送分析数据、遥测数据、广告标识符或崩溃报告。
打包的桌面应用会检查 GitHub Releases 以获取更新。GitHub 会依据其自身的隐私政策,接收响应该请求所需的常规网络信息。
Claude Code、Codex 和 OpenCode 等 Agent 使用你机器上的凭据与各自的 Provider 通信。Paseo 不管理也不拦截这些 Provider API 调用。
## 官方中继
中继是可选的。为了连接你的客户端和 daemon,它会处理:
- IP 地址和连接时间
- 会话标识符和公开握手密钥
- 消息大小和总带宽
- 临时连接和路由状态
你的客户端和 daemon 使用 NaCl box 加密对应用流量进行端到端加密。中继传输的是密文,无法读取你的代码、提示词、终端输出或 Agent 对话。有效载荷仅在转发期间存在于中继内存中。我们不存储消息内容。基础设施可能会保留有限的运维日志和汇总指标,用于安全、容量规划和故障排查。
## Paseo Hub
当你创建或使用托管 Hub 账户时,我们会处理:
- 你的姓名、邮箱、账户凭据、会话、IP 地址和用户代理
- 你的组织、成员、角色、邀请和 daemon 注册信息
- 你所连接服务的标识符和凭据
- 从这些服务接收的 webhook 事件、消息、评论、附件元数据及相关上下文
- 工作流配置、触发输入、输出、执行状态、活动和审计记录
- 提供访问所需的 Stripe 客户标识符和订阅信息
除非某个工作流明确将信息发送到 Hub 或已连接的服务,否则你的代码仓库、本地文件和 Agent Provider 凭据都保留在你自己的基础设施上。Hub 不提供 AI 推理。
## 谁控制工作流数据
Paseo 控制账户、账单、安全和服务运营数据。当某个组织使用 Hub 在其工作流中处理个人数据时,由该组织决定处理数据的目的,Paseo 代表该组织处理这些数据。
## 我们为什么处理数据
我们处理数据是为了:
- 提供账户、Hub 工作流、中继连接、账单和支持
- 对用户、daemon 和已连接的服务进行身份验证
- 防止滥用并保护服务
- 维护运营和审计记录
- 履行会计、税务及其他法律义务
法律依据是履行我们与你之间的合同、我们在运营和保护服务方面的正当利益,以及遵守法律义务。
## 服务提供商
我们使用 Fly.io 提供托管基础设施,使用 Stripe 处理订阅和付款,使用 GitHub 发布软件并提供已连接的 GitHub 功能。Slack、Discord、Linear 及其他服务仅在你选择连接或使用它们时才会接收数据。
部分服务提供商可能在欧洲经济区以外处理数据。在需要时,我们会为这些传输采取适当的合同保障措施。
我们不会出售个人数据、不会与广告商共享,也不会用于训练 AI 模型。
## 保留与删除
在你的账户保持有效期间,我们会保留账户和组织数据。在提供和保护服务所需的期间内,我们会保留运营、工作流和审计记录。账单记录可能会在法律要求的会计和税务期间内保留。短期授权码和会话会自动过期。
你可以发送邮件请求删除账户。在法律要求的情况下,或在记录构成其他组织合法审计历史的一部分时,部分记录可能会保留。
## Cookie
营销网站不使用分析或广告 Cookie。Hub 仅使用让你登录和运营账户所需的会话及安全 Cookie。
## 你的权利
根据适用法律,你可以请求访问、更正、删除、限制处理、反对处理你的个人数据,或请求数据可携带。请发送邮件至 [hello@moboudra.com](mailto:hello@moboudra.com)。你也可以向[西班牙数据保护局](https://www.aepd.es/)投诉。
## 安全
我们采用访问控制、加密传输和有限的服务权限。任何在线服务都无法保证绝对安全。请阅读 Paseo 的[安全模型](https://github.com/getpaseo/paseo/blob/main/SECURITY.md),或私下向 [hello@moboudra.com](mailto:hello@moboudra.com) 报告漏洞。
## 儿童
官方服务面向专业开发者,不针对 16 岁以下的儿童。
## 变更
当我们的服务或数据处理方式发生重大变化时,我们会更新本页面及其日期。
+97
View File
@@ -0,0 +1,97 @@
---
title: "Terms of Service"
description: "Terms for the official Paseo Relay and hosted Paseo Hub services."
lastUpdated: "2026-08-29"
---
These Terms govern the official services operated at paseo.sh, relay.paseo.sh, and hub.paseo.sh. By using the official relay or hosted Hub, you agree to them. Our [Privacy Policy](/privacy) explains how those services process data.
## Who provides the services
**Mohamed Boudra Ziani**, operating as Paseo\
NIF/VAT ID: ES26617095T\
Roc Boronat 48, Bajos 2\
08005 Barcelona, Spain\
Email: [hello@moboudra.com](mailto:hello@moboudra.com)
## Paseo's open-source software
Paseo is open-source software licensed under the Apache License 2.0. You can install, modify, and self-host it under that license without purchasing Paseo Hub or using the official relay.
These Terms do not replace or restrict the open-source license. They apply only to services operated by Paseo. A self-hosted Hub or relay is operated by whoever hosts it.
## The official relay
The relay is an optional service that connects Paseo clients to your daemon without requiring you to expose the daemon directly. Traffic is encrypted end-to-end between your client and daemon. The relay carries encrypted data but cannot read its contents.
The relay is subject to reasonable and fair use. We may limit bandwidth, connection volume, or abusive traffic when necessary to keep it reliable and secure.
## Paseo Hub
Hub lets you connect daemons, configure workflows, receive events from connected services, and instruct agents running on your infrastructure. Hub does not provide AI inference.
You are responsible for:
- The workflows, permissions, and connected services you configure
- The people you invite and the access you give them
- Actions performed by your agents and connected accounts
- Reviewing generated code and other agent output
- Maintaining backups of your repositories and local data
## Your content
You keep ownership of your prompts, workflow configuration, code, messages, and outputs. You give Paseo only the permission needed to receive, transmit, store, and process that content to operate the services you request.
We do not sell your content, use it for advertising, or use it to train AI models.
## Accounts and teams
Keep your account credentials secure. Organization owners and administrators are responsible for invitations, permissions, connected services, and activity performed by their members. If you use Hub for an organization, you confirm that you are authorized to act for it and to process the data sent through its workflows.
## Acceptable use
You must not use the services to:
- Break applicable law or infringe another person's rights
- Attack systems, distribute malware, or evade access controls
- Gain unauthorized access to another account, daemon, or service
- Interfere with the service or bypass reasonable usage limits
- Resell the official services without our written permission
## Trials, subscriptions, and payment
Current prices, taxes, billing periods, and seat calculations are shown before purchase. Trial terms are shown when you start a trial. A no-card trial ends unless you add a valid payment method before it expires.
Paid subscriptions renew automatically for the billing period shown at checkout until canceled. You can manage payment methods, invoices, and cancellation through the billing portal. Cancellation normally takes effect at the end of the current paid period. Payments are non-refundable except where the law requires otherwise.
Stripe processes payments. Paseo does not store complete payment-card details. Nothing in these Terms removes cancellation, refund, withdrawal, or other rights given to you by applicable consumer law.
## Third-party services
Paseo can connect to services such as GitHub, Slack, Discord, Linear, Anthropic, and OpenAI. Those services have their own terms and privacy policies. Paseo is not responsible for their availability, output, or handling of data.
## Availability and changes
We work to keep the official relay and Hub available, but do not promise uninterrupted service or a service level unless we agree one in writing. We may change features, introduce reasonable limits, or suspend access when needed for security, abuse prevention, legal compliance, or operation of the service.
Where practical, we will give reasonable notice before a change that materially reduces a paid service.
## Suspension and termination
You may stop using the services or cancel your subscription at any time. We may suspend access for non-payment, serious abuse, security threats, or a material breach of these Terms. Where practical, we will explain the reason and allow you to correct it.
## Warranty and liability
Software and automated agents can make mistakes. Review important actions and maintain appropriate backups.
To the extent permitted by law, the official services are provided as available and without implied warranties. Paseo is not liable for indirect or consequential losses caused by agent output, third-party services, or your workflow configuration. Our total liability relating to a paid service will not exceed the amount you paid for it during the preceding 12 months.
Nothing here limits liability or statutory rights that cannot legally be limited.
## Governing law
These Terms are governed by Spanish law. If you are a consumer, you keep the mandatory protections and court rights provided by the law where you live.
## Changes and contact
We may update these Terms as the services change. We will announce material changes through the service or by email where appropriate. Questions can be sent to [hello@moboudra.com](mailto:hello@moboudra.com).
+99
View File
@@ -0,0 +1,99 @@
---
title: "服务条款"
description: "官方 Paseo 中继和托管 Paseo Hub 服务的条款。"
lastUpdated: "2026-08-29"
---
本中文版本为英文原文的翻译,仅供参考。如两者存在任何不一致,以英文版本为准。
本条款适用于在 paseo.sh、relay.paseo.sh 和 hub.paseo.sh 上运营的官方服务。使用官方中继或托管 Hub,即表示你同意本条款。我们的[隐私政策](/privacy)说明了这些服务如何处理数据。
## 谁提供服务
**Mohamed Boudra Ziani**,以 Paseo 名义经营\
NIF/VAT ID:ES26617095T\
Roc Boronat 48, Bajos 2\
08005 Barcelona, Spain\
邮箱:[hello@moboudra.com](mailto:hello@moboudra.com)
## Paseo 开源软件
Paseo 是依据 Apache License 2.0 授权的开源软件。你可以在该许可证下安装、修改和自托管它,无需购买 Paseo Hub 或使用官方中继。
本条款不取代也不限制开源许可证,仅适用于由 Paseo 运营的服务。自托管的 Hub 或中继由其托管者运营。
## 官方中继
中继是一项可选服务,它将 Paseo 客户端连接到你的 daemon,而无需你直接暴露 daemon。流量在你的客户端和 daemon 之间进行端到端加密。中继传输加密数据,但无法读取其内容。
中继须遵守合理公平使用原则。在必要时,为保持其可靠和安全,我们可能会限制带宽、连接数量或滥用流量。
## Paseo Hub
Hub 让你可以连接 daemon、配置工作流、接收已连接服务的事件,并向运行在你基础设施上的 Agent 下达指令。Hub 不提供 AI 推理。
你需要对以下事项负责:
- 你配置的工作流、权限和已连接的服务
- 你邀请的人员以及你授予他们的访问权限
- 你的 Agent 和已连接账户执行的操作
- 审查生成的代码和其他 Agent 输出
- 维护代码仓库和本地数据的备份
## 你的内容
你保留对提示词、工作流配置、代码、消息和输出的所有权。你仅授予 Paseo 为运营你所请求的服务而接收、传输、存储和处理这些内容所需的许可。
我们不会出售你的内容、不会将其用于广告,也不会用它训练 AI 模型。
## 账户和团队
请妥善保管你的账户凭据。组织所有者和管理员对邀请、权限、已连接的服务及其成员的活动负责。如果你为某个组织使用 Hub,即表示你确认你有权代表该组织行事,并有权处理通过其工作流发送的数据。
## 可接受的使用
你不得使用这些服务:
- 违反适用法律或侵犯他人权利
- 攻击系统、传播恶意软件或规避访问控制
- 未经授权访问他人的账户、daemon 或服务
- 干扰服务或绕过合理的使用限制
- 未经我们书面许可转售官方服务
## 试用、订阅和付款
当前价格、税费、计费周期和席位计算方式会在购买前显示。试用条款会在你开始试用时显示。无需绑卡的试用会在到期时结束,除非你在到期前添加有效的付款方式。
付费订阅会按结账时显示的计费周期自动续订,直至取消。你可以通过账单门户管理付款方式、发票和取消订阅。取消通常在当前付费周期结束时生效。除法律另有要求外,付款不予退还。
付款由 Stripe 处理。Paseo 不存储完整的支付卡信息。本条款中的任何内容均不会剥夺适用的消费者保护法赋予你的取消、退款、撤回或其他权利。
## 第三方服务
Paseo 可以连接 GitHub、Slack、Discord、Linear、Anthropic 和 OpenAI 等服务。这些服务有各自的条款和隐私政策。Paseo 不对它们的可用性、输出或数据处理负责。
## 可用性和变更
我们努力保持官方中继和 Hub 可用,但除非另行书面约定,否则不承诺服务不中断或任何服务等级。在出于安全、防止滥用、法律合规或服务运营的需要时,我们可能会更改功能、引入合理限制或暂停访问。
在可行的情况下,对于会实质性削减付费服务的变更,我们会提前给予合理通知。
## 暂停和终止
你可以随时停止使用服务或取消订阅。如出现未付款、严重滥用、安全威胁或实质性违反本条款的情况,我们可能会暂停访问。在可行的情况下,我们会说明原因并允许你予以纠正。
## 保证和责任
软件和自动化 Agent 可能会犯错。请审查重要操作并保留适当的备份。
在法律允许的范围内,官方服务按现状提供,不附带任何默示保证。对于由 Agent 输出、第三方服务或你的工作流配置造成的间接或后果性损失,Paseo 不承担责任。我们就某项付费服务承担的总责任不超过你在此前 12 个月内为该服务支付的金额。
本条款中的任何内容均不限制依法不得限制的责任或法定权利。
## 适用法律
本条款受西班牙法律管辖。如果你是消费者,你仍享有你所在地法律规定的强制性保护和诉讼权利。
## 变更和联系方式
随着服务的变化,我们可能会更新本条款。在适当的情况下,我们会通过服务或电子邮件公布重大变更。如有疑问,请发送邮件至 [hello@moboudra.com](mailto:hello@moboudra.com)。
+2
View File
@@ -20,9 +20,11 @@
"@tanstack/react-start": "^1.168.26",
"framer-motion": "^12.35.2",
"github-slugger": "^2.0.0",
"i18next": "^26.3.0",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.3",
@@ -0,0 +1,7 @@
---
title: "你好,世界"
description: "Paseo 博客的第一篇文章。"
date: "2026-03-29"
---
欢迎来到 Paseo 博客。
@@ -0,0 +1,48 @@
---
title: "我对 Electron 的看法错了"
description: "当小体积安装包不再比渲染、通知和打包 Node daemon 更重要时,我为什么把 Paseo 从 Tauri 迁移到了 Electron。"
date: "2026-05-28"
draft: "false"
---
开始构建 Paseo 时,我选择了 Tauri。
当时的理由看起来显而易见。我在做一个使用 Web 界面的桌面应用,希望交付的东西不显得臃肿。和很多开发者一样,我也早已默认 Electron 是那个不好的选项。
Tauri 具备所有对的东西:Rust、小体积二进制、原生 WebView、更低的内存占用。它看起来是更好的选择。
我还经常在产品落地页上看到 “Built with Tauri”,我想这对我的影响比我意识到的更大。它让 Tauri 显得像是酷小孩们都在用的东西。
我以为自己比那些 Electron 应用更懂行。一开始,我似乎也确实是对的。在 macOS 上,应用体积很小,界面运行良好,安装包的大小让我自我感觉良好。
让应用在 Windows 上运行起来也没什么问题。Tauri 在那里使用基于 Chromium 的 WebView2,所以应用的表现与我的预期足够接近。确实有一些平台差异要处理,但都不是 Tauri 特有的。
麻烦是从 Linux 开始的。
Tauri 不会在各个平台上捆绑同一个浏览器引擎,这正是它的设计初衷。在 Linux 上,它依赖 WebKitGTK。这听起来很优雅,直到你开始在不同发行版、不同 GPU 配置以及 Wayland/X11 的差异之间调试渲染行为。
对 Paseo 来说,这变成了大量我并不想做的产品工作。Tauri 使用的 WebKitGTK 绑定太旧了。Wayland 有问题。等我终于让应用跑起来,它看起来就是不一样。不只是字重或抗锯齿这类小事,有些页面的布局确实存在差异。
这些我也许都能忍,但随后我开始实现通知功能。
对 Paseo 来说,通知不是锦上添花。当一个 Agent 完成、失败或需要关注时,我希望用户点击通知就能跳转到正确的位置。
Tauri 的通知插件可以显示通知,但[桌面端的点击处理](https://github.com/tauri-apps/plugins-workspace/issues/2150)并没有以我需要的方式提供。有一个叫 Actions API 的东西可以给通知附加回调,但文档把它标注为仅限移动端,这很奇怪。
这让我注意到 Tauri 正在向移动应用扩展,很难不产生一些疑虑。我还在和最基本的桌面产品问题较劲,而这个框架已经转向了完全不同的方向。
我尝试了一堆 Rust crate,但没有一个能按我想要的方式工作,最后只好为每个平台分别编写通知处理逻辑。这并不简单,而且现在我还得维护它。
还有 daemon 的问题。这部分其实不能怪 Tauri,但它让我进一步倒向了 Electron。
Paseo 有一个 Node.js daemon,而我想要一键式的体验:下载应用、打开、开始使用。我不想告诉用户“先去运行这条命令”,也不想向他们解释什么是 “daemon”。
Tauri 支持 sidecar,所以我把 daemon 打包了进去,它也能工作。但这本身就成了一个项目:不同平台和目标三元组需要不同的二进制文件,还有打包细节、权限、进程启动、路径和升级。
某一刻我意识到,我其实是在多绕几步重新造一个 Electron。
于是我决定试试 Electron。迁移过程有点痛苦,但我出乎意料地很快就让一切跑了起来。经过一周扎实的工作,Paseo 反而变得更轻、更简单了。界面在各个平台上看起来都一样。通知的表现符合我的需要。daemon 也更简单了,因为 Electron 已经内置了 Node。
总的来说,从那以后我对 Electron 一直很满意。它大多数时候不会碍事。出现性能问题时,原因往往在我的应用代码,而不是 Electron 本身。我想这也是 Electron 名声不好的原因:做出一个臃肿的 Electron 应用实在太容易了。
我的结论并不是 Tauri 不好。它只是不适合 Paseo,而我做出那个选择更多是凭感觉,而不是客观地权衡利弊。
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { getChangelogReleaseGroups, localizeReleases, parseChangelog } from "~/changelog";
const ENGLISH = parseChangelog(
[
"# Changelog",
"",
"## 0.2.0 - 2026-05-02",
"",
"- New",
"",
"## 0.1.0 - 2026-05-01",
"",
"- Old",
].join("\n"),
"en",
);
describe("localizeReleases", () => {
it("uses a translation whose version and date match the English entry", () => {
const chinese = parseChangelog("## 0.1.0 - 2026-05-01\n\n- 旧\n", "zh-CN");
expect(localizeReleases(ENGLISH, chinese).map((release) => release.locale)).toEqual([
"en",
"zh-CN",
]);
});
it("shows a release in English when its notes changed after translation", () => {
const stale = parseChangelog("## 0.2.0 - 2026-04-30\n\n- 新\n", "zh-CN");
expect(localizeReleases(ENGLISH, stale)[0]).toBe(ENGLISH[0]);
});
it("drops a translation whose English entry was renamed, as when a beta is promoted", () => {
const beta = parseChangelog("## 0.2.0-beta.1 - 2026-05-02\n\n- 新\n", "zh-CN");
expect(localizeReleases(ENGLISH, beta)).toEqual(ENGLISH);
});
it("refuses a duplicated version", () => {
expect(() => parseChangelog("## 1.0.0 - 2026-05-01\n## 1.0.0 - 2026-05-01\n", "zh-CN")).toThrow(
"Duplicate changelog version: 1.0.0",
);
});
});
function publishedVersions(locale: "en" | "zh-CN"): string[] {
return getChangelogReleaseGroups(locale)
.flatMap((group) => group.releases)
.map((release) => `${release.version} ${release.date}`);
}
describe("the published changelog", () => {
it("lists the same releases in every language", () => {
expect(publishedVersions("zh-CN")).toEqual(publishedVersions("en"));
});
});
+40 -9
View File
@@ -1,9 +1,13 @@
import changelogMarkdown from "../../../CHANGELOG.md?raw";
import englishChangelog from "../../../CHANGELOG.md?raw";
import chineseChangelog from "../../../CHANGELOG.zh-CN.md?raw";
import { DEFAULT_LOCALE, type Locale, localizePath } from "~/i18n";
export interface ChangelogRelease {
version: string;
date: string;
markdown: string;
/** The language of `markdown`; English when the release has no translation yet. */
locale: Locale;
}
export interface ChangelogReleaseGroup {
@@ -21,7 +25,7 @@ function minorVersion(version: string): string {
return version.split(".").slice(0, 2).join(".");
}
function parseChangelog(markdown: string): ChangelogRelease[] {
export function parseChangelog(markdown: string, locale: Locale): ChangelogRelease[] {
const releases: ChangelogRelease[] = [];
const versions = new Set<string>();
let currentRelease: ChangelogRelease | null = null;
@@ -40,6 +44,7 @@ function parseChangelog(markdown: string): ChangelogRelease[] {
version,
date: heading[2],
markdown: "",
locale,
};
continue;
}
@@ -51,6 +56,24 @@ function parseChangelog(markdown: string): ChangelogRelease[] {
return releases;
}
/**
* The English releases, each replaced by its translation when one exists. A translation counts
* only when it carries the same version and date as the English entry, so a stale translation never
* stands in for newer notes. A release without a current translation is shown whole in English, and
* a translation whose English entry was renamed or removed, such as a promoted beta, is dropped:
* a release never waits on its translation.
*/
export function localizeReleases(
english: readonly ChangelogRelease[],
translated: readonly ChangelogRelease[],
): ChangelogRelease[] {
const byVersion = new Map(translated.map((release) => [release.version, release]));
return english.map((release) => {
const translation = byVersion.get(release.version);
return translation?.date === release.date ? translation : release;
});
}
function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseGroup[] {
const groups: ChangelogReleaseGroup[] = [];
@@ -67,9 +90,18 @@ function groupChangelogReleases(releases: ChangelogRelease[]): ChangelogReleaseG
return groups;
}
const changelogReleases = parseChangelog(changelogMarkdown);
const englishReleases = parseChangelog(englishChangelog, DEFAULT_LOCALE);
export const changelogReleaseGroups = groupChangelogReleases(changelogReleases);
const releaseGroups: Record<Locale, ChangelogReleaseGroup[]> = {
en: groupChangelogReleases(englishReleases),
"zh-CN": groupChangelogReleases(
localizeReleases(englishReleases, parseChangelog(chineseChangelog, "zh-CN")),
),
};
export function getChangelogReleaseGroups(locale: Locale): ChangelogReleaseGroup[] {
return releaseGroups[locale];
}
/**
* Deep link from a downloadable version to its notes.
@@ -81,9 +113,8 @@ export const changelogReleaseGroups = groupChangelogReleases(changelogReleases);
* changelog commit hasn't been deployed yet) get the plain changelog, never a
* dead anchor.
*/
export function changelogLink(version: string): { to: "/changelog"; hash?: string } {
const known = changelogReleases.some((release) => release.version === version);
return known
? { to: "/changelog", hash: releaseAnchor(minorVersion(version)) }
: { to: "/changelog" };
export function changelogLink(version: string, locale: Locale): { to: string; hash?: string } {
const to = localizePath("/changelog", locale);
const known = englishReleases.some((release) => release.version === version);
return known ? { to, hash: releaseAnchor(minorVersion(version)) } : { to };
}
@@ -1,12 +1,24 @@
import { useTranslation } from "react-i18next";
import { LandingPage } from "~/components/landing-page";
import { getAgentPage } from "~/data/agent-pages";
import { type AgentPageSlug, getAgentPage } from "~/data/agent-pages";
import { getTranslator, routeLocale } from "~/i18n";
import { pageMeta } from "~/meta";
export function agentRouteOptions(slug: string) {
const page = getAgentPage(slug);
export function agentRouteOptions(slug: AgentPageSlug) {
return {
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
head: ({ params }: { params: { locale?: string } }) => {
const locale = routeLocale(params);
const page = getAgentPage(slug, getTranslator(locale));
return pageMeta({
title: page.metaTitle,
description: page.metaDescription,
path: `/${slug}`,
locale,
});
},
component: function AgentLandingPage() {
const { t } = useTranslation();
const page = getAgentPage(slug, t);
return <LandingPage title={page.title} subtitle={page.subtitle} />;
},
};
@@ -1,18 +1,23 @@
import { useTranslation } from "react-i18next";
import { DocsMarkdown } from "~/components/docs-markdown";
import { SiteShell } from "~/components/site-shell";
import { UntranslatedNotice } from "~/components/untranslated-notice";
import { getAlternativePage } from "~/data/alternative-pages";
import { getTranslator, routeLocale, useLocale } from "~/i18n";
import { pageMeta } from "~/meta";
export function alternativeRouteOptions(slug: string) {
const page = getAlternativePage(slug);
return {
head: () =>
pageMeta(
page?.title ?? "Alternative - Paseo",
page?.description ?? "",
`/alternatives/${slug}`,
),
head: ({ params }: { params: { locale?: string } }) => {
const locale = routeLocale(params);
const page = getAlternativePage(slug, locale);
return pageMeta({
title: page?.title ?? getTranslator(locale)("common.notFoundTitle"),
description: page?.description ?? "",
path: `/alternatives/${slug}`,
locale,
});
},
component: function AlternativePageRoute() {
return <AlternativePageContent slug={slug} />;
},
@@ -20,20 +25,22 @@ export function alternativeRouteOptions(slug: string) {
}
function AlternativePageContent({ slug }: { slug: string }) {
const page = getAlternativePage(slug);
const { t } = useTranslation();
const page = getAlternativePage(slug, useLocale());
if (!page) {
return (
<SiteShell width="default">
<p className="text-muted-foreground">Page not found.</p>
<p className="text-muted-foreground">{t("common.notFound")}</p>
</SiteShell>
);
}
return (
<SiteShell width="default">
<UntranslatedNotice contentLocale={page.locale} />
<div className="comparison-prose">
<DocsMarkdown>{page.content}</DocsMarkdown>
<DocsMarkdown lang={page.locale}>{page.content}</DocsMarkdown>
</div>
</SiteShell>
);
@@ -1,15 +1,19 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLocalizePath } from "~/i18n";
export interface BreadcrumbItem {
label: string;
/** Internal path. The last item is the current page and has no href. */
/** Language-neutral internal path. The last item is the current page and has no href. */
href?: string;
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<nav aria-label="Breadcrumb" className="not-prose mb-6">
<nav aria-label={t("common.breadcrumb")} className="not-prose mb-6">
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{items.map((item, index) => {
const last = index === items.length - 1;
@@ -17,7 +21,7 @@ export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
<li key={`${item.href ?? ""}#${item.label}`} className="flex items-center gap-2">
{index > 0 && <ChevronRight size={14} className="text-border" />}
{item.href && !last ? (
<Link to={item.href} className="hover:text-foreground transition-colors">
<Link to={localize(item.href)} className="hover:text-foreground transition-colors">
{item.label}
</Link>
) : (
@@ -1,4 +1,5 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
interface CodeBlockProps {
children: string;
@@ -11,6 +12,7 @@ const SIZE_PADDING: Record<NonNullable<CodeBlockProps["size"]>, string> = {
};
export function CodeBlock({ children, size = "md" }: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(() => {
@@ -31,7 +33,7 @@ export function CodeBlock({ children, size = "md" }: CodeBlockProps) {
type="button"
onClick={handleCopy}
className="text-muted-foreground hover:text-foreground transition-colors p-1"
title="Copy to clipboard"
title={t("common.copyToClipboard")}
>
{copied ? (
<svg
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs";
import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs";
@@ -8,13 +9,15 @@ interface DocsBreadcrumbsProps {
}
export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) {
const { t } = useTranslation();
const docsLabel = t("nav.docs");
const items = useMemo<BreadcrumbItem[]>(
() => [
{ label: "Docs", href: "/docs" },
{ label: docsLabel, href: "/docs" },
...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })),
{ label: doc.frontmatter.nav },
],
[doc, tree],
[doc, docsLabel, tree],
);
return <Breadcrumbs items={items} />;
}
@@ -1,5 +1,6 @@
import { Check, Copy, FileText } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface DocsMarkdownActionsProps {
content: string;
@@ -7,6 +8,7 @@ interface DocsMarkdownActionsProps {
}
export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActionsProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
useEffect(() => {
@@ -27,7 +29,7 @@ export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActio
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy as markdown"}
{copied ? t("docs.copied") : t("docs.copyMarkdown")}
</button>
<a
href={markdownHref}
@@ -36,7 +38,7 @@ export function DocsMarkdownActions({ content, markdownHref }: DocsMarkdownActio
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<FileText size={14} />
View as markdown
{t("docs.viewMarkdown")}
</a>
</div>
);
@@ -1,6 +1,8 @@
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import { useTranslation } from "react-i18next";
import { openEnclosingExamples } from "~/docs-example-target";
import { type Locale, localizePath, useLocale } from "~/i18n";
import { getDocsHighlighter, docsRehypePlugins, docsRemarkPlugins } from "~/docs-rehype";
function getCodeText(children: React.ReactNode): string {
@@ -65,9 +67,15 @@ function DocsPre({
return <pre {...props}>{children}</pre>;
}
const docsMarkdownComponents: Components = {
pre: DocsPre,
};
/** Links to other site pages stay in the reader's language; files and external URLs are unchanged. */
function markdownComponents(locale: Locale): Components {
return {
pre: DocsPre,
a: ({ href, node: _node, ...props }) => (
<a {...props} href={href === undefined ? undefined : localizePath(href, locale)} />
),
};
}
// A collapsed example hides everything inside it, so a link to one scrolls to
// nothing until the block is open.
@@ -91,15 +99,21 @@ function useHashTarget(content: string): void {
}, [content]);
}
export function DocsMarkdown({ children }: { children: string }) {
/** Renders Markdown written in `lang`, which differs from the page's language for a fallback. */
export function DocsMarkdown({ children, lang }: { children: string; lang: Locale }) {
useHashTarget(children);
const { t } = useTranslation();
const locale = useLocale();
const anchorLabel = t("docs.headingAnchor");
const rehypePlugins = React.useMemo(() => docsRehypePlugins(anchorLabel), [anchorLabel]);
const components = React.useMemo(() => markdownComponents(locale), [locale]);
return (
<div className="docs-prose">
<div className="docs-prose" lang={lang}>
<ReactMarkdown
remarkPlugins={docsRemarkPlugins}
rehypePlugins={docsRehypePlugins}
components={docsMarkdownComponents}
rehypePlugins={rehypePlugins}
components={components}
>
{children}
</ReactMarkdown>
+12 -5
View File
@@ -1,7 +1,9 @@
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type DocsNavNode } from "~/docs";
import { splitLocalePath, useLocalizePath } from "~/i18n";
interface DocsNavProps {
nodes: DocsNavNode[];
@@ -47,13 +49,14 @@ function PageLink({
onNavigate?: () => void;
}) {
const location = useLocation();
const isActive = location.pathname === node.href;
const href = useLocalizePath()(node.href);
const isActive = location.pathname === href;
const ref = useScrollIntoViewWhenActive(isActive);
return (
<Link
ref={ref}
to={node.href}
to={href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={onNavigate}
data-docs-nav-active={isActive ? "" : undefined}
@@ -80,7 +83,8 @@ function GroupNode({
onNavigate?: () => void;
}) {
const location = useLocation();
const currentHref = location.pathname;
const localize = useLocalizePath();
const currentHref = splitLocalePath(location.pathname).path;
const containsActive = useMemo(() => nodeContainsHref(node, currentHref), [node, currentHref]);
const isActive = node.href === currentHref;
const ref = useScrollIntoViewWhenActive(isActive);
@@ -129,7 +133,7 @@ function GroupNode({
) : (
<Link
ref={ref}
to={node.href}
to={localize(node.href)}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={handleClick}
aria-expanded={hasChildren ? isOpen : undefined}
@@ -158,9 +162,12 @@ function CategoryNode({
mobile?: boolean;
onNavigate?: () => void;
}) {
const { t } = useTranslation();
// Categories are named in the English docs' frontmatter; the page language translates the name.
const label = t(`docs.categories.${node.label}`, { defaultValue: node.label });
return (
<div className={mobile ? "space-y-1" : "space-y-1 mt-6 first:mt-0"}>
<div className="px-3 py-2 text-xs font-medium text-foreground">{node.label}</div>
<div className="px-3 py-2 text-xs font-medium text-foreground">{label}</div>
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
);
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type DocHeading } from "~/docs";
import { openEnclosingExamples } from "~/docs-example-target";
@@ -53,6 +54,7 @@ function OutlineLink({
}
export function DocsOutline({ headings }: DocsOutlineProps) {
const { t } = useTranslation();
const [activeId, setActiveId] = useState<string | null>(() =>
headings.length > 0 ? headings[0].id : null,
);
@@ -91,10 +93,12 @@ export function DocsOutline({ headings }: DocsOutlineProps) {
return (
<nav
aria-label="On this page"
aria-label={t("docs.onThisPage")}
className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4"
>
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">On this page</div>
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">
{t("docs.onThisPage")}
</div>
<ul className="space-y-1 border-l border-border">
{headings.map((heading) => (
<li key={heading.id} className="text-sm">
@@ -1,9 +1,11 @@
import { ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Doc } from "~/docs";
const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main";
export function DocsSourceFooter({ doc }: { doc: Doc }) {
const { t } = useTranslation();
const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`;
return (
@@ -14,7 +16,7 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) {
rel="noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-white/60 underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
View on GitHub
{t("docs.viewOnGitHub")}
<ExternalLink aria-hidden="true" className="size-3.5" />
</a>
</footer>
@@ -1,5 +1,6 @@
import { motion } from "framer-motion";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DEFAULT_MOCKUP_STATE,
DESIGN_HEIGHT,
@@ -10,12 +11,10 @@ import {
ScaledMockup,
} from "~/components/mockup";
const ALT =
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
export function HeroMockup() {
const { t } = useTranslation();
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
@@ -23,10 +22,10 @@ export function HeroMockup() {
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
{MOCKUP_STATES.map((option) => (
<StatePill
key={option.id}
id={option.id}
label={option.label}
selected={option.id === state}
key={option}
id={option}
label={t(`landing.mockup.${option}`)}
selected={option === state}
onSelect={setState}
/>
))}
@@ -36,7 +35,7 @@ export function HeroMockup() {
width={DESIGN_WIDTH}
height={DESIGN_HEIGHT}
borderRadius={18}
label={ALT}
label={t("landing.mockup.alt")}
className="ring-1 ring-white/10"
>
<MockupWindow state={state} />
+204 -239
View File
@@ -1,5 +1,6 @@
import { PLUGINS_LINKED } from "~/plugins/launch";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import {
ArrowRight,
Blocks,
@@ -73,6 +74,7 @@ import type { DesktopPlatform, MobilePlatform } from "~/platform";
import { isMobilePlatform } from "~/platform";
import { useRelease, useVisitorPlatform } from "~/routes/__root";
import { HeroMockup } from "~/components/hero-mockup";
import { useLocale, useLocalizePath } from "~/i18n";
import {
AntigravityIcon,
ClaudeCodeIcon,
@@ -356,6 +358,7 @@ function SectionTitle({
}
function SocialProofWall() {
const { t } = useTranslation();
return (
<motion.section
initial={FADE_IN_UP}
@@ -364,8 +367,8 @@ function SocialProofWall() {
transition={EASE_OUT_05}
>
<SectionTitle
title="Loved by developers"
description="See what developers are saying about Paseo"
title={t("landing.socialProof.title")}
description={t("landing.socialProof.description")}
/>
<div className="social-proof-marquee space-y-4 overflow-hidden">
@@ -403,6 +406,7 @@ function SocialProofRow({
}
function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: boolean }) {
const { t } = useTranslation();
return (
<a
href={tweet.url}
@@ -410,7 +414,7 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
rel="noreferrer"
tabIndex={inert ? -1 : undefined}
className="group flex h-[154px] w-[320px] shrink-0 flex-col justify-between overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-colors hover:border-white/20 hover:bg-white/[0.05] md:w-[420px]"
aria-label={`Read ${tweet.name}'s original post`}
aria-label={t("landing.socialProof.readPost", { name: tweet.name })}
>
<div>
<div className="flex min-w-0 items-center gap-3">
@@ -434,10 +438,12 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
}
function MultiProviderSection() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<FeatureSection
title="Works with your tools"
description="Bring your subscriptions, skills and configuration"
title={t("landing.providers.title")}
description={t("landing.providers.description")}
>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-4">
{FEATURED_AGENTS.map((agent) => (
@@ -452,10 +458,12 @@ function MultiProviderSection() {
</div>
))}
<a
href="/agents"
href={localize("/agents")}
className="flex items-center justify-center gap-2 rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-3 py-3 text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.03] transition-colors sm:gap-3 sm:px-5 sm:py-4"
>
<span className="text-sm font-medium sm:text-base">+{ADDITIONAL_AGENT_COUNT} more</span>
<span className="text-sm font-medium sm:text-base">
{t("common.more", { count: ADDITIONAL_AGENT_COUNT })}
</span>
</a>
</div>
</FeatureSection>
@@ -463,38 +471,40 @@ function MultiProviderSection() {
}
function TurnkeySection() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<FeatureSection
title="Run it anywhere"
description="Use Paseo locally, from another machine, or with a team"
title={t("landing.runAnywhere.title")}
description={t("landing.runAnywhere.description")}
>
<div className="space-y-8">
<ProductLead
icon={Monitor}
title="Desktop app"
description="The one click experience, download the app and it just works, no server required"
title={t("landing.runAnywhere.desktopTitle")}
description={t("landing.runAnywhere.desktopDescription")}
/>
<div className="grid gap-4 md:grid-cols-3">
<TurnkeyExtensionCard
icon={Smartphone}
title="Mobile"
description="Full client and native, not a WebView"
ctaHref="/download"
ctaLabel="Download"
title={t("landing.runAnywhere.mobileTitle")}
description={t("landing.runAnywhere.mobileDescription")}
ctaHref={localize("/download")}
ctaLabel={t("landing.runAnywhere.mobileCta")}
/>
<TurnkeyExtensionCard
icon={Laptop}
title="Remote machines"
description="Run Paseo on a home lab, or a cloud machine"
ctaHref="/docs#server--cli"
ctaLabel="Docs"
title={t("landing.runAnywhere.remoteTitle")}
description={t("landing.runAnywhere.remoteDescription")}
ctaHref={localize("/docs#server--cli")}
ctaLabel={t("landing.runAnywhere.remoteCta")}
/>
<TurnkeyExtensionCard
icon={Users}
title="Teams and triggers"
description="Share access or start work from GitHub, Slack, and Discord"
ctaHref="/hub"
ctaLabel="Paseo Hub"
title={t("landing.runAnywhere.teamsTitle")}
description={t("landing.runAnywhere.teamsDescription")}
ctaHref={localize("/hub")}
ctaLabel={t("landing.runAnywhere.teamsCta")}
showIntegrationIcons
/>
</div>
@@ -601,43 +611,31 @@ type AutomationKind = "mcp" | "cli" | "sdk";
const AUTOMATION_OPTIONS: Array<{
kind: AutomationKind;
label: string;
caption: string;
icon: LucideIcon;
}> = [
{
kind: "mcp",
label: "MCP",
caption: "From another agent",
icon: Bot,
},
{
kind: "cli",
label: "CLI",
caption: "From the terminal",
icon: Terminal,
},
{
kind: "sdk",
label: "SDK",
caption: "From code",
icon: Braces,
},
{ kind: "mcp", label: "MCP", icon: Bot },
{ kind: "cli", label: "CLI", icon: Terminal },
{ kind: "sdk", label: "SDK", icon: Braces },
];
const AUTOMATION_LINKS = [
{ href: "/docs/mcp", label: "MCP docs" },
{ href: "/docs/cli", label: "CLI docs" },
{ href: "/docs/sdk", label: "SDK docs" },
] as const;
function AutomationSection() {
const { t } = useTranslation();
const localize = useLocalizePath();
const [activeKind, setActiveKind] = React.useState<AutomationKind>("mcp");
const links = React.useMemo(
() =>
AUTOMATION_OPTIONS.map((option) => ({
href: localize(`/docs/${option.kind}`),
label: t(`landing.automation.${option.kind}Docs`),
})),
[localize, t],
);
return (
<FeatureSection
title="Built for automation"
description="Use MCP, the CLI, or the TypeScript SDK to automate Paseo"
links={AUTOMATION_LINKS}
title={t("landing.automation.title")}
description={t("landing.automation.description")}
links={links}
>
<div className="grid gap-4 md:grid-cols-[14rem_minmax(0,1fr)]">
<div className="grid self-start gap-2" role="tablist">
@@ -665,6 +663,7 @@ function AutomationSelector({
active: boolean;
onSelect: (kind: AutomationKind) => void;
}) {
const { t } = useTranslation();
const Icon = option.icon;
const handleClick = React.useCallback(() => onSelect(option.kind), [onSelect, option.kind]);
@@ -684,7 +683,9 @@ function AutomationSelector({
<Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="text-[10px]">{option.label}</span>
</div>
<p className="text-xs leading-snug text-white/85 md:text-sm">{option.caption}</p>
<p className="text-xs leading-snug text-white/85 md:text-sm">
{t(`landing.automation.${option.kind}Caption`)}
</p>
</button>
);
}
@@ -703,25 +704,26 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
}
function McpAutomationTranscript() {
const { t } = useTranslation();
return (
<div className="space-y-5">
<div className="ml-auto w-fit max-w-xl rounded-xl rounded-tr-none bg-white/[0.07] px-4 py-3">
<p className="text-sm leading-relaxed text-white/75">
Take the open GitHub issues labeled ready and fan them out to separate worktree agents.
{t("landing.automation.transcriptRequest")}
</p>
</div>
<div className="flex items-start gap-3">
<Bot className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
<div className="min-w-0 flex-1 space-y-4">
<p className="text-sm leading-relaxed text-white/55">
I found two ready issues. I will run each in its own worktree.
{t("landing.automation.transcriptPlan")}
</p>
<div className="space-y-2 font-mono text-[11px]">
<McpAgentCall issue="#412" provider="claude/opus-4.6" />
<McpAgentCall issue="#417" provider="codex/gpt-5.6-sol" />
</div>
<p className="text-sm leading-relaxed text-white/55">
Done, two agents are running. I will let you know when they finish.
{t("landing.automation.transcriptDone")}
</p>
</div>
</div>
@@ -741,6 +743,7 @@ function McpAgentCall({ issue, provider }: { issue: string; provider: string })
}
function CliAutomationExample() {
const { t } = useTranslation();
return (
<div className="font-mono text-[11px] leading-5 text-white/60">
<div className="space-y-6">
@@ -765,7 +768,7 @@ function CliAutomationExample() {
</div>
<div>
<div className="text-white/30"># Target another host</div>
<div className="text-white/30">{t("landing.automation.otherHostComment")}</div>
<ShellPrompt>
<span className="text-white">paseo ls</span>{" "}
<span className="text-sky-300/75">--host</span>{" "}
@@ -832,20 +835,27 @@ function SdkAutomationExample() {
}
function ExtensibleSection() {
const { t } = useTranslation();
const pluginActions = React.useMemo(() => <PluginActions />, []);
return (
<FeatureSection
title="Extend it with plugins"
description="Add new panels and functionality that work in desktop, web and mobile"
title={t("landing.plugins.title")}
description={t("landing.plugins.description")}
>
<div className="space-y-8">
<ProductLead icon={Puzzle} title="Plugins" actions={PLUGIN_ACTIONS} />
<ProductLead icon={Puzzle} title={t("landing.plugins.lead")} actions={pluginActions} />
<div className="grid gap-4 md:grid-cols-3">
{PLUGIN_CAPABILITIES.map((capability) => (
<FeatureCard key={capability.title} {...capability} />
<FeatureCard
key={capability.key}
icon={capability.icon}
title={t(`landing.plugins.${capability.key}Title`)}
description={t(`landing.plugins.${capability.key}Description`)}
/>
))}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/45">Paseo is Apache 2.0.</p>
<p className="text-sm text-white/45">{t("landing.plugins.license")}</p>
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
@@ -853,7 +863,7 @@ function ExtensibleSection() {
className="inline-flex shrink-0 items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<GitFork className="h-4 w-4" strokeWidth={1.5} />
Fork the repo
{t("landing.plugins.fork")}
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
</a>
</div>
@@ -866,89 +876,59 @@ const PLUGINS_BROWSE = PLUGINS_LINKED
? { href: "/plugins", external: false }
: { href: "https://paseo.cafe", external: true };
const PLUGIN_ACTIONS = (
<>
<a
href={PLUGINS_BROWSE.href}
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
>
Browse plugins
<ArrowRight className="h-3.5 w-3.5" />
</a>
<a
href="/docs/plugins"
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
>
Plugin docs
</a>
</>
);
function PluginActions() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<>
<a
href={localize(PLUGINS_BROWSE.href)}
{...(PLUGINS_BROWSE.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex items-center gap-1.5 rounded-full bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground/90"
>
{t("landing.plugins.browse")}
<ArrowRight className="h-3.5 w-3.5" />
</a>
<a
href={localize("/docs/plugins")}
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 px-3 py-1.5 text-xs text-white/80 transition-colors hover:bg-white/10"
>
{t("landing.plugins.docs")}
</a>
</>
);
}
const PLUGIN_CAPABILITIES: ReadonlyArray<{
icon: LucideIcon;
title: string;
description: string;
}> = [
{
icon: LayoutPanelLeft,
title: "UI",
description: "Screens, workspace panels, sidebar items, slash commands, and themes",
},
{
icon: Workflow,
title: "Agent lifecycle",
description: "Change config and MCP servers, answer permissions, and follow up",
},
{
icon: Bot,
title: "Providers",
description: "Add a coding agent as a provider, used like the built-in ones",
},
];
const PLUGIN_CAPABILITIES = [
{ icon: LayoutPanelLeft, key: "ui" },
{ icon: Workflow, key: "lifecycle" },
{ icon: Bot, key: "providers" },
] as const;
const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [
{
icon: Lock,
title: "Private",
description: "No telemetry, tracking, or forced login. Code stays on your machine.",
},
{
icon: Compass,
title: "Independent",
description: "Paseo doesn't answer to investors. Its users guide what gets built.",
},
{
icon: Gem,
title: "Polished",
description: "Install the app and start working. No need to know what a daemon is.",
},
{
icon: Merge,
title: "Unified",
description: "Every coding agent, used the same way. Switch providers any time.",
},
{
icon: Blocks,
title: "Composable",
description: "Run agents on a laptop or a server, and connect from any device.",
},
{
icon: Puzzle,
title: "Extensible",
description: "If Paseo doesn't fit how you work, change it with a plugin or fork.",
},
];
const PRINCIPLES = [
{ icon: Lock, key: "private" },
{ icon: Compass, key: "independent" },
{ icon: Gem, key: "polished" },
{ icon: Merge, key: "unified" },
{ icon: Blocks, key: "composable" },
{ icon: Puzzle, key: "extensible" },
] as const;
function PhilosophySection() {
const { t } = useTranslation();
return (
<FeatureSection
title="Philosophy"
description="What Paseo stands for, and what every feature is built on"
title={t("landing.philosophy.title")}
description={t("landing.philosophy.description")}
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{PRINCIPLES.map((principle) => (
<FeatureCard key={principle.title} {...principle} />
<FeatureCard
key={principle.key}
icon={principle.icon}
title={t(`landing.philosophy.${principle.key}Title`)}
description={t(`landing.philosophy.${principle.key}Description`)}
/>
))}
</div>
</FeatureSection>
@@ -956,6 +936,8 @@ function PhilosophySection() {
}
function GetStarted() {
const { t } = useTranslation();
const localize = useLocalizePath();
const platform = useVisitorPlatform();
return (
<div className="pt-10">
@@ -974,17 +956,17 @@ function GetStarted() {
</div>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 pt-6">
<span className="text-xs text-muted-foreground">Supports</span>
<span className="text-xs text-muted-foreground">{t("landing.getStarted.supports")}</span>
<div className="flex items-center gap-1">
{FEATURED_AGENTS.map((agent) => (
<AgentBadge key={agent.name} name={agent.name} Icon={agent.Icon} />
))}
</div>
<a
href="/agents"
href={localize("/agents")}
className="whitespace-nowrap text-xs text-muted-foreground hover:text-foreground transition-colors"
>
+{ADDITIONAL_AGENT_COUNT} more
{t("common.more", { count: ADDITIONAL_AGENT_COUNT })}
</a>
</div>
</div>
@@ -997,23 +979,25 @@ const SECONDARY_CTA_CLASS =
"inline-flex items-center justify-center gap-2 rounded-lg border border-white/12 px-3 py-2.5 text-sm text-white hover:bg-white/10 transition-colors";
function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) {
const download = getDesktopDownload(useRelease(), platform);
const { t } = useTranslation();
const download = getDesktopDownload(useRelease(), platform, useLocale());
const Icon = download.icon;
return (
<a href={download.href} className={PRIMARY_CTA_CLASS}>
<Icon className="h-4 w-4" />
Download for {download.label}
{t("landing.getStarted.downloadFor", { platform: download.label })}
</a>
);
}
function StoreButton({ platform }: { platform: MobilePlatform }) {
const { t } = useTranslation();
const store = MOBILE_STORES[platform];
const Icon = store.icon;
return (
<a href={store.href} target="_blank" rel="noopener noreferrer" className={PRIMARY_CTA_CLASS}>
<Icon className="h-4 w-4" />
Get the {store.label} app
{t("landing.getStarted.getStoreApp", { store: store.label })}
</a>
);
}
@@ -1021,10 +1005,12 @@ function StoreButton({ platform }: { platform: MobilePlatform }) {
// On a phone the desktop build is the secondary path, so it points at /download
// instead of handing the visitor a .dmg they cannot open.
function DesktopAppLink() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<a href="/download" className={SECONDARY_CTA_CLASS}>
<a href={localize("/download")} className={SECONDARY_CTA_CLASS}>
<Monitor className="h-4 w-4" strokeWidth={1.5} />
Desktop app
{t("landing.getStarted.desktopApp")}
</a>
);
}
@@ -1054,35 +1040,45 @@ function StoreIconLinks() {
);
}
const SERVER_INSTALL_TRIGGER = (
<span
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2.5 text-white hover:bg-white/10 transition-colors"
aria-label="Install the daemon on a remote machine"
>
<TerminalIcon className="h-5 w-5" />
</span>
);
const SERVER_INSTALL_FOOTNOTE = (
<>
Requires Node.js 18+. Run <span className="font-mono text-white/40">paseo</span> to start the
daemon.
</>
);
const SERVER_INSTALL_FOOTNOTE_COMPONENTS = {
code: <span className="font-mono text-white/40" />,
};
function ServerInstallButton() {
const { t } = useTranslation();
const trigger = React.useMemo(
() => (
<span
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2.5 text-white hover:bg-white/10 transition-colors"
aria-label={t("landing.getStarted.serverInstallLabel")}
>
<TerminalIcon className="h-5 w-5" />
</span>
),
[t],
);
const footnote = React.useMemo(
() => (
<Trans
i18nKey="landing.getStarted.serverInstallFootnote"
components={SERVER_INSTALL_FOOTNOTE_COMPONENTS}
/>
),
[],
);
return (
<CommandDialog
trigger={SERVER_INSTALL_TRIGGER}
title="Run agents on a remote machine"
description="For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon"
trigger={trigger}
title={t("landing.getStarted.serverInstallTitle")}
description={t("landing.getStarted.serverInstallDescription")}
command="npm install -g @getpaseo/cli && paseo"
footnote={SERVER_INSTALL_FOOTNOTE}
footnote={footnote}
/>
);
}
function PhoneShowcase() {
const { t } = useTranslation();
const containerRef = React.useRef<HTMLDivElement>(null);
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
@@ -1142,10 +1138,10 @@ function PhoneShowcase() {
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
<p className="max-w-md text-balance text-center text-lg text-white/80">
When you want to step away from your desk, you can.
{t("landing.showcase.heading")}
</p>
<p className="max-w-sm text-balance text-center text-sm text-white/50">
The native mobile app has full feature parity with desktop.
{t("landing.showcase.subheading")}
</p>
</motion.div>
@@ -1159,7 +1155,7 @@ function PhoneShowcase() {
style={leftPhoneStyle}
className="w-[160px] md:w-[240px] absolute"
role="img"
aria-label="Paseo workspace drawer"
aria-label={t("landing.showcase.sidebarLabel")}
>
<PhoneFrame time="18:54" depth="right">
<MobileSidebar />
@@ -1173,7 +1169,7 @@ function PhoneShowcase() {
transition={EASE_OUT_06_DELAY_01}
className="w-[220px] md:w-[240px] relative z-10"
role="img"
aria-label="Paseo agent chat"
aria-label={t("landing.showcase.chatLabel")}
>
<PhoneFrame time="18:53">
<MobileChat />
@@ -1185,7 +1181,7 @@ function PhoneShowcase() {
style={rightPhoneStyle}
className="w-[160px] md:w-[240px] absolute"
role="img"
aria-label="Paseo diff view"
aria-label={t("landing.showcase.diffLabel")}
>
<PhoneFrame time="18:55" depth="left">
<MobileDiff />
@@ -1196,7 +1192,41 @@ function PhoneShowcase() {
);
}
const FAQ_ENTRIES = [
"free",
"code",
"agents",
"providers",
"desktop",
"voice",
"network",
"git",
"ban",
"worktrees",
] as const;
const FAQ_LINKS = {
relay: "/docs/security",
agents: "/agents",
muse: "/docs/muse-code",
voice: "/docs/voice",
configuration: "/docs/configuration",
worktrees: "/docs/worktrees",
};
function FAQ() {
const { t } = useTranslation();
const localize = useLocalizePath();
const links = React.useMemo(
() =>
Object.fromEntries(
Object.entries(FAQ_LINKS).map(([name, href]) => [
name,
<a key={name} href={localize(href)} className="underline hover:text-white/80" />,
]),
),
[localize],
);
return (
<motion.div
initial={FADE_IN_UP}
@@ -1205,78 +1235,13 @@ function FAQ() {
transition={EASE_OUT_05}
className="space-y-6"
>
<h2 className="text-3xl font-medium">FAQ</h2>
<h2 className="text-3xl font-medium">{t("landing.faq.title")}</h2>
<div className="space-y-6">
<FAQItem question="Is this free?">
Yes. Paseo is free and open source. You need agent providers installed with your own
credentials. Voice is local-first by default and can optionally use cloud speech providers
if you configure them.
</FAQItem>
<FAQItem question="Does my code leave my machine?">
Paseo doesn&apos;t send your code anywhere. Agents run locally and talk to their own APIs
as they normally would. For remote access, you can use the optional{" "}
<a href="/docs/security" className="underline hover:text-white/80">
end-to-end encrypted relay
</a>
, connect directly over your local network, or use your own tunnel.
</FAQItem>
<FAQItem question="What agents does it support?">
Paseo supports many providers. It has custom implementations for Claude, Codex, OpenCode,
Pi, OMP, Antigravity, and Muse Code, and supports many more via ACP. See the full list
here:{" "}
<a href="/agents" className="underline hover:text-white/80">
all supported providers
</a>
. See{" "}
<a href="/docs/muse-code" className="underline hover:text-white/80">
Muse Code setup and limitations
</a>
.
</FAQItem>
<FAQItem question="How does Paseo run providers?">
Paseo runs the providers installed on your machine as you&apos;d normally run them. Paseo
doesn&apos;t modify or change their behavior.
</FAQItem>
<FAQItem question="Do I need the desktop app?">
No. You can run the daemon headless and use any client to connect. The desktop app just
bundles the daemon with a UI.
</FAQItem>
<FAQItem question="How does voice work?">
Voice runs locally on your device by default. You talk, the app transcribes and sends it
to your agent as text. Optionally, you can configure OpenAI speech providers for
higher-quality transcription and text-to-speech. See the{" "}
<a href="/docs/voice" className="underline hover:text-white/80">
voice docs
</a>
.
</FAQItem>
<FAQItem question="Can I connect from outside my network?">
Yes. You can use the hosted relay (end-to-end encrypted, Paseo can&apos;t read your
traffic), set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the
daemon port directly. See{" "}
<a href="/docs/configuration" className="underline hover:text-white/80">
configuration
</a>
.
</FAQItem>
<FAQItem question="Do I need git or GitHub?">
No. Paseo works in any directory. Worktrees are optional and only relevant if you use git.
You can run agents anywhere you&apos;d normally work.
</FAQItem>
<FAQItem question="Can I get banned for using Paseo?">
Paseo is designed to use each provider&apos;s officially supported integration and does
not attempt to bypass its terms of service. It doesn&apos;t extract tokens or call
inference APIs directly.
</FAQItem>
<FAQItem question="How do worktrees work?">
When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo
creates a git worktree and runs the agent inside it. The agent works on an isolated branch
without touching your main working directory. See the{" "}
<a href="/docs/worktrees" className="underline hover:text-white/80">
worktrees docs
</a>
.
</FAQItem>
{FAQ_ENTRIES.map((entry) => (
<FAQItem key={entry} question={t(`landing.faq.${entry}Question`)}>
<Trans i18nKey={`landing.faq.${entry}Answer`} components={links} />
</FAQItem>
))}
</div>
</motion.div>
);
@@ -0,0 +1,249 @@
import { useLocation, useNavigate } from "@tanstack/react-router";
import { AnimatePresence, motion, type Transition } from "framer-motion";
import { Check, ChevronDown, Languages } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LANGUAGE_ABBREVIATIONS,
LANGUAGE_NAMES,
LOCALES,
type Locale,
localizePath,
splitLocalePath,
useLocale,
} from "~/i18n";
const MENU_HIDDEN = { opacity: 0, y: -4 };
const MENU_SHOWN = { opacity: 1, y: 0 };
const MENU_TRANSITION: Transition = { duration: 0.15, ease: "easeOut" };
const ITEM_SELECTOR = '[role="menuitemradio"]';
/** The closest the menu comes to the edge of the screen. */
const VIEWPORT_MARGIN = 8;
/** Which item takes focus when the menu opens: the current language, or an end the arrow key named. */
type OpenFocus = "current" | "first" | "last";
/**
* Switches the page to the same page in another language, keeping its query and hash so filters
* and anchors survive. The URL carries the choice, so every link on the new page stays in it.
*
* The trigger shows the language abbreviated; the menu names every language in itself. It follows
* the menu button pattern: arrows move between languages, Escape and outside presses close it.
*/
export function LanguageSelector() {
const { t } = useTranslation();
const locale = useLocale();
const location = useLocation();
const navigate = useNavigate();
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [openFocus, setOpenFocus] = useState<OpenFocus | null>(null);
const open = openFocus !== null;
const close = useCallback(() => setOpenFocus(null), []);
// The menu hangs from the trigger's end, which on a narrow header can push it off the screen.
useLayoutEffect(() => {
if (open && menuRef.current) keepInViewport(menuRef.current);
}, [open]);
useEffect(() => {
if (!openFocus) return;
focusOnOpen(menuItems(menuRef.current), openFocus);
}, [openFocus]);
useEffect(() => {
if (!open) return;
function closeOnOutsidePress(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) close();
}
document.addEventListener("pointerdown", closeOnOutsidePress);
return () => document.removeEventListener("pointerdown", closeOnOutsidePress);
}, [open, close]);
const toggle = useCallback(() => setOpenFocus((current) => (current ? null : "current")), []);
const onTriggerKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setOpenFocus(event.key === "ArrowDown" ? "first" : "last");
}
}, []);
const onMenuKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const items = menuItems(menuRef.current);
const index = items.indexOf(document.activeElement as HTMLElement);
const moveTo = (next: number) => {
event.preventDefault();
items[(next + items.length) % items.length]?.focus();
};
switch (event.key) {
case "ArrowDown":
return moveTo(index + 1);
case "ArrowUp":
return moveTo(index - 1);
case "Home":
return moveTo(0);
case "End":
return moveTo(items.length - 1);
case " ":
event.preventDefault();
items[index]?.click();
return;
case "Escape":
event.preventDefault();
close();
triggerRef.current?.focus();
return;
case "Tab":
close();
return;
}
},
[close],
);
const { path } = splitLocalePath(location.pathname);
const suffix = `${location.searchStr}${location.hash ? `#${location.hash}` : ""}`;
const choose = useCallback(
(href: string) => {
close();
triggerRef.current?.focus();
void navigate({ href });
},
[close, navigate],
);
return (
<div ref={rootRef} className="relative inline-flex">
<button
ref={triggerRef}
type="button"
aria-label={t("languageSelector.current", { language: LANGUAGE_NAMES[locale] })}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={toggle}
onKeyDown={onTriggerKeyDown}
className={`-mx-1.5 inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${open ? "text-foreground" : "text-muted-foreground"}`}
>
<Languages size={16} aria-hidden="true" />
<span className="font-medium tracking-wide">{LANGUAGE_ABBREVIATIONS[locale]}</span>
<ChevronDown
size={14}
aria-hidden="true"
className={`transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
id={menuId}
role="menu"
aria-label={t("languageSelector.label")}
onKeyDown={onMenuKeyDown}
initial={MENU_HIDDEN}
animate={MENU_SHOWN}
exit={MENU_HIDDEN}
transition={MENU_TRANSITION}
className="absolute right-0 top-full z-50 mt-2 flex min-w-44 flex-col gap-0.5 rounded-lg border border-border bg-card p-1 shadow-xl shadow-black/40"
>
{LOCALES.map((option) => (
<LanguageOption
key={option}
language={option}
href={`${localizePath(path, option)}${suffix}`}
checked={option === locale}
onChoose={choose}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/**
* One language in the menu. It is a real link to the same page in that language, so it can be
* opened in a new tab; a plain click switches in place.
*/
function LanguageOption({
language,
href,
checked,
onChoose,
}: {
language: Locale;
href: string;
checked: boolean;
onChoose: (href: string) => void;
}) {
const { t } = useTranslation();
const localName = t(`languageSelector.names.${language}`);
const onClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
onChoose(href);
},
[href, onChoose],
);
const focusOnHover = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => event.currentTarget.focus(),
[],
);
return (
<a
href={href}
hrefLang={language}
role="menuitemradio"
aria-checked={checked}
tabIndex={-1}
onClick={onClick}
onMouseEnter={focusOnHover}
className={`flex items-center gap-3 whitespace-nowrap rounded-md px-2.5 py-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground ${checked ? "text-foreground" : "text-muted-foreground"}`}
>
<span className="flex flex-col">
<span lang={language}>{LANGUAGE_NAMES[language]}</span>
{localName !== LANGUAGE_NAMES[language] && (
<span className="text-xs text-extra-muted-foreground">{localName}</span>
)}
</span>
<Check
size={14}
aria-hidden="true"
className={`ml-auto shrink-0 ${checked ? "" : "invisible"}`}
/>
</a>
);
}
function menuItems(menu: HTMLElement | null): HTMLElement[] {
return menu ? [...menu.querySelectorAll<HTMLElement>(ITEM_SELECTOR)] : [];
}
function keepInViewport(menu: HTMLElement) {
const { left, right } = menu.getBoundingClientRect();
let shift = 0;
if (left < VIEWPORT_MARGIN) shift = VIEWPORT_MARGIN - left;
else if (right > window.innerWidth - VIEWPORT_MARGIN) {
shift = window.innerWidth - VIEWPORT_MARGIN - right;
}
menu.style.translate = `${shift}px 0`;
}
function focusOnOpen(items: HTMLElement[], openFocus: OpenFocus) {
if (openFocus === "first") items[0]?.focus();
else if (openFocus === "last") items.at(-1)?.focus();
else items.find((item) => item.getAttribute("aria-checked") === "true")?.focus();
}
+35 -29
View File
@@ -1,38 +1,44 @@
import type { ReactNode } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown, { type Components } from "react-markdown";
import { SiteShell } from "~/components/site-shell";
import { UntranslatedNotice } from "~/components/untranslated-notice";
import { formatDate, localizePath, useLocale } from "~/i18n";
import { getLegalDocument, type LegalDocumentName } from "~/legal";
interface LegalPageProps {
title: string;
lastUpdated: string;
children: ReactNode;
}
export function LegalPage({ title, lastUpdated, children }: LegalPageProps) {
export function LegalPage({ name }: { name: LegalDocumentName }) {
const { t } = useTranslation();
const locale = useLocale();
const document = getLegalDocument(name, locale);
const components = React.useMemo<Components>(
() => ({
a: ({ href, node: _node, ...props }) => {
const external = href !== undefined && /^https?:\/\//i.test(href);
return (
<a
{...props}
href={href === undefined ? undefined : localizePath(href, locale)}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
/>
);
},
}),
[locale],
);
return (
<SiteShell width="prose">
<article className="space-y-8 text-white/70 leading-relaxed [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-white [&_h2]:text-xl [&_h2]:font-medium [&_h2]:text-white [&_li]:pl-1 [&_section]:space-y-3 [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:space-y-1">
<header className="space-y-3">
<h1 className="text-3xl font-medium text-white">{title}</h1>
<p className="text-sm text-white/50">Last updated: {lastUpdated}</p>
<article className="space-y-4 text-white/70 leading-relaxed [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:text-white [&_h2]:pt-4 [&_h2]:text-xl [&_h2]:font-medium [&_h2]:text-white [&_li]:pl-1 [&_strong]:font-medium [&_strong]:text-white [&_ul]:ml-5 [&_ul]:list-disc [&_ul]:space-y-1">
<header className="space-y-3 pb-4">
<h1 className="text-3xl font-medium text-white">{document.title}</h1>
<p className="text-sm text-white/50">
{t("legal.lastUpdated", { date: formatDate(document.lastUpdated, locale, "long") })}
</p>
</header>
{children}
<UntranslatedNotice contentLocale={document.locale} />
<div className="space-y-4" lang={document.locale}>
<ReactMarkdown components={components}>{document.content}</ReactMarkdown>
</div>
</article>
</SiteShell>
);
}
export function PaseoLegalIdentity() {
return (
<address className="not-italic">
<strong className="font-medium text-white">Mohamed Boudra Ziani</strong>, operating as Paseo
<br />
NIF/VAT ID: ES26617095T
<br />
Roc Boronat 48, Bajos 2
<br />
08005 Barcelona, Spain
<br />
Email: <a href="mailto:hello@moboudra.com">hello@moboudra.com</a>
</address>
);
}
@@ -16,6 +16,7 @@ import {
Zap,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { DiffStat } from "./atoms";
import { CodexIcon } from "./icons";
@@ -219,14 +220,18 @@ export function Composer({
showProgress?: boolean;
busy?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
<div className={showProgress ? "pb-[13px]" : ""}>
{showProgress ? (
<div className="flex items-center gap-[8px]">
<ComposerPill>6/6 tasks</ComposerPill>
<ComposerPill>
<CircleDot size={12} className="text-mock-dot-running" />3 subagents
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
</ComposerPill>
<ComposerPill>
<CircleDot size={12} className="text-mock-dot-running" />
{t("landing.mockup.app.subagents", { count: 3 })}
</ComposerPill>
<ComposerPill>
<DiffStat add="1.9k" remove="684" />
@@ -238,7 +243,7 @@ export function Composer({
<div className="px-[14px] pt-[13px] pb-[12px]">
<div className="flex items-start gap-[10px]">
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
Message the agent, tag @files, or use /commands and /skills
{t("landing.mockup.app.composerPlaceholder")}
</span>
</div>
<div className="flex items-center gap-[17px] pt-[26px]">
@@ -9,6 +9,7 @@ import {
RefreshCw,
Rows3,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Code, DiffStat } from "./atoms";
import { TABBAR_H } from "./geometry";
import { ReactFileIcon } from "./icons";
@@ -536,10 +537,11 @@ export function ChangesBranchRow() {
}
export function ChangesScopeRow() {
const { t } = useTranslation();
return (
<div className="flex h-[36px] shrink-0 items-center border-b border-mock-border px-[14px]">
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg-muted">
Uncommitted
{t("landing.mockup.app.uncommitted")}
<ChevronDown size={12} />
</span>
<span className="ml-[9px] flex items-center gap-[5px] text-[11.5px] tabular-nums">
@@ -556,10 +558,11 @@ export function ChangesScopeRow() {
}
export function ChangesCommitsRow() {
const { t } = useTranslation();
return (
<div className="flex h-[36px] shrink-0 items-center gap-[8px] border-t border-mock-border px-[10px] text-[12px] text-mock-fg">
<ChevronRight size={13} className="text-mock-fg-muted" />
Commits
{t("landing.mockup.app.commits")}
</div>
);
}
@@ -19,6 +19,7 @@ import {
SquareTerminal,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { CodexIcon } from "../icons";
const TOOL_ROWS = [
@@ -116,6 +117,7 @@ function Transcript() {
}
function Composer() {
const { t } = useTranslation();
return (
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
<div className="mb-[16px]">
@@ -125,7 +127,9 @@ function Composer() {
</span>
</div>
<div className="rounded-[22px] border border-mock-border-accent bg-mock-surface1 px-[17px] pt-[15px] pb-[13px]">
<span className="block text-[16px] text-mock-fg-xmuted">Message, @files, /commands</span>
<span className="block text-[16px] text-mock-fg-xmuted">
{t("landing.mockup.app.mobileComposerPlaceholder")}
</span>
<div className="flex items-center gap-[13px] pt-[26px]">
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
<span className="flex shrink-0 items-center gap-[5px]">
@@ -15,6 +15,7 @@ import {
SquareDot,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Code } from "../atoms";
import { ReactFileIcon } from "../icons";
@@ -73,15 +74,16 @@ const DIFF_LINES: MobileDiffLine[] = [
];
function Tabs() {
const { t } = useTranslation();
return (
<div className="flex shrink-0 items-center gap-[5px] px-[18px] pt-[4px] pb-[14px]">
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted">
<Files size={17} strokeWidth={1.8} />
Files
{t("landing.mockup.app.files")}
</span>
<span className="flex items-center gap-[7px] rounded-[8px] bg-mock-surface2 px-[10px] py-[6px] text-[16px] text-mock-fg">
<FileDiff size={17} strokeWidth={1.8} />
Changes
{t("landing.mockup.app.changes")}
</span>
<span className="flex items-center gap-[7px] rounded-[8px] px-[10px] py-[6px] text-[16px] text-mock-fg-muted tabular-nums">
<GitPullRequest size={17} strokeWidth={1.8} />
@@ -110,10 +112,11 @@ function BranchRow() {
}
function ScopeRow() {
const { t } = useTranslation();
return (
<div className="flex shrink-0 items-center gap-[12px] border-y border-mock-border px-[18px] py-[11px]">
<span className="flex items-center gap-[5px] text-[16px] text-mock-fg">
Uncommitted
{t("landing.mockup.app.uncommitted")}
<ChevronDown size={16} className="text-mock-fg-muted" />
</span>
<span className="flex items-center gap-[6px] text-[15px] tabular-nums">
@@ -170,6 +173,7 @@ const GUTTER_STYLE = { width: GUTTER_W };
const GUTTER_RULE_STYLE = { left: GUTTER_W };
export function MobileDiff() {
const { t } = useTranslation();
return (
<>
<Tabs />
@@ -192,7 +196,7 @@ export function MobileDiff() {
))}
</div>
<div className="flex h-[52px] shrink-0 items-center border-t border-mock-border px-[18px] pb-[16px] text-[16px] text-mock-fg">
Commits
{t("landing.mockup.app.commits")}
</div>
</>
);
@@ -21,6 +21,7 @@ import {
X,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { LetterTile, PaseoTile } from "./atoms";
// Icons passed as props are hoisted so they are not rebuilt on every render.
@@ -86,6 +87,7 @@ function BlankPageGlyph({ running }: { running?: boolean }) {
}
function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "passed">) {
const { t } = useTranslation();
if (!host && !pr) return null;
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[12.5px] text-mock-fg-muted">
@@ -107,7 +109,7 @@ function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "pass
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={13} />
passed
{t("landing.mockup.app.checksPassedShort")}
</span>
</>
) : null}
@@ -247,18 +249,25 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
}
export function MobileSidebar() {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-sidebar">
<div className="pt-[4px]">
<NavRow icon={NEW_WORKSPACE_ICON} label="New workspace" trailing={NEW_WORKSPACE_CLOSE} />
<NavRow icon={HISTORY_ICON} label="History" />
<NavRow icon={SCHEDULES_ICON} label="Schedules" />
<NavRow
icon={NEW_WORKSPACE_ICON}
label={t("landing.mockup.app.newWorkspace")}
trailing={NEW_WORKSPACE_CLOSE}
/>
<NavRow icon={HISTORY_ICON} label={t("landing.mockup.app.history")} />
<NavRow icon={SCHEDULES_ICON} label={t("landing.mockup.app.schedules")} />
</div>
<div className="mt-[4px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden">
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">Pinned</div>
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">
{t("landing.mockup.app.pinned")}
</div>
<div className="px-[8px]">
{PINNED.map(({ key, ...row }) => (
@@ -274,21 +283,21 @@ export function MobileSidebar() {
</span>
</div>
<GroupHeader icon={READY_ICON} label="Ready to review" />
<GroupHeader icon={READY_ICON} label={t("landing.mockup.app.readyToReview")} />
<div className="px-[8px]">
{READY.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={WORKING_ICON} label="Working" />
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
<div className="px-[8px]">
{WORKING.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={DONE_ICON} label="Done" />
<GroupHeader icon={DONE_ICON} label={t("landing.mockup.app.done")} />
<div className="px-[8px]">
{DONE.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
@@ -300,7 +309,7 @@ export function MobileSidebar() {
<div className="flex h-[64px] shrink-0 items-center px-[20px] pb-[16px] text-mock-fg-muted">
<span className="flex flex-1 items-center gap-[10px] text-[16px] text-mock-fg">
<SquarePlus size={19} strokeWidth={1.8} />
Add project
{t("landing.mockup.app.addProject")}
</span>
<span className="flex items-center gap-[22px]">
<Columns2 size={20} strokeWidth={1.8} />
@@ -1,12 +1,14 @@
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
import { useTranslation } from "react-i18next";
// --- review -----------------------------------------------------------------
/** The selected Changes-tree file, open in its own workspace tab. */
export function DiffReviewPane() {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
<ScopeRow label="Uncommitted" />
<ScopeRow label={t("landing.mockup.app.uncommitted")} />
<DiffRows rows={REVIEW_DIFF_ROWS} />
</div>
);
@@ -10,7 +10,9 @@ import {
RefreshCw,
Rows3,
} from "lucide-react";
import type { TFunction } from "i18next";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
import type { MockupStateId } from "./states";
@@ -75,15 +77,18 @@ const FILES_TAB_GLYPH = <Files size={12} />;
// --- Changes ---------------------------------------------------------------
const CHANGES_TABS: PanelTab[] = [
{ label: "Files", icon: FILES_TAB_GLYPH },
{ label: "Changes", icon: CHANGES_TAB_GLYPH },
{ label: "3981", icon: PR_TAB_GLYPH },
];
function changesTabs(t: TFunction): PanelTab[] {
return [
{ label: t("landing.mockup.app.files"), icon: FILES_TAB_GLYPH },
{ label: t("landing.mockup.app.changes"), icon: CHANGES_TAB_GLYPH },
{ label: "3981", icon: PR_TAB_GLYPH },
];
}
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
const { t } = useTranslation();
return (
<PanelShell tabs={CHANGES_TABS} active={1}>
<PanelShell tabs={changesTabs(t)} active={1}>
<ChangesBranchRow />
<ChangesScopeRow />
<ExplorerChangesTree selectedFile={selectedFile} />
@@ -94,8 +99,6 @@ function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
// --- Ship (GitHub PR checks) ------------------------------------------------
const SHIP_TABS: PanelTab[] = CHANGES_TABS;
const CHECKS: { name: string; context: string; time: string }[] = [
{ name: "changes", context: "CI", time: "11s" },
{ name: "setup", context: "Docker", time: "5s" },
@@ -113,15 +116,16 @@ function CheckGlyph() {
}
function ShipPanel() {
const { t } = useTranslation();
return (
<PanelShell tabs={SHIP_TABS} active={2}>
<PanelShell tabs={changesTabs(t)} active={2}>
<div
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
<ExternalLink size={13} />
View
{t("landing.mockup.app.viewPr")}
</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
@@ -135,7 +139,7 @@ function ShipPanel() {
<div className="flex items-center gap-[7px] pt-[6px] text-[11.5px]">
<span className="flex items-center gap-[5px] text-mock-success">
<GitPullRequest size={12} />
Open
{t("landing.mockup.app.prOpen")}
</span>
<span className="text-mock-fg-muted">getpaseo/paseo</span>
</div>
@@ -144,9 +148,11 @@ function ShipPanel() {
<div className="flex items-center gap-[11px] bg-mock-surface1 px-[16px] py-[11px]">
<CheckGlyph />
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-mock-fg">All checks have passed</span>
<span className="block text-[13px] font-medium text-mock-fg">
{t("landing.mockup.app.checksPassed")}
</span>
<span className="block text-[11px] text-mock-fg-muted">
9 successful, 13 skipped checks
{t("landing.mockup.app.checksDetail", { successful: 9, skipped: 13 })}
</span>
</span>
<ChevronDown size={14} className="text-mock-fg-muted" />
@@ -154,7 +160,7 @@ function ShipPanel() {
<div className="min-h-0 flex-1 overflow-hidden">
<div className="flex items-center gap-[6px] px-[16px] pt-[11px] pb-[4px] text-[11.5px] text-mock-fg-muted">
9 successful checks
{t("landing.mockup.app.successfulChecks", { count: 9 })}
<ChevronDown size={12} />
</div>
{CHECKS.map((check) => (
@@ -170,9 +176,11 @@ function ShipPanel() {
))}
<div className="flex items-center gap-[6px] px-[16px] pt-[13px] pb-[4px] text-[11.5px] text-mock-fg-muted">
<ChevronDown size={12} />
Activity
{t("landing.mockup.app.activity")}
</div>
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">
{t("landing.mockup.app.noActivity")}
</div>
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">No activity yet</div>
</div>
</PanelShell>
);
@@ -181,16 +189,19 @@ function ShipPanel() {
// --- Plugins (invented custom dashboard) ------------------------------------
// No real reference exists; this reads as a user-built native surface plugin.
const PLUGIN_TABS: PanelTab[] = [
...CHANGES_TABS,
{ label: "Release Radar", icon: <Puzzle size={12} /> },
];
const PLUGIN_TAB: PanelTab = { label: "Release Radar", icon: <Puzzle size={12} /> };
const TILES: { label: string; value: string; tile: string }[] = [
{ label: "Merged · 7d", value: "12", tile: "text-mock-tile-teal" },
{ label: "In review", value: "4", tile: "text-mock-tile-amber" },
{ label: "Failing", value: "1", tile: "text-mock-tile-red" },
];
function pluginTabs(t: TFunction): PanelTab[] {
return [...changesTabs(t), PLUGIN_TAB];
}
function tiles(t: TFunction): { label: string; value: string; tile: string }[] {
return [
{ label: t("landing.mockup.app.mergedWeek"), value: "12", tile: "text-mock-tile-teal" },
{ label: t("landing.mockup.app.inReview"), value: "4", tile: "text-mock-tile-amber" },
{ label: t("landing.mockup.app.failing"), value: "1", tile: "text-mock-tile-red" },
];
}
// Weekly merge counts — a small bar chart the plugin author drew. Heights and
// their style objects are precomputed so the render loop allocates nothing.
@@ -204,41 +215,44 @@ const BARS: { id: string; style: { height: string } }[] = [
{ id: "sun", style: { height: "62px" } },
];
const RECENT: { title: string; meta: string; tone: string }[] = [
{
title: "Connect to remote daemons over SSH",
meta: "#3989 · 20m ago",
tone: "bg-mock-dot-success",
},
{
title: "Keep restored workspaces out of loops",
meta: "#3987 · 1h ago",
tone: "bg-mock-dot-success",
},
{
title: "Honor configured commit signing",
meta: "#3976 · reviewing",
tone: "bg-mock-dot-warning",
},
];
function recentlyMerged(t: TFunction): { title: string; meta: string; tone: string }[] {
return [
{
title: "Connect to remote daemons over SSH",
meta: `#3989 · ${t("landing.mockup.app.minutesAgo", { count: 20 })}`,
tone: "bg-mock-dot-success",
},
{
title: "Keep restored workspaces out of loops",
meta: `#3987 · ${t("landing.mockup.app.hoursAgo", { count: 1 })}`,
tone: "bg-mock-dot-success",
},
{
title: "Honor configured commit signing",
meta: `#3976 · ${t("landing.mockup.app.reviewing")}`,
tone: "bg-mock-dot-warning",
},
];
}
function PluginDashboard() {
const { t } = useTranslation();
return (
<PanelShell tabs={PLUGIN_TABS} active={3}>
<PanelShell tabs={pluginTabs(t)} active={3}>
<div
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<Puzzle size={14} className="text-mock-accent-bright" />
<span className="text-[13px] font-medium text-mock-fg">Release Radar</span>
<span className="text-[11px] text-mock-fg-muted">plugin</span>
<span className="text-[11px] text-mock-fg-muted">{t("landing.mockup.app.plugin")}</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
</div>
<div className="min-h-0 flex-1 overflow-hidden px-[16px] pt-[14px]">
<div className="flex gap-[9px]">
{TILES.map((tile) => (
{tiles(t).map((tile) => (
<div
key={tile.label}
className="flex-1 rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[11px]"
@@ -253,7 +267,7 @@ function PluginDashboard() {
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
<span>Merges · last 7 days</span>
<span>{t("landing.mockup.app.mergesWeek")}</span>
<span className="flex items-center gap-[5px] text-mock-tile-teal">
<Rows3 size={11} />
+18%
@@ -270,9 +284,11 @@ function PluginDashboard() {
</div>
</div>
<div className="mt-[16px] text-[11px] text-mock-fg-muted">Recently merged</div>
<div className="mt-[16px] text-[11px] text-mock-fg-muted">
{t("landing.mockup.app.recentlyMerged")}
</div>
<div className="pt-[6px]">
{RECENT.map((item) => (
{recentlyMerged(t).map((item) => (
<div key={item.title} className="flex items-center gap-[10px] py-[7px]">
<span className={`size-[7px] shrink-0 rounded-full ${item.tone}`} />
<span className="min-w-0 flex-1">
@@ -18,6 +18,7 @@ import {
SquarePlus,
} from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { SIDEBAR_W, TITLEBAR_H } from "./geometry";
import { PaseoMark } from "./icons";
import { FADE_IN, FADE_OUT } from "./motion";
@@ -197,6 +198,7 @@ function RowAvatar({ avatar, running }: { avatar: Avatar; running?: boolean }) {
}
function RowMeta({ row }: { row: WorkspaceRow }) {
const { t } = useTranslation();
if (row.host || row.pr) {
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[10.5px] leading-[13px] text-mock-fg-muted">
@@ -218,7 +220,7 @@ function RowMeta({ row }: { row: WorkspaceRow }) {
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={11} className="shrink-0" />
passed
{t("landing.mockup.app.checksPassedShort")}
</span>
</>
) : null}
@@ -264,6 +266,7 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
// --- sidebar ----------------------------------------------------------------
export function Sidebar({ state }: { state: MockupStateId }) {
const { t } = useTranslation();
const extending = state === "extend";
return (
@@ -285,9 +288,9 @@ export function Sidebar({ state }: { state: MockupStateId }) {
animate={extending ? NAV_EXTENDED_LAYOUT : NAV_DEFAULT_LAYOUT}
className="overflow-hidden px-[8px] pt-[7px]"
>
<NavItem icon={NEW_WORKSPACE_ICON} label="New workspace" />
<NavItem icon={HISTORY_ICON} label="History" />
<NavItem icon={SCHEDULES_ICON} label="Schedules" />
<NavItem icon={NEW_WORKSPACE_ICON} label={t("landing.mockup.app.newWorkspace")} />
<NavItem icon={HISTORY_ICON} label={t("landing.mockup.app.history")} />
<NavItem icon={SCHEDULES_ICON} label={t("landing.mockup.app.schedules")} />
<motion.div initial={false} animate={extending ? FADE_IN : FADE_OUT}>
<NavItem icon={PLUGIN_ICON} label="Release Radar" active />
</motion.div>
@@ -296,7 +299,9 @@ export function Sidebar({ state }: { state: MockupStateId }) {
<div className="mt-[9px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden px-[8px]">
<div className="px-[9px] pt-[8px] text-[11px] text-mock-fg-muted">Pinned</div>
<div className="px-[9px] pt-[8px] text-[11px] text-mock-fg-muted">
{t("landing.mockup.app.pinned")}
</div>
{PINNED_ROWS.map((row) => (
<Row key={row.id} row={row} />
))}
@@ -309,12 +314,12 @@ export function Sidebar({ state }: { state: MockupStateId }) {
</span>
</div>
<GroupHeader icon={READY_TO_REVIEW_ICON} label="Ready to review" />
<GroupHeader icon={READY_TO_REVIEW_ICON} label={t("landing.mockup.app.readyToReview")} />
{READY_TO_REVIEW_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
<GroupHeader icon={WORKING_ICON} label="Working" />
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
<AnimatePresence initial={false} mode="popLayout">
{WORKING_ROWS.map((row) => (
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
@@ -323,7 +328,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
))}
</AnimatePresence>
<GroupHeader icon={DONE_ICON} label="Done" />
<GroupHeader icon={DONE_ICON} label={t("landing.mockup.app.done")} />
{DONE_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
@@ -332,7 +337,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
<div className="h-px shrink-0 bg-mock-border" />
<div className="flex h-[44px] shrink-0 items-center gap-[8px] px-[14px] text-[12.5px] text-mock-fg-muted">
<SquarePlus size={14} />
<span className="text-mock-fg">Add project</span>
<span className="text-mock-fg">{t("landing.mockup.app.addProject")}</span>
<span className="flex-1" />
<PanelBottom size={14} />
<House size={14} />
@@ -2,14 +2,10 @@
* The states the hero mockup can be in, in pill order. Labels are copy — edit
* them here and nothing else has to change.
*/
export const MOCKUP_STATES = [
{ id: "build", label: "Build" },
{ id: "review", label: "Review" },
{ id: "ship", label: "Ship" },
{ id: "extend", label: "Extend" },
] as const;
/** The scenes the hero mockup can show; each pill's label is `landing.mockup.<id>`. */
export const MOCKUP_STATES = ["build", "review", "ship", "extend"] as const;
export type MockupStateId = (typeof MOCKUP_STATES)[number]["id"];
export type MockupStateId = (typeof MOCKUP_STATES)[number];
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
@@ -0,0 +1,29 @@
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { SiteShell } from "~/components/site-shell";
import { LocaleProvider, useLocalizePath } from "~/i18n";
/**
* The page for a URL that matches no page, in the language its path names. Unmatched URLs can sit
* outside the language layout, so this provides the language itself.
*/
export function NotFoundPage() {
return (
<LocaleProvider>
<NotFoundContent />
</LocaleProvider>
);
}
function NotFoundContent() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<SiteShell width="default">
<h1 className="mb-4 text-3xl font-medium tracking-tight">{t("common.notFound")}</h1>
<Link to={localize("/")} className="text-white/70 underline hover:text-white">
{t("common.backHome")}
</Link>
</SiteShell>
);
}
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { DocsMarkdown } from "~/components/docs-markdown";
import { DocsMarkdownActions } from "~/components/docs-markdown-actions";
import { DocsSourceFooter } from "~/components/docs-source-footer";
import { UntranslatedNotice } from "~/components/untranslated-notice";
import { docMarkdownPath, getDoc } from "~/docs";
import { useLocale } from "~/i18n";
export function RenderedDoc({ slug }: { slug: string }) {
const { t } = useTranslation();
const doc = getDoc(slug, useLocale());
if (!doc) {
return <p className="text-muted-foreground">{t("docs.notFound")}</p>;
}
return (
<>
<UntranslatedNotice contentLocale={doc.locale} />
<DocsMarkdownActions content={doc.content} markdownHref={docMarkdownPath(doc)} />
<DocsMarkdown lang={doc.locale}>{doc.content}</DocsMarkdown>
<DocsSourceFooter doc={doc} />
</>
);
}
+53 -161
View File
@@ -1,7 +1,9 @@
import { useTranslation } from "react-i18next";
import { PLUGINS_LINKED } from "~/plugins/launch";
import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
import { SponsorsSection } from "~/components/sponsorship";
import { useLocale, useLocalizePath } from "~/i18n";
interface SiteFooterProps {
width?: "default" | "prose" | "wide";
@@ -15,193 +17,83 @@ const WIDTH_CLASSES: Record<NonNullable<SiteFooterProps["width"]>, string> = {
wide: "max-w-7xl p-6 md:p-20 md:pt-0",
};
const LINK_CLASS = "block text-muted-foreground hover:text-foreground transition-colors";
function ExternalLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{children}
</a>
);
}
export function SiteFooter({ width = "default", sponsors = true }: SiteFooterProps) {
const { t } = useTranslation();
const locale = useLocale();
const localize = useLocalizePath();
const widthClasses = WIDTH_CLASSES[width];
const alternatives = getAlternativePages();
const alternatives = getAlternativePages(locale);
const page = (path: string, label: string) => (
<a href={localize(path)} className={LINK_CLASS}>
{label}
</a>
);
return (
<footer className={`${widthClasses} mx-auto mt-12 md:mt-16`}>
{sponsors ? <SponsorsSection placement="footer" /> : null}
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-extra-muted-foreground">Product</p>
<p className="text-extra-muted-foreground">{t("footer.product")}</p>
<div className="space-y-2">
<a
href="/blog"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Blog
</a>
<a
href="/docs"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</a>
{PLUGINS_LINKED && (
<a
href="/plugins"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Plugins
</a>
)}
<a
href="/changelog"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Changelog
</a>
<a
href="/hub"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Hub
</a>
<a
href="/docs/cli"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
CLI
</a>
<a
href="/privacy"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Privacy
</a>
<a
href="/terms"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Terms
</a>
<a
href="/sponsor"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Sponsor
</a>
{page("/blog", t("footer.blog"))}
{page("/docs", t("footer.docs"))}
{PLUGINS_LINKED && page("/plugins", t("footer.plugins"))}
{page("/changelog", t("footer.changelog"))}
{page("/hub", t("footer.hub"))}
{page("/docs/cli", t("footer.cli"))}
{page("/privacy", t("footer.privacy"))}
{page("/terms", t("footer.terms"))}
{page("/sponsor", t("footer.sponsor"))}
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Agents</p>
<p className="text-extra-muted-foreground">{t("footer.agents")}</p>
<div className="space-y-2">
<a
href="/claude-code"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Claude Code
</a>
<a
href="/codex"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Codex
</a>
<a
href="/opencode"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
OpenCode
</a>
<a
href="/antigravity"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Antigravity
</a>
<a
href="/muse-code"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Muse Code
</a>
<a
href="/agents"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
All providers
</a>
{page("/claude-code", "Claude Code")}
{page("/codex", "Codex")}
{page("/opencode", "OpenCode")}
{page("/antigravity", "Antigravity")}
{page("/muse-code", "Muse Code")}
{page("/agents", t("footer.allProviders"))}
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Alternatives</p>
<p className="text-extra-muted-foreground">{t("footer.alternatives")}</p>
<div className="space-y-2">
{alternatives.map((page) => (
<a
key={page.slug}
href={page.href}
className="block text-muted-foreground hover:text-foreground transition-colors"
>
{page.name}
{alternatives.map((alternative) => (
<a key={alternative.slug} href={localize(alternative.href)} className={LINK_CLASS}>
{alternative.name}
</a>
))}
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Community</p>
<p className="text-extra-muted-foreground">{t("footer.community")}</p>
<div className="space-y-2">
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Discord
</a>
<a
href="https://www.reddit.com/r/PaseoAI/"
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Reddit
</a>
<a
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
</a>
<ExternalLink href="https://discord.gg/jz8T2uahpH">Discord</ExternalLink>
<ExternalLink href="https://www.reddit.com/r/PaseoAI/">Reddit</ExternalLink>
<ExternalLink href="https://github.com/getpaseo/paseo">GitHub</ExternalLink>
</div>
</div>
<div className="space-y-3">
<p className="text-extra-muted-foreground">Download</p>
<p className="text-extra-muted-foreground">{t("footer.download")}</p>
<div className="space-y-2">
<a
href={appStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
App Store
</a>
<a
href={playStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Google Play
</a>
<a
href="https://github.com/getpaseo/paseo/releases"
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Desktop
</a>
<a
href={webAppUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-muted-foreground hover:text-foreground transition-colors"
>
Web App
</a>
<ExternalLink href={appStoreUrl}>App Store</ExternalLink>
<ExternalLink href={playStoreUrl}>Google Play</ExternalLink>
<ExternalLink href="https://github.com/getpaseo/paseo/releases">
{t("footer.desktop")}
</ExternalLink>
<ExternalLink href={webAppUrl}>{t("footer.webApp")}</ExternalLink>
</div>
</div>
</div>
+22 -32
View File
@@ -1,55 +1,45 @@
import { useTranslation } from "react-i18next";
import { PLUGINS_LINKED } from "~/plugins/launch";
import "~/styles.css";
import { DiscordIcon, GitHubIcon } from "~/components/brand-icons";
import { LanguageSelector } from "~/components/language-selector";
import { useLocalizePath } from "~/i18n";
import { useStars } from "~/routes/__root";
const NAV_LINK_CLASS = "text-sm text-muted-foreground hover:text-foreground transition-colors";
export function SiteHeader() {
const { t } = useTranslation();
const localize = useLocalizePath();
const { stars } = useStars();
return (
<header className="flex flex-col items-center gap-4 md:flex-row md:justify-between">
<a href="/" className="flex items-center gap-3">
<a href={localize("/")} 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>
</a>
<div className="flex flex-wrap items-center justify-center gap-4">
{PLUGINS_LINKED && (
<a
href="/plugins"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Plugins
<a href={localize("/plugins")} className={NAV_LINK_CLASS}>
{t("nav.plugins")}
</a>
)}
<a
href="/blog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Blog
<a href={localize("/blog")} className={NAV_LINK_CLASS}>
{t("nav.blog")}
</a>
<a
href="/docs"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
<a href={localize("/docs")} className={NAV_LINK_CLASS}>
{t("nav.docs")}
</a>
<a
href="/changelog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Changelog
<a href={localize("/changelog")} className={NAV_LINK_CLASS}>
{t("nav.changelog")}
</a>
<a
href="/download"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Download
<a href={localize("/download")} className={NAV_LINK_CLASS}>
{t("nav.download")}
</a>
<a
href="/sponsor"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Sponsor
<a href={localize("/sponsor")} className={NAV_LINK_CLASS}>
{t("nav.sponsor")}
</a>
<LanguageSelector />
<a
href="https://discord.gg/jz8T2uahpH"
target="_blank"
@@ -63,7 +53,7 @@ export function SiteHeader() {
href="https://github.com/getpaseo/paseo"
target="_blank"
rel="noopener noreferrer"
aria-label={stars ? `GitHub, ${stars} stars` : "GitHub"}
aria-label={stars ? t("nav.githubStars", { stars }) : "GitHub"}
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1.5"
>
<GitHubIcon width="18" height="18" />
+75 -68
View File
@@ -1,4 +1,7 @@
import { ArrowRight, Check, ExternalLink, Heart } from "lucide-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useLocalizePath } from "~/i18n";
import { BuyMeACoffeeIcon, GitHubIcon, OpenCollectiveIcon } from "~/components/brand-icons";
import {
BUY_ME_A_COFFEE_URL,
@@ -40,43 +43,47 @@ function SectionHeading({
);
}
const MAINTAINER_LINK = (
<a
href="https://github.com/boudra"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
Mo
</a>
const MAINTAINER_COMPONENTS = {
maintainer: (
<a
href="https://github.com/boudra"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
/>
),
};
const MAINTAINER_SIGNATURE = (
<p className="text-white/50">
<Trans i18nKey="sponsor.maintainer" components={MAINTAINER_COMPONENTS} />
</p>
);
/** Links `<hub>` in sponsorship copy to the Hub page in the reader's language. */
function useHubLinkComponents() {
const localize = useLocalizePath();
return React.useMemo(
() => ({
hub: <a href={localize("/hub")} className="underline hover:text-white/90" />,
}),
[localize],
);
}
export function FounderNote() {
const { t } = useTranslation();
const hubLink = useHubLinkComponents();
return (
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
<p>Paseo is an independent project used by tens of thousands of developers daily.</p>
<p>{t("sponsor.note.independent")}</p>
<p>{t("sponsor.note.maintained")}</p>
<p>{t("sponsor.note.community")}</p>
<p>{t("sponsor.note.neutral")}</p>
<p>
It is maintained by one person, full time, and shaped by the community. There are no
investors, no board and no company behind it. I have turned down funding offers to keep it
that way.
<Trans i18nKey="sponsor.note.selfFunded" components={hubLink} />
</p>
<p>
The community helps Paseo grow through feedback, testing, bug reports, code contributions
and plugins. Your sponsorship helps me keep improving and maintaining it.
</p>
<p>
A tool that sits between you and your code, your keys and your machines has to stay neutral,
and that gets hard once someone else owns a return on it. Funding creates pressure to
monetize, and that pressure changes what gets built.
</p>
<p>
Paseo is self-funded: the work is paid for by sponsorship and by{" "}
<a href="/hub" className="underline hover:text-white/90">
Paseo Hub
</a>
, an optional hosted service. Your support is what lets me keep working on it.
</p>
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
{MAINTAINER_SIGNATURE}
</div>
);
}
@@ -88,7 +95,7 @@ interface BackingOption {
external: boolean;
}
const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
const DONATION_OPTIONS: ReadonlyArray<BackingOption> = [
{
href: GITHUB_SPONSORS_URL,
name: "GitHub Sponsors",
@@ -107,12 +114,6 @@ const BACKING_OPTIONS: ReadonlyArray<BackingOption> = [
icon: BuyMeACoffeeIcon,
external: true,
},
{
href: "/sponsor#spot",
name: "Sponsor as a company",
icon: SpotIcon,
external: false,
},
];
function SpotIcon({ className }: { className?: string }) {
@@ -125,9 +126,23 @@ function SpotIcon({ className }: { className?: string }) {
}
export function BackingOptions() {
const { t } = useTranslation();
const localize = useLocalizePath();
const options = React.useMemo(
() => [
...DONATION_OPTIONS,
{
href: localize("/sponsor#spot"),
name: t("sponsor.company.title"),
icon: SpotIcon,
external: false,
},
],
[localize, t],
);
return (
<div className="grid gap-3 md:grid-cols-2">
{BACKING_OPTIONS.map((option) => (
{options.map((option) => (
<a
key={option.href}
href={option.href}
@@ -149,32 +164,28 @@ export function BackingOptions() {
);
}
const SPOT_FEATURES: ReadonlyArray<string> = [
"Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month",
"Your logo in the GitHub README, around 30,000 visitors a month",
"An announcement in the Discord, around 2,000 members",
"An announcement from me on X",
];
const SPOT_FEATURES = ["homepage", "readme", "discord", "x"] as const;
export function SponsorSpotSection() {
const { t } = useTranslation();
const open = openSpotCount();
return (
<section id="spot" className="scroll-mt-8">
<SectionHeading
as="h2"
title="Sponsor as a company"
description="Support Paseo financially and get visibility for your company"
title={t("sponsor.company.title")}
description={t("sponsor.company.description")}
/>
<div className="space-y-8">
<div className="flex items-end gap-2">
<span className="text-4xl font-medium tracking-tight">{SPONSOR_SPOT_PRICE}</span>
<span className="mb-1 text-sm text-white/45">a month</span>
<span className="mb-1 text-sm text-white/45">{t("sponsor.company.perMonth")}</span>
</div>
<ul className="grid max-w-2xl gap-3 text-white/70">
{SPOT_FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<Check aria-hidden="true" className="mt-1 size-4 shrink-0 text-emerald-300" />
<span>{feature}</span>
<span>{t(`sponsor.company.${feature}`)}</span>
</li>
))}
</ul>
@@ -184,14 +195,14 @@ export function SponsorSpotSection() {
href={SPONSOR_SPOT_CHECKOUT_URL}
className="inline-flex items-center rounded-md bg-white px-5 py-2.5 text-sm font-medium text-black transition-colors hover:bg-white/90"
>
Become a sponsor
{t("sponsor.becomeSponsor")}
</a>
) : null}
<a
href={`mailto:${SPONSOR_CONTACT_EMAIL}`}
className="text-sm text-white/60 underline transition-colors hover:text-white/90"
>
{open > 0 ? "Or email me first" : "Email me to hear when a spot opens"}
{open > 0 ? t("sponsor.company.emailFirst") : t("sponsor.company.emailWhenOpen")}
</a>
</div>
</div>
@@ -201,9 +212,10 @@ export function SponsorSpotSection() {
/** The /sponsor page's first section: the note and the ways to back the work. */
export function SponsorPaseoSection() {
const { t } = useTranslation();
return (
<section>
<SectionHeading as="h1" title="Sponsor Paseo" />
<SectionHeading as="h1" title={t("sponsor.title")} />
<div className="space-y-10">
<FounderNote />
<BackingOptions />
@@ -214,26 +226,19 @@ export function SponsorPaseoSection() {
/** Homepage: a short version of the note with the same links as /sponsor. */
export function SponsorSection() {
const { t } = useTranslation();
const hubLink = useHubLinkComponents();
return (
<section>
<SectionHeading as="h2" title="Sponsor Paseo" />
<SectionHeading as="h2" title={t("sponsor.title")} />
<div className="space-y-10">
<div className="max-w-2xl space-y-5 leading-relaxed text-white/70">
<p>Paseo is an independent project used by tens of thousands of developers daily.</p>
<p>{t("sponsor.note.independent")}</p>
<p>{t("sponsor.shortNote.maintained")}</p>
<p>
It is maintained by one person, full time, and shaped by the community. There are no
investors and no company behind it. I have turned down funding to keep it that way. A
tool that sits between you and your code, your keys and your machines has to stay
neutral, and funding creates pressure to monetize.
<Trans i18nKey="sponsor.shortNote.selfFunded" components={hubLink} />
</p>
<p>
Paseo is self-funded through sponsorship and{" "}
<a href="/hub" className="underline hover:text-white/90">
Paseo Hub
</a>
. Your support is what lets me keep working on it.
</p>
<p className="text-white/50">{MAINTAINER_LINK}, maintainer</p>
{MAINTAINER_SIGNATURE}
</div>
<BackingOptions />
</div>
@@ -272,6 +277,8 @@ interface SponsorsSectionProps {
* spots are open, a quiet invitation. Every page shows it once.
*/
export function SponsorsSection({ placement }: SponsorsSectionProps) {
const { t } = useTranslation();
const localize = useLocalizePath();
const style = SPONSORS_PLACEMENTS[placement];
const open = openSpotCount();
return (
@@ -282,7 +289,7 @@ export function SponsorsSection({ placement }: SponsorsSectionProps) {
className={`fill-rose-300/80 text-rose-300/80 ${style.heart}`}
strokeWidth={1.5}
/>
Sponsors
{t("sponsor.sponsors")}
</h2>
<ul className="flex flex-wrap items-center gap-x-14 gap-y-8">
{SPONSORS.map((sponsor) => (
@@ -305,10 +312,10 @@ export function SponsorsSection({ placement }: SponsorsSectionProps) {
{open > 0 ? (
<li>
<a
href="/sponsor#spot"
href={localize("/sponsor#spot")}
className={`inline-flex items-center rounded-md border border-dashed border-white/15 text-white/45 transition-colors hover:border-white/30 hover:text-white/75 ${style.button}`}
>
Become a sponsor
{t("sponsor.becomeSponsor")}
</a>
</li>
) : null}
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { type Locale, useLocale } from "~/i18n";
/**
* Shown above content that has no translation in the page's language and is therefore shown whole in
* its original language. Content is never mixed paragraph by paragraph.
*/
export function UntranslatedNotice({
contentLocale,
message,
}: {
contentLocale: Locale;
/** Overrides the default page-level notice, e.g. for one release of a translated page. */
message?: string;
}) {
const { t } = useTranslation();
const locale = useLocale();
if (contentLocale === locale) return null;
return (
<p
role="note"
className="not-prose mb-6 rounded-md border border-border px-4 py-3 text-sm text-muted-foreground"
>
{message ?? t("common.untranslated")}
</p>
);
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { getAlternativePages } from "~/data/alternative-pages";
import { getDocs, parseHeadings } from "~/docs";
import type { Locale } from "~/i18n";
import { getLegalDocument, type LegalDocumentName } from "~/legal";
import { getPosts } from "~/posts";
/**
* First-party pages in both languages, paired by path. Every page ships in Chinese, so a missing
* translation is a failure here rather than a silent English fallback in production.
*/
interface ContentPair {
path: string;
/** Whether headings render with `#` anchors; blog posts and legal pages have none. */
anchored: boolean;
english: { content: string; locale: Locale };
chinese: { content: string; locale: Locale };
}
const LEGAL_DOCUMENTS: LegalDocumentName[] = ["privacy", "terms"];
function pairs<T extends { content: string; locale: Locale }>(
english: T[],
chinese: T[],
pathOf: (page: T) => string,
anchored: boolean,
): ContentPair[] {
const byPath = new Map(chinese.map((page) => [pathOf(page), page]));
return english.map((page) => ({
path: pathOf(page),
anchored,
english: page,
chinese: byPath.get(pathOf(page)) ?? { content: "", locale: "en" },
}));
}
const CONTENT: ContentPair[] = [
...pairs(getDocs("en"), getDocs("zh-CN"), (doc) => doc.href, true),
...pairs(getPosts("en"), getPosts("zh-CN"), (post) => `/blog/${post.slug}`, false),
...pairs(getAlternativePages("en"), getAlternativePages("zh-CN"), (page) => page.href, true),
...LEGAL_DOCUMENTS.map((name) => ({
path: `/${name}`,
anchored: false,
english: getLegalDocument(name, "en"),
chinese: getLegalDocument(name, "zh-CN"),
})),
];
/** Sections in page order, with the anchor `#` links land on where the page renders one. */
function sections(content: string, anchored: boolean): string[] {
return parseHeadings(content).map(
(heading) => `h${heading.depth}${anchored ? ` #${heading.id}` : ""}`,
);
}
function untranslated(pair: ContentPair): boolean {
return pair.chinese.locale !== "zh-CN";
}
/** The sections a translation adds, drops, reorders, or anchors differently, as a readable diff. */
function sectionMismatch(pair: ContentPair): string[] {
const english = sections(pair.english.content, pair.anchored);
const chinese = sections(pair.chinese.content, pair.anchored);
if (english.join() === chinese.join()) return [];
return [`${pair.path}: English ${english.join(" ")} | Chinese ${chinese.join(" ")}`];
}
describe("Chinese content", () => {
it("covers every first-party page", () => {
expect(CONTENT.length).toBeGreaterThan(70);
expect(CONTENT.filter(untranslated).map((pair) => pair.path)).toEqual([]);
});
it("has every English section, in order, under the English anchor", () => {
// Pin each translated heading with the English anchor: `## 运行 Agent {#running-agents}`.
expect(CONTENT.flatMap(sectionMismatch)).toEqual([]);
});
});
@@ -0,0 +1,124 @@
---
title: 开源的 Claude Desktop 替代品,支持原生移动端和多 Provider
description: Paseo 是一个开源的 Claude Desktop 替代品,运行在你自己的机器上,不需要 Paseo 账号,没有遥测,也不依赖云服务。
nav: Claude Desktop
order: 55
---
# Paseo 与 Claude Desktop 对比
Claude Desktop 是 Anthropic 推出的应用,在 macOS、Windows 和 Linux 上提供 Claude Chat、Cowork 和 Claude Code。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
Claude Desktop 是 Anthropic 官方的 Claude 界面,需要 Claude 账号。它可以在本地、通过 SSH 或在 Anthropic 的基础设施上运行 Claude Code。
Paseo 是一个开源的控制平面,运行在你掌控的机器上。它不需要 Paseo 账号,不收集遥测数据,也不依赖 Paseo 的云服务。你可以从桌面端、移动端、Web 或 CLI 直接连接;当 daemon 位于防火墙之后时,也可以使用可选的端到端加密中继。
Paseo 不会上传或存储你的代码。中继无法读取你的代码、消息或 Agent 输出。你还可以自托管 daemon、Web 客户端和中继。
## 架构 {#architecture}
Paseo 在你的笔记本、工作站、虚拟机、家庭实验室或云主机上运行一个独立的 daemon。它的客户端直接连接,或通过可选的端到端加密中继连接。daemon 使用你已安装的 Provider 及其现有的凭据、技能、MCP 服务器和项目配置来启动它们。
Claude Desktop 是由 Anthropic 控制的宿主应用。Claude Code 可以在本地运行、通过 SSH 连接,或使用 Anthropic 托管的云端会话。
## Provider {#providers}
Claude Desktop 运行 Claude Code。
Paseo 同样运行 Claude Code,还原生支持 Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过应用内目录支持另外 30 多个 Agent,包括 GitHub Copilot、Cursor、Gemini CLI 和 Amp。Paseo 支持 [Agent Client Protocol](https://agentclientprotocol.com),因此任何 ACP Agent 都可以使用。自定义 Provider 可以运行任何 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
Claude Desktop 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## 桌面平台 {#desktop-platforms}
Claude Desktop 和 Paseo 都支持 macOS、Windows 和 Linux。
## 移动端 {#mobile}
移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。
Claude 有 iOS 和 Android 应用。Dispatch 可以通过一台处于活动状态的 Claude Desktop 主机启动本地的 Claude Code 工作,或在 Anthropic 的基础设施上启动云端会话。
## 窗格 {#panes}
两者都支持围绕 Claude Code 的可视化编码工作流。
Paseo 应用支持拆分窗格和标签页(⌘D 垂直拆分,⌘⇧D 水平拆分)。窗格可以是 Agent、终端、diff 查看器,以及用于测试运行中服务的浏览器。
Claude Desktop 有一个图形化的 Code 标签页,包含会话、集成终端、文件编辑器、可视化 diff 审查、应用实时预览、PR 监控和定时任务。
## GitHub {#github}
Paseo 应用可以完成提交、推送、创建 PR、关注检查和审查,以及合并。
Claude Desktop 可以监控拉取请求的状态,并根据工作流和权限修复失败或在检查通过后合并。
## CLI 与自动化 {#cli-and-automation}
Claude Code 有自己的 CLI、IDE 集成、Web 界面、定时任务和云端会话。
Paseo 的 CLI 控制的是与应用相同的 daemon:
```bash
paseo run --provider claude "implement OAuth"
paseo run --provider codex --worktree refactor-auth "refactor auth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` 连接到远程 daemon。`paseo schedule` 按 cron 运行 Agent。MCP 服务器让其他 Agent 能够创建 worktree、启动 Agent、打开终端和发送提示词。
## Worktree 与服务 {#worktrees-and-services}
两者都支持并行编码会话,包括 Git worktree。
Paseo 还为每个 worktree 提供独立的开发服务器 URL。两个 Agent 同时运行开发服务器时,会分别得到 `web--fix-auth--my-app.localhost` 和 `web--add-search--my-app.localhost`,而不会发生端口冲突。
## 语音 {#voice}
Paseo 支持语音输入和实时语音模式。语音转文字和文字转语音都可以在你的设备上本地运行。
Claude 在其自己的移动端和应用界面中支持语音。Claude Code 本身可以在 Claude Desktop、终端、IDE、Web 以及移动端 Remote Control 工作流中使用。
## 对比 {#comparison}
| | Paseo | Claude Desktop |
| ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------- |
| 许可证 | 开源(Apache-2.0) | 未以开源形式发布 |
| 需要账号 | 否 | Claude 账号 |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS、Linux、Windows) |
| 移动应用 | 是(原生,与桌面端功能一致) | Claude 应用(Dispatch 和 Cowork) |
| CLI | 是(应用能做的都能做) | 是(Claude Code CLI) |
| 远程机器 | 是(daemon 可以安装在任何地方) | 是(SSH 主机、Anthropic 托管的云端会话) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | Anthropic Remote |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | 是 |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | Claude Code |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | - |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | - |
| 编辑器 | 是 | 是(文件编辑器) |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | PR 监控和合并工作流 |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是(应用实时预览) |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | 否 |
| 定时任务与 heartbeat | 是 | 是(定时任务) |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | 是(在 Claude 的移动端和应用界面中) |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Codex App 对比](/alternatives/codex-app)、[Paseo 与 OpenCode Desktop 对比](/alternatives/opencode-desktop)、[Paseo 与 Conductor 对比](/alternatives/conductor)。
@@ -0,0 +1,118 @@
---
title: 开源的 Codex App 替代品,支持原生移动端和多 Provider
description: Paseo 是一个开源的 Codex 替代品,运行在你自己的机器上,不需要 Paseo 账号,没有遥测,也不依赖云服务。
nav: Codex App
order: 54
---
# Paseo 与 Codex App 对比
OpenAI 通过 ChatGPT 桌面应用在 macOS、Windows 和 Linux 上提供 Codex,支持本地、worktree、云端和远程工作流。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
OpenAI 通过 ChatGPT 桌面和移动应用提供 Codex。使用这些产品需要 OpenAI 账号,云端工作运行在 OpenAI 托管的基础设施上。
Paseo 在你掌控的机器上运行你已安装的 Codex CLI。它不需要 Paseo 账号,不收集遥测数据,也不依赖 Paseo 的云服务。你可以从桌面端、移动端、Web 或 CLI 直接连接,也可以使用可选的端到端加密中继。
Paseo 不会上传或存储你的代码。中继无法读取你的代码、消息或 Agent 输出。你可以自托管 daemon、Web 客户端和中继,并用同一个控制平面管理 Codex、Claude Code、OpenCode、Pi、ACP Agent 和自定义 Provider。
## 架构 {#architecture}
Paseo 在你的笔记本、工作站、虚拟机、家庭实验室或云主机上运行一个独立的 daemon。daemon 使用你已安装的 Codex CLI 及其现有的凭据和配置来启动它。客户端直接连接,或通过可选的端到端加密中继连接。
OpenAI 通过其 ChatGPT 产品提供本地、worktree、远程和云端的 Codex 工作流。本地和远程工作使用一台已连接的主机,云端工作则运行在 OpenAI 托管的基础设施上。
## Provider {#providers}
Codex App 运行 Codex。
Paseo 同样运行 Codex,还原生支持 Claude Code、OpenCode、Pi、Antigravity 和 Muse Code,并通过应用内目录支持另外 30 多个 Agent,包括 GitHub Copilot、Cursor、Gemini CLI 和 Amp。Paseo 支持 [Agent Client Protocol](https://agentclientprotocol.com),因此任何 ACP Agent 都可以使用。自定义 Provider 可以运行任何 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
Codex 支持技能、MCP 服务器和其他 Agent 扩展。OpenAI 没有公开文档化的扩展 API,无法为 Codex 应用本身添加服务端行为和原生界面组件。
## 桌面平台 {#desktop-platforms}
OpenAI 的桌面应用和 Paseo 都支持 macOS、Windows 和 Linux。
## 移动端 {#mobile}
移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。
ChatGPT Remote 可以通过一台已连接的桌面主机,在 iOS 和 Android 上启动和继续 Codex 对话、批准操作并审查输出。
## Worktree 与本地设置 {#worktrees-and-local-setup}
两者都支持使用 Git worktree 进行并行工作。
Codex App 在 `$CODEX_HOME/worktrees` 下创建由 Codex 管理的 worktree,并通过 `.codex` 配置支持本地环境设置脚本和项目操作。
Paseo 在 `$PASEO_HOME/worktrees` 下创建 worktree,运行 `paseo.json` 中的设置和清理钩子,并为每个 worktree 提供独立的开发服务器 URL,例如 `web--fix-auth--my-app.localhost`,这样并行的服务就不会争抢端口。
## GitHub 与审查 {#github-and-review}
两者都支持在应用中审查 diff、提交、推送和创建拉取请求。
Paseo 还会在应用中显示 PR 的检查和审查,并通过 CLI 和 MCP 服务器提供同样的工作流。
## CLI 与自动化 {#cli-and-automation}
Codex 有自己的 CLI、IDE 扩展、Web 应用、自动化和 SDK。
Paseo 的 CLI 控制的是与应用相同的 daemon:
```bash
paseo run --provider codex "implement OAuth"
paseo run --provider claude --worktree refactor-auth "refactor auth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` 连接到远程 daemon。`paseo schedule` 按 cron 运行 Agent。MCP 服务器让其他 Agent 能够创建 worktree、启动 Agent、打开终端和发送提示词。
## 语音 {#voice}
Codex App 支持语音输入。
Paseo 支持语音输入和实时语音模式。语音转文字和文字转语音都可以在你的设备上本地运行。
## 对比 {#comparison}
| | Paseo | Codex App |
| ------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------- |
| 许可证 | 开源(Apache-2.0) | Codex CLI 开源;应用为专有软件 |
| 需要账号 | 否 | OpenAI 账号 |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS、Linux、Windows) |
| 移动应用 | 是(原生,与桌面端功能一致) | ChatGPT 应用(Remote) |
| CLI | 是(应用能做的都能做) | 是(Codex CLI) |
| 远程机器 | 是(daemon 可以安装在任何地方) | 是(已连接的主机、OpenAI 托管的云端) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | OpenAI Remote |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | - |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | Codex |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | - |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | - |
| 编辑器 | 是 | - |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | 提交、推送、PR |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是 |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | 否 |
| 定时任务与 heartbeat | 是 | - |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | 语音输入 |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Claude Desktop 对比](/alternatives/claude-desktop)、[Paseo 与 OpenCode Desktop 对比](/alternatives/opencode-desktop)、[所有支持的 Provider](/agents)。
@@ -0,0 +1,109 @@
---
title: 开源的 Conductor 替代品,支持 Linux、Windows 和移动端
description: Paseo 是一个开源的 Conductor 替代品,支持 Linux、Windows、原生移动应用、自托管的 daemon 以及可扩展的客户端。
nav: Conductor
order: 50
---
# Paseo 与 Conductor 对比
Conductor 是一款专有的 macOS 应用,用于在并行的 Git worktree 和托管的云端工作区中运行 Claude Code、Codex、Cursor 和 OpenCode。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
Conductor 在 macOS 上免费提供本地工作区。它的托管云端工作区、API、协作功能以及即将推出的移动应用包含在每月 50 美元的 Pro 套餐中。
Conductor 完成了 2200 万美元的 A 轮融资,并且是专有软件。Paseo 是独立项目,采用 Apache 2.0 许可,支持 macOS、Linux、Windows、iOS 和 Android,并且可以连接到你掌控的机器。
## 架构 {#architecture}
Paseo daemon 作为独立进程运行。桌面端、Web、移动端和 CLI 都通过 websocket 连接到它。你可以在笔记本、虚拟机、Docker 或一组机器上运行 daemon,并从任意客户端连接到其中任何一个。
Conductor 通过其 macOS 应用运行本地工作区,并在托管的 Vercel 沙箱中运行云端工作区。它目前不支持把客户端连接到你自己运维的云主机。
## Provider {#providers}
Paseo 原生支持 Claude Code、Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过应用内目录支持另外 30 多个 Agent,包括 GitHub Copilot、Cursor、Gemini CLI 和 Amp。Paseo 支持 [Agent Client Protocol](https://agentclientprotocol.com),因此任何 ACP Agent 都可以使用。自定义 Provider 可以运行任何 CLI Agent。查看[所有支持的 Provider](/agents)。
Conductor 支持 Claude Code、Codex、Cursor 和 OpenCode。
两者都使用你自己的 Provider 凭据。Paseo 启动安装在你机器上的 Provider。Conductor 捆绑了托管的 Claude Code 和 Codex 二进制文件,并为 Cursor 和 OpenCode 提供托管集成。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
Conductor 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## 窗格 {#panes}
Paseo 应用支持拆分窗格和标签页(⌘D 垂直拆分,⌘⇧D 水平拆分)。窗格可以是与 Agent 并排的终端、diff 查看器,以及用于测试运行中服务的浏览器。
## GitHub {#github}
Paseo 应用可以完成提交、推送、创建 PR、关注检查和审查,以及合并。
## CLI {#cli}
Paseo 有一个与应用功能对应的 CLI:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` 连接到远程 daemon。`paseo schedule` 按 cron 运行 Agent。
Conductor 把 API 列为 Pro 功能,但没有公开文档化与 Paseo 相当的面向用户的 CLI。
## Worktree 与服务 {#worktrees-and-services}
两者都把并行的 Agent 隔离在 git worktree 中。
Paseo 还为每个 worktree 提供独立的开发服务器 URL。两个 Agent 同时运行开发服务器时,会分别得到 `web--fix-auth--my-app.localhost` 和 `web--add-search--my-app.localhost`,而不会发生端口冲突。
## 移动端 {#mobile}
移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。Conductor 把它的移动应用列为 Pro 套餐下即将推出的功能。
## 语音 {#voice}
Paseo 支持本地的语音转文字和文字转语音。Conductor 目前没有公开文档化的语音界面。
## 对比 {#comparison}
| | Paseo | Conductor |
| ------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------ |
| 许可证 | 开源(Apache-2.0) | 闭源 |
| 需要账号 | 否 | 是 |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(仅 macOS) |
| 移动应用 | 是(原生,与桌面端功能一致) | Pro 套餐下即将推出 |
| CLI | 是(应用能做的都能做) | Pro 套餐下提供 API |
| 远程机器 | 是(daemon 可以安装在任何地方) | 否(Pro 套餐下提供托管云端工作区) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | - |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | 否 |
| SSH 访问 | 是 | 否 |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | Claude Code、Codex、Cursor、OpenCode |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | - |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | - |
| 编辑器 | 是 | - |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | - |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | GitHub |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | - |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | - |
| 定时任务与 heartbeat | 是 | - |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | - |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Superset 对比](/alternatives/superset)、[Paseo 与 OpenChamber 对比](/alternatives/openchamber)、[Paseo 与 Happy Coder 对比](/alternatives/happy-coder)。
@@ -0,0 +1,109 @@
---
title: 支持 worktree 和多 Provider 的 Happy Coder 替代品
description: Paseo 为远程编码 Agent 工作流加入了托管的 worktree、覆盖桌面端和移动端的原生客户端,以及可扩展的 daemon 和客户端界面。
nav: Happy Coder
order: 53
---
# Paseo 与 Happy Coder 对比
Happy Coder 在其 macOS、Web、iOS 和 Android 应用之间连接 Claude Code 和 Codex 会话。它在你的笔记本上包装 Agent CLI,并通过端到端加密的中继同步会话。它以 MIT 许可开源。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
Happy Coder 专注于通过端到端加密的中继,把 Claude Code 和 Codex 会话连接到它的移动端、Web 和 macOS 客户端。
Paseo 同样支持这两个 Provider,还支持 OpenCode、Pi、ACP Agent 和自定义 Provider。它还通过同一个 daemon 管理工作区、worktree、服务、拉取请求审查和应用插件。
## 架构 {#architecture}
Paseo 在自己的 daemon 中运行 Agent。daemon 负责 Agent 的生命周期、worktree 和开发服务器。客户端通过 websocket 连接并驱动 daemon。
Happy Coder 在你的笔记本上通过它的 CLI 运行 Agent,并通过端到端加密的中继把会话同步到它的桌面端、移动端和 Web 客户端。
## 窗格 {#panes}
Paseo 应用支持拆分窗格和标签页(⌘D 垂直拆分,⌘⇧D 水平拆分)。窗格可以是与 Agent 并排的终端、diff 查看器,以及用于测试运行中服务的浏览器。
Happy Coder 的 macOS 应用把对话与文件、diff、终端和预览并排放置。
## GitHub {#github}
Paseo 应用可以完成提交、推送、创建 PR、关注检查和审查,以及合并。
## 移动端 {#mobile}
移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。Happy Coder 也提供原生的 iOS 和 Android 应用。
## Provider {#providers}
Paseo 原生支持 Claude Code、Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过应用内目录支持另外 30 多个 Agent,包括 GitHub Copilot、Cursor、Gemini CLI 和 Amp。Paseo 支持 [Agent Client Protocol](https://agentclientprotocol.com),因此任何 ACP Agent 都可以使用。自定义 Provider 可以运行任何 CLI Agent。查看[所有支持的 Provider](/agents)。
Happy Coder 运行 Claude Code 和 Codex。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
Happy Coder 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## Worktree 与服务 {#worktrees-and-services}
Paseo 在各自的 git worktree 中运行每个 Agent。每个 worktree 都有独立的开发服务器 URL,例如 `web--fix-auth--my-app.localhost`,因此并行的 Agent 不会争抢同一个端口。
Happy Coder 可以在选定的机器路径中启动 Agent,包括已有的 Git worktree。它目前不会创建 worktree,也不管理其生命周期。
## CLI {#cli}
Paseo 有一个与应用功能对应的 CLI:
```bash
paseo run --provider codex "implement OAuth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` 连接到远程 daemon。`paseo schedule` 按 cron 运行 Agent。
Happy Coder 提供 CLI,用于启动被包装的会话,以及创建远程会话、向其发送消息、监控和停止远程会话。它没有公开文档化定时任务或循环。
## 语音 {#voice}
两款产品都支持语音交互。Paseo 可以在设备上本地运行语音转文字和文字转语音。
## 对比 {#comparison}
| | Paseo | Happy Coder |
| ------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------- |
| 许可证 | 开源(Apache-2.0) | 开源(MIT) |
| 需要账号 | 否 | - |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS) |
| 移动应用 | 是(原生,与桌面端功能一致) | 是(原生) |
| CLI | 是(应用能做的都能做) | 是(启动和控制会话) |
| 远程机器 | 是(daemon 可以安装在任何地方) | - |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | 是(端到端加密) |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | - |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | Claude Code、Codex |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 使用已有的 worktree 路径,不管理 worktree |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | 是(包装 Agent CLI) |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | - |
| 编辑器 | 是 | - |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | - |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是(预览) |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | - |
| 定时任务与 heartbeat | 是 | - |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | 是 |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Conductor 对比](/alternatives/conductor)、[Paseo 与 Superset 对比](/alternatives/superset)、[Paseo 与 OpenChamber 对比](/alternatives/openchamber)。
@@ -0,0 +1,85 @@
---
title: 支持多 Provider 编排的 OpenChamber 替代品
description: Paseo 是一个 OpenChamber 替代品,适合需要多个 Agent harness、应用插件以及 Apache-2.0 代码库的开发者。
nav: OpenChamber
order: 52
---
# Paseo 与 OpenChamber 对比
OpenChamber 是一个以 MIT 许可发布、围绕 OpenCode 构建的工作区,提供桌面端、Web、VS Code、服务端以及基于 Capacitor 的移动客户端。
Paseo 可以从桌面端、移动端、Web 和 CLI 编排多种编码 Agent harness。它以 Apache-2.0 许可开源。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
OpenChamber 围绕 OpenCode 构建 Agent 工作流。Paseo 在运行 OpenCode 的同时,还运行 Claude Code、Codex、Pi、ACP Agent 和自定义 Provider。
OpenChamber 用 Capacitor 把它的 Web 界面打包到 iOS 和 Android 上。界面运行在 WKWebView 或 Android WebView 中,并带有通知、安全存储、二维码配对和小组件等原生集成。Paseo 的移动客户端使用 React Native 构建,并通过 App Store 和 Google Play 发布。
## 架构 {#architecture}
两者都可以在你的工作站或其他机器上运行服务端,并从桌面端、Web 和移动客户端连接。
Paseo 的 daemon 通过每个 Provider 自己的原生 harness 或通过 ACP 启动它。OpenChamber 管理一个 OpenCode 服务端,并围绕 OpenCode 会话构建 Agent 工作流。
## Provider {#providers}
OpenChamber 使用 OpenCode 作为 Agent 运行时。OpenCode 可以连接许多模型提供商,OpenChamber 还为 Claude 等订阅提供集成。
Paseo 在 Agent harness 层面支持多 Provider。它原生支持 Claude Code、Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过 ACP 目录支持另外 30 多个 Agent,以及任意自定义的 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
OpenChamber 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## 工作区与审查 {#workspaces-and-review}
两者都支持隔离的 Git worktree、终端、diff 审查、浏览器预览、GitHub 工作流、定时任务和远程机器。
OpenChamber 另外提供多次运行对比、Fusion 以及引导式的变更讲解。Paseo 另外提供拆分窗格和标签页、原生的 Files 和 Changes 浏览器,以及在所有客户端中可用的完整拉取请求工作流。
## 自动化 {#automation}
Paseo 通过 CLI、TypeScript SDK 和 MCP 服务器提供工作区和 Agent 操作。这些接口可以创建工作区、启动 Agent、跟踪进度、发送消息和管理定时任务。
OpenChamber 的 CLI 用于运行和管理它的服务端、远程访问和更新。它的 Agent Control Tool 让 OpenCode Agent 可以创建和继续会话、创建 worktree 以及管理定时任务。
## 移动端与语音 {#mobile-and-voice}
两者都提供 iOS 和 Android 客户端,并支持语音输入和语音回复。Paseo 的移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。OpenChamber 用 Capacitor 打包它的 Web 界面,并在 WebView 周围添加原生集成。
## 对比 {#comparison}
| | Paseo | OpenChamber |
| ------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------- |
| 许可证 | 开源(Apache-2.0) | 开源(MIT) |
| 需要账号 | 否 | - |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS、Linux、Windows) |
| 移动应用 | 是(原生,与桌面端功能一致) | 是(Capacitor WebView) |
| CLI | 是(应用能做的都能做) | 是(运行和管理服务端) |
| 远程机器 | 是(daemon 可以安装在任何地方) | 是(服务端运行在另一台机器上) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | - |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | - |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | OpenCode |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree、多次运行对比) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | - |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | 是(Agent Control Tool,仅限 OpenCode Agent) |
| 编辑器 | 是 | - |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | GitHub(issue、PR、检查、审查、合并) |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是(预览) |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | 预览和端口检测 |
| 定时任务与 heartbeat | 是 | 是(定时任务) |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | 语音输入和语音回复 |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Conductor 对比](/alternatives/conductor)、[Paseo 与 Superset 对比](/alternatives/superset)、[Paseo 与 Happy Coder 对比](/alternatives/happy-coder)。
@@ -0,0 +1,124 @@
---
title: 支持原生移动端和多 Provider 编排的 OpenCode Desktop 替代品
description: Paseo 是一个 OpenCode Desktop 替代品,适合需要原生移动应用、自托管 daemon,并希望把 OpenCode 与 Claude Code、Codex、Copilot 等一起使用的开发者。
nav: OpenCode Desktop
order: 56
---
# Paseo 与 OpenCode Desktop 对比
OpenCode Desktop 是 OpenCode 的桌面应用。它目前以 beta 版本提供,支持 macOS、Windows 和 Linux。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
OpenCode 通过 OpenCode Agent 运行时连接许多模型提供商。Paseo 在运行 OpenCode 的同时,还运行独立的 Claude Code、Codex、Pi、ACP 以及自定义 Agent harness。
OpenCode 提供终端、IDE、Web 以及 beta 版的桌面界面。Paseo 另外提供原生的 iOS 和 Android 客户端、托管的 worktree 和服务、拉取请求工作流以及应用插件。
## 架构 {#architecture}
Paseo 在你的机器上运行一个 daemon。桌面端、Web、移动端和 CLI 客户端通过 websocket 连接到它。daemon 使用你已安装的 CLI 和凭据,以本地进程的方式启动 OpenCode 和其他 Provider。
OpenCode Desktop 是 OpenCode 的桌面应用。OpenCode 提供终端界面、桌面应用、IDE 扩展、Web 界面和各种集成。
## Provider {#providers}
OpenCode 是一个支持多模型的编码 Agent。它可以通过自己的 Provider 系统连接许多 LLM 提供商,包括 OpenCode Zen、本地模型和 API 提供商。
Paseo 在 Agent harness 层面支持多 Provider。它原生支持 OpenCode、Claude Code、Codex、Pi、Antigravity 和 Muse Code,并通过应用内目录支持另外 30 多个 Agent,包括 GitHub Copilot、Cursor、Gemini CLI 和 Amp。Paseo 支持 [Agent Client Protocol](https://agentclientprotocol.com),因此任何 ACP Agent 都可以使用。自定义 Provider 可以运行任何 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
OpenCode Desktop 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## 桌面平台 {#desktop-platforms}
Paseo 支持 macOS、Linux 和 Windows。OpenCode 为同样的平台提供 beta 版桌面构建。
## 移动端 {#mobile}
移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。
OpenCode Desktop 是一个桌面应用。OpenCode 也有 Web 和分享链接工作流,但没有原生的移动应用。
## 窗格 {#panes}
Paseo 应用支持拆分窗格和标签页(⌘D 垂直拆分,⌘⇧D 水平拆分)。窗格可以是 Agent、终端、diff 查看器,以及用于测试运行中服务的浏览器。
OpenCode 提供终端、IDE 和桌面界面。它的核心工作流围绕 OpenCode 会话展开。
## GitHub {#github}
Paseo 应用可以完成提交、推送、创建 PR、关注检查和审查,以及合并。
OpenCode 提供 GitHub 和 GitLab 集成,OpenCode 会话可以通过它的 Agent 工作流修改和审查代码。
## CLI 与自动化 {#cli-and-automation}
OpenCode 有自己的终端界面、CLI、IDE 扩展、GitHub 和 GitLab 集成以及分享链接。
Paseo 的 CLI 控制的是与应用相同的 daemon:
```bash
paseo run --provider opencode "implement OAuth"
paseo run --provider claude --worktree refactor-auth "refactor auth"
paseo run --host devbox:6767 "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` 连接到远程 daemon。`paseo schedule` 按 cron 运行 Agent。MCP 服务器让其他 Agent 能够创建 worktree、启动 Agent、打开终端和发送提示词。
## Worktree 与服务 {#worktrees-and-services}
Paseo 在各自的 Git worktree 中运行每个 Agent。每个 worktree 都有独立的开发服务器 URL,例如 `web--fix-auth--my-app.localhost`,因此并行的 Agent 不会争抢端口。
OpenCode 支持在同一个项目上进行多会话工作。如果你希望为 OpenCode 会话提供 worktree 隔离,Paseo 可以在 Paseo 工作区中启动 OpenCode 来实现这一点。
## 隐私与源代码 {#privacy-and-source}
两者都是开源的。
Paseo 采用 Apache-2.0 许可,通过你掌控的 daemon 运行你的 Agent。OpenCode 是开源的,并表示默认不会存储你的代码或上下文数据。你创建的 OpenCode 分享链接是公开的。
## 语音 {#voice}
Paseo 支持语音输入和实时语音模式。语音转文字和文字转语音都可以在你的设备上本地运行。
## 对比 {#comparison}
| | Paseo | OpenCode Desktop |
| ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------- |
| 许可证 | 开源(Apache-2.0) | 开源(MIT) |
| 需要账号 | 否 | - |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(beta,macOS、Linux、Windows) |
| 移动应用 | 是(原生,与桌面端功能一致) | 否 |
| CLI | 是(应用能做的都能做) | 是(OpenCode CLI) |
| 远程机器 | 是(daemon 可以安装在任何地方) | OpenCode 服务端 |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | - |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | - |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | OpenCode(支持许多模型提供商) |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 多个会话,没有内置的 worktree 管理 |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | - |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | OpenCode 内的 MCP 支持 |
| 编辑器 | 是 | - |
| 终端 | 是 | OpenCode 终端工作流 |
| Diff 审查 | 是(评论会发送给 Agent) | - |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | GitHub、GitLab 集成 |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 否 |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | 否 |
| 定时任务与 heartbeat | 是 | - |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | 否 |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Codex App 对比](/alternatives/codex-app)、[Paseo 与 Claude Desktop 对比](/alternatives/claude-desktop)、[Paseo 与 OpenChamber 对比](/alternatives/openchamber)。
@@ -0,0 +1,119 @@
---
title: 面向 Agent 式开发的 Orca 替代品
description: Paseo 是一个开源的 Agent 式开发环境,提供并行 worktree、编辑器、终端、diff、拉取请求以及内置浏览器。
nav: Orca
order: 57
---
# Paseo 与 Orca 对比
Orca 是一款以 MIT 许可发布的桌面应用,用于在并行的 Git worktree 中运行终端编码 Agent。它包括一个移动伴侣应用、SSH worktree、远程 Orca 服务器、嵌入式浏览器和 CLI。
Paseo 是一个开源的 Agent 式开发环境。你可以在各自独立的 worktree 中并行运行多个 Agent,编辑文件,审查 diff 和拉取请求,并用终端和内置的桌面浏览器测试你的应用。它以 Apache-2.0 许可提供桌面端、移动端、Web 和 CLI。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
Orca 围绕终端构建。每个 Agent 都运行在一个终端窗格中,实验性的 Chat UI 可以把受支持的 Agent 显示为聊天记录。Orca 围绕这些终端内置了大量工具。
Paseo 围绕与 Agent 的聊天构建。应用内置了每个开发者都会用到的功能:编辑器、终端、diff、拉取请求和浏览器。只有部分团队需要的功能,例如 issue 看板或云端虚拟机,则以插件的形式按需安装,因此你不用的东西不会碍事。
Orca 由 Stably AI 开发,这是一家获得风险投资的公司(Y Combinator)。Paseo 是独立的开源项目。
## 架构与远程机器 {#architecture-and-remote-machines}
Paseo 桌面应用会自动启动并管理一个本地 daemon。这个 daemon 负责 Agent 和工作区,因此其他客户端可以连接到同一个开发环境。桌面端、Web、移动端和 CLI 客户端可以直接连接、通过 SSH 连接,或者通过可选的端到端加密中继连接。你可以在笔记本、虚拟机、家用服务器或 Docker 中运行 daemon,并从任意客户端连接到其中任何一个。请参阅[连接](/docs/connectivity)。
Orca 默认在其桌面应用内运行,并通过两种方式访问其他机器。SSH worktree 让 Orca 运行时留在你的笔记本上,而在远程主机上运行选定的 worktree 和终端。远程 Orca 服务器(beta)在另一台机器上运行 Orca 桌面端或 `orca serve`,并让客户端与之配对。Orca 的文档建议为远程 Orca 服务器使用 Tailscale 或其他私有网络。
## 移动端与中继 {#mobile-and-the-relay}
两者都有 iOS 和 Android 应用。
Paseo 的移动应用就是完整的应用,在 iOS 和 Android 上原生运行,功能与桌面端完全一致。它们已在 App Store 和 Google Play 上架。要从手机连接,请开启中继并扫描二维码。daemon 主动向外连接到中继,你的手机在中继处与它会合,因此你不需要 VPN、端口转发、SSH 或账号。通信是端到端加密的,[中继服务器](https://github.com/getpaseo/paseo-relay)也是开源的。你也可以通过 Tailscale 直接连接。
Orca 的移动伴侣应用处于 beta 阶段,iOS 版在 App Store 上提供,Android 版以 APK 下载提供。它可以回复 Agent、以终端或 Chat UI 打开会话、提交更改,以及在配对的桌面端上创建工作区。Orca Relay 同样处于 beta 阶段,用于在手机与桌面不在同一网络时连接手机,并且需要登录 Orca 账号。没有 Orca Relay 时,手机需要通过局域网或 Tailscale 访问桌面端。
## 聊天 {#chat}
Paseo 在桌面端、Web 和移动端的主界面都是与 Agent 的聊天。Paseo 通过结构化的 harness 运行受支持的 Provider,因此消息、工具调用、权限请求、模式、斜杠命令和文件附件都来自 Agent 自己的协议。
Orca 的主界面是 Agent 的终端。它的 Chat UI 是在同一个终端会话之上的实验性视图,需要在 **Settings → Experimental** 中开启。它会解析 Claude、Codex、Grok 和 OMP 的聊天记录,而终端仍然是事实来源。
## Provider {#providers}
Orca 可以在终端中运行任何 CLI Agent,并列出了 30 多个受支持的 Agent。
Paseo 通过原生的结构化 harness 运行 Claude Code、Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过 ACP 目录支持另外 30 多个 Agent,以及任意自定义的 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。插件可以添加工作区面板、侧边栏项、Command Center 条目、斜杠命令、Composer 胶囊、设置界面、主题、附件来源和 Agent Provider。它的服务端代码在 daemon 旁边运行,客户端代码则在每个已连接的客户端中运行:桌面端、浏览器、iOS 和 Android。
社区插件列在 [paseo.sh/plugins](/plugins) 上。
Orca 有一个默认关闭的实验性插件系统。Orca 插件可以添加在沙箱框架中渲染的桌面面板、命令、快捷键、语言包、虚拟机配方和 Agent 配置,并且可以从 git 市场安装。插件 worker 在桌面电脑上运行,Orca 没有公开文档化其移动应用中的插件界面。
## 范围 {#scope}
Orca 在核心应用中包含了许多工具:Monaco 代码编辑器、用于把浏览器中的页面元素发送给 Agent 的 Design Mode、computer use、Linear 和 Jira 看板、带用量跟踪的 Agent 账号切换,以及每个工作区的云端虚拟机配方。如果你希望在一个应用中直接获得这些功能而无需扩展,Orca 提供了它们。
Paseo 包含编辑器、终端、评论会发送给 Agent 的 diff 审查、GitHub、GitLab、Gitea、Forgejo 和 Codeberg 上的拉取请求、带元素选择器和 Agent 浏览器工具的浏览器、套餐用量、定时任务和 heartbeat。看板和云端虚拟机则由插件添加。
## 工作区与审查 {#workspaces-and-review}
两者都提供 Git worktree、拆分窗格、终端、diff 审查、应用内浏览器、拉取请求工作流以及定时运行 Agent。
Paseo 提供评论会发送给 Agent 的 diff 审查,并可以在 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 上创建拉取请求。它还为每个 worktree 提供独立的服务 URL,例如 `web--fix-auth--my-app.localhost`,因此并行的开发服务器不会争抢端口。Orca 为 SSH worktree 提供端口转发,并支持 GitHub 以及 GitLab、Bitbucket、Azure DevOps 和 Gitea 上的拉取请求。
## 自动化 {#automation}
Paseo 通过 CLI、TypeScript SDK 和 MCP 服务器提供工作区和 Agent 操作:
```bash
paseo run --provider codex "implement OAuth"
paseo --host ssh://user@devbox run --cwd /srv/app "run the test suite"
paseo ls
paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
Orca 的 CLI 可以创建 worktree、驱动它的浏览器并管理自动化。Orca 会安装技能,让 Agent 可以调用同样的命令。
## 账号与遥测 {#accounts-and-telemetry}
Paseo 不需要账号,也不收集遥测数据。
本地使用 Orca 不需要账号。Orca Relay 和共享制品需要 Orca 账号。打包发布的 Orca 构建会发送匿名使用遥测数据,你可以关闭它。
## 对比 {#comparison}
| | Paseo | Orca |
| ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------- |
| 许可证 | 开源(Apache-2.0) | 开源(MIT) |
| 需要账号 | 否 | 本地使用不需要,中继需要 Orca 账号 |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS、Linux、Windows) |
| 移动应用 | 是(原生,与桌面端功能一致) | 是(伴侣应用,beta) |
| CLI | 是(应用能做的都能做) | 是(worktree、浏览器、自动化) |
| 远程机器 | 是(daemon 可以安装在任何地方) | 是(SSH worktree、远程 Orca 服务器 beta) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | 是(beta,需要 Orca 账号) |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | 是(局域网、Tailscale) |
| SSH 访问 | 是 | 是(SSH worktree) |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | 终端中的任意 CLI Agent,列出了 30 多个 |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | 是(主界面,实验性 Chat UI) |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | 是(Agent 通过已安装的技能调用 Orca CLI) |
| 编辑器 | 是 | 是(Monaco) |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | GitHub、GitLab、Bitbucket、Azure DevOps、Gitea |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是(Design Mode) |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | SSH worktree 的端口转发 |
| 定时任务与 heartbeat | 是 | 是(定时运行 Agent) |
| 套餐用量 | 是 | 是(用量跟踪、账号切换) |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 实验性,仅限桌面端 |
| 语音 | 是(本地语音输入、实时语音) | 语音输入 |
| 遥测 | 无 | 匿名使用数据,可选择退出 |
另请参阅:[Paseo 与 Conductor 对比](/alternatives/conductor)、[Paseo 与 Superset 对比](/alternatives/superset)、[Paseo 与 OpenChamber 对比](/alternatives/openchamber)。
@@ -0,0 +1,95 @@
---
title: 支持原生移动端的开源 Superset 替代品
description: Paseo 是一个采用 Apache-2.0 许可的 Superset 替代品,无需账号即可在本地运行,并支持覆盖其服务端和客户端的应用插件。
nav: Superset
order: 51
---
# Paseo 与 Superset 对比
Superset 是一个源代码可见的桌面工作区,用于在并行的 Git worktree 中运行 CLI 编码 Agent。它包括 CLI、SDK、MCP 服务器和远程主机。
Paseo 可以从桌面端、移动端、Web 和 CLI 编排编码 Agent。它以 Apache-2.0 许可开源。
![Paseo 桌面端和移动端应用](/hero-mockup.png)
## 主要区别 {#the-main-difference}
Superset 提供一个以终端为中心的工作区,用于在并行的 Git worktree 中运行许多 CLI Agent。它需要 Superset 账号和 GitHub 登录,其源代码以 Elastic License 2.0 发布。
Paseo 无需账号即可在本地运行,采用 Apache 2.0 许可,为受支持的 Agent harness 提供结构化界面,并支持带有服务端行为和原生客户端组件的应用插件。
## 许可证 {#license}
Paseo 以 Apache-2.0 许可开源。你可以审计、fork、修改和重新分发它。
Superset 以 Elastic License 2.0 发布源代码。你可以使用和修改它,但该许可证限制把 Superset 作为托管服务提供,也限制绕过受许可证保护的功能。
## 架构与访问 {#architecture-and-access}
两者都在你掌控的机器上运行 Agent,并且可以连接到远程主机。
Paseo 的 daemon 独立于它的客户端运行。桌面端、Web、移动端、CLI、SDK 和 MCP 客户端都可以直接连接,本地使用不需要账号。
Superset 在打开应用时需要 Superset 账号和 GitHub 登录。它的同步、远程访问和团队工作流使用 Superset 的云服务。
## Provider {#providers}
Superset 支持许多基于 CLI 的编码 Agent,并允许你添加自定义的终端 Agent。
Paseo 通过原生的结构化 harness 运行 Claude Code、Codex、OpenCode、Pi、Antigravity 和 Muse Code,并通过 ACP 目录支持另外 30 多个 Agent,以及任意自定义的 CLI Agent。查看[所有支持的 Provider](/agents)。
## 应用插件 {#application-plugins}
[Paseo 插件](/docs/plugins)用于扩展 Paseo 本身。它们可以添加服务端行为和原生客户端组件,例如工作区面板、侧边栏项、Composer 附件、主题和 Command Center 条目,并在桌面端、浏览器、iOS 和 Android 上运行。
Superset 没有公开文档化的应用扩展 API,无法同时添加服务端行为和原生客户端组件。
## 工作区与审查 {#workspaces-and-review}
两者都提供 Git worktree、拆分窗格、终端、diff 审查、应用内浏览器、GitHub 工作流、定时任务和远程主机。
Paseo 为受支持的 Provider 提供结构化的聊天界面,支持模式、斜杠命令、工具调用和文件附件。Superset 可以在终端窗格中运行任何 CLI Agent,并为受支持的 Agent 添加生命周期状态。
Paseo 还为每个 worktree 提供独立的服务 URL,例如 `web--fix-auth--my-app.localhost`,因此并行的开发服务器不会争抢端口。
## 自动化 {#automation}
两者都通过 CLI、TypeScript SDK 和 MCP 服务器提供工作区和 Agent 操作。它们可以创建工作区、启动 Agent、跟踪进度和管理定时工作。
## 价格 {#pricing}
Paseo 免费,没有席位限制。
Superset 有一个免费的个人版。团队功能、远程访问和集成属于它的付费套餐。
## 对比 {#comparison}
| | Paseo | Superset |
| ------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------ |
| 许可证 | 开源(Apache-2.0) | 源代码可见(Elastic License 2.0) |
| 需要账号 | 否 | 是,需要 GitHub 登录 |
| 桌面应用 | 是(一键安装,内置 daemon) | 是(macOS,Linux 为实验性) |
| 移动应用 | 是(原生,与桌面端功能一致) | 即将推出 |
| CLI | 是(应用能做的都能做) | 是(另有 SDK 和 MCP 服务器) |
| 远程机器 | 是(daemon 可以安装在任何地方) | 是(主机服务端;付费套餐通过 Superset 云提供远程访问) |
| 内置中继 | 是(需手动开启,端到端加密,无需账号) | Superset 云服务(付费套餐) |
| 直接网络访问 | 是(局域网、Tailscale、VPN) | - |
| SSH 访问 | 是 | - |
| Provider | Claude Code、Codex、OpenCode、Pi、Antigravity、Muse Code,另有 30 多个 | CLI Agent、自定义终端 Agent |
| 并行 Agent | 是(隔离的 worktree,可跨机器) | 是(Git worktree) |
| 终端 Agent | 是(在终端中运行任意 Agent,完成时收到通知) | 是(为受支持的 Agent 提供生命周期状态) |
| Agent 编排 | 是(Agent 可以创建 worktree 并启动其他 Agent,可跨 Provider) | 是(CLI、SDK 和 MCP 服务器可以创建工作区并启动 Agent) |
| 编辑器 | 是 | - |
| 终端 | 是 | 是 |
| Diff 审查 | 是(评论会发送给 Agent) | 是 |
| 应用内拉取请求 | GitHub、GitLab、Gitea、Forgejo、Codeberg | GitHub |
| 应用内浏览器 | 是(元素选择器、Agent 浏览器工具) | 是 |
| 每个 worktree 的开发服务器 URL | 是(`web--fix-auth--my-app.localhost`) | 端口检测 |
| 定时任务与 heartbeat | 是 | 是(定时任务) |
| 套餐用量 | 是 | - |
| 插件 | 是(新界面、面板、Agent hook 和 Provider,同一个插件可在桌面端、Web 和移动端运行) | 否 |
| 语音 | 是(本地语音输入、实时语音) | - |
| 遥测 | 无 | - |
另请参阅:[Paseo 与 Conductor 对比](/alternatives/conductor)、[Paseo 与 OpenChamber 对比](/alternatives/openchamber)、[Paseo 与 Happy Coder 对比](/alternatives/happy-coder)。
+62 -419
View File
@@ -1,10 +1,15 @@
// Source of truth for per-agent marketing landing pages.
// To add a new agent, append an entry here and create a 4-line route file at
// `src/routes/<slug>.tsx`. The sitemap (vite.config) reads `AGENT_PAGE_SLUGS`.
import type { TFunction } from "i18next";
export interface AgentPage {
// Source of truth for per-agent marketing landing pages. Each page's copy lives in the
// `agentPages.<slug>` translation resources. To add an agent, append it here, add its copy to every
// resource file, and create a 4-line route file at `src/routes/{-$locale}/<slug>.tsx`.
export interface AgentPageIdentity {
slug: string;
name: string;
}
export interface AgentPage extends AgentPageIdentity {
title: string;
subtitle: string;
metaTitle: string;
@@ -12,422 +17,60 @@ export interface AgentPage {
}
export const AGENT_PAGES = [
{
slug: "claude-code",
name: "Claude Code",
title: "Open source app for Claude Code",
subtitle:
"Run Claude Code on your machine, drive it from your phone or desktop. Launch agents, watch them work, review and merge from anywhere.",
metaTitle: "Claude Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Claude Code. Run agents on your machine, monitor progress, review diffs, and merge from anywhere. Self-hosted, your code stays local.",
},
{
slug: "codex",
name: "Codex",
title: "Open source app for Codex",
subtitle:
"Run OpenAI's Codex on your machine, drive it from your phone or desktop. Same setup, same machine, no laptop required.",
metaTitle: "Codex Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for OpenAI Codex. Launch agents on your machine, monitor progress, and ship code from anywhere. Self-hosted.",
},
{
slug: "opencode",
name: "OpenCode",
title: "Open source app for OpenCode",
subtitle:
"Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
metaTitle: "OpenCode Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for OpenCode. Launch agents on your machine, watch them work, ship code from anywhere. Self-hosted.",
},
{
slug: "pi",
name: "Pi Agent",
title: "Open source app for the Pi coding agent",
subtitle:
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
metaTitle: "Pi Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
},
{
slug: "omp",
name: "OMP (Oh My Pi)",
title: "Open source app for OMP (Oh My Pi)",
subtitle:
"Run OMP (Oh My Pi) on your machine, drive it from your phone or desktop. Self-hosted and open source.",
metaTitle: "OMP (Oh My Pi) Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for OMP (Oh My Pi). Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
},
{
slug: "cursor",
name: "Cursor",
title: "Open source app for Cursor",
subtitle:
"Send tasks to Cursor on your machine, drive it from your phone or desktop. Review the diff anywhere.",
metaTitle: "Cursor Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Cursor. Launch tasks on your machine, monitor output, review diffs, and merge from anywhere. Self-hosted.",
},
{
slug: "muse-code",
name: "Muse Code",
title: "Open source app for Muse Code",
subtitle:
"Run Meta's Muse Code on your machine, drive it from your phone or desktop. Choose models, review tool calls, and answer approvals from anywhere.",
metaTitle: "Muse Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Meta Muse Code. Run your installed Muse CLI, watch streamed turns, and answer approvals. See setup and version limitations in the docs.",
},
{
slug: "antigravity",
name: "Antigravity",
title: "Open source app for Antigravity",
subtitle:
"Run your installed Antigravity CLI on your machine and drive it from your phone or desktop.",
metaTitle: "Antigravity Mobile and Desktop App, Open Source",
metaDescription:
"Run your installed Antigravity CLI through Paseo. Watch agents work and review changes from your phone or desktop. Self-hosted, your code stays on your machine.",
},
{
slug: "copilot",
name: "GitHub Copilot",
title: "Open source app for GitHub Copilot",
subtitle:
"Drive GitHub Copilot from your phone or desktop. Same account, same machine, ship without sitting down at your desk.",
metaTitle: "GitHub Copilot Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for GitHub Copilot. Launch sessions on your machine, monitor progress, merge from anywhere.",
},
{
slug: "gemini",
name: "Gemini CLI",
title: "Open source app for Gemini CLI",
subtitle:
"Run Google's Gemini CLI on your machine, drive it from your phone or desktop. Real coding work, no laptop required.",
metaTitle: "Gemini CLI Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Google's Gemini CLI. Launch agents on your machine, monitor progress, and ship from anywhere. Self-hosted.",
},
{
slug: "hermes",
name: "Hermes Agent",
title: "Open source app for the Hermes agent",
subtitle:
"Run Nous Research's Hermes Agent on your machine, drive it from your phone or desktop.",
metaTitle: "Hermes Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Nous Research's Hermes Agent. Launch sessions on your machine, monitor progress, ship code from anywhere.",
},
{
slug: "qwen-code",
name: "Qwen Code",
title: "Open source app for Qwen Code",
subtitle: "Run Alibaba's Qwen Code on your machine, drive it from your phone or desktop.",
metaTitle: "Qwen Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Alibaba's Qwen Code. Launch agents on your machine, monitor progress, and merge from anywhere.",
},
{
slug: "kimi",
name: "Kimi Code CLI",
title: "Open source app for Kimi Code CLI",
subtitle:
"Run Moonshot AI's Kimi Code CLI on your machine, drive it from your phone or desktop.",
metaTitle: "Kimi Code CLI Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Moonshot AI's Kimi Code CLI. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "amp",
name: "Amp",
title: "Open source app for the Amp coding agent",
subtitle:
"Run Sourcegraph's Amp on your machine, drive it from your phone or desktop. Frontier coding, no laptop required.",
metaTitle: "Amp Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Amp, Sourcegraph's frontier coding agent. Launch tasks on your machine, monitor progress, ship from anywhere.",
},
{
slug: "auggie",
name: "Auggie CLI",
title: "Open source app for Auggie CLI",
subtitle: "Run Augment Code's Auggie CLI on your machine, drive it from your phone or desktop.",
metaTitle: "Auggie CLI Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Augment Code's Auggie CLI. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "cline",
name: "Cline",
title: "Open source app for the Cline coding agent",
subtitle:
"Run the Cline coding agent on your machine, drive it from your phone or desktop. Watch it work, jump in when needed.",
metaTitle: "Cline Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Cline, the autonomous coding agent. Launch tasks, monitor output, review diffs from anywhere.",
},
{
slug: "codebuddy",
name: "Codebuddy Code",
title: "Open source app for Codebuddy Code",
subtitle: "Run Tencent Cloud's Codebuddy on your machine, drive it from your phone or desktop.",
metaTitle: "Codebuddy Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Tencent Cloud's Codebuddy Code. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "cortex-code",
name: "Cortex Code",
title: "Open source app for Cortex Code",
subtitle:
"Run Snowflake's Cortex Code on your machine, drive it from your phone or desktop. No laptop required.",
metaTitle: "Cortex Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Snowflake's Cortex Code. Launch agents on your machine, monitor progress, ship from anywhere.",
},
{
slug: "corust",
name: "Corust Agent",
title: "Open source app for the Corust agent",
subtitle:
"Build Rust with the Corust agent on your machine, drive it from your phone or desktop.",
metaTitle: "Corust Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for the Corust Rust-focused coding agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "crow",
name: "crow-cli",
title: "Open source app for crow-cli",
subtitle:
"Run crow-cli, the minimal ACP-native coding agent, on your machine. Drive it from your phone or desktop.",
metaTitle: "Crow-CLI Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for crow-cli, the minimal ACP-native coding agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "deepagents",
name: "DeepAgents",
title: "Open source app for DeepAgents",
subtitle:
"Run LangChain's DeepAgents on your machine, drive it from your phone or desktop. Batteries included.",
metaTitle: "DeepAgents Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for the LangChain DeepAgents coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "deepseek-tui",
name: "CodeWhale",
title: "Open source app for CodeWhale",
subtitle:
"Run CodeWhale's DeepSeek V4 terminal coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "CodeWhale Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for CodeWhale. Launch coding sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "dimcode",
name: "DimCode",
title: "Open source app for DimCode",
subtitle:
"Leading models, one command. Run DimCode on your machine, drive it from your phone or desktop.",
metaTitle: "DimCode Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for DimCode, the multi-model coding agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "dirac",
name: "Dirac",
title: "Open source app for the Dirac coding agent",
subtitle:
"Run Dirac's hash-anchored parallel edits on your machine, drive it from your phone or desktop.",
metaTitle: "Dirac Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for the Dirac coding agent. Hash-anchored parallel edits, AST manipulation, ship from anywhere.",
},
{
slug: "factory-droid",
name: "Factory Droid",
title: "Open source app for Factory Droid",
subtitle:
"Run Factory's Droid coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "Factory Droid Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Factory AI's Droid coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "fast-agent",
name: "fast-agent",
title: "Open source app for fast-agent",
subtitle:
"Run fast-agent, the multi-provider coding agent, on your machine. Drive it from your phone or desktop.",
metaTitle: "Fast-Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for fast-agent, the multi-provider coding agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "glm",
name: "GLM Agent",
title: "Open source app for the GLM agent",
subtitle:
"Run Zhipu AI's GLM coding agent on your machine, drive it from your phone or desktop. Streaming, mid-session model switching.",
metaTitle: "GLM Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Zhipu AI's GLM coding agent. Launch sessions on your machine, monitor progress, ship code from anywhere.",
},
{
slug: "goose",
name: "goose",
title: "Open source app for the goose coding agent",
subtitle:
"Run Block's goose on your machine, drive it from your phone or desktop. Local, extensible, open source.",
metaTitle: "Goose Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Block's goose, the local open-source AI agent. Launch tasks on your machine, ship from anywhere.",
},
{
slug: "junie",
name: "Junie",
title: "Open source app for the Junie coding agent",
subtitle:
"Run JetBrains' Junie on your machine, drive it from your phone or desktop. Real work, no IDE required.",
metaTitle: "Junie Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for JetBrains' Junie coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "kilo",
name: "Kilo Code",
title: "Open source app for Kilo Code",
subtitle:
"Run Kilo Code on your machine, drive it from your phone or desktop. Send tasks, watch them ship.",
metaTitle: "Kilo Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Kilo Code. Launch tasks on your machine via Kilo CLI, monitor progress, merge from anywhere.",
},
{
slug: "minion-code",
name: "Minion Code",
title: "Open source app for Minion Code",
subtitle:
"Run Minion Code's framework agent on your machine, drive it from your phone or desktop.",
metaTitle: "Minion Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Minion Code, the Minion-framework coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "mistral-vibe",
name: "Mistral Vibe",
title: "Open source app for Mistral Vibe",
subtitle:
"Run Mistral's open-source Vibe assistant on your machine, drive it from your phone or desktop.",
metaTitle: "Mistral Vibe Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Mistral's Vibe coding assistant. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "nova",
name: "Nova",
title: "Open source app for the Nova coding agent",
subtitle: "Run Compass AI's Nova on your machine, drive it from your phone or desktop.",
metaTitle: "Nova Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Compass AI's Nova coding agent. Launch sessions on your machine, monitor progress, merge from anywhere.",
},
{
slug: "poolside",
name: "Poolside",
title: "Open source app for the Poolside coding agent",
subtitle:
"Drive Poolside's coding agent from your phone or desktop. Kick off the work, watch it land.",
metaTitle: "Poolside Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Poolside's coding agent. Launch tasks on your machine, monitor progress, ship from anywhere.",
},
{
slug: "qoder",
name: "Qoder CLI",
title: "Open source app for Qoder CLI",
subtitle:
"Run the Qoder agentic coding assistant on your machine, drive it from your phone or desktop.",
metaTitle: "Qoder CLI Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Qoder, the agentic coding assistant. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "sigit",
name: "siGit Code",
title: "Open source app for siGit Code",
subtitle:
"Run siGit's local-first coding agent on your machine, drive it from your phone or desktop. Optional on-device LLM inference.",
metaTitle: "SiGit Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for siGit Code, the local-first coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "stakpak",
name: "Stakpak",
title: "Open source app for the Stakpak DevOps agent",
subtitle:
"Run Stakpak's Rust-based DevOps agent on your machine, drive it from your phone or desktop.",
metaTitle: "Stakpak DevOps Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Stakpak, the Rust-based DevOps agent. Launch tasks on your machine, monitor from anywhere.",
},
{
slug: "vtcode",
name: "VT Code",
title: "Open source app for VT Code",
subtitle:
"Run VT Code's multi-provider coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "VT Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for VT Code, the multi-provider coding agent. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "agoragentic",
name: "Agoragentic",
title: "Open source app for Agoragentic",
subtitle:
"Run Agoragentic's 174+ AI capabilities on your machine, drive it from your phone or desktop.",
metaTitle: "Agoragentic Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Agoragentic, the AI agent marketplace. Launch sessions on your machine, ship from anywhere.",
},
{
slug: "autohand",
name: "Autohand Code",
title: "Open source app for Autohand Code",
subtitle:
"Run Autohand AI's coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "Autohand Code Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for Autohand AI's coding agent. Launch sessions on your machine, monitor progress, ship from anywhere.",
},
{
slug: "grok",
name: "Grok",
title: "Open source app for Grok",
subtitle:
"Run xAI's Grok Build coding agent on your machine, drive it from your phone or desktop.",
metaTitle: "Grok Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for xAI's Grok Build coding agent. Launch sessions on your machine, monitor progress, and ship from anywhere. Self-hosted.",
},
] as const satisfies readonly AgentPage[];
{ slug: "claude-code", name: "Claude Code" },
{ slug: "codex", name: "Codex" },
{ slug: "opencode", name: "OpenCode" },
{ slug: "pi", name: "Pi Agent" },
{ slug: "omp", name: "OMP (Oh My Pi)" },
{ slug: "cursor", name: "Cursor" },
{ slug: "muse-code", name: "Muse Code" },
{ slug: "antigravity", name: "Antigravity" },
{ slug: "copilot", name: "GitHub Copilot" },
{ slug: "gemini", name: "Gemini CLI" },
{ slug: "hermes", name: "Hermes Agent" },
{ slug: "qwen-code", name: "Qwen Code" },
{ slug: "kimi", name: "Kimi Code CLI" },
{ slug: "amp", name: "Amp" },
{ slug: "auggie", name: "Auggie CLI" },
{ slug: "cline", name: "Cline" },
{ slug: "codebuddy", name: "Codebuddy Code" },
{ slug: "cortex-code", name: "Cortex Code" },
{ slug: "corust", name: "Corust Agent" },
{ slug: "crow", name: "crow-cli" },
{ slug: "deepagents", name: "DeepAgents" },
{ slug: "deepseek-tui", name: "CodeWhale" },
{ slug: "dimcode", name: "DimCode" },
{ slug: "dirac", name: "Dirac" },
{ slug: "factory-droid", name: "Factory Droid" },
{ slug: "fast-agent", name: "fast-agent" },
{ slug: "glm", name: "GLM Agent" },
{ slug: "goose", name: "goose" },
{ slug: "junie", name: "Junie" },
{ slug: "kilo", name: "Kilo Code" },
{ slug: "minion-code", name: "Minion Code" },
{ slug: "mistral-vibe", name: "Mistral Vibe" },
{ slug: "nova", name: "Nova" },
{ slug: "poolside", name: "Poolside" },
{ slug: "qoder", name: "Qoder CLI" },
{ slug: "sigit", name: "siGit Code" },
{ slug: "stakpak", name: "Stakpak" },
{ slug: "vtcode", name: "VT Code" },
{ slug: "agoragentic", name: "Agoragentic" },
{ slug: "autohand", name: "Autohand Code" },
{ slug: "grok", name: "Grok" },
] as const satisfies readonly AgentPageIdentity[];
export const AGENT_PAGE_SLUGS: readonly string[] = AGENT_PAGES.map((p) => p.slug);
export type AgentPageSlug = (typeof AGENT_PAGES)[number]["slug"];
const AGENT_PAGE_MAP_INTERNAL: Record<string, AgentPage> = Object.fromEntries(
AGENT_PAGES.map((p) => [p.slug, p]),
);
export function getAgentPage(slug: string): AgentPage {
const page = AGENT_PAGE_MAP_INTERNAL[slug];
/** An agent's landing page copy in the translator's language. */
export function getAgentPage(slug: AgentPageSlug, t: TFunction): AgentPage {
const page = AGENT_PAGES.find((candidate) => candidate.slug === slug);
if (!page) throw new Error(`Unknown agent page slug: ${slug}`);
return page;
return {
...page,
title: t(`agentPages.${slug}.title`),
subtitle: t(`agentPages.${slug}.subtitle`),
metaTitle: t(`agentPages.${slug}.metaTitle`),
metaDescription: t(`agentPages.${slug}.metaDescription`),
};
}
+24 -31
View File
@@ -1,45 +1,35 @@
import type { Locale } from "~/i18n";
import { groupTranslations, inLocale, parseFrontmatter } from "~/markdown-content";
export interface AlternativePage {
slug: string;
name: string;
/** The language-neutral page path. */
href: string;
title: string;
description: string;
content: string;
/** The language `content` is written in. */
locale: Locale;
order: number;
}
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
const data: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
data[key] = value;
}
return { data, content: match[2] };
}
const alternativeModules = import.meta.glob("../content/alternatives/*.md", {
eager: true,
query: "?raw",
import: "default",
}) as Record<string, string>;
function loadAlternativePages(): AlternativePage[] {
const alternativeTranslations = groupTranslations(alternativeModules);
function loadAlternativePages(locale: Locale): AlternativePage[] {
const pages: AlternativePage[] = [];
for (const [path, raw] of Object.entries(alternativeModules)) {
const fileName = path.split("/").pop() ?? "";
for (const group of alternativeTranslations) {
const fileName = group.path.split("/").pop() ?? "";
const slug = fileName.replace(/\.md$/, "");
const { data, content } = parseFrontmatter(raw);
const localized = inLocale(group, locale);
const { data, content } = parseFrontmatter(localized.raw);
const order = Number.parseInt(data.order ?? "999", 10);
pages.push({
@@ -49,6 +39,7 @@ function loadAlternativePages(): AlternativePage[] {
title: data.title ?? slug,
description: data.description ?? "",
content,
locale: localized.locale,
order: Number.isFinite(order) ? order : 999,
});
}
@@ -57,15 +48,17 @@ function loadAlternativePages(): AlternativePage[] {
return pages;
}
let cached: AlternativePage[] | undefined;
const cached = new Map<Locale, AlternativePage[]>();
export function getAlternativePages(): AlternativePage[] {
if (!cached) cached = loadAlternativePages();
return cached;
export function getAlternativePages(locale: Locale): AlternativePage[] {
let pages = cached.get(locale);
if (!pages) {
pages = loadAlternativePages(locale);
cached.set(locale, pages);
}
return pages;
}
export function getAlternativePage(slug: string): AlternativePage | undefined {
return getAlternativePages().find((p) => p.slug === slug);
export function getAlternativePage(slug: string, locale: Locale): AlternativePage | undefined {
return getAlternativePages(locale).find((p) => p.slug === slug);
}
export const ALTERNATIVE_PAGE_SLUGS: readonly string[] = getAlternativePages().map((p) => p.slug);
+22 -14
View File
@@ -5,9 +5,14 @@ import { SPONSORS, sponsorHref } from "./sponsors";
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
const WEBSITE_PUBLIC = fileURLToPath(new URL("../../public", import.meta.url));
const READMES = ["README.md", "README.zh-CN.md", "README.ja.md", "README.ko.md"];
// Each README invites sponsors to the sponsor page in its own language, where the website has one.
const READMES = [
{ readme: "README.md", sponsorPage: "https://paseo.sh/sponsor" },
{ readme: "README.zh-CN.md", sponsorPage: "https://paseo.sh/zh-CN/sponsor" },
{ readme: "README.ja.md", sponsorPage: "https://paseo.sh/sponsor" },
{ readme: "README.ko.md", sponsorPage: "https://paseo.sh/sponsor" },
];
const README_SPONSOR_CELLS = 3;
const SPONSOR_PAGE_URL = "https://paseo.sh/sponsor";
describe("sponsorHref", () => {
it("tags the link with the placement it is shown in", () => {
@@ -48,17 +53,20 @@ describe("SPONSORS", () => {
expect(existsSync(`${WEBSITE_PUBLIC}${sponsor.logo.onLight}`)).toBe(true);
});
it.each(READMES)("fill the sponsors table in %s, followed by invitations", (readme) => {
const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length);
it.each(READMES)(
"fill the sponsors table in $readme, followed by invitations",
({ readme, sponsorPage }) => {
const openCells = Math.max(0, README_SPONSOR_CELLS - SPONSORS.length);
expect(readmeSponsorCells(readme)).toEqual([
...SPONSORS.map((sponsor) => ({
href: sponsorHref(sponsor, "readme"),
onDark: `packages/website/public${sponsor.logo.onDark}`,
onLight: `packages/website/public${sponsor.logo.onLight}`,
description: sponsor.description,
})),
...Array.from({ length: openCells }, () => ({ invite: SPONSOR_PAGE_URL })),
]);
});
expect(readmeSponsorCells(readme)).toEqual([
...SPONSORS.map((sponsor) => ({
href: sponsorHref(sponsor, "readme"),
onDark: `packages/website/public${sponsor.logo.onDark}`,
onLight: `packages/website/public${sponsor.logo.onLight}`,
description: sponsor.description,
})),
...Array.from({ length: openCells }, () => ({ invite: sponsorPage })),
]);
},
);
});
@@ -0,0 +1,54 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import { describe, expect, it } from "vitest";
import { parseHeadings } from "~/docs";
import { docsRehypePlugins, docsRemarkPlugins } from "~/docs-rehype";
function render(markdown: string): string {
return renderToStaticMarkup(
createElement(
ReactMarkdown,
{ remarkPlugins: docsRemarkPlugins, rehypePlugins: docsRehypePlugins("链接到本节") },
markdown,
),
);
}
const TRANSLATED = [
"## 安装 {#install}",
"",
"### 运行 `paseo run` {#paseo-run}",
"",
"## 配置",
"",
":::example[回复 Slack 提及 {#answer-a-slack-mention}]",
"正文",
":::",
].join("\n");
describe("{#id} anchors", () => {
it("pin heading ids and leave no literal syntax in the page", () => {
const html = render(TRANSLATED);
expect(html).toContain('<h2 id="install">');
expect(html).toContain('<h3 id="paseo-run">');
expect(html).toContain("<code>paseo run</code>");
expect(html).toContain('<h2 id="配置">');
expect(html).toContain('<details class="docs-example" id="answer-a-slack-mention">');
expect(html).toContain('<summary class="docs-example-summary">回复 Slack 提及</summary>');
expect(html).not.toContain("{#");
});
it("give the outline the same ids the page renders", () => {
expect(parseHeadings(TRANSLATED)).toEqual([
{ depth: 2, text: "安装", id: "install" },
{ depth: 3, text: "运行 paseo run", id: "paseo-run" },
{ depth: 2, text: "配置", id: "配置" },
]);
});
it("leave English headings to the slugger", () => {
expect(render("## Install the CLI")).toContain('<h2 id="install-the-cli">');
});
});
+48
View File
@@ -0,0 +1,48 @@
import type { Root } from "mdast";
/**
* A heading or example label may pin its anchor with a trailing `{#id}`. Translations pin every
* anchor to the English one, so `/zh-CN/docs/cli#agent-run` lands on the same section as
* `/docs/cli#agent-run` and links between docs keep working in every language.
*/
const ANCHOR_ID = /\s*\{#([A-Za-z0-9_-]+)\}\s*$/;
export function splitAnchorId(text: string): { text: string; id: string | undefined } {
const match = ANCHOR_ID.exec(text);
return match ? { text: text.slice(0, match.index), id: match[1] } : { text, id: undefined };
}
interface MarkdownNode {
type: string;
value?: string;
children?: MarkdownNode[];
data?: { hProperties?: Record<string, unknown> } & Record<string, unknown>;
}
/** Removes a trailing `{#id}` from the last text in `node` and returns the id. */
export function takeAnchorId(node: MarkdownNode): string | undefined {
const last = node.children?.at(-1);
if (!last || last.type !== "text" || last.value === undefined) return undefined;
const { text, id } = splitAnchorId(last.value);
if (id === undefined) return undefined;
last.value = text;
if (!text) node.children?.pop();
return id;
}
/** Applies `{#id}` heading anchors; rehype-slug keeps an id that is already set. */
export function remarkHeadingIds() {
return (tree: Root) => {
visit(tree as unknown as MarkdownNode, (node) => {
if (node.type !== "heading") return;
const id = takeAnchorId(node);
if (id === undefined) return;
node.data = { ...node.data, hProperties: { ...node.data?.hProperties, id } };
});
};
}
function visit(node: MarkdownNode, visitor: (node: MarkdownNode) => void): void {
visitor(node);
for (const child of node.children ?? []) visit(child, visitor);
}
@@ -1,5 +1,6 @@
import GithubSlugger from "github-slugger";
import type { Root } from "mdast";
import { takeAnchorId } from "./docs-anchor-ids.js";
// `:::example[Answer a Slack mention]` becomes a <details> block. Authors get a
// collapsible without us enabling rehype-raw, which would turn on raw HTML for
@@ -32,10 +33,11 @@ export function remarkDocsExample() {
visit(tree as unknown as DirectiveNode, (node) => {
if (node.type !== "containerDirective" || node.name !== DIRECTIVE_NAME) return;
const label = (node.children ?? []).find(isLabel);
const pinnedId = label ? takeAnchorId(label) : undefined;
const title = label ? labelText(label) : "";
node.data = {
hName: "details",
hProperties: { className: "docs-example", id: slugger.slug(title) },
hProperties: { className: "docs-example", id: pinnedId ?? slugger.slug(title) },
};
if (label) {
label.data = {
+24
View File
@@ -0,0 +1,24 @@
import { getDoc } from "~/docs";
import { getTranslator, type Locale } from "~/i18n";
import { pageMeta } from "~/meta";
/** Title, description, and language links for a docs page. */
export function docPageMeta(slug: string, locale: Locale) {
const t = getTranslator(locale);
const path = slug === "" ? "/docs" : `/docs/${slug}`;
const doc = getDoc(slug, locale);
if (!doc) {
return pageMeta({
title: t("docs.notFoundTitle"),
description: t("docs.notFound"),
path,
locale,
});
}
return pageMeta({
title: t("docs.pageTitle", { title: doc.frontmatter.title }),
description: doc.frontmatter.description,
path,
locale,
});
}
+25 -16
View File
@@ -5,9 +5,15 @@ import remarkGfm from "remark-gfm";
import { createHighlighterCore, type HighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import type { PluggableList } from "unified";
import { remarkHeadingIds } from "./docs-anchor-ids.js";
import { remarkDocsExample } from "./docs-example-directive.js";
export const docsRemarkPlugins: PluggableList = [remarkGfm, remarkDirective, remarkDocsExample];
export const docsRemarkPlugins: PluggableList = [
remarkGfm,
remarkDirective,
remarkDocsExample,
remarkHeadingIds,
];
let docsHighlighter: Promise<HighlighterCore> | undefined;
@@ -39,19 +45,22 @@ export function getDocsHighlighter(): Promise<HighlighterCore> {
return docsHighlighter;
}
export const docsRehypePlugins: PluggableList = [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "prepend",
properties: { className: "heading-anchor", ariaLabel: "Link to this section" },
content: {
type: "element",
tagName: "span",
properties: { className: "heading-anchor-icon", ariaHidden: "true" },
children: [{ type: "text", value: "#" }],
/** Rehype plugins for docs, with the heading anchor's accessible name in the page language. */
export function docsRehypePlugins(anchorLabel: string): PluggableList {
return [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "prepend",
properties: { className: "heading-anchor", ariaLabel: anchorLabel },
content: {
type: "element",
tagName: "span",
properties: { className: "heading-anchor-icon", ariaHidden: "true" },
children: [{ type: "text", value: "#" }],
},
},
},
],
];
],
];
}
+59 -37
View File
@@ -1,4 +1,7 @@
import Slugger from "github-slugger";
import { DEFAULT_LOCALE, type Locale, splitLocalePath, withLocalePrefix } from "~/i18n";
import { splitAnchorId } from "~/docs-anchor-ids";
import { groupTranslations, inLocale, parseFrontmatter } from "~/markdown-content";
interface DocFrontmatter {
title: string;
@@ -16,8 +19,12 @@ export interface DocHeading {
export interface Doc {
slug: string;
/** The language-neutral page path; localize it for the reader's language. */
href: string;
/** The file this doc's content comes from, such as `public-docs/cli.zh-CN.md`. */
sourcePath: string;
/** The language `content` is written in; English when the requested language has no translation. */
locale: Locale;
frontmatter: DocFrontmatter;
content: string;
headings: DocHeading[];
@@ -47,25 +54,6 @@ export type DocsNavNode =
order: number;
};
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
const data: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
data[key] = value;
}
return { data, content: match[2] };
}
function stripInlineMarkdown(text: string): string {
return text
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
@@ -77,7 +65,7 @@ function stripInlineMarkdown(text: string): string {
.trim();
}
function parseHeadings(content: string): DocHeading[] {
export function parseHeadings(content: string): DocHeading[] {
const slugger = new Slugger();
const headings: DocHeading[] = [];
const regex = /^(#{1,6})\s+(.+)$/gm;
@@ -87,13 +75,14 @@ function parseHeadings(content: string): DocHeading[] {
const depth = match[1].length;
if (depth < 2 || depth > 4) continue;
const text = stripInlineMarkdown(match[2].trim());
const heading = splitAnchorId(match[2].trim());
const text = stripInlineMarkdown(heading.text);
if (!text) continue;
headings.push({
depth,
text,
id: slugger.slug(text),
id: heading.id ?? slugger.slug(text),
});
}
@@ -114,28 +103,38 @@ function pathToSlug(path: string): string {
}
function pathToSourcePath(path: string): string {
return path.split("/public-docs/")[1] ?? path;
return `public-docs/${path.split("/public-docs/")[1] ?? path}`;
}
function loadDocs(): Doc[] {
const docTranslations = groupTranslations(docModules);
function loadDocs(locale: Locale): Doc[] {
const docs: Doc[] = [];
for (const [path, raw] of Object.entries(docModules)) {
for (const group of docTranslations) {
const { raw, locale: contentLocale } = inLocale(group, locale);
const { data, content } = parseFrontmatter(raw);
const slug = pathToSlug(path);
// Where a doc sits in the navigation is the English original's decision in every language.
const structure = parseFrontmatter(group.sources.en).data;
const slug = pathToSlug(group.path);
const href = slug === "" ? "/docs" : `/docs/${slug}`;
const order = Number.parseInt(data.order ?? "999", 10);
const order = Number.parseInt(structure.order ?? "999", 10);
const sourcePath = pathToSourcePath(group.path);
docs.push({
slug,
href,
sourcePath: `public-docs/${pathToSourcePath(path)}`,
sourcePath:
contentLocale === DEFAULT_LOCALE
? sourcePath
: sourcePath.replace(/\.md$/, `.${contentLocale}.md`),
locale: contentLocale,
frontmatter: {
title: data.title ?? "",
description: data.description ?? "",
nav: data.nav ?? data.title ?? slug,
order: Number.isFinite(order) ? order : 999,
category: data.category,
category: structure.category,
},
content,
headings: parseHeadings(content),
@@ -146,15 +145,20 @@ function loadDocs(): Doc[] {
return docs;
}
let cached: Doc[] | undefined;
const cached = new Map<Locale, Doc[]>();
export function getDocs(): Doc[] {
if (!cached) cached = loadDocs();
return cached;
/** Every doc in a language, each in English where it has no translation yet. */
export function getDocs(locale: Locale): Doc[] {
let docs = cached.get(locale);
if (!docs) {
docs = loadDocs(locale);
cached.set(locale, docs);
}
return docs;
}
export function getDoc(slug: string): Doc | undefined {
return getDocs().find((d) => d.slug === slug);
export function getDoc(slug: string, locale: Locale): Doc | undefined {
return getDocs(locale).find((d) => d.slug === slug);
}
export function getLegacyDocsRedirect(pathname: string): string | undefined {
@@ -173,8 +177,10 @@ function formatLabel(segment: string): string {
}
function insertDocByPath(nodes: DocsNavNode[], doc: Doc, stripTopDirectory = false): void {
const relative = doc.sourcePath.replace(/^public-docs\//, "");
const segments = relative.replace(/\.md$/, "").split("/");
const relative = doc.sourcePath
.replace(/^public-docs\//, "")
.replace(doc.locale === DEFAULT_LOCALE ? ".md" : `.${doc.locale}.md`, "");
const segments = relative.split("/");
const fileName = segments.pop() ?? "";
const directories = segments;
@@ -311,3 +317,19 @@ export function getDocBreadcrumbGroups(doc: Doc, tree: DocsNavNode[]): { label:
.filter((node): node is Extract<DocsNavNode, { type: "group" }> => node.type === "group")
.map((node) => ({ label: node.label }));
}
/** The raw Markdown URL of a doc, in the language its content is written in. */
export function docMarkdownPath(doc: Doc): string {
return withLocalePrefix(doc.slug === "" ? "/docs.md" : `/docs/${doc.slug}.md`, doc.locale);
}
/**
* Resolves a raw Markdown URL such as `/docs/cli.md` or `/zh-CN/docs/cli.md`. Returns null when the
* URL is not a docs Markdown URL, and `{ doc: undefined }` when it names no doc.
*/
export function resolveDocMarkdownPath(pathname: string): { doc: Doc | undefined } | null {
const { locale, path } = splitLocalePath(pathname);
if (path === "/docs.md") return { doc: getDoc("", locale) };
const match = /^\/docs\/(.+)\.md$/.exec(path);
return match ? { doc: getDoc(match[1], locale) } : null;
}
+11 -5
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { type Locale, localizePath } from "~/i18n";
import type { DesktopPlatform, MobilePlatform } from "~/platform";
export function releaseBase(version: string) {
@@ -44,8 +45,8 @@ export function parseReleaseAssetUrl(value: unknown): string | null {
}
/** Download buttons link here; the thanks page starts the download itself. */
export function thanksPageHref(fileUrl: string): string {
return `/download/thanks?${new URLSearchParams({ file: fileUrl })}`;
export function thanksPageHref(fileUrl: string, locale: Locale): string {
return localizePath(`/download/thanks?${new URLSearchParams({ file: fileUrl })}`, locale);
}
export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6758887924";
@@ -61,15 +62,20 @@ export interface PrimaryDownload {
export function getDesktopDownload(
release: ReleaseAssetInfo,
platform: DesktopPlatform,
locale: Locale,
): PrimaryDownload {
const urls = downloadUrls(release);
switch (platform) {
case "windows":
return { label: "Windows", href: thanksPageHref(urls.windowsExeX64), icon: WindowsIcon };
return {
label: "Windows",
href: thanksPageHref(urls.windowsExeX64, locale),
icon: WindowsIcon,
};
case "linux":
return { label: "Linux", href: thanksPageHref(urls.linuxAppImage), icon: LinuxIcon };
return { label: "Linux", href: thanksPageHref(urls.linuxAppImage, locale), icon: LinuxIcon };
case "mac":
return { label: "Mac", href: thanksPageHref(urls.macAppleSilicon), icon: AppleIcon };
return { label: "Mac", href: thanksPageHref(urls.macAppleSilicon, locale), icon: AppleIcon };
}
}
+15 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
// The documented `GET /api/billing/plans` response, copied from getpaseo/hub docs/public-api.md.
import documentedResponse from "./hub-plans.fixture.json" with { type: "json" };
import { getTranslator } from "./i18n";
import { formatPlanPrice, parseHubPlansResponse, planPriceQualifier } from "./hub-plans";
describe("parseHubPlansResponse", () => {
@@ -56,10 +57,20 @@ describe("parseHubPlansResponse", () => {
describe("plan price copy", () => {
it("shows Free as $0 a month and Pro as $15 per seat a month", () => {
const plans = parseHubPlansResponse(documentedResponse);
const t = getTranslator("en");
expect(formatPlanPrice(plans.free.price)).toBe("$0");
expect(planPriceQualifier(plans.free)).toBe("/ month");
expect(formatPlanPrice(plans.paid.price)).toBe("$15");
expect(planPriceQualifier(plans.paid)).toBe("per seat / month");
expect(formatPlanPrice(plans.free.price, "en")).toBe("$0");
expect(planPriceQualifier(plans.free, t)).toBe("/ month");
expect(formatPlanPrice(plans.paid.price, "en")).toBe("$15");
expect(planPriceQualifier(plans.paid, t)).toBe("per seat / month");
});
it("describes the same prices in Chinese around the catalog's unit label", () => {
const plans = parseHubPlansResponse(documentedResponse);
const t = getTranslator("zh-CN");
expect(formatPlanPrice(plans.paid.price, "zh-CN")).toBe("US$15");
expect(planPriceQualifier(plans.free, t)).toBe("/ 月");
expect(planPriceQualifier(plans.paid, t)).toBe("每席位 / 月");
});
});
+21 -6
View File
@@ -1,4 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import type { TFunction } from "i18next";
import type { Locale } from "~/i18n";
const HUB_PLANS_URL =
import.meta.env.VITE_HUB_PLANS_URL ?? "https://hub.paseo.sh/api/billing/plans";
@@ -56,8 +58,8 @@ export const getHubPlans = createServerFn({ method: "GET" }).handler(async () =>
});
/** The plan's monthly figure in its own currency, e.g. "$15". */
export function formatPlanPrice(price: HubPlanOffer["price"]): string {
return new Intl.NumberFormat("en", {
export function formatPlanPrice(price: HubPlanOffer["price"], locale: Locale): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: price.currency.toUpperCase(),
minimumFractionDigits: 0,
@@ -66,10 +68,23 @@ export function formatPlanPrice(price: HubPlanOffer["price"]): string {
/** What the figure is for: "per seat / month" when each seat is charged, "/ month" when the
* plan costs nothing, since a free plan has no per-seat charge to describe. */
export function planPriceQualifier(plan: HubPlanOffer): string {
const period = plan.price.intervalCount === 1 ? "month" : `${plan.price.intervalCount} months`;
if (plan.price.unitAmount === 0) return `/ ${period}`;
return `per ${plan.billing.unit.label} / ${period}`;
export function planPriceQualifier(plan: HubPlanOffer, t: TFunction): string {
const count = plan.price.intervalCount;
const unit = billingUnitLabel(plan, t);
if (plan.price.unitAmount === 0) {
return count === 1 ? t("hub.pricing.perMonth") : t("hub.pricing.perMonths", { count });
}
return count === 1
? t("hub.pricing.perUnitMonth", { unit })
: t("hub.pricing.perUnitMonths", { unit, count });
}
/**
* The billing unit in the page's language. Hub serves its catalog in English, so units the site
* knows are translated here and any other unit shows the catalog's label.
*/
export function billingUnitLabel(plan: HubPlanOffer, t: TFunction): string {
return plan.billing.unit.key === "seat" ? t("hub.pricing.seat") : plan.billing.unit.label;
}
function selectOffer(plans: readonly HubBillingPlan[], slug: string): HubPlanOffer {
@@ -0,0 +1,90 @@
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { Suspense, use } from "react";
import { renderToPipeableStream } from "react-dom/server";
import { PassThrough } from "node:stream";
import { useTranslation } from "react-i18next";
import { describe, expect, it } from "vitest";
import { getTranslator, LocaleProvider } from "~/i18n";
/** A promise the test settles by hand, so two renders can be interleaved deterministically. */
function gate() {
let open = () => {};
const promise = new Promise<void>((resolve) => {
open = resolve;
});
return { promise, open };
}
/** Suspends until `waitFor` settles, then reads copy, as a page waiting on data does. */
function Navigation({ waitFor }: { waitFor: Promise<void> }) {
use(waitFor);
const { t } = useTranslation();
return <nav>{`${t("nav.docs")}|${t("nav.changelog")}`}</nav>;
}
async function startRender(url: string, waitFor: Promise<void>) {
const rootRoute = createRootRoute();
const localeRoute = createRoute({
getParentRoute: () => rootRoute,
path: "{-$locale}",
component: () => (
<LocaleProvider>
<Suspense fallback={null}>
<Navigation waitFor={waitFor} />
</Suspense>
</LocaleProvider>
),
});
const router = createRouter({
routeTree: rootRoute.addChildren([localeRoute]),
history: createMemoryHistory({ initialEntries: [url] }),
});
await router.load();
let html = "";
const output = new PassThrough();
output.on("data", (chunk: Buffer) => {
html += chunk.toString();
});
const finished = new Promise<string>((resolve, reject) => {
output.on("end", () => resolve(html));
renderToPipeableStream(<RouterProvider router={router} />, { onShellError: reject }).pipe(
output,
);
});
return () => finished;
}
describe("concurrent requests in different languages", () => {
it("each render with their own language when their renders interleave", async () => {
const chineseData = gate();
const englishData = gate();
const chinese = await startRender("/zh-CN", chineseData.promise);
const english = await startRender("/", englishData.promise);
englishData.open();
const englishHtml = await english();
chineseData.open();
const chineseHtml = await chinese();
expect(englishHtml).toContain("Docs|Changelog");
expect(chineseHtml).toContain("文档|更新日志");
expect(chineseHtml).not.toContain("Docs");
});
it("translate outside React without one language leaking into the other", () => {
const chinese = getTranslator("zh-CN");
const english = getTranslator("en");
expect([chinese("nav.docs"), english("nav.docs"), chinese("nav.docs")]).toEqual([
"文档",
"Docs",
"文档",
]);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { Locale } from "./locale";
/**
* A calendar date such as "Oct 19, 2026" or "2026年10月19日", read in UTC so SSR and hydration agree.
* `long` spells the month out: "October 19, 2026".
*/
export function formatDate(
date: Date | string,
locale: Locale,
month: "short" | "long" = "short",
): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month,
day: "numeric",
timeZone: "UTC",
}).format(typeof date === "string" ? new Date(date) : date);
}
export function formatNumber(value: number, locale: Locale): string {
return new Intl.NumberFormat(locale).format(value);
}
+7
View File
@@ -0,0 +1,7 @@
import type en from "./resources/en.json";
declare module "i18next" {
interface CustomTypeOptions {
resources: { translation: typeof en };
}
}
+20
View File
@@ -0,0 +1,20 @@
export {
DEFAULT_LOCALE,
LANGUAGE_ABBREVIATIONS,
LANGUAGE_NAMES,
OPEN_GRAPH_LOCALES,
LOCALES,
type Locale,
isLocale,
isPagePath,
languageAlternates,
localeFromParam,
localeParam,
localizePath,
routeLocale,
splitLocalePath,
withLocalePrefix,
} from "./locale";
export { getTranslator } from "./translator";
export { LocaleProvider, useLocale, useLocalizePath } from "./react";
export { formatDate, formatNumber } from "./format";
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { pageMeta } from "~/meta";
import en from "./resources/en.json";
import zhCN from "./resources/zh-CN.json";
import { localeFromParam, localizePath, splitLocalePath } from "./locale";
describe("locale paths", () => {
it("move a page between languages and keep its query and hash", () => {
expect(splitLocalePath("/zh-CN/plugins/all?q=dark#top")).toEqual({
locale: "zh-CN",
path: "/plugins/all?q=dark#top",
});
expect(splitLocalePath("/zh-CN")).toEqual({ locale: "zh-CN", path: "/" });
expect(splitLocalePath("/zh-CNx")).toEqual({ locale: "en", path: "/zh-CNx" });
expect(localizePath("/zh-CN/docs/cli?x=1", "en")).toBe("/docs/cli?x=1");
expect(localizePath("/docs/cli#agent-run", "zh-CN")).toBe("/zh-CN/docs/cli#agent-run");
expect(localizePath("/", "zh-CN")).toBe("/zh-CN");
});
it("leave files, endpoints, and external links alone", () => {
for (const path of ["/docs/cli.md", "/llms.txt", "/api/download", "https://x.dev/a", "#top"]) {
expect(localizePath(path, "zh-CN")).toBe(path);
}
});
it("never spell English in a URL", () => {
expect(localeFromParam(undefined)).toBe("en");
expect(localeFromParam("zh-CN")).toBe("zh-CN");
expect(localeFromParam("en")).toBeNull();
expect(localeFromParam("fr")).toBeNull();
});
});
describe("pageMeta", () => {
it("is canonical to itself and lists every language with English as x-default", () => {
const { meta, links } = pageMeta({
title: "命令行",
description: "说明",
path: "/docs/cli",
locale: "zh-CN",
});
expect(links).toEqual([
{ rel: "canonical", href: "https://paseo.sh/zh-CN/docs/cli" },
{ rel: "alternate", hrefLang: "en", href: "https://paseo.sh/docs/cli" },
{ rel: "alternate", hrefLang: "zh-CN", href: "https://paseo.sh/zh-CN/docs/cli" },
{ rel: "alternate", hrefLang: "x-default", href: "https://paseo.sh/docs/cli" },
]);
expect(meta).toEqual(
expect.arrayContaining([
{ property: "og:locale", content: "zh_CN" },
{ property: "og:locale:alternate", content: "en_US" },
{ property: "og:url", content: "https://paseo.sh/zh-CN/docs/cli" },
{ name: "twitter:title", content: "命令行" },
]),
);
});
});
interface Resources {
[key: string]: string | Resources;
}
/** Every string in a resource tree, keyed by its dotted path. */
function strings(resources: Resources, prefix = ""): [string, string][] {
return Object.entries(resources).flatMap(([key, value]): [string, string][] =>
typeof value === "string" ? [[`${prefix}${key}`, value]] : strings(value, `${prefix}${key}.`),
);
}
function placeholders(text: string): string {
return [...text.matchAll(/\{\{(\w+)\}\}/g)]
.map((match) => match[1])
.sort()
.join();
}
describe("interface copy", () => {
it("fills the same placeholders in every language", () => {
const chinese = new Map(strings(zhCN));
const mismatched = strings(en).filter(([key, english]) => {
const translated = chinese.get(key);
return translated !== undefined && placeholders(translated) !== placeholders(english);
});
expect(mismatched).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* The site's languages and how a language shows in a URL. English pages keep their unprefixed
* paths; every other language lives under its tag, so `/docs` and `/zh-CN/docs` are the same page.
*/
export const LOCALES = ["en", "zh-CN"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
/** Each language named in itself, for the language selector. */
export const LANGUAGE_NAMES: Record<Locale, string> = {
en: "English",
"zh-CN": "简体中文",
};
/** Each language abbreviated, for the closed language selector. */
export const LANGUAGE_ABBREVIATIONS: Record<Locale, string> = {
en: "EN",
"zh-CN": "ZH",
};
/** `og:locale` values, which use an underscore and a region. */
export const OPEN_GRAPH_LOCALES: Record<Locale, string> = {
en: "en_US",
"zh-CN": "zh_CN",
};
export function isLocale(value: unknown): value is Locale {
return typeof value === "string" && (LOCALES as readonly string[]).includes(value);
}
/**
* The language named by the optional `{-$locale}` route segment, or null when the segment is not a
* prefixed language. English is never spelled out in a URL, so `/en/docs` is not a page.
*/
export function localeFromParam(param: string | undefined): Locale | null {
if (param === undefined) return DEFAULT_LOCALE;
return param !== DEFAULT_LOCALE && isLocale(param) ? param : null;
}
/**
* The language of a route under `{-$locale}`. The layout route only matches valid languages, so
* route functions such as `head` can read it from their params.
*/
export function routeLocale(params: { locale?: string }): Locale {
return localeFromParam(params.locale) ?? DEFAULT_LOCALE;
}
/** The `{-$locale}` segment value for a language; English has none. */
export function localeParam(locale: Locale): string | undefined {
return locale === DEFAULT_LOCALE ? undefined : locale;
}
const NON_PAGE_PREFIXES = ["/api/", "/_serverFn/", "/schemas/", "/plugins/thumb/"];
/**
* Whether a site-relative URL names a page that exists in every language. Files such as images,
* `.md` sources, `llms.txt`, and API endpoints are shared by all languages and keep their path.
*/
export function isPagePath(path: string): boolean {
if (!path.startsWith("/") || path.startsWith("//")) return false;
const pathname = path.split(/[?#]/, 1)[0];
if (NON_PAGE_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false;
const lastSegment = pathname.split("/").at(-1) ?? "";
return !lastSegment.includes(".");
}
/**
* Splits a site path into its language and the language-neutral path, keeping any query and hash.
* `/zh-CN/docs?q=1` is `{ locale: "zh-CN", path: "/docs?q=1" }`; `/docs` is English.
*/
export function splitLocalePath(path: string): { locale: Locale; path: string } {
for (const locale of LOCALES) {
if (locale === DEFAULT_LOCALE) continue;
const prefix = `/${locale}`;
if (path === prefix) return { locale, path: "/" };
if (path.startsWith(prefix) && /[/?#]/.test(path.charAt(prefix.length))) {
const rest = path.slice(prefix.length);
return { locale, path: rest.startsWith("/") ? rest : `/${rest}` };
}
}
return { locale: DEFAULT_LOCALE, path };
}
/**
* The URL of a language-neutral site path in a language. Paths that are not pages, external URLs,
* and fragments are returned unchanged.
*/
export function localizePath(path: string, locale: Locale): string {
if (!isPagePath(path)) return path;
return withLocalePrefix(splitLocalePath(path).path, locale);
}
/**
* Puts a language-neutral site path under a language's prefix. Use it directly for files that exist
* per language, such as a doc's `.md` source; pages go through `localizePath`.
*/
export function withLocalePrefix(path: string, locale: Locale): string {
if (locale === DEFAULT_LOCALE) return path;
if (path === "/" || path.startsWith("/?") || path.startsWith("/#")) {
return `/${locale}${path.slice(1)}`;
}
return `/${locale}${path}`;
}
/**
* A page's path in every language, tagged for `hreflang`, plus English again as `x-default` for
* readers in any other language. Page metadata and the sitemaps list the same set.
*/
export function languageAlternates(path: string): { hreflang: string; path: string }[] {
return [
...LOCALES.map((locale) => ({ hreflang: locale, path: localizePath(path, locale) })),
{ hreflang: "x-default", path: localizePath(path, DEFAULT_LOCALE) },
];
}
+22
View File
@@ -0,0 +1,22 @@
import { useParams } from "@tanstack/react-router";
import { type ReactNode, useCallback } from "react";
import { I18nextProvider } from "react-i18next";
import { type Locale, localizePath, routeLocale } from "./locale";
import { getI18n } from "./translator";
/** The page's language, read from the URL's `{-$locale}` segment. */
export function useLocale(): Locale {
return routeLocale(useParams({ strict: false }));
}
/** Provides the page language's translations to `useTranslation` below it. */
export function LocaleProvider({ children }: { children: ReactNode }) {
const locale = useLocale();
return <I18nextProvider i18n={getI18n(locale)}>{children}</I18nextProvider>;
}
/** Maps a language-neutral site path to the current language's URL for plain anchors. */
export function useLocalizePath(): (path: string) => string {
const locale = useLocale();
return useCallback((path: string) => localizePath(path, locale), [locale]);
}
+787
View File
@@ -0,0 +1,787 @@
{
"common": {
"breadcrumb": "Breadcrumb",
"untranslated": "This page has not been translated into your language yet, so it is shown in English.",
"notFound": "Page not found.",
"notFoundTitle": "Not Found - Paseo",
"more": "+{{count}} more",
"copy": "Copy",
"copied": "Copied",
"close": "Close",
"loading": "Loading…",
"copyToClipboard": "Copy to clipboard",
"backHome": "Go to the homepage"
},
"languageSelector": {
"label": "Language",
"current": "Language: {{language}}",
"names": {
"en": "English",
"zh-CN": "Simplified Chinese"
}
},
"nav": {
"plugins": "Plugins",
"blog": "Blog",
"docs": "Docs",
"changelog": "Changelog",
"download": "Download",
"sponsor": "Sponsor",
"hub": "Hub",
"githubStars": "GitHub, {{stars}} stars"
},
"footer": {
"product": "Product",
"blog": "Blog",
"docs": "Docs",
"plugins": "Plugins",
"changelog": "Changelog",
"hub": "Hub",
"cli": "CLI",
"privacy": "Privacy",
"terms": "Terms",
"sponsor": "Sponsor",
"agents": "Agents",
"allProviders": "All providers",
"alternatives": "Alternatives",
"community": "Community",
"download": "Download",
"desktop": "Desktop",
"webApp": "Web App"
},
"docs": {
"headingAnchor": "Link to this section",
"onThisPage": "On this page",
"copyMarkdown": "Copy as markdown",
"copied": "Copied",
"viewMarkdown": "View as markdown",
"viewOnGitHub": "View on GitHub",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"notFound": "Doc not found.",
"notFoundTitle": "Not Found - Paseo Docs",
"pageTitle": "{{title}} - Paseo Docs",
"categories": {
"Getting started": "Getting started",
"Workspaces": "Workspaces",
"Providers": "Providers",
"Orchestration": "Orchestration",
"Browser": "Browser",
"Schedules": "Schedules",
"Configuration": "Configuration",
"Plugins": "Plugins",
"TypeScript SDK": "TypeScript SDK",
"Hub": "Hub",
"Troubleshooting": "Troubleshooting"
}
},
"agentPages": {
"claude-code": {
"title": "Open source app for Claude Code",
"subtitle": "Run Claude Code on your machine, drive it from your phone or desktop. Launch agents, watch them work, review and merge from anywhere.",
"metaTitle": "Claude Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Claude Code. Run agents on your machine, monitor progress, review diffs, and merge from anywhere. Self-hosted, your code stays local."
},
"codex": {
"title": "Open source app for Codex",
"subtitle": "Run OpenAI's Codex on your machine, drive it from your phone or desktop. Same setup, same machine, no laptop required.",
"metaTitle": "Codex Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for OpenAI Codex. Launch agents on your machine, monitor progress, and ship code from anywhere. Self-hosted."
},
"opencode": {
"title": "Open source app for OpenCode",
"subtitle": "Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
"metaTitle": "OpenCode Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for OpenCode. Launch agents on your machine, watch them work, ship code from anywhere. Self-hosted."
},
"pi": {
"title": "Open source app for the Pi coding agent",
"subtitle": "Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
"metaTitle": "Pi Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted."
},
"omp": {
"title": "Open source app for OMP (Oh My Pi)",
"subtitle": "Run OMP (Oh My Pi) on your machine, drive it from your phone or desktop. Self-hosted and open source.",
"metaTitle": "OMP (Oh My Pi) Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for OMP (Oh My Pi). Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted."
},
"cursor": {
"title": "Open source app for Cursor",
"subtitle": "Send tasks to Cursor on your machine, drive it from your phone or desktop. Review the diff anywhere.",
"metaTitle": "Cursor Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Cursor. Launch tasks on your machine, monitor output, review diffs, and merge from anywhere. Self-hosted."
},
"muse-code": {
"title": "Open source app for Muse Code",
"subtitle": "Run Meta's Muse Code on your machine, drive it from your phone or desktop. Choose models, review tool calls, and answer approvals from anywhere.",
"metaTitle": "Muse Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Meta Muse Code. Run your installed Muse CLI, watch streamed turns, and answer approvals. See setup and version limitations in the docs."
},
"antigravity": {
"title": "Open source app for Antigravity",
"subtitle": "Run your installed Antigravity CLI on your machine and drive it from your phone or desktop.",
"metaTitle": "Antigravity Mobile and Desktop App, Open Source",
"metaDescription": "Run your installed Antigravity CLI through Paseo. Watch agents work and review changes from your phone or desktop. Self-hosted, your code stays on your machine."
},
"copilot": {
"title": "Open source app for GitHub Copilot",
"subtitle": "Drive GitHub Copilot from your phone or desktop. Same account, same machine, ship without sitting down at your desk.",
"metaTitle": "GitHub Copilot Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for GitHub Copilot. Launch sessions on your machine, monitor progress, merge from anywhere."
},
"gemini": {
"title": "Open source app for Gemini CLI",
"subtitle": "Run Google's Gemini CLI on your machine, drive it from your phone or desktop. Real coding work, no laptop required.",
"metaTitle": "Gemini CLI Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Google's Gemini CLI. Launch agents on your machine, monitor progress, and ship from anywhere. Self-hosted."
},
"hermes": {
"title": "Open source app for the Hermes agent",
"subtitle": "Run Nous Research's Hermes Agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Hermes Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Nous Research's Hermes Agent. Launch sessions on your machine, monitor progress, ship code from anywhere."
},
"qwen-code": {
"title": "Open source app for Qwen Code",
"subtitle": "Run Alibaba's Qwen Code on your machine, drive it from your phone or desktop.",
"metaTitle": "Qwen Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Alibaba's Qwen Code. Launch agents on your machine, monitor progress, and merge from anywhere."
},
"kimi": {
"title": "Open source app for Kimi Code CLI",
"subtitle": "Run Moonshot AI's Kimi Code CLI on your machine, drive it from your phone or desktop.",
"metaTitle": "Kimi Code CLI Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Moonshot AI's Kimi Code CLI. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"amp": {
"title": "Open source app for the Amp coding agent",
"subtitle": "Run Sourcegraph's Amp on your machine, drive it from your phone or desktop. Frontier coding, no laptop required.",
"metaTitle": "Amp Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Amp, Sourcegraph's frontier coding agent. Launch tasks on your machine, monitor progress, ship from anywhere."
},
"auggie": {
"title": "Open source app for Auggie CLI",
"subtitle": "Run Augment Code's Auggie CLI on your machine, drive it from your phone or desktop.",
"metaTitle": "Auggie CLI Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Augment Code's Auggie CLI. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"cline": {
"title": "Open source app for the Cline coding agent",
"subtitle": "Run the Cline coding agent on your machine, drive it from your phone or desktop. Watch it work, jump in when needed.",
"metaTitle": "Cline Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Cline, the autonomous coding agent. Launch tasks, monitor output, review diffs from anywhere."
},
"codebuddy": {
"title": "Open source app for Codebuddy Code",
"subtitle": "Run Tencent Cloud's Codebuddy on your machine, drive it from your phone or desktop.",
"metaTitle": "Codebuddy Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Tencent Cloud's Codebuddy Code. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"cortex-code": {
"title": "Open source app for Cortex Code",
"subtitle": "Run Snowflake's Cortex Code on your machine, drive it from your phone or desktop. No laptop required.",
"metaTitle": "Cortex Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Snowflake's Cortex Code. Launch agents on your machine, monitor progress, ship from anywhere."
},
"corust": {
"title": "Open source app for the Corust agent",
"subtitle": "Build Rust with the Corust agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Corust Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for the Corust Rust-focused coding agent. Launch tasks on your machine, ship from anywhere."
},
"crow": {
"title": "Open source app for crow-cli",
"subtitle": "Run crow-cli, the minimal ACP-native coding agent, on your machine. Drive it from your phone or desktop.",
"metaTitle": "Crow-CLI Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for crow-cli, the minimal ACP-native coding agent. Launch tasks on your machine, monitor from anywhere."
},
"deepagents": {
"title": "Open source app for DeepAgents",
"subtitle": "Run LangChain's DeepAgents on your machine, drive it from your phone or desktop. Batteries included.",
"metaTitle": "DeepAgents Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for the LangChain DeepAgents coding agent. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"deepseek-tui": {
"title": "Open source app for CodeWhale",
"subtitle": "Run CodeWhale's DeepSeek V4 terminal coding agent on your machine, drive it from your phone or desktop.",
"metaTitle": "CodeWhale Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for CodeWhale. Launch coding sessions on your machine, monitor progress, ship from anywhere."
},
"dimcode": {
"title": "Open source app for DimCode",
"subtitle": "Leading models, one command. Run DimCode on your machine, drive it from your phone or desktop.",
"metaTitle": "DimCode Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for DimCode, the multi-model coding agent. Launch tasks on your machine, ship from anywhere."
},
"dirac": {
"title": "Open source app for the Dirac coding agent",
"subtitle": "Run Dirac's hash-anchored parallel edits on your machine, drive it from your phone or desktop.",
"metaTitle": "Dirac Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for the Dirac coding agent. Hash-anchored parallel edits, AST manipulation, ship from anywhere."
},
"factory-droid": {
"title": "Open source app for Factory Droid",
"subtitle": "Run Factory's Droid coding agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Factory Droid Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Factory AI's Droid coding agent. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"fast-agent": {
"title": "Open source app for fast-agent",
"subtitle": "Run fast-agent, the multi-provider coding agent, on your machine. Drive it from your phone or desktop.",
"metaTitle": "Fast-Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for fast-agent, the multi-provider coding agent. Launch tasks on your machine, monitor from anywhere."
},
"glm": {
"title": "Open source app for the GLM agent",
"subtitle": "Run Zhipu AI's GLM coding agent on your machine, drive it from your phone or desktop. Streaming, mid-session model switching.",
"metaTitle": "GLM Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Zhipu AI's GLM coding agent. Launch sessions on your machine, monitor progress, ship code from anywhere."
},
"goose": {
"title": "Open source app for the goose coding agent",
"subtitle": "Run Block's goose on your machine, drive it from your phone or desktop. Local, extensible, open source.",
"metaTitle": "Goose Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Block's goose, the local open-source AI agent. Launch tasks on your machine, ship from anywhere."
},
"junie": {
"title": "Open source app for the Junie coding agent",
"subtitle": "Run JetBrains' Junie on your machine, drive it from your phone or desktop. Real work, no IDE required.",
"metaTitle": "Junie Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for JetBrains' Junie coding agent. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"kilo": {
"title": "Open source app for Kilo Code",
"subtitle": "Run Kilo Code on your machine, drive it from your phone or desktop. Send tasks, watch them ship.",
"metaTitle": "Kilo Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Kilo Code. Launch tasks on your machine via Kilo CLI, monitor progress, merge from anywhere."
},
"minion-code": {
"title": "Open source app for Minion Code",
"subtitle": "Run Minion Code's framework agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Minion Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Minion Code, the Minion-framework coding agent. Launch sessions on your machine, ship from anywhere."
},
"mistral-vibe": {
"title": "Open source app for Mistral Vibe",
"subtitle": "Run Mistral's open-source Vibe assistant on your machine, drive it from your phone or desktop.",
"metaTitle": "Mistral Vibe Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Mistral's Vibe coding assistant. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"nova": {
"title": "Open source app for the Nova coding agent",
"subtitle": "Run Compass AI's Nova on your machine, drive it from your phone or desktop.",
"metaTitle": "Nova Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Compass AI's Nova coding agent. Launch sessions on your machine, monitor progress, merge from anywhere."
},
"poolside": {
"title": "Open source app for the Poolside coding agent",
"subtitle": "Drive Poolside's coding agent from your phone or desktop. Kick off the work, watch it land.",
"metaTitle": "Poolside Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Poolside's coding agent. Launch tasks on your machine, monitor progress, ship from anywhere."
},
"qoder": {
"title": "Open source app for Qoder CLI",
"subtitle": "Run the Qoder agentic coding assistant on your machine, drive it from your phone or desktop.",
"metaTitle": "Qoder CLI Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Qoder, the agentic coding assistant. Launch sessions on your machine, ship from anywhere."
},
"sigit": {
"title": "Open source app for siGit Code",
"subtitle": "Run siGit's local-first coding agent on your machine, drive it from your phone or desktop. Optional on-device LLM inference.",
"metaTitle": "SiGit Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for siGit Code, the local-first coding agent. Launch sessions on your machine, ship from anywhere."
},
"stakpak": {
"title": "Open source app for the Stakpak DevOps agent",
"subtitle": "Run Stakpak's Rust-based DevOps agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Stakpak DevOps Agent Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Stakpak, the Rust-based DevOps agent. Launch tasks on your machine, monitor from anywhere."
},
"vtcode": {
"title": "Open source app for VT Code",
"subtitle": "Run VT Code's multi-provider coding agent on your machine, drive it from your phone or desktop.",
"metaTitle": "VT Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for VT Code, the multi-provider coding agent. Launch sessions on your machine, ship from anywhere."
},
"agoragentic": {
"title": "Open source app for Agoragentic",
"subtitle": "Run Agoragentic's 174+ AI capabilities on your machine, drive it from your phone or desktop.",
"metaTitle": "Agoragentic Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Agoragentic, the AI agent marketplace. Launch sessions on your machine, ship from anywhere."
},
"autohand": {
"title": "Open source app for Autohand Code",
"subtitle": "Run Autohand AI's coding agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Autohand Code Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for Autohand AI's coding agent. Launch sessions on your machine, monitor progress, ship from anywhere."
},
"grok": {
"title": "Open source app for Grok",
"subtitle": "Run xAI's Grok Build coding agent on your machine, drive it from your phone or desktop.",
"metaTitle": "Grok Mobile and Desktop App, Open Source",
"metaDescription": "Open source mobile and desktop app for xAI's Grok Build coding agent. Launch sessions on your machine, monitor progress, and ship from anywhere. Self-hosted."
}
},
"home": {
"metaTitle": "Paseo – Run Claude Code, Codex, Copilot, OpenCode from anywhere",
"metaDescription": "Open source app for Claude Code, Codex, OpenCode, Pi, and 30+ more coding agents. Run many agents in parallel on your machines. Editor, terminals, diffs, pull requests, and a browser in one window. One download on desktop. The full app on iOS and Android.",
"title": "The agentic development<br/>environment",
"subtitle": "Run many coding agents at once,<mobileBreak/><wideSpace/>on any machine.<br/>From your desk and from your phone."
},
"agents": {
"metaTitle": "Supported agents – Every coding agent Paseo runs",
"metaDescription": "Run Claude Code, Codex, OpenCode, Pi, OMP, Cursor, Muse Code, Antigravity, and dozens more coding agents from your phone. Self-hosted, your code stays on your machine.",
"title": "Supported agents",
"intro": "Paseo supports many agents natively, it can run any <acp>ACP agent</acp> and you can implement new providers via <plugins>plugins</plugins>."
},
"landing": {
"socialProof": {
"title": "Loved by developers",
"description": "See what developers are saying about Paseo",
"readPost": "Read {{name}}'s original post"
},
"providers": {
"title": "Works with your tools",
"description": "Bring your subscriptions, skills and configuration"
},
"runAnywhere": {
"title": "Run it anywhere",
"description": "Use Paseo locally, from another machine, or with a team",
"desktopTitle": "Desktop app",
"desktopDescription": "The one click experience, download the app and it just works, no server required",
"mobileTitle": "Mobile",
"mobileDescription": "Full client and native, not a WebView",
"mobileCta": "Download",
"remoteTitle": "Remote machines",
"remoteDescription": "Run Paseo on a home lab, or a cloud machine",
"remoteCta": "Docs",
"teamsTitle": "Teams and triggers",
"teamsDescription": "Share access or start work from GitHub, Slack, and Discord",
"teamsCta": "Paseo Hub"
},
"automation": {
"title": "Built for automation",
"description": "Use MCP, the CLI, or the TypeScript SDK to automate Paseo",
"mcpCaption": "From another agent",
"cliCaption": "From the terminal",
"sdkCaption": "From code",
"mcpDocs": "MCP docs",
"cliDocs": "CLI docs",
"sdkDocs": "SDK docs",
"transcriptRequest": "Take the open GitHub issues labeled ready and fan them out to separate worktree agents.",
"transcriptPlan": "I found two ready issues. I will run each in its own worktree.",
"transcriptDone": "Done, two agents are running. I will let you know when they finish.",
"otherHostComment": "# Target another host"
},
"plugins": {
"title": "Extend it with plugins",
"description": "Add new panels and functionality that work in desktop, web and mobile",
"lead": "Plugins",
"browse": "Browse plugins",
"docs": "Plugin docs",
"license": "Paseo is Apache 2.0.",
"fork": "Fork the repo",
"uiTitle": "UI",
"uiDescription": "Screens, workspace panels, sidebar items, slash commands, and themes",
"lifecycleTitle": "Agent lifecycle",
"lifecycleDescription": "Change config and MCP servers, answer permissions, and follow up",
"providersTitle": "Providers",
"providersDescription": "Add a coding agent as a provider, used like the built-in ones"
},
"philosophy": {
"title": "Philosophy",
"description": "What Paseo stands for, and what every feature is built on",
"privateTitle": "Private",
"privateDescription": "No telemetry, tracking, or forced login. Code stays on your machine.",
"independentTitle": "Independent",
"independentDescription": "Paseo doesn't answer to investors. Its users guide what gets built.",
"polishedTitle": "Polished",
"polishedDescription": "Install the app and start working. No need to know what a daemon is.",
"unifiedTitle": "Unified",
"unifiedDescription": "Every coding agent, used the same way. Switch providers any time.",
"composableTitle": "Composable",
"composableDescription": "Run agents on a laptop or a server, and connect from any device.",
"extensibleTitle": "Extensible",
"extensibleDescription": "If Paseo doesn't fit how you work, change it with a plugin or fork."
},
"getStarted": {
"supports": "Supports",
"downloadFor": "Download for {{platform}}",
"getStoreApp": "Get the {{store}} app",
"desktopApp": "Desktop app",
"serverInstallLabel": "Install the daemon on a remote machine",
"serverInstallTitle": "Run agents on a remote machine",
"serverInstallDescription": "For headless machines you want to connect to from the Paseo apps. The desktop app already includes a built-in daemon",
"serverInstallFootnote": "Requires Node.js 18+. Run <code>paseo</code> to start the daemon."
},
"showcase": {
"heading": "When you want to step away from your desk, you can.",
"subheading": "The native mobile app has full feature parity with desktop.",
"sidebarLabel": "Paseo workspace drawer",
"chatLabel": "Paseo agent chat",
"diffLabel": "Paseo diff view"
},
"faq": {
"title": "FAQ",
"freeQuestion": "Is this free?",
"freeAnswer": "Yes. Paseo is free and open source. You need agent providers installed with your own credentials. Voice is local-first by default and can optionally use cloud speech providers if you configure them.",
"codeQuestion": "Does my code leave my machine?",
"codeAnswer": "Paseo doesn't send your code anywhere. Agents run locally and talk to their own APIs as they normally would. For remote access, you can use the optional <relay>end-to-end encrypted relay</relay>, connect directly over your local network, or use your own tunnel.",
"agentsQuestion": "What agents does it support?",
"agentsAnswer": "Paseo supports many providers. It has custom implementations for Claude, Codex, OpenCode, Pi, OMP, Antigravity, and Muse Code, and supports many more via ACP. See the full list here: <agents>all supported providers</agents>. See <muse>Muse Code setup and limitations</muse>.",
"providersQuestion": "How does Paseo run providers?",
"providersAnswer": "Paseo runs the providers installed on your machine as you'd normally run them. Paseo doesn't modify or change their behavior.",
"desktopQuestion": "Do I need the desktop app?",
"desktopAnswer": "No. You can run the daemon headless and use any client to connect. The desktop app just bundles the daemon with a UI.",
"voiceQuestion": "How does voice work?",
"voiceAnswer": "Voice runs locally on your device by default. You talk, the app transcribes and sends it to your agent as text. Optionally, you can configure OpenAI speech providers for higher-quality transcription and text-to-speech. See the <voice>voice docs</voice>.",
"networkQuestion": "Can I connect from outside my network?",
"networkAnswer": "Yes. You can use the hosted relay (end-to-end encrypted, Paseo can't read your traffic), set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the daemon port directly. See <configuration>configuration</configuration>.",
"gitQuestion": "Do I need git or GitHub?",
"gitAnswer": "No. Paseo works in any directory. Worktrees are optional and only relevant if you use git. You can run agents anywhere you'd normally work.",
"banQuestion": "Can I get banned for using Paseo?",
"banAnswer": "Paseo is designed to use each provider's officially supported integration and does not attempt to bypass its terms of service. It doesn't extract tokens or call inference APIs directly.",
"worktreesQuestion": "How do worktrees work?",
"worktreesAnswer": "When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo creates a git worktree and runs the agent inside it. The agent works on an isolated branch without touching your main working directory. See the <worktrees>worktrees docs</worktrees>."
},
"mockup": {
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
"build": "Build",
"review": "Review",
"ship": "Ship",
"extend": "Extend",
"app": {
"newWorkspace": "New workspace",
"history": "History",
"schedules": "Schedules",
"pinned": "Pinned",
"readyToReview": "Ready to review",
"working": "Working",
"done": "Done",
"addProject": "Add project",
"files": "Files",
"changes": "Changes",
"uncommitted": "Uncommitted",
"commits": "Commits",
"viewPr": "View",
"prOpen": "Open",
"checksPassed": "All checks have passed",
"checksDetail": "{{successful}} successful, {{skipped}} skipped checks",
"successfulChecks": "{{count}} successful checks",
"activity": "Activity",
"noActivity": "No activity yet",
"tasksProgress": "{{completed}}/{{total}} tasks",
"composerPlaceholder": "Message the agent, tag @files, or use /commands and /skills",
"mobileComposerPlaceholder": "Message, @files, /commands",
"plugin": "plugin",
"mergedWeek": "Merged · 7d",
"inReview": "In review",
"failing": "Failing",
"mergesWeek": "Merges · last 7 days",
"recentlyMerged": "Recently merged",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"reviewing": "reviewing",
"subagents": "{{count}} subagents",
"checksPassedShort": "passed"
}
}
},
"sponsor": {
"metaTitle": "Sponsor Paseo",
"metaDescription": "Paseo is an independent project used by tens of thousands of developers daily, maintained by one person, shaped by the community, with no investors. Sponsor the work on GitHub Sponsors, Open Collective or Buy Me a Coffee, or sponsor it as a company.",
"title": "Sponsor Paseo",
"sponsors": "Sponsors",
"becomeSponsor": "Become a sponsor",
"maintainer": "<maintainer>Mo</maintainer>, maintainer",
"note": {
"independent": "Paseo is an independent project used by tens of thousands of developers daily.",
"maintained": "It is maintained by one person, full time, and shaped by the community. There are no investors, no board and no company behind it. I have turned down funding offers to keep it that way.",
"community": "The community helps Paseo grow through feedback, testing, bug reports, code contributions and plugins. Your sponsorship helps me keep improving and maintaining it.",
"neutral": "A tool that sits between you and your code, your keys and your machines has to stay neutral, and that gets hard once someone else owns a return on it. Funding creates pressure to monetize, and that pressure changes what gets built.",
"selfFunded": "Paseo is self-funded: the work is paid for by sponsorship and by <hub>Paseo Hub</hub>, an optional hosted service. Your support is what lets me keep working on it."
},
"shortNote": {
"maintained": "It is maintained by one person, full time, and shaped by the community. There are no investors and no company behind it. I have turned down funding to keep it that way. A tool that sits between you and your code, your keys and your machines has to stay neutral, and funding creates pressure to monetize.",
"selfFunded": "Paseo is self-funded through sponsorship and <hub>Paseo Hub</hub>. Your support is what lets me keep working on it."
},
"company": {
"title": "Sponsor as a company",
"description": "Support Paseo financially and get visibility for your company",
"perMonth": "a month",
"homepage": "Your logo on the paseo.sh homepage and docs, around 70,000 visitors a month",
"readme": "Your logo in the GitHub README, around 30,000 visitors a month",
"discord": "An announcement in the Discord, around 2,000 members",
"x": "An announcement from me on X",
"emailFirst": "Or email me first",
"emailWhenOpen": "Email me to hear when a spot opens"
}
},
"download": {
"metaTitle": "Download Paseo for macOS, Windows, Linux, iOS, and Android",
"metaDescription": "Install Paseo on every platform. Native desktop apps for macOS, Windows, and Linux. Mobile apps for iOS and Android. Self-hosted, open source, free to download.",
"title": "Download",
"whatsNew": "What's new",
"desktop": "Desktop",
"desktopDescription": "Recommended, bundles everything you need",
"macRequirement": "Requires macOS 13 or newer",
"download": "Download",
"mobile": "Mobile",
"web": "Web",
"webDescription": "Connect to a server from any browser",
"webApp": "Web App",
"open": "Open",
"server": "Server",
"serverDescription": "Run the Paseo server anywhere, connect from any client",
"allReleases": "All releases are available on <github>GitHub</github>.",
"releaseChannel": "Release channel",
"stable": "Stable",
"beta": "Beta",
"betaWarning": "Beta builds ship ahead of stable and can break.",
"betaSettings": "Already running the desktop app? Set <setting>Settings → Release channel → Beta</setting> and it updates itself from here on."
},
"thanks": {
"metaTitle": "Thanks for downloading Paseo",
"metaDescription": "Your Paseo download is starting. Join the community on Discord, GitHub, and Reddit, or sponsor Paseo.",
"title": "Thanks for downloading Paseo",
"intro": "Get help, share feedback, and follow along.",
"didNotStart": "Download didn't start?",
"tryAgain": "Try again",
"discord": "Get help and share feedback.",
"github": "Star, report issues, contribute.",
"reddit": "Workflows and questions."
},
"hub": {
"metaTitle": "Paseo Hub - GitHub, Slack, and Discord triggers",
"metaDescription": "Run Paseo Hub yourself and start agents on your own machines from GitHub, Slack, and Discord.",
"title": "Paseo Hub",
"intro": "An optional service that sits above your daemons and gives them extra capabilities.",
"runIt": "Run it yourself or use the hosted service.",
"pricing": {
"title": "Choose how to run Hub",
"unavailable": "The plan comparison is temporarily unavailable.",
"selfHost": "Self-host Hub",
"selfHosted": "Self-hosted",
"free": "Free",
"openSource": "/ open source",
"getStarted": "Get started",
"operateHub": "You operate Hub",
"ownApps": "Provider apps you create and control",
"openSourceFeature": "Open source",
"unlimitedRuns": "Unlimited agent runs",
"runsPerMonth": "{{value}} agent runs a month",
"unlimitedUnits": "Unlimited {{unit}}s",
"units_one": "{{value}} {{unit}}",
"units_other": "{{value}} {{unit}}s",
"perMonth": "/ month",
"perMonths": "/ {{count}} months",
"perUnitMonth": "per {{unit}} / month",
"perUnitMonths": "per {{unit}} / {{count}} months",
"seat": "seat"
},
"triggers": {
"title": "Triggers, running on your machines",
"description": "Mention the bot in GitHub, Slack, or Discord and an agent starts on your daemon.",
"github": "Issues, PRs, and reviews.",
"slack": "Channels and threads.",
"discord": "Channels and threads."
},
"demo": {
"showIssues": "<mention/> show me the latest P0 issues",
"issuesOpen": "Three P0 issues are open:",
"createPr": "<mention/> create a pull request for the first one",
"ranOn": "Ran on <host>macbook-pro</host>. Opened <pr>getpaseo/paseo#1234</pr> with a fix for the terminal writes.",
"filesChanged": "3 files changed",
"testsPassed": "tests passed",
"bot": "Bot",
"caption": "The agent ran on your daemon, with your keys."
},
"agents": {
"title": "Runs the agents you already have",
"description": "The Hub ships no agent of its own, so your team works with the providers, subscriptions, and skills already configured on your daemons."
},
"shape": {
"title": "Works with your existing infrastructure",
"description": "The Hub connects to the daemons you already run, and your apps keep connecting to those same daemons.",
"triggersRow": "Triggers and teammates",
"yourTeam": "Your team",
"futureIntegrations": "Future integrations",
"webhooks": "webhooks and mentions",
"hub": "Hub",
"hubHosting": "Self-hosted or managed",
"sameRpcs": "the same RPCs the app and CLI use",
"daemonsRow": "Your daemons",
"directOrRelay": "direct connection or relay",
"appsRow": "Paseo apps",
"desktop": "Desktop",
"web": "Web",
"mobile": "Mobile",
"cli": "CLI"
},
"faq": {
"title": "FAQ",
"whatQuestion": "What is the Hub?",
"whatAnswer": "A separate service that sits above your Paseo daemons. You connect one or more daemons to it and it gives them GitHub, Slack, and Discord triggers, with collaboration features planned. Paseo works fully without it.",
"separateQuestion": "Why is it a separate service instead of part of the daemon?",
"separateAnswer1": "The daemon stays one lean executable on one machine. The Hub talks to daemons over the same RPCs the app and the CLI use, so anyone can build their own hub on top of Paseo.",
"separateAnswer2": "It also has a different job. The Hub is meant to be exposed to the internet, shared with a team, and run multi-tenant. None of that belongs in a single machine daemon.",
"selfHostQuestion": "Can I run it myself?",
"selfHostAnswer": "Yes. Run <code>npx @getpaseo/hub</code> and complete setup in the browser. The source is available on <github>GitHub</github>, and the <guide>self-hosting guide</guide> covers more involved deployments.",
"needQuestion": "What do I need to run it?",
"needAnswer": "Node.js and a running Paseo daemon. Hub guides you through connecting the provider apps you want.",
"hostedQuestion": "Is there a hosted version?",
"hostedAnswer": "Yes. The hosted service runs the same Hub software you can self-host.",
"plannedQuestion": "What else is planned?",
"plannedIntro": "Not built yet, listed so you know where this is going.",
"plannedProvisioning": "Provisioning daemons for your team",
"plannedSharing": "Sharing settings and skills across a team",
"plannedIntegrations": "More integrations",
"moveQuestion": "Do I have to move my team to a new tool?",
"moveAnswer": "No. The Hub works in the Slack or Discord your team already uses, and on the daemons you already run. When the work gets serious you switch to the Paseo app on those same daemons.",
"agentsQuestion": "Which agents does it run?",
"agentsAnswer": "Whichever ones your daemons have configured, with your own subscriptions and skills. The Hub ships no agent of its own.",
"codeQuestion": "Can the Hub run code on my machines?",
"codeAnswer": "Yes, on the daemons you connect to it and nowhere else. That is the whole point of triggers, and it's why access to the beta stays small and trusted.",
"accessQuestion": "How do I get access?",
"accessAnswer": "You can self-host today or <hosted>sign in to Hosted Hub</hosted> for a free account. When you want more agent runs or more seats, upgrade from Billing inside Hub."
}
},
"changelog": {
"metaTitle": "Changelog - Paseo",
"metaDescription": "Product updates, bug fixes, and improvements shipped in each Paseo release. Track new agent providers, mobile features, and daemon changes over time.",
"title": "Changelog",
"linkTo": "Link to Paseo {{version}}",
"untranslatedRelease": "These release notes have not been translated yet, so they are shown in English."
},
"blog": {
"metaTitle": "Blog – Updates and announcements from the Paseo team",
"metaDescription": "Product updates, technical posts, and announcements from the Paseo team. Notes on building a self-hosted, multi-agent dev environment for your phone.",
"postTitle": "{{title}} - Paseo",
"notFoundTitle": "Not Found - Paseo",
"notFound": "Post not found.",
"draft": "DRAFT",
"showingDrafts": "Showing draft posts",
"noPosts": "No posts yet."
},
"legal": {
"lastUpdated": "Last updated: {{date}}"
},
"plugins": {
"meta": {
"indexTitle": "Plugins – Extend Paseo with community plugins",
"indexDescription": "Themes, providers, panels, and automations built by the Paseo community. Install any of them with one command.",
"allTitle": "All plugins – Paseo plugins",
"allDescription": "Every community plugin for Paseo, by installs or newest first.",
"categoryTitle": "{{category}} – Paseo plugins",
"authorTitle": "{{author}} – Paseo plugins",
"authorDescription": "Paseo plugins published by {{author}}.",
"pluginTitle": "{{name}} – Paseo plugin",
"authorNotFoundTitle": "Author not found – Paseo",
"authorNotFoundDescription": "Author not found.",
"pluginNotFoundTitle": "Plugin not found – Paseo",
"pluginNotFoundDescription": "Plugin not found.",
"categoryNotFoundTitle": "Category not found – Paseo",
"categoryNotFoundDescription": "Category not found."
},
"title": "Plugins",
"pluginCount_one": "{{count}} plugin",
"pluginCount_other": "{{count}} plugins",
"authorCount_one": "{{count}} author",
"authorCount_other": "{{count}} authors",
"counts": "{{plugins}} by {{authors}}",
"lead": "Browse community plugins that extend Paseo with new functionality.",
"readDocs": "Read the docs",
"submit": "Submit a plugin",
"buildTitle": "Build your own plugin",
"buildDescription": "Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one with the plugin docs, then submit it to get it listed here.",
"featured": "Featured",
"featuredDescription": "A selection of hand picked plugins",
"seeAll": "See all",
"whatsNew": "What’s new",
"recentlyUpdated": "Recently updated",
"categoriesTitle": "Categories",
"mostInstalled": "Most installed",
"newest": "Newest",
"allPlugins": "All plugins",
"allChip": "All",
"results": "Results for “{{term}}”",
"resultsInCategory": "Results for “{{term}}” in {{category}}",
"clear": "Clear",
"noMatches": "No plugins match.",
"clearFilters": "Clear filters",
"noneUpdated": "No plugins have been updated since they were listed.",
"noneInCategory": "No plugins in this category yet.",
"window": {
"label": "Time window",
"week": "This week",
"month": "This month",
"all": "All time"
},
"search": "Search plugins",
"clearSearch": "Clear search",
"installs": "installs",
"back": "Plugins",
"reportBug": "Report a bug",
"source": "Source",
"screenshot": "{{name}} screenshot {{index}}",
"video": "{{name}} video {{index}}",
"previous": "Previous",
"next": "Next",
"untranslatedOverview": "This plugin’s overview has not been translated into your language yet, so it is shown in English.",
"notFound": {
"crumb": "Not found",
"browseAll": "Browse all plugins",
"authorTitle": "Author not found",
"authorBody": "Nobody with that GitHub owner has a plugin listed.",
"pluginTitle": "Plugin not found",
"pluginBody": "There is no plugin listed at this address.",
"categoryTitle": "Category not found",
"categoryBody": "There is no plugin category here."
},
"ago": {
"today": "today",
"yesterday": "yesterday",
"days": "{{count}}d ago",
"weeks": "{{count}}w ago"
},
"categories": {
"daemon-management": {
"label": "Daemon management",
"description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers."
},
"themes": {
"label": "Themes",
"description": "Change the app color scheme."
},
"providers": {
"label": "Providers",
"description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback."
},
"orchestration": {
"label": "Orchestration",
"description": "Coordinate agents, delegate work, and send messages across hosts."
},
"git": {
"label": "Git",
"description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews."
},
"workspaces": {
"label": "Workspaces",
"description": "Add workspace panels and tabs for boards, browsers, documents, and terminals."
},
"sidebar": {
"label": "Sidebar",
"description": "Add sidebar items and panels for usage, notifications, pricing, and links."
},
"extras": {
"label": "Extras",
"description": "Add presence, pets, puzzles, wellbeing reminders, and demos."
},
"utils": {
"label": "Utils",
"description": "Extend the composer and timeline with commands, prompts, skills, and small helpers."
}
}
}
}
@@ -0,0 +1,787 @@
{
"common": {
"breadcrumb": "面包屑导航",
"untranslated": "此页面尚未翻译成你的语言,因此以英文显示。",
"notFound": "页面不存在。",
"notFoundTitle": "页面不存在 - Paseo",
"more": "还有 {{count}} 个",
"copy": "复制",
"copied": "已复制",
"close": "关闭",
"loading": "加载中…",
"copyToClipboard": "复制到剪贴板",
"backHome": "返回首页"
},
"languageSelector": {
"label": "语言",
"current": "语言:{{language}}",
"names": {
"en": "英语",
"zh-CN": "简体中文"
}
},
"nav": {
"plugins": "插件",
"blog": "博客",
"docs": "文档",
"changelog": "更新日志",
"download": "下载",
"sponsor": "赞助",
"hub": "Hub",
"githubStars": "GitHub,{{stars}} 颗星"
},
"footer": {
"product": "产品",
"blog": "博客",
"docs": "文档",
"plugins": "插件",
"changelog": "更新日志",
"hub": "Hub",
"cli": "CLI",
"privacy": "隐私",
"terms": "条款",
"sponsor": "赞助",
"agents": "Agent",
"allProviders": "所有 Provider",
"alternatives": "替代方案",
"community": "社区",
"download": "下载",
"desktop": "桌面版",
"webApp": "网页版"
},
"docs": {
"headingAnchor": "链接到此部分",
"onThisPage": "本页内容",
"copyMarkdown": "复制为 Markdown",
"copied": "已复制",
"viewMarkdown": "查看 Markdown",
"viewOnGitHub": "在 GitHub 上查看",
"openMenu": "打开菜单",
"closeMenu": "关闭菜单",
"notFound": "文档不存在。",
"notFoundTitle": "页面不存在 - Paseo 文档",
"pageTitle": "{{title}} - Paseo 文档",
"categories": {
"Getting started": "快速上手",
"Workspaces": "工作区",
"Providers": "Provider",
"Orchestration": "编排",
"Browser": "浏览器",
"Schedules": "定时任务",
"Configuration": "配置",
"Plugins": "插件",
"TypeScript SDK": "TypeScript SDK",
"Hub": "Hub",
"Troubleshooting": "故障排查"
}
},
"agentPages": {
"claude-code": {
"title": "Claude Code 的开源应用",
"subtitle": "在你的机器上运行 Claude Code,从手机或桌面端操控。随时随地启动 Agent、查看进展、审查并合并代码。",
"metaTitle": "Claude Code 开源移动端与桌面端应用",
"metaDescription": "Claude Code 的开源移动端与桌面端应用。在你的机器上运行 Agent,随时随地跟踪进度、审查 diff 并合并。自托管,代码留在本地。"
},
"codex": {
"title": "Codex 的开源应用",
"subtitle": "在你的机器上运行 OpenAI 的 Codex,从手机或桌面端操控。同样的配置,同一台机器,不必守在笔记本前。",
"metaTitle": "Codex 开源移动端与桌面端应用",
"metaDescription": "OpenAI Codex 的开源移动端与桌面端应用。在你的机器上启动 Agent,随时随地跟踪进度、交付代码。自托管。"
},
"opencode": {
"title": "OpenCode 的开源应用",
"subtitle": "在你的机器上运行 OpenCode,从手机或桌面端操控。两端都开源,代码留在本地。",
"metaTitle": "OpenCode 开源移动端与桌面端应用",
"metaDescription": "OpenCode 的开源移动端与桌面端应用。在你的机器上启动 Agent,观察它们工作,随时随地交付代码。自托管。"
},
"pi": {
"title": "Pi 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Pi 编程 Agent,从手机或桌面端操控。自托管且开源。",
"metaTitle": "Pi Agent 开源移动端与桌面端应用",
"metaDescription": "Pi 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并合并。自托管。"
},
"omp": {
"title": "OMP (Oh My Pi) 的开源应用",
"subtitle": "在你的机器上运行 OMP (Oh My Pi),从手机或桌面端操控。自托管且开源。",
"metaTitle": "OMP (Oh My Pi) 开源移动端与桌面端应用",
"metaDescription": "OMP (Oh My Pi) 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并合并。自托管。"
},
"cursor": {
"title": "Cursor 的开源应用",
"subtitle": "把任务发给你机器上的 Cursor,从手机或桌面端操控。随时随地审查 diff。",
"metaTitle": "Cursor 开源移动端与桌面端应用",
"metaDescription": "Cursor 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地查看输出、审查 diff 并合并。自托管。"
},
"muse-code": {
"title": "Muse Code 的开源应用",
"subtitle": "在你的机器上运行 Meta 的 Muse Code,从手机或桌面端操控。随时随地选择模型、审查工具调用并处理审批。",
"metaTitle": "Muse Code 开源移动端与桌面端应用",
"metaDescription": "Meta Muse Code 的开源移动端与桌面端应用。运行你已安装的 Muse CLI,查看流式输出的轮次并处理审批。设置方法与版本限制见文档。"
},
"antigravity": {
"title": "Antigravity 的开源应用",
"subtitle": "在你的机器上运行已安装的 Antigravity CLI,从手机或桌面端操控。",
"metaTitle": "Antigravity 开源移动端与桌面端应用",
"metaDescription": "通过 Paseo 运行你已安装的 Antigravity CLI。在手机或桌面端观察 Agent 工作并审查改动。自托管,代码留在你的机器上。"
},
"copilot": {
"title": "GitHub Copilot 的开源应用",
"subtitle": "从手机或桌面端操控 GitHub Copilot。同一个账号,同一台机器,不必坐在桌前也能交付。",
"metaTitle": "GitHub Copilot 开源移动端与桌面端应用",
"metaDescription": "GitHub Copilot 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并合并。"
},
"gemini": {
"title": "Gemini CLI 的开源应用",
"subtitle": "在你的机器上运行 Google 的 Gemini CLI,从手机或桌面端操控。真正的编程工作,不必守在笔记本前。",
"metaTitle": "Gemini CLI 开源移动端与桌面端应用",
"metaDescription": "Google Gemini CLI 的开源移动端与桌面端应用。在你的机器上启动 Agent,随时随地跟踪进度并交付。自托管。"
},
"hermes": {
"title": "Hermes Agent 的开源应用",
"subtitle": "在你的机器上运行 Nous Research 的 Hermes Agent,从手机或桌面端操控。",
"metaTitle": "Hermes Agent 开源移动端与桌面端应用",
"metaDescription": "Nous Research Hermes Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度、交付代码。"
},
"qwen-code": {
"title": "Qwen Code 的开源应用",
"subtitle": "在你的机器上运行阿里巴巴的 Qwen Code,从手机或桌面端操控。",
"metaTitle": "Qwen Code 开源移动端与桌面端应用",
"metaDescription": "阿里巴巴 Qwen Code 的开源移动端与桌面端应用。在你的机器上启动 Agent,随时随地跟踪进度并合并。"
},
"kimi": {
"title": "Kimi Code CLI 的开源应用",
"subtitle": "在你的机器上运行月之暗面的 Kimi Code CLI,从手机或桌面端操控。",
"metaTitle": "Kimi Code CLI 开源移动端与桌面端应用",
"metaDescription": "月之暗面 Kimi Code CLI 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"amp": {
"title": "Amp 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Sourcegraph 的 Amp,从手机或桌面端操控。前沿的编程能力,不必守在笔记本前。",
"metaTitle": "Amp Agent 开源移动端与桌面端应用",
"metaDescription": "Amp 的开源移动端与桌面端应用,Amp 是 Sourcegraph 的前沿编程 Agent。在你的机器上启动任务,随时随地跟踪进度并交付。"
},
"auggie": {
"title": "Auggie CLI 的开源应用",
"subtitle": "在你的机器上运行 Augment Code 的 Auggie CLI,从手机或桌面端操控。",
"metaTitle": "Auggie CLI 开源移动端与桌面端应用",
"metaDescription": "Augment Code Auggie CLI 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"cline": {
"title": "Cline 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Cline 编程 Agent,从手机或桌面端操控。观察它工作,需要时随时介入。",
"metaTitle": "Cline Agent 开源移动端与桌面端应用",
"metaDescription": "自主编程 Agent Cline 的开源移动端与桌面端应用。随时随地启动任务、查看输出、审查 diff。"
},
"codebuddy": {
"title": "Codebuddy Code 的开源应用",
"subtitle": "在你的机器上运行腾讯云的 Codebuddy,从手机或桌面端操控。",
"metaTitle": "Codebuddy Code 开源移动端与桌面端应用",
"metaDescription": "腾讯云 Codebuddy Code 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"cortex-code": {
"title": "Cortex Code 的开源应用",
"subtitle": "在你的机器上运行 Snowflake 的 Cortex Code,从手机或桌面端操控。不必守在笔记本前。",
"metaTitle": "Cortex Code 开源移动端与桌面端应用",
"metaDescription": "Snowflake Cortex Code 的开源移动端与桌面端应用。在你的机器上启动 Agent,随时随地跟踪进度并交付。"
},
"corust": {
"title": "Corust Agent 的开源应用",
"subtitle": "在你的机器上用 Corust Agent 构建 Rust 项目,从手机或桌面端操控。",
"metaTitle": "Corust Agent 开源移动端与桌面端应用",
"metaDescription": "专注 Rust 的 Corust 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地交付。"
},
"crow": {
"title": "crow-cli 的开源应用",
"subtitle": "在你的机器上运行 crow-cli,一个精简的原生 ACP 编程 Agent。从手机或桌面端操控。",
"metaTitle": "Crow-CLI 开源移动端与桌面端应用",
"metaDescription": "crow-cli 的开源移动端与桌面端应用,crow-cli 是一个精简的原生 ACP 编程 Agent。在你的机器上启动任务,随时随地查看。"
},
"deepagents": {
"title": "DeepAgents 的开源应用",
"subtitle": "在你的机器上运行 LangChain 的 DeepAgents,从手机或桌面端操控。开箱即用。",
"metaTitle": "DeepAgents 开源移动端与桌面端应用",
"metaDescription": "LangChain DeepAgents 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"deepseek-tui": {
"title": "CodeWhale 的开源应用",
"subtitle": "在你的机器上运行 CodeWhale 基于 DeepSeek V4 的终端编程 Agent,从手机或桌面端操控。",
"metaTitle": "CodeWhale 开源移动端与桌面端应用",
"metaDescription": "CodeWhale 的开源移动端与桌面端应用。在你的机器上启动编程会话,随时随地跟踪进度并交付。"
},
"dimcode": {
"title": "DimCode 的开源应用",
"subtitle": "领先模型,一条命令。在你的机器上运行 DimCode,从手机或桌面端操控。",
"metaTitle": "DimCode 开源移动端与桌面端应用",
"metaDescription": "多模型编程 Agent DimCode 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地交付。"
},
"dirac": {
"title": "Dirac 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Dirac 基于哈希锚点的并行编辑,从手机或桌面端操控。",
"metaTitle": "Dirac Agent 开源移动端与桌面端应用",
"metaDescription": "Dirac 编程 Agent 的开源移动端与桌面端应用。基于哈希锚点的并行编辑与 AST 操作,随时随地交付。"
},
"factory-droid": {
"title": "Factory Droid 的开源应用",
"subtitle": "在你的机器上运行 Factory 的 Droid 编程 Agent,从手机或桌面端操控。",
"metaTitle": "Factory Droid 开源移动端与桌面端应用",
"metaDescription": "Factory AI Droid 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"fast-agent": {
"title": "fast-agent 的开源应用",
"subtitle": "在你的机器上运行多 Provider 编程 Agent fast-agent,从手机或桌面端操控。",
"metaTitle": "Fast-Agent 开源移动端与桌面端应用",
"metaDescription": "多 Provider 编程 Agent fast-agent 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地查看。"
},
"glm": {
"title": "GLM Agent 的开源应用",
"subtitle": "在你的机器上运行智谱 AI 的 GLM 编程 Agent,从手机或桌面端操控。支持流式输出,可在会话中切换模型。",
"metaTitle": "GLM Agent 开源移动端与桌面端应用",
"metaDescription": "智谱 AI GLM 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度、交付代码。"
},
"goose": {
"title": "goose 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Block 的 goose,从手机或桌面端操控。本地运行、可扩展、开源。",
"metaTitle": "Goose Agent 开源移动端与桌面端应用",
"metaDescription": "Block 的本地开源 AI Agent goose 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地交付。"
},
"junie": {
"title": "Junie 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 JetBrains 的 Junie,从手机或桌面端操控。真正的工作,不需要 IDE。",
"metaTitle": "Junie Agent 开源移动端与桌面端应用",
"metaDescription": "JetBrains Junie 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"kilo": {
"title": "Kilo Code 的开源应用",
"subtitle": "在你的机器上运行 Kilo Code,从手机或桌面端操控。发送任务,看着它们交付。",
"metaTitle": "Kilo Code 开源移动端与桌面端应用",
"metaDescription": "Kilo Code 的开源移动端与桌面端应用。通过 Kilo CLI 在你的机器上启动任务,随时随地跟踪进度并合并。"
},
"minion-code": {
"title": "Minion Code 的开源应用",
"subtitle": "在你的机器上运行 Minion Code 基于框架的 Agent,从手机或桌面端操控。",
"metaTitle": "Minion Code 开源移动端与桌面端应用",
"metaDescription": "基于 Minion 框架的编程 Agent Minion Code 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地交付。"
},
"mistral-vibe": {
"title": "Mistral Vibe 的开源应用",
"subtitle": "在你的机器上运行 Mistral 开源的 Vibe 助手,从手机或桌面端操控。",
"metaTitle": "Mistral Vibe 开源移动端与桌面端应用",
"metaDescription": "Mistral Vibe 编程助手的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"nova": {
"title": "Nova 编程 Agent 的开源应用",
"subtitle": "在你的机器上运行 Compass AI 的 Nova,从手机或桌面端操控。",
"metaTitle": "Nova Agent 开源移动端与桌面端应用",
"metaDescription": "Compass AI Nova 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并合并。"
},
"poolside": {
"title": "Poolside 编程 Agent 的开源应用",
"subtitle": "从手机或桌面端操控 Poolside 的编程 Agent。启动工作,看着它落地。",
"metaTitle": "Poolside Agent 开源移动端与桌面端应用",
"metaDescription": "Poolside 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地跟踪进度并交付。"
},
"qoder": {
"title": "Qoder CLI 的开源应用",
"subtitle": "在你的机器上运行 Qoder 智能编程助手,从手机或桌面端操控。",
"metaTitle": "Qoder CLI 开源移动端与桌面端应用",
"metaDescription": "智能编程助手 Qoder 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地交付。"
},
"sigit": {
"title": "siGit Code 的开源应用",
"subtitle": "在你的机器上运行 siGit 本地优先的编程 Agent,从手机或桌面端操控。可选在设备端进行 LLM 推理。",
"metaTitle": "SiGit Code 开源移动端与桌面端应用",
"metaDescription": "本地优先编程 Agent siGit Code 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地交付。"
},
"stakpak": {
"title": "Stakpak DevOps Agent 的开源应用",
"subtitle": "在你的机器上运行 Stakpak 基于 Rust 的 DevOps Agent,从手机或桌面端操控。",
"metaTitle": "Stakpak DevOps Agent 开源移动端与桌面端应用",
"metaDescription": "基于 Rust 的 DevOps Agent Stakpak 的开源移动端与桌面端应用。在你的机器上启动任务,随时随地查看。"
},
"vtcode": {
"title": "VT Code 的开源应用",
"subtitle": "在你的机器上运行 VT Code 的多 Provider 编程 Agent,从手机或桌面端操控。",
"metaTitle": "VT Code 开源移动端与桌面端应用",
"metaDescription": "多 Provider 编程 Agent VT Code 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地交付。"
},
"agoragentic": {
"title": "Agoragentic 的开源应用",
"subtitle": "在你的机器上运行 Agoragentic 的 174+ 项 AI 能力,从手机或桌面端操控。",
"metaTitle": "Agoragentic 开源移动端与桌面端应用",
"metaDescription": "AI Agent 市场 Agoragentic 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地交付。"
},
"autohand": {
"title": "Autohand Code 的开源应用",
"subtitle": "在你的机器上运行 Autohand AI 的编程 Agent,从手机或桌面端操控。",
"metaTitle": "Autohand Code 开源移动端与桌面端应用",
"metaDescription": "Autohand AI 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。"
},
"grok": {
"title": "Grok 的开源应用",
"subtitle": "在你的机器上运行 xAI 的 Grok Build 编程 Agent,从手机或桌面端操控。",
"metaTitle": "Grok 开源移动端与桌面端应用",
"metaDescription": "xAI Grok Build 编程 Agent 的开源移动端与桌面端应用。在你的机器上启动会话,随时随地跟踪进度并交付。自托管。"
}
},
"home": {
"metaTitle": "Paseo – 随时随地运行 Claude Code、Codex、Copilot、OpenCode",
"metaDescription": "面向 Claude Code、Codex、OpenCode、Pi 以及另外 30 多个编程 Agent 的开源应用。在你自己的机器上并行运行多个 Agent。编辑器、终端、diff、拉取请求和浏览器集于一个窗口。桌面端一次下载即可使用,iOS 和 Android 上提供完整应用。",
"title": "面向 Agent 的<br/>开发环境",
"subtitle": "同时运行多个编程 Agent,<mobileBreak/>在任何机器上。<br/>在桌前,也在手机上。"
},
"agents": {
"metaTitle": "支持的 Agent – Paseo 可运行的所有编程 Agent",
"metaDescription": "在手机上运行 Claude Code、Codex、OpenCode、Pi、OMP、Cursor、Muse Code、Antigravity 以及数十个其他编程 Agent。自托管,代码留在你的机器上。",
"title": "支持的 Agent",
"intro": "Paseo 原生支持许多 Agent,可以运行任何 <acp>ACP Agent</acp>,你还可以通过<plugins>插件</plugins>实现新的 Provider。"
},
"landing": {
"socialProof": {
"title": "深受开发者喜爱",
"description": "看看开发者们怎么评价 Paseo",
"readPost": "阅读 {{name}} 的原帖"
},
"providers": {
"title": "与你的工具协同工作",
"description": "沿用你的订阅、技能和配置"
},
"runAnywhere": {
"title": "随处运行",
"description": "在本机、在另一台机器上,或与团队一起使用 Paseo",
"desktopTitle": "桌面应用",
"desktopDescription": "一键体验,下载应用即可使用,无需服务器",
"mobileTitle": "移动端",
"mobileDescription": "功能完整的原生客户端,不是 WebView",
"mobileCta": "下载",
"remoteTitle": "远程机器",
"remoteDescription": "在家庭实验室或云主机上运行 Paseo",
"remoteCta": "文档",
"teamsTitle": "团队与触发器",
"teamsDescription": "共享访问权限,或从 GitHub、Slack 和 Discord 发起工作",
"teamsCta": "Paseo Hub"
},
"automation": {
"title": "为自动化而生",
"description": "使用 MCP、CLI 或 TypeScript SDK 自动化 Paseo",
"mcpCaption": "由另一个 Agent 调用",
"cliCaption": "在终端中使用",
"sdkCaption": "在代码中使用",
"mcpDocs": "MCP 文档",
"cliDocs": "CLI 文档",
"sdkDocs": "SDK 文档",
"transcriptRequest": "把标记为 ready 的 GitHub 未关闭 issue 分发给各自独立的 worktree Agent。",
"transcriptPlan": "我找到了两个 ready 的 issue,会分别在各自的 worktree 中运行。",
"transcriptDone": "完成,两个 Agent 正在运行。它们结束时我会通知你。",
"otherHostComment": "# 指定另一台主机"
},
"plugins": {
"title": "用插件扩展",
"description": "添加新的面板和功能,在桌面端、网页端和移动端都能使用",
"lead": "插件",
"browse": "浏览插件",
"docs": "插件文档",
"license": "Paseo 采用 Apache 2.0 许可证。",
"fork": "Fork 仓库",
"uiTitle": "界面",
"uiDescription": "页面、工作区面板、侧边栏项目、斜杠命令和主题",
"lifecycleTitle": "Agent 生命周期",
"lifecycleDescription": "修改配置和 MCP 服务器、响应权限请求、发送后续消息",
"providersTitle": "Provider",
"providersDescription": "把编程 Agent 添加为 Provider,用法与内置 Provider 相同"
},
"philosophy": {
"title": "理念",
"description": "Paseo 的立场,也是每项功能的基础",
"privateTitle": "私密",
"privateDescription": "没有遥测、没有追踪、不强制登录。代码留在你的机器上。",
"independentTitle": "独立",
"independentDescription": "Paseo 不对投资人负责,由用户决定开发什么。",
"polishedTitle": "精致",
"polishedDescription": "安装应用即可开始工作,不需要知道 daemon 是什么。",
"unifiedTitle": "统一",
"unifiedDescription": "所有编程 Agent,用法都一样。随时切换 Provider。",
"composableTitle": "可组合",
"composableDescription": "在笔记本或服务器上运行 Agent,从任何设备连接。",
"extensibleTitle": "可扩展",
"extensibleDescription": "如果 Paseo 不符合你的工作方式,用插件或 fork 来改变它。"
},
"getStarted": {
"supports": "支持",
"downloadFor": "下载 {{platform}} 版",
"getStoreApp": "获取 {{store}} 应用",
"desktopApp": "桌面应用",
"serverInstallLabel": "在远程机器上安装 daemon",
"serverInstallTitle": "在远程机器上运行 Agent",
"serverInstallDescription": "适用于你想从 Paseo 应用连接的无界面机器。桌面应用已内置 daemon",
"serverInstallFootnote": "需要 Node.js 18+。运行 <code>paseo</code> 启动 daemon。"
},
"showcase": {
"heading": "想离开桌前时,随时都可以。",
"subheading": "原生移动应用的功能与桌面端完全一致。",
"sidebarLabel": "Paseo 工作区抽屉",
"chatLabel": "Paseo Agent 对话",
"diffLabel": "Paseo diff 视图"
},
"faq": {
"title": "常见问题",
"freeQuestion": "Paseo 免费吗?",
"freeAnswer": "是的。Paseo 免费且开源。你需要安装 AgentProvider 并使用自己的凭据。语音默认在本地处理,配置后也可以选择使用云端语音服务。",
"codeQuestion": "我的代码会离开我的机器吗?",
"codeAnswer": "Paseo 不会把你的代码发送到任何地方。Agent 在本地运行,像平常一样调用各自的 API。远程访问时,你可以使用可选的<relay>端到端加密中继</relay>,也可以通过局域网直连,或使用你自己的隧道。",
"agentsQuestion": "支持哪些 Agent?",
"agentsAnswer": "Paseo 支持很多 Provider。它为 Claude、Codex、OpenCode、Pi、OMP、Antigravity 和 Muse Code 提供了专门的实现,并通过 ACP 支持更多 Agent。完整列表见:<agents>所有支持的 Provider</agents>。另见 <muse>Muse Code 的设置与限制</muse>。",
"providersQuestion": "Paseo 如何运行 Provider?",
"providersAnswer": "Paseo 按你平常的方式运行你机器上已安装的 Provider,不会修改或改变它们的行为。",
"desktopQuestion": "我需要桌面应用吗?",
"desktopAnswer": "不需要。你可以无界面运行 daemon,并用任意客户端连接。桌面应用只是把 daemon 和界面打包在一起。",
"voiceQuestion": "语音功能如何工作?",
"voiceAnswer": "语音默认在你的设备本地运行。你说话,应用转写后以文本形式发送给 Agent。你也可以配置 OpenAI 语音服务,获得更高质量的转写和语音合成。详见<voice>语音文档</voice>。",
"networkQuestion": "可以从外部网络连接吗?",
"networkAnswer": "可以。你可以使用托管中继(端到端加密,Paseo 无法读取你的流量),搭建自己的隧道(Tailscale、Cloudflare Tunnel 等),或直接暴露 daemon 端口。详见<configuration>配置</configuration>。",
"gitQuestion": "我需要 git 或 GitHub 吗?",
"gitAnswer": "不需要。Paseo 可在任意目录中工作。worktree 是可选的,只在使用 git 时才相关。你可以在平常工作的任何地方运行 Agent。",
"banQuestion": "使用 Paseo 会被封号吗?",
"banAnswer": "Paseo 的设计是使用各 Provider 官方支持的集成方式,不会试图绕过其服务条款。它不会提取令牌,也不会直接调用推理 API。",
"worktreesQuestion": "worktree 如何工作?",
"worktreesAnswer": "当你使用 worktree 选项启动 Agent 时(无论从应用、桌面端还是 CLI),Paseo 会创建一个 git worktree 并在其中运行 Agent。Agent 在隔离的分支上工作,不会影响你的主工作目录。详见<worktrees>worktree 文档</worktrees>。"
},
"mockup": {
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
"build": "构建",
"review": "审查",
"ship": "发布",
"extend": "扩展",
"app": {
"newWorkspace": "新建工作区",
"history": "历史",
"schedules": "计划",
"pinned": "已置顶",
"readyToReview": "待查看",
"working": "运行中",
"done": "已完成",
"addProject": "添加 project",
"files": "文件",
"changes": "变更",
"uncommitted": "未 commit",
"commits": "提交",
"viewPr": "查看",
"prOpen": "开放",
"checksPassed": "所有检查均已通过",
"checksDetail": "检查:{{successful}} 个成功,{{skipped}} 个已跳过",
"successfulChecks": "{{count}} 个成功的检查",
"activity": "动态",
"noActivity": "暂无动态",
"tasksProgress": "{{completed}}/{{total}} 项任务",
"composerPlaceholder": "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills",
"mobileComposerPlaceholder": "发消息,@files,/commands",
"plugin": "插件",
"mergedWeek": "已合并 · 7 天",
"inReview": "审查中",
"failing": "失败",
"mergesWeek": "合并 · 最近 7 天",
"recentlyMerged": "最近合并",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"reviewing": "审查中",
"subagents": "{{count}} 个 subagent",
"checksPassedShort": "通过"
}
}
},
"sponsor": {
"metaTitle": "赞助 Paseo",
"metaDescription": "Paseo 是一个独立项目,每天有数万名开发者在使用,由一个人维护、由社区塑造,没有投资人。你可以通过 GitHub Sponsors、Open Collective 或 Buy Me a Coffee 赞助,也可以以公司身份赞助。",
"title": "赞助 Paseo",
"sponsors": "赞助商",
"becomeSponsor": "成为赞助商",
"maintainer": "维护者 <maintainer>Mo</maintainer>",
"note": {
"independent": "Paseo 是一个独立项目,每天有数万名开发者在使用。",
"maintained": "它由一个人全职维护,并由社区共同塑造。没有投资人、没有董事会,背后也没有公司。为了保持这一点,我拒绝过融资邀约。",
"community": "社区通过反馈、测试、问题报告、代码贡献和插件帮助 Paseo 成长。你的赞助让我能够持续改进和维护它。",
"neutral": "一个位于你与代码、密钥和机器之间的工具必须保持中立,而一旦有人要从中获得回报,这就很难做到。融资会带来变现的压力,这种压力会改变产品的方向。",
"selfFunded": "Paseo 自筹资金:开发工作由赞助和可选的托管服务 <hub>Paseo Hub</hub> 支付。正是你的支持让我能够继续投入。"
},
"shortNote": {
"maintained": "它由一个人全职维护,并由社区共同塑造。没有投资人,背后也没有公司。为了保持这一点,我拒绝过融资。一个位于你与代码、密钥和机器之间的工具必须保持中立,而融资会带来变现的压力。",
"selfFunded": "Paseo 通过赞助和 <hub>Paseo Hub</hub> 自筹资金。正是你的支持让我能够继续投入。"
},
"company": {
"title": "以公司身份赞助",
"description": "在资金上支持 Paseo,同时提升你公司的曝光度",
"perMonth": "每月",
"homepage": "你的 Logo 出现在 paseo.sh 首页和文档中,每月约 70,000 名访客",
"readme": "你的 Logo 出现在 GitHub README 中,每月约 30,000 名访客",
"discord": "在 Discord 社区发布公告,约 2,000 名成员",
"x": "我在 X 上发布一条公告",
"emailFirst": "或先给我发邮件",
"emailWhenOpen": "发邮件给我,名额空出时通知你"
}
},
"download": {
"metaTitle": "下载适用于 macOS、Windows、Linux、iOS 和 Android 的 Paseo",
"metaDescription": "在所有平台上安装 Paseo。提供 macOS、Windows 和 Linux 原生桌面应用,以及 iOS 和 Android 移动应用。自托管、开源、免费下载。",
"title": "下载",
"whatsNew": "更新内容",
"desktop": "桌面端",
"desktopDescription": "推荐,包含所需的一切",
"macRequirement": "需要 macOS 13 或更高版本",
"download": "下载",
"mobile": "移动端",
"web": "网页端",
"webDescription": "从任意浏览器连接到服务器",
"webApp": "网页应用",
"open": "打开",
"server": "服务器",
"serverDescription": "在任何地方运行 Paseo 服务器,从任意客户端连接",
"allReleases": "所有版本都可以在 <github>GitHub</github> 上获取。",
"releaseChannel": "发布通道",
"stable": "稳定版",
"beta": "测试版",
"betaWarning": "测试版先于稳定版发布,可能存在问题。",
"betaSettings": "已经在使用桌面应用?前往 <setting>设置 → 发布通道 → Beta</setting>,之后它会自动从这里更新。"
},
"thanks": {
"metaTitle": "感谢下载 Paseo",
"metaDescription": "你的 Paseo 下载即将开始。欢迎加入 Discord、GitHub 和 Reddit 社区,或赞助 Paseo。",
"title": "感谢下载 Paseo",
"intro": "获取帮助、分享反馈、关注最新动态。",
"didNotStart": "下载没有开始?",
"tryAgain": "重试",
"discord": "获取帮助、分享反馈。",
"github": "点 Star、报告问题、参与贡献。",
"reddit": "工作流与问答。"
},
"hub": {
"metaTitle": "Paseo Hub - GitHub、Slack 和 Discord 触发器",
"metaDescription": "自行运行 Paseo Hub,从 GitHub、Slack 和 Discord 在你自己的机器上启动 Agent。",
"title": "Paseo Hub",
"intro": "一个可选服务,位于你的 daemon 之上,为它们提供额外能力。",
"runIt": "可以自行运行,也可以使用托管服务。",
"pricing": {
"title": "选择 Hub 的运行方式",
"unavailable": "方案对比暂时不可用。",
"selfHost": "自托管 Hub",
"selfHosted": "自托管",
"free": "免费",
"openSource": "/ 开源",
"getStarted": "开始使用",
"operateHub": "由你运营 Hub",
"ownApps": "由你创建和控制的 Provider 应用",
"openSourceFeature": "开源",
"unlimitedRuns": "无限次 Agent 运行",
"runsPerMonth": "每月 {{value}} 次 Agent 运行",
"unlimitedUnits": "无限{{unit}}",
"units_one": "{{value}} 个{{unit}}",
"units_other": "{{value}} 个{{unit}}",
"perMonth": "/ 月",
"perMonths": "/ {{count}} 个月",
"perUnitMonth": "每{{unit}} / 月",
"perUnitMonths": "每{{unit}} / {{count}} 个月",
"seat": "席位"
},
"triggers": {
"title": "触发器,运行在你的机器上",
"description": "在 GitHub、Slack 或 Discord 中提及机器人,就会在你的 daemon 上启动一个 Agent。",
"github": "Issue、PR 和代码审查。",
"slack": "频道和讨论串。",
"discord": "频道和讨论串。"
},
"demo": {
"showIssues": "<mention/> 给我看看最新的 P0 issue",
"issuesOpen": "目前有三个未关闭的 P0 issue:",
"createPr": "<mention/> 为第一个创建一个拉取请求",
"ranOn": "已在 <host>macbook-pro</host> 上运行。已创建 <pr>getpaseo/paseo#1234</pr>,修复了终端写入问题。",
"filesChanged": "3 个文件已更改",
"testsPassed": "测试通过",
"bot": "机器人",
"caption": "Agent 在你的 daemon 上运行,使用你的密钥。"
},
"agents": {
"title": "运行你已有的 Agent",
"description": "Hub 本身不附带任何 Agent,因此你的团队使用的是 daemon 上已配置好的 Provider、订阅和技能。"
},
"shape": {
"title": "适配你现有的基础设施",
"description": "Hub 连接到你已在运行的 daemon,你的应用也继续连接这些 daemon。",
"triggersRow": "触发器和团队成员",
"yourTeam": "你的团队",
"futureIntegrations": "未来的集成",
"webhooks": "webhook 和提及",
"hub": "Hub",
"hubHosting": "自托管或托管",
"sameRpcs": "与应用和 CLI 相同的 RPC",
"daemonsRow": "你的 daemon",
"directOrRelay": "直连或中继",
"appsRow": "Paseo 应用",
"desktop": "桌面端",
"web": "网页端",
"mobile": "移动端",
"cli": "CLI"
},
"faq": {
"title": "常见问题",
"whatQuestion": "Hub 是什么?",
"whatAnswer": "一个独立的服务,位于你的 Paseo daemon 之上。你把一个或多个 daemon 连接到它,它就为这些 daemon 提供 GitHub、Slack 和 Discord 触发器,协作功能也在计划中。没有它,Paseo 也能完整工作。",
"separateQuestion": "为什么它是独立服务,而不是 daemon 的一部分?",
"separateAnswer1": "daemon 保持为单台机器上的一个精简可执行程序。Hub 通过与应用和 CLI 相同的 RPC 与 daemon 通信,所以任何人都可以基于 Paseo 构建自己的 hub。",
"separateAnswer2": "它的职责也不同。Hub 需要暴露在互联网上、与团队共享,并以多租户方式运行。这些都不属于单机 daemon 的职责。",
"selfHostQuestion": "我可以自己运行吗?",
"selfHostAnswer": "可以。运行 <code>npx @getpaseo/hub</code>,然后在浏览器中完成设置。源代码在 <github>GitHub</github> 上,更复杂的部署请参阅<guide>自托管指南</guide>。",
"needQuestion": "运行它需要什么?",
"needAnswer": "Node.js 和一个正在运行的 Paseo daemon。Hub 会引导你连接需要的 Provider 应用。",
"hostedQuestion": "有托管版本吗?",
"hostedAnswer": "有。托管服务运行的就是你可以自托管的同一套 Hub 软件。",
"plannedQuestion": "还计划了什么?",
"plannedIntro": "以下尚未实现,列出来是为了让你了解方向。",
"plannedProvisioning": "为团队预配 daemon",
"plannedSharing": "在团队内共享设置和技能",
"plannedIntegrations": "更多集成",
"moveQuestion": "我需要把团队迁移到新工具吗?",
"moveAnswer": "不需要。Hub 在你团队已在使用的 Slack 或 Discord 中工作,运行在你已有的 daemon 上。工作变得复杂时,你可以在同样的 daemon 上切换到 Paseo 应用。",
"agentsQuestion": "它运行哪些 Agent?",
"agentsAnswer": "你的 daemon 上配置了哪些就运行哪些,使用你自己的订阅和技能。Hub 本身不附带任何 Agent。",
"codeQuestion": "Hub 能在我的机器上运行代码吗?",
"codeAnswer": "能,但仅限于你连接到它的 daemon,其他地方都不行。这正是触发器的意义,也是测试版访问范围保持小而可信的原因。",
"accessQuestion": "如何获得访问权限?",
"accessAnswer": "你现在就可以自托管,或<hosted>登录托管版 Hub</hosted> 获取免费账户。需要更多 Agent 运行次数或更多席位时,可在 Hub 内的账单页面升级。"
}
},
"changelog": {
"metaTitle": "更新日志 - Paseo",
"metaDescription": "每个 Paseo 版本发布的产品更新、问题修复和改进。了解新的 Agent Provider、移动端功能和 daemon 变化。",
"title": "更新日志",
"linkTo": "链接到 Paseo {{version}}",
"untranslatedRelease": "此版本的发布说明尚未翻译,因此以英文显示。"
},
"blog": {
"metaTitle": "博客 – 来自 Paseo 团队的更新与公告",
"metaDescription": "来自 Paseo 团队的产品更新、技术文章和公告。记录如何打造一个可在手机上使用的自托管多 Agent 开发环境。",
"postTitle": "{{title}} - Paseo",
"notFoundTitle": "未找到 - Paseo",
"notFound": "未找到文章。",
"draft": "草稿",
"showingDrafts": "正在显示草稿文章",
"noPosts": "暂无文章。"
},
"legal": {
"lastUpdated": "最后更新:{{date}}"
},
"plugins": {
"meta": {
"indexTitle": "插件 – 用社区插件扩展 Paseo",
"indexDescription": "由 Paseo 社区构建的主题、Provider、面板和自动化。每一个都只需一条命令即可安装。",
"allTitle": "全部插件 – Paseo 插件",
"allDescription": "Paseo 的全部社区插件,可按安装量或最新排序。",
"categoryTitle": "{{category}} – Paseo 插件",
"authorTitle": "{{author}} – Paseo 插件",
"authorDescription": "{{author}} 发布的 Paseo 插件。",
"pluginTitle": "{{name}} – Paseo 插件",
"authorNotFoundTitle": "未找到作者 – Paseo",
"authorNotFoundDescription": "未找到作者。",
"pluginNotFoundTitle": "未找到插件 – Paseo",
"pluginNotFoundDescription": "未找到插件。",
"categoryNotFoundTitle": "未找到分类 – Paseo",
"categoryNotFoundDescription": "未找到分类。"
},
"title": "插件",
"pluginCount_other": "{{count}} 个插件",
"authorCount_other": "{{count}} 位作者",
"counts": "{{authors}},共 {{plugins}}",
"lead": "浏览社区插件,为 Paseo 添加新功能。",
"readDocs": "阅读文档",
"submit": "提交插件",
"buildTitle": "构建你自己的插件",
"buildDescription": "插件可以为 Paseo 添加主题、工作区面板、斜杠命令和 Agent 钩子。参照插件文档编写一个插件,然后提交,即可在这里上架。",
"featured": "精选",
"featuredDescription": "人工挑选的插件",
"seeAll": "查看全部",
"whatsNew": "最新上架",
"recentlyUpdated": "最近更新",
"categoriesTitle": "分类",
"mostInstalled": "安装最多",
"newest": "最新",
"allPlugins": "全部插件",
"allChip": "全部",
"results": "“{{term}}”的搜索结果",
"resultsInCategory": "{{category}}中“{{term}}”的搜索结果",
"clear": "清除",
"noMatches": "没有匹配的插件。",
"clearFilters": "清除筛选",
"noneUpdated": "上架以来还没有插件更新过。",
"noneInCategory": "这个分类还没有插件。",
"window": {
"label": "时间范围",
"week": "本周",
"month": "本月",
"all": "全部时间"
},
"search": "搜索插件",
"clearSearch": "清除搜索",
"installs": "次安装",
"back": "插件",
"reportBug": "报告问题",
"source": "源代码",
"screenshot": "{{name}} 截图 {{index}}",
"video": "{{name}} 视频 {{index}}",
"previous": "上一个",
"next": "下一个",
"untranslatedOverview": "该插件的概览尚未翻译为你的语言,因此以英文显示。",
"notFound": {
"crumb": "未找到",
"browseAll": "浏览全部插件",
"authorTitle": "未找到作者",
"authorBody": "没有使用该 GitHub 所有者发布的插件。",
"pluginTitle": "未找到插件",
"pluginBody": "这个地址下没有已上架的插件。",
"categoryTitle": "未找到分类",
"categoryBody": "这里没有插件分类。"
},
"ago": {
"today": "今天",
"yesterday": "昨天",
"days": "{{count}} 天前",
"weeks": "{{count}} 周前"
},
"categories": {
"daemon-management": {
"label": "daemon 管理",
"description": "管理主机、daemon 任务、资源、审批和 MCP 服务器。"
},
"themes": {
"label": "主题",
"description": "更改应用的配色方案。"
},
"providers": {
"label": "Provider",
"description": "接入编程 Agent、ACP 服务器和模型路由,并支持账户与配额回退。"
},
"orchestration": {
"label": "编排",
"description": "协调 Agent、委派工作,并在主机之间发送消息。"
},
"git": {
"label": "Git",
"description": "处理分支、worktree、PR、堆叠、diff 和审查。"
},
"workspaces": {
"label": "工作区",
"description": "为看板、浏览器、文档和终端添加工作区面板与标签页。"
},
"sidebar": {
"label": "侧边栏",
"description": "添加用于用量、通知、定价和链接的侧边栏项目与面板。"
},
"extras": {
"label": "趣味",
"description": "添加在线状态、宠物、谜题、健康提醒和演示。"
},
"utils": {
"label": "工具",
"description": "为 Composer 和时间线添加命令、提示词、技能和小工具。"
}
},
"pluginCount_one": "{{count}} 个插件",
"authorCount_one": "{{count}} 位作者"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { createInstance, type i18n, type TFunction } from "i18next";
import en from "./resources/en.json";
import zhCN from "./resources/zh-CN.json";
import { DEFAULT_LOCALE, type Locale } from "./locale";
const RESOURCES: Record<Locale, typeof en> = { en, "zh-CN": zhCN };
function createI18n(locale: Locale): i18n {
const instance = createInstance({
lng: locale,
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [locale, DEFAULT_LOCALE],
resources: {
[locale]: { translation: RESOURCES[locale] },
[DEFAULT_LOCALE]: { translation: RESOURCES[DEFAULT_LOCALE] },
},
initAsync: false,
interpolation: { escapeValue: false },
react: { useSuspense: false },
});
void instance.init();
return instance;
}
/**
* One i18next instance per language, created once and never switched. Requests in different
* languages share no translation state, so one server process can render them concurrently.
*/
const INSTANCES: Record<Locale, i18n> = {
en: createI18n("en"),
"zh-CN": createI18n("zh-CN"),
};
/** The instance for `LocaleProvider`; the `~/i18n` module does not export it. */
export function getI18n(locale: Locale): i18n {
return INSTANCES[locale];
}
/** Interface copy for code that runs outside React, such as route `head` functions. */
export function getTranslator(locale: Locale): TFunction {
return INSTANCES[locale].t;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Locale } from "~/i18n";
import { groupTranslations, inLocale, parseFrontmatter } from "~/markdown-content";
export type LegalDocumentName = "privacy" | "terms";
export interface LegalDocument {
title: string;
description: string;
/** ISO date of the English original's last material change. */
lastUpdated: string;
content: string;
/** The language the document is shown in; English when it has no translation yet. */
locale: Locale;
}
const legalModules = import.meta.glob("../legal/*.md", {
eager: true,
query: "?raw",
import: "default",
}) as Record<string, string>;
const legalTranslations = groupTranslations(legalModules);
export function getLegalDocument(name: LegalDocumentName, locale: Locale): LegalDocument {
const group = legalTranslations.find((candidate) => candidate.path.endsWith(`/${name}.md`));
if (!group) throw new Error(`Missing legal document: ${name}`);
const localized = inLocale(group, locale);
const { data, content } = parseFrontmatter(localized.raw);
return {
title: data.title ?? "",
description: data.description ?? "",
lastUpdated: parseFrontmatter(group.sources.en).data.lastUpdated ?? "",
content,
locale: localized.locale,
};
}
+138 -27
View File
@@ -1,10 +1,23 @@
import { getAlternativePages } from "~/data/alternative-pages";
import { AGENT_PAGES } from "~/data/agent-pages";
import { AGENT_PAGES, getAgentPage } from "~/data/agent-pages";
import { type Doc, getDocs } from "~/docs";
import { getTranslator, type Locale, localizePath, withLocalePrefix } from "~/i18n";
const SITE_URL = "https://paseo.sh";
const PRODUCT_PREAMBLE = `# Paseo
interface LlmsCopy {
preamble: string;
docs: string;
alternatives: string;
agents: string;
optional: string;
/** Pages under "Optional", by language-neutral path. */
pages: { path: string; title: string; description: string }[];
}
const COPY: Record<Locale, LlmsCopy> = {
en: {
preamble: `# Paseo
> Open source agentic development environment for desktop, mobile, web, and CLI.
@@ -17,55 +30,153 @@ Agents run on your own machines with your existing tools, configuration, and cre
Paseo supports Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, Muse Code, and additional ACP-compatible agents. The CLI, TypeScript SDK, and MCP tools expose agent and workspace operations for automation. Plugins extend providers, workflows, and the app's interface.
Distribution: native apps for Mac, Windows, Linux, iOS, and Android; web app; Homebrew; npm. Source: Apache-2.0 at https://github.com/getpaseo/paseo. Marketing site: https://paseo.sh.
`;
`,
docs: "Docs",
alternatives: "Alternatives",
agents: "Supported agents",
optional: "Optional",
pages: [
{
path: "/changelog",
title: "Changelog",
description: "Release notes for the Paseo daemon, CLI, desktop, and mobile apps.",
},
{
path: "/download",
title: "Download",
description: "Install Paseo on Mac, Windows, Linux, iOS, Android, or run the web app.",
},
{
path: "/hub",
title: "Paseo Hub",
description:
"Connect daemons and run GitHub, Slack, Discord, and Linear workflows through the hosted service or your own deployment.",
},
{
path: "/blog",
title: "Blog",
description: "Updates and technical posts from the Paseo team.",
},
{ path: "/privacy", title: "Privacy", description: "Privacy policy." },
{
path: "/terms",
title: "Terms",
description: "Terms for the official relay and hosted Hub.",
},
{
path: "https://github.com/getpaseo/paseo",
title: "GitHub",
description: "Source code, issues, and releases.",
},
],
},
"zh-CN": {
preamble: `# Paseo
function docLine(doc: Doc): string {
const url = `${SITE_URL}${doc.href}.md`;
> 面向桌面、移动端、Web 和 CLI 的开源 Agent 开发环境。
Paseo 是一个完整的 Agent 开发环境。在以 Agent 驱动的开发中,它可以取代你的 IDE:运行编程 Agent、编辑文件、审查 diff 和 PR、在终端中运行测试,并在内置的桌面浏览器中检查你的应用。在一个工作区内,用分屏排列 Agent、文件、终端和浏览器标签页。
并行运行多个 Agent,每个 Agent 都有自己的 git worktree 和分支。为每个任务选择 Provider 和模型,检查结果,再决定合并什么。Agent 还可以创建 worktree、启动其他 Agent,并通过 Paseo 的编排工具与它们通信。
Agent 运行在你自己的机器上,使用你现有的工具、配置和凭据。你可以在桌面端、完整的原生 iOS 和 Android 应用、Web 浏览器或 CLI 中工作。桌面应用会自动启动本地 daemon。进行远程工作时,在另一台机器上运行 daemon,然后直接连接,或通过可选的端到端加密中继连接。
Paseo 支持 Claude Code、Codex、GitHub Copilot、OpenCode、Pi、Antigravity、Muse Code,以及其他兼容 ACP 的 Agent。CLI、TypeScript SDK 和 MCP 工具为自动化开放了 Agent 和工作区操作。插件可以扩展 Provider、工作流和应用界面。
分发方式:Mac、Windows、Linux、iOS 和 Android 原生应用;Web 应用;Homebrew;npm。源代码:Apache-2.0,位于 https://github.com/getpaseo/paseo。官网:https://paseo.sh。
`,
docs: "文档",
alternatives: "替代方案",
agents: "支持的 Agent",
optional: "可选",
pages: [
{
path: "/changelog",
title: "更新日志",
description: "Paseo daemon、CLI、桌面和移动应用的发布说明。",
},
{
path: "/download",
title: "下载",
description: "在 Mac、Windows、Linux、iOS、Android 上安装 Paseo,或运行 Web 应用。",
},
{
path: "/hub",
title: "Paseo Hub",
description:
"连接 daemon,通过托管服务或你自己的部署运行 GitHub、Slack、Discord 和 Linear 工作流。",
},
{ path: "/blog", title: "博客", description: "Paseo 团队的动态和技术文章。" },
{ path: "/privacy", title: "隐私", description: "隐私政策。" },
{ path: "/terms", title: "条款", description: "官方中继和托管 Hub 的条款。" },
{
path: "https://github.com/getpaseo/paseo",
title: "GitHub",
description: "源代码、issue 和发布版本。",
},
],
},
};
function absoluteUrl(path: string, locale: Locale): string {
const localized = localizePath(path, locale);
return localized.startsWith("/") ? `${SITE_URL}${localized}` : localized;
}
function docLine(doc: Doc, locale: Locale): string {
const url = `${SITE_URL}${withLocalePrefix(doc.href, locale)}.md`;
const description = doc.frontmatter.description?.trim();
const suffix = description ? `: ${description}` : "";
return `- [${doc.frontmatter.title}](${url})${suffix}`;
}
function agentLine(agent: (typeof AGENT_PAGES)[number]): string {
return `- [${agent.name}](${SITE_URL}/${agent.slug}): ${agent.subtitle}`;
function agentLine(agent: (typeof AGENT_PAGES)[number], locale: Locale): string {
const page = getAgentPage(agent.slug, getTranslator(locale));
return `- [${page.name}](${absoluteUrl(`/${page.slug}`, locale)}): ${page.subtitle}`;
}
function alternativeLine(page: ReturnType<typeof getAlternativePages>[number]): string {
function alternativeLine(
page: ReturnType<typeof getAlternativePages>[number],
locale: Locale,
): string {
const description = page.description.trim();
const suffix = description ? `: ${description}` : "";
return `- [${page.title}](${SITE_URL}${page.href})${suffix}`;
return `- [${page.title}](${absoluteUrl(page.href, locale)})${suffix}`;
}
function topLevelDocs(): Doc[] {
return getDocs().filter((d) => !d.slug.includes("/"));
function topLevelDocs(locale: Locale): Doc[] {
return getDocs(locale).filter((d) => !d.slug.includes("/"));
}
export function buildLlmsTxt(): string {
const docs = topLevelDocs().map(docLine).join("\n");
const alternatives = getAlternativePages().map(alternativeLine).join("\n");
const agents = AGENT_PAGES.map(agentLine).join("\n");
/** The site index for language models, in a language: `/llms.txt` or `/zh-CN/llms.txt`. */
export function buildLlmsTxt(locale: Locale): string {
const copy = COPY[locale];
const docs = topLevelDocs(locale)
.map((doc) => docLine(doc, locale))
.join("\n");
const alternatives = getAlternativePages(locale)
.map((page) => alternativeLine(page, locale))
.join("\n");
const agents = AGENT_PAGES.map((agent) => agentLine(agent, locale)).join("\n");
const pages = copy.pages
.map((page) => `- [${page.title}](${absoluteUrl(page.path, locale)}): ${page.description}`)
.join("\n");
return `${PRODUCT_PREAMBLE}
## Docs
return `${copy.preamble}
## ${copy.docs}
${docs}
## Alternatives
## ${copy.alternatives}
${alternatives}
## Supported agents
## ${copy.agents}
${agents}
## Optional
## ${copy.optional}
- [Changelog](${SITE_URL}/changelog): Release notes for the Paseo daemon, CLI, desktop, and mobile apps.
- [Download](${SITE_URL}/download): Install Paseo on Mac, Windows, Linux, iOS, Android, or run the web app.
- [Paseo Hub](${SITE_URL}/hub): Connect daemons and run GitHub, Slack, Discord, and Linear workflows through the hosted service or your own deployment.
- [Blog](${SITE_URL}/blog): Updates and technical posts from the Paseo team.
- [Privacy](${SITE_URL}/privacy): Privacy policy.
- [Terms](${SITE_URL}/terms): Terms for the official relay and hosted Hub.
- [GitHub](https://github.com/getpaseo/paseo): Source code, issues, and releases.
${pages}
`;
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { groupTranslations, inLocale } from "~/markdown-content";
describe("groupTranslations", () => {
it("files each translation under its English original", () => {
const [cli, why] = groupTranslations({
"docs/cli.md": "CLI",
"docs/cli.zh-CN.md": "命令行",
"docs/why.md": "Why",
});
expect(cli).toEqual({ path: "docs/cli.md", sources: { en: "CLI", "zh-CN": "命令行" } });
expect(inLocale(cli, "zh-CN")).toEqual({ raw: "命令行", locale: "zh-CN" });
expect(inLocale(why, "zh-CN")).toEqual({ raw: "Why", locale: "en" });
});
it("refuses a translation without an English original", () => {
expect(() => groupTranslations({ "docs/gone.zh-CN.md": "已删除" })).toThrow(
"docs/gone.md has a translation but no English original",
);
});
it("treats a dotted name that is not a site language as English", () => {
const groups = groupTranslations({ "posts/v0.1.md": "v0.1", "posts/notes.fr.md": "Notes" });
expect(groups.map((group) => group.path)).toEqual(["posts/v0.1.md", "posts/notes.fr.md"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n";
/**
* Long-form Markdown is written per language next to its English original: `cli.md` is English,
* `cli.zh-CN.md` is its Chinese counterpart. Both share the English file's path as their identity,
* so a page, its links, and its anchors are the same thing in every language.
*/
export interface MarkdownTranslations {
/** The English file's path, which identifies the content in every language. */
path: string;
sources: Partial<Record<Locale, string>> & { en: string };
}
/** One piece of content in the language it is written in. */
export interface LocalizedMarkdown {
raw: string;
/** The language of `raw`; English when the requested language has no translation yet. */
locale: Locale;
}
const TRANSLATION_SUFFIX = /\.([A-Za-z]{2,3}(?:-[A-Za-z0-9]+)*)\.md$/;
/**
* Groups raw Markdown modules by the English file they translate. A translation without an English
* original is a mistake in the content tree, so it fails loudly instead of becoming a page.
*/
export function groupTranslations(modules: Record<string, string>): MarkdownTranslations[] {
const english = new Map<string, string>();
const translations: { path: string; locale: Locale; raw: string }[] = [];
for (const [path, raw] of Object.entries(modules)) {
const suffix = TRANSLATION_SUFFIX.exec(path);
if (suffix && isLocale(suffix[1]) && suffix[1] !== DEFAULT_LOCALE) {
translations.push({ path: path.replace(TRANSLATION_SUFFIX, ".md"), locale: suffix[1], raw });
} else {
english.set(path, raw);
}
}
const grouped = new Map<string, MarkdownTranslations>(
[...english].map(([path, raw]) => [path, { path, sources: { en: raw } }]),
);
for (const translation of translations) {
const group = grouped.get(translation.path);
if (!group) throw new Error(`${translation.path} has a translation but no English original`);
group.sources[translation.locale] = translation.raw;
}
return [...grouped.values()];
}
/** The content in a language, or the whole English original when it has no translation. */
export function inLocale(group: MarkdownTranslations, locale: Locale): LocalizedMarkdown {
const raw = group.sources[locale];
return raw === undefined ? { raw: group.sources.en, locale: DEFAULT_LOCALE } : { raw, locale };
}
export function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
const data: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
data[key] = value;
}
return { data, content: match[2] };
}
+36 -4
View File
@@ -1,7 +1,27 @@
const SITE_ORIGIN = "https://paseo.sh";
import { LOCALES, type Locale, languageAlternates, localizePath, OPEN_GRAPH_LOCALES } from "~/i18n";
export function pageMeta(title: string, description: string, path: string, image?: string) {
const url = `${SITE_ORIGIN}${path}`;
export const SITE_ORIGIN = "https://paseo.sh";
interface PageMetaInput {
title: string;
description: string;
/** The page's language-neutral path, such as `/docs/cli`. */
path: string;
locale: Locale;
image?: string;
}
/** The absolute URL of a language-neutral path in a language. */
export function siteUrl(path: string, locale: Locale): string {
return `${SITE_ORIGIN}${localizePath(path, locale)}`;
}
/**
* Title, description, social cards, and the canonical and alternate-language links for a page that
* exists in every site language. English is the `x-default` for visitors in any other language.
*/
export function pageMeta({ title, description, path, locale, image }: PageMetaInput) {
const url = siteUrl(path, locale);
return {
meta: [
{ title },
@@ -9,6 +29,11 @@ export function pageMeta(title: string, description: string, path: string, image
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:url", content: url },
{ property: "og:locale", content: OPEN_GRAPH_LOCALES[locale] },
...LOCALES.filter((other) => other !== locale).map((other) => ({
property: "og:locale:alternate",
content: OPEN_GRAPH_LOCALES[other],
})),
...(image
? [
{ property: "og:image", content: image },
@@ -18,6 +43,13 @@ export function pageMeta(title: string, description: string, path: string, image
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
],
links: [{ rel: "canonical", href: url }],
links: [
{ rel: "canonical", href: url },
...languageAlternates(path).map((alternate) => ({
rel: "alternate",
hrefLang: alternate.hreflang,
href: `${SITE_ORIGIN}${alternate.path}`,
})),
],
};
}
@@ -3,7 +3,7 @@ import { buildDocsNavTree, getDoc, getDocs, getLegacyDocsRedirect } from "./docs
describe("plugin documentation", () => {
it("groups current guides by task without version navigation", () => {
const plugins = buildDocsNavTree(getDocs()).find(
const plugins = buildDocsNavTree(getDocs("en")).find(
(node) => node.type === "category" && node.label === "Plugins",
);
@@ -22,7 +22,7 @@ describe("plugin documentation", () => {
it.each(["v0.7", "v0.8"])("redirects %s links to existing current pages", (version) => {
for (const page of ["", "/index", "/reference", "/providers", "/migration"]) {
const target = `/docs/plugins${page === "/index" ? "" : page}`;
expect(getDoc(target.slice("/docs/".length))).toBeDefined();
expect(getDoc(target.slice("/docs/".length), "en")).toBeDefined();
for (const extension of ["", ".md"]) {
const source = `/docs/plugins/${version}${page}${extension}`;
expect(getLegacyDocsRedirect(source)).toBe(`${target}${extension}`);
+5 -3
View File
@@ -1,5 +1,7 @@
import { Link } from "@tanstack/react-router";
import { type ReactNode, useMemo } from "react";
import type { ReactNode } from "react";
import { useLocalizePath } from "~/i18n";
import { pluginOwnerHref } from "./links";
import { type Author, authorAvatarUrl } from "./registry";
const SIZE_CLASS = {
@@ -38,9 +40,9 @@ export function AuthorLink({
className?: string;
children?: ReactNode;
}) {
const params = useMemo(() => ({ owner: author.username }), [author.username]);
const localize = useLocalizePath();
return (
<Link to="/plugins/$owner" params={params} className={className}>
<Link to={localize(pluginOwnerHref(author.username))} className={className}>
{children ?? (
<>
<AuthorAvatar author={author} size="sm" />
+66 -35
View File
@@ -1,5 +1,8 @@
import type { TFunction } from "i18next";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SiteShell } from "~/components/site-shell";
import { useLocale, useLocalizePath } from "~/i18n";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
@@ -48,13 +51,17 @@ export function BrowsePage({
now: string;
query: BrowseQuery;
}) {
const { t } = useTranslation();
const locale = useLocale();
const localize = useLocalizePath();
const category = query.category ? getCategory(query.category) : null;
const ordered = sortPlugins(plugins, installs, query);
// Search ranks by relevance; the tab's order breaks ties.
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
const title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
const matches = query.q ? searchPlugins(ordered, query.q, locale) : ordered;
const results = category ? getPluginsInCategory(matches, category) : matches;
const categoryLabel = category ? t(`plugins.categories.${category}.label`) : undefined;
const title = categoryLabel ?? t("plugins.allPlugins");
const clearHref = localize(browseHref({ ...query, q: undefined }));
return (
<SiteShell width="wide">
<div className="lg:flex lg:gap-12">
@@ -65,14 +72,14 @@ export function BrowsePage({
<div className="min-w-0 flex-1">
<div className="mb-6 flex items-baseline justify-between gap-4">
<h1 className="text-3xl font-medium tracking-tight">
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
{resultsTitle({ term: query.q, categoryLabel, title, t })}
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{results.length}
</span>
</h1>
{query.q && (
<a href={clearHref} className={CLEAR_CLASS}>
Clear
{t("plugins.clear")}
</a>
)}
</div>
@@ -80,20 +87,18 @@ export function BrowsePage({
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<p className="text-sm text-muted-foreground">{t("plugins.noMatches")}</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
{t("plugins.clearFilters")}
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">
{query.sort === "updated"
? "No plugins have been updated since they were listed."
: "No plugins in this category yet."}
{query.sort === "updated" ? t("plugins.noneUpdated") : t("plugins.noneInCategory")}
</p>
)}
{results.length > 0 && (
@@ -112,6 +117,24 @@ export function BrowsePage({
);
}
/** The heading: the category or all plugins, or the search term and where it searched. */
function resultsTitle({
term,
categoryLabel,
title,
t,
}: {
term: string | undefined;
categoryLabel: string | undefined;
title: string;
t: TFunction;
}): string {
if (!term) return title;
return categoryLabel
? t("plugins.resultsInCategory", { term, category: categoryLabel })
: t("plugins.results", { term });
}
function sortPlugins(
plugins: Plugin[],
installs: Record<string, InstallCounts>,
@@ -134,8 +157,9 @@ function CardDetail({
now: string;
query: BrowseQuery;
}) {
if (query.sort === "new") return addedAgo(plugin, now);
if (query.sort === "updated") return updatedAgo(plugin, now);
const { t } = useTranslation();
if (query.sort === "new") return addedAgo(plugin, now, t);
if (query.sort === "updated") return updatedAgo(plugin, now, t);
return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />;
}
@@ -150,26 +174,28 @@ interface NavEntry {
/** Category sidebar on wide screens, a row of chips on narrow ones. Counts follow the search. */
function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const entries: NavEntry[] = [
{
href: browseHref({ ...query, category: undefined }),
label: "All plugins",
chip: "All",
href: localize(browseHref({ ...query, category: undefined })),
label: t("plugins.allPlugins"),
chip: t("plugins.allChip"),
count: plugins.length,
},
...CATEGORIES.map((category) => ({
href: browseHref({ ...query, category: category.slug }),
label: category.label,
chip: category.label,
slug: category.slug,
count: getPluginsInCategory(plugins, category.slug).length,
href: localize(browseHref({ ...query, category })),
label: t(`plugins.categories.${category}.label`),
chip: t(`plugins.categories.${category}.label`),
slug: category,
count: getPluginsInCategory(plugins, category).length,
})),
];
return (
<>
<nav aria-label="Categories" className="-ml-2.5 hidden lg:block">
<nav aria-label={t("plugins.categoriesTitle")} className="-ml-2.5 hidden lg:block">
{entries.map((entry, index) => (
<div key={entry.label}>
<div key={entry.href}>
<a
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
@@ -188,10 +214,13 @@ function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery
<ContributeLinks />
</div>
</nav>
<nav aria-label="Categories" className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden">
<nav
aria-label={t("plugins.categoriesTitle")}
className="-mx-6 flex gap-2 overflow-x-auto px-6 pb-1 lg:hidden"
>
{entries.map((entry) => (
<a
key={entry.label}
key={entry.href}
href={entry.href}
aria-current={query.category === entry.slug ? "page" : undefined}
className={query.category === entry.slug ? CHIP_ON : CHIP_OFF}
@@ -205,13 +234,15 @@ function CategoryNav({ plugins, query }: { plugins: Plugin[]; query: BrowseQuery
}
function SortTabs({ query }: { query: BrowseQuery }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const windowHrefs = useMemo(
() => ({
week: browseHref({ ...query, window: "week" }),
month: browseHref({ ...query, window: "month" }),
all: browseHref({ ...query, window: "all" }),
week: localize(browseHref({ ...query, window: "week" })),
month: localize(browseHref({ ...query, window: "month" })),
all: localize(browseHref({ ...query, window: "all" })),
}),
[query],
[localize, query],
);
const windowSwitch = query.sort === "installs" && (
<WindowSwitch current={query.window} hrefs={windowHrefs} />
@@ -221,25 +252,25 @@ function SortTabs({ query }: { query: BrowseQuery }) {
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
href={browseHref({ ...query, sort: "installs" })}
href={localize(browseHref({ ...query, sort: "installs" }))}
aria-current={query.sort === "installs" ? "page" : undefined}
className={query.sort === "installs" ? TAB_ON : TAB_OFF}
>
Most installed
{t("plugins.mostInstalled")}
</a>
<a
href={browseHref({ ...query, sort: "new" })}
href={localize(browseHref({ ...query, sort: "new" }))}
aria-current={query.sort === "new" ? "page" : undefined}
className={query.sort === "new" ? TAB_ON : TAB_OFF}
>
Newest
{t("plugins.newest")}
</a>
<a
href={browseHref({ ...query, sort: "updated" })}
href={localize(browseHref({ ...query, sort: "updated" }))}
aria-current={query.sort === "updated" ? "page" : undefined}
className={query.sort === "updated" ? TAB_ON : TAB_OFF}
>
Recently updated
{t("plugins.recentlyUpdated")}
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
+11 -49
View File
@@ -1,52 +1,14 @@
/** Category slugs in display order. Labels and descriptions are site copy under `plugins.categories`. */
export const CATEGORIES = [
{
slug: "daemon-management",
label: "Daemon management",
description: "Manage hosts, daemon jobs, resources, approvals, and MCP servers.",
},
{
slug: "themes",
label: "Themes",
description: "Change the app color scheme.",
},
{
slug: "providers",
label: "Providers",
description:
"Connect coding agents, ACP servers, and model routers, with account and quota fallback.",
},
{
slug: "orchestration",
label: "Orchestration",
description: "Coordinate agents, delegate work, and send messages across hosts.",
},
{
slug: "git",
label: "Git",
description: "Work with branches, worktrees, pull requests, stacks, diffs, and reviews.",
},
{
slug: "workspaces",
label: "Workspaces",
description: "Add workspace panels and tabs for boards, browsers, documents, and terminals.",
},
{
slug: "sidebar",
label: "Sidebar",
description: "Add sidebar items and panels for usage, notifications, pricing, and links.",
},
{
slug: "extras",
label: "Extras",
description: "Add presence, pets, puzzles, wellbeing reminders, and demos.",
},
{
slug: "utils",
label: "Utils",
description:
"Extend the composer and timeline with commands, prompts, skills, and small helpers.",
},
"daemon-management",
"themes",
"providers",
"orchestration",
"git",
"workspaces",
"sidebar",
"extras",
"utils",
] as const;
export type CategorySlug = (typeof CATEGORIES)[number]["slug"];
export type Category = (typeof CATEGORIES)[number];
export type CategorySlug = (typeof CATEGORIES)[number];
@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { useLocalizePath } from "~/i18n";
import { DOCS_URL, SUBMIT_URL } from "./links";
const LINK_CLASS =
@@ -9,13 +11,15 @@ const SECONDARY_BUTTON_CLASS =
/** "Read the docs" and "Submit a plugin", as two quiet links. */
export function ContributeLinks() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<>
<a href={DOCS_URL} className={LINK_CLASS}>
Read the docs
<a href={localize(DOCS_URL)} className={LINK_CLASS}>
{t("plugins.readDocs")}
</a>
<a href={SUBMIT_URL} className={LINK_CLASS}>
Submit a plugin
{t("plugins.submit")}
</a>
</>
);
@@ -23,13 +27,15 @@ export function ContributeLinks() {
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
const { t } = useTranslation();
const localize = useLocalizePath();
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
{t("plugins.submit")}
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
<a href={localize(DOCS_URL)} className={SECONDARY_BUTTON_CLASS}>
{t("plugins.readDocs")}
</a>
</>
);
@@ -37,17 +43,17 @@ export function ContributeButtons() {
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
export function ContributeSection() {
const { t } = useTranslation();
return (
<section
aria-labelledby="build-your-own"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8"
>
<h2 id="build-your-own" className="text-lg font-medium">
Build your own plugin
{t("plugins.buildTitle")}
</h2>
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one
with the plugin docs, then submit it to get it listed here.
{t("plugins.buildDescription")}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<ContributeButtons />
+8 -7
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
@@ -9,22 +10,22 @@ interface PluginsHeroProps {
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
const { t } = useTranslation();
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-3xl font-medium tracking-tight">{t("plugins.title")}</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
{t("plugins.counts", {
plugins: t("plugins.pluginCount", { count: pluginCount }),
authors: t("plugins.authorCount", { count: authorCount }),
})}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Browse community plugins that extend Paseo with new functionality.
{t("plugins.lead")}
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
@@ -1,13 +1,15 @@
import { Download } from "lucide-react";
import { useTranslation } from "react-i18next";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<span className="relative inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
<span className="sr-only">{t("plugins.installs")}</span>
</span>
);
}
+3
View File
@@ -30,6 +30,9 @@ export function parseSort(value: unknown): BrowseSort | undefined {
export function pluginHref(id: string): string {
return `/plugins/${id}`;
}
export function pluginOwnerHref(owner: string): string {
return `/plugins/${owner}`;
}
export function categoryHref(category: CategorySlug): string {
return `/plugins/category/${category}`;
}
+24 -11
View File
@@ -1,5 +1,6 @@
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import type { TFunction } from "i18next";
import { ChevronLeft, ChevronRight, Play, X } from "lucide-react";
import {
type CSSProperties,
@@ -11,6 +12,7 @@ import {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
const TILE_CLASS =
"relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%]";
@@ -34,10 +36,11 @@ interface MediaItem {
/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */
export function MediaGallery({ name, media }: { name: string; media: string[] }) {
const { t } = useTranslation();
const dialog = useRef<HTMLDialogElement>(null);
const entries = useMemo(
() => media.map((url, index) => mediaEntry(name, url, index)),
[name, media],
() => media.map((url, index) => mediaEntry({ name, url, index, t })),
[name, media, t],
);
const items = useMemo(() => entries.filter((entry) => typeof entry !== "string"), [entries]);
const [viewing, setViewing] = useState<number | null>(null);
@@ -104,7 +107,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
<>
<button
type="button"
aria-label="Previous"
aria-label={t("plugins.previous")}
onClick={showPrevious}
className={`${VIEWER_BUTTON_CLASS} left-4 top-1/2 -translate-y-1/2`}
>
@@ -112,7 +115,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
</button>
<button
type="button"
aria-label="Next"
aria-label={t("plugins.next")}
onClick={showNext}
className={`${VIEWER_BUTTON_CLASS} right-4 top-1/2 -translate-y-1/2`}
>
@@ -122,7 +125,7 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
)}
<button
type="button"
aria-label="Close"
aria-label={t("common.close")}
onClick={close}
className={`${VIEWER_BUTTON_CLASS} right-4 top-4`}
>
@@ -134,15 +137,25 @@ export function MediaGallery({ name, media }: { name: string; media: string[] })
}
/** A registry media entry as a gallery item, or the raw entry when it is not a safe URL. */
function mediaEntry(name: string, url: string, index: number): MediaItem | string {
function mediaEntry({
name,
url,
index,
t,
}: {
name: string;
url: string;
index: number;
t: TFunction;
}): MediaItem | string {
const source = pluginOverviewUrl(url);
if (!source) return url;
const kind = pluginMediaKind(source);
return {
url: source,
kind,
label: `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`,
};
const label = t(kind === "video" ? "plugins.video" : "plugins.screenshot", {
name,
index: index + 1,
});
return { url: source, kind, label };
}
/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */
+19 -8
View File
@@ -1,20 +1,31 @@
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Breadcrumbs } from "~/components/breadcrumbs";
import { SiteShell } from "~/components/site-shell";
import { useLocalizePath } from "~/i18n";
const NOT_FOUND_CRUMBS = [{ label: "Plugins", href: "/plugins" }, { label: "Not found" }];
/** What was not found; each has a title and an explanation under `plugins.notFound`. */
export type PluginsNotFoundKind = "author" | "plugin" | "category";
export function PluginsNotFound({ title, children }: { title: string; children: string }) {
export function PluginsNotFound({ kind }: { kind: PluginsNotFoundKind }) {
const { t } = useTranslation();
const localize = useLocalizePath();
const crumbs = useMemo(
() => [{ label: t("plugins.title"), href: "/plugins" }, { label: t("plugins.notFound.crumb") }],
[t],
);
return (
<SiteShell width="default">
<Breadcrumbs items={NOT_FOUND_CRUMBS} />
<h1 className="text-3xl font-medium tracking-tight mb-4">{title}</h1>
<Breadcrumbs items={crumbs} />
<h1 className="text-3xl font-medium tracking-tight mb-4">
{t(`plugins.notFound.${kind}Title`)}
</h1>
<p className="text-lg text-white/70 leading-relaxed max-w-2xl">
{children}{" "}
<Link to="/plugins" className="underline hover:text-white">
Browse all plugins
{t(`plugins.notFound.${kind}Body`)}{" "}
<Link to={localize("/plugins")} className="underline hover:text-white">
{t("plugins.notFound.browseAll")}
</Link>
.
</p>
</SiteShell>
);

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