ormusClaude Code mods
Claude Code 2.1.289 · Ghostty 1.3.1 · tmux 3.7b · part two

Mod Drawing Surface

What a Claude Code mod can draw, recorded in a real Ghostty session. The demo mod, draw-lab, adds a /draw command with five pane pages and restyles four parts of Claude Code's own interface. Every capture here was driven through Ghostty's AppleScript commands, with no hand edits.

Companion to the Claude Code Mods Field GuideRecorded 4 Oct 2026
Ghostty window running Claude Code with draw-lab: an animated plasma raster in a side pane, Bash calls drawn as bordered cards, a custom turn timer and a custom prompt hint. 1 2 3 4
One frame from the recording. The left side is Claude Code's transcript, partly drawn by the mod. The right side is the mod's own pane.
  1. 1A Raster pane, repainted 15 times a second
  2. 2Bash calls redrawn as cards (ToolUse)
  3. 3Turn timer replaced (TurnDuration)
  4. 4Prompt hint, in the first version that replaced it (PromptHint)
Render sites

Fifteen places a mod can draw

A ui.render hook names a component. It can return its own tree, rewrite the props and call next(e), or pass through untouched. The permission dialog is the one thing a mod cannot redraw, because its answer authorises an action. A mod can only add context to it with $.ui.notice.

Sites in teal are drawn by draw-lab in this recording.

Transcript rows

  • UserMessageYour prompt, with origin and expansion state
  • AssistantMessageClaude's text, per reply
  • ToolUseOne tool call: input, running, errored, output
  • ToolResultA tool's output
  • ToolGroupA folded run of calls; can be unfolded
  • ToolProgressBackground hints for long calls
  • CommandOutputA slash command's reply
  • InfoNoticeSystem notices in the transcript
  • AskUserQuestionThe question picker Claude raises

Around the prompt

  • SpinnerThe working line: verb, message, mode
  • TurnDurationThe "Cooked for 5s" line after a turn
  • SessionModeThe mode indicator
  • PromptHintThe hint row under the prompt
  • AbovePromptA band above the input (part one's demo)

The mod's own space

  • PaneA docked region the mod opens with $.ui.open; the only site a mod creates

Each site hands the hook typed props. Pane and AbovePrompt also pass their size (bodyColumns, maxRows), their scroll position, and which agent's transcript is in view.

Element tables

What each surface can draw

Elements are not globals. A hook asks for the table of the surface it is drawing on with $.ui.resolve(e). An element a surface lacks draws as an empty fragment, so one hook can serve every surface.

ElementTerminalDesktopVS CodeMobileWhat it is
Box Text✓✓✓✓Flex layout, borders, colour, hover styles
Button Link✓✓✓✓Pressable controls; handlers run in the hooks module
Code Markdown✓✓✓✓The engine's own highlighter and Markdown renderer
Input Select✓✓✓–Text field and dropdown
Svg–✓✓✓Vector drawing on the app surfaces
Client✓✓––A region run by the mod's own surface module
Raster✓–––A grid of truecolour cells, repainted in place
Image✓–––Real pixels where the terminal supports them (Ghostty, kitty)
/draw layout

Boxes, borders and text

A terminal-flavoured flexbox: direction, wrap, gap, justify and align, fixed or percentage sizes, padding and margin. Boxes take five border styles in any hex colour. Text can be bold, italic, underlined, struck through, inverse or dim, in any foreground and background colour, and can truncate at the start, middle or end.

Hover needs no hook. A keyed Box declares a hover style, and the surface applies it while the pointer is over it. A scope can light a whole group of elements across different sites at once.

borderStyle · borderColor · backgroundColor · flexWrap · justifyContent · hover={{ borderColor, backgroundColor }} · wrap="truncate-end"
Pane showing five bordered boxes labelled single, double, round, bold and classic; a row of text styles; a rainbow ramp; a space-between flex row; a hover box and a truncated box.
The hover box with a cyan border and dark blue fill while the pointer is over it.
The same box with the pointer over it, moved there with Ghostty's send mouse position.
/draw controls

Controls with handlers

Buttons, a Select and an Input call back into the hooks module. Here each handler writes an atom, and every drawing that reads that atom redraws. In the recording the primary button was clicked three times. Then "hello from Ghostty" was typed into the field and submitted, and the line under it echoed the text.

Markdown and Code use Claude Code's own renderers, so the mod's output matches the transcript's styling. Code can number lines from any start line, or read the source as a unified diff.

Button onPress · Input onSubmit · Select onSelect · Link href · Markdown · Code language | format="diff"
Controls page: Pressed 3× button, Reset, a link, a Terminal select set to Ghostty, a Say input, an echo line reading you said hello from Ghostty, rendered Markdown, highlighted TypeScript with line numbers 12 and 13, and a red and green diff.
The Terminal select opened, listing Ghostty, kitty and iTerm2.
The Select, opened with a click.
/draw raster

Raster: a cell grid you repaint

A Raster is a fixed box of cells. Each cell is one character with a 24-bit foreground and a 24-bit background. Using the upper half block ▀ turns each cell into two pixels, so a 72×18 Raster is a 72×36 image. A timer calls $.ui.blit with new cells, and the surface repaints at its next frame without redrawing the tree.

  • size1–512 columns × 1–256 rows
  • cellsbase64 of little-endian u32 triplets: code point, foreground, background
  • colour0x00RRGGBB, or bit 24 alone for the terminal default; 1,024 distinct pairs at once
  • glyphOne printable width-1 BMP character: blocks, box drawing and braille all work
Animated plasma field in shifting rainbow colours, drawn with half-block characters.
30 frames captured from Ghostty while the plasma was running.
const words = new Uint32Array(RW * RH * 3)
// for each cell: [▀, top pixel colour, bottom pixel colour]
words[i] = 0x2580; words[i + 1] = top; words[i + 2] = bottom
const cells = new Uint8Array(words.buffer).toBase64()

$.clock.every(66, () =>
  $.ui.blit({ requestId: 'draw-lab', key: 'plasma', cells: plasma(t += 0.066) }))
/draw image

Image: real pixels

An Image places a picture over a box of cells, scaled to fill it. The terminal draws it with its graphics protocol, so in Ghostty it shows real pixels. On a terminal without one, the required alt text is drawn dim in its place. The Mandelbrot set here is computed in the hooks module as 360×180 RGBA bytes.

  • sources{ png }, { rgba, width, height }, a { file } path, or a POSIX shared-memory name
  • limitsUp to 255×255 cells, 2048×2048 pixels, 2 MiB of bytes
  • videoKeyed, $.ui.blit swaps the source at frame rate. A shared-memory source lets another process stream frames, and no pixels pass through the mod.
A sharp pixel rendering of the Mandelbrot set in blue, violet and black inside the pane.
/draw life

Client: a module with its own loop

A Client hands a region to one of the mod's surface modules. That module runs on the drawing thread with local state, its own frame clock, a pointer listener and a key listener. It draws with the same Box and Text elements, and can post data back to the hooks module. Here it runs Conway's Game of Life at about eight generations a second, with no round trip through the hooks module.

  • surfacestate, setState, every, onPointer, onKey, post, columns, rows
  • pointerCells plus sub-cell fine position; a drag holds the pointer past the edges
  • bounds20,000 nodes, 32 deep, 100,000 characters, one second per call; three setState calls in a row with no input unmount it
Animated Game of Life: green cells evolving across the pane.
24 frames captured from Ghostty. The frame clock ran on its own.
const LifeView: ClientModule<null, Life> = (_props, surface) => {
  if (surface.state === undefined) {
    surface.setState(fresh())
    surface.every(120, () => surface.setState(step(surface.state)))
    surface.onPointer(ev => { /* paint cells under a drag */ })
  }
  return <Box flexDirection="column">{rows.map(r => <Text>{r}</Text>)}</Box>
}
/draw skin

Restyling Claude Code itself

The same hooks redraw Claude Code's own interface. draw-lab changes four sites and returns next(e) for everything else:

  • SpinnerA ◆ and a rainbow verb, plus the live mode: responding, tool-input, tool-use
  • ToolUseEach Bash call as a bordered card coloured by state, showing its description and command
  • ToolGroupPasses isExpanded: true to next, so grouped Bash calls unfold into ToolUse rows
  • TurnDuration◷ 3.8s · Brewed · drawn by draw-lab
  • PromptHintThe /draw page list, added after Claude Code's own hint line with tail

/draw skin toggles an atom, and every one of those sites redraws, including rows already in the transcript.

skin off
Default transcript: Listed 1 directory, ran 1 shell command, then Cogitated for 5s.
skin on
The same transcript with each Bash call as a green bordered card and the custom turn timer.
Custom spinner reading Elucidating in rainbow letters, first in responding mode, then in tool-use mode.
The spinner during the turn: first while Claude responds, then while a tool runs.
Footer hint listing /draw layout, controls, raster, image, life, skin.
In tmux

Splitting the pane under a running mod

The same mod, run inside tmux on its own test server (tmux -L drawlab, so no real session was touched). The demo split the window side by side, split Claude's half again top and bottom, squeezed it to 70×20 with resize-pane, then removed the other panes. Each pane change is a real terminal resize as far as Claude Code is concerned.

Claude Code notice: tmux focus-events off, add set -g focus-events on to ~/.tmux.conf and reattach for focus tracking.
Claude Code knows it is in tmux. This notice appeared at startup. It sees its own pane's size and nothing of the panes around it.
Claude Code at 256 columns with the Draw Lab pane docked on the right.
256 × 71. The pane docks as a sidebar.
Claude Code at 128 columns beside a shell; the pane is narrower and its contents have wrapped.
128 × 71. Still docked, about 40 columns wide. Rows wrap and the truncated box shortens.
Claude Code at 70 by 20; the pane has become a small framed box above the prompt.
70 × 20. The pane leaves the side and shows inline, above the prompt.
WhatResultWhat happened
Box and Text layoutreflowsEvery width change re-ran the ui.render hook with the new bodyColumns. Wrapping rows wrapped, and fixed-width boxes stayed fixed.
Pane placementadaptsDocked at 256 and 128 columns, inline at 70×20, and docked again when the width came back. The pane was never dropped, because it had been opened by a command.
Client (Life)re-measuresThe module was called again with new columns and rows and started a board that fits. This only works since the 0×0 fix described below.
RasterclipsIts size is fixed at 72×18 cells. At half width the right side was cut off, and inline only two rows showed. The animation kept running throughout.
Imagealt textDrew "Mandelbrot set" instead of pixels at every size, including in a fresh Claude started with tmux's allow-passthrough on.
TruecolourreducedRaster and colour ramps came out banded. A 24-bit gradient printed straight into the same tmux was smooth, so the reduction happens before tmux. Setting FORCE_COLOR=3 changed nothing.
The plasma raster at half width, cut off at the pane's right edge.
Raster at 128 columns: clipped, not scaled.
The Game of Life board at half width, sized to fit the narrower pane.
Life at 128 columns: a new board sized to the room.
At 70 by 20 the Draw Lab pane is an inline framed box above the prompt showing two rows of the raster.
The inline pane at 70×20. Only two of the Raster's 18 rows fit.
Image page inside tmux showing only the alt text Mandelbrot set.
The Image page inside tmux: the alt text only.
Two plasma frames side by side: smooth gradients when run directly in Ghostty, visibly banded colour steps inside tmux.
Left: the Raster run directly in Ghostty. Right: the same Raster inside tmux, with visible colour steps.
A smooth 24-bit colour gradient printed directly in a tmux shell pane.
A 24-bit gradient printed from a shell in the same tmux server: smooth, so tmux itself passes truecolour.

What this means for a mod author

Size Rasters and Images from e.props.bodyColumns and the pane's scroll.bodyRows instead of fixed numbers, and redraw at the new size when the hook runs again. A height-only change redraws nothing, so a hook that cares about height should read it from the Pane props or from a Client's rows. Give every Image a useful alt, because inside tmux the alt is all anyone sees. Don't let the meaning of a drawing depend on subtle colour differences, which may be reduced to a 256-colour palette.

Found while building and recording

What surprised us

  • Quirk

    Tool calls are grouped before a ToolUse hook sees them

    Two read-only Bash calls became one folded line, "Listed 1 directory, ran 2 shell commands", and the card hook never ran. The fix is the documented one: a ToolGroup hook passes isExpanded: true to next.

  • Quirk

    A streaming call looks finished

    While the model is still writing a tool call's input, its row arrives with isRunning: false and no command. The first card drew a green ✓ "Shell $" before anything ran. The card now treats an empty command as "Preparing command…".

  • Bug

    A variable named h breaks JSX

    The Life module used h for the board height. JSX compiles to calls to a global h, so the module crashed with "h is not a function". The typings warn about this. A mount test caught it before the recording.

  • Bug

    A Client's first call measures 0×0

    surface.columns and rows are 0 until the first layout. Handlers that captured the size on that first call kept an 8×4 board for good. The size now lives in state and is re-measured on every call.

  • Quirk

    Hover needs a keyed Box

    An unkeyed Box with hover is refused, and the engine draws its own default instead. Add a key, or a scope that names a hover group.

  • Limit

    Synthetic input did not reach the Client

    Ghostty's AppleScript mouse and key events reached Buttons, Inputs and the Select, but the Life board did not respond to a scripted drag or Space. The test kit confirms the module's pointer and key handlers work, but this recording has no capture of them in Ghostty. A real mouse is the next thing to try.

  • Limit

    The Select change went unrecorded

    Ghostty's send key has no name for the down arrow, and clicking an option in the open list did not pick it. The recording shows the Select open, but not changed.

  • Bug

    Replacing the prompt hint hides Claude Code's own hints

    The first PromptHint hook drew its own text in place of the whole line, which also removed Claude Code's live hints, such as "install gh for PR status" and the agent count. The hook now leaves the line alone and appends its text through the tail prop, which the typings document for exactly this. The hero shot above was taken before the fix. Note that Claude Code's built-in context meter is not always on: it appears only within 20,000 tokens of the auto-compact point. For a permanent readout, use a status line with context_window.used_percentage.

  • Quirk

    Hot reload redraws the past

    Editing the mod while the demo session ran reloaded it within the turn, and earlier transcript rows were redrawn by the new hooks. That's useful for iterating on a look, but it also means old rows never record what was on screen at the time.