Quick start
A minimal plugin has three files. This example adds a viewer that renders
.csv files as a simple HTML table.
{
"id": "my-csv-viewer",
"name": "CSV Viewer",
"version": "1.0.0",
"main": "index.js"
}
// React is provided by Quipu — do not bundle it.
const { createElement: h, useState } = React;
function CsvViewer({ activeFile }) {
const [rows, setRows] = useState([]);
React.useEffect(() => {
if (!activeFile) return;
fetch(`/api/file?path=${activeFile.path}`)
.then(r => r.text())
.then(text => setRows(text.trim().split('\n').map(r => r.split(','))));
}, [activeFile]);
return h('table', { style: { padding: '16px' } },
rows.map((cols, i) => h('tr', { key: i },
cols.map((c, j) => h('td', { key: j }, c))
))
);
}
// Every plugin must export an `init` function.
export function init(api) {
api.register({
id: 'csv-viewer',
canHandle: (tab) => tab.name?.endsWith('.csv'),
priority: 50,
component: CsvViewer,
});
}
Drop the directory into ~/.quipu/plugins/ and relaunch Quipu.
Quipu loads every plugin at startup and calls init(api) with the full plugin API.
Manifest
Every plugin directory must contain a manifest.json at its root.
Quipu reads this file before loading the plugin to validate structure and resolve the entry point.
{
"id": "my-plugin", // unique, kebab-case
"name": "My Plugin", // display name
"version": "1.0.0", // semver
"main": "index.js", // entry point (relative to plugin dir)
"description": "Optional blurb", // shown in UI (future)
"author": "you" // optional
}
| Field | Required | Description |
|---|---|---|
| id | required | Globally unique plugin identifier. Use kebab-case. Quipu uses this as the namespace for all registrations from this plugin. |
| name | required | Human-readable display name shown in error messages and (future) plugin manager UI. |
| version | required | Semver version string. Used for display and conflict reporting. |
| main | required | Path to the plugin's JS entry point, relative to the plugin directory. Must be an ES module or IIFE that exports an init function. |
| description | optional | Short description of what the plugin does. |
| author | optional | Author name or email. |
id, name, version, or
main fields. Failed plugins show a warning toast at startup and are skipped.
Plugin API (init)
Every plugin must export a named function init(api).
Quipu calls it once at startup, passing a scoped PluginApi object
that exposes all registration methods and services.
/**
* @param {import('quipu').PluginApi} api
*/
export function init(api) {
// api.register(descriptor) — register a file viewer
// api.registerPanel(descriptor) — add a sidebar panel
// api.registerCommand(id, fn, opts)— add a command palette entry
// api.executeCommand(id, ...args) — trigger any registered command
// api.registerKeybinding(entry) — bind a key combo to a command
// api.services.* — file system, git, terminal, kernel
// api.version — current Quipu version string
}
quipu package (coming soon) for full PluginApi typings.
Until then, use JSDoc @param {import('quipu').PluginApi} api or
copy the interface from src/types/plugin-types.ts in the Quipu repo.
Viewer extensions
A viewer replaces the default TipTap editor for specific file types.
Register one with api.register(descriptor). Quipu picks the
viewer with the highest priority that returns true from
canHandle(tab).
ExtensionDescriptor
| Field | Required | Description |
|---|---|---|
| id | required | Unique viewer ID. Collisions are logged and the later registration wins. |
| canHandle | required | (tab: Tab) => boolean. Return true to claim the tab. Test tab.name, tab.path, or tab.type. |
| priority | required | Higher numbers win. Built-in viewers use 10–90. Use 50 as a safe default for plugins. |
| component | required | React component that receives { tab, activeFile, onContentChange, isActive, workspacePath, showToast } as props. |
| commands | optional | Array of commands shown in the menu bar when this viewer is active. Each entry: { id, label, handler }. |
| onSave | optional | (tab) => void — called when the user triggers save (Ctrl/Cmd+S) while this viewer is active. |
export function init(api) {
api.register({
id: 'my-csv-viewer',
// Claim any .csv file
canHandle: (tab) => tab.name?.endsWith('.csv') ?? false,
priority: 50,
component: CsvViewer, // your React component
// Optional: toolbar commands shown when this viewer is active
commands: [
{
id: 'csv.export',
label: 'Export as JSON',
handler: () => { /* ... */ },
},
],
});
}
React, ReactDOM, and ReactDOMClient
as globals — they are already on window when your plugin loads.
See the Building section for the Vite config that externalizes them correctly.
Sidebar panels
Panels appear in the activity bar on the left. Register one with
api.registerPanel(descriptor) and it will appear alongside
the built-in Explorer, Search, and Source Control panels.
PanelDescriptor
| Field | Required | Description |
|---|---|---|
| id | required | Unique panel ID used to toggle visibility. |
| label | required | Tooltip shown on hover in the activity bar. |
| icon | required | Phosphor icon name with Icon suffix, e.g. "BracketsCurlyIcon". Falls back to a circle if not found. |
| component | required | React component rendered in the sidebar when this panel is active. Receives no props by default. |
| order | optional | Sort order in the activity bar. Built-ins use 0, 1, 2. Use 10+ for plugins. |
| badge | optional | () => number | null. Called on each render. Return a positive integer to show a badge, or null to hide it. |
export function init(api) {
api.registerPanel({
id: 'my-panel',
label: 'My Panel',
icon: 'BracketsCurlyIcon',
component: MyPanelComponent,
order: 10,
});
}
Quipu renders your component inside the sidebar container when the user clicks
your icon in the activity bar. The component is mounted/unmounted as the panel
is toggled; use useEffect to fetch data on mount.
Commands
Commands are named actions that appear in the command palette
(Ctrl/Cmd+Shift+P). Register one with
api.registerCommand(id, handler, options). Call any registered
command programmatically with api.executeCommand(id, ...args).
export function init(api) {
// Register a command that appears in the command palette
api.registerCommand(
'my-plugin.sayHello', // command ID (namespaced by convention)
(name = 'world') => {
alert(`Hello, ${name}!`);
},
{
label: 'Say Hello', // shown in command palette
category: 'My Plugin', // group label
shortcut: 'Ctrl+Shift+H', // display-only hint (wire up separately)
}
);
// Call another command (built-in or plugin)
api.registerCommand('my-plugin.openSearch', () => {
api.executeCommand('view.search');
}, { label: 'Open Search', category: 'My Plugin' });
}
Built-in command IDs
Use these with api.executeCommand(id) to trigger core app actions:
| Command ID | Description | |
|---|---|---|
| file.save | Save the active tab. | |
| file.closeTab | Close the active tab. | |
| file.reloadFromDisk | Reload the active tab from disk. | |
| view.toggleSidebar | Show / hide the sidebar. | |
| view.search | Open the Search panel. | |
| view.quickOpen | Open Quick Open (Ctrl+P). | |
| view.commandPalette | Open the command palette. | |
| terminal.toggle | Show / hide the terminal. | |
| terminal.new | Open a new terminal tab. | |
| terminal.send | Send the active file to the FRAME skill. | |
| terminal.claude | Send the active file to Claude in the terminal. | |
| diff.open | Open the diff overlay. Pass { filePath, diffText, isStaged } as the first argument. | |
| diff.close | Close the diff overlay. | |
| editor.find | Toggle the in-editor find bar. | |
| tab.next | Switch to the next tab. | |
| tab.prev | Switch to the previous tab. |
Keybindings
Bind a keyboard shortcut to any registered command using
api.registerKeybinding(entry). Built-in shortcuts are always
registered first and take precedence over plugin shortcuts on conflict.
export function init(api) {
api.registerCommand('my-plugin.sayHello', () => alert('Hello!'), {
label: 'Say Hello',
category: 'My Plugin',
});
api.registerKeybinding({
key: 'ctrl+shift+h', // Windows / Linux
mac: 'cmd+shift+h', // macOS (optional — falls back to key)
commandId: 'my-plugin.sayHello',
});
}
Key string format
Key strings are lowercase modifier(s) joined by +, followed by the
lowercased KeyboardEvent.key value. Modifiers must appear in this
order: ctrl, cmd, shift, alt.
| Example | Matches | |
|---|---|---|
| ctrl+s | Ctrl+S on Windows/Linux | |
| cmd+s | Cmd+S on macOS | |
| ctrl+shift+k | Ctrl+Shift+K | |
| ctrl+` | Ctrl+Backtick | |
| ctrl+tab | Ctrl+Tab | |
| ctrl+shift+enter | Ctrl+Shift+Enter |
Services
api.services exposes the same dual-runtime services the app uses internally.
Each service works in both Electron and browser mode without any changes to your plugin code.
openFileDialog() for native file pickers in Electron.export function init(api) {
const { fileSystem } = api.services;
api.registerCommand('my-plugin.showFileSize', async () => {
const content = await fileSystem.readFile('/path/to/file.txt');
alert(`File is ${content.length} bytes`);
}, { label: 'Show File Size', category: 'My Plugin' });
}
activeFile.path
inside viewer components. Pass it to service calls to operate on whatever the user currently has open.
Building
For plugins larger than a single file, use Vite (or any bundler) to compile
your source into a single index.js. The critical requirement is
that React and ReactDOM are externalized — Quipu provides them
at runtime as globals.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/index.tsx',
formats: ['es'], // ES module output
fileName: () => 'index.js',
},
rollupOptions: {
// Externalize React — Quipu provides these as window globals
external: ['react', 'react-dom', 'react-dom/client'],
output: {
globals: {
'react': 'React',
'react-dom': 'ReactDOM',
'react-dom/client': 'ReactDOMClient',
},
},
},
},
});
{
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"@types/react": "^19.0.0"
}
}
Run npm run build — the compiled index.js goes to
dist/. Copy dist/index.js and manifest.json
into your plugin directory.
React.createElement directly (or the h alias shown in the
quick-start example) and write plain .js. Quipu loads any ES module.
Installing
Quipu looks for plugins in a fixed directory at startup. Drop your plugin folder there and relaunch — no package manager or registry required.
-
1
Create the plugins directory if it does not exist:
mkdir -p ~/.quipu/plugins -
2
Copy your plugin directory into it:
cp -r my-csv-viewer ~/.quipu/plugins/ -
3
Confirm the layout:
~/.quipu/plugins/ └── my-csv-viewer/ ├── manifest.json └── index.js -
4
Relaunch Quipu. Plugins are loaded once at startup. If a plugin fails validation or throws in
init(), Quipu shows a warning toast and continues loading other plugins.
npm run dev (Vite watch mode) in
your plugin directory, then restart Quipu to pick up changes. A hot-reload
mechanism is planned for a future release.