To always show a line right above the prompt, handle ui.render with { component: 'AbovePrompt' } and return a tree (the band). For a single line of text below the prompt, use $.ui.status(text). For a notification that disappears after a few seconds, use $.ui.toast(text). The last two need no render hook and can be called from anywhere in one line.
How they differ
| Thing | How to show it | How long it stays | Good for |
|---|---|---|---|
| Band | Return a tree from ui.render for AbovePrompt
|
Until you stop returning it | Values you always want to see: price, remaining quota, state. Buttons allowed |
| Status line | $.ui.status(text) |
Until you change it | One-line results of background work |
| Toast | $.ui.toast(text) |
4 seconds by default | "Done" notifications |
| Faint line in the conversation | $.ui.log(text) |
Stays (Claude does not read it) | A short note you want on record |
The band (the strip above the prompt)
The band is the strip right above the prompt input, and all mods share one band.
let price = '—'
export function register(on) {
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Text({ bold: true, children: ['BTC'] }),
Text({ color: 'success', children: [price] }),
],
})
})
}
Do not erase other mods' bands
When you return a tree for the band, it replaces whatever mods that run after yours drew in the band. To keep theirs, put the result of await next(e) inside your own Box.
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
const theirs = await next(e)
return Box({
flexDirection: 'column',
children: [Text({ children: [`BTC ${price}`] }), theirs],
})
})
If you do not want to show anything, return next(e).
Register once per mod
Registering the same event and matcher pair (ui.render with AbovePrompt) twice in one mod makes it fail to load. When your mod grows and you want both a "clock band" and a "price band," register once and lay out the sections inside it.
Reading the width
The band width arrives as e.props.bodyColumns (in columns). When things do not fit, it looked cleaner to drop whole low-priority sections than to cut text midway. A leftover count like "+3" or a truncated price looks half-finished.
Desktop text is proportional and narrow, so more characters fit than the column count suggests. If you lay out strictly by column count, you drop sections while there is still room on the right. I treat the desktop width as 1.5 times the column count.
const raw = e.props?.bodyColumns ?? e.viewport?.columns ?? 100
const cols = e.surface === 'terminal' ? raw : Math.round(raw * 1.5)
1.5 is a value I tuned on my own setup, not an official number. It may change with the font or zoom level, so leave some margin.
Band buttons and number keys
You can put a Button in the band too. According to the official reference, if a band button has a numeric hotkey, typing just that digit into an empty prompt and waiting a moment presses it. That lets you put actions like "press 1 to open" in the band.
Ask for a redraw after changing a value
As with the pane in Part 3, call $.ui.invalidate('ui.render') after changing a value. For values fetched from outside, such as a price, create a timer in session.start and refetch periodically.
on('session.start', async ($, e, next) => {
$.clock.every(15_000, async () => {
try {
const r = await $.http.fetch('https://api.example.com/price')
if (r.ok) price = JSON.parse(r.text).price
} catch {
// If the fetch fails, keep the previous value
}
$.ui.invalidate('ui.render')
})
return next(e)
})
There is no setInterval inside hooks. Use $.clock.every and $.clock.after for timers (Part 5).
The status line
$.ui.status('checks: 3 passing')
According to the official docs, in the terminal this shows one line below the prompt and stays until you change it. It is prefixed with ⚠ and the mod's name, like ⚠ my-mod: checks: 3 passing.
Since it needs no render hook, it is the easiest way to show results from inside a timer. The official example runs gh pr checks every minute and puts the result in the status line.
on('session.start', async ($, e, next) => {
$.clock.every(60_000, async () => {
const r = await $.process.run(['gh', 'pr', 'checks', '--json', 'state'])
$.ui.status('checks: ' + (r.exitCode === 0 ? 'ok' : 'ng'))
})
return next(e)
})
Where and how the status line appears in the desktop app is not described on the official pages, so it is not verified. For values you always want visible on the desktop, the band, which is reliably drawn, is the safer place.
Toasts
$.ui.toast('PDF exported')
In the terminal, a toast appears at the top right with the mod's name and disappears after a few seconds. The default is 4 seconds, and you can change it with { timeoutMs }.
$.ui.toast('Backtest finished', { timeoutMs: 8000 })
There are two main uses:
- Notifying that background work has finished
- Standing in when you cannot open a pane on your own. The official docs also say to use a toast when you want to signal something without opening a pane
If you open a pane with holdToasts: true, toasts wait and do not appear until that pane is closed. Use it when you do not want notifications piling up while someone is reading.
Where toasts appear on screen in the desktop app is not described on the official pages, so it is not verified.
Leave a faint line in the conversation
$.ui.log('build finished')
This leaves a faint line in the conversation like ● my-mod: build finished. Claude does not read this line. To tell Claude something, use the text in a command's return value, or $.prompt.submit (Part 8).
If you pass a second argument, $.ui.log('message', { to: 'debug' }), it is written to the debug log instead of the conversation. Part 5 uses this for debugging.
Add a word to the spinner
Another easy target is the spinner line shown while Claude is working. The very first official example is a mod that appends the number of tool calls after the spinner.
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
return next({ ...e, props: { ...e.props, suffix: ` · tool calls: ${calls}…` } })
})
Passing a copy with modified props to next keeps Claude Code's spinner behavior and just adds text. Spinner is a site drawn in both the terminal and the desktop app.
Which to choose
- A value you always want to see: the band
- Results of background work: the status line (its desktop appearance is not verified, so if it matters, show it in the band too)
- A "done" notification: a toast
- Something to look at in detail, or with many actions: the pane from Part 3
The modscode gallery has many real mods that combine bands and toasts. Mods that show remaining usage or context size in the band are especially common, and they are good for comparing how each one drops band sections.
The following articles in this series answer common problems, starting with the most frequent one: nothing is drawn and there is no error.
This article was written with AI assistance (Claude) and checked against the official Claude Code docs; anything marked "not verified" could not be confirmed there.
Top comments (0)