A user file or a package can replace how any named part of the terminal is drawn: messages, tool cards, the approval box, the prompt frame, the bars, the home screen and more. Every slot follows one contract, and the built-in look is drawn through the same contract, so what a custom view receives is exactly what the default receives.
Views are off until installed, work in every tier and cost nothing when absent (a terminal without extensions never
loads the machinery). Types for authors live in @mendcode/extension-api/views (types and one small constant table;
no zod, nothing that grows the terminal's startup).
import type { ViewRenderer } from "@mendcode/extension-api/views"- A slot has a typed, serialisable input: the plain data the built-in renderer receives, plus
width,height,theme({ name, colors }),ascii(plain glyphs wanted) andnow. Never app internals, never functions. - A renderer is a pure, synchronous function
input -> lines | undefined. A line is an array of spans{ text, fg?, bg?, bold?, dim?, italic?, underline?, action?, arg? };fg/bgare theme roles (primary,muted,success,error... seeinput.theme.colors) or#rrggbb.undefinedfalls through to the built-in. (prompt.framereturns{ top, bottom, left?, right? }.) - The terminal keeps all input handling. A span with
action(an id from the slot's closed set, witharg) only asks the terminal to run that action when clicked; keys, answers and permissions are never decided by a view.
export default {
name: "my-views",
tui(api) {
api.view("footer", (input) => [[{ text: ` ${input.agent ?? ""} ${input.model ?? ""}`, fg: "muted" }]])
},
}api.view("slashMenu", ...) still works as the old name of slash.menu. The last view registered for a slot wins.
- User file:
<data dir>/extensions/<name>.ts. A session's own model may write the starter too (it is MendCode's template in a new file);--force, which replaces a view you already have, is refused from a session, and the code it then writes goes throughcustomize_ui, which asks you.mendcode view scaffold <slot>(or/customize> Views > New view file) writes a commented starter,view-<slot>.ts, and never overwrites a file (--forcedoes).mendcode view listprints every slot. Both work on files only and never start a daemon. - Package: a package's
extensions/folder, like any terminal extension. A package that wants to own the look lists its views inactivate.views(slot to module) and they load only while it owns the look (packages);mendcode package newwrites two to start from, andexamples/packages/retro/is a full reskin. A view that never returns cannot be interrupted from inside the process: the 4 ms budget and three strikes bound a view across frames, not inside one call; use--safe-modeto start without it. - Project files load only where the project is trusted, the same rule as every project extension. Remote terminals
and pure (
--safe-mode) terminals never load extensions, so a peer or a remote session cannot install a view in another terminal. - Hot reload: edits are picked up by the daemon's existing watcher (
extensions.changed); finished transcript rows are laid out again. /customize> Views states above its rows that views are not sandboxed, and lists the slots that have a custom view (and the one you are on) with the owner (user <name>,project <name>,package <name>, or built-in), the number of faults, the last error and the average time per call. Left/right pick a slot, Enter turns its custom view off or on (disabledViewsin the profile),rresets it (clears faults and the off switch), and the next row writes a starter for the picked slot.
What was verified, plainly: view code runs in this process, with your permissions. It is not sandboxed. Installing a
view is trusting its author, exactly as installing an extension is. /customize > Views says the same above its rows.
The contract below limits how a faulty view can hurt the screen; it does not limit what a hostile one can do.
Be clear about what this is not: a view is ordinary extension code. It is imported into the terminal process and runs with the user's privileges. There is no operating-system sandbox, and a view can do whatever any extension can do. That is why project views need project trust and why remote or pure terminals do not load extensions at all. What the contract adds is that a view's faults never reach the screen:
- The input is a structured clone, deep-frozen. A view cannot mutate the app's data, cannot reach a function, a session or the daemon through it.
- Every call is wrapped: a throw, a returned promise (views are synchronous), a malformed result or a call slower than 4 ms counts as a strike and that frame is drawn by the built-in. The first call of a view is not timed (it pays for compiling). The budget is measured after the call returns: a loop cannot be interrupted while it runs, so the budget bounds a view across frames, not inside one.
- Three strikes open the circuit breaker: the view is not called again until it is reset (
/customize> Views,r) or the file is reloaded, the built-in draws instead, and one notice tells the user. - Output is validated and then clipped: at most
heightrows (and the slot's row cap), each cut towidthwith the terminal's own width helpers, with control characters and escape sequences removed. An unknownaction, anargthat names nothing real (an option index out of range, a session that is not listed), a click region on a slot that has none, or spans that are not objects are invalid output (a strike).
A custom approval or question view restyles; it cannot change what a decision means.
- The terminal owns the options. Keys, option order, what each option does, and the rows that list them (number,
label, key, and the click on each) are drawn by the terminal's built-in renderer in a block of its own, whatever the
view returns. A view restyles everything around the block and chooses where it goes: a row that holds only the text
{options}(exported asHOST_OPTIONS_ROW) is replaced by the block; without one the block goes under the view's rows. The input'soptionRowssays how many rows the block takes, so a view can plan its layout. - A view cannot make a click region for a permission:
approvalandquestionhave no actions, so a span with anactionthere is invalid output (a strike) and the built-in draws. It cannot draw on the block's rows either, and it cannot make "Reject" look like "Allow": text it writes outside the block is just text, and only the block's rows answer a click (a click calls the same code path as the built-in one). When the screen is short the view's rows are cut, never the block's. - The text being approved (
approval.subject: the shell command, or the file and change) must be visible. If the output does not contain that exact text (whitespace may differ, a view may wrap or indent it), the terminal puts the built-in's warning line with the full text (up to 400 characters, with a count of the rest) above the view. Click regions of every other slot follow their rows when the terminal inserts rows like this one. - The same holds for
question: the question text stays visible and the answers are the terminal's block. A batch of questions is one interactive panel (tabs, answers, review): the terminal draws all of it as the block, and a view may add rows around it. - Other slots with actions (
sessions.sidebarandhomeopen a listed session,slash.menuselects an item,tool.cardfolds,noticeresumes, schedules, snoozes, cancels or dismisses a limit pause and sends a queued message) are the view's own and do not grant a permission; theirargmust still name something real.
- Transcript slots (
message.*,tool.card) are drawn once per finished item and layout: the transcript's own row cache holds them, and the registry also remembers a finished item's result on its whole input. A streaming item is not remembered. A view that depends onnowin a transcript slot will not see it update on finished items. - Chrome slots are memoised on their input (the same input within the same clock tick is one call).
- With no custom view the built-in table is called directly: the frame cost is the same as before views existed.
maxRows caps the output; the input's height is never larger. Hit regions: action (and arg) on a span.
Drawn once per finished item. message.user input: itemId, turnId, text, at?, time (formatted, or "" when
timestamps are off; a !command always has it), mode (minimal | balanced | full), style (layout, icon,
lead, label, tint), variant (message | shell | notice), local?, attachments?, images, username,
expanded, and for a sent set of comments on an answer (docs/editor.md) comments?: the comments (n, of, quote, cut, text), the note, head? and tail?, with the layout? and collapse? settings the built-in draws them by. message.assistant adds renderer, details, detailsAll and body: the message as rows (Markdown as the
renderer allows, no lead or time), laid out 4 cells narrower than width so a rail or an indent fits. message.reasoning:
text, running, ms?, rows, oneLine, hidden. Actions: none.
A custom view of message.user or message.assistant (and of turn.status) also gets builtin: the built-in's own rows of
that item. Return [...input.builtin, footer] to add your own text under a message (or above it) and keep everything else as
it is; the example message-footer does that.
A tool call card. Return undefined for kinds you do not draw; the built-in card (with the legacy renderTool hook)
draws them. Input: kind (read, shell, edit, grep, mcp__server__tool...), detail (path, command, pattern),
title?, status (running | ok | error | cancelled | interrupted | unknown), text, output (non-empty lines, at most
400), summary?, level (summary | preview | full), expanded, page, counts (lines, entries, matches),
pattern?, elapsedMs?, exitCode?, toolCalls?, agent?, model?, awaiting?, compact?, density? (the full view's toolDensity for this call: line keeps a finished call to its header row, brief a short preview capped by kind; absent, the level decides), meta?, frame
(spinner), tailLines, mode, render?. A running card is not remembered between frames. Actions: toggle (fold or
open the card, as clicking its fold row does), page (next page of a full card). The minimal view draws one-line
tool rows instead of cards, so this slot is not called there; saved-memory notes and submitted plans are not cards.
The box above the prompt while a request waits. Input: requestId, variant (panel, or inline in the minimal
view), title, tool?, permission?, subject (the text being approved), detail (the built-in's rows, already
wrapped: head, diff, reviewer verdict), explain? (the rows of ?), options ({ label, key, choice } in order),
choice (highlighted index), hint, plan, optionRows (rows of the terminal's option block). Actions: none; the
options are the terminal's block, placed with a {options} row. Rules above apply.
Input: requestId, variant (single | batch), title, detail, multiple, options ({ label, description? }), choice,
picked, typing (the reader is typing in the card's "Type your own answer" field), optionRows; questions (header, question,
context?, options, multiple?, custom?; a single question is a batch of one) and draft (tab, choice, answers, custom,
editing, scroll). Actions: none; the answers with that field (and, for a batch, the tabs) are the terminal's block, placed with a
{options} row, and the terminal puts its cursor in the field. The built-in card stands where the prompt is (layout).
The rules or box around the input. Input: preset (box | top-bottom | minimal | ascii-box), border, lead,
borders. Return { top, bottom, left?, right? }: at most 3 rows above and below, and the cells before and after each
input row (at most a quarter of the width each). The input row itself, its cursor and the status-in-border line stay the
terminal's. Not called in the minimal view's bare mode. Actions: none.
The session bar (one row). Input: path, home?, branch?, hideProject, files?, title, diff?, context?
(tokens, limit), tick?, contextHover, contextStyle, navigation?. The legacy header.replace slot keeps priority
for the whole header. Actions: none.
The left session list, height rows by 38 cells. Input: sessions (sessionId, title, state, queued,
updatedAt, archivedAt?, lifecycle?, draft?: preview, chars, attachments, images, updatedAt of the unsent
draft; an untitled session with one reads Draft · preview), current, selected?, focused. Actions: open with arg = a listed
sessionId (switches to it, like a click).
Three kinds, one slot. kind: "toast": variant, title?, message (return rows of equal width; they float at the top right).
kind: "limit": view (state, provider?, message, resetsAt?) and chips (action, label). kind: "queue":
previews, delivery, held, paused, mode?, shell. Actions: send (queue: send the queued message now) and, for
a limit bar, the actions of chips (resume, schedule, cancel, snooze, dismiss). Return undefined for kinds you
do not draw. For kind: "toast", inline: true asks for the one-row form the inline live region uses (the message in the
variant's color, width cells); a floating box does not fit there.
The row or rows under the prompt (at most 4). Input: leftItems?, rightItems?, separator?, agent?, agentColor?,
model?, effort?, fast? ({ mark, applies }; model no longer carries the mark), provider?, counters?, segments?
and detail? (the placed footer segments of the first row and of the full view's second row, each { id, spans, rank, compact? }),
divider?, plainMarks?, permission?, permissionLabel?, profile?, pr?, loops, goal?, workflows?, notice?,
connection?, shell?, script segments, and extraRows. The legacy footer.* slots add to or replace the built-in row inside
the built-in only. Actions: none.
The new-chat screen. Input: screenHeight?, version?, root, bare, sessions (id?, title, state, updatedAt,
startedAt?, detail?), selectedId?, spinner, look (the /customize home profile), tip?, footer?. The home's
keyboard selection stays the terminal's. Actions: open with arg = the id of a listed session (switches to it, like a
click on the built-in Agent View row; an arg that names no listed session is invalid output). The legacy welcome.replace
slot takes priority.
One widget body of the right panel (/panel). Input: widget (the widget id: agent, context, text...), options,
body (the built-in's rows). Restyle body or draw your own; the panel keeps the title, frame and maxRows. Actions:
none.
The file viewer: the open file in the right slot (variant: slot) or as a full page on a narrow terminal (page).
Input: path, name, language, kind (text, binary, special, large, directory, missing, loading, error), size,
lines, mime?, note? (the one line a file that is not text gets), top, rows (the visible lines: line, text, the highlighted
spans, mark? = added / modified / deleted from Git, selected, hit, pending?), selection?, search?, focused, panel
(the right panel exists in the same slot), options (lineNumbers, wrap as resolved for this file, left, prose? for Markdown and
text, which wrap at spaces), notice?, openLink? (the editor link for the file; the built-in header does not attach it to o open,
which would let a terminal underline the hint), redacted, keys? (focus, switch, close: the keys of the hints as the user has
them bound and labelled, opt+} or ctrl+x }; a key that is not bound is absent, so leave its hint out: never write a key by hand),
and for a directory entries (name, kind = up / dir / file / link / other, size?), cursor and entriesTotal.
Actions: close, switch (only while panel), open, ask, copy (no arg), line with arg = a line number among rows (a
press selects it; a drag extends), entry with arg = an index of entries (a press enters the folder or opens the file). The terminal
keeps the keyboard, the reads and the selection; the view only draws.
The slash command menu. Input: catalog, query, selection, group, groups, suggested?, items, layout. See
the menu contract. Actions: select with arg = index in items. Keyboard
navigation always works.
The transcript rows that are not message or tool cards. All three are blocks: they are laid out once when they finish and
cached, and have no clickable regions (actions: none). Return undefined for a variant you do not draw.
tool.row: a tool call drawn as a row, not a card.variant:minimal(the minimal view's one-line row:title,status,run= how many successful tools this row stands for,folded= a later row already stands for it,reason= the last line of a failure),memory(a saved-memory note:text,hint= the muted/memory undoat the row's right edge,detail= the whole memory text andopen= the reader opened it),orientation(the engine's injected code map folded to one row:text,hint; open, the block draws as atool.card, whichcodeMapCardmakes the default in the full view),plan(a submitted plan,textis Markdown) orimages(the image line under a tool that made pictures,imagesandwhere).compaction.card: the card of a turn that packed the conversation.state,reason,idle?(it ran while idle, before the prompt cache expired),summary,followUp,method?,tailTurns?,tailTokens?,fallbackReason?,cachedPrefix?(the summary request repeated the session's last request), the resolvedstyle/presentation,progress,scratchpad, andexpanded(the reader opened it).summaryRowsis the summary already rendered as Markdown rows.turn.status: the rows between turns.variant:end(the outcome withend { outcome, input, output, reasoning?, ms?, reason?, error? }, drawn as the status line or the error box),goal(goal),limit(limit),note(note, a cache note),undone(undone= messages removed by a rewind) orcheckpoint.metais the profile's message-meta level andmodethe presentation mode.atis when the row was made (a turn end: when the turn ended) andtimethat time formatted, or""while timestamps are off; with them on the built-in ends the line withdone 5:25 PM.
These three wrap everything on screen that is not a transcript block or one of the slots above. In each, the terminal draws
the built-in first and hands its rows to the view in the model (body for a page, rows for the others): return them
restyled, reordered, framed or replaced, or undefined to keep them. The model is only built while a custom view exists.
page: every full-screen page goes through this ONE shape. Input: page (the id: settings, customize, usage,
sessions, agents, memory, model, mcps, permissions, workflows, loops, changes, help, intro, setup,
theme... all pages of the terminal, see PAGE_TITLES in apps/cli/src/view/page-views.ts), title, hints? (the
built-in's esc ... hint line), selected? (the row the built-in highlighted, read from what it drew), scroll? (the
scroll offset, for the pages that keep one: help), body (the built-in rows of the page, height of them at most) and
status (the status rows the terminal appends under every page). Selection, scroll, typing and every key stay the
terminal's: the view only draws, so a view cannot trap the user. A page always shows how to leave it: if the rows you return
contain no esc hint the terminal adds esc back (replacing the last row only when the page is full). Actions: none, and
pages have no mouse regions (the terminal ignores the mouse on pages).
What the shape does not give you: a per-page typed model (rows of a settings page as {label, value}, the tabs of the
usage page as a list). The page modules own their layout, so body carries the drawn rows and selected/hints are read
from them. A view that wants the data of a page can match on page and parse body; a typed model per page needs that
page's state split from its renderer and is not done. /status has one (page.status, below) and goes through both: its
view draws the body, then page wraps it.
overlay: the command palette, the theme and model pickers opened as palettes, and the floating cards (fetch, rewind,
goal and return prompts, and the quick model switch /model opens over the chat, variant: "card" with title: "Model"). Input: variant (palette, picker or card), title, query, selected, matches and the
built-in card as rows. A custom palette is drawn from the top left; typing, selection and running an item stay the
terminal's. Cards keep their place.
bar: the rows around the prompt that are not a card: variant is working (the working row), find, search,
attachments, ide, voice, vim, daemon, shortcuts, mention (the @ popup) or agents (the live sub-agents strip). A bar is drawn only while the
terminal shows it; the view restyles its rows, it cannot make one appear.
The first-run screen (setup, drawn by onboarding-page.ts, the Import picker inside it by onboarding-import.ts,
previews by onboarding-sample.ts) and /tutorial
(tutorial, tutorial-page.ts) are pages like the rest: their rows reach a custom page view as body. Their previews
are not mock-ups: the chat preview replays a transcript through the real createTranscript, the approval preview is the
real renderApprovalPanel, and the tutorial's practice chat is the real terminal app drawing into the page's rectangle
(see setup). Inside the practice chat no extension or custom view is loaded, so it shows the built-in look.
A page slot returns the whole page body (height rows). page.status is wired: the /status page draws through it
(connected, session, info, models, mcps, extensions, storage, scroll...); its keys (scroll, refresh,
back) stay the terminal's.
Everything that draws in apps/cli/src/view is either a slot above or listed here. test/views-coverage.test.ts scans
that folder for draw entry points (render*, *Row(s), *Line(s), draw*) and fails when a file is neither
registered against a slot nor on the allowlist with a reason, so new UI cannot bypass the contract. Not slots:
- The prompt editor (
editor.ts) and the tasks and scratchpad dock (dock.ts), and the live compaction panel (compaction-live.ts) hold editable text with a cursor and click geometry the terminal measures from the rows it draws. Their chrome isprompt.frame,barandcompaction.card; the text areas themselves are not replaceable. - Mermaid and diagrams: a diagram inside an answer is part of the
message.assistantbody (bodyhas its rows); the diagram viewer page (/diagram) is apage. - Helpers with no pixels of their own: box drawing, row layout, hyperlink post-processing and overlay compositing.
- Headless output: the
/loopmonitor of the automation mode prints to stdout. - Unused: the old slash and
@suggestion renderers; the menu and popup draw throughslash.menuandbar. - Typed per-page models (see
pageabove) are not provided.
Draws read calls as one line; every other tool keeps the built-in card. Save as <data dir>/extensions/compact-read.ts.
export default {
name: "compact-read",
tui(api) {
api.view("tool.card", (card) => {
if (card.kind !== "read") return undefined
const lines = card.counts.lines ?? card.output?.length ?? 0
const mark = card.ascii ? "-" : "◇"
return [[
{ text: ` ${mark} read `, fg: card.status === "error" ? "error" : "muted" },
{ text: card.detail, fg: "text" },
{ text: ` · ${lines} line${lines === 1 ? "" : "s"}`, fg: "muted", dim: true },
...(card.status === "running" ? [{ text: " ...", fg: "warning" }] : []),
]]
})
},
}Keeps the built-in Markdown (code blocks, lists, links) and puts it behind a rail with a small header. body is already
laid out 4 cells narrower than width, so the rail fits. Save as <data dir>/extensions/rail-assistant.ts.
export default {
name: "rail-assistant",
tui(api) {
api.view("message.assistant", (message) => {
const rail = { text: message.ascii ? "| " : "┃ ", fg: "primary" }
return [
[{ ...rail, text: `${rail.text}assistant`, bold: true }, ...(message.time ? [{ text: ` ${message.time}`, fg: "muted" }] : [])],
...message.body.map((row) => [rail, ...row]),
]
})
},
}Adds a muted line under every answer with when it was sent and how long it is, and leaves the built-in message as it was:
builtin holds its rows. at is known even while timestamps are off, so this line does not depend on that setting. Save as
<data dir>/extensions/message-footer.ts.
export default {
name: "message-footer",
tui(api) {
api.view("message.assistant", (message) => {
if (!message.builtin) return undefined
const sent = message.at ? new Date(message.at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) : "now"
const words = message.text.split(/\s+/).filter(Boolean).length
return [...message.builtin, [{ text: ` sent ${sent} · ${words} words`, fg: "muted", dim: true }]]
})
},
}The input and output types, VIEW_SLOTS (every slot with its action set and row cap), the budget and the strike limit are in
packages/extension-api/src/views.ts. The host and built-in table are apps/cli/src/view/views.ts and view-builtins.ts;
the registry (breaker, budget, validation, memo) is apps/cli/src/extensions/views.ts; the safety rules are
apps/cli/src/view/view-safety.ts. Tests: apps/cli/test/views-golden.test.ts (the default look is unchanged through the
contract), views-custom.test.ts, views-manage.test.ts and views-docs.test.ts (the two examples above run as written).