Plugin API
Third parties extend the TUI only through the APIs on this page. Objects, fds, and methods not listed here are not provided. Make them unreachable in the host; do not document a honor-system ban.
Open now: palette packs, the root chrome.right rail,
conversation.input.dock above the composer,
conversation.navigation.dock between the input and composer metadata, the outer
conversation.composer.dock telemetry row, local commands and semantic overlays,
plus standard ACP Session config, Plan, statistics, and run-state facts. All four slots
are additive list seats. They accept structured TuiNode trees and never become
session history.
Open: acpSessionPlan
acpSessionPlan folds standard ACP session/update plan, plan_update, and
plan_removed messages into structured state with list(), current(), and
subscribe(listener). The builtin plan-view Client Plugin consumes it like any
other plugin: it owns the conversation.input.dock summary and registers local
/plan-view to open the full list with tuiOverlay.openView(). The active entry uses
a distinct ◐ marker in the review and the animated running marker in the dock, and is
explicitly labeled in progress in both surfaces. Agent /plan
command or mode semantics remain separate.
Open: acpSessionStats
acpSessionStats folds standard ACP prompt-response usage, first text/thought
tokens, tool calls, and resumed usage into current-session token, cache, turn,
step, LLM, tool, TTFT, and throughput statistics. It exposes current() and
subscribe(listener). The builtin stats-view Client Plugin is merely its
default consumer: it contributes the compact readout to
conversation.composer.dock; the Rust shell no longer owns that business UI.
Open: acpSessionStatus
acpSessionStatus folds the non-statistics facts /status needs from standard
ACP initialize / authenticate / session / session/prompt response /
session/update traffic, plus the optional Martty session.status /
session.event extensions: state (idle/starting/running), connection, server,
authenticate state, session binding, model, effort, permission, plan, and agent
preset. It exposes current() and subscribe(listener) and never accumulates
tokens or timings — statistics have exactly one source, acpSessionStats.
The builtin status-view Client Plugin is its default consumer: it registers
the local /status command and opens a markdown status view with
tuiOverlay.openView(). Run-state facts come from
acpSessionStatus.current(); tokens, turns, steps, LLM/tool time, TTFT, and
rate all come from acpSessionStats.current() — the same snapshot stats-view
renders in the composer dock, so the two readouts cannot drift. The Rust
painter only renders the serialized TuiNodes and reads none of its own
transcript accumulators for this command. Runs without a Client tree (demo,
standalone painter) keep a lean Rust fallback that renders run-state and ACP
facts only, also without touching token/timing accumulators.
Builtin Queue and Agent projections
Queue and Agent/session state are local Client interaction state, not timeline
content and not public domain services. The Rust painter sends snapshots to the
builtin tuiQueue and tuiAgents services. queue-view contributes the Queue
summary and every queued item to conversation.input.dock; agents-view contributes the
compact session rail to conversation.navigation.dock and registers /agents.
The normal Queue title keeps enter send first visible. With an empty composer,
Enter steers the FIFO head into the active turn; a deferred steer returns to the front.
The default rail is a muted · Agents · completed/total summary. ↓ expands main and
every subagent in place; ←/→ moves, Enter opens, and Escape collapses.
No overlay or mouse action is created.
Native views remain as no-Client-tree fallbacks.
Agent navigation adds no business-specific color tokens. Optional
generic.tone values reference the existing general Theme tokens. The collapsed
label uses caption; running progress alternates between brand_soft and brand,
failure uses err, and the remaining text and chrome use fg, panel, and
border. Plan summaries use the same visual grammar.
UI Plugins and the two welcome slots
tuiPresets composes several independent UI Plugin contributions into one
saved choice. It lives on the Client tree, separate from Agent Presets that
compose Host/Agent capabilities, and is not an alias for Theme. The mount
callback passed to ctx.tuiPresets.register({ id, label }, mount) composes only
structural UI contributions such as slots, chrome, or a pet and returns one
disposer. It does not register, select, or generate a Theme: /ui, /theme,
and dark/light are independent state dimensions. /ui
opens the native single-select form, /ui reuses the slash menu above the
composer for candidates, and /ui <id> switches directly. The id persists in
$MARTTY_HOME/settings.json. Creator can inspect UiPresets and adjacent Client
services, generate a code.client Package that calls tuiPresets.register,
and preview it with cordis_define/run. Those operations are process-local;
after a successful run, tui_plugin_save must write it under
$MARTTY_HOME/plugins/<artifact-id>/plugin.json for restart recovery.
New artifacts use kind: "ui". Legacy kind: "ui-preset" manifests normalize
to ui when read; UiPresets, tuiPresets, and the uiPreset settings key
remain compatibility-only internal names.
The builtin default (Martty) and deepseek UI Plugins both fill two single
root slots:
welcome.hero: the centered brand region, structured aslogo + hint;welcome.info: the lower-left version, model, workspace, session, credential, access, and help region.
Both currently reuse the native dynamic information renderer, but that region
is independently replaceable. deepseek-logo only registers the deepseek
preset; it owns no special slash command. Use /ui deepseek and /ui default.
The Rust painter keeps the original responsive whale/wordmark primitive and
owns centering and balanced outer padding. Switching never enters the ACP
prompt or transcript.
Open: tuiTheme
palette must match tui-palette.v0.schema.json.
| Field | Rule |
|---|---|
id |
Stable string; colliding with an existing id fails register |
label |
Short name for /theme |
dark / light |
Each is a complete token → #RRGGBB map. Missing any token, or any unknown name, fails |
Closed token names:
bg surface panel fg fg_secondary fg_tertiary caption brand brand_soft bubble_bg bubble_fg border code_bg ok warn err hint chip_bg
Builtin default remains the cold bluish skin and cannot be replaced or removed.
/theme toggle and ctrl+t only change dark/light inside the current theme.
The /theme completion menu separates toggle from Theme Plugins. /theme <id> is a special
single-select Theme Plugin switch: selecting an id starts its owning Client
Plugin and stops the previously selected Theme Plugin; selecting default stops
the current dynamic Theme Plugin. Explicit selection persists beside
uiPreset in $MARTTY_HOME/settings.json; temporary Creator previews and automatic
fallbacks do not rewrite that preference.
A Theme Plugin may register a palette, commands, overlays, slots, timers,
transactions, and Package-private RPC together. None uses a theme condition or
subscribes to the active theme; they are ordinary contributions of one Cordis
Fiber. /theme replacement, stop, update, or unload retracts that whole Fiber.
Stopped Theme Plugin ids remain in the /theme catalog so they can be started
again later.
register(palette, { activate: true }) is the immediate-preview path for a new
dynamic cordis_run. The Client runner places that run in the same /theme seat
and replaces the previous Theme Plugin; if the target fails, the previous Plugin
stays intact. The dynamic Client facade exposes register only, not activate(),
active(), or theme subscribe(), so code cannot bypass the Plugin switch.
This changes the TUI: composer, status, borders, bubbles, code blocks, tool cards, and the hint row all recolor. Layout, box-drawing characters, font size, and kitty pet sprites do not.
A Theme Plugin remains an ordinary Cordis Plugin: one apply registers independent
contributions through their services and one Fiber owns all disposers. /theme
only provides special single-select switching between those Plugins.
Cordis mode discovers this API through Client inspect: Theme on platform
client is the directory published by the TUI over ACP, not tuiTheme on the
Agent tree. code.client injects tuiTheme and registers its palette; the same
Plugin injects other services for its other contributions. Do not use Web
theme.overrideTokens, CSS variables, or theme conditions.
Minimal plugin
export const inject = ["tuiTheme"];
export function apply(ctx) {
ctx.effect(() => ctx.tuiTheme.register({
id: "ember",
label: "Ember",
dark: { /* 18 tokens, see fixtures/demo-skin.v0.json */ },
light: { /* 18 tokens */ },
}));
}
For immediate preview, change the registration call to:
ctx.tuiTheme.register(palette, { activate: true })
Do not subscribe to theme state or hand-write a return path; /theme replaces the
whole Plugin.
Open: tuiSlots
The shell declares four additive list slots: the root chrome.right rail,
conversation.input.dock above the composer,
conversation.navigation.dock inside the composer above its metadata row, and the
outer compact conversation.composer.dock telemetry row. A plugin waits with
inject, then registers any composition of the node kinds in
tui-node.v0.schema.json.
export const inject = ['tuiSlots']
export function apply(ctx) {
ctx.tuiSlots.inject('chrome.right', () => {
const panel = ctx.tuiSlots.register(
{ name: 'chrome.right', id: 'build-monitor', order: 20 },
[{ id: 'title', kind: 'markdown', text: '# Build monitor' }],
)
return panel.dispose
})
}
panel.update(nextNodes) replaces the contribution immediately. Unload removes
it; the rail collapses when the last contribution disappears. Narrow terminals
temporarily hide the rail without deleting state. Dynamic Creator plugins query
Slots.list and inject tuiSlots, not Web React ctx.slots. The optional
@openma/deepseek-harness-tui/right-demo export is a rich gallery plugin.
Use the same registration API for all three conversation docks. Put Plan, Todo, Goal, or
other content needing its own line in conversation.input.dock — it owns the
single cap row and displaces the tip line while present; put ambient compact
navigation such as Agents, branches, or sessions in
conversation.navigation.dock; put ambient compact statistics in
conversation.composer.dock. Open detailed content in a command-owned overlay
instead of expanding the compact stats seat. A generic node's optional tone
references an existing general Theme token; it cannot add a token and should not
invent business-specific color semantics. Use the general boolean
selected: true for transient keyboard focus; the painter renders it reversed.
It is not a persistent value or another status color.
Open: tuiOverlay
openSlider(options, handlers) opens a numeric slider;
openSelect(options, handlers) opens an ordinary single-select form; and
openView({ id, title, nodes }) opens a read-only TuiNode[] modal. All three
share one modal seat and return a lifecycle-owned close() controller. Views scroll
with up/down and close through Enter or Escape; the primitive has no Plan-specific
behavior.
Select options may carry group?: string, a single-line decorative heading and
divider, never a navigable or submitted option. Calling openSelect with the same
id refreshes options and handlers without closing the modal. The painter preserves
the search query and selected value when still visible; an old controller cannot
close the refreshed panel.
Select options may opt into deletable?: boolean with handlers.onDelete(value).
Delete closes the form and dispatches a separate delete event, not a submit.
Disabled or non-deletable rows and absent handlers do nothing. Non-searchable forms
also accept Backspace (Mac Delete); searchable forms keep Backspace for editing.
The owning plugin presents confirmation and performs any deletion.
Local commands may declare
input: { hint, options: [{ value, label, description }] } in
tuiCommands.register. After /name , the Rust painter reuses the existing
upward slash menu for those candidates. The returned disposer also exposes
update({ input }) so dynamic catalogs such as UI Plugins can refresh them.
Standard ACP AvailableCommand.input currently carries only hint, not an
enumerated candidate list; Agent commands therefore expose the standard hint,
while Client extensions or values derived from ACP config/catalog can provide
actual candidates.
Host data and polling
A dynamic Package's Host half registers Package-private JSON methods with
harness.handle(method, handler). Its Client half calls them with
host.call(method, args). Arguments and results must be lossless JSON; omitting
args delivers null to the Host.
For periodic refresh, also inject timer and use the Plugin-owned
ctx.interval; never use a bare Node setInterval:
return {
inject: ['tuiSlots', 'timer'],
apply(ctx) {
const panel = ctx.tuiSlots.register(
{ name: 'chrome.right', id: 'monitor' },
[{ id: 'loading', kind: 'notice', level: 'info', text: 'Loading…' }],
)
let refreshing = false
const refresh = async () => {
if (refreshing) return
refreshing = true
try {
const state = await host.call('read-state')
panel.update([{ id: 'state', kind: 'generic', title: 'State', body: state.text }])
} catch (error) {
panel.update([{ id: 'error', kind: 'notice', level: 'error', text: String(error) }])
} finally {
refreshing = false
}
}
void refresh()
ctx.interval(() => void refresh(), 1000)
},
}
host.call uses the negotiated ACP _dsh/cordis/plugin/invoke Extension Request. Timers and Slots remain in the
Client tree and never become session/update or conversation history.
- insert:
- id: tui-right-demo
name: '@openma/deepseek-harness-tui/right-demo'
Not provided
Plugin modules do not receive: the TTY, stdin, raw mode, kitty escapes, ratatui, the JSON-RPC method table, fd 3/4, the _dsh/cordis/tui/* transport, full-screen rows/cols, a frame loop, or coordinates such as “bottom N rows”.
Nodes must not contain RGB; color only by token name. RGB appears only in a palette pack's dark/light tables.
Persistence and loading
TUI merges builtins, installed package entries, and Creator artifacts into the
same /ui and /theme catalogs. Install a third-party package with
dsh plugin --profile martty add <package-or-path>. Its Host registrar registers
an absolute Client module entry with tuiClientPlugins; the Host runner sends
only that serialized directory to the separate Client process. Client-only
Creator artifacts live under $MARTTY_HOME/plugins and are managed by
tui_plugin_list/read/save/remove. Installed packages win same-id conflicts.
tui_plugin_save accepts only Packages without code.host. Any Host half,
with or without a Client half, makes the complete Plugin Harness-owned and
subject to that Harness's persistence path; Martty never writes Host code into
.martty.
MARTTY_HOME resolves from the explicit environment variable, then
$DSH_HOME/.martty, then ~/.martty. On first startup, legacy artifacts and
settings are copied forward without deleting the source or overwriting current
Martty data.
An installed package exports a small Host registrar:
export const inject = ['tuiClientPlugins']
export function apply(ctx) {
return ctx.tuiClientPlugins.register({
id: 'paper-lantern',
kind: 'theme',
entry: new URL('./client.js', import.meta.url).href,
})
}
Its client.js exports an ordinary Cordis Client inject / apply module.
The package bundle patch inserts only the registrar on the Host profile; no
Host fiber, service, or plugin tree is synchronized to the Client.
Cordis has no parent pointer from one plugin instance onto another.
tui-theme, palette packs, tui-cordis-client-runner, and tui-runner are
peer lifecycles in the Client tree.
The protocol that waits on the TUI capability is the service name:
tui-themeandtui-slotsprovidectx.tuiThemeandctx.tuiSlots.tui-cordis-client-runnerinjects both, publishes inspect, and evaluates dynamiccode.client.- Third-party Client modules inject the service they use and stay PENDING until it exists.
- Package and Creator modules use the same restricted Client facade and lifecycle manager.
Do not stack third parties with ctx.plugin(ember) inside the runner. The
composition mounts third-party packages as siblings. Web
Web ctx.slots and terminal ctx.tuiSlots are different services.
Local packages use dsh plugin --profile martty add ./path and load package
exports. A new registrar needs a TUI restart or later /refresh. Hot-swap of
an already-mounted palette ships by phase.
Later
Conversation slots such as conversation.chat remain closed. ACP session/update stays the transcript source of truth.