MarttyDocs
DSH-NATIVE AGENT TUI中文

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 as logo + 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:

  1. tui-theme and tui-slots provide ctx.tuiTheme and ctx.tuiSlots.
  2. tui-cordis-client-runner injects both, publishes inspect, and evaluates dynamic code.client.
  3. Third-party Client modules inject the service they use and stay PENDING until it exists.
  4. 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.