The Claude Code interface I wished for, as add-ons you can switch on one at a time.

rich-ui is a Claude Code mod: the interface changes I kept wishing for, built as add-ons. Some are real upgrades (mermaid diagrams drawn in the transcript, markdown files opened rendered in a pane), some are just touches I enjoy (a quieter spinner, a welcome greeting). It never changes what Claude says or does, only how it looks, and every feature has its own switch, so take what you like and leave the rest.
Start with: mermaid diagrams, drawn right in the transcript instead of dumped as code.

/plugin install rich-ui --marketplace emergentai/claude-code-rich-ui
That adds this repo as a marketplace and installs the plugin. Mods are on by default in the terminal from Claude Code 2.1.287, so there is nothing else to enable. Works in the terminal only (not the desktop app or the IDE extensions), and the mods API is still early access, so it can shift between Claude Code releases. Tested on 2.1.310.
Marketplaces like this one don't auto-update by default; turn it on in /plugin (Marketplaces tab) if you want new features as they land.
| Feature | What it does |
|---|---|
| Mermaid diagrams | Complete mermaid blocks in Claude's replies are drawn as text diagrams. A dim show source line above each one swaps it back to the code. |
| Status band | A line under the prompt shows context and plan usage, only when they run high. |
| Markdown preview | Click a link to a markdown file in a reply and it opens, rendered, in a pane. |
| Welcome greeting (experimental) | A big orange "welcome" sweeps in above the prompt once per session. On by default (switch it off in /rich-ui); may be removed in a future version. |
| Zen spinner and Zen footer (experimental) | A quiet one-line spinner with the seconds and a dim turn-end time (on by default), and a random zen saying in place of the hint line under the prompt (off by default). Their own rows in /rich-ui; may be removed in a future version. |
Flowcharts (subgraphs included), sequence, class, state, ER, XY chart, pie, C4 and architecture diagrams are drawn. Any other type stays the code block it always was, and so does anything too big for your window.
It all runs locally. No network calls, no telemetry.
/rich-ui opens the feature list. Click a row, or Enter, to flip it; your choices are remembered. Turning a feature off puts the transcript back exactly as stock Claude Code draws it, including diagrams already on screen.
/plugin uninstall rich-ui
That removes the plugin. Your on/off choices are kept in Claude Code's plugin store (~/.claude/plugins/store/rich-ui_*.json), which an uninstall leaves alone, so a reinstall picks them up again. Delete that file if you want a clean slate.
Planned, not promised: more useful links, a status cost segment, more diagram types. The bar for anything new is that it should help in most sessions and add no clutter; the longer version is in docs/FEATURES.md.
Usage, troubleshooting and FAQ · Report an issue
MIT licensed. Third-party notices are in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 39 lines1import type { Register } from 'claude-code'
2
3import { renderSpinner, renderToolProgress, renderTurnDuration } from './features/calm-spinner'
4import { addPromptSection, refreshOnWrite, renderLinkedReply, renderPreview } from './features/markdown-preview'
5import { renderAssistantMessage } from './features/mermaid'
6import { advanceFooter, renderInfoNotice, renderPromptHint } from './features/quiet-footer'
7import { onSessionEnd, recordUsage } from './features/status-band'
8import { renderWelcome } from './features/welcome-greeting'
9import { renderPane, runCommand, startSession } from './manager'
10
11// The engine only accepts literal `on("event", hook)` calls here, so `on` is
12// never wrapped or passed on, and every hook is listed below by the name of an
13// exported handler. Each feature handler checks its own feature's flag.
14export const register: Register = on => {
15 on('session.start', startSession)
16 on('command.run', { command: 'rich-ui' }, runCommand)
17 on('ui.render', { component: 'Pane', requestId: 'rich-ui' }, renderPane)
18
19 on('ui.render', { component: 'AssistantMessage' }, renderAssistantMessage)
20
21 on('prompt.compose', addPromptSection)
22 on('tool.call', refreshOnWrite)
23 on('ui.render', { component: 'AssistantMessage' }, renderLinkedReply)
24 on('ui.render', { component: 'Pane', requestId: 'rich-ui-preview' }, renderPreview)
25
26 on('ui.render', { component: 'AbovePrompt' }, renderWelcome)
27
28 on('ui.render', { component: 'Spinner' }, renderSpinner)
29 on('ui.render', { component: 'TurnDuration' }, renderTurnDuration)
30 on('ui.render', { component: 'ToolProgress' }, renderToolProgress)
31
32 on('turn.complete', advanceFooter)
33 on('ui.render', { component: 'PromptHint' }, renderPromptHint)
34 on('ui.render', { component: 'InfoNotice' }, renderInfoNotice)
35
36 on('session.measure', recordUsage)
37 on('session.end', onSessionEnd)
38}
39hooks/features/calm-spinner/index.ts 150 lines1import { atom, read, update } from 'claude-code'
2import type { MatchedHook } from 'claude-code'
3
4import type { EnabledMap, StatusMap } from '../../../types'
5import { messageOf, withError } from '../../core/errors'
6import type { Feature } from '../../core/feature'
7import { guard, isOn } from '../../core/gate'
8import { familyFor, formatElapsed, lineCells, lineColumns } from './line'
9import type { SpinnerMode, ThemeFamily } from './line'
10import { buildSpinner, buildTurnLine, ROW_KEY } from './view'
11
12// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
13const enabledAtom = atom(
14 { plugin: 'rich-ui', key: 'enabled' } as const,
15 {} as EnabledMap,
16 { shape: 'enabled-1' },
17)
18const statusAtom = atom(
19 { plugin: 'rich-ui', key: 'status' } as const,
20 {} as StatusMap,
21 { shape: 'status-1' },
22)
23
24export const calmSpinner: Feature = {
25 id: 'calm-spinner',
26 name: 'Zen spinner',
27 description: 'Show a calm one-line status while Claude works',
28 isExperimental: true,
29 defaultEnabled: true,
30}
31
32// How often the row repaints: the highlight glides, and the seconds move.
33const TICK_MS = 125
34// A site not seen for this long is a new turn, not the old one going on.
35const STALE_MS = 2000
36
37type Site = {
38 startedAt: number
39 lastSeen: number
40 mode: SpinnerMode
41 columns: number
42 family: ThemeFamily
43}
44
45// One module environment holds the live rows; a hot reload starts a fresh one.
46const sites = new Map<string, Site>()
47let ticker: { cancel(): void } | undefined
48
49// The row is a Raster the engine draws once; the timer then repaints it with
50// `$.ui.blit`, which needs no render pass. The seconds, the label and the track's
51// colour all live in the cells. Written inline in the hook: the engine's module
52// scan rejects a function that is handed `$`.
53export const renderSpinner: MatchedHook<'ui.render', { component: 'Spinner' }> = async (
54 $,
55 e,
56 next,
57) => {
58 if (!isOn(await read($, enabledAtom), calmSpinner) || e.surface !== 'terminal') {
59 sites.delete(e.requestId)
60
61 return next(e)
62 }
63
64 const postError = (error: unknown) => {
65 void $.clock.after(0, async () => {
66 const at = await $.clock.now()
67 await update($, statusAtom, map => withError(map, calmSpinner.id, messageOf(error), at))
68 })
69 }
70
71 return guard(
72 async () => {
73 const now = await $.clock.now()
74 // The colours follow the theme setting, read at each draw so a change shows
75 // from the next one. Unreadable means the both-readable light set.
76 let theme: unknown
77 try {
78 theme = (await $.config.list()).find(row => row.key === 'theme')?.value
79 } catch {
80 theme = undefined
81 }
82 const known = sites.get(e.requestId)
83 const isContinuing = known !== undefined && now - known.lastSeen <= STALE_MS
84 const site: Site = {
85 startedAt: isContinuing ? known.startedAt : now,
86 lastSeen: now,
87 mode: e.props.mode,
88 columns: lineColumns(e.viewport?.columns),
89 family: familyFor(theme),
90 }
91 sites.set(e.requestId, site)
92
93 if (ticker === undefined) {
94 ticker = $.clock.every(TICK_MS, async () => {
95 const at = await $.clock.now()
96 for (const [requestId, live] of sites) {
97 const result = await $.ui.blit({
98 requestId,
99 key: ROW_KEY,
100 cells: lineCells(live.mode, at - live.startedAt, at, live.columns, live.family),
101 columns: live.columns,
102 rows: 1,
103 })
104 if (result.deny === undefined) live.lastSeen = at
105 // Denied: the turn ended or a resize redrew the row. The grace keeps
106 // a row the surface has not mounted yet.
107 else if (at - live.lastSeen > STALE_MS) sites.delete(requestId)
108 }
109 if (sites.size === 0) {
110 ticker?.cancel()
111 ticker = undefined
112 }
113 })
114 }
115
116 return buildSpinner(
117 $.ui.resolve(e),
118 site.columns,
119 lineCells(site.mode, now - site.startedAt, now, site.columns, site.family),
120 )
121 },
122 () => next(e),
123 postError,
124 )
125}
126
127// "Baked for 3s" becomes a dim "3s". A line that waits on background agents, and
128// ctrl+o and --verbose, keep the engine's own.
129export const renderTurnDuration: MatchedHook<'ui.render', { component: 'TurnDuration' }> = async (
130 $,
131 e,
132 next,
133) => {
134 if (!isOn(await read($, enabledAtom), calmSpinner) || e.surface !== 'terminal') return next(e)
135 if (e.props.isExpanded === true || e.props.waitingFor !== undefined) return next(e)
136
137 return buildTurnLine($.ui.resolve(e), formatElapsed(e.props.durationMs))
138}
139
140// The "(ctrl+b to run in background)" pill draws nothing.
141export const renderToolProgress: MatchedHook<'ui.render', { component: 'ToolProgress' }> = async (
142 $,
143 e,
144 next,
145) => {
146 if (!isOn(await read($, enabledAtom), calmSpinner) || e.surface !== 'terminal') return next(e)
147
148 return next({ ...e, props: { ...e.props, hint: '' } })
149}
150hooks/features/markdown-preview/index.tsx 141 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, MatchedHook } from 'claude-code'
3
4import type { EnabledMap, PreviewState } from '../../../types'
5import type { Feature } from '../../core/feature'
6import { isOn } from '../../core/gate'
7import { clipText, describeError, PANE_ID, pathFromHref, previewLinks } from './preview'
8import { buildLinkedReply, buildPreview } from './view'
9
10// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
11const enabledAtom = atom(
12 { plugin: 'rich-ui', key: 'enabled' } as const,
13 {} as EnabledMap,
14 { shape: 'enabled-1' },
15)
16const previewAtom = atom(
17 { plugin: 'rich-ui', key: 'preview' } as const,
18 {} as PreviewState,
19 { shape: 'preview-1' },
20)
21
22const PROMPT_SECTION_ID = 'rich-ui:markdown-preview'
23export const PROMPT_TEXT =
24 'The user can click a markdown link to a local .md file in your replies to open it rendered in a preview pane. ' +
25 'Whenever you create or substantially change a markdown file, link it in your reply as a markdown link, ' +
26 'for example [the plan](docs/plan.md), with the path relative to the working directory or absolute. ' +
27 'Do not write that path only in backticks or as plain text: that is not clickable.'
28
29const MERMAID_FENCE = /^(?:`{3,}|~{3,})[ \t]*mermaid/im
30
31export const markdownPreview: Feature = {
32 id: 'markdown-preview',
33 name: 'Markdown preview',
34 description: 'Show linked markdown files rendered in a side pane',
35 defaultEnabled: true,
36}
37
38// Tells the model the preview is on, so it links the markdown files it writes.
39// Composed per request, so the /rich-ui toggle takes effect on the next one.
40export const addPromptSection: Hook<'prompt.compose'> = async ($, e, next) => {
41 const result = await next(e)
42 if (!isOn(await read($, enabledAtom), markdownPreview)) return result
43 if (!e.surfaces.includes('terminal')) return result
44 if (result.sections.some(section => section.id === PROMPT_SECTION_ID)) return result
45
46 return {
47 sections: [...result.sections, { id: PROMPT_SECTION_ID, text: PROMPT_TEXT, scope: 'session' }],
48 }
49}
50
51// Refreshes the pane's text when Claude writes or edits the markdown file on
52// show. The tool call itself is never held up or changed: whatever goes wrong
53// here is dropped.
54export const refreshOnWrite: Hook<'tool.call'> = async ($, e, next) => {
55 const result = await next(e)
56
57 try {
58 if (!isOn(await read($, enabledAtom), markdownPreview)) return result
59 if (e.tool !== 'Write' && e.tool !== 'Edit') return result
60 if ('deny' in result && result.deny) return result
61 if ('isError' in result && result.isError) return result
62
63 const path = e.file_path
64 if ((await read($, previewAtom)).path !== path) return result
65
66 const { text, isTruncated } = clipText(await $.fs.read(path))
67 await update($, previewAtom, () => ({ path, text, isTruncated }))
68 } catch {
69 // The preview is a convenience; it must not fail a tool call.
70 }
71
72 return result
73}
74
75// A reply that links to a local markdown file is drawn by the engine's Markdown
76// with those links answered here: pressing one opens the file in the pane. A
77// reply with a mermaid fence is left to that feature.
78export const renderLinkedReply: MatchedHook<
79 'ui.render',
80 { component: 'AssistantMessage' }
81> = async ($, e, next) => {
82 if (!isOn(await read($, enabledAtom), markdownPreview)) return next(e)
83 if (e.surface !== 'terminal') return next(e)
84 if (e.props.isSummary) return next(e)
85
86 const text = e.props.text
87 if (MERMAID_FENCE.test(text)) return next(e)
88 const links = previewLinks(text)
89 if (links.length === 0) return next(e)
90
91 const cwd = await $.session.cwd()
92
93 return buildLinkedReply({
94 elements: $.ui.resolve(e),
95 text,
96 links,
97 isFirstOfReply: e.props.isFirstOfReply,
98 onLinkPress: href => {
99 void (async () => {
100 // Asked before anything is awaited: a pane opened while a press is being
101 // answered is placed at any width, one opened after is placed only from
102 // 144 columns and otherwise waits undrawn.
103 const opened = await $.ui.open({
104 id: PANE_ID,
105 title: 'Markdown preview',
106 focus: true,
107 closeOnEscape: true,
108 columns: 80,
109 })
110 if (!opened.isPlaced) $.ui.toast(`Markdown preview could not open: ${opened.reason}`)
111
112 const path = pathFromHref(href, cwd)
113 try {
114 const { text: body, isTruncated } = clipText(await $.fs.read(path))
115 await update($, previewAtom, () => ({ path, text: body, isTruncated }))
116 } catch (error) {
117 await update($, previewAtom, () => ({
118 path,
119 error: `Could not read ${path}: ${describeError(error)}`,
120 }))
121 }
122 })()
123 },
124 })
125}
126
127export const renderPreview: MatchedHook<
128 'ui.render',
129 { component: 'Pane'; requestId: 'rich-ui-preview' }
130> = async ($, e, next) => {
131 if (e.surface !== 'terminal') return next(e)
132 if (!isOn(await read($, enabledAtom), markdownPreview)) return next(e)
133
134 return buildPreview({
135 elements: $.ui.resolve(e),
136 state: await read($, previewAtom),
137 width: (e.props.bodyColumns ?? 80) - (e.props.placement === 'dock' ? 2 : 0),
138 isDocked: e.props.placement === 'dock',
139 })
140}
141hooks/features/mermaid/index.ts 93 lines1import { atom, read, update } from 'claude-code'
2import type { MatchedHook } from 'claude-code'
3
4import { messageOf, withError, withNote } from '../../core/errors'
5import type { Feature } from '../../core/feature'
6import { guard, isOn } from '../../core/gate'
7import type { EnabledMap, ShownMap, StatusMap } from '../../../types'
8import { splitReply } from './detect'
9import { buildReply } from './view'
10
11// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
12const enabledAtom = atom(
13 { plugin: 'rich-ui', key: 'enabled' } as const,
14 {} as EnabledMap,
15 { shape: 'enabled-1' },
16)
17const statusAtom = atom(
18 { plugin: 'rich-ui', key: 'status' } as const,
19 {} as StatusMap,
20 { shape: 'status-1' },
21)
22const shownAtom = atom(
23 { plugin: 'rich-ui', key: 'shown' } as const,
24 {} as ShownMap,
25 { shape: 'shown-1' },
26)
27
28const QUICK_CHECK = /^(?:`{3,}|~{3,})[ \t]*mermaid/im
29
30export const mermaid: Feature = {
31 id: 'mermaid',
32 name: 'Mermaid diagrams',
33 description: 'Draw mermaid diagrams as text diagrams',
34 defaultEnabled: true,
35}
36
37// Written inline, not through shared helpers that take `$`: the engine's module
38// scan rejects those. The status writes are deferred because the engine refuses
39// state writes while a ui.render hook draws.
40export const renderAssistantMessage: MatchedHook<
41 'ui.render',
42 { component: 'AssistantMessage' }
43> = async ($, e, next) => {
44 if (!isOn(await read($, enabledAtom), mermaid)) return next(e)
45 if (e.surface !== 'terminal') return next(e)
46 // The terminal marks a summary row itself (a faint " · summary"); leave it.
47 if (e.props.isSummary) return next(e)
48 const text = e.props.text
49 if (!QUICK_CHECK.test(text)) return next(e)
50
51 const segments = splitReply(text)
52 if (!segments.some(segment => segment.kind === 'mermaid')) return next(e)
53
54 const postNote = (note: string) => {
55 void $.clock.after(0, () =>
56 update($, statusAtom, map => withNote(map, mermaid.id, note)),
57 )
58 }
59 const postError = (error: unknown) => {
60 void $.clock.after(0, async () => {
61 const at = await $.clock.now()
62 await update($, statusAtom, map =>
63 withError(map, mermaid.id, messageOf(error), at),
64 )
65 })
66 }
67
68 return guard(
69 async () => {
70 const viewport = e.viewport
71 if (!viewport) {
72 postNote('Diagrams left as code: terminal size unknown')
73
74 return next(e)
75 }
76
77 return buildReply({
78 elements: $.ui.resolve(e),
79 segments,
80 viewport,
81 requestId: e.requestId,
82 isFirstOfReply: e.props.isFirstOfReply,
83 shown: await read($, shownAtom),
84 onToggle: id => {
85 void update($, shownAtom, map => ({ ...map, [id]: !map[id] }))
86 },
87 })
88 },
89 () => next(e),
90 postError,
91 )
92}
93hooks/features/quiet-footer/index.ts 85 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, MatchedHook } from 'claude-code'
3
4import type { EnabledMap } from '../../../types'
5import type { Feature } from '../../core/feature'
6import { isOn } from '../../core/gate'
7import { pickQuote, ZEN_QUOTES } from './zen'
8
9// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
10const enabledAtom = atom(
11 { plugin: 'rich-ui', key: 'enabled' } as const,
12 {} as EnabledMap,
13 { shape: 'enabled-1' },
14)
15
16// Which saying shows, kept in shared state and not in the module: a hook may run in a fresh
17// module environment each time, and a saying held there came back as a new pick on every
18// draw. -1 means none chosen yet.
19const quoteAtom = atom(
20 { plugin: 'rich-ui', key: 'zen-quote' } as const,
21 -1,
22 { shape: 'zen-quote-1' },
23)
24
25export const quietFooter: Feature = {
26 id: 'quiet-footer',
27 name: 'Zen footer',
28 description: 'Swap the hint under the prompt for a zen saying',
29 isExperimental: true,
30 defaultEnabled: false,
31}
32
33// Each finished turn moves the footer to another saying, and the hint is drawn again to
34// show it. Only the main conversation's turns come through this event.
35export const advanceFooter: Hook<'turn.complete'> = async ($, e, next) => {
36 if (isOn(await read($, enabledAtom), quietFooter)) {
37 await update($, quoteAtom, previous =>
38 pickQuote(previous < 0 ? undefined : previous, Math.random()),
39 )
40 $.ui.invalidate('ui.render')
41 }
42
43 return next(e)
44}
45
46// The hint line under the prompt says a zen saying in place of the shortcut reminders,
47// "esc to interrupt" and any confirmation that would have gone in it. The keys still
48// work. The engine draws "pill · hint" for any hint a mod writes, so the line reads
49// "⏵⏵ auto mode on · Chop wood, carry water."; an empty hint left the "·" dangling. The
50// permission-mode pill is not part of this component and stays.
51export const renderPromptHint: MatchedHook<'ui.render', { component: 'PromptHint' }> = async (
52 $,
53 e,
54 next,
55) => {
56 if (!isOn(await read($, enabledAtom), quietFooter) || e.surface !== 'terminal') return next(e)
57
58 // Drawing never picks: the first saying is chosen once, after the draw, and kept until
59 // the next turn ends.
60 const stored = await read($, quoteAtom)
61 if (stored < 0 || stored >= ZEN_QUOTES.length) {
62 void $.clock.after(0, () =>
63 update($, quoteAtom, previous =>
64 previous >= 0 && previous < ZEN_QUOTES.length ? previous : pickQuote(undefined, Math.random()),
65 ),
66 )
67 }
68
69 return next({ ...e, props: { ...e.props, hint: ZEN_QUOTES[stored >= 0 && stored < ZEN_QUOTES.length ? stored : 0]! } })
70}
71
72// The dim notices under the logo (the model source, an experiment, a settings hint)
73// draw nothing either.
74export const renderInfoNotice: MatchedHook<'ui.render', { component: 'InfoNotice' }> = async (
75 $,
76 e,
77 next,
78) => {
79 if (!isOn(await read($, enabledAtom), quietFooter) || e.surface !== 'terminal') return next(e)
80
81 const { Box } = $.ui.resolve(e)
82
83 return Box({ display: 'none' })
84}
85hooks/features/status-band/index.tsx 71 lines1import { atom, read, update } from 'claude-code'
2import type { Hook } from 'claude-code'
3
4import type { EnabledMap, UsageSnapshot } from '../../../types'
5import type { Feature } from '../../core/feature'
6import { isOn } from '../../core/gate'
7import { stopTicker } from '../welcome-greeting/clock'
8import { bandText, segmentsFor } from './band'
9
10// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
11const enabledAtom = atom(
12 { plugin: 'rich-ui', key: 'enabled' } as const,
13 {} as EnabledMap,
14 { shape: 'enabled-1' },
15)
16const usageAtom = atom(
17 { plugin: 'rich-ui', key: 'usage' } as const,
18 { limits: [] } as UsageSnapshot,
19 { shape: 'usage-1' },
20)
21
22export const statusBand: Feature = {
23 id: 'status-band',
24 name: 'Status band',
25 description: 'Show context and plan usage when they run high',
26 defaultEnabled: true,
27}
28
29// The line lives in the engine's own status row under the prompt, not in the
30// band above it: that band shares its space with the slash menu and surveys
31// and carries a collapse mark. The engine raises session.measure when a figure
32// moves, which is when the line is rewritten. The manager clears or restores it
33// when the feature is toggled (manager/index.ts).
34async function refreshStatus($: Parameters<Hook<'session.measure'>>[0]) {
35 const isShown = isOn(await read($, enabledAtom), statusBand)
36 $.ui.status(
37 isShown ? bandText(segmentsFor(await read($, usageAtom), await $.clock.now())) : undefined,
38 )
39}
40
41export const recordUsage: Hook<'session.measure'> = async ($, e, next) => {
42 const snapshot: UsageSnapshot = {
43 contextPercent: e.context.percent,
44 limits: e.rateLimits.map(limit => ({
45 kind: limit.kind,
46 percentUsed: limit.percentUsed,
47 resetsAt: limit.resetsAt,
48 })),
49 }
50 await update($, usageAtom, () => snapshot)
51 await refreshStatus($)
52
53 return next(e)
54}
55
56// /clear ends the session under reason 'clear' and the process goes on under
57// a new session id with no session.start (docs: SessionEndInput.reason). The
58// usage atom survives that handoff with its pre-clear reading, so it is reset
59// here instead of waiting on a session.measure that may not come soon.
60export const onSessionEnd: Hook<'session.end'> = async ($, e, next) => {
61 // The engine allows one unmatched session.end hook, so the welcome greeting's stop rides
62 // here (every reason; /clear does not let it play again). Synchronous, and cannot throw.
63 stopTicker()
64 if (e.reason === 'clear') {
65 await update($, usageAtom, () => ({ limits: [] }) as UsageSnapshot)
66 await refreshStatus($)
67 }
68
69 return next(e)
70}
71hooks/features/welcome-greeting/index.tsx 134 lines1import { atom, read, update } from 'claude-code'
2import type { MatchedHook, Timer } from 'claude-code'
3
4import type { EnabledMap } from '../../../types'
5import type { Feature } from '../../core/feature'
6import { isOn } from '../../core/gate'
7import { playStartedAt, setTicker, startPlay, stopTicker } from './clock'
8import { FRAME_MS, bandSize, createPacer } from './frames'
9import { CAPTION, TOTAL_MS, cleanText, rowsFor, welcomeCells } from './welcome'
10
11// Declared here, not imported: the engine reads a state source only where it is written (docs/dev/architecture.md 4.0).
12const enabledAtom = atom(
13 { plugin: 'rich-ui', key: 'enabled' } as const,
14 {} as EnabledMap,
15 { shape: 'enabled-1' },
16)
17
18const welcomeAtom = atom(
19 { plugin: 'rich-ui', key: 'welcome' } as const,
20 CAPTION,
21 { shape: 'welcome-1' },
22)
23
24// Whether the greeting has begun in this process. A host atom, not a module variable, so
25// /clear and a plugin reload (which replaces the module) do not bring it back.
26const playedAtom = atom({ plugin: 'rich-ui', key: 'welcome-played' } as const, false, {
27 shape: 'welcome-played-1',
28})
29
30export const welcomeGreeting: Feature = {
31 id: 'welcome-greeting',
32 name: 'Welcome greeting',
33 description: 'Greet each session with a big animated message above the prompt',
34 isExperimental: true,
35 defaultEnabled: true,
36}
37
38export const MAX_COLUMNS = 400
39const RASTER_KEY = 'welcome-greeting'
40
41// The band above the prompt, drawn once per
42// session (not again after /clear or a reload) on an idle prompt, repainted by one timer, then
43// handed back to the engine. The band is as wide as the prompt and as tall as the engine
44// allows (at most half the terminal, less one row); "welcome" is centred in it over
45// the terminal's own background, fading in from the left and out from the left.
46export const renderWelcome: MatchedHook<'ui.render', { component: 'AbovePrompt' }> = async (
47 $,
48 e,
49 next,
50) => {
51 if (e.surface !== 'terminal') return next(e)
52 if (!isOn(await read($, enabledAtom), welcomeGreeting)) {
53 stopTicker()
54
55 return next(e)
56 }
57 const rows = rowsFor(e.props.maxRows)
58 if (bandSize(e.viewport) === undefined || rows === undefined) return next(e)
59
60 try {
61 const now = await $.clock.now()
62 let start = playStartedAt()
63 if (start === undefined) {
64 // Not begun: only start on an idle prompt.
65 if (e.props.hasSurvey || e.props.isWorking) return next(e)
66 if (await read($, playedAtom)) return next(e)
67 start = now
68 startPlay(now)
69 // The engine refuses state writes while a ui.render hook draws.
70 $.clock.after(0, async () => {
71 await update($, playedAtom, () => true)
72 })
73 }
74 // A turn has started (the person pressed Enter): the greeting is over at once, so it
75 // never sits over the transcript while Claude answers, and it does not come back.
76 if (start !== undefined && e.props.isWorking) {
77 stopTicker()
78 startPlay(now - TOTAL_MS)
79
80 return next(e)
81 }
82 // Played already; a redraw during the show keeps drawing it, after it the engine's.
83 if (now - start >= TOTAL_MS) return next(e)
84
85 const rest = await next(e)
86 // The field keeps what was typed; the sign shows it cleaned to what the font draws.
87 const text = cleanText(await read($, welcomeAtom))
88 const { Box, Raster } = $.ui.resolve(e)
89 const requestId = e.requestId
90 const columns = Math.max(1, Math.min(MAX_COLUMNS, e.props.bodyColumns ?? 80))
91
92 let elapsed = now - start
93 let lastCells = welcomeCells(columns, rows, elapsed, text)
94 const pacer = createPacer()
95 const timer: Timer = $.clock.every(FRAME_MS, async () => {
96 if (!pacer.begin()) return
97 let isDenied = true
98 try {
99 elapsed += FRAME_MS
100 // While it holds the picture is still: nothing to send until it ends.
101 const cells = welcomeCells(columns, rows, elapsed, text)
102 if (cells === lastCells) isDenied = false
103 else {
104 lastCells = cells
105 const result = await $.ui.blit({ requestId, key: RASTER_KEY, cells })
106 isDenied = result.deny !== undefined
107 }
108 } catch {
109 // A frame that fails is a frame skipped.
110 }
111 if (elapsed >= TOTAL_MS) {
112 stopTicker(timer)
113 // Ask for the redraw that hands the band back to the engine.
114 $.ui.invalidate('ui.render')
115
116 return
117 }
118 if (pacer.end(isDenied)) stopTicker(timer)
119 })
120 setTicker(timer)
121
122 return (
123 <Box flexDirection="column">
124 <Raster key={RASTER_KEY} columns={columns} rows={rows} cells={lastCells} />
125 {rest}
126 </Box>
127 )
128 } catch {
129 stopTicker()
130
131 return next(e)
132 }
133}
134hooks/manager/index.ts 180 lines1import { atom, read, update } from 'claude-code'
2import type { Hook, MatchedHook, Timer } from 'claude-code'
3
4import type { EnabledMap, StatusMap, UsageSnapshot } from '../../types'
5import { withoutStatus } from '../core/errors'
6import { isOn } from '../core/gate'
7import { parseStored, parseWelcome, serialize, STORE_KEY, toggled } from '../core/prefs'
8import { features } from '../core/registry'
9import { bandText, segmentsFor } from '../features/status-band/band'
10import { statusBand } from '../features/status-band'
11import { CAPTION, MAX_TEXT, cleanText } from '../features/welcome-greeting/welcome'
12import { VERSION } from '../core/version'
13import { buildPane } from './pane'
14import { contentRows, experimentalCount } from './size'
15
16const PANE_ID = 'rich-ui'
17
18// The welcome text field takes this many characters, the most the sign shows.
19const MAX_FIELD = MAX_TEXT
20const SAVE_PAUSE_MS = 400
21// The pending write after the last keystroke, so a burst of typing is one write.
22let saveTimer: Timer | undefined
23
24// Only a text the person changed is stored; the default stays unsaved.
25const custom = (welcome: string) => (welcome === CAPTION ? undefined : welcome)
26
27// Declared here, not imported: the engine's module scan reads a state source
28// only where it is written (docs/dev/architecture.md 4.0).
29const enabledAtom = atom(
30 { plugin: 'rich-ui', key: 'enabled' } as const,
31 {} as EnabledMap,
32 { shape: 'enabled-1' },
33)
34const welcomeAtom = atom(
35 { plugin: 'rich-ui', key: 'welcome' } as const,
36 CAPTION,
37 { shape: 'welcome-1' },
38)
39// Bumped when the person presses Enter in the welcome field, and only then. The engine
40// empties a text field on Enter (a submit), and the text has not changed, so nothing
41// else would make the pane draw it again; a new address (see pane.tsx) draws a fresh field
42// holding the text. Typing never bumps it, so typing never replaces the field.
43const welcomeRevAtom = atom(
44 { plugin: 'rich-ui', key: 'welcomeRev' } as const,
45 0,
46 { shape: 'welcome-rev-1' },
47)
48const statusAtom = atom(
49 { plugin: 'rich-ui', key: 'status' } as const,
50 {} as StatusMap,
51 { shape: 'status-1' },
52)
53const usageAtom = atom(
54 { plugin: 'rich-ui', key: 'usage' } as const,
55 { limits: [] } as UsageSnapshot,
56 { shape: 'usage-1' },
57)
58const saveFailedAtom = atom(
59 { plugin: 'rich-ui', key: 'saveFailed' } as const,
60 false,
61 { shape: 'save-failed-1' },
62)
63
64// One session.start hook does both jobs, because the engine allows only one
65// unmatched hook per event: it loads the saved choices and registers /rich-ui.
66export const startSession: Hook<'session.start'> = async ($, e, next) => {
67 let stored: EnabledMap = {}
68 let welcome = CAPTION
69 try {
70 const raw = await $.store.get(STORE_KEY)
71 stored = parseStored(raw)
72 welcome = cleanText(parseWelcome(raw))
73 } catch {
74 // An unreadable store means every feature uses its default (an empty map).
75 }
76 await update($, enabledAtom, () => stored)
77 await update($, welcomeAtom, () => welcome)
78 // Rows can draw before the choices load (a resumed session does), so draw them again.
79 $.ui.invalidate('ui.render')
80
81 await $.command.register({
82 name: 'rich-ui',
83 description: 'Choose which rich-ui features are on',
84 })
85
86 return next(e)
87}
88
89export const runCommand: MatchedHook<
90 'command.run',
91 { command: 'rich-ui' }
92> = async $ => {
93 const status = await read($, statusAtom)
94 const saveFailed = await read($, saveFailedAtom)
95 const statusLines = Object.values(status).reduce(
96 (total, entry) => total + (entry.error ? 1 : 0) + entry.notes.length,
97 0,
98 )
99
100 await $.ui.open({
101 id: PANE_ID,
102 title: 'rich-ui',
103 focus: true,
104 closeOnEscape: true,
105 rows: contentRows(features.length, statusLines, saveFailed, experimentalCount(features)),
106 })
107
108 return {}
109}
110
111export const renderPane: MatchedHook<
112 'ui.render',
113 { component: 'Pane'; requestId: 'rich-ui' }
114> = async ($, e, next) => {
115 if (e.surface !== 'terminal') return next(e)
116
117 return buildPane({
118 elements: $.ui.resolve(e),
119 features,
120 enabled: await read($, enabledAtom),
121 status: await read($, statusAtom),
122 saveFailed: await read($, saveFailedAtom),
123 width: (e.props.bodyColumns ?? 80) - (e.props.placement === 'dock' ? 2 : 0),
124 isDocked: e.props.placement === 'dock',
125 bodyRows: e.props.scroll.bodyRows,
126 welcome: await read($, welcomeAtom),
127 welcomeRev: await read($, welcomeRevAtom),
128 version: VERSION,
129 // The welcome text saves as it is typed: the field keeps exactly what the person types
130 // (the pane redraws with the same text, so nothing moves), the sign cleans it when it
131 // draws, and the store write waits for a pause in typing. Enter just writes it at once.
132 onWelcome: async text => {
133 await update($, welcomeAtom, () => text.slice(0, MAX_FIELD))
134 saveTimer?.cancel()
135 saveTimer = $.clock.after(SAVE_PAUSE_MS, () => {
136 void (async () => {
137 const welcome = cleanText(await read($, welcomeAtom))
138 try {
139 await $.store.set(STORE_KEY, serialize(await read($, enabledAtom), custom(welcome)))
140 await update($, saveFailedAtom, () => false)
141 } catch {
142 await update($, saveFailedAtom, () => true)
143 }
144 })()
145 })
146 },
147 onWelcomeDone: async () => {
148 saveTimer?.cancel()
149 const welcome = cleanText(await read($, welcomeAtom))
150 try {
151 await $.store.set(STORE_KEY, serialize(await read($, enabledAtom), custom(welcome)))
152 await update($, saveFailedAtom, () => false)
153 } catch {
154 await update($, saveFailedAtom, () => true)
155 }
156 // Enter emptied the field: draw it again with the text.
157 await update($, welcomeRevAtom, n => n + 1)
158 },
159 onToggle: async feature => {
160 const enabled = await update($, enabledAtom, map => toggled(map, feature))
161 await update($, statusAtom, map => withoutStatus(map, feature.id))
162 // The status band's line sits in the engine's status row, so toggling its
163 // feature clears or restores that line at once.
164 if (feature.id === statusBand.id) {
165 const segments = segmentsFor(await read($, usageAtom), await $.clock.now())
166 $.ui.status(isOn(enabled, statusBand) ? bandText(segments) : undefined)
167 }
168 // Rows already in the transcript were drawn under the old choice.
169 $.ui.invalidate('ui.render')
170 try {
171 await $.store.set(STORE_KEY, serialize(enabled, custom(cleanText(await read($, welcomeAtom)))))
172 await update($, saveFailedAtom, () => false)
173 } catch {
174 // The choice holds for this session; the footer says it was not saved.
175 await update($, saveFailedAtom, () => true)
176 }
177 },
178 })
179}
180hooks/core/errors.ts 45 lines1import type { FeatureStatus, StatusMap } from '../../types'
2
3// Pure helpers over the status map. The engine's module scan rejects a function
4// that is handed `$`, so the state writes themselves are written inline in each
5// hook (`$.clock.after(0, ...)` then `update($, statusAtom, map => withError(...))`).
6// The writes are deferred because the engine refuses state writes while a
7// ui.render hook draws, and errors and notes are raised from render hooks.
8
9const EMPTY: FeatureStatus = { notes: [] }
10
11export function messageOf(error: unknown): string {
12 return error instanceof Error ? error.message : String(error)
13}
14
15export function withError(
16 map: StatusMap,
17 id: string,
18 message: string,
19 at: number,
20): StatusMap {
21 const previous = map[id] ?? EMPTY
22
23 return {
24 ...map,
25 [id]: {
26 ...previous,
27 error: { message, count: (previous.error?.count ?? 0) + 1, at },
28 },
29 }
30}
31
32export function withNote(map: StatusMap, id: string, text: string): StatusMap {
33 const previous = map[id] ?? EMPTY
34 if (previous.notes.includes(text)) return map
35
36 return { ...map, [id]: { ...previous, notes: [...previous.notes, text] } }
37}
38
39export function withoutStatus(map: StatusMap, id: string): StatusMap {
40 if (!(id in map)) return map
41 const { [id]: _removed, ...rest } = map
42
43 return rest
44}
45hooks/core/feature.ts 12 lines1// A feature is its metadata plus the hook handlers it exports. The engine only
2// allows literal `on("event", hook)` calls in register.tsx, so register.tsx
3// wires each handler with `gated(feature, handler)`; features never see `on`.
4export type Feature = {
5 id: string
6 name: string
7 description: string
8 defaultEnabled: boolean
9 // Shown under an "Experimental" header in /rich-ui: it may be removed in a future version.
10 isExperimental?: boolean
11}
12hooks/core/gate.ts 31 lines1import type { EnabledMap } from '../../types'
2
3// The gate, as pure helpers. The engine's module scan allows only literal
4// `on("event", hook)` calls, forbids wrapping `on`, and rejects any function
5// that is handed `$`. So a gate cannot be a wrapper: every feature hook is an
6// exported const arrow that reads the flag and records failures inline, and uses
7// these to decide and to contain a throw.
8
9export function isOn(
10 enabled: EnabledMap,
11 feature: { id: string; defaultEnabled: boolean },
12): boolean {
13 return enabled[feature.id] ?? feature.defaultEnabled
14}
15
16// Runs the feature's work; if it throws, reports and falls back to the
17// engine's own behaviour (`next(e)` replays if the hook already called it).
18export async function guard<R>(
19 run: () => R | Promise<R>,
20 fallback: () => R | Promise<R>,
21 onError: (error: unknown) => void,
22): Promise<R> {
23 try {
24 return await run()
25 } catch (error) {
26 onError(error)
27
28 return fallback()
29 }
30}
31hooks/features/calm-spinner/line.ts 155 lines1// The calm working row as data: its words and the cells of its Raster. Pure: no
2// `$`, no engine. The row is one line, a margin, a short track with a soft
3// highlight gliding along it, then "thinking 42s", padded to a fixed width because
4// a blit must repeat the mounted size exactly.
5import { DEFAULT_COLOR, packCells } from '../../core/raster'
6import type { Cell } from '../../core/raster'
7
8export type SpinnerMode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
9
10const DOT = 0x2022
11const FAINT_DOT = 0x00b7
12const SPACE = 0x20
13
14// The stock spinner's colour is Claude's orange in both theme families (from the
15// app's built-in theme tables, as the Firstmate calm mod reads them). The track
16// fades from a dim shade of it, mixed toward the family's background, up to the
17// full colour; the words are a quiet grey that reads on that background.
18export type ThemeFamily = 'dark' | 'light'
19const ACCENT = 0xd77757
20const PALETTES: Record<ThemeFamily, { dim: number; text: number }> = {
21 dark: { dim: mix(ACCENT, 0x1e1e1e, 0.72), text: 0x9a9a9a },
22 light: { dim: mix(ACCENT, 0xffffff, 0.68), text: 0x6b6b6b },
23}
24
25// Values starting `dark` or `light` pick that family; `auto`, a custom theme and
26// anything unreadable use light, which reads on a dark background too.
27export function familyFor(theme: unknown): ThemeFamily {
28 return typeof theme === 'string' && theme.startsWith('dark') ? 'dark' : 'light'
29}
30
31// `from` shifted toward `to` by `amount` (0 to 1), channel by channel.
32function mix(from: number, to: number, amount: number): number {
33 let out = 0
34 for (const shift of [16, 8, 0]) {
35 const a = (from >> shift) & 255
36 const b = (to >> shift) & 255
37 out |= Math.round(a + (b - a) * amount) << shift
38 }
39
40 return out
41}
42// One pass of the highlight along the track and back.
43export const SWEEP_PERIOD_MS = 3200
44// How far the highlight's glow reaches, in cells.
45const GLOW = 1.8
46
47// Left margin, track, and the gap before the words.
48export const LEAD = 2
49export const GAP = 2
50const TRACK_FULL = 8
51const TRACK_SHORT = 4
52
53// Room for " " + 8 cells + " " + "requesting 99m 59s".
54export const FULL_COLUMNS = LEAD + TRACK_FULL + GAP + 20
55const MIN_COLUMNS = 8
56const MARGIN = 2
57// The words need this much room to carry the label; under it only the time stays.
58const LABEL_ROOM = 14
59
60export function labelFor(mode: SpinnerMode): string {
61 switch (mode) {
62 case 'requesting':
63 return 'starting'
64 case 'thinking':
65 return 'thinking'
66 case 'responding':
67 return 'writing'
68 default:
69 return 'working'
70 }
71}
72
73export function formatElapsed(ms: number): string {
74 const total = Math.max(0, Math.floor(ms / 1000))
75 const seconds = total % 60
76 const minutes = Math.floor(total / 60) % 60
77 const hours = Math.floor(total / 3600)
78
79 if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
80 if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`
81
82 return `${seconds}s`
83}
84
85// The width of the row: the transcript's margin off the viewport, capped.
86export function lineColumns(viewportColumns: number | undefined): number {
87 if (viewportColumns === undefined) return FULL_COLUMNS
88
89 return Math.min(FULL_COLUMNS, Math.max(MIN_COLUMNS, viewportColumns - MARGIN))
90}
91
92// How the width divides: the track shrinks, then goes to one cell, as the row narrows.
93export function layoutFor(columns: number): { track: number; room: number } {
94 const track = columns >= FULL_COLUMNS - 2 ? TRACK_FULL : columns >= 22 ? TRACK_SHORT : 1
95
96 return { track, room: Math.max(0, columns - LEAD - track - GAP) }
97}
98
99// What follows the track.
100export function lineText(mode: SpinnerMode, elapsedMs: number, columns: number): string {
101 const time = formatElapsed(elapsedMs)
102 const { room } = layoutFor(columns)
103 const text = room >= LABEL_ROOM ? `${labelFor(mode)} ${time}` : time
104
105 return text.slice(0, room)
106}
107
108// How lit each track cell is, 0 to 1: a highlight eases from one end to the other
109// and back, with a soft glow either side. A one-cell track just pulses.
110export function sweepLevels(nowMs: number, length: number): number[] {
111 const phase = (nowMs % SWEEP_PERIOD_MS) / SWEEP_PERIOD_MS
112 if (length <= 1) return [(1 - Math.cos(phase * 2 * Math.PI)) / 2]
113
114 const there = phase < 0.5 ? phase * 2 : 2 - phase * 2
115 const eased = there * there * (3 - 2 * there)
116 const position = eased * (length - 1)
117
118 return Array.from({ length }, (_, cell) => Math.max(0, 1 - Math.abs(cell - position) / GLOW))
119}
120
121// The colour of a track cell lit to `level` (0 to 1) in a family.
122export function trackColor(family: ThemeFamily, level: number): number {
123 return mix(PALETTES[family].dim, ACCENT, level)
124}
125
126export function lineCells(
127 mode: SpinnerMode,
128 elapsedMs: number,
129 nowMs: number,
130 columns: number,
131 family: ThemeFamily = 'light',
132): string {
133 const { track } = layoutFor(columns)
134 const levels = sweepLevels(nowMs, track)
135 const text = lineText(mode, elapsedMs, columns)
136 const cells: Cell[] = []
137
138 for (let column = 0; column < columns; column++) {
139 const inTrack = column - LEAD
140 const inText = column - LEAD - track - GAP
141 const level = levels[inTrack]
142 const letter = text[inText]
143
144 if (level !== undefined && inTrack >= 0) {
145 cells.push({ code: level > 0.6 ? DOT : FAINT_DOT, fg: trackColor(family, level) })
146 } else if (letter !== undefined && inText >= 0) {
147 cells.push({ code: letter.codePointAt(0)!, fg: PALETTES[family].text })
148 } else {
149 cells.push({ code: SPACE, fg: DEFAULT_COLOR })
150 }
151 }
152
153 return packCells(cells)
154}
155