SLOPSHOPPER

rich-ui

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

newpanebandspinnerrowsguard
★ 2v0.5.0MITupdated 2026-10-25emergentai/claude-code-rich-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rich-ui
│ ┃ rich-ui ✕ › fix the failing auth test and add an audit log call │ ┃ ▀ █ ▀ 5 of 6 on │ ┃ █▀▀ █ █▀▀ █▀▄ ▄▄▄ █ █ █ ⏺ Read(src/auth.ts) │ ┃ █ █ █▄▄ █ █ █▄█ █ ⎿ Read 6 lines │ ┃ ────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ mermaid ⎿ 3 pass, 1 fail │ ┃ Draw mermaid diagrams as text │ ┃ diagrams ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ markdown-preview 42s │ ┃ Show linked markdown files │ ┃ rendered in a side › /rich-ui │ ┃ pane │ ┃ │ ┃ status-band │ ┃ Show context and plan usage when │ ┃ they run │ ┃ high │ ┃ │ ┃ Experimental · may be removed in a futur │ ┃ │ ┃ welcome-greeting │ ┃ Greet each session with a big │ ┃ animated •······· thinking 0s ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
Pane · rich-ui
▀ █ ▀ 5 of 6 on █▀▀ █ █▀▀ █▀▄ ▄▄▄ █ █ █ █ █ █▄▄ █ █ █▄█ █ ────────────────────────────────────────────────────── mermaid Draw mermaid diagrams as text diagrams markdown-preview Show linked markdown files rendered in a side pane status-band Show context and plan usage when they run high Experimental · may be removed in a future version welcome-greeting Greet each session with a big animated message above the prompt ╭───────────────────────────────────────────────╮ │ welcome ⏎ done 7/20 │ ╰───────────────────────────────────────────────╯ calm-spinner Show a calm one-line status while Claude works quiet-footer Swap the hint under the prompt for a zen saying Enter toggles · Esc closes · rich-ui 0.5.0
Pane · rich-ui-preview
Markdown preview Esc closes · a link to the file in a reply reloads it
Spinner
•······· thinking 0s
Turn line
42s
README

<img src="docs/assets/rich-ui.png" alt="rich-ui logo" width="56" height="56" align="absmiddle">&nbsp;&nbsp;&nbsp;rich-ui

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

Release License: MIT Tested on Claude Code 2.1.310 Stars

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.

rich-ui drawing a mermaid diagram in Claude Code

📦 Install

/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.

✨ What you get

FeatureWhat it does
Mermaid diagramsComplete 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 bandA line under the prompt shows context and plan usage, only when they run high.
Markdown previewClick 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.

🎚️ Switch things off

/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.

Uninstall

/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.

🗺️ Coming next

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.

📚 Docs and help

Usage, troubleshooting and FAQ · Report an issue

MIT licensed. Third-party notices are in THIRD_PARTY_NOTICES.md.

Source 35 files
hooks/register.tsx 39 lines
1import 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}
39
hooks/features/calm-spinner/index.ts 150 lines
1import { 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}
150
hooks/features/markdown-preview/index.tsx 141 lines
1import { 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}
141
hooks/features/mermaid/index.ts 93 lines
1import { 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}
93
hooks/features/quiet-footer/index.ts 85 lines
1import { 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}
85
hooks/features/status-band/index.tsx 71 lines
1import { 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}
71
hooks/features/welcome-greeting/index.tsx 134 lines
1import { 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}
134
hooks/manager/index.ts 180 lines
1import { 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}
180
hooks/core/errors.ts 45 lines
1import 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}
45
hooks/core/feature.ts 12 lines
1// 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}
12
hooks/core/gate.ts 31 lines
1import 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}
31
hooks/features/calm-spinner/line.ts 155 lines
1// 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