UI
A mod draws in four places: its own panes, the rows Claude Code draws (slots), the blocks of Claude's replies (markdown slots), and short messages: toasts, progress lines, and questions. All of it lives on mod.ui.
mod.ui.pane(pane: Pane<State>): PaneHandle
mod.ui.render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement): void
mod.ui.toast(text: string): void
mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>Claude Mod Manager (cmod) draws the mod again whenever a value in mod.state changes. A render reads mod.state and returns what to show. It never draws by hand.
Elements
ui/elements.js exports the elements a render draws with. Each takes the props Claude Code types in the claude-code module:
| Element | Props type | What it draws |
|---|---|---|
Box | BoxProps | A flex container: flexDirection, gap, padding, margin, minWidth, and the rest |
Text | TextProps | Text: bold, dimColor, color, wrap, and the rest |
Button | ButtonProps | A button: label or one string child, onPress, hotkey, key, variant |
Link | LinkProps | A link |
Code | CodeProps | Highlighted code |
Markdown | MarkdownProps | Markdown text, as Claude Code draws a reply |
Input | InputProps | A text input |
Select | SelectProps | A choice of options |
Image | ImageProps | An image, with alt text |
Write them as JSX in a .tsx file. The template's tsconfig.json sets jsx: react with jsxFactory: h and jsxFragmentFactory: Fragment. Claude Code declares h and Fragment as globals, and testMod supplies them in tests. Never declare, import, or name anything h or Fragment in a file that writes JSX. A component is a function that returns a RenderElement:
import type { RenderElement } from 'claude-code'
import { Box, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
export function Count({ label, count }: { readonly label: string; readonly count: number }): RenderElement {
return (
<Box gap={1}>
<Text bold>{label}</Text>
<Text dimColor>{`${count}`}</Text>
</Box>
)
}An element called outside a render throws <Element> was called outside a render. Use it inside a pane's render or a slot's component. So build elements inside a pane's render or a slot's component, never at module load or in a hook.
drawWith(table, draw, markdown?) is the function cmod draws a render with. A mod never calls it.
Panes
A pane is a framed region the mod opens. Claude Code docks it beside the transcript in fullscreen, or shows it above the prompt.
definePane
definePane<State>(pane: Pane<State>): Pane<State>
type Pane<State> = {
readonly id: string
readonly title: string
readonly columns?: number | ((state: State) => number | undefined)
readonly rows?: number | ((state: State) => number | undefined)
render(mod: Mod<State>, props: Frozen<RenderPropsOf['Pane']>): RenderElement
}idis 1 to 64 letters, digits,_, or-. Another id throwsdefinePane: "<id>" is not a pane id.titlelabels the pane's tab while more than one pane is open.columnsandrowsask for a size. A number must be a whole number above 0, ordefinePanethrows. A function reads the state and returns the size, orundefinedfor Claude Code's default. When the state changes the size, cmod resizes an open pane. A function that returns a bad size keeps an open pane at its size and logs the pane's title to the debug log.open, andtoggleon a closed pane, reject with that size instead.renderdraws the pane's body.propsholdstitle,isFocused,bodyColumns,placement('dock'or'inline'),scroll, the body's window over a taller drawing, andview, which transcript is on screen beside the pane.
Put each pane in its own file in src/panes/.
import { definePane } from '../../node_modules/@cmodjs/core/ui/define-pane.js'
import { Box, Button, Text } from '../../node_modules/@cmodjs/core/ui/elements.js'
export type NotesState = { global: { notes: readonly string[] } }
export const notesPane = definePane<NotesState>({
id: 'notes',
title: 'Notes',
columns: (state) => (state.global.notes.length > 10 ? 40 : 24),
render: (mod) => (
<Box flexDirection="column" gap={1}>
{mod.state.global.notes.map((note) => <Text>{note}</Text>)}
<Button label="Clear" onPress={() => { mod.state.global.notes = [] }} />
</Box>
),
})mod.ui.pane
mod.ui.pane(pane) adds the pane and returns its handle:
type PaneHandle = {
open(): Promise<void>
close(): Promise<void>
toggle(): Promise<void>
readonly isOpen: boolean
}openasks Claude Code to show the pane.isOpenturns true once Claude Code places it. Claude Code may hold a pane back, and thenisOpenstays false.mod.ui.paneonly adds the pane. cmod opens it only when the mod callsopenortoggle, and Claude Code shows a pane only once the plugin has opened it. So give the person a way in, such as a slash command whosereplycallspane.toggle().- A pane opened from what the person did, such as a slash command they typed or a
Buttonthey pressed, shows at any terminal width. A pane opened from anything else, such assetup, a timer, orSessionStart, shows only on a terminal 144 columns wide, or 110 for a pane the person opened before. Below that it waits undrawn until the person opens it or the terminal widens. closecloses it. The person closes a pane too, andisOpenfollows.togglecloses an open pane and opens a closed one.- When the mod starts,
isOpenis true for each of its panes Claude Code already shows. - Adding two panes with one id throws
<mod>: the pane "<id>" is already added. Give each pane its own id. - The line cmod logs when the mod first starts names the pane by its
title, as inthe Notes pane.
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
import { slashCommand } from '../node_modules/@cmodjs/core/jobs/slash-command.js'
import { notesPane, type NotesState } from './panes/notes.js'
const initialState: NotesState = { global: { notes: [] } }
export const notes = defineMod({
name: 'notes',
state: initialState,
setup(mod) {
const pane = mod.ui.pane(notesPane)
mod.use(slashCommand({ name: 'notes', description: 'Show or hide the notes', reply: () => pane.toggle() }))
},
})Slots: change a row Claude Code draws
mod.ui.render(slot, Component) draws in place of one of Claude Code's own rows. ui/slots.js exports slots:
| Slot | What Claude Code draws there |
|---|---|
slots.AssistantMessage | One text block of Claude's reply |
slots.UserMessage | A user row: the prompt, a task notification, or another agent's message |
slots.ToolUse | A tool call's row |
slots.ToolResult | A tool call's result |
slots.ToolGroup | The condensed row of a group of tool calls |
slots.ToolProgress | A tool's progress line |
slots.CommandOutput | A slash command's output |
slots.AskUserQuestion | The dialog the AskUserQuestion tool opens |
slots.InfoNotice | A dim status line under the logo |
slots.Spinner | The spinner line while Claude works |
slots.TurnDuration | The line that closes a turn |
slots.SessionMode | The session's modes |
slots.PromptHint | The prompt's hint line, such as ? for shortcuts |
slots.AbovePrompt | The band above the prompt, where surveys draw |
Component gets SlotProps<S>: the props Claude Code types as RenderPropsOf['<Slot>'], plus Default.
Defaultdraws what Claude Code, and the renders of other mods, would draw. Pass it props to change what it draws, such as<Default hint="…" />. Return<Default />to change nothing.- A render adds to what it wraps. In
AbovePrompt, drawing<Default />and a line under it keeps the lines of Claude Code and other mods. - A render must draw the same for the same props. One that calls
Defaulta different number of times on a second draw falls back to Claude Code's drawing. - A render that throws falls back to Claude Code's drawing. cmod logs
<mod>: the <Slot> render threw, so Claude Code draws its own: <error>once. - A mod renders each slot once. A second
mod.ui.renderof one slot throws<mod>: a render of <Slot> is already added. Render each slot once.For a markdown slot the error names it asa render of markdown <Kind>, such asa render of markdown CodeBlock. slots.ToolUsechanges a call's own row. A group of calls shows theslots.ToolGrouprow, which Claude Code builds from the stored message, so a mod that hides a tool's input also setsisExpandedonslots.ToolGroupto unfold the group intoToolUserows.- No slot covers the permission dialog.
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
import { Box, Text } from '../node_modules/@cmodjs/core/ui/elements.js'
import { slots } from '../node_modules/@cmodjs/core/ui/slots.js'
export const hints = defineMod({
name: 'hints',
state: { session: { prompts: 0 } },
setup(mod) {
mod.ui.render(slots.PromptHint, ({ hint, Default }) => <Default hint={hint.toUpperCase()} />)
mod.ui.render(slots.AbovePrompt, ({ hasSurvey, Default }) =>
hasSurvey ? <Default /> : (
<Box flexDirection="column">
<Default />
<Text dimColor>{`Prompts: ${mod.state.session.prompts}`}</Text>
</Box>
))
mod.on('UserPromptSubmit', () => {
mod.state.session.prompts += 1
})
},
})Slot and SlotProps are the types behind slots. Type a component's props as SlotProps<typeof slots.PromptHint>.
Markdown slots: change the blocks of a reply
ui/markdown.js exports markdownSlots, one per markdown block:
| Slot | Block | Props beyond text and source |
|---|---|---|
markdownSlots.Heading | A heading | depth, 1 to 6 |
markdownSlots.Paragraph | A paragraph | none |
markdownSlots.CodeBlock | A code block | lang, meta, value |
markdownSlots.BlockQuote | A block quote | none |
markdownSlots.List | A list | ordered, start, spread |
markdownSlots.Table | A table | align |
markdownSlots.ThematicBreak | A --- line | none |
markdownSlots.HtmlBlock | An HTML block | value |
textis the block's plain text, one line per list item, table row, or quoted line.sourceis the block's markdown.Defaultdraws the block as Claude Code would.<Default source="…" />draws other markdown in its place.- A markdown slot render draws in Claude's replies and in every
Markdownelement of the mod's panes. - While a reply streams in, Claude Code draws a block that is not finished yet. The mod draws it once it is.
- A render that throws falls back to Claude Code's drawing of the block and logs once.
import type { RenderElement } from 'claude-code'
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
import { Text } from '../node_modules/@cmodjs/core/ui/elements.js'
import { markdownSlots } from '../node_modules/@cmodjs/core/ui/markdown.js'
import type { SlotProps } from '../node_modules/@cmodjs/core/ui/slots.js'
function MermaidBlock({ lang, value, Default }: SlotProps<typeof markdownSlots.CodeBlock>): RenderElement {
return lang === 'mermaid' ? <Text>{`diagram: ${value}`}</Text> : <Default />
}
export const diagrams = defineMod({
name: 'diagrams',
setup(mod) {
mod.ui.render(markdownSlots.CodeBlock, MermaidBlock)
},
})markdownBlocks<S>(text: string, slot: S): Omit<SlotProps<S>, 'Default'>[]markdownBlocks returns the props of every block of one kind in a markdown text. A test uses it to call a component with real props.
MarkdownKind, MarkdownReader, and MarkdownPiece are the types behind markdownSlots. A mod never builds them.
Toasts
mod.ui.toast(text) shows the person a short message.
Progress lines
mod.ui.progress<T>(title: string, task: (report: (step: ProgressStep) => void) => Promise<T>): Promise<T>
type ProgressStep = { readonly done: number; readonly total: number; readonly label?: string }progress draws a line in the band above the prompt while task runs, and removes it when task settles. The line shows a spinner and the title. After the first report it also shows a bar, done/total, and the label. progress resolves or rejects as task does.
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
export const indexer = defineMod({
name: 'indexer',
setup(mod) {
mod.on('SessionStart', async () => {
const entries = await mod.fs.list(mod.projectRoot)
await mod.ui.progress('Indexing', async (report) => {
for (const [index, entry] of entries.entries()) report({ done: index + 1, total: entries.length, label: entry.name })
})
})
},
})Questions
mod.ui.ask(question: string, options?: readonly string[] | AskOptions): Promise<string>
type AskOptions = { options?: readonly string[]; header?: string; multiSelect?: true }ask shows a question with 2 to 4 options and resolves the answer. Fewer than two options are padded with Yes and No, and the person may type an answer of their own. header is a short chip beside the question, 12 characters at most. multiSelect lets the person pick several, and the answer comes back joined by commas.
import { defineMod } from '../node_modules/@cmodjs/core/mod.js'
export const committer = defineMod({
name: 'committer',
setup(mod) {
mod.on('Stop', async () => {
const answer = await mod.ui.ask('Commit the changes?', { options: ['Commit', 'Wait'], header: 'Commit' })
if (answer === 'Commit') await mod.process.run(['git', 'commit', '-am', 'wip'], { cwd: mod.projectRoot })
})
},
})Test the UI
tested.lines(paneId) reads an open pane as rows of text. tested.lines(slot, props) draws a slot render. tested.press(paneId, key) presses a button. tested.shown.toasts lists the toasts. testing.md covers each.