docs(plugins): file panel examples and author reference

Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin
reference, and an e2e spec that installs both examples, opens a PDF in the browser
viewer, and edits Markdown through Paseo's document across the switcher, Source,
autosave and conflicts.
This commit is contained in:
Mohamed Boudra
2026-10-19 13:40:04 +02:00
parent 9d2533183b
commit 970b41cb24
20 changed files with 930 additions and 1 deletions
+11
View File
@@ -556,6 +556,17 @@ one section's order from built-ins, plugin groups, and the section's preference
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
with new-API items.
## Contribute file panels
Client `addFilePanel` registrations become the body of a file tab, rendering host-owned text or
bytes. The tab's bar owns the document's status (size, lines, save state, conflicts) and switches
between the file's panels; Source, Paseo's editor, is a built-in panel for every text file and
always the last choice. Keep editing in the host document so autosave and conflicts have one
owner. See the
[public file panel reference](../public-docs/plugins/reference.md#file-panels) for selectors,
selection, document props, `DocumentFrame`, and the version requirement. The examples live in
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
@@ -0,0 +1,272 @@
import { execFileSync } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { Frame, TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { copyPluginExample } from "../support/helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { gotoWorkspace } from "../support/helpers/launcher";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openFileExplorer,
openFileFromExplorer,
expectFileTabOpen,
} from "../support/helpers/file-explorer";
// Full Chromium includes the PDF viewer; the default headless shell does not.
test.use({ channel: "chromium" });
function samplePdf() {
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
const offsets = objects.map((object, index) => {
const offset = Buffer.byteLength(pdf);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
return offset;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return pdf;
}
async function openFile(page: Page, name: string) {
await openFileFromExplorer(page, name);
await expectFileTabOpen(page, name);
}
const WIDE = { width: 1280, height: 900 };
const COMPACT = { width: 390, height: 844 };
async function capture(page: Page, testInfo: TestInfo, name: string) {
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
async function captureWideAndCompact(
page: Page,
testInfo: TestInfo,
name: string,
ready: () => Promise<void>,
) {
await capture(page, testInfo, `${name}-wide`);
await page.setViewportSize(COMPACT);
await ready();
await capture(page, testInfo, `${name}-compact`);
await page.setViewportSize(WIDE);
await ready();
}
function filePanelSwitcher(page: Page) {
return page.getByTestId("file-panel-switcher").filter({ visible: true });
}
/** Turns on vim keybindings without discarding settings the test writes later. */
async function enableVimKeybindings(page: Page) {
await page.addInitScript(() => {
const key = "@paseo:app-settings";
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
});
}
async function chooseRichMarkdown(page: Page) {
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
await page.getByRole("button", { name: "Use", exact: true }).click();
}
async function replaceMarkdown(page: Page, text: string) {
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
}
async function selectPanel(page: Page, title: string) {
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
}
async function expectPanels(page: Page, titles: string[]) {
for (const title of titles) {
await expect(
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
).toBeInViewport({ ratio: 1 });
}
}
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
async function pdfViewer(page: Page): Promise<Frame> {
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
const find = () =>
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
await expect.poll(() => find() !== null).toBe(true);
return find()!;
}
async function expectPdfViewerReady(page: Page) {
// A narrow viewer collapses its toolbar to Print and More actions.
const viewer = await pdfViewer(page);
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
}
async function expectMarkdownEditor(page: Page, text: string) {
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
await expect(editor).toBeVisible();
await expect(editor).toContainText(text);
await expect(editor).toBeInViewport({ ratio: 1 });
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
}
test("directory examples mount the PDF frame and edit Markdown through Paseo's document", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const pdf = await copyPluginExample("pdf-panel");
const markdown = await copyPluginExample("markdown-panel");
execFileSync(
process.platform === "win32" ? "npm.cmd" : "npm",
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: markdown.directory, stdio: "pipe" },
);
const workspace = await seedWorkspace({
repoPrefix: "file-panel-examples-",
repo: {
files: [
{
path: "notes.md",
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
},
{ path: "second.md", content: "# Remembered panel\n" },
{ path: "report.pdf", content: samplePdf() },
],
},
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pdf.directory);
await client.installDirectoryPlugin(markdown.directory);
await enableVimKeybindings(page);
await page.setViewportSize(WIDE);
const gate = await installDaemonWebSocketGate(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await openFile(page, "report.pdf");
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
"src",
/^blob:/,
);
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
"sandbox",
);
const documentUrl = await page
.getByTitle("Document preview", { exact: true })
.getAttribute("src");
expect(
await page.evaluate(
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
documentUrl,
),
).toBe("application/pdf");
await expectPdfViewerReady(page);
await expect(
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
).toHaveValue("1");
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
await expectPdfViewerReady(page);
});
await openFile(page, "notes.md");
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
await expect(
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
).toBeVisible();
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
ratio: 1,
});
});
await chooseRichMarkdown(page);
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Markdown panel example",
);
gate.holdNextClientRequest("fs.file.write.request");
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
await gate.waitForHeldClientRequest();
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
await capture(page, testInfo, "markdown-example-dirty-wide");
gate.releaseHeldClientRequest();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Edited by Rich Markdown\n\nAutosaved by Paseo.\n");
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
await expectMarkdownEditor(page, "Edited by Rich Markdown");
});
// Source is Paseo's editor; its cursor and vim mode join the bar's status.
await selectPanel(page, "Source");
const source = page.getByTestId("file-source-editor").filter({ visible: true });
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
await source.locator(".cm-line").nth(2).click();
const bar = page.getByTestId("file-panel-bar").filter({ visible: true });
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
await capture(page, testInfo, "markdown-example-source-wide");
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
await page.setViewportSize(COMPACT);
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
await filePanelSwitcher(page).click();
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
await expect(filePanelSwitcher(page)).toHaveText("Source");
await source.locator(".cm-line").nth(2).click();
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
await capture(page, testInfo, "markdown-example-source-compact");
await page.setViewportSize(WIDE);
await selectPanel(page, "Rich Markdown");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
"# External update",
);
await replaceMarkdown(page, "# Local edit\n");
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
await capture(page, testInfo, "markdown-example-conflict-wide");
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
.toBe("# Local edit\n");
await openFile(page, "second.md");
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
"Remembered panel",
);
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
await page.setViewportSize(COMPACT);
await expectMarkdownEditor(page, "Remembered panel");
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
} finally {
await client.removePlugin("pdf-panel");
await client.removePlugin("markdown-panel");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await workspace.cleanup();
await pdf.cleanup();
await markdown.cleanup();
}
});
@@ -0,0 +1 @@
node_modules/
+22
View File
@@ -0,0 +1,22 @@
# Markdown file panel example
Requires Paseo 0.13.0 or later. Install the editor dependencies, then install this directory:
```sh
cd plugin-examples/markdown-panel
npm install
paseo plugin install "$PWD"
```
Open a `.md` or `.markdown` file in Files. Choose **Rich Markdown — markdown-panel** in the
picker. Check **Don't ask me again** to remember the choice on this device.
The panel is a CodeMirror editor with Markdown highlighting, line numbers, undo, redo, and line
wrapping. Edits call the host document's `setText`; Paseo owns autosave, the dirty dot, and
external-change conflicts. The bar's switcher still offers Paseo's Markdown and Source panels. Files above 1 MiB or hosts
without editing support render read-only. Native shows selectable, read-only text.
Paseo bundles the editor dependencies into this plugin. They are loaded only on web; the native
path uses React Native primitives. DOM integration lives in `client/web.ts`.
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
@@ -0,0 +1,53 @@
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
import { useLayoutEffect, useMemo, useRef } from "react";
import { ScrollView, Text, View } from "react-native";
import { mountEditor } from "./web";
interface TextPanelProps {
document: PluginTextDocument;
theme: PluginFilePanelProps["theme"];
}
const editorStyle = { flex: 1, minHeight: 0 };
function MarkdownEditor({ document, theme }: TextPanelProps) {
const parent = useRef<View>(null);
const editor = useRef<ReturnType<typeof mountEditor>>(null);
const currentDocument = useRef(document);
currentDocument.current = document;
useLayoutEffect(() => {
editor.current = mountEditor(parent.current, currentDocument.current, theme, (text) => {
currentDocument.current.setText(text);
});
return () => {
editor.current?.destroy();
editor.current = null;
};
}, [theme, document.editable]);
useLayoutEffect(() => {
editor.current?.setText(document.text);
}, [document.text]);
return <View ref={parent} style={editorStyle} />;
}
export function RichMarkdownPanel({ document, theme, layout }: PluginFilePanelProps) {
const styles = useMemo(
() => ({
screen: { flex: 1, backgroundColor: theme.colors.surface0 },
text: { color: theme.colors.foreground, padding: 16 },
}),
[theme],
);
if (document.kind !== "text") return null;
if (layout.platform === "web") return <MarkdownEditor document={document} theme={theme} />;
return (
<ScrollView style={styles.screen}>
<Text selectable style={styles.text}>
{document.text}
</Text>
</ScrollView>
);
}
@@ -0,0 +1,71 @@
import type { PluginFilePanelProps, PluginTextDocument } from "@getpaseo/plugin/client";
import type { EditorView, EditorViewConfig } from "@codemirror/view";
import { Platform } from "react-native";
/** DOM code stays here; native never evaluates the editor dependencies. */
export function mountEditor(
parent: unknown,
document: PluginTextDocument,
theme: PluginFilePanelProps["theme"],
onChange: (text: string) => void,
) {
if (Platform.OS !== "web" || !parent) return null;
const { EditorState } = require("@codemirror/state") as typeof import("@codemirror/state");
const { EditorView, keymap, lineNumbers } =
require("@codemirror/view") as typeof import("@codemirror/view");
const { defaultKeymap, history, historyKeymap } =
require("@codemirror/commands") as typeof import("@codemirror/commands");
const { markdown } =
require("@codemirror/lang-markdown") as typeof import("@codemirror/lang-markdown");
const { syntaxHighlighting, defaultHighlightStyle } =
require("@codemirror/language") as typeof import("@codemirror/language");
let applyingHostText = false;
const view: EditorView = new EditorView({
parent: parent as EditorViewConfig["parent"],
state: EditorState.create({
doc: document.text,
extensions: [
markdown(),
syntaxHighlighting(defaultHighlightStyle),
lineNumbers(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
EditorView.lineWrapping,
EditorState.readOnly.of(!document.editable),
EditorView.editable.of(document.editable),
EditorView.contentAttributes.of({ "aria-label": "Rich Markdown editor" }),
EditorView.theme({
"&": {
height: "100%",
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
},
".cm-scroller": { overflow: "auto" },
".cm-content": { padding: "16px", caretColor: theme.colors.foreground },
".cm-gutters": {
backgroundColor: theme.colors.surface0,
color: theme.colors.foregroundMuted,
border: "none",
},
}),
EditorView.updateListener.of((update) => {
if (update.docChanged && !applyingHostText) onChange(update.state.doc.toString());
}),
],
}),
});
return {
setText(text: string) {
if (view.state.doc.toString() === text) return;
applyingHostText = true;
try {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
} finally {
applyingHostText = false;
}
},
destroy() {
view.destroy();
},
};
}
@@ -0,0 +1,14 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { RichMarkdownPanel } from "./client/rich-markdown";
export default function contribute(client: PluginClientContext) {
client.addFilePanel({
id: "rich-markdown",
title: "Rich Markdown",
icon: "FileType",
files: { extensions: ["md", "markdown"] },
document: "text",
Component: RichMarkdownPanel,
});
return () => {};
}
@@ -0,0 +1,3 @@
export default function contribute() {
return () => {};
}
+247
View File
@@ -0,0 +1,247 @@
{
"name": "@getpaseo/plugin-example-markdown-panel",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@getpaseo/plugin-example-markdown-panel",
"version": "0.1.0",
"dependencies": {
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.12.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.43.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
"integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}
@@ -0,0 +1,13 @@
{
"name": "@getpaseo/plugin-example-markdown-panel",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.12.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.43.0"
}
}
@@ -0,0 +1,6 @@
{
"id": "markdown-panel",
"requirements": {
"paseo": ">=0.13.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
# PDF file panel example
Requires Paseo 0.13.0 or later. Install this directory with
`paseo plugin install /absolute/path/to/plugin-examples/pdf-panel`, then open a `.pdf` in Files.
The panel passes Paseo's local document URL to `DocumentFrame` on web and iOS.
Files up to 50 MiB match. Android shows the filename, size, and a message that inline PDF rendering is unavailable. The plugin does not read files or construct daemon URLs.
See the [file panel reference](https://paseo.sh/docs/plugins/reference#file-panels).
Inline PDF rendering needs a daemon from Paseo 0.13.0 or later; see the
[DocumentFrame reference](https://paseo.sh/docs/plugins/reference#documentframe).
@@ -0,0 +1,26 @@
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
import { useMemo } from "react";
import { Text, View } from "react-native";
const fallbackStyle = {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 24,
} as const;
export function PdfPanel({ document, file, theme, layout }: PluginFilePanelProps) {
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
if (document.kind !== "bytes") return null;
if (layout.platform === "android") {
return (
<View style={fallbackStyle}>
<Text style={textStyle}>
{file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline.
</Text>
</View>
);
}
return <DocumentFrame url={document.url} testID="pdf-panel" />;
}
@@ -0,0 +1,15 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { PdfPanel } from "./client/pdf-panel";
export default function contribute(client: PluginClientContext) {
client.addFilePanel({
id: "pdf",
title: "PDF",
icon: "FileText",
files: { extensions: ["pdf"] },
document: "bytes",
maxBytes: 50 * 1024 * 1024,
Component: PdfPanel,
});
return () => {};
}
@@ -0,0 +1,3 @@
export default function contribute() {
return () => {};
}
@@ -0,0 +1,6 @@
{
"id": "pdf-panel",
"requirements": {
"paseo": ">=0.13.0"
}
}
+1
View File
@@ -11,6 +11,7 @@ category: Plugins
A plugin is a TypeScript project installed into one Paseo daemon. It can add
[screens and sidebar items](/docs/plugins/reference#screens-and-sidebar-items),
[workspace panels](/docs/plugins/reference#workspace-panels),
[file panels](/docs/plugins/reference#file-panels),
[Command Center items](/docs/plugins/reference#command-center-items),
[slash commands](/docs/plugins/reference#slash-commands),
[composer pills](/docs/plugins/reference#composer-pills),
+1
View File
@@ -73,6 +73,7 @@ Use this table as the complete registration checklist.
| `plugin.addSurface(id, Component)` in the old root entry | `client.addScreen({ id, title, Component })` in `index.client.tsx` |
| `plugin.addSidebarItem({ id, title, icon, surface })` in the old root entry | `client.addSidebarHeaderItem({ id, title, Component })` in `index.client.tsx`; see [sidebar items](reference#sidebar-items) |
| New sidebar footer contribution | `client.addSidebarFooterItem({ id, title, Component })` in `index.client.tsx` |
| New file panel contribution | `client.addFilePanel(panel)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
+151
View File
@@ -16,6 +16,7 @@ Local plugins are directory sources installed into one Paseo daemon. A plugin ca
- React Native screens and sidebar header and footer items to Paseo clients;
- workspace and agent panels opened as workspace tabs;
- [file panels](#file-panels) for viewing or editing matching files;
- global, workspace, and agent actions in the Command Center;
- slash commands in the message composer;
- transformed and daemon-pushed agent timeline rows;
@@ -1755,6 +1756,156 @@ Agent snapshot fields:
Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.
## File panels
Register a component that renders a file as the body of its file tab. Paseo loads the document
from the selected host, including remote hosts. Your plugin receives its contents and edits
through the host document. The tab's bar keeps the file's size, line count, save state, and
conflict alerts, and switches between every panel that can open the file.
```tsx
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { RichMarkdownPanel } from "./client/rich-markdown";
export default function contribute(client: PluginClientContext) {
return client.addFilePanel({
id: "rich-markdown",
title: "Rich Markdown",
icon: "FileType",
files: { extensions: ["md", "markdown"] },
document: "text",
Component: RichMarkdownPanel,
});
}
```
`addFilePanel` takes a `PluginFilePanelContribution` and returns an idempotent cleanup function.
Paseo also removes the registration when the client entry is unloaded.
| Field | Required | Meaning |
| ----------- | -------- | -------------------------------------------------------------------------------------- |
| `id` | Yes | Plugin-local ID matching `^[a-z][a-z0-9-]*$`. |
| `title` | Yes | Non-empty label in the panel switcher, the picker, and the Open with menu. |
| `icon` | Yes | Lucide name, `{ svg }`, or component. See [Icons](#icons). |
| `files` | Yes | `{ extensions: readonly string[]; filenames?: readonly string[] }`. |
| `document` | Yes | `"text"` or `"bytes"`; selects the document passed to the component. |
| `maxBytes` | No | Positive finite size limit in bytes. Defaults to 10 MiB. Larger files skip this panel. |
| `Component` | Yes | Component receiving `PluginFilePanelProps`. |
Extensions have no leading dot and match without case sensitivity. Filenames match the exact
basename, including case. Selectors contain no paths, globs, or wildcards. Use `extensions: []`
for a filename-only selector. At least one extension or filename is required.
### File panel props
`PluginFilePanelProps` includes the common `theme`, `host`, `layout`, and optional `navigation`
props described under [Theme and layout](#theme-and-layout), plus:
| Prop | Type | Meaning |
| -------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `workspaceId` | `string` | Workspace containing the file tab. |
| `file.path` | `string` | Absolute path on the selected host. |
| `file.name` | `string` | Basename. |
| `file.extension` | `string` | Lowercase extension without the dot, or `""`. |
| `file.mimeType` | `string` | Host-reported MIME type; binary files can report `application/octet-stream`. |
| `file.size` | `number` | Size in bytes. |
| `file.modifiedAt` | `string` | Modification timestamp. |
| `file.lineStart`, `file.lineEnd` | `number` (optional) | Requested line range. |
| `navigationRevision` | `number` | Increments when the same tab receives another line navigation. |
| `document` | `PluginTextDocument` or `PluginBytesDocument` | Matches the registration's `document` field. |
A `PluginTextDocument` keeps the text owned by Paseo:
| Field | Type | Meaning |
| ---------- | ------------------------ | -------------------------------------------------------------------------------------- |
| `kind` | `"text"` | Document discriminator. |
| `text` | `string` | Current editor text, including external updates. |
| `editable` | `boolean` | False on native, daemons without file editing, or files above 1 MiB. Render read-only. |
| `setText` | `(next: string) => void` | Replace editor text. Paseo owns autosave, dirty state, and conflict handling. |
Call `document.setText(next)` from your editor's change callback when `editable` is true.
Update your editor when `document.text` changes; do not send that update back as a user edit.
Do not write the file through RPCs or maintain a second save workflow. The **Source** panel in
the switcher opens Paseo's text editor on the same document.
A `PluginBytesDocument` is read-only:
| Field | Type | Meaning |
| ------- | ------------ | ------------------------------------------------------------------------------------- |
| `kind` | `"bytes"` | Document discriminator. |
| `bytes` | `Uint8Array` | Loaded file bytes. |
| `url` | `string` | Host-owned local preview URL: `blob:` on web, `file:` on native. Valid while mounted. |
Use `bytes` for your own renderer or pass `url` to `DocumentFrame`. Do not reconstruct a daemon
URL from `file.path`; local document URLs also work when the daemon is remote.
### Selection and Open with
Text panels require a text file. Bytes panels accept any file with loaded bytes. Paseo filters
matching panels by document kind and size before selecting one. Candidates are ordered: the
built-in Markdown, HTML, and Image panels, then plugin panels in host catalog order, then the
built-in **Source** panel, which every text file has. Source opens a file by default only when
nothing else claims it.
A remembered association selects its panel when that panel is available on the current host.
Otherwise Paseo opens the first candidate. When a plugin panel is among several candidates and no
association applies, Paseo asks once per tab open, over the panel it already opened. Built-in
panels alone never ask: a Markdown file opens with Markdown and offers Source in the switcher. With
a Markdown plugin installed, the picker offers **Markdown — Paseo**, **Rich Markdown —
markdown-panel**, and **Source — Paseo**. A plugin that claims a text extension competes with
Source, so its first open asks; a bytes plugin that alone claims a binary file opens without
asking.
**Don't ask me again** remembers the choice on this device for the extension. When a candidate
matches an exact filename, the choice applies to that basename instead. An unavailable association
is ignored on that host and retained for hosts where its panel is installed.
Users switch the current tab with the bar's panel switcher, which shows up to three panels side by
side (two on a phone) and a menu beyond that, or with **Open with…** in the tab menu. That choice wins over the
remembered association for this tab until it is retargeted. **Always use**, in the bar's overflow
menu and in Open with, remembers the shown panel. Opening a file at a line, from a link or a
search result, switches the tab to Source. Files with no candidate show **Binary preview
unavailable**; binary files have no Source panel.
### DocumentFrame
Import `DocumentFrame` from `@getpaseo/plugin/client/ui` to display a local document URL:
```tsx
import type { PluginFilePanelProps } from "@getpaseo/plugin/client";
import { DocumentFrame } from "@getpaseo/plugin/client/ui";
export function PdfPanel({ document }: PluginFilePanelProps) {
if (document.kind !== "bytes") return null;
return <DocumentFrame url={document.url} testID="pdf-panel" />;
}
```
| Prop | Required | Meaning |
| -------- | -------- | ----------------------------------------------------------------------------------- |
| `url` | Yes | Host document URL; `blob:` on web or `file:` on native. Other schemes are rejected. |
| `testID` | No | Test identifier for the frame. |
Web inspects the local document URL’s media type before mounting the iframe. PDF uses the
browser’s PDF viewer without a sandbox; every other type, including HTML and unknown types,
keeps a full sandbox with no scripts or same-origin access. Native uses a WebView with
JavaScript, storage, and further navigation disabled. iOS supports inline PDF rendering; Android's
WebView does not. Provide an Android fallback or bundle your own renderer.
Inline PDF rendering needs a daemon from Paseo 0.13.0 or later; older daemons report PDFs as
`application/octet-stream`.
### Version requirement and examples
File panels and `DocumentFrame` require Paseo 0.13.0 or later. Declare
`"requirements": { "paseo": ">=0.13.0" }` in `paseo-plugin.json`. Both daemon and app check the
[version requirement](#requirements); older apps skip the client bundle before evaluation.
Complete directory examples:
- [PDF panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel): bytes with `DocumentFrame` and an Android fallback.
- [Markdown panel](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel): a bundled CodeMirror editor that calls the host document's `setText`, with read-only native text.
## Command Center items
Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linux, then search for the item title.
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: paseo-plugin
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a screen, sidebar header or footer item, or workspace panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
description: Build and manage trusted local Paseo plugins. Use when the user asks to create, edit, install, reload, enable, disable, remove, or troubleshoot a Paseo plugin; add lifecycle hooks; transform agent configuration, environment, MCP servers, or workspace creation; automate permissions or turn follow-ups; add a screen, sidebar header or footer item, workspace panel, or file panel; add Command Center items or slash commands; add composer pills or attachment sources; transform, render, or append agent timeline items; contribute a theme; use Paseo from plugin code; or add plugin RPCs.
---
# Paseo plugins
@@ -31,6 +31,7 @@ Pick the contribution that matches the request. Each row names the registration,
| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Screen and sidebar item | `addScreen` + `addSidebarHeaderItem`/`addSidebarFooterItem` | A full screen of plugin UI, or sidebar header or footer rows that open a screen or popover | reference.md → Screens and sidebar items |
| Workspace panel | `addWorkspacePanel` | UI that lives as a tab beside agents, terminals, files, and diffs; `locations: ["explorer"]` for the Explorer | reference.md → Workspace panels |
| File panel | `addFilePanel` | View or edit a matching file inside the file tab, using the host document | reference.md → File panels; `plugin-examples/pdf-panel`, `plugin-examples/markdown-panel` |
| Command Center item | `addCommandCenterItem` | A global, workspace, or agent action reachable from ⌘K | reference.md → Command Center items |
| Client slash command | `addSlashCommand` | A `/command args` in the composer that runs plugin code instead of prompting the agent | reference.md → Slash commands |
| Composer pill | `addComposerPill` | A per-agent button in the composer track bar next to Tasks and Subagents | reference.md → Composer pills |