Plugin Authoring Guide
TUICommander uses an Obsidian-style plugin system. Plugins extend the Activity Center (bell dropdown), watch terminal output, and interact with app state. Plugins are external ES modules loaded at runtime from the user’s plugins directory in the desktop app. The native agent usage feature is initialized separately; there is no built-in plugin registration list.
Quick Start: External Plugin
- Create a directory:
~/.config/com.tuic.commander/plugins/my-plugin/ - Create
manifest.json:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"minAppVersion": "0.3.0",
"main": "main.js"
}
Note: All manifest fields use camelCase (
minAppVersion,agentTypes,contentUri) — this matches the Rust serde serialization format. Do not use snake_case.
// ✅ Correct
{ "minAppVersion": "0.5.0", "agentTypes": ["claude"] }
// ❌ Wrong
{ "min_app_version": "0.5.0", "agent_types": ["claude"] }
- Create
main.js(ES module with default export):
const PLUGIN_ID = "my-plugin";
export default {
id: PLUGIN_ID,
onload(host) {
host.registerSection({
id: "my-section",
label: "MY SECTION",
priority: 30,
canDismissAll: false,
});
host.registerOutputWatcher({
pattern: /hello (\w+)/,
onMatch(match, sessionId) {
host.addItem({
id: `hello:${match[1]}`,
pluginId: PLUGIN_ID,
sectionId: "my-section",
title: `Hello ${match[1]}`,
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><circle cx="8" cy="8" r="6"/></svg>',
dismissible: true,
});
},
});
},
onunload() {},
};
- In the desktop app, save a
.js,.mjs, or.jsoncode file outsidedata/to trigger hot reload. New top-level plugin directories and symlinks are discovered live. The watcher does not follow symlink targets, so edits within a linked target may need an explicit reload or restart. Browser/PWA clients do not load these external packages or run this watcher.
Architecture
PTY reader thread (Rust) ──> output_watchers.rs
|
+-- StreamLines (assemble lines — the only assembler)
+-- clean_line (strip ANSI codes + backticks)
+-- OutputWatcherRegistry (compiled patterns, per client)
+-- WatcherLineBatcher (one event per 100ms, lossless)
|
v
pty-watcher-lines event [{text, matched_ids}]
(matched lines only, or every line while a
pattern stays in the WebView)
|
v
pluginRegistry.handleWatcherLines()
|
+-- fire the ids Rust matched
+-- dispatchLine() — the watchers Rust
could not compile, skipping the
ids already fired
|
v
OutputWatcher.onMatch()
|
+-- host.addItem() --> Activity Center bell
|
user clicks item
|
markdownProviderRegistry.resolve(contentUri)
|
MarkdownTab renders content
Tauri OutputParser --> pluginRegistry.dispatchStructuredEvent(type, payload, sessionId)
|
+-- structuredEventHandler(payload, sessionId)
Plugin Lifecycle
- Discovery — Rust
list_user_pluginsscans~/.config/com.tuic.commander/plugins/formanifest.jsonfiles - Validation — Frontend validates manifest fields and
minAppVersion - Import —
import("plugin://my-plugin/main.js")loads the module via the custom URI protocol (on Windows the loader rewrites this tohttp://plugin.localhost/my-plugin/main.js, since WebView2 only serves custom schemes underhttp://{scheme}.localhost/...) - Module check — Default export must have
id,onload,onunload - Register —
pluginRegistry.register(plugin, capabilities)callsplugin.onload(host) - Active — Plugin receives PTY lines, structured events, and can use the PluginHost API
- Hot reload — File changes emit
plugin-changedevents; the plugin is unregistered and re-imported. Creating a new top-level plugin directory or symlink after startup also emits the event (the watcher does not descend into symlink targets, so the create of the link itself is the trigger), and the new plugin is discovered and loaded without a restart - Unload —
plugin.onunload()is called, then tracked registrations are disposed andopenPaneltabs close. Timers, DOM listeners and other resources created directly by the plugin remain its responsibility.
Crash Safety
Every boundary is wrapped in try/catch:
import()— syntax errors or missing exports are caught- Module validation — missing
id,onload, oronunloadlogs an error and skips the plugin plugin.onload()— if it throws, partial registrations are cleaned up automatically- Watcher/handler dispatch — exceptions are caught and logged, other plugins continue
- UI callbacks registered through
PluginHost(file icons/previews, activity items, context menu actions, ticker messages, dashboards, commands) are isolated the same way; failures are written both to the app log and the plugin’s dedicated log buffer
A broken plugin produces a console error and is skipped. The app always continues.
Manifest Reference
File: ~/.config/com.tuic.commander/plugins/{id}/manifest.json
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Must match the directory name |
name | string | yes | Human-readable display name |
version | string | yes | Plugin semver (e.g. "1.0.0") |
minAppVersion | string | yes | Minimum TUICommander version required |
main | string | yes | Entry point filename (e.g. "main.js") |
description | string | no | Short description |
author | string | no | Author name |
capabilities | string[] | no | Tier 3/4 capabilities needed (defaults to []) |
allowedUrls | string[] | for net:http | Non-empty URL patterns required for HTTP access (e.g. ["https://api.example.com/*"]) |
agentTypes | string[] | no | Agent types this plugin targets (e.g. ["claude"]). Omit or [] for universal plugins. |
binaries | string[] | no | CLI binaries this plugin may execute via exec:cli (e.g. ["rtk", "mdkb"]) |
Validation Rules
idmust match the directory name exactlyidmust not be emptymainmust not contain path separators or..- All
capabilitiesmust be known strings (see Capabilities section) minAppVersionmust be <= the current app version (semver comparison)
Plugin Interface
interface TuiPlugin {
id: string;
onload(host: PluginHost): void;
onunload(): void;
}
The onload function receives a PluginHost object — this is your entire API surface. External plugins cannot import app internals; everything goes through host.
PluginHost API Reference
Tier 0: Logging (always available)
host.log(level, message, data?) -> void
Write to the plugin’s dedicated log ring buffer (max 500 entries). Viewable in Settings > Plugins > click “Logs” on any plugin row.
host.log("info", "Plugin initialized");
host.log("error", "Failed to process", { code: 404 });
Levels: "debug", "info", "warn", "error". The optional data parameter accepts any JSON-serializable value and is displayed alongside the message.
Errors thrown inside onload, onunload, output watchers, and structured event handlers are automatically captured to the plugin’s log. Use host.log() for additional diagnostic output. Error count badges appear on plugins with recent errors in the Settings panel.
Tier 1: Activity Center + Watchers + Providers (always available)
All register*() methods return a Disposable with a dispose() method. You do not need to call dispose() manually — all registrations are automatically disposed when onunload() is called (including during hot reload). Panels opened through openPanel also close on unload, and their message, visibility and close bridges are removed. Resources created directly by plugin code, such as timers or DOM listeners, must be released by onunload(). Only call dispose() if you need to dynamically remove a registration while the plugin is still running.
host.registerSection(section) -> Disposable
Adds a section heading to the Activity Center dropdown.
host.registerSection({
id: "my-section", // Must match sectionId in addItem()
label: "MY SECTION", // Displayed as section header
priority: 30, // Lower number = higher position
canDismissAll: false, // Show "Dismiss All" button?
});
host.registerOutputWatcher(watcher) -> Disposable
Watches every PTY output line (after ANSI stripping, backtick removal, and line reassembly).
host.registerOutputWatcher({
pattern: /Deployed: (\S+) to (\S+)/,
onMatch(match, sessionId) {
// match[0] = full match, match[1] = first capture group, etc.
// sessionId = the PTY session that produced the line
host.addItem({ ... });
},
});
Rules:
onMatchmust be synchronous and fast (< 1ms) — it runs on the interface main thread (deferred to a microtask, but a slow handler still blocks painting)pattern.lastIndexis reset before each test (safe to use global flag, but unnecessary)- Input is ANSI-stripped but may contain Unicode (checkmarks, arrows, emoji)
- Arguments are positional:
onMatch(match, sessionId)— NOT destructured
Where the matching runs. The registry sends the pattern source and flags of every
watcher to Rust (set_plugin_output_watchers). The PTY reader thread assembles the
lines, cleans them (tuic-terminal/src/output_watchers.rs, a port of stripAnsi plus the backtick strip),
and tests them against the compiled patterns. Rust is the only line assembler: the
WebView reassembles nothing, so a watcher that registers halfway through a line still
sees that line whole. The WebView is only woken for a line that matched: it receives that
cleaned line, runs your RegExp on it again, and gives onMatch a genuine
RegExpExecArray. The i, m and s flags apply on both sides.
Rust may match a little more than your RegExp does, never less — the re-run in the
WebView filters the difference. One accepted divergence: a pattern that counts UTF-16
code units of astral characters (/^.{2}$/ against an emoji) matches in JavaScript only.
Patterns that stay in the WebView. The Rust regex crate is not a superset of JS
RegExp — it has no lookaround, no backreferences, and no negated class escape inside a
character class ([\D.]). Rust reports such a pattern as rejected instead of compiling
it to something different, and the registry keeps matching that watcher itself, on the
same assembled lines. The behavior of the watcher does not change; only the thread that
tests the line does. One rejected pattern makes Rust ship every line instead of the
matched ones alone, so prefer a portable pattern when you have the choice.
Every line is tested. The events are rate-limited to one per 100 ms, but the lines inside a window are concatenated in order, never dropped, so no line is lost and no line is fabricated from two spliced chunks.
host.registerStructuredEventHandler(type, handler) -> Disposable
Handles typed events from the Rust OutputParser.
host.registerStructuredEventHandler("plan-file", (payload, sessionId) => {
const { path } = payload as { path: string };
host.addItem({ ... });
});
See Structured Event Types for all types and payload shapes.
host.registerMarkdownProvider(scheme, provider) -> Disposable
Provides content for a URI scheme when the user clicks an ActivityItem.
host.registerMarkdownProvider("my-scheme", {
async provideContent(uri) {
const id = uri.searchParams.get("id");
if (!id) return null;
try {
return await host.invoke("read_file", { path: dir, file: name });
} catch {
return null;
}
},
});
host.addItem(item) / host.removeItem(id) / host.updateItem(id, updates)
Manage activity items:
host.addItem({
id: "deploy:api:prod", // Unique identifier
pluginId: "my-plugin", // Must match your plugin id
sectionId: "my-section", // Must match your registered section
title: "api-server", // Primary text
subtitle: "Deployed to prod", // Secondary text (optional)
icon: '<svg .../>', // Inline SVG with fill="currentColor"
iconColor: "#3fb950", // Optional CSS color for the icon
dismissible: true,
contentUri: "my-scheme:detail?id=api", // Opens in MarkdownTab on click
// OR: onClick: () => { ... }, // Mutually exclusive with contentUri
});
host.updateItem("deploy:api:prod", { subtitle: "Rolled back" });
host.removeItem("deploy:api:prod");
Tier 2: Read-Only App State (always available)
host.getActiveRepo() -> RepoSnapshot | null
const repo = host.getActiveRepo();
// { path: "/Users/me/project", displayName: "project", activeBranch: "main", worktreePath: null }
host.getRepos() -> RepoListEntry[]
const repos = host.getRepos();
// [{ path: "/Users/me/project", displayName: "project" }, ...]
host.getActiveTerminalSessionId() -> string | null
const sessionId = host.getActiveTerminalSessionId();
host.getRepoPathForSession(sessionId) -> string | null
Resolves which repository owns a given terminal session by searching all repos and branches for a terminal matching the session ID. Returns null if the session is not associated with any repository (e.g. a standalone terminal or an unknown session ID). Useful in output watcher callbacks where sessionId is provided but you need the repo context.
host.registerOutputWatcher({
pattern: /Deployed: (\S+)/,
onMatch(match, sessionId) {
const repoPath = host.getRepoPathForSession(sessionId);
if (!repoPath) return; // session not tied to a repo
// repoPath = "/Users/me/project"
},
});
host.getSessionCwd(sessionId) -> string | null
Returns the current working directory of the given terminal session, or null if the session is unknown or has no recorded CWD. No capability required.
const cwd = host.getSessionCwd(sessionId);
// → "/Users/me/project" (or null)
host.getActiveRepoPath() -> string | null
Shortcut for the active repository’s path (equivalent to host.getActiveRepo()?.path). Returns null when no repository is active. No capability required.
const repoPath = host.getActiveRepoPath();
host.getClaudeProjectDir(repoPath) -> Promise<string | null>
Resolves a repository path to the absolute path of its Claude Code project directory (~/.claude/projects/<slug>). The slug encoding is handled by the Rust side — plugins should use this instead of constructing paths manually. Requires "fs:read" capability.
const projectDir = await host.getClaudeProjectDir("/Users/me/my-project");
// → "/Users/me/.claude/projects/-Users-me-my-project"
const files = await host.listDirectory(projectDir, "*.jsonl", { sortBy: "mtime" });
host.getPrNotifications() -> PrNotificationSnapshot[]
const prs = host.getPrNotifications();
// [{ id, repoPath, branch, prNumber, title, type }, ...]
host.getSettings(repoPath) -> RepoSettingsSnapshot | null
const settings = host.getSettings("/Users/me/project");
// { path, displayName, baseBranch: "main", color: "#3fb950" }
host.getTerminalState() -> TerminalStateSnapshot | null
Returns the active terminal’s state snapshot.
const state = host.getTerminalState();
// { sessionId, shellState: "busy"|"idle"|null, agentType: "claude"|null,
// agentActive: boolean, awaitingInput: "question"|null, repoPath }
host.onStateChange(callback) -> Disposable
Register a callback for terminal/branch state changes. Fires on agent start/stop, branch change, shell state change, and awaiting-input change.
const sub = host.onStateChange((event) => {
// event.type: "agent-started" | "agent-stopped" | "branch-changed"
// | "repo-changed" | "shell-state-changed" | "awaiting-input-changed"
// event.detail: new branch, repo path, shell state or awaiting-input kind; undefined when cleared
// Branch/repo events describe active navigation (sessionId: null, terminalId: "").
// Shell/awaiting events identify the changed terminal and its session.
});
// sub.dispose() to unsubscribe
State events report changes after the runtime starts; use snapshot getters for initial values. Terminal events observe existing terminal sessions, including remote updates. Branch events describe the active workspace’s branch, not every repository HEAD in the background.
When teardown clears a session and changes its final shell or awaiting-input state together, those events retain the old session ID. A replacement session establishes a new baseline without replaying its initial state.
Tier 2b: Git Read (capability-gated)
These methods require declaring "git:read" in manifest.json. They provide read-only access to git repository state.
host.getGitBranches(repoPath) -> Promise<Array<{ name, isCurrent }>>
const branches = await host.getGitBranches("/Users/me/project");
// [{ name: "main", isCurrent: true }, { name: "feature/x", isCurrent: false }]
host.getRecentCommits(repoPath, count?) -> Promise<Array<{ hash, message, author, date }>>
const commits = await host.getRecentCommits("/Users/me/project", 5);
// [{ hash: "abc1234", message: "fix: bug", author: "name", date: "2026-02-25" }]
host.getGitDiff(repoPath, scope?) -> Promise<string>
const diff = await host.getGitDiff("/Users/me/project", "staged");
// Returns unified diff string
Tier 3: Write Actions (capability-gated)
These methods require declaring capabilities in manifest.json. Calling without the required capability throws PluginCapabilityError.
host.writePty(sessionId, data) -> Promise<void>
Sends raw bytes to a terminal session. Requires "pty:write" capability.
Prefer
sendAgentInput()for user input.writePtysends raw data — it does not handle Enter key semantics for Ink-based agents. Use it only when you need exact byte control.
await host.writePty(sessionId, "\x03"); // Send Ctrl-C
host.sendAgentInput(sessionId, text) -> Promise<void>
Sends user input to an agent session with correct Enter handling. Requires "pty:write" capability.
Ink-based agents (Claude Code, Codex, etc.) run in raw mode and need Ctrl-U, the text, and \r as three writes with a real time gap between them, so each arrives in its own read (Claude Code strips a Ctrl-U that arrives inside a long text and then refuses the Enter). Shell sessions receive Ctrl-U + text in one write, then \r. This method handles both cases automatically based on the detected agent type.
await host.sendAgentInput(sessionId, "y"); // confirm a prompt
await host.sendAgentInput(sessionId, "explain this code"); // send a message
host.openMarkdownPanel(title, contentUri) -> void
Opens a virtual markdown tab and shows the panel. Requires "ui:markdown" capability.
host.openMarkdownPanel("CI Report", "my-scheme:report?id=123");
host.openMarkdownFile(absolutePath) -> void
Opens a local markdown file in the markdown panel. Requires "ui:markdown" capability. The path must be absolute. This is useful for plugins that ship a README.md or other documentation files.
// Open the plugin's own README
host.openMarkdownFile("/Users/me/.config/com.tuic.commander/plugins/my-plugin/README.md");
host.openMarkdownFileBackground(absolutePath) -> boolean
Opens a local Markdown file as a pinned background tab without changing the active pane. Requires "ui:markdown" capability. Returns false when no registered repository owns the absolute path; otherwise returns true, including when the existing tab was reused.
const owned = host.openMarkdownFileBackground("/Users/me/project/plans/feature.md");
host.playNotificationSound(sound?) -> Promise<void>
Plays a notification sound. Requires "ui:sound" capability.
| Parameter | Type | Default | Description |
|---|---|---|---|
sound | string | "info" | One of: "question", "error", "completion", "warning", "info" |
await host.playNotificationSound("error"); // CI failure, build error
await host.playNotificationSound("question"); // input prompt, awaiting user
await host.playNotificationSound("completion"); // task finished
await host.playNotificationSound(); // defaults to "info"
Tier 3b: Filesystem Operations (capability-gated)
These methods provide sandboxed filesystem access. All paths must be absolute and within the user’s home directory ($HOME).
host.readFile(absolutePath) -> Promise<string>
Read a file’s content as UTF-8 text. Maximum file size: 10 MB. Requires "fs:read" capability.
const content = await host.readFile("/Users/me/.claude/projects/foo/conversation.jsonl");
host.readFiles(absolutePaths) -> Promise<(string | null)[]>
Read up to 1000 files in one round trip, in request order. An entry is null when that path could not be read — a file that vanished between the listing and the read does not lose the batch. Requires "fs:read" capability.
Use this instead of looping over readFile whenever the paths are known up front: listing a directory and reading every entry costs two calls instead of one per file.
const names = await host.listDirectory(dir, "*.md");
const contents = await host.readFiles(names.map((name) => `${dir}/${name}`));
const found = names
.map((filename, i) => ({ filename, content: contents[i] }))
.filter((entry) => entry.content != null);
host.readFileBase64(absolutePath, options?) -> Promise<string>
Read a file’s raw bytes and return them as a base64 string. The default maximum is 10 MiB. A plugin may request a positive per-call options.maxBytes; the host clamps it to 512 MiB to bound the Base64 and decoded-buffer memory peak. Use this for binary previews such as .docx, images, archives, or bounded in-memory database snapshots. Requires "fs:read" capability.
const encoded = await host.readFileBase64("/Users/me/Documents/spec.docx");
const bytes = Uint8Array.from(atob(encoded), (ch) => ch.charCodeAt(0));
const database = await host.readFileBase64("/Users/me/data.sqlite", {
maxBytes: 256 * 1024 * 1024,
});
host.writeFileBase64(absolutePath, content, options?) -> Promise<void>
Atomically replace a file with bytes supplied as Base64. The default maximum is 10 MiB; a positive options.maxBytes is clamped to the 512 MiB host ceiling. The complete decoded payload is validated before the temporary file is atomically renamed over the destination. Requires "fs:write" capability.
await host.writeFileBase64("/Users/me/data.sqlite", encodedDatabase, {
maxBytes: 256 * 1024 * 1024,
});
host.listDirectory(path, pattern?, options?) -> Promise<string[]>
List filenames in a directory, optionally filtered by a glob pattern. Returns filenames only (not full paths). Requires "fs:list" capability.
Options:
sortBy:"name"(default, alphabetical) or"mtime"(newest first). Use"mtime"to efficiently find the most recently modified file when the directory contains many historical entries.
const files = await host.listDirectory("/Users/me/.claude/projects/foo", "*.jsonl");
// ["conversation-1.jsonl", "conversation-2.jsonl"]
// Find the currently active session JSONL among 100+ historical ones:
const recent = await host.listDirectory(dir, "*.jsonl", { sortBy: "mtime" });
const activeFile = recent[0]; // most recently written
host.watchPath(path, callback, options?) -> Promise<Disposable>
Watch a path for filesystem changes. Emits batched events after a debounce period. Requires "fs:watch" capability.
Each watch delivers only its own events — a plugin with several watches gets one callback per change, not one per watch.
const watcher = await host.watchPath(
"/Users/me/.claude/projects/foo",
(events) => {
for (const event of events) {
console.log(event.type, event.path); // "create" | "modify" | "delete"
}
},
{ recursive: true, debounceMs: 500 },
);
// Later: stop watching
watcher.dispose();
Options:
recursive— Watch subdirectories (default:false)debounceMs— Debounce window in milliseconds (default:300)
FsChangeEvent:
interface FsChangeEvent {
type: "create" | "modify" | "delete";
path: string;
}
host.writeFile(absolutePath, content) -> Promise<void>
Write content to a file within $HOME. Creates parent directories if needed. Refuses to overwrite directories. Max 10 MB. Requires "fs:write" capability.
await host.writeFile("/Users/me/project/stories/new-story.md", "---\nstatus: pending\n---\n# New Story");
host.renamePath(from, to) -> Promise<void>
Rename or move a file within $HOME. Both paths must be absolute. Source must exist. Creates parent directories for destination if needed. Requires "fs:rename" capability.
await host.renamePath(
"/Users/me/project/stories/old-name.md",
"/Users/me/project/stories/new-name.md",
);
host.scanBuildArtifacts(repoPaths, options?) -> Promise<ArtifactEntry[]>
Recursively scan the given repo roots for build-artifact directories (Rust/Maven target/, node_modules/, JS framework caches like .next/.turbo, Python .venv/__pycache__/tool caches, .NET obj/bin, Gradle/CMake/Flutter build/, SwiftPM .build/Pods, .terraform, Elixir _build, Zig, Haskell, Composer vendor/). Read-only. Unlike listDirectory, this ignores .gitignore (artifact dirs are gitignored by design) and stops descending on a match, so a nested node_modules is folded into the outer entry — never double counted. Ambiguously-named dirs (target, bin/obj, build, vendor, …) only count when a toolchain marker sits beside them (e.g. Cargo.toml or pom.xml for target; a .csproj/.fsproj/.vbproj/.sln/.slnx for bin/obj; build.gradle/CMakeLists.txt/pubspec.yaml for build; composer.json for vendor) — a Go sysroot bin or an Xcode PIFCache/target is walked like any other dir, not claimed. The full rule table is ARTIFACT_RULES in src-tauri/src/plugin_fs.rs. Each repoPaths entry is $HOME-scoped and intersected server-side with the app’s actual registered-repository list — a path that isn’t equal to or nested under a genuinely registered repo root is silently dropped, so a plugin cannot widen its scan surface by passing arbitrary $HOME paths. Results for the same normalized set of roots are shared across concurrent callers and reused for 30 seconds. Pass { forceRefresh: true } to bypass a completed cached result; an already-running scan for the same roots is still shared. Requires "fs:scan" capability.
Each ArtifactEntry is { path, kind, size_bytes, trimmable_bytes, last_modified_secs, repo } — kind is one of rust | maven | node | jscache | python | dotnet | gradle | cmake | swift | flutter | terraform | elixir | zig | haskell | php, last_modified_secs is the max mtime of the dir’s direct children (a “last build” signal), and trimmable_bytes is the share of size_bytes that trimBuildArtifact would reclaim (0 for kinds with no separable intermediates). Both sizes come from one filesystem walk.
const entries = await host.scanBuildArtifacts(host.getRepos().map((r) => r.path));
const refreshed = await host.scanBuildArtifacts(host.getRepos().map((r) => r.path), {
forceRefresh: true,
});
host.deleteBuildArtifact(path, repoPaths) -> Promise<void>
Delete a build-artifact directory. Destructive. The backend guard requires (all must hold): the path canonicalizes to a basename that is a known artifact dir, it is strictly inside one of repoPaths (never a repo root itself), that containing root is itself a server-verified registered repository (caller-supplied roots not backed by a real registered repo are dropped — the plugin cannot claim an arbitrary $HOME directory as a “repo”), it is $HOME-scoped, and — for ambiguously-named dirs — the same toolchain marker required by the scanner sits beside it (so Rust src/bin sources are refused). Only then is remove_dir_all run. Requires "fs:delete" capability.
await host.deleteBuildArtifact("/Users/me/project/target", ["/Users/me/project"]);
host.trimBuildArtifact(path, repoPaths) -> Promise<number>
Remove only a build-artifact directory’s regenerable intermediates, leaving the built executables in place. Prefer this over deleteBuildArtifact: a full clean of a Rust target/ also deletes the binary the user may be running, while reclaiming (measured across 5 real repos) only ~1% more disk.
Authorization is deleteBuildArtifact’s, unchanged. On top of it, only paths produced by expanding the matched toolchain rule’s own trim patterns are removed, and each is re-verified to sit strictly inside the artifact dir; symlinked directories are never followed or expanded into.
Which sub-paths count as intermediates is a per-rule table (ARTIFACT_RULES[].trim in src-tauri/src/plugin_fs.rs):
| Kind | Trimmed | Kept |
|---|---|---|
rust (target/) | <profile>/{deps,build,incremental,.fingerprint}, plus the same under a target triple | the linked executables at each profile root |
swift (.build/) | index-build/, <triple>/<profile>/{ModuleCache,index,<Module>.build} | the product binary, Modules/, checkouts/, repositories/ |
maven (target/) | classes, test-classes, generated-*, maven-status, maven-archiver, *-reports | the packaged *.jar/*.war |
gradle (build/) | classes, tmp, kotlin, intermediates, generated, reports, test-results, jacoco | libs/, outputs/, distributions/, install/ |
The bar for inclusion is that rebuilding costs only local CPU — anything needing the network to restore (cargo registry, SwiftPM checkouts/repositories) is deliberately kept. Every other kind has an empty trim list: it reports trimmable_bytes: 0 and rejects a trim, because there is no subset that spares an output (node_modules, .venv, vendor) or because the whole dir is already the intermediate half of a pair (.NET obj vs bin, which the scanner reports as two separate entries).
Check trimmable_bytes > 0 before offering it. It resolves with the bytes it actually reclaimed, measured as it deleted; 0 means there was nothing left to trim. Patch cached totals with that number and never with the trimmable_bytes of the last scan — a build between the scan and the trim moves the estimate, and subtracting it publishes a total no measurement supports. Requires "fs:delete" capability — same blast-radius class, on a subset.
const reclaimed = await host.trimBuildArtifact("/Users/me/project/target", ["/Users/me/project"]);
Tier 3c: Status Bar Ticker (capability-gated)
The status bar has a shared ticker area that rotates messages from multiple plugins. Messages are grouped by priority tier:
| Tier | Priority | Behavior |
|---|---|---|
| Low | < 10 | Shown only in the popover, not in rotation |
| Normal | 10–99 | Auto-rotates every 5s in the ticker area |
| Urgent | >= 100 | Pinned — pauses rotation until cleared |
Users can click the counter badge (e.g. 1/3 ▸) to cycle manually, or right-click the ticker to see all active messages in a popover.
host.setTicker(options) -> void
Set a ticker message in the shared status bar ticker. Preferred API — supports source labels. If a message with the same id from this plugin already exists, it is replaced. Requires "ui:ticker" capability.
host.setTicker({
id: "my-status",
text: "Processing: 42%",
label: "MyPlugin", // Shown as "MyPlugin · Processing: 42%"
icon: '<svg viewBox="0 0 16 16" fill="currentColor">...</svg>',
priority: 10,
ttlMs: 60000,
onClick: () => { /* optional click handler */ },
});
Options:
id— Unique message identifier (scoped to your plugin). Reusing an id replaces the previous message.text— Message text displayed in the ticker rotation.label— Optional human-readable source label shown before the text (e.g."Usage").icon— Optional inline SVG icon.priority— Priority tier (see table above). Default:0.ttlMs— Auto-expire after N milliseconds.0= persistent (must be removed manually). Default:60000.onClick— Optional callback invoked when the user clicks the message text.
host.clearTicker(id) -> void
Remove a ticker message by id. Requires "ui:ticker" capability.
host.clearTicker("my-status");
The removed postTickerMessage and removeTickerMessage aliases are not supported. Use setTicker and clearTicker.
Tier 3d: Panel UI (capability-gated)
host.openPanel(options) -> PanelHandle
Open an HTML panel in a sandboxed iframe tab. Returns a handle for updating content or closing the panel. If a panel with the same id is already open, it will be activated and updated. Requires "ui:panel" capability.
When the panel is hidden, its iframe is unloaded; showing it loads the page again. Keep state that must survive tab switches outside the iframe. This also applies to URL tabs.
const panel = host.openPanel({
id: "my-dashboard",
title: "Dashboard",
html: "<html><body><h1>Hello</h1></body></html>",
onMessage(data) {
// Receive structured messages from the iframe
console.log("Got message from iframe:", data);
// Send response back
panel.send({ type: "response", ok: true });
},
onVisibilityChange(visible) {
// A panel stays mounted behind display:none when another tab is selected.
// Skip work while hidden and catch up here.
if (visible && stale) rebuild();
},
onClose() {
// The tab is gone for good, whoever closed it — the ×, a middle-click, the
// context menu, or your own close(). A hidden panel comes back; a closed one
// does not, and isVisible() reports false for both. Release what the panel
// owned here or it outlives the tab.
watch.dispose();
panel = null;
},
});
// …and query it at any time, e.g. before an expensive re-render
if (!panel.isVisible()) { stale = true; return; }
// Update content later. Returns false once the user has closed the tab — a
// plugin that renders on a timer or a file event should stop, or re-open with
// openPanel() if the render was user-requested.
if (!panel.update("<html><body><h1>Updated</h1></body></html>")) panel = null;
// Send a message to the iframe at any time
panel.send({ type: "refresh", items: [...] });
// Transfer ownership of large buffers instead of copying them. The sender's
// buffer is detached after postMessage succeeds.
panel.send({ type: "database", buffer }, [buffer]);
// Close the panel
panel.close();
Inside the iframe:
<script>
// Send message to host
window.parent.postMessage({ type: "save", config: { ... } }, "*");
// Receive messages from host
window.addEventListener("message", (e) => {
if (e.data?.type === "response") {
console.log("Host says:", e.data.ok);
}
});
</script>
CSS Base Stylesheet + Theme Injection: Every plugin panel iframe receives two automatic CSS injections:
-
Base stylesheet (
pluginBaseStyles.ts) — a complete design foundation with reset, typography, buttons, inputs, cards, tables, badges, toasts, scrollbars, and empty states. All values use CSS custom properties from the app theme. Plugins get a polished, consistent look without writing any CSS. -
Theme variables — all CSS custom properties from the app’s
:rootare injected (e.g.--bg-primary,--fg-primary,--border,--accent,--error,--warning,--success,--text-on-accent). These match the user’s active theme.
Design strategy: Write minimal plugin-specific CSS that overrides the base. The base provides:
| Base class | Description |
|---|---|
body | Themed background, font, color |
button, .btn | Default button with hover/active states |
button.primary, .btn-primary | Accent-colored button |
button.danger, .btn-danger | Error-colored button |
input, textarea, select | Themed form controls with focus ring |
.card | Bordered container with hover elevation |
table, th, td | Styled table with hover rows |
.badge | Inline label (combine with .badge-p1, .badge-error, .badge-success, .badge-accent, .badge-warning, .badge-muted) |
label, .hint | Form labels and help text |
.filter-bar | Flex row for search/filter UI |
.empty-state | Centered placeholder with .hint |
.toast, .toast.error, .toast.success | Fixed-position notification (add .show to display) |
h1–h4 | Themed headings |
code, a, hr, small | Themed inline elements |
::-webkit-scrollbar | Styled scrollbar matching the app |
Example — minimal plugin CSS:
<style>
/* Only what's specific to this plugin */
body { padding: 16px; }
.my-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
</style>
Dashboard layout classes. For analytics/status dashboards, the base stylesheet also ships a .dashboard/.dash-* class family that mirrors the native Claude Usage dashboard. Use them instead of inventing layout CSS — see docs/plugins-style.md for the full guide, checklist, and class reference.
host.openEditorTab(filePath, repoPath, opts?)
Open a file in the CodeMirror editor tab. Requires "ui:panel" capability.
host.openEditorTab("src/main.ts", "/Users/me/project", { line: 42 });
| Param | Type | Description |
|---|---|---|
filePath | string | Relative or absolute file path |
repoPath | string | Repository root path |
opts.fsRoot | string? | Filesystem root (defaults to repoPath) |
opts.line | number? | Line number to scroll to |
host.registerFilePreview(options) -> Disposable
Register a custom file preview handler for specific extensions. When the user opens a file matching one of the claimed extensions, the onOpen callback is invoked instead of the default editor. Requires "ui:file-preview" capability.
host.registerFilePreview({
extensions: ["csv", "tsv"],
async onOpen(ctx) {
const raw = await host.readFile(`${ctx.fsRoot}/${ctx.filePath}`);
host.openPanel({ id: `csv-${ctx.filePath}`, title: "CSV", html: buildTable(raw) });
},
});
| Param | Type | Description |
|---|---|---|
options.extensions | string[] | File extensions to claim (without dot, case-insensitive) |
options.onOpen | (ctx: FilePreviewContext) => void | Handler called when the user opens a matching file |
FilePreviewContext:
| Field | Type | Description |
|---|---|---|
filePath | string | Relative path within the repo |
repoPath | string | Repository root path |
fsRoot | string | Filesystem root for resolving absolute paths |
On plugin unload, claimed extensions are released and files revert to the default open behavior.
host.registerDashboard(options) -> Disposable
Register a one-click entry point for the plugin’s dashboard. When registered, Settings → Plugins shows a Dashboard button in the plugin row that calls options.open() and automatically closes the Settings panel so the dashboard becomes visible.
host.registerDashboard({
label: "My Plugin", // optional, defaults to "Dashboard"
icon: MY_PLUGIN_ICON, // optional inline SVG string
open: () => openDashboard(host),
});
A plugin may only register one dashboard — calling registerDashboard a second time replaces the previous entry. Dispose the returned handle in onunload (or rely on automatic cleanup via the plugin’s disposable tracker).
host.registerCommand(options) -> Disposable
Register a plugin command that users can bind to a keyboard shortcut. The command appears in Settings → Keyboard Shortcuts under a dedicated “Plugin Commands” section and can be rebound by the user. The action name is auto-namespaced as plugin:<pluginId>:<id>.
host.registerCommand({
id: "open-dashboard", // unique per plugin
title: "Open My Dashboard", // label in the Shortcuts UI
defaultShortcut: "Cmd+Shift+M",// optional — leave unbound by default
run: () => openDashboard(host),
});
If defaultShortcut conflicts with an existing binding (built-in or another plugin), the command is registered but left unbound; a warning is logged and the user can pick a free combo via Settings. The handle returned is automatically tracked and released on plugin unload.
All standard elements (buttons, inputs, tables) will look correct automatically.
Available CSS variables (from the app’s active theme):
| Variable | Usage |
|---|---|
--bg-primary | Main canvas |
--bg-secondary | Sidebar-level surfaces |
--bg-tertiary | Inputs, elevated surfaces |
--bg-highlight | Hover states |
--fg-primary | Primary text |
--fg-secondary | Labels, secondary text |
--fg-muted | Tertiary text |
--accent | Links, primary actions |
--accent-hover | Hover on accent |
--success | Positive states |
--warning | Caution states |
--error | Error states |
--border | All borders |
--text-on-accent | Text on colored backgrounds |
--text-on-error | Text on error backgrounds |
--text-on-success | Text on success backgrounds |
Security: The iframe uses sandbox="allow-scripts allow-same-origin". This permits scripts and same-origin access; it is not an isolation boundary from the host DOM. Plugin modules also execute in the host JavaScript realm. The close-panel message type is handled as a system message; all other messages are routed to the onMessage callback.
TUIC SDK (window.tuic)
Every plugin iframe automatically receives the TUIC SDK — a lightweight JavaScript API for host integration. The SDK is injected alongside the base CSS and theme variables.
Feature detection:
if (window.tuic) {
// Running inside TUICommander — SDK is available
console.log("TUIC SDK version:", window.tuic.version);
}
Programmatic API:
| Method | Description |
|---|---|
tuic.version | SDK version string (e.g. "1.0") |
tuic.open(path, opts?) | Open a markdown file in a new tab. path is absolute. opts.pinned pins the tab. |
tuic.openUrl(url) | Open an http, https, or mailto URL outside TUICommander. Other schemes are rejected by the host. |
tuic.edit(path, opts?) | Open a file in the code editor. opts.line (1-based) jumps to a line; omit for the top. |
tuic.terminal(repoPath) | Open a new terminal in the given repository. |
// Open a file
tuic.open("/Users/me/myrepo/README.md");
// Open a pinned file
tuic.open("/Users/me/myrepo/docs/guide.md", { pinned: true });
// Open a terminal in a repo
tuic.terminal("/Users/me/myrepo");
// Open a website in the system browser
tuic.openUrl("https://example.com/docs");
Link interception: Standard HTML links with tuic:// scheme are intercepted automatically — no JavaScript required:
Absolute http, https, and mailto links in inline plugin HTML also open through the host when clicked. Relative links remain blocked so an inline panel cannot navigate into the TUICommander app. A panel loading a cross-origin URL cannot have its clicks inspected by the host; use the tab menu’s Open in Browser action if that page needs external links.
<!-- Opens a markdown file -->
<a href="tuic://open/Users/me/myrepo/README.md">View README</a>
<!-- Opens a pinned markdown file -->
<a href="tuic://open/Users/me/myrepo/docs/guide.md" data-pinned>Pinned Guide</a>
<!-- Opens a terminal -->
<a href="tuic://terminal?repo=/Users/me/myrepo">Open Terminal</a>
URL format:
| URL | Action |
|---|---|
tuic://open/<absolute-path> | Open file in markdown tab |
tuic://edit/<absolute-path>?line=N | Open file in the code editor (optional line) |
tuic://terminal?repo=<repo-path> | Open terminal in repository |
Security: Paths are validated against the list of known repositories. Paths outside any registered repo are rejected with a warning. The SDK runs inside the sandbox and communicates with the host exclusively via postMessage.
Tier 3e: Sidebar Plugin Panels (capability-gated)
host.registerSidebarPanel(options) -> SidebarPanelHandle
Register a collapsible panel section in the sidebar, displayed below the branch list for each repo. Requires "ui:sidebar" capability.
Panels display structured data (not HTML) — the app renders items natively for visual consistency with the rest of the sidebar.
interface SidebarPanelOptions {
id: string; // Unique panel ID (scoped to plugin)
label: string; // Section header text
icon?: string; // Inline SVG for header
priority?: number; // Lower = higher in sidebar (default 100)
collapsed?: boolean; // Initial collapsed state (default true)
}
interface SidebarPanelHandle {
setItems(items: SidebarItem[]): void; // Replace all items
setBadge(text: string | null): void; // Header badge (e.g. "3")
dispose(): void; // Remove panel
}
interface SidebarItem {
id: string; // Unique item ID (scoped to panel)
label: string; // Primary text
subtitle?: string; // Secondary text (smaller, muted)
icon?: string; // Inline SVG (fill="currentColor")
iconColor?: string; // CSS color
onClick?: () => void; // Click handler
contextMenu?: SidebarItemAction[]; // Right-click actions
}
interface SidebarItemAction {
label: string;
action: () => void;
disabled?: boolean;
}
Example:
const panel = host.registerSidebarPanel({
id: "active-plans",
label: "ACTIVE PLANS",
icon: '<svg ...>...</svg>',
priority: 10,
collapsed: false,
});
panel.setItems([
{ id: "plan-1", label: "Feature Plan", subtitle: "In Progress · M", onClick: () => openPlan() },
]);
panel.setBadge("1");
Behavior:
- Panels appear inside
RepoSection, below branches, only when the repo is expanded - Items are rendered as native sidebar list items (same style as branches)
- Right-click on items shows a context menu with plugin-defined actions
- Badge appears as a small counter pill on the section header
- On plugin unload, panels are automatically removed
Tier 3f: Context Menu Actions (capability-gated)
host.registerTerminalAction(action) -> Disposable
Register an action in the terminal right-click “Actions” submenu. Requires "ui:context-menu" capability.
The action handler receives a TerminalActionContext snapshot captured at right-click time (not at click time), avoiding race conditions if the user switches terminals between opening the menu and clicking.
interface TerminalActionContext {
sessionId: string | null; // PTY session ID of the right-clicked terminal
repoPath: string | null; // Repository path that owns the terminal
}
interface TerminalAction {
id: string; // Unique action ID (scoped to plugin)
label: string; // Display label in the menu
action: (ctx: TerminalActionContext) => void; // Handler
disabled?: (ctx: TerminalActionContext) => boolean; // Evaluated at menu-open time
}
Example:
const d = host.registerTerminalAction({
id: "restart-agent",
label: "Restart Agent",
action: (ctx) => {
if (ctx.sessionId) host.sendAgentInput(ctx.sessionId, "exit");
},
disabled: (ctx) => !ctx.sessionId,
});
Behavior:
- Actions from all plugins are shown in a flat list under the “Actions” submenu
- The submenu is hidden when no actions are registered
disabledcallback is re-evaluated each time the context menu opens- On plugin unload, actions are automatically removed; stale handler references are no-ops
host.registerContextMenuAction(action) -> Disposable
Register an action in context menus for a specific target type. Requires "ui:context-menu" capability.
type ContextMenuTarget = "terminal" | "branch" | "repo" | "tab";
interface ContextMenuAction {
id: string;
label: string;
icon?: string; // Inline SVG
target: ContextMenuTarget;
action: (ctx: ContextMenuContext) => void;
disabled?: (ctx: ContextMenuContext) => boolean;
}
interface ContextMenuContext {
target: ContextMenuTarget;
sessionId?: string; // terminal, tab
repoPath?: string; // branch, repo, terminal
branchName?: string; // branch only
tabId?: string; // tab only
}
Example:
host.registerContextMenuAction({
id: "deploy",
label: "Deploy Branch",
target: "branch",
action: (ctx) => {
if (ctx.branchName) deploy(ctx.repoPath, ctx.branchName);
},
});
Behavior:
- Actions appear after built-in items, separated by a divider
disabledcallback is re-evaluated each time the context menu opens- On plugin unload, actions are automatically removed
Tier 3g: Credential Access (capability-gated)
host.readCredential(serviceName) -> Promise<string | null>
Read credentials from the system credential store by service name. Returns the raw credential JSON string, or null if not found. Requires "credentials:read" capability.
First call from an external plugin shows a user consent dialog. Built-in plugins skip the dialog.
const credJson = await host.readCredential("Claude Code-credentials");
if (credJson) {
const creds = JSON.parse(credJson);
const token = creds.claudeAiOauth.accessToken;
}
Platforms:
- macOS: Reads from Keychain (
security find-generic-password -s <service> -w) - Linux/Windows: Reads from
~/.claude/.credentials.json
Tier 3h: HTTP Requests (capability-gated)
host.httpFetch(url, options?) -> Promise<HttpResponse>
Make an HTTP request. Non-2xx status codes are returned normally (not thrown as errors). Requires "net:http" capability.
Plugins can only fetch URLs matching their manifest’s allowedUrls patterns. A manifest declaring net:http with empty or omitted allowedUrls is rejected. An empty list never grants unrestricted access, including localhost or private destinations.
const resp = await host.httpFetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "value" }),
});
if (resp.status === 200) {
const data = JSON.parse(resp.body);
}
HttpResponse:
interface HttpResponse {
status: number;
headers: Record<string, string>;
body: string;
}
Security and limits:
file://,data://,ftp://schemes are blocked- 30-second timeout, 10 MB response limit, max 5 redirects
- Localhost (
localhost,127.0.0.1,::1,[::1],0.0.0.0) is blocked unless explicitly declared inallowedUrls - Every HTTP request must match a declared pattern; an empty list permits no requests
allowedUrls pattern matching:
- Patterns use prefix matching with an optional trailing
*wildcard "https://api.example.com/*"— matches any path under that origin"https://api.example.com/v2/data"— matches that exact URL only"http://localhost:8080/*"— allows localhost on that port (required to unblock localhost)- The URL must start with the pattern prefix (before
*) to match
Tier 3i: File Tail (capability-gated)
host.readFileTail(absolutePath, maxBytes) -> Promise<string>
Read the last N bytes of a file, skipping any partial first line. Useful for reading recent entries from large JSONL files. Requires "fs:read" capability.
const tail = await host.readFileTail("/Users/me/.claude/hud-tracking.jsonl", 512 * 1024);
const lines = tail.split("\n").filter(Boolean);
Tier 3j: CLI Execution (capability-gated)
host.execCli(binary, args, cwd?) -> Promise<string>
Execute a CLI binary declared in the plugin’s manifest and return its stdout. Requires "exec:cli" capability.
Only binaries listed in the manifest’s binaries field can be executed. The on-disk manifest is the source of truth — the frontend cannot grant access to undeclared binaries.
// manifest.json
{ "capabilities": ["exec:cli"], "binaries": ["mdkb"] }
const raw = await host.execCli("mdkb", ["--format", "json", "status"], "/Users/me/project");
const status = JSON.parse(raw);
console.log(status.index.documents); // 1486
Security and limits:
- Only binaries declared in the plugin’s
binariesmanifest field can be executed - Working directory must be absolute and within
$HOME - 30-second timeout
- 5 MiB limit per stdout/stderr stream; both streams drain concurrently. Overflow or read failure stops the process with an error.
- Binary is resolved via PATH lookup and known install locations (
~/.cargo/bin/,/usr/local/bin/, etc.)
Tier 4: Scoped Tauri Invoke (whitelisted commands only)
host.invoke<T>(cmd, args?) -> Promise<T>
Invokes a whitelisted Tauri command. Non-whitelisted commands throw immediately.
Whitelisted commands:
| Command | Args | Returns | Capability |
|---|---|---|---|
read_file | { path: string, file: string } | string | invoke:read_file |
list_markdown_files | { path: string } | Array<{ path, git_status }> | invoke:list_markdown_files |
read_plugin_data | { plugin_id: string, path: string } | string | none (always allowed) |
write_plugin_data | { plugin_id: string, path: string, content: string } | void | none (always allowed) |
delete_plugin_data | { plugin_id: string, path: string } | void | none (always allowed) |
get_input_buffer_content | { sessionId: string } | string | pty:read |
Plugin data storage is sandboxed to ~/.config/com.tuic.commander/plugins/{id}/data/. No capability required — every plugin can store its own data.
// Store cache data
await host.invoke("write_plugin_data", {
plugin_id: "my-plugin",
path: "cache.json",
content: JSON.stringify({ lastCheck: Date.now() }),
});
// Read it back
const raw = await host.invoke("read_plugin_data", {
plugin_id: "my-plugin",
path: "cache.json",
});
const cache = JSON.parse(raw);
Capabilities
What capabilities are, and what they are not. Capabilities gate what a plugin declares — a plugin that never asked for
exec:clicannot reach the exec commands under its own id. They do not gate what a plugin can impersonate. Plugins load with a bare dynamicimport()into the same JavaScript realm as the host, andplugin_idis caller-supplied — it is the only key the Rust capability checks consult. Any plugin can therefore importinvokeitself and pass another plugin’s id to inherit that plugin’s exec, credential and allowed-URL grants.Plugins are isolated from the host’s declared surface, not from each other. Treat every installed plugin as trusted with the union of all installed capabilities, and vet what you install. This matches TUICommander’s threat model — a local tool where the human user is the trust boundary.
A per-plugin token was considered and deliberately rejected: same-realm JavaScript can read it out of the module that holds it, proxy the function that uses it, or monkey-patch the transport, so it would be a boundary in name only. Real isolation requires running each plugin off-realm (a Worker or sandboxed iframe) with a host-created
MessagePortas its only channel to the backend, so a plugin’s identity is the port it was handed rather than a string it supplies. That is tracked as future work, not a gap being quietly ignored.
Capabilities gate access to Tier 3 and Tier 4 methods. Declare them in manifest.json:
{
"capabilities": ["pty:write", "ui:sound"]
}
| Capability | Unlocks | Risk |
|---|---|---|
pty:write | host.writePty(), host.sendAgentInput() | Can send input to terminals |
pty:read | host.invoke("get_input_buffer_content", …) | Can read the terminal input line buffer |
ui:markdown | host.openMarkdownPanel(), host.openMarkdownFile(), host.openMarkdownFileBackground() | Can open panels and files in the UI |
ui:sound | host.playNotificationSound(sound?) | Can play sounds (question, error, completion, warning, info) |
ui:panel | host.openPanel() | Can render arbitrary HTML in sandboxed iframe |
ui:ticker | host.setTicker(), host.clearTicker() | Can post messages to the shared status bar ticker |
credentials:read | host.readCredential() | Can read system credentials (consent dialog shown) |
net:http | host.httpFetch() | Requires a non-empty allowedUrls list; requests must match a declared pattern |
invoke:read_file | host.invoke("read_file", ...) | Can read files on disk |
invoke:list_markdown_files | host.invoke("list_markdown_files", ...) | Can list directory contents |
fs:read | host.readFile(), host.readFiles(), host.readFileBase64(), host.readFileTail() | Can read files within $HOME (10 MiB default; binary reads may request up to the 512 MiB host ceiling) |
fs:list | host.listDirectory() | Can list directory contents within $HOME |
fs:watch | host.watchPath() | Can watch filesystem paths within $HOME for changes |
fs:write | host.writeFile(), host.writeFileBase64() | Can atomically write files within $HOME (10 MiB default; binary writes may request up to the 512 MiB host ceiling) |
fs:rename | host.renamePath() | Can rename/move files within $HOME |
fs:scan | host.scanBuildArtifacts() | Can recursively scan registered repos for build-artifact directories (read-only; ignores .gitignore) |
fs:delete | host.deleteBuildArtifact(), host.trimBuildArtifact() | Can delete a build-artifact directory inside a registered repo (guarded remove_dir_all), or remove just its intermediates |
exec:cli | host.execCli() | Can execute CLI binaries declared in manifest binaries field |
git:read | host.getGitBranches(), host.getRecentCommits(), host.getGitDiff() | Read-only access to git repository state |
ui:context-menu | host.registerTerminalAction() | Can add actions to the terminal right-click “Actions” submenu |
ui:sidebar | host.registerSidebarPanel() | Can register collapsible panel sections in the sidebar |
ui:file-icons | host.registerFileIconProvider() | Can provide file/folder icons for the file browser (e.g. VS Code icon themes) |
ui:file-preview | host.registerFilePreview() | Can claim file extensions and provide custom preview UIs |
Tier 1, Tier 2, and plugin data commands are always available without capabilities.
Agent-Scoped Plugins
Plugins can declare which AI agents they target via the agentTypes manifest field:
{
"id": "claude-usage",
"agentTypes": ["claude"],
...
}
Behavior
- Universal plugins (
agentTypesomitted or[]): receive events from all terminals. This is the default. - Agent-scoped plugins (
agentTypes: ["claude"]): output watchers and structured event handlers only fire for terminals where the detected foreground process matches one of the listed agent types.
What gets filtered
| Dispatch method | Filtered by agentTypes |
|---|---|
registerOutputWatcher callbacks | Yes |
registerStructuredEventHandler callbacks | Yes |
| All other PluginHost methods (Tier 1-4) | No — always available |
How agent detection works
TUICommander polls the foreground process of each terminal’s PTY every 3 seconds (via get_session_foreground_process). The process name is classified into an agent type:
| Process name | Agent type |
|---|---|
claude | "claude" |
gemini | "gemini" |
opencode | "opencode" |
aider | "aider" |
codex | "codex" |
amp | "amp" |
cursor-agent | "cursor" |
goose | "goose" |
droid | "droid" |
git | "git" |
| (anything else) | null (plain shell) |
Timing considerations
Agent detection is polled, not instant. When a user launches claude in a terminal, there is a brief window (up to 3 seconds) before the first poll detects it. During this window, agent-scoped plugins will not receive events from that terminal. This is by design — it avoids false matches during shell startup.
Example: Claude-only plugin
{
"id": "claude-usage",
"name": "Claude Usage Dashboard",
"version": "1.0.0",
"minAppVersion": "0.3.0",
"main": "main.js",
"agentTypes": ["claude"],
"capabilities": ["fs:read", "ui:panel", "ui:ticker"]
}
This plugin’s output watchers will only fire when the terminal is running Claude Code. If the user switches to a plain shell or runs Gemini, the watchers are silently skipped.
Example: Multi-agent plugin
{
"agentTypes": ["claude", "gemini", "codex"]
}
Targets Claude, Gemini, and Codex terminals. All other terminals are ignored.
Content URI Format
scheme:path?key=value&key2=value2
Examples:
plan:file?path=%2Frepo%2Fplans%2Ffoo.mdstories:detail?id=324-9b46&dir=%2Frepo%2Fstories
Icons
All icons must be monochrome inline SVGs with fill="currentColor" and viewBox 0 0 16 16:
const ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="..."/></svg>';
Never use emoji — they render inconsistently across platforms.
Hot Reload
When any file in a plugin directory changes, the app:
- Emits a
plugin-changedevent with the plugin ID - Calls
pluginRegistry.unregister(id)(runsonunload, disposes all registrations) - Re-imports the module with a cache-busting query (
?t=timestamp) - Validates and re-registers the plugin
This means you can edit main.js and see changes without restarting the app.
Build & Install
External plugins must be pre-compiled ES modules. Use esbuild:
esbuild src/main.ts --bundle --format=esm --outfile=main.js --external:nothing
Install by copying the directory to:
- macOS:
~/Library/Application Support/com.tuic.commander/plugins/my-plugin/ - Linux:
~/.config/com.tuic.commander/plugins/my-plugin/ - Windows:
%APPDATA%/com.tuic.commander/plugins/my-plugin/
Directory structure:
my-plugin/
manifest.json
main.js
Core-rendered plugin UX
Core renders the following Solid components. Plugins supply manifest metadata and host registrations; they do not import or mount these components.
Manifest UX field inventory
agentTypes, allowedUrls, author, binaries, capabilities, description, id, main, minAppVersion, name, version.
Core UX component inventory
BrowseRow, PluginLogViewer, PluginRow, PluginsTab.
PluginsTab
Settings > Plugins has Installed and Browse views. Installed lists loaded/discovered plugin state. Browse reads community registry entries and offers install, update and refresh actions. Installation accepts ZIP archives. The manifest id identifies the package, main selects its module, and minAppVersion controls compatibility. author is optional metadata. allowedUrls, agentTypes and binaries drive host access/event routing, not editable settings controls.
PluginRow
The Installed row shows manifest name (falls back to id), version, description and capabilities. It shows load errors, error counts and available updates. Its toggle persists enabled state; Logs expands PluginLogViewer; external packages can be uninstalled. A README.md adds a documentation button. host.registerDashboard({ label?, icon?, open }) adds a dashboard button: core closes Settings before calling open. Register only one dashboard per plugin. host.registerCommand({ id, title, defaultShortcut?, run }) adds a rebindable entry under Settings > Keyboard Shortcuts > Plugin Commands.
PluginLogViewer
The expanded Logs view shows the plugin ring buffer’s timestamp, level and message, or an empty state. Use host.log(level, message, data?); host boundary errors are captured automatically.
BrowseRow
The Browse row shows registry name, version, description, author, tags and minimum app version. It offers Install for an absent package, Update when a newer version is available, or Installed for the current version. Registry metadata is separate from the installed manifest.
Settings schema and Tasks views
The current PluginManifest has no settings schema, field-kind union or generic Tasks-view declaration. Core does not render schema-driven plugin settings or generic plugin Tasks views. Do not invent manifest fields or import core Settings components. Plugins can open their own UI with host.openPanel or host.openMarkdownPanel. When a settings schema or Tasks-view API is added, document its fields, kinds and core-rendered controls here and extend the source-derived UX drift check to its real schema/renderer. The manifest field and host method inventories fail on new API entries until the guide is updated.
Plugin Management (Settings > Plugins)
The Settings panel has a Plugins tab with two sub-tabs:
Installed
- Lists external plugins with toggle, logs, and uninstall buttons; native agent usage is managed in Settings > Agents
- The UI retains a legacy “Built-in” badge path, but no built-in plugin packages are registered by the current startup code
- Error count badges appear on plugins with recent errors
- “Logs” button opens an expandable log viewer showing the plugin’s ring buffer
- “Install from file…” button opens a file dialog accepting
.ziparchives
Browse
- Shows plugins from the community registry (fetched from GitHub)
- “Install” button downloads and installs directly
- “Update available” badge when a newer version exists
- “Refresh” button forces a new registry fetch (normally cached for 1 hour)
Enable/Disable
Plugin enabled state is persisted in AppConfig.disabled_plugin_ids. Disabled plugins appear in the Installed list but are not loaded.
ZIP Plugin Installation
Plugins can be distributed as ZIP archives:
- From Settings: Click “Install from file…” in the Plugins tab
- From URL: Use
tuic://install-plugin?url=https://example.com/plugin.zip - From Rust:
invoke("install_plugin_from_zip", { path })orinvoke("install_plugin_from_url", { url })
ZIP requirements:
- Must contain a valid
manifest.json(at root or in a single top-level directory) - All paths are validated for zip-slip attacks (no
..traversal) - If updating an existing plugin, the
data/directory is preserved
Deep Link Scheme (tuic://)
TUICommander registers the tuic:// URL scheme for external integration:
| URL | Action |
|---|---|
tuic://install-plugin?url=https://... | Download ZIP, show confirmation, install |
tuic://open-repo?path=/path/to/repo | Switch to repo (must already be in sidebar) |
tuic://settings?tab=plugins | Open Settings to a specific tab |
tuic://open/<path> | Open markdown file in tab (iframe SDK only) |
tuic://terminal?repo=<path> | Open terminal in repo (iframe SDK only) |
Security: install-plugin requires HTTPS URLs and shows a confirmation dialog. open-repo only accepts paths already in the repository list. open and terminal validate paths against known repos (available only inside plugin iframes via the TUIC SDK, not as OS-level deep links).
Plugin Registry
The registry is a JSON file hosted on GitHub (sstraus/tuicommander-plugins repo). The app fetches it on demand (Browse tab) with a 1-hour TTL cache.
Registry entries include: id, name, description, author, latestVersion, minAppVersion, capabilities, downloadUrl.
The Browse tab compares installed versions to detect available updates.
Per-Plugin Error Logging
Each plugin has a dedicated ring buffer logger (500 entries max). Errors from onload, onunload, output watchers, structured event handlers, and PluginHost-registered UI callbacks are automatically captured.
Plugins can also write to their log via host.log(level, message, data).
View logs in Settings > Plugins > click “Logs” on any plugin row.
Preinstalled External Plugins
Plan Tracker (plan) and Stories Ticker (stories-ticker) are external packages installed into the normal plugin directory by a one-time migration. They use only PluginHost, declare capabilities in their manifests, and can be disabled, uninstalled, or updated like any other external plugin. The migration marker prevents an explicitly uninstalled package from being recreated on later launches.
External packages are desktop-only. Browser/PWA mode does not import plugin code from the desktop configuration directory.
Note: Session prompt tracking is now a native Rust feature (via
input_line_buffer.rsand the Activity Dashboard). The formersessionPromptPluginbuilt-in has been removed.
See examples/plugins/report-watcher/ for a template showing how to extract terminal output into Activity Center items with a markdown viewer.
Create new plugins as external packages with a manifest and ES module entry point. src/plugins/index.ts initializes native agent usage and loads external packages; it has no BUILTIN_PLUGINS list.
Testing
Mock setup for plugin tests
import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("../../invoke", () => ({
invoke: vi.fn(),
listen: vi.fn().mockResolvedValue(() => {}),
}));
import { invoke } from "../../invoke";
import { pluginRegistry } from "../../plugins/pluginRegistry";
import { activityStore } from "../../stores/activityStore";
import { markdownProviderRegistry } from "../../plugins/markdownProviderRegistry";
beforeEach(() => {
pluginRegistry.clear();
activityStore.clearAll();
markdownProviderRegistry.clear();
vi.mocked(invoke).mockReset();
});
Testing output watchers
Rust assembles the lines, so a test pushes an assembled line rather than raw PTY text:
it("detects deployment from PTY output", async () => {
pluginRegistry.register(myPlugin);
pluginRegistry.handleWatcherLines("session-1", [
{ text: "Deployed: api-server to prod", matched_ids: [] },
]);
// onMatch is deferred with queueMicrotask
await new Promise<void>((resolve) => queueMicrotask(resolve));
const items = activityStore.getForSection("my-section");
expect(items).toHaveLength(1);
expect(items[0].title).toBe("api-server");
});
registerOutputWatcher also calls set_plugin_output_watchers, so the invoke mock must
return a promise. Until it resolves to { applied: true, rejected: [] }, the registry
treats every watcher as its own and scans the line itself — which is what the example
above exercises with an empty matched_ids.
To test the Rust path instead, resolve the sync with { applied: true, rejected: [] } and
put the qualified id (<clientId>/<watcherId>, both read from the invoke payload) in
matched_ids. A watcher fires once per line either way: an id Rust flagged is skipped
by the frontend scan that follows. A reply with applied: false is ignored, because the
set it describes is not the set the backend holds.
Testing capability gating
it("external plugin without pty:write throws on sendAgentInput", async () => {
let host;
pluginRegistry.register(
{ id: "ext", onload: (h) => { host = h; }, onunload: () => {} },
[], // no capabilities
);
await expect(host.sendAgentInput("s1", "hello")).rejects.toThrow(PluginCapabilityError);
});
CSS Classes
Activity items use these CSS classes (defined in src/styles.css):
| Class | Element |
|---|---|
activity-section-header | Section heading row |
activity-section-label | Section label text |
activity-dismiss-all | “Dismiss All” button |
activity-item | Individual item row |
activity-item-icon | Item icon container |
activity-item-body | Title + subtitle wrapper |
activity-item-title | Primary text |
activity-item-subtitle | Secondary text |
activity-item-dismiss | Dismiss button |
activity-last-item-btn | Shortcut button in toolbar |
activity-last-item-icon | Shortcut button icon |
activity-last-item-title | Shortcut button text |
Structured Event Types
The Rust OutputParser detects patterns in terminal output and emits typed events. Handle them with host.registerStructuredEventHandler(type, handler).
plan-file
Detected when a plan file path appears in terminal output. The emitted path is relative or absolute, as it appeared in the output — the parser does NOT resolve relative paths to absolute:
- Relative paths (e.g.
plans/foo.md,.claude/plans/bar.md) are emitted unchanged - Tilde paths (
~/.claude/plans/bar.md) are the only ones rewritten —~is expanded to the user’s home directory - Already-absolute paths are passed through unchanged
Relative-to-absolute resolution (against the terminal session’s CWD via host.getSessionCwd) happens later, in the external Plan Tracker plugin; plans whose CWD cannot be determined are skipped.
{ type: "plan-file", path: string }
// path: relative or absolute, e.g. "plans/foo.md", ".claude/plans/bar.md",
// "/Users/me/.claude/plans/graceful-rolling-quasar.md"
Repo scoping: Plan Tracker asks host.openMarkdownFileBackground() to resolve the repository that owns each absolute plan path. Unowned paths are tracked but not opened, preventing cross-repository tab leakage.
rate-limit
Detected when AI API rate limits are hit.
{
type: "rate-limit",
pattern_name: string, // e.g. "claude-http-429", "openai-http-429"
matched_text: string, // the matched substring
retry_after_ms: number | null, // ms to wait (default 60000)
}
Pattern names: claude-http-429, claude-overloaded, openai-http-429, cursor-rate-limit, gemini-resource-exhausted, http-429, retry-after-header, openai-retry-after, openai-tpm-limit, openai-rpm-limit.
status-line
Detected when an AI agent emits a status/progress line.
{
type: "status-line",
task_name: string, // e.g. "Reading files"
full_line: string, // complete line trimmed
time_info: string | null, // e.g. "12s"
token_info: string | null, // e.g. "2.4k tokens"
}
pr-url
Detected when a GitHub/GitLab PR/MR URL appears in output.
{
type: "pr-url",
number: number, // PR/MR number
url: string, // full URL
platform: string, // "github" or "gitlab"
}
progress
Detected from OSC 9;4 terminal progress sequences.
{
type: "progress",
state: number, // 0=remove, 1=normal, 2=error, 3=indeterminate
value: number, // 0-100
}
question
Detected when an interactive prompt appears (Y/N prompts, numbered menus, inquirer-style).
{
type: "question",
prompt_text: string, // the question line (ANSI-stripped)
}
usage-limit
Detected when Claude Code reports usage limits.
{
type: "usage-limit",
percentage: number, // 0-100
limit_type: string, // "weekly" or "session"
}
Example Plugins
See examples/plugins/ for complete working examples:
| Example | Tier | Capabilities | Demonstrates |
|---|---|---|---|
hello-world | 1 | none | Output watcher, addItem |
auto-confirm | 1+3 | pty:write | Auto-responding to Y/N prompts |
ci-notifier | 1+3 | ui:sound, ui:markdown | Sound notifications, markdown panels |
repo-dashboard | 1+2 | none | Read-only state, dynamic markdown |
claude-status | 1 | none | Agent-scoped (agentTypes: ["claude"]), structured events |
telegram-notifier | 1+3 | net:http, ui:panel, ui:ticker | Telegram push notifications, per-event toggles, settings panel |
Distributable Plugins
Available from the plugin registry (submodule at plugins/). Installable via Settings > Plugins > Browse.
| Plugin | Tier | Capabilities | Description |
|---|---|---|---|
mdkb-dashboard | 2+3 | exec:cli, fs:read, ui:panel, ui:ticker | mdkb knowledge base dashboard |
rtk-dashboard | 3 | exec:cli, ui:panel, ui:context-menu | RTK token savings dashboard (binaries: ["rtk"]) |
csv-preview | 3 | ui:file-preview, ui:panel, fs:read | Preview CSV/TSV files as sortable HTML tables |
docx-preview | 3 | ui:file-preview, ui:panel, fs:read | Preview Word .docx/.dotx files as HTML with Mammoth.js |
xlsx-preview | 3 | ui:file-preview, ui:panel, fs:read | Preview Excel .xlsx/.xlsm/.xlsb/.xls and .ods spreadsheets as sortable tables with SheetJS |
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Plugin not loading | manifest.json missing or malformed | Check console for validation errors |
requires app version X.Y.Z | minAppVersion too high | Lower minAppVersion or update app |
not in the invoke whitelist | Calling non-whitelisted Tauri command | Only use commands listed in the whitelist table |
not declared in plugin ... manifest binaries | Binary not in manifest binaries field | Add the binary name to the binaries array in manifest.json |
requires capability "X" | Missing capability in manifest | Add the capability to manifest.json capabilities array |
| Module not found | main field doesn’t match filename | Ensure "main": "main.js" matches your actual file |
| Changes not reflecting | Hot reload cache | Save the file again, or restart the app |
default export error | Module doesn’t export default { ... } | Ensure your module has a default export with id, onload, onunload |