Skip to content

Latest commit

 

History

History
775 lines (581 loc) · 38.3 KB

File metadata and controls

775 lines (581 loc) · 38.3 KB

Plugin Configuration Reference

Quil supports custom pane types via TOML plugin files. This document covers every configuration option, constraint, and behavior so you can create your own plugins.

Overview

Plugins define how panes are spawned, persisted, and restored. Each plugin is a single .toml file placed in the plugins directory.

Item Details
File location ~/.quil/plugins/*.toml (or $QUIL_HOME/plugins/)
Loading On daemon startup; hot reload via F1 → Plugins → Reload
Defaults 4 built-in TOML plugins (claude-code, opencode, ssh, stripe) are written on first run and never overwritten — your edits are preserved across upgrades
Built-in The terminal plugin is defined in Go (not TOML) because it requires runtime shell detection

Quick Start

Save this as ~/.quil/plugins/htop.toml:

[plugin]
name = "htop"
display_name = "System Monitor"
category = "tools"
description = "Interactive process viewer"

[command]
cmd = "htop"

Then press F1 → Plugins → Reload (or restart the daemon). Open with Ctrl+N → Tools → System Monitor.


Plugin Metadata — [plugin]

[plugin]
name = "my-tool"
display_name = "My Tool"
category = "tools"
description = "A short description of what this plugin does"
schema_version = 1
Field Type Required Default Description
name string Yes Unique plugin identifier. Should match the filename (e.g., my-tool.tomlname = "my-tool"). Used internally for pane type tracking.
display_name string No Value of name Human-readable label shown in the Ctrl+N creation dialog and pane borders.
category string No "tools" Groups the plugin in the creation dialog.
description string No "" One-line description shown in plugin lists.
homepage string No "" Project/tool URL. When the plugin's binary isn't found on PATH, the Ctrl+N list shows the entry greyed out with this link (instead of hiding it), so users know where to get the tool.
schema_version int No 0 Plugin TOML schema version. Built-in plugins shipped via defaults/*.toml carry an embedded schema_version; on startup, Quil compares your on-disk file's value with the embedded default and — if yours is older — opens a side-by-side migration dialog so you can merge new defaults without losing edits. Custom plugins: set schema_version = 1 and bump it whenever you publish a breaking schema change so consumers of your plugin get a prompt. Omitting the field is treated as 0 (legacy) and the migration check is skipped.

Categories

Value Label in UI Use for
"terminal" Terminal Shell-based plugins
"ai" AI Assistant AI coding tools, chat interfaces
"tools" Tools Utilities, monitors, webhook listeners
"remote" Remote SSH, remote connections

Categories control display order in the Ctrl+N dialog: Terminal → AI Assistant → Tools → Remote.


Command Configuration — [command]

[command]
cmd = "ssh"
args = ["-o", "ServerAliveInterval=60"]
env = ["TERM=xterm-256color"]
detect = "ssh -V"
arg_template = ["-p", "{port}", "{user}@{host}"]
Field Type Required Default Description
cmd string Yes Binary name or absolute path. Resolved via PATH at runtime (exec.LookPath).
path string No "" Explicit absolute path to the binary. If set, bypasses PATH lookup. Useful for tools installed in non-standard locations or when Quil is launched from Explorer on Windows with an incomplete PATH. Detection order: pathexec.LookPath(cmd)searchBinary fallback (which scans ~/.local/bin everywhere and the User PATH on Windows).
args string[] No [] Default arguments passed every time the plugin is launched. Overridden when instance-specific args are provided.
env string[] No [] Environment variables as KEY=VALUE pairs. Merged into the PTY process environment. For claude-code and opencode panes Quil also injects QUIL_PANE_ID=<paneID> (and QUIL_HOME=<dir> plus OPENCODE_CONFIG_CONTENT=… for opencode) so their respective session-id hooks can attribute writes to a specific pane; custom plugins that wrap those tools can read these from their child process and assume they are set.
detect string No Value of cmd Command used to check if the tool is installed. Only the first word is used for PATH lookup (e.g., "ssh -V" checks for ssh).
shell_integration bool No false Reserved for the built-in terminal plugin. Injects OSC 7 directory tracking hooks. Has no effect in user TOML plugins.
arg_template string[] No [] Template arguments with {placeholder} tokens. Expanded from form field values when creating an instance. See Template Expansion.
prompts_cwd bool No false If true, the pane setup dialog asks the user for a working directory when creating the pane. Pre-filled with the active pane's CWD (tracked via OSC 7). Empty input falls back to the daemon's os.Getwd(). See Pane Setup Dialog.
discover string No "" Discovery mode for the setup dialog. "git" replaces the plain directory browser with a git-repo candidate list: the enclosing repo of the active pane's CWD plus one-level sub-repos, capped at 10, with a final "Browse…" row that falls back to the plain browser (only meaningful with prompts_cwd = true). "kube" adds a kube-context pick field: "Default context" (the kubeconfig current-context, no flag) plus the contexts found in KUBECONFIG / ~/.kube/config (current-context marked), and injects --context <name> into the launch args — CWD-independent, so it works with prompts_cwd = false. Unknown values fail plugin load.
raw_keys string[] No [] List of key strings (e.g. ["shift+tab"]) that bypass Quil's global shortcut layer for panes of this type and are forwarded directly to the PTY. Capped at 64 entries. Single-rune printable entries (e.g. ["a"]) shadow ordinary typing for the entire pane and are warned about in the daemon log. The default plugins do not opt in — Tab and Shift+Tab reach the PTY naturally because pane navigation lives on Alt+Arrow. The mechanism is available for custom plugins that need to override some other global shortcut.
record_history bool No false If true, the daemon sets QUIL_RECORD_HISTORY=1 in the pane's PTY env and the plugin's hook producer appends each submitted user prompt to ~/.quil/history/<paneID>.jsonl (capped per-entry, ring-trimmed to the last 200). The active pane's history is browsable via the command_history keybinding (Alt+Shift+I). Only meaningful for plugins that have a hook producer recording prompts — currently the claude-code default. Has no effect for plugins without such a producer.
sessions string No "" Session source for the setup dialog's resume picker. "claude" lists the Claude Code transcripts recorded for the selected working directory (~/.claude/projects/<escaped-cwd>/*.jsonl, newest first, capped at 200) and, when the user picks one, spawns --resume <id> instead of the preassign_id strategy's start_args. Requires prompts_cwd = true — the listing is scoped to the directory chosen in the dialog, so the combination is rejected at plugin load rather than rendering a field that can never list anything. Unknown values fail plugin load too. See Resume Picker.

Form Fields — [[command.form_fields]]

Define user-fillable fields for the instance creation dialog (Ctrl+N → select plugin → fill form).

[[command.form_fields]]
name = "host"
label = "Hostname"
required = true

[[command.form_fields]]
name = "port"
label = "Port"
default = "22"
Field Type Required Default Description
name string Yes Placeholder key used in arg_template expansion. Must be unique within the plugin.
label string No "" Display label shown next to the input field.
required bool No false If true, the form cannot be submitted until this field has a value. Shown with * in the UI.
default string No "" Pre-filled value shown in the input field.

If a plugin defines no form_fields, the instance creation step is skipped — the pane is created directly with the plugin's default args.

Template Expansion

The arg_template field supports {fieldname} placeholders that are replaced with form field values at pane creation time.

Example:

arg_template = ["-p", "{port}", "{user}@{host}"]

With form values port = "2222", user = "admin", host = "example.com":

Result: ["-p", "2222", "admin@example.com"]

These expanded args become the pane's InstanceArgs — they override the plugin's default args and are saved for rerun resume strategy.

Runtime Toggles — [[command.toggles]]

Declare boolean flags shown in the pane setup dialog. Each toggle has args that are appended to the spawn command when the user enables it. Toggle state is per pane, captured at creation time, and persisted across daemon restarts via the pane's InstanceArgs.

By default toggles render as independent checkboxes. Assigning a shared group value to two or more toggles turns them into a mutually-exclusive set — rendered as radio buttons in the dialog, enabling one automatically disables the others in the group. All members of the group may still be OFF (a valid "pick none" state).

# Independent checkbox
[[command.toggles]]
name = "verbose"
label = "Verbose output"
args_when_on = ["-v"]
default = false

# Mutually-exclusive group: selecting one disables the other.
[[command.toggles]]
name = "dangerously_skip_permissions"
label = "Dangerously skip permissions (unattended mode — no confirmations)"
args_when_on = ["--dangerously-skip-permissions"]
default = false
group = "permission_mode"

[[command.toggles]]
name = "enable_auto_mode"
label = "Enable auto mode (safer alternative to skipping permissions)"
args_when_on = ["--enable-auto-mode"]
default = false
group = "permission_mode"
Field Type Required Default Description
name string Yes Stable identifier for the toggle (used for future addressability).
label string Yes Text shown next to the control in the setup dialog.
args_when_on string[] No [] Arguments appended to the spawn command when the user enables this toggle. Combined with any other enabled toggles' args, then appended after InstanceArgs.
default bool No false Initial state in the dialog. Users who always want a toggle on can set this to true. Within a group, if multiple members declare default = true only the last one in declaration order stays on — the invariant is preserved from the first render.
group string No "" Non-empty value marks this toggle as part of a mutually-exclusive radio group. Toggles that share the same group value cannot be enabled simultaneously; enabling one disables the others. Empty string = independent checkbox (default).

If a plugin declares any toggles (or sets prompts_cwd = true), the setup dialog is shown automatically after plugin selection. The dialog is skipped entirely when neither opt-in is set — existing plugins (terminal, ssh, stripe) are unaffected.

Pane Setup Dialog

When a plugin opts in via prompts_cwd = true or [[command.toggles]], Quil inserts a setup step between plugin selection and split-direction selection. The dialog renders:

  1. Working directory text input (only if prompts_cwd = true), pre-filled with the active pane's CWD. Validated on Enter: the path is trimmed, stripped of surrounding quotes (to accept Windows Copy as path output), ~ is expanded, then verified via os.Stat as an existing directory. Empty input is accepted and means "use daemon default".
  2. Kube context pick list (only if discover = "kube").
  3. One checkbox per toggle, in declaration order.
  4. Session picker (only if sessions = "claude"). See Resume Picker.
  5. Continue button.

Navigation: Tab / Shift+Tab / / cycle fields. Space flips the focused checkbox. Enter validates and submits. Esc unwinds — back to the instance form if the plugin also has form_fields, otherwise back to the plugin picker. Ctrl+V pastes into the CWD field.

Resume Picker

With sessions = "claude", the setup dialog gains a Session field below the toggles, just above Continue. Two constraints put it there: it must come after the working directory, because the listing is scoped to the folder selected above it, and it is the only field that expands — keeping it last means focusing it does not shift the fixed-height rows above.

The field is collapsed to a single line while unfocused, showing the current choice (New session by default), so a dialog that already carries a directory browser does not grow for a field most panes never touch. Tab onto it and it expands into a scrolling list:

> Session:
  > New session
      2h ago   Add resume option to claude pane setup dialog
      1d ago   fix(update): release only our own apply lock
      3d ago   I would like to add more mouse controls. For e…
    2/22  ↑↓ PgUp/PgDn move  Enter select  i details

Each row shows a relative age and a title. The title is the first prompt you typed in that session, read from the head of the transcript; a transcript with no typed prompt in the scanned window falls back to its short session id.

Row 0 is always New session and is pre-selected, so a user who never touches the field gets the previous behavior exactly.

Sessions already open in another live pane are listed but blocked. They render greyed with an [open in 2.Claude] marker, and Enter is refused — two claude processes attached to one transcript overwrite each other's history. The cursor can still land on the row so the footer can explain why it is blocked, matching how the Ctrl+N plugin list treats an uninstalled binary.

Changing the working directory discards the selection and rescans: a session recorded under another project is not a meaningful resume target.

The listing is fetched from the daemon on first focus, not when the dialog opens — creating a pane with a fresh session performs no session I/O at all. If the daemon does not answer within 3 seconds the field says so rather than sitting on Scanning…, and New session stays selectable through every failure mode.

Session details (i)

A row's age and title are often not enough to tell two sessions apart. Press i on a highlighted session and the list is replaced in place by a detail panel:

> Session:
    2db05609-f1d5-4576-b2a1-9e0c3a7f1188
    Started    2026-07-24 09:12  (2d ago)
    Last used  2026-07-26 08:41  (36m ago)
    Prompts    47 typed · 1.8 MB

    First  we are working with sqube gateway - UI redesign. I
           will need to rework the panel layout so the candidate
           list keeps its filters when you switch tabs, and the…
    Last   check why the deploy job is flaking on the arm64
           runner

Last is the point of the panel — it is the prompt you left the conversation on, and it appears nowhere else. First is capped at three lines because it is already the row's title in the list.

i or Esc returns to the list; Enter selects the session as usual. The panel is a mode you browse in: / move to another session and re-read for it, so comparing candidates does not mean toggling the panel per row.

The read happens on demand, once per session you look at, and streams the whole transcript because the last prompt is only knowable at the end. It stays affordable on a keypress by rejecting each line with a byte comparison before any JSON parsing — the largest transcript in the author's ~/.claude is 88 MB and reads in about a second. Only prompts you typed are counted: Claude records a tool result as a user entry too, so counting raw user entries reports hundreds for a conversation with a few dozen real prompts.

Picking a session spawns claude --resume <id> in place of start_args. Runtime toggles still compose: --dangerously-skip-permissions, --enable-auto-mode, and --chrome are appended exactly as they are for a fresh session. Once Claude boots, its SessionStart hook records the live session id, so restore after a daemon restart works through the existing machinery with no special-casing.


Persistence Configuration — [persistence]

Controls how panes survive daemon restarts.

[persistence]
strategy = "rerun"
ghost_buffer = true
start_args = ["--session-id", "{session_id}"]
resume_args = ["--resume", "{session_id}"]
Field Type Required Default Description
strategy string No "" (none) Resume mechanism. See Strategy Reference.
ghost_buffer bool No true If true, PTY output is saved to disk and replayed on reconnect (shows previous terminal content with a dimmed "restored" label). Set to false for TUI apps that manage their own display — but measure first: "full-screen app" is not the test. A program that writes to the main screen and scrolls normally (so it is scrollable while attached) replays into coherent history, and false throws that history away on every reattach. claude-code ships true for exactly this reason. false is for programs that own the alternate screen.
redraw_key string No "" Byte(s) written to the pane's stdin when a client attaches and the pane had no ghost replay to send. Only meaningful with ghost_buffer = false, which is what leaves a reconnecting client with a blank rectangle in front of a live process. See below before setting it.

redraw_key — when to set it, and when not to

With ghost_buffer = false there is nothing to replay on reconnect, so the pane renders blank until the program next writes something — and a full-screen program has no reason to do that unprompted. The pane looks dead while being perfectly healthy.

Leaving it unset does not mean nothing happens. A pane with no redraw_key is given a 1-column resize instead, so its program repaints via SIGWINCH. That is the default because a signal cannot be misread: it needs no opt-in and is safe for every pane.

Setting redraw_key therefore means "my program ignores SIGWINCH — send this instead", and it suppresses the resize. Only set it if that is true.

A key is input, not a signal. It lands in the child's stdin, so only set it when you know both that the program treats the byte as "redraw" and that nothing else is reading its stdin as data. A pane that might be sitting in cat > file or at a password prompt must leave it unset — there, a stray \f is silent data corruption rather than a repaint.

Neither trigger is universal, and measurements are counter-intuitive in both directions. On a real PTY:

Program 1-column resize (SIGWINCH) Ctrl+L (\f)
vim ~5 KB repaint
claude-code 0 bytes ~3.8 KB repaint
opencode ~8 KB repaint 0 bytes

claude-code re-lays-out on a resize but only paints on its own render tick, which input drives — so it needs the key. opencode is the exact reverse, so adding redraw_key = "\f" to it would replace a working mechanism with a no-op. That is not hypothetical: it is how opencode and lazygit panes came to render blank on every reattach.

Measure before declaring. Spawn the program on a PTY, let it settle, confirm it is idle, write the candidate byte, and check that output follows. If it does not, leave the field unset and let the resize do the work.

[persistence]
ghost_buffer = false
redraw_key = "\f"   # Ctrl+L — measured to repaint claude-code, which ignores SIGWINCH

| start_args | string[] | No | [] | Template arguments appended on fresh pane creation. {key} placeholders expanded from plugin state (e.g., generated UUIDs). Used with preassign_id strategy. | | resume_args | string[] | No | [] | Template arguments used when restoring a pane after daemon restart. {key} placeholders expanded from previously scraped state. If any placeholder cannot be resolved, the pane starts fresh instead. |

Scrape Patterns — [[persistence.scrape]]

Extract state values from PTY output using regex patterns. Scraped values are persisted to disk and available for resume arg expansion.

[[persistence.scrape]]
name = "session_id"
pattern = 'Session ID: ([a-f0-9-]+)'
Field Type Required Default Description
name string Yes Key name for the scraped value (e.g., "session_id"). Used as {name} in start_args/resume_args templates.
pattern string Yes Go regex pattern with exactly one capture group. The first submatch is stored as the value. Matched continuously against all PTY output.

How scraping works at runtime:

  1. Every chunk of PTY output is tested against all scrape patterns
  2. When a pattern matches, the captured value is stored in the pane's plugin state
  3. Plugin state is persisted to workspace.json on each snapshot
  4. On daemon restart, scraped values are loaded and available for resume_args expansion

Regex tips:

  • Use raw TOML strings ('...') to avoid escaping backslashes
  • Only the first capture group is used — keep patterns simple
  • Invalid patterns are logged at startup but don't prevent the plugin from loading
  • Patterns are pre-compiled for performance (not re-compiled on each match)

Strategy Reference

Strategy On fresh start On restore after daemon restart Best for
"" / "none" Start cmd + args Start cmd + args (no state) Stateless tools, system monitors
"cwd_only" Start in current directory Restore saved CWD, start fresh shell Terminal shells (built-in only)
"rerun" Start cmd + instanceArgs Rerun cmd + saved instanceArgs SSH connections, Stripe listeners
"preassign_id" Generate UUID, store in plugin state, expand start_args Expand resume_args using saved state AI tools (Claude Code)
"session_scrape" Start normally Expand resume_args from scraped output values Tools that emit session tokens

Strategy details:

"none" / "" — No persistence. The pane is recreated from scratch on every daemon restart. Suitable for disposable tools like system monitors.

"cwd_only" — Reserved for the built-in terminal plugin. Saves the current working directory (tracked via OSC 7 shell integration) and restores it on respawn. The shell itself starts fresh.

"rerun" — The simplest persistence for interactive tools. On restore, the exact same command and arguments are re-executed. The user's saved instance args (from the form) are preserved. Example: an SSH connection to admin@prod-server:2222 is re-established automatically.

"preassign_id" — For tools that support session IDs. On first launch:

  1. Quil generates a UUID
  2. Stores it as session_id in plugin state
  3. Expands start_args (e.g., ["--session-id", "{session_id}"])
  4. Appends expanded args to the command

On restore:

  1. Loads saved session_id from disk
  2. Expands resume_args (e.g., ["--resume", "{session_id}"])
  3. Uses expanded args instead of start_args

"session_scrape" — For tools that emit session tokens in their output. Scrape patterns continuously match PTY output and store extracted values. On restore, resume_args are expanded with the scraped values. If scraping never captured the required values, the pane starts fresh.


Display Configuration — [display]

[display]
border_color = "blue"
dialog_width = 60
wide_canvas = false
Field Type Required Default Description
border_color string No "" Lipgloss terminal color for the pane border (e.g., "blue", "cyan", "#ff5733").
dialog_width int No 50 Width (in characters) of the instance creation form dialog. Increase for plugins with long field labels.
wide_canvas bool No false Keep the pane's terminal sized to the full window regardless of its layout rect. Small panes show a preview of the wide buffer — left-edge crop by default, soft-wrap via toggle_wrap (Alt+Shift+W); zoom (Ctrl+E) shows the native render instantly. Built for AI tools (claude-code, opencode ship with true) whose transcript is immutable hard-wrapped text — rendering wide once beats resizing, which garbles history. Not for full-screen TUIs (k9s, lazygit): a cropped/wrapped altscreen app is unusable.
  • min_native_cols (int, default 80) — only meaningful with wide_canvas = true. Inner-width threshold in columns: when a pane's usable width is this value it renders natively (its PTY is sized to the pane, Claude wraps at the real width, text selection works directly). Below it, the pane keeps the window-sized canvas and renders a cropped/soft-wrapped preview. Lower it to favor native rendering in tighter splits; raise it to keep more splits on the preview. 0 uses the built-in default (80).

Pre-configured Instances — [[instances]]

Ship the plugin with ready-to-use configurations. Users see these as selectable options in the Ctrl+N dialog.

[[instances]]
name = "staging"
display_name = "Staging Server"
args = ["-p", "2222", "deploy@staging.example.com"]

[[instances]]
name = "production"
display_name = "Production"
args = ["admin@prod.example.com"]
env = ["SSH_AUTH_SOCK=/run/ssh-agent.sock"]
Field Type Required Default Description
name string Yes Unique identifier for this instance within the plugin.
display_name string No "" Human-readable label shown in the instance selection list.
args string[] No [] Instance-specific command arguments. Override the plugin's default args.
env string[] No [] Instance-specific environment variables (KEY=VALUE).

Note: Users can also create and save instances at runtime via the Ctrl+N form. These are stored in ~/.quil/instances.json and appear alongside TOML-defined instances.


Error Handlers — [[error_handlers]]

Match PTY output against regex patterns to show help dialogs or log errors.

[[error_handlers]]
pattern = 'Permission denied \(publickey'
title = "SSH Authentication Failed"
message = """
SSH key not configured for {host}.

1. Generate: ssh-keygen -t ed25519
2. Copy key: ssh-copy-id {user}@{host}
3. Retry the connection"""
action = "dialog"

[[error_handlers]]
pattern = "Connection refused|No route to host"
title = "Connection Failed"
message = "Cannot reach {host}. Check that the server is running."
action = "dialog"
Field Type Required Default Description
pattern string Yes Go regex matched against PTY output. Use | alternation for multiple patterns. Use raw TOML strings ('...') to avoid double-escaping.
title string No "" Title shown in the error dialog.
message string No "" Body text of the error dialog. Supports {host}, {user}, {port} placeholder expansion from the pane's instance args. Use TOML multi-line strings ("""...""") for longer messages.
action string No "log" What to do when the pattern matches.

Actions

Value Behavior
"dialog" Show a modal error dialog in the TUI with title and message.
"log" Log the match to the daemon log file (~/.quil/quild.log). No visible UI.

Invalid action values are logged as a warning and treated as "log".

Message Placeholders

Error messages support these placeholders, extracted from the pane's instance args:

Placeholder Source Example
{host} Hostname from user@host pattern in args example.com
{user} Username from user@host pattern in args admin
{port} Port from -p <port> flag or host:port in args 2222

Notification Handlers — [[notification_handlers]]

Push events to Quil's notification center (M12) when a pattern matches PTY output. Unlike error handlers — which fire a modal dialog — notification handlers add an entry to the non-modal sidebar (Alt+N to toggle, F3 to focus).

[[notification_handlers]]
pattern = '✔ Build successful'
title = "Build OK"
severity = "info"

[[notification_handlers]]
pattern = '✗ Build failed|Error: '
title = "Build failed"
severity = "error"
Field Type Required Default Description
pattern string Yes Go regex matched against PTY output. Same compilation rules as error handlers.
title string No "" Title shown in the notification sidebar entry.
severity string No "info" One of "info", "warning", "error". Drives the colour of the pane name in the sidebar. Unknown values are warned about and downgraded to "info".

Idle Handlers — [[idle_handlers]]

Context-aware idle detection: when a pane has produced no PTY output for ~5 seconds, the daemon strips ANSI from the last 4 KB of the pane's ring buffer and matches it against this plugin's idle patterns. A match emits a notification (same path as notification_handlers). 30-second cooldown per pane prevents floods.

[[idle_handlers]]
pattern = '\? .*\(Y/n\)'
title = "Waiting for confirmation"
severity = "warning"

[[idle_handlers]]
pattern = 'Password:|password:'
title = "Password prompt"
severity = "warning"
Field Type Required Default Description
pattern string Yes Go regex matched against the ANSI-stripped tail (last 4 KB) of the pane's output buffer.
title string No "" Title shown in the notification sidebar.
severity string No "info" Same severity values as notification handlers.

The default plugins ship with sensible idle handlers — terminal detects shell prompts that look like questions, claude-code detects "Press Enter to continue" prompts, ssh detects sudo password prompts.


Cross-Platform Notes

TOML plugin files are fully portable — the same .toml works on Linux, macOS, and Windows without modification.

Aspect Behavior
Binary resolution cmd and detect are resolved via exec.LookPath at runtime. Use the binary name (e.g., "ssh"), not an absolute path.
Windows .exe Not needed — Go automatically appends .exe during PATH lookup on Windows.
Detection detect runs the first word through PATH lookup (e.g., "ssh -V" checks if ssh is on PATH).
Environment vars env entries use KEY=VALUE format on all platforms.
PTY Unix uses creack/pty, Windows uses ConPTY. Transparent to plugins.
Shell integration Reserved for the built-in terminal plugin. Handles bash, zsh, PowerShell, and fish automatically.

Validation Rules

These rules are enforced when loading a TOML plugin file:

Rule Behavior on violation
name is required Plugin file skipped with error log
cmd is required Plugin file skipped with error log
strategy must be a valid value Plugin file skipped with error log
Invalid regex in pattern Pattern skipped with warning log; plugin still loads
Invalid action value Defaults to "log" with warning log
Missing display_name Defaults to name
Missing category Defaults to "tools"
Missing ghost_buffer Defaults to true

Plugin Lifecycle

Daemon startup
  ├── Write default TOML files (if missing)
  ├── Load built-in terminal plugin (Go)
  ├── Load all *.toml from ~/.quil/plugins/
  │     ├── Parse TOML → validate → compile regex patterns
  │     └── TOML plugins override built-ins with same name
  ├── Detect availability (exec.LookPath on each binary)
  └── Restore workspace (load panes, ghost buffers, plugin state)

User creates pane (Ctrl+N)
  ├── Dialog: Category → Plugin → Instance/Form → Split Direction
  ├── Expand arg_template from form field values
  └── Send to daemon: type, instanceName, instanceArgs

Daemon spawns pane
  ├── Look up plugin in registry
  ├── Apply instance args (override default args)
  ├── If preassign_id: generate UUID, expand start_args
  ├── Merge plugin env vars into PTY process
  ├── Resolve cmd to absolute path via exec.LookPath
  └── Start PTY process → begin output streaming

Pane running
  ├── PTY output → scrape patterns → store matches in plugin state
  ├── PTY output → error patterns → show dialog if matched
  └── Periodic snapshots save plugin state to disk

Daemon restart
  ├── Load workspace + plugin state from disk
  ├── For each pane, dispatch by strategy:
  │     ├── rerun → same cmd + saved args
  │     ├── preassign_id → expand resume_args from state
  │     ├── session_scrape → expand resume_args from scraped values
  │     └── none → start fresh
  └── Replay ghost buffers to reconnecting clients

Hot reload (F1 → Plugins → Reload)
  ├── Re-read all *.toml files
  ├── Re-compile regex patterns
  └── Re-detect availability

Complete Examples

Example 1: Minimal Plugin

A system monitor with no persistence or forms — just run a binary.

[plugin]
name = "htop"
display_name = "System Monitor"
category = "tools"
description = "Interactive process viewer"

[command]
cmd = "htop"

Example 2: Remote Connection with Forms

A full-featured plugin with instance forms, error handlers, and persistence.

[plugin]
name = "ssh"
display_name = "SSH"
category = "remote"
description = "Remote SSH connection"

[command]
cmd = "ssh"
detect = "ssh -V"
arg_template = ["-p", "{port}", "{user}@{host}"]

# --- Form fields shown when creating a new instance ---

[[command.form_fields]]
name = "name"
label = "Connection Name"
required = true

[[command.form_fields]]
name = "host"
label = "Hostname"
required = true

[[command.form_fields]]
name = "user"
label = "Username"
required = true

[[command.form_fields]]
name = "port"
label = "Port"
default = "22"

[[command.form_fields]]
name = "description"
label = "Description"

# --- Visual settings ---

[display]
dialog_width = 60

# --- Resume after daemon restart ---

[persistence]
strategy = "rerun"
ghost_buffer = true

# --- Help dialogs for common errors ---

[[error_handlers]]
pattern = 'Permission denied \(publickey'
title = "SSH Authentication Failed"
message = """
SSH key not configured for {host}.

1. Generate:  ssh-keygen -t ed25519
2. Copy key:  ssh-copy-id {user}@{host}
3. Retry the connection"""
action = "dialog"

[[error_handlers]]
pattern = "Host key verification failed"
title = "Unknown Host"
message = "Run: ssh-keyscan {host} >> ~/.ssh/known_hosts"
action = "dialog"

[[error_handlers]]
pattern = "Connection refused|No route to host"
title = "Connection Failed"
message = "Cannot reach {host}. Check that the server is running and the address is correct."
action = "dialog"

# --- Pre-configured servers ---

[[instances]]
name = "staging"
display_name = "Staging Server"
args = ["-p", "2222", "deploy@staging.example.com"]

[[instances]]
name = "production"
display_name = "Production"
args = ["admin@prod.example.com"]

Example 3: AI Tool with Session Resume

A plugin that generates a session ID, passes it to the tool, and resumes the session after restart.

[plugin]
name = "my-ai-tool"
display_name = "My AI Tool"
category = "ai"
description = "AI assistant with session persistence"

[command]
cmd = "my-ai-tool"
detect = "my-ai-tool --version"

[persistence]
strategy = "preassign_id"
ghost_buffer = false
start_args = ["--session-id", "{session_id}"]
resume_args = ["--resume", "{session_id}"]

[[error_handlers]]
pattern = '(?i)API.?key.*not (found|set)|authentication.*failed'
title = "Authentication Required"
message = "Set MY_AI_TOOL_API_KEY in your environment."
action = "dialog"

How it works:

  1. First launch: Quil generates a UUID (e.g., a1b2c3d4-...), stores it as session_id, and starts: my-ai-tool --session-id a1b2c3d4-...
  2. Daemon restart: Quil loads the saved session_id from disk and starts: my-ai-tool --resume a1b2c3d4-...
  3. ghost_buffer = false: The tool's TUI output is not saved/replayed (the tool redraws its own interface on resume).

Example 4: Webhook Listener

A simple tool with default args, a form for URL override, and auth error handling.

[plugin]
name = "stripe"
display_name = "Stripe"
category = "tools"
description = "Stripe webhook listener"

[command]
cmd = "stripe"
args = ["listen"]
detect = "stripe --version"
arg_template = ["listen", "--forward-to", "{url}"]

[[command.form_fields]]
name = "name"
label = "Name"
required = true

[[command.form_fields]]
name = "url"
label = "Forward URL"
required = true
default = "http://localhost:8080/webhook"

[[command.form_fields]]
name = "description"
label = "Description"

[persistence]
strategy = "rerun"
ghost_buffer = true

[[error_handlers]]
pattern = "not logged in|login required"
title = "Stripe Authentication Required"
message = "Run 'stripe login' in a terminal pane first."
action = "dialog"

TOML Syntax Tips

  • Raw strings ('...'): No escape processing. Use for regex patterns: 'Permission denied \(publickey'
  • Multi-line strings ("""..."""): For long error messages with line breaks.
  • Array of tables ([[section]]): Each [[command.form_fields]] or [[error_handlers]] block defines one entry in the array.
  • Boolean values: true / false (lowercase, no quotes).
  • Comments: Lines starting with # are ignored.