diff --git a/docs/plugins.md b/docs/plugins.md index f74b4b0022..d65b7b2a84 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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::` 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 diff --git a/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts new file mode 100644 index 0000000000..1519267b44 --- /dev/null +++ b/packages/app/e2e/browser/plugin-file-panel-examples.spec.ts @@ -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, +) { + 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 { + 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(); + } +}); diff --git a/plugin-examples/markdown-panel/.gitignore b/plugin-examples/markdown-panel/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/plugin-examples/markdown-panel/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/plugin-examples/markdown-panel/README.md b/plugin-examples/markdown-panel/README.md new file mode 100644 index 0000000000..d480720bc8 --- /dev/null +++ b/plugin-examples/markdown-panel/README.md @@ -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). diff --git a/plugin-examples/markdown-panel/client/rich-markdown.tsx b/plugin-examples/markdown-panel/client/rich-markdown.tsx new file mode 100644 index 0000000000..7fc91b9763 --- /dev/null +++ b/plugin-examples/markdown-panel/client/rich-markdown.tsx @@ -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(null); + const editor = useRef>(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 ; +} + +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 ; + return ( + + + {document.text} + + + ); +} diff --git a/plugin-examples/markdown-panel/client/web.ts b/plugin-examples/markdown-panel/client/web.ts new file mode 100644 index 0000000000..79ce8fb42c --- /dev/null +++ b/plugin-examples/markdown-panel/client/web.ts @@ -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(); + }, + }; +} diff --git a/plugin-examples/markdown-panel/index.client.tsx b/plugin-examples/markdown-panel/index.client.tsx new file mode 100644 index 0000000000..c6cf4ffec9 --- /dev/null +++ b/plugin-examples/markdown-panel/index.client.tsx @@ -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 () => {}; +} diff --git a/plugin-examples/markdown-panel/index.server.ts b/plugin-examples/markdown-panel/index.server.ts new file mode 100644 index 0000000000..34a7dd0074 --- /dev/null +++ b/plugin-examples/markdown-panel/index.server.ts @@ -0,0 +1,3 @@ +export default function contribute() { + return () => {}; +} diff --git a/plugin-examples/markdown-panel/package-lock.json b/plugin-examples/markdown-panel/package-lock.json new file mode 100644 index 0000000000..3d35021262 --- /dev/null +++ b/plugin-examples/markdown-panel/package-lock.json @@ -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" + } + } +} diff --git a/plugin-examples/markdown-panel/package.json b/plugin-examples/markdown-panel/package.json new file mode 100644 index 0000000000..317acef302 --- /dev/null +++ b/plugin-examples/markdown-panel/package.json @@ -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" + } +} diff --git a/plugin-examples/markdown-panel/paseo-plugin.json b/plugin-examples/markdown-panel/paseo-plugin.json new file mode 100644 index 0000000000..36dff65bfc --- /dev/null +++ b/plugin-examples/markdown-panel/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "markdown-panel", + "requirements": { + "paseo": ">=0.13.0" + } +} diff --git a/plugin-examples/pdf-panel/README.md b/plugin-examples/pdf-panel/README.md new file mode 100644 index 0000000000..c7f34b4fb7 --- /dev/null +++ b/plugin-examples/pdf-panel/README.md @@ -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). diff --git a/plugin-examples/pdf-panel/client/pdf-panel.tsx b/plugin-examples/pdf-panel/client/pdf-panel.tsx new file mode 100644 index 0000000000..7bff4312b4 --- /dev/null +++ b/plugin-examples/pdf-panel/client/pdf-panel.tsx @@ -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 ( + + + {file.name} ({Math.round(file.size / 1024)} KB). Android cannot render PDF inline. + + + ); + } + return ; +} diff --git a/plugin-examples/pdf-panel/index.client.tsx b/plugin-examples/pdf-panel/index.client.tsx new file mode 100644 index 0000000000..759bff9d16 --- /dev/null +++ b/plugin-examples/pdf-panel/index.client.tsx @@ -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 () => {}; +} diff --git a/plugin-examples/pdf-panel/index.server.ts b/plugin-examples/pdf-panel/index.server.ts new file mode 100644 index 0000000000..34a7dd0074 --- /dev/null +++ b/plugin-examples/pdf-panel/index.server.ts @@ -0,0 +1,3 @@ +export default function contribute() { + return () => {}; +} diff --git a/plugin-examples/pdf-panel/paseo-plugin.json b/plugin-examples/pdf-panel/paseo-plugin.json new file mode 100644 index 0000000000..ffc93155d7 --- /dev/null +++ b/plugin-examples/pdf-panel/paseo-plugin.json @@ -0,0 +1,6 @@ +{ + "id": "pdf-panel", + "requirements": { + "paseo": ">=0.13.0" + } +} diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index b21a637ffa..ce6b549bf9 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -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), diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index 7b24b37099..eeeb0708fc 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -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` | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 7a05329116..abef4e8b39 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 ; +} +``` + +| 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. diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 331fe66865..a3f9bf44bd 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -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 |