SLOPSHOPPER

clarity

A clearer Claude Code terminal: boxed and organized you/claude conversations, colour-coded clickable tool rows, a live status band with a timer and task info…

newpanebandspinnerrowsguard
★ 1v1.0.0-beta.2MITupdated 2026-10-16CircularBlox/Clarity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clarity
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ Ready ⏺ Read(src/auth.ts) │ ┃ │ context ██████████░░░░░░░░░░ 49% $0.42 ⎿ Read 6 lines │ ┃ ╰─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Changed ⏺ Bash(bun test) │ ┃ ▮▮ src/auth.ts +2 −1 ⎿ 3 pass, 1 fail │ ┃ ▮▮▮▮ src/audit.ts +6 −0 │ ┃ ▮▮▮▮▮ src/cache.ts +8 −0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Failed ✻ Worked for 42s · done 4:20 PM │ ┃ ✗ Bash bun test │ ┃ › /clarity │ ┃ Recent l │ ┃ 8:53 am Bash cat .env │ ┃ 8:53 am Bash rm -rf build && git push --f │ ┃ 8:53 am Bash git status --porcelain │ ┃ 8:53 am Bash bun test │ ┃ 8:53 am Write src/cache.ts │ ┃ 8:53 am Write src/audit.ts │ ┃ │ ┃ Your prompts cl │ ┃ copy-turn-1 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
╭──────────────────────────────────────────────────────╮ │ ✓ Ready │ │ context ██████████░░░░░░░░░░ 49% $0.42 │ ╰──────────────────────────────────────────────────────╯ Changed 3 files ▮▮ src/auth.ts +2 −1 ▮▮▮▮ src/audit.ts +6 −0 ▮▮▮▮▮ src/cache.ts +8 −0 Failed 1 ✗ Bash bun test Recent latest first 8:53 am Bash cat .env 8:53 am Bash rm -rf build && git push --force origi… 8:53 am Bash git status --porcelain 8:53 am Bash bun test 8:53 am Write src/cache.ts 8:53 am Write src/audit.ts Your prompts click to copy copy-turn-1
Pane · clarity
clarity settings General 1: cycle-on on-onon-off Turn it off for regular Claude Code. 2: cycle-focus focus-onfocus-off Hides startup notices and tool output that worked. Look 3: cycle-palette palette-autopalette-darkpalette-contrastpa Auto matches your Claude Code theme. 4: cycle-width width-70width-80width-100width-120width-0 How wide replies get before they wrap. f: cycle-font font-leavefont-readablefont-large Sets the editor terminal to JetBrains Mono, readable or large. 5: cycle-tools tools-summarytools-full Click any tool row to see its full output. Layout 6: cycle-band band-onband-off The bar just above where you type. 7: cycle-panel panel-autopanel-offpanel-open Opens beside the chat when there's room. Behaviour 8: cycle-alerts alerts-notifyalerts-soundalerts-bothalerts A ping when Claude needs you or finishes a long task. 9: cycle-copy copy-oncopy-off Copy buttons under code and long replies. Esc to close · /clarity panel to open the session panel Tip: clarity works best on its own. Turn off other mods that restyle the transcript (like skins) in /plugin. Preview ╭────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────╮ │ │ │ you │ │ │ │ add rate limiting to the api │ │ │ ╰──────────────────────────────────────────────────╯ │ │ │ │ ╭──────────────────────────────────────────────────╮ │ │ │ ● claude │ │ │ │ I'll check the server first. │ │ │ ╰──────────────────────────────────────────────────╯ │ │ ▸ Read ×2 Grep ✓ │ │ ▸ Bash npm test ✓ │ │ ▸ Edit src/server.ts … │ │ ━━ 42s ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ╰────────────────────────────────────────────────────────╯
README

clarity

A clearer Claude Code terminal: boxed and organized you/claude conversations, colour-coded clickable tool rows, a live status band with a timer and task info, a session panel, copy buttons, cards, alerts, readable palettes, and overall transforms Claude Code.

A turn in clarity: your prompt boxed with its attached file and image, Claude's reply in its own frame, tool rows as coloured badges, a failed test in a red box, and code in a box with a copy button

Install

Needs Claude Code 2.1.287 or later. Run this inside Claude Code:

/plugin install clarity --marketplace CircularBlox/Clarity

Answer y to add the marketplace and pick a scope. clarity is on as soon as it installs. Type /clarity to open its settings.

From a shell, the same thing is:

claude plugin install clarity --marketplace CircularBlox/Clarity

In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with CircularBlox/Clarity, and Add plugin.

Update

claude plugin update clarity@clarity

Then run /reload-plugins in any open session.

Uninstall

claude plugin uninstall clarity@clarity

If you set a terminal font with clarity, set it back to leave first (in /clarity, under Look). That takes clarity's lines out of your editor's settings.

Use

/clarity opens the settings. Click a choice, or press a setting's number to step through its choices. Esc closes it.

Other commands, for when you know what you want:

CommandDoes
/clarity panelOpens the session panel
/clarity on / offTurns clarity on or off
/clarity focusHides startup notices and tool output that worked
/clarity tools full / summaryFull tool rows, or one line each
/clarity statusSays what is on

clarity works best on its own. If you run other mods that restyle the transcript, like skins, turn them off in /plugin. clarity still copes when they stay on: it labels and frames the rows they draw instead of redrawing them.

Preview

The status band, through one turn. It replaces the stock spinner while it is on.

The status band cycling through Thinking, Reading, Editing, Running npm test, Writing and Ready, with the time, tokens, plan step and context

Tool rows. Each tool is a badge in the colour of what it does. Calls made back to back fold into one strip, and a click opens it.

Tool rows: Read and Grep badges, an Edit with +15 −3, three calls folded into one strip, and a failed test in a red box

<table> <tr> <td width="44%"><img alt="The session panel: what Claude is doing, a context meter and cost, the plan with done and pending steps, changed files with edit bars, failures, recent tool calls and past prompts" src="docs/previews/panel.svg"></td> <td><img alt="The /clarity settings: General, Look, Layout and Behaviour, each choice a chip with the current one filled" src="docs/previews/settings.svg"></td> </tr> </table>

These previews are drawn from clarity's own output, rendered through Claude Code's test kit. In a real Cursor terminal it looks like this:

clarity in Cursor's terminal: a boxed reply, a folded run of Bash calls, the status band, and the settings menu with its preview

A reply with a table in its frame, beside the settings menu

What it changes

PartWhat you see
Your promptA rounded box with a you label. Files you add with @, folders, pasted images and pasted text each get a small card under it. Prompts over 12 lines fold the same way.
Claude's replyIts own frame with a claude label. A line of room between paragraphs, and lines that wrap at 80 columns. Replies over 30 lines fold to their first lines, with a button to show the rest.
CodeIts own frame with the language and a copy button. Long replies get a copy button too.
Tool rowsOne line on a tinted strip. Reads are blue, searches purple, commands orange, edits green, the web cyan, agents pink. Edits show +12 −3.
Back-to-back callsFold into one strip, [Bash ×2] [Edit] 3 tools. Click to open, click again to fold.
FailuresAlways shown, in a red box that says what failed.
QuestionsA banner, so you notice Claude is waiting on you.
Slash commandsTheir output sits in a titled card.
End of a turnA rule with the time. After a turn over 45 seconds, a "While you were away" card with tool calls, files changed and failures.
Status bandWhat Claude is doing, for how long, tokens written, the plan step, running agents, failures and context. It shows compaction with a timer, and warns once a plan usage limit passes 80%, with when it resets. Facts that do not fit drop out whole.
Session panelNow, context and cost, the plan, changed files, failures, recent calls and your prompts. Click a prompt to copy it.

Buttons are always in brackets, like [ ⧉ copy ], and change when you point at them. Coloured words without brackets are labels.

Settings

SectionSettingChoices
Generalclarityon, off
Generalfocuson, off
Lookcoloursauto, dark, contrast, colorblind, theme
Lookline width70, 80, 100, 120, full
Lookterminal fontleave, readable, large
Looktool rowsone line, full
Layoutstatus bandon, off
Layoutsession panelauto, off, open now
Behaviouralertsnotify, sound, both, off
Behaviourcopy buttonson, off

auto colours follow your Claude Code theme. colorblind uses the Okabe–Ito set. theme uses only your theme's own colours, which suits light themes.

terminal font sets your editor's terminal (Cursor or VS Code) to JetBrains Mono: 13 with 1.3 line spacing for readable, 14 with 1.35 for large, and turns on smooth scrolling. It falls back to Menlo when JetBrains Mono is missing. leave is the default and touches nothing.

Settings are kept between sessions.

What it can reach

What claude plugin validate reports, in words.

AccessDetail
ReadsYour prompts, each tool call's input and result, the model's output as it streams (counted, not kept), the final reply, and the session's context use and cost.
ChangesHow rows are drawn. Prompts, tool calls and replies pass through unchanged, and so does what the model reads.
WritesIts own settings, to its plugin store. Your editor's settings.json, only when you pick a terminal font, and only the font lines. A settings file with comments is left alone.
Runspbcopy when the terminal will not copy for it. ls ~/Library/Fonts after setting the font, to see if JetBrains Mono is there.
EnvironmentReads HOME, TERM_PROGRAM and VSCODE_GIT_ASKPASS_MAIN to find your editor.
Sound and notificationsOnly with alerts on.
NetworkNone.

Compatibility

WhereWorks
Terminal, fullscreen layoutEverything, clicks included
Terminal, main-screen layoutEverything but clicks
Desktop app, VS Code extension, mobileclarity steps aside and Claude Code draws as usual

The session panel docks once the terminal is about 110 columns wide when you open it, or 144 when it opens on its own. Below that, /clarity panel still opens it.

Known limitations

  • If another mod's hook runs before clarity's and draws a row itself, clarity never sees that row.
  • Edit line counts come from each edit's text, so a full rewrite counts whole.
  • Folding and the plan start from the turn after clarity loads.
  • The status band hides the stock spinner's tips. Turn the band off to get the spinner back.
  • Plan tracking reads Claude's TodoWrite lists.
  • Where an organization manages Claude Code, installed mods do not see compaction start and end. The band then leaves compaction to Claude Code's own spinner line, which still shows.
  • Smooth scrolling covers the editor's terminal. Inside Claude Code's fullscreen layout, Claude Code scrolls the transcript itself.

Develop

claude --plugin-dir ./
claude plugin validate .
claude plugin test .

The tests run against Claude Code's engine through its test kit. They cover the logic, every drawn part, the menu, the band, the panel, and tool calls folding into one strip.

Dependencies

None at runtime. clarity uses only Claude Code's mod API.

NameVersionLicense (SPDX)Source
JetBrains Mono, optionalanyOFL-1.1https://github.com/JetBrains/JetBrainsMono

Third-party notices

Claude and Anthropic are trademarks of Anthropic, PBC. Cursor is a trademark of Anysphere, Inc. Visual Studio Code is a trademark of Microsoft Corporation. JetBrains Mono is a trademark of JetBrains s.r.o. Use here is descriptive and implies no endorsement.

License

MIT. Not an official Anthropic product.

Source 4 files
hooks/register.tsx 848 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { Activity, Env, FontChoice, Ledger, Prefs, RunPlace, ToolRun } from '../types'
5import {
6  ALERTS,
7  ASKS,
8  FONTS,
9  DEFAULT_PREFS,
10  EDITS,
11  FOLD_PROMPT,
12  FOLD_REPLY,
13  EMPTY_LEDGER,
14  IDLE,
15  KEEP_RESULT,
16  NARROW,
17  PALETTES,
18  PANELS,
19  WIDTHS,
20  activityText,
21  applyFont,
22  clip,
23  describeInput,
24  drawnByMod,
25  editDelta,
26  editedPath,
27  editorSettingsPath,
28  nearestLimit,
29  fillActivity,
30  fillLedger,
31  firstLine,
32  foldText,
33  groupTools,
34  lineCount,
35  palettes,
36  parseAttachments,
37  parseTodos,
38  readPrefs,
39  recordEdit,
40  resolvePalette,
41  runCommand,
42  runStatus,
43  splitBlocks,
44  statusOf,
45  summarizeTurn,
46} from './logic'
47import type { Status, TurnSnapshot } from './logic'
48import {
49  badge,
50  band,
51  card,
52  failHeader,
53  attachmentCards,
54  awayCard,
55  menu,
56  openedRow,
57  panel,
58  questionBanner,
59  replyBlocks,
60  replyBox,
61  toolLine,
62  turnRule,
63  userRow,
64} from './rows'
65import type { Chip, Look, MenuSection, ToolParts, UI } from './rows'
66
67const prefsAtom = atom({ plugin: 'clarity', key: 'prefs' } as const, DEFAULT_PREFS)
68const envAtom = atom({ plugin: 'clarity', key: 'env' } as const, { theme: 'dark' } as Env)
69const tickAtom = atom({ plugin: 'clarity', key: 'tick' } as const, 0)
70const activityAtom = atom({ plugin: 'clarity', key: 'activity' } as const, IDLE)
71const ledgerAtom = atom({ plugin: 'clarity', key: 'ledger' } as const, EMPTY_LEDGER)
72const expandedAtom = atom({ plugin: 'clarity', key: 'expanded' } as const, false)
73const runsAtom = atom({ plugin: 'clarity', key: 'runs' } as const, null as ToolRun | null)
74const placesAtom = atom({ plugin: 'clarity', key: 'places' } as const, null as RunPlace | null)
75
76// Every read and write of the session's state goes through these, which fill in fields an
77// older clarity never wrote.
78async function readLedger($: EngineInterface): Promise<Ledger> {
79  return fillLedger(await read($, ledgerAtom))
80}
81
82function editLedger($: EngineInterface, fn: (ledger: Ledger) => Ledger) {
83  return update($, ledgerAtom, raw => fn(fillLedger(raw)))
84}
85
86async function readActivity($: EngineInterface): Promise<Activity> {
87  return fillActivity(await read($, activityAtom))
88}
89
90function editActivity($: EngineInterface, fn: (activity: Activity) => Activity) {
91  return update($, activityAtom, raw => fn(fillActivity(raw)))
92}
93
94const STORE_KEY = 'prefs'
95const MENU = 'clarity-menu'
96const PANEL = 'clarity-session'
97// Below this many columns a docked pane is squeezing the transcript.
98const CRAMPED = 70
99// A turn longer than this raises the "done" alert.
100const LONG_TURN_MS = 45_000
101// A reply block at least this many lines tall gets a copy button.
102const COPY_LINES = 4
103
104let hasWarnedCramped = false
105// Ticks the band's timer while a turn runs.
106let ticker: ReturnType<EngineInterface['clock']['every']> | undefined
107// The run the next tool call joins: set by a call, ended by any text the model writes.
108let currentRun: string | null = null
109// Tool calls this session, and the session as the running turn found it.
110let toolCount = 0
111let turnStart: TurnSnapshot | null = null
112
113// What a render hook needs, or null when clarity should hand the row back.
114async function look($: EngineInterface, e: RenderInput): Promise<Look | null> {
115  if (e.surface !== 'terminal') return null
116  const prefs = await read($, prefsAtom)
117  if (!prefs.isOn) return null
118  const env = await read($, envAtom)
119  const columns = e.viewport?.columns ?? 100
120  if (columns < CRAMPED && !hasWarnedCramped) {
121    hasWarnedCramped = true
122    $.ui.toast(
123      `clarity: only ${columns} columns to draw in. Move the terminal into the editor area, or close a side pane, for easier reading.`,
124    )
125  }
126  return { prefs, colors: palettes[resolvePalette(prefs.palette, env.theme)], columns, isNarrow: columns < NARROW }
127}
128
129function elements($: EngineInterface, e: RenderInput): UI {
130  // look() has already returned null off the terminal.
131  return $.ui.resolve(e) as unknown as UI
132}
133
134// Writes the prefs, keeps them for later sessions and redraws every row clarity draws.
135async function savePrefs($: EngineInterface, prefs: Prefs) {
136  await update($, prefsAtom, () => prefs)
137  await $.store.set(STORE_KEY, prefs)
138  $.ui.invalidate('ui.render')
139}
140
141// The settings button: opens the menu, or closes it when it is already showing.
142async function toggleMenu($: EngineInterface) {
143  const isOpen = (await $.ui.panes()).some(pane => pane.id === MENU && pane.isShown)
144  if (isOpen) await $.ui.close({ id: MENU }).catch(() => undefined)
145  else await openMenu($)
146}
147
148function openMenu($: EngineInterface) {
149  return $.ui.open({ id: MENU, title: 'clarity', focus: true, closeOnEscape: true, rows: 52 })
150}
151
152// Unasked (at start) it waits for room; asked (a press, a command) it opens at any width and
153// comes to the front, so the button never seems to do nothing.
154function openPanel($: EngineInterface, isAsked = true) {
155  return isAsked ? $.ui.open({ id: PANEL, title: 'Session', focus: true }) : $.ui.open({ id: PANEL, title: 'Session' })
156}
157
158// The terminal's own clipboard first; where it refuses (Cursor's terminal often does), the
159// Mac's pasteboard.
160function copyText($: EngineInterface, e: RenderInput, text: string) {
161  void (async () => {
162    const result = await $.ui.copy({ text, surface: e.surface }).catch(() => ({ isCopied: false }))
163    if (result.isCopied) return $.ui.toast('Copied')
164    const fallback = await $.process.run(['pbcopy'], { stdin: text }).catch(() => null)
165    $.ui.toast(fallback !== null && fallback.exitCode === 0 ? 'Copied' : 'Could not copy here')
166  })()
167}
168
169// Sets the font of the editor this terminal runs in (Cursor, VS Code) to the person's choice.
170// Asked (a pick in the menu), it says what it did; at session start it only keeps the editor
171// in step, quietly unless something needs saying.
172async function syncFont($: EngineInterface, choice: FontChoice, isAsked: boolean) {
173  const home = await $.env.get('HOME')
174  const path = editorSettingsPath({
175    home,
176    termProgram: await $.env.get('TERM_PROGRAM'),
177    askpass: await $.env.get('VSCODE_GIT_ASKPASS_MAIN'),
178    platform: home?.startsWith('/Users/') ? 'mac' : home?.startsWith('/home/') ? 'linux' : 'other',
179  })
180  if (path === null) {
181    if (isAsked) $.ui.toast('clarity: this terminal is not Cursor or VS Code, so set its font in its own settings.')
182    return
183  }
184  const before = await $.fs.read(path).catch(() => '')
185  const edit = applyFont(typeof before === 'string' ? before : '', choice)
186  if ('error' in edit) {
187    if (isAsked) $.ui.toast(`clarity: left the font alone: ${edit.error}.`)
188    return
189  }
190  if (!edit.isChanged) {
191    if (isAsked) $.ui.toast(choice === 'leave' ? 'clarity: the font is yours again.' : 'clarity: the font is already set.')
192    return
193  }
194  await $.fs.write(path, edit.text)
195  if (choice === 'leave') {
196    if (isAsked) $.ui.toast('clarity: took its font settings out; your editor\'s own font is back.')
197    return
198  }
199  const fonts = home === undefined ? '' : (await $.process.run(['ls', `${home}/Library/Fonts`]).catch(() => null))?.stdout ?? ''
200  const hasFont = /JetBrainsMono/i.test(fonts)
201  $.ui.toast(
202    hasFont
203      ? 'clarity: terminal font set. Quit and reopen the editor if any letters look out of line.'
204      : 'clarity: font set, using Menlo until JetBrains Mono is installed: brew install --cask font-jetbrains-mono',
205  )
206}
207
208async function alert($: EngineInterface, text: string) {
209  const { alerts } = await read($, prefsAtom)
210  if (alerts === 'notify' || alerts === 'both') {
211    await $.ui.notify(text, { title: 'Claude Code' }).catch(() => undefined)
212  }
213  if (alerts === 'sound' || alerts === 'both') {
214    await $.audio.play({ asset: 'assets/chime.wav' }).catch(() => undefined)
215  }
216}
217
218async function refreshContext($: EngineInterface) {
219  try {
220    const usage = await $.session.usage()
221    const percent = usage.context.percent ?? (usage.context.tokens !== undefined ? (usage.context.tokens / usage.context.window) * 100 : null)
222    await editLedger($, ledger => ({ ...ledger, context: percent, cost: usage.cost?.usd ?? ledger.cost, limit: nearestLimit(usage.rateLimits) }))
223  } catch {
224    // No usage to read here (a test, a headless run): the band leaves context out.
225  }
226}
227
228type RunView = { run: ToolRun; place: RunPlace; isOpen: boolean; toggle: () => void }
229
230// The run a row belongs to, when it is one of several calls made back to back.
231async function runOf($: EngineInterface, id: string | undefined): Promise<RunView | null> {
232  if (id === undefined) return null
233  const place = await read($, memberOf(placesAtom, { requestId: id }))
234  if (place === null) return null
235  const run = await read($, memberOf(runsAtom, { requestId: place.run }))
236  if (run === null || run.ids.length < 2) return null
237  const open = memberOf(expandedAtom, { requestId: place.run })
238  return { run, place, isOpen: await read($, open), toggle: () => void update($, open, v => !v) }
239}
240
241// ` 5 tools  [Bash ×2] [Edit] [Read ×2]  ✓ `: a run as one strip.
242function runLine(ui: UI, look: Look, view: RunView): RenderElement {
243  return toolLine(
244    ui,
245    look,
246    {
247      tools: groupTools(view.run.tools.map(tool => ({ tool }))),
248      detail: `${view.run.ids.length} tools`,
249      status: runStatus(view.run),
250      isOpen: view.isOpen,
251    },
252    view.toggle,
253  )
254}
255
256// A row in a run: the first draws the run's strip, the rest stay folded into it until it is
257// opened; a failure always shows.
258function inRun(ui: UI, look: Look, view: RunView, own: RenderElement, failure: RenderElement | null): RenderElement {
259  const { Box } = ui
260  if (!view.isOpen) {
261    if (view.place.index > 0) return failure ?? <Box />
262    return failure === null ? (
263      runLine(ui, look, view)
264    ) : (
265      <Box flexDirection="column">
266        {runLine(ui, look, view)}
267        {failure}
268      </Box>
269    )
270  }
271  return view.place.index === 0 ? (
272    <Box flexDirection="column">
273      {runLine(ui, look, view)}
274      {indent(ui, look, own)}
275    </Box>
276  ) : (
277    indent(ui, look, own)
278  )
279}
280
281function indent(ui: UI, look: Look, body: RenderElement): RenderElement {
282  return <ui.Box paddingLeft={look.isNarrow ? 0 : 2}>{body}</ui.Box>
283}
284
285export const register: Register = on => {
286  hasWarnedCramped = false
287
288  on('session.start', async ($, e, next) => {
289    await $.command.register({
290      name: 'clarity',
291      description: 'Readable transcript: opens the clarity menu (or: panel, on, off, focus, tools, status)',
292      argumentHint: '[panel | on | off | focus | tools summary | tools full | status]',
293    })
294    const prefs = readPrefs(await $.store.get(STORE_KEY))
295    // An older clarity pinned a status line; nothing here sets one now.
296    $.ui.status(undefined)
297    let theme = 'dark'
298    try {
299      const value = (await $.config.list()).find(row => row.key === 'theme')?.value
300      if (typeof value === 'string') theme = value
301    } catch {
302      // No /config here: the dark default stands.
303    }
304    await update($, prefsAtom, () => prefs)
305    await update($, envAtom, () => ({ theme }))
306    await refreshContext($)
307    // Opened unasked, the pane waits until the terminal is wide enough to dock it.
308    if (prefs.isOn && prefs.panel === 'auto') void openPanel($, false)
309    if (prefs.font !== 'leave') void syncFont($, prefs.font, false).catch(() => undefined)
310    return next(e)
311  })
312
313  on('command.run', { command: 'clarity' }, async ($, e) => {
314    const result = runCommand(e.args, await read($, prefsAtom))
315    if (result.open === 'menu') {
316      await openMenu($)
317      return {}
318    }
319    if (result.open === 'panel') {
320      await openPanel($)
321      return {}
322    }
323    await savePrefs($, result.prefs)
324    return { text: result.text }
325  })
326
327  // --- What the session is doing, for the band, the panel, the spinner and the alerts ---
328
329  on('prompt.submit', async ($, e, next) => {
330    if (e.origin.kind === 'composer' && e.command === undefined && e.isMeta !== true && e.text.trim() !== '') {
331      const text = firstLine(e.text, 200)
332      const whole = e.text.slice(0, 4000)
333      await editLedger($, ledger => ({
334        ...ledger,
335        turns: [...ledger.turns, text].slice(-200),
336        prompts: [...ledger.prompts, whole].slice(-200),
337      }))
338    }
339    return next(e)
340  })
341
342  on('turn.start', async ($, e, next) => {
343    if (e.agentId === undefined) {
344      const now = await $.clock.now()
345      await editActivity($, (): Activity => ({ phase: 'thinking', tool: '', detail: '', since: now, tokens: 0 }))
346      await update($, tickAtom, () => now)
347      const ledger = await readLedger($)
348      turnStart = { tools: toolCount, errors: ledger.errors.length, files: ledger.files }
349      currentRun = null
350      ticker?.cancel()
351      ticker = $.clock.every(1000, () => void $.clock.now().then(t => update($, tickAtom, () => t)))
352    }
353    return next(e)
354  })
355
356  // The model's output as it streams: whether it is thinking or writing, and how many tokens
357  // so far (four characters a token while it streams, the exact count when each step ends).
358  on('turn.step', async function* ($, e, next) {
359    const stream = next(e)
360    const isMain = e.agentId === undefined
361    const base = isMain ? await readActivity($).then(a => a.tokens, () => 0) : 0
362    let chars = 0
363    let shown = 0
364    let phase: Activity['phase'] = 'thinking'
365    for (;;) {
366      const step = await stream.next()
367      if (step.done === true) return step.value
368      const chunk = step.value
369      // Counting never stands in the stream's way: a failure here leaves the chunk to pass.
370      try {
371        if (isMain && chunk.kind === 'text') currentRun = null
372        if (isMain && (chunk.kind === 'text' || chunk.kind === 'thinking')) {
373          chars += chunk.text.length
374          const now: Activity['phase'] = chunk.kind === 'text' ? 'writing' : 'thinking'
375          const estimate = Math.round(chars / 4)
376          if (estimate - shown >= 40 || now !== phase) {
377            shown = estimate
378            phase = now
379            await editActivity($, (a): Activity => ({ ...a, phase: a.phase === 'tool' || a.phase === 'waiting' ? a.phase : now, tokens: base + estimate }))
380          }
381        } else if (isMain && chunk.kind === 'stop' && chunk.usage !== null) {
382          const exact = base + chunk.usage.output_tokens
383          await editActivity($, (a): Activity => ({ ...a, tokens: exact }))
384        }
385      } catch {
386        // The band's count is a nicety; the model's output is not.
387      }
388      yield chunk
389    }
390  })
391
392  on('tool.call', async ($, e, next) => {
393    const isMain = e.agentId === undefined
394    const tool = String(e.tool)
395    const id = e.tool_use_id
396    let runId: string | null = null
397    if (isMain) {
398      const isAsk = ASKS.has(tool)
399      await editActivity($, (a): Activity => ({ ...a, phase: isAsk ? 'waiting' : 'tool', tool, detail: describeInput(tool, e) }))
400      if (isAsk) void alert($, 'Claude needs your answer')
401      if (id !== undefined) {
402        // Calls with no text between them join one run, keyed by its first call.
403        runId = currentRun ?? id
404        currentRun = runId
405        const run = (await read($, memberOf(runsAtom, { requestId: runId }))) ?? { ids: [], tools: [], running: 0, failed: 0 }
406        const joined: ToolRun = { ...run, ids: [...run.ids, id], tools: [...run.tools, tool], running: run.running + 1 }
407        await update($, memberOf(runsAtom, { requestId: runId }), () => joined)
408        await update($, memberOf(placesAtom, { requestId: id }), () => ({ run: runId ?? id, index: joined.ids.length - 1 }))
409      }
410      const todos = tool === 'TodoWrite' ? parseTodos(e) : null
411      await editLedger($, ledger => ({
412        ...ledger,
413        ...(todos === null ? {} : { todos }),
414        agents: ledger.agents + (tool === 'Agent' ? 1 : 0),
415      }))
416    }
417    const ran = await next(e)
418    toolCount += 1
419    const isError = ran.deny === undefined && ran.isError === true
420    if (ran.deny === undefined) {
421      const path = editedPath(e)
422      if (isError) {
423        const failure = { tool, detail: describeInput(tool, e), text: firstLine(ran.text) }
424        await editLedger($, ledger => ({ ...ledger, errors: [...ledger.errors, failure].slice(-50) }))
425      } else if (EDITS.has(tool) && path !== undefined) {
426        await editLedger($, ledger => recordEdit(ledger, path, editDelta(tool, e)))
427      }
428    }
429    if (isMain) {
430      if (runId !== null) {
431        const finished = runId
432        await update($, memberOf(runsAtom, { requestId: finished }), run =>
433          run === null ? null : { ...run, running: Math.max(0, run.running - 1), failed: run.failed + (isError ? 1 : 0) },
434        )
435      }
436      const at = await $.clock.now()
437      const action = { at, tool, detail: describeInput(tool, e), isOk: !isError && ran.deny === undefined }
438      await editLedger($, ledger => ({
439        ...ledger,
440        recent: [...ledger.recent, action].slice(-8),
441        agents: Math.max(0, ledger.agents - (tool === 'Agent' ? 1 : 0)),
442      }))
443      await editActivity($, (a): Activity => ({ ...a, phase: 'thinking', tool: '', detail: '' }))
444    }
445    return ran
446  })
447
448  // Compaction: the band says it is happening and for how long, and a toast says what it freed.
449  // A compaction worked out ahead of time, in the background, is left alone.
450  on('session.compact', async ($, e, next) => {
451    if (e.trigger === 'precompute') return next(e)
452    const now = await $.clock.now()
453    const before = (await readLedger($)).context
454    await editActivity($, (): Activity => ({ phase: 'compacting', tool: '', detail: '', since: now, tokens: 0 }))
455    await update($, tickAtom, () => now)
456    ticker?.cancel()
457    ticker = $.clock.every(1000, () => void $.clock.now().then(t => update($, tickAtom, () => t)))
458    try {
459      return await next(e)
460    } finally {
461      ticker?.cancel()
462      ticker = undefined
463      await editActivity($, () => IDLE)
464      await refreshContext($)
465      const after = (await readLedger($)).context
466      const took = Math.round(((await $.clock.now()) - now) / 1000)
467      const freed = before !== null && after !== null ? ` · context ${Math.round(before)}% → ${Math.round(after)}%` : ''
468      $.ui.toast(`clarity: compacted in ${took}s${freed}`)
469    }
470  }).catch(($, e, next) => next(e))
471
472  on('turn.complete', async ($, e, next) => {
473    const result = await next(e)
474    if (e.agentId === undefined) {
475      const reply = 'answer' in e ? e.answer : ''
476      ticker?.cancel()
477      ticker = undefined
478      await editActivity($, () => IDLE)
479      const durationMs = 'durationMs' in e ? e.durationMs : 0
480      const before = turnStart
481      turnStart = null
482      await editLedger($, (ledger): Ledger => {
483        const summary = before !== null && durationMs > LONG_TURN_MS ? summarizeTurn(before, { tools: toolCount, errors: ledger.errors.length, files: ledger.files }) : null
484        const kept = Object.entries(ledger.summaries).slice(-49)
485        return {
486          ...ledger,
487          lastReply: reply,
488          summaries: summary === null ? ledger.summaries : Object.fromEntries([...kept, [String(durationMs), summary]]),
489        }
490      })
491      await refreshContext($)
492      if ('durationMs' in e && e.durationMs > LONG_TURN_MS) void alert($, `Done: ${firstLine(reply, 100) || 'the turn finished'}`)
493    }
494    return result
495  })
496
497  // --- Panes: the menu and the session panel ---
498
499  on('ui.render', { component: 'Pane', requestId: MENU }, async ($, e) => {
500    const ui = $.ui.resolve(e)
501    if (e.surface !== 'terminal') return <ui.Text>Open the clarity menu in the terminal.</ui.Text>
502    const prefs = await read($, prefsAtom)
503    const env = await read($, envAtom)
504    const l: Look = {
505      prefs,
506      colors: palettes[resolvePalette(prefs.palette, env.theme)],
507      columns: e.props.bodyColumns,
508      isNarrow: e.props.bodyColumns < NARROW,
509    }
510    const save = (change: Partial<Prefs>) => void savePrefs($, { ...prefs, ...change })
511    const onOff = [
512      { value: 'on', label: 'on' },
513      { value: 'off', label: 'off' },
514    ]
515    const flag = (v: boolean) => (v ? 'on' : 'off')
516    const choices = (values: readonly (string | number)[], label = (v: string | number) => String(v)) =>
517      values.map(v => ({ value: String(v), label: label(v) }))
518    const sections: MenuSection[] = [
519      {
520        title: 'General',
521        tone: l.colors.claude,
522        settings: [
523          { key: 'on', hotkey: '1', label: 'clarity', hint: 'Turn it off for regular Claude Code.', options: onOff, current: flag(prefs.isOn), pick: v => save({ isOn: v === 'on' }) },
524          { key: 'focus', hotkey: '2', label: 'focus', hint: 'Hides startup notices and tool output that worked.', options: onOff, current: flag(prefs.isFocus), pick: v => save({ isOn: true, isFocus: v === 'on' }) },
525        ],
526      },
527      {
528        title: 'Look',
529        tone: l.colors.you,
530        settings: [
531          { key: 'palette', hotkey: '3', label: 'colours', hint: 'Auto matches your Claude Code theme.', options: choices(PALETTES), current: prefs.palette, pick: v => save({ palette: v as Prefs['palette'] }) },
532          { key: 'width', hotkey: '4', label: 'line width', hint: 'How wide replies get before they wrap.', options: choices([...WIDTHS].sort((a, b) => (a || 999) - (b || 999)), v => (v === 0 ? 'full' : String(v))), current: String(prefs.measure), pick: v => save({ measure: Number(v) }) },
533          {
534            key: 'font',
535            hotkey: 'f',
536            label: 'terminal font',
537            hint: 'Sets the editor terminal to JetBrains Mono, readable or large.',
538            options: FONTS.map(f => ({ value: f, label: f })),
539            current: prefs.font,
540            pick: v => {
541              const font = v as FontChoice
542              save({ font })
543              void syncFont($, font, true).catch(() => $.ui.toast('clarity: could not change the font here.'))
544            },
545          },
546          {
547            key: 'tools',
548            hotkey: '5',
549            label: 'tool rows',
550            hint: 'Click any tool row to see its full output.',
551            options: [
552              { value: 'summary', label: 'one line' },
553              { value: 'full', label: 'full' },
554            ],
555            current: prefs.tools,
556            pick: v => save({ tools: v === 'full' ? 'full' : 'summary' }),
557          },
558        ],
559      },
560      {
561        title: 'Layout',
562        tone: l.colors.kinds.write,
563        settings: [
564          { key: 'band', hotkey: '6', label: 'status band', hint: 'The bar just above where you type.', options: onOff, current: flag(prefs.band), pick: v => save({ band: v === 'on' }) },
565          {
566            key: 'panel',
567            hotkey: '7',
568            label: 'session panel',
569            hint: "Opens beside the chat when there's room.",
570            options: [...choices(PANELS), { value: 'open', label: '▤ open now' }],
571            current: prefs.panel,
572            pick: v => {
573              if (v === 'open') {
574                void openPanel($)
575                return
576              }
577              save({ panel: v === 'off' ? 'off' : 'auto' })
578              if (v === 'auto') void openPanel($)
579              else void $.ui.close({ id: PANEL }).catch(() => undefined)
580            },
581          },
582        ],
583      },
584      {
585        title: 'Behaviour',
586        tone: l.colors.warn,
587        settings: [
588          { key: 'alerts', hotkey: '8', label: 'alerts', hint: 'A ping when Claude needs you or finishes a long task.', options: choices(ALERTS), current: prefs.alerts, pick: v => save({ alerts: v as Prefs['alerts'] }) },
589          { key: 'copy', hotkey: '9', label: 'copy buttons', hint: 'Copy buttons under code and long replies.', options: onOff, current: flag(prefs.copy), pick: v => save({ copy: v === 'on' }) },
590        ],
591      },
592    ]
593    return menu(elements($, e), l, sections, [
594      'Esc to close · /clarity panel to open the session panel',
595      'Tip: clarity works best on its own. Turn off other mods that restyle the transcript (like skins) in /plugin.',
596    ])
597  })
598
599  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e) => {
600    const ui = $.ui.resolve(e)
601    if (e.surface !== 'terminal') return <ui.Text>The session panel draws in the terminal.</ui.Text>
602    const prefs = await read($, prefsAtom)
603    const env = await read($, envAtom)
604    const l: Look = { prefs, colors: palettes[resolvePalette(prefs.palette, env.theme)], columns: e.props.bodyColumns, isNarrow: false }
605    const ledger = await readLedger($)
606    return panel(elements($, e), l, await readActivity($), ledger, e.props.bodyColumns, {
607      copyTurn: i => copyText($, e, ledger.prompts[i] ?? ledger.turns[i] ?? ''),
608    })
609  })
610
611  // --- The band above the prompt ---
612
613  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
614    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
615    const prefs = await read($, prefsAtom)
616    if (!prefs.isOn || !prefs.band) return next(e)
617    const below = await next(e)
618    const env = await read($, envAtom)
619    const l: Look = { prefs, colors: palettes[resolvePalette(prefs.palette, env.theme)], columns: e.props.bodyColumns, isNarrow: false }
620    const ui = elements($, e)
621    const ledger = await readLedger($)
622    const activity = await readActivity($)
623    const elapsed = activity.phase === 'idle' || activity.since === 0 ? 0 : Math.max(0, (await read($, tickAtom)) - activity.since)
624    const ours = band(ui, l, activity, ledger, e.props.bodyColumns, { elapsedMs: elapsed, tokens: activity.tokens }, {
625      panel: () => void openPanel($),
626      menu: () => void toggleMenu($),
627      copy: prefs.copy && ledger.lastReply !== '' ? () => copyText($, e, ledger.lastReply) : undefined,
628    })
629    // Another mod's band keeps its place under ours.
630    return drawnByMod(next.trace) !== null && below !== undefined && below !== null ? (
631      <ui.Box flexDirection="column">
632        {ours}
633        {below}
634      </ui.Box>
635    ) : (
636      ours
637    )
638  })
639
640  // --- The transcript ---
641
642  // The person's prompt: a labelled, tinted block. Over another mod's row, the label alone.
643  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
644    const l = await look($, e)
645    if (l === null || e.props.origin.kind !== 'composer') return next(e)
646    const below = await next(e)
647    const ui = elements($, e)
648    if (drawnByMod(next.trace) !== null) {
649      return (
650        <ui.Box flexDirection="column" marginTop={1}>
651          {badge(ui, l.colors, ' you ', l.colors.you)}
652          {below}
653          {attachmentCards(ui, l, parseAttachments(e.props.text))}
654        </ui.Box>
655      )
656    }
657    // A long prompt (a paste, say) folds to its first lines until opened.
658    const fold = foldText(e.props.text, FOLD_PROMPT)
659    const open = memberOf(expandedAtom, e)
660    const isOpen = fold !== null && (await read($, open))
661    const more: Chip | undefined =
662      fold === null
663        ? undefined
664        : { key: 'fold', label: isOpen ? '▴ show less' : `▾ show ${fold.hidden} more lines`, onPress: () => void update($, open, v => !v) }
665    return userRow(ui, l, fold === null || isOpen ? e.props.text : fold.head, parseAttachments(e.props.text), more)
666  })
667
668  // A reply: each block in a soft frame, paragraphs spaced, code in its own copyable box.
669  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
670    const l = await look($, e)
671    if (l === null || e.props.isSummary === true) return next(e)
672    const below = await next(e)
673    const ui = elements($, e)
674    const byMod = drawnByMod(next.trace) !== null
675    const text = e.props.text
676    const onCopy = l.prefs.copy ? (code: string) => copyText($, e, code) : undefined
677    // A long reply folds to its first lines until opened (clarity's own drawing only).
678    const fold = byMod ? null : foldText(text, FOLD_REPLY)
679    const open = memberOf(expandedAtom, e)
680    const isOpen = fold !== null && (await read($, open))
681    const chips: Chip[] = [
682      ...(fold === null
683        ? []
684        : [{ key: 'fold', label: isOpen ? '▴ show less' : `▾ show ${fold.hidden} more lines`, onPress: () => void update($, open, v => !v) }]),
685      ...(l.prefs.copy && text.split('\n').length >= COPY_LINES
686        ? [{ key: 'copy-reply', label: '⧉ copy reply', onPress: () => copyText($, e, text) }]
687        : []),
688    ]
689    // Another mod sizes its own tables and cards to the width: leave it the whole of it.
690    const boxLook = byMod ? { ...l, prefs: { ...l.prefs, measure: 0 } } : l
691    const shown = fold === null || isOpen ? text : fold.head
692    const body = byMod ? below : replyBlocks(ui, l, splitBlocks(shown), onCopy)
693    return replyBox(ui, boxLook, e.props.isFirstOfReply, body, chips, !byMod)
694  })
695
696  // One tool call: one clickable line (an edit's line counts on it); a failure gets a red box.
697  // Calls made back to back fold into one strip for the run until it is opened.
698  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
699    const l = await look($, e)
700    if (l === null) return next(e)
701    const p = e.props
702    const below = await next(e)
703    const ui = elements($, e)
704    if (drawnByMod(next.trace) !== null) return indent(ui, l, below)
705    const detail = describeInput(p.tool, p.input)
706    const failure = p.isErrored && !p.isInterrupted ? failHeader(ui, l, p.tool, detail, below) : null
707    if (l.prefs.tools === 'full') return failure ?? below
708    const open = memberOf(expandedAtom, e)
709    const isOpen = await read($, open)
710    const status: Status = p.isRunning ? 'running' : p.isInterrupted ? 'interrupted' : 'ok'
711    const delta = EDITS.has(p.tool) ? editDelta(p.tool, p.input) : undefined
712    const parts: ToolParts = { tools: [{ tool: p.tool, label: p.tool }], detail, status, ...(delta === undefined ? {} : { delta }) }
713    const toggle = () => void update($, open, v => !v)
714    const own = failure ?? (isOpen ? openedRow(ui, l, parts, toggle, below) : toolLine(ui, l, parts, toggle))
715    const view = await runOf($, p.tool_use_id)
716    return view === null ? own : inRun(ui, l, view, own, failure)
717  })
718
719  // A tool's output: shown when opened, for edits and errors; a line count for shell output.
720  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
721    const l = await look($, e)
722    if (l === null) return next(e)
723    const p = e.props
724    const below = await next(e)
725    const ui = elements($, e)
726    if (drawnByMod(next.trace) !== null) return indent(ui, l, below)
727    if (l.prefs.tools === 'full' || p.isErrored) return below
728    const view = await runOf($, p.tool_use_id)
729    if (view !== null && !view.isOpen) return <ui.Box />
730    if (KEEP_RESULT.has(p.tool)) return below
731    if (await read($, memberOf(expandedAtom, e))) return indent(ui, l, below)
732    if (p.tool !== 'Bash' || l.prefs.isFocus) return <ui.Box />
733    const lines = lineCount(p.output)
734    return (
735      <ui.Box paddingLeft={l.isNarrow ? 2 : 4}>
736        <ui.Text color={l.colors.muted}>{lines === 0 ? '⎿ no output' : `⎿ ${lines} line${lines === 1 ? '' : 's'} of output · click the row to see it`}</ui.Text>
737      </ui.Box>
738    )
739  })
740
741  // A folded run of reads and searches: one clickable line of tool names.
742  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
743    const l = await look($, e)
744    if (l === null || e.props.isExpanded) return next(e)
745    const ui = elements($, e)
746    if (l.prefs.tools === 'full') {
747      const below = await next(e)
748      return drawnByMod(next.trace) !== null ? indent(ui, l, below) : below
749    }
750    const calls = e.props.calls
751    const status = statusOf(calls)
752    const open = memberOf(expandedAtom, e)
753    const isOpen = await read($, open)
754    const first = calls[0]
755    const parts: ToolParts = {
756      tools: groupTools(calls),
757      detail: calls.length === 1 && first !== undefined ? describeInput(first.tool, first.input) : '',
758      status,
759    }
760    const toggle = () => void update($, open, v => !v)
761    const view = await runOf($, first?.tool_use_id)
762    if (isOpen || status === 'error') {
763      const below = await next({ ...e, props: { ...e.props, isExpanded: true } })
764      if (drawnByMod(next.trace) !== null) return indent(ui, l, below)
765      const own = openedRow(ui, l, parts, toggle, below)
766      return view === null ? own : inRun(ui, l, view, own, status === 'error' ? own : null)
767    }
768    const below = await next(e)
769    if (drawnByMod(next.trace) !== null) return indent(ui, l, below)
770    const own = toolLine(ui, l, parts, toggle)
771    return view === null ? own : inRun(ui, l, view, own, null)
772  })
773
774  // The end of a turn: a rule, so turns read as blocks. Under another mod's footer, the rule alone.
775  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
776    const l = await look($, e)
777    if (l === null) return next(e)
778    const below = await next(e)
779    const ui = elements($, e)
780    // A long turn closes with what it did, for whoever looked away while it ran.
781    const summary = (await readLedger($)).summaries[String(e.props.durationMs)]
782    const away = summary === undefined || summary.tools === 0 ? null : awayCard(ui, l, e.props.durationMs, summary)
783    if (drawnByMod(next.trace) !== null) {
784      return (
785        <ui.Box flexDirection="column" marginBottom={1}>
786          {away}
787          {below}
788          <ui.Text color={l.colors.rule}>{'─'.repeat(Math.max(10, l.columns - 2))}</ui.Text>
789        </ui.Box>
790      )
791    }
792    return away === null ? (
793      turnRule(ui, l, e.props.durationMs)
794    ) : (
795      <ui.Box flexDirection="column">
796        {away}
797        {turnRule(ui, l, e.props.durationMs)}
798      </ui.Box>
799    )
800  })
801
802  // A slash command's output in a titled card.
803  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
804    const l = await look($, e)
805    if (l === null || e.props.text.trim() === '') return next(e)
806    const below = await next(e)
807    if (drawnByMod(next.trace) !== null) return below
808    const ui = elements($, e)
809    const title = `/${e.props.command}${e.props.args === '' ? '' : ` ${clip(e.props.args, 40)}`}`
810    return card(ui, l, title, <ui.Markdown text={e.props.text} />, e.props.isErrored)
811  })
812
813  // A question for the person: a banner so it is never missed.
814  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
815    const l = await look($, e)
816    if (l === null) return next(e)
817    return questionBanner(elements($, e), l, await next(e))
818  })
819
820  // While the band is on it is the one status line, with its own timer: the stock spinner
821  // steps aside. With the band off, the spinner says what is happening.
822  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
823    const l = await look($, e)
824    if (l === null) return next(e)
825    // Claude Code's own messages (compacting, retrying) always show; only the word steps aside.
826    if (e.props.message !== null) return next(e)
827    if (l.prefs.band) return elements($, e).Box({})
828    const activity = await readActivity($)
829    if (activity.phase !== 'tool' && activity.phase !== 'waiting') return next(e)
830    return next({ ...e, props: { ...e.props, message: clip(activityText(activity), Math.max(20, l.columns - 40)) } })
831  })
832
833  // Startup notices: one short dim line, or none in focus mode.
834  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
835    const l = await look($, e)
836    if (l === null) return next(e)
837    const ui = elements($, e)
838    if (l.prefs.isFocus) return <ui.Box />
839    const command = e.props.command === null ? '' : `  ${e.props.command}`
840    return (
841      <ui.Text color={l.colors.muted} wrap="truncate">
842        · {e.props.text}
843        {command}
844      </ui.Text>
845    )
846  })
847}
848
hooks/logic.ts 699 lines
1// Pure helpers: no `$`, so tests import them directly.
2import type { Activity, Alerts, FileChange, FontChoice, IconChoice, Ledger, PaletteChoice, PaletteName, PanelChoice, Prefs, Todo, ToolRun, TurnSummary } from '../types'
3
4// What a tool does, for its colour: reading, searching, running, writing, the web, delegating.
5export type Kind = 'read' | 'search' | 'run' | 'write' | 'web' | 'agent' | 'other'
6
7export type Palette = {
8  kinds: Record<Kind, string>
9  text: string
10  muted: string
11  you: string
12  youBg: string | undefined
13  claude: string
14  tool: string
15  ok: string
16  error: string
17  warn: string
18  rule: string
19  accentBg: string | undefined
20  /** Dark text on a coloured chip; undefined draws chips inverse instead. */
21  chipText: string | undefined
22  /** The toolbar's background. */
23  barBg: string | undefined
24}
25
26// Hex chosen for at least 7:1 (text) and 4.5:1 (accents) against a near-black background.
27const DARK: Palette = {
28  kinds: { read: '#82AAFF', search: '#C3A6FF', run: '#FFB86C', write: '#A6E3A1', web: '#7DCFFF', agent: '#FF9AC1', other: '#A3ACBD' },
29  text: '#E6E9EF',
30  muted: '#A3ACBD',
31  you: '#82AAFF',
32  youBg: '#1B2233',
33  claude: '#F0A875',
34  tool: '#7FD6C2',
35  ok: '#A6E3A1',
36  error: '#FF8A9B',
37  warn: '#F2C97D',
38  rule: '#4A5263',
39  accentBg: '#262B38',
40  chipText: '#10131A',
41  barBg: '#1F2430',
42}
43
44// As bright as a terminal goes: white text, saturated accents, no mid-greys.
45const CONTRAST: Palette = {
46  kinds: { read: '#5FB3FF', search: '#D69CFF', run: '#FFA640', write: '#7CFF7C', web: '#4DF0F0', agent: '#FF7AC8', other: '#D0D4DC' },
47  text: '#FFFFFF',
48  muted: '#D0D4DC',
49  you: '#5FB3FF',
50  youBg: '#0B2340',
51  claude: '#FFB454',
52  tool: '#4DF0D0',
53  ok: '#7CFF7C',
54  error: '#FF5C7A',
55  warn: '#FFE066',
56  rule: '#8A93A6',
57  accentBg: '#1E2533',
58  chipText: '#000000',
59  barBg: '#141922',
60}
61
62// Okabe–Ito hues: told apart with any common colour-vision deficiency.
63const COLORBLIND: Palette = {
64  kinds: { read: '#56B4E9', search: '#CC79A7', run: '#E69F00', write: '#009E73', web: '#F0E442', agent: '#D55E00', other: '#B5B5B5' },
65  text: '#EDEDED',
66  muted: '#B5B5B5',
67  you: '#56B4E9',
68  youBg: '#13283A',
69  claude: '#E69F00',
70  tool: '#CC79A7',
71  ok: '#009E73',
72  error: '#D55E00',
73  warn: '#F0E442',
74  rule: '#5A5A5A',
75  accentBg: '#262626',
76  chipText: '#111111',
77  barBg: '#1E1E1E',
78}
79
80// Theme keys only, so a light (or any custom) theme stays legible.
81const THEME: Palette = {
82  kinds: { read: 'suggestion', search: 'remember', run: 'warning', write: 'success', web: 'ide', agent: 'claude', other: 'inactive' },
83  text: 'text',
84  muted: 'inactive',
85  you: 'suggestion',
86  youBg: undefined,
87  claude: 'claude',
88  tool: 'permission',
89  ok: 'success',
90  error: 'error',
91  warn: 'warning',
92  rule: 'subtle',
93  accentBg: undefined,
94  chipText: undefined,
95  barBg: undefined,
96}
97
98export const palettes: Record<PaletteName, Palette> = { dark: DARK, contrast: CONTRAST, colorblind: COLORBLIND, theme: THEME }
99
100export function resolvePalette(choice: PaletteChoice, theme: string): PaletteName {
101  if (choice !== 'auto') return choice
102  // auto: our hex only where the person's theme is a dark one.
103  return theme.startsWith('dark') ? 'dark' : 'theme'
104}
105
106type TraceLink = { readonly plugin: string; readonly tier: string }
107
108const KINDS: Record<string, Kind> = {
109  Read: 'read',
110  NotebookRead: 'read',
111  LSP: 'read',
112  Grep: 'search',
113  Glob: 'search',
114  ToolSearch: 'search',
115  Bash: 'run',
116  Monitor: 'run',
117  Edit: 'write',
118  MultiEdit: 'write',
119  Write: 'write',
120  NotebookEdit: 'write',
121  TodoWrite: 'write',
122  WebFetch: 'web',
123  WebSearch: 'web',
124  Agent: 'agent',
125  Skill: 'agent',
126  Workflow: 'agent',
127}
128
129export function kindOf(tool: string): Kind {
130  return KINDS[tool] ?? (tool.startsWith('mcp__') ? 'web' : 'other')
131}
132
133// The tools of a folded group as labels with counts, in first-seen order: `Read ×2`, `Grep`.
134export function groupTools(calls: readonly { tool: string }[]): { tool: string; label: string }[] {
135  const counts = new Map<string, number>()
136  for (const call of calls) counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1)
137  return [...counts].map(([tool, n]) => ({ tool, label: n > 1 ? `${tool} ×${n}` : tool }))
138}
139
140// The tiers of plugins a person or their admin installs; `builtin` and
141// `core` are Claude Code's own drawing, which clarity replaces rather than decorates.
142const MOD_TIERS = new Set(['user', 'prepend', 'append'])
143
144// The mod that drew a row beneath clarity, or null when Claude Code drew it (or nothing did).
145// A trace ends at the link that answered itself, so its last entry is the one that drew.
146export function drawnByMod(trace: readonly TraceLink[]): string | null {
147  const last = trace[trace.length - 1]
148  if (last === undefined || !MOD_TIERS.has(last.tier)) return null
149  return last.plugin
150}
151
152// Below this the indents go; labels always stay, they are what tells rows apart.
153export const NARROW = 60
154
155function basename(path: string): string {
156  const parts = path.split('/').filter(Boolean)
157  return parts.slice(-2).join('/') || path
158}
159
160function field(input: unknown, key: string): string | undefined {
161  if (input === null || typeof input !== 'object') return undefined
162  const value = (input as Record<string, unknown>)[key]
163  return typeof value === 'string' && value.length > 0 ? value : undefined
164}
165
166export function shortPath(path: string): string {
167  return basename(path)
168}
169
170// The one fact that identifies a call: its file, command, pattern or target.
171export function describeInput(tool: string, input: unknown): string {
172  const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? field(input, 'path')
173  if (path !== undefined && tool !== 'Grep' && tool !== 'Glob') return basename(path)
174  const first =
175    field(input, 'command') ??
176    field(input, 'pattern') ??
177    field(input, 'query') ??
178    field(input, 'url') ??
179    field(input, 'description') ??
180    field(input, 'skill') ??
181    field(input, 'prompt')
182  if (first !== undefined) return first.split('\n')[0] ?? ''
183  if (input !== null && typeof input === 'object') {
184    for (const value of Object.values(input)) {
185      if (typeof value === 'string' && value.length > 0) return value.split('\n')[0] ?? ''
186    }
187  }
188  return ''
189}
190
191export function editedPath(input: unknown): string | undefined {
192  return field(input, 'file_path') ?? field(input, 'notebook_path')
193}
194
195const VERBS: Record<string, string> = {
196  Bash: 'Running',
197  Read: 'Reading',
198  Edit: 'Editing',
199  MultiEdit: 'Editing',
200  Write: 'Writing',
201  NotebookEdit: 'Editing',
202  Grep: 'Searching for',
203  Glob: 'Finding',
204  WebFetch: 'Fetching',
205  WebSearch: 'Searching the web for',
206  Agent: 'Delegating:',
207  Skill: 'Using skill',
208  TodoWrite: 'Updating the plan',
209}
210
211// `Running npm test`, `Editing src/app.ts`: what the spinner and the band say.
212export function activityText(activity: Activity): string {
213  switch (activity.phase) {
214    case 'idle':
215      return 'Ready'
216    case 'thinking':
217      return 'Thinking'
218    case 'writing':
219      return 'Writing'
220    case 'compacting':
221      return 'Compacting the conversation'
222    case 'waiting':
223      return 'Waiting for you'
224    case 'tool': {
225      const verb = VERBS[activity.tool] ?? `Using ${activity.tool}`
226      return activity.detail === '' ? verb : `${verb} ${activity.detail}`
227    }
228  }
229}
230
231// Tools that stop and wait on the person.
232export const ASKS = new Set(['AskUserQuestion', 'ExitPlanMode'])
233
234export const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
235
236function countLines(text: string | undefined): number {
237  return text === undefined || text === '' ? 0 : text.split('\n').length
238}
239
240// Lines in and out of one edit, from its input (an estimate: a rewrite counts whole).
241export function editDelta(tool: string, input: unknown): { added: number; removed: number } {
242  if (tool === 'Write') return { added: countLines(field(input, 'content')), removed: 0 }
243  if (tool === 'MultiEdit' && input !== null && typeof input === 'object') {
244    const edits = (input as { edits?: unknown }).edits
245    if (Array.isArray(edits)) {
246      return edits.reduce(
247        (sum: { added: number; removed: number }, one: unknown) => ({
248          added: sum.added + countLines(field(one, 'new_string')),
249          removed: sum.removed + countLines(field(one, 'old_string')),
250        }),
251        { added: 0, removed: 0 },
252      )
253    }
254  }
255  return {
256    added: countLines(field(input, 'new_string') ?? field(input, 'new_source')),
257    removed: countLines(field(input, 'old_string')),
258  }
259}
260
261export function recordEdit(ledger: Ledger, path: string, delta: { added: number; removed: number }): Ledger {
262  const before = ledger.files[path] ?? { added: 0, removed: 0, edits: 0 }
263  return {
264    ...ledger,
265    files: {
266      ...ledger.files,
267      [path]: { added: before.added + delta.added, removed: before.removed + delta.removed, edits: before.edits + 1 },
268    },
269  }
270}
271
272export function firstLine(text: unknown, room = 120): string {
273  const s = typeof text === 'string' ? text : ''
274  return clip((s.split('\n').find(line => line.trim() !== '') ?? '').trim(), room)
275}
276
277export type Attachment = { kind: 'file' | 'folder' | 'image' | 'paste'; name: string; detail: string }
278
279// What a prompt brings along, read from its text: `@path` mentions (a trailing slash is a
280// folder), pasted images (`[Image #1]`) and pasted text (`[Pasted text #1 +40 lines]`).
281export function parseAttachments(text: string): Attachment[] {
282  const found: Attachment[] = []
283  const seen = new Set<string>()
284  const add = (a: Attachment) => {
285    const id = `${a.kind}:${a.name}:${a.detail}`
286    if (!seen.has(id)) {
287      seen.add(id)
288      found.push(a)
289    }
290  }
291  for (const m of text.matchAll(/(?:^|\s)@(?:"([^"]+)"|([^\s@]+))/g)) {
292    const path = (m[1] ?? m[2] ?? '').replace(/[.,;:!?)]+$/, '')
293    if (path === '') continue
294    const isFolder = path.endsWith('/')
295    const parts = path.replace(/\/$/, '').split('/')
296    const name = parts.pop() ?? path
297    add({ kind: isFolder ? 'folder' : 'file', name, detail: parts.slice(-2).join('/') })
298  }
299  for (const m of text.matchAll(/\[Image #(\d+)\]/g)) add({ kind: 'image', name: `Image ${m[1]}`, detail: '' })
300  for (const m of text.matchAll(/\[Pasted text #(\d+)(?: \+(\d+) lines?)?\]/g)) {
301    add({ kind: 'paste', name: `Pasted text ${m[1]}`, detail: m[2] === undefined ? '' : `${m[2]} lines` })
302  }
303  return found
304}
305
306export type Block = { kind: 'prose'; text: string } | { kind: 'code'; text: string; lang: string; body: string }
307
308// A reply cut into paragraphs and fenced code blocks, so each can sit apart with room around it.
309// A list or table stays whole: only a blank line ends a prose block.
310export function splitBlocks(markdown: string): Block[] {
311  const blocks: Block[] = []
312  const lines = markdown.replace(/\r\n/g, '\n').split('\n')
313  let prose: string[] = []
314  const flush = () => {
315    const text = prose.join('\n').trim()
316    if (text !== '') blocks.push({ kind: 'prose', text })
317    prose = []
318  }
319  for (let i = 0; i < lines.length; i++) {
320    const line = lines[i] ?? ''
321    const fence = /^(\s*)(```+|~~~+)(.*)$/.exec(line)
322    if (fence !== null) {
323      flush()
324      const mark = fence[2] ?? '```'
325      const body: string[] = []
326      let j = i + 1
327      while (j < lines.length && !(lines[j] ?? '').trim().startsWith(mark)) body.push(lines[j++] ?? '')
328      const lang = (fence[3] ?? '').trim()
329      blocks.push({ kind: 'code', lang, body: body.join('\n'), text: [line, ...body, mark].join('\n') })
330      i = j
331      continue
332    }
333    if (line.trim() === '') flush()
334    else prose.push(line)
335  }
336  flush()
337  return blocks
338}
339
340// Every fenced code block's body, joined, for the copy-code button.
341export function codeBlocks(markdown: string): string {
342  const blocks: string[] = []
343  const re = /```[^\n]*\n([\s\S]*?)```/g
344  let m: RegExpExecArray | null
345  while ((m = re.exec(markdown)) !== null) blocks.push((m[1] ?? '').replace(/\n$/, ''))
346  return blocks.join('\n\n')
347}
348
349export function clip(text: string, room: number): string {
350  if (room <= 1) return ''
351  return text.length <= room ? text : `${text.slice(0, room - 1)}…`
352}
353
354type CallLike = { tool: string; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
355
356// `Read ×2 · Grep · Bash`, in first-seen order.
357export function groupSummary(calls: readonly CallLike[]): string {
358  const counts = new Map<string, number>()
359  for (const call of calls) counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1)
360  return [...counts].map(([tool, n]) => (n > 1 ? `${tool} ×${n}` : tool)).join(' · ')
361}
362
363export type Status = 'running' | 'error' | 'interrupted' | 'ok'
364
365export function statusOf(calls: readonly CallLike[]): Status {
366  if (calls.some(call => call.isRunning)) return 'running'
367  if (calls.some(call => call.isErrored)) return 'error'
368  if (calls.some(call => call.isInterrupted)) return 'interrupted'
369  return 'ok'
370}
371
372export const MARK: Record<Status, string> = { running: '…', error: '✗', interrupted: '⊘', ok: '✓' }
373
374// Tools whose result is the point (a diff, a plan): their result stays in summary mode.
375export const KEEP_RESULT = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'ExitPlanMode', 'TodoWrite', 'AskUserQuestion'])
376
377export function lineCount(output: unknown): number {
378  const text =
379    typeof output === 'string'
380      ? output
381      : output !== null && typeof output === 'object'
382        ? [field(output, 'stdout'), field(output, 'stderr')].filter(Boolean).join('\n')
383        : ''
384  return text.length === 0 ? 0 : text.trimEnd().split('\n').length
385}
386
387// `8.5k`, as the stock spinner counts tokens.
388export function formatTokens(n: number): string {
389  return n < 1000 ? String(Math.round(n)) : `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
390}
391
392export function formatDuration(ms: number): string {
393  const seconds = Math.round(ms / 1000)
394  if (seconds < 60) return `${seconds}s`
395  return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
396}
397
398export function nextOf<T extends string | number>(list: readonly T[], current: T): T {
399  const at = list.indexOf(current)
400  return list[(at + 1) % list.length] ?? list[0]!
401}
402
403export const PALETTES: readonly PaletteChoice[] = ['auto', 'dark', 'contrast', 'colorblind', 'theme']
404export const ALERTS: readonly Alerts[] = ['notify', 'sound', 'both', 'off']
405export const PANELS: readonly PanelChoice[] = ['auto', 'off']
406// 80 is a comfortable reading measure; the menu cycles from it.
407export const WIDTHS: readonly number[] = [80, 70, 100, 120, 0]
408export const FONTS: readonly FontChoice[] = ['leave', 'readable', 'large']
409export const ICONS: readonly IconChoice[] = ['unicode', 'ascii']
410
411// The editor terminal settings each font choice writes. JetBrains Mono first, Menlo where it
412// is not installed; ligatures off so `!=` and `->` read as typed.
413const FONT_KEYS: Record<Exclude<FontChoice, 'leave'>, Record<string, unknown>> = {
414  readable: {
415    'terminal.integrated.fontFamily': "'JetBrains Mono', Menlo, monospace",
416    'terminal.integrated.fontSize': 13,
417    'terminal.integrated.lineHeight': 1.3,
418    'terminal.integrated.fontWeightBold': 'bold',
419    'terminal.integrated.fontLigatures.enabled': false,
420    'terminal.integrated.smoothScrolling': true,
421  },
422  large: {
423    'terminal.integrated.fontFamily': "'JetBrains Mono', Menlo, monospace",
424    'terminal.integrated.fontSize': 14,
425    'terminal.integrated.lineHeight': 1.35,
426    'terminal.integrated.fontWeightBold': 'bold',
427    'terminal.integrated.fontLigatures.enabled': false,
428    'terminal.integrated.smoothScrolling': true,
429  },
430}
431const FONT_KEY_NAMES = Object.keys(FONT_KEYS.readable)
432
433// Where the editor that hosts this terminal keeps its settings, from its environment.
434export function editorSettingsPath(env: { home?: string; termProgram?: string; askpass?: string; platform: 'mac' | 'linux' | 'other' }): string | null {
435  if (env.home === undefined || env.termProgram !== 'vscode' || env.platform === 'other') return null
436  const app = (env.askpass ?? '').includes('Cursor') ? 'Cursor' : 'Code'
437  return env.platform === 'mac' ? `${env.home}/Library/Application Support/${app}/User/settings.json` : `${env.home}/.config/${app}/User/settings.json`
438}
439
440export type FontEdit = { text: string; isChanged: boolean } | { error: string }
441
442// The editor's settings with clarity's font keys set (or, for `leave`, taken out). A file with
443// comments or trailing commas is not plain JSON: it is left alone rather than rewritten.
444export function applyFont(settingsText: string, choice: FontChoice): FontEdit {
445  let settings: Record<string, unknown>
446  try {
447    const parsed: unknown = settingsText.trim() === '' ? {} : JSON.parse(settingsText)
448    if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'the settings file is not a JSON object' }
449    settings = parsed as Record<string, unknown>
450  } catch {
451    return { error: 'the settings file has comments or trailing commas' }
452  }
453  const next: Record<string, unknown> = { ...settings }
454  if (choice === 'leave') for (const key of FONT_KEY_NAMES) delete next[key]
455  else Object.assign(next, FONT_KEYS[choice])
456  const isChanged = JSON.stringify(next) !== JSON.stringify(settings)
457  return { text: `${JSON.stringify(next, null, 4)}\n`, isChanged }
458}
459
460export type CommandResult = { prefs: Prefs; text: string; open?: 'menu' | 'panel' }
461
462const HELP = '/clarity opens the menu · /clarity panel · on | off | focus | tools summary | tools full | status'
463
464export function runCommand(args: string, prefs: Prefs): CommandResult {
465  const [word, arg] = args.trim().toLowerCase().split(/\s+/)
466  const verb = word || 'menu'
467  switch (verb) {
468    case 'menu':
469      return { prefs, text: '', open: 'menu' }
470    case 'panel':
471      return { prefs, text: '', open: 'panel' }
472    case 'on':
473      return { prefs: { ...prefs, isOn: true }, text: 'on.' }
474    case 'off':
475      return { prefs: { ...prefs, isOn: false }, text: 'off: Claude Code draws everything as usual. /clarity on brings it back.' }
476    case 'focus': {
477      const isFocus = arg === 'on' ? true : arg === 'off' ? false : !prefs.isFocus
478      return {
479        prefs: { ...prefs, isOn: true, isFocus },
480        text: isFocus ? 'Focus on: notices and successful tool output are hidden.' : 'Focus off.',
481      }
482    }
483    case 'tools': {
484      const tools = arg === 'full' ? 'full' : arg === 'summary' ? 'summary' : prefs.tools === 'full' ? 'summary' : 'full'
485      return {
486        prefs: { ...prefs, tools },
487        text: tools === 'full' ? 'Tool rows are drawn in full.' : 'Tool rows are one line each; click one to open it.',
488      }
489    }
490    case 'status':
491      return { prefs, text: status(prefs) }
492    default:
493      return { prefs, text: HELP }
494  }
495}
496
497export function status(prefs: Prefs): string {
498  if (!prefs.isOn) return `off. ${HELP}`
499  return `on · colours ${prefs.palette} · tools ${prefs.tools} · focus ${prefs.isFocus ? 'on' : 'off'} · band ${prefs.band ? 'on' : 'off'} · alerts ${prefs.alerts}. ${HELP}`
500}
501
502export const DEFAULT_PREFS: Prefs = {
503  isOn: true,
504  isFocus: false,
505  tools: 'summary',
506  palette: 'auto',
507  alerts: 'notify',
508  band: true,
509  panel: 'auto',
510  measure: 80,
511  copy: true,
512  font: 'leave',
513  icons: 'unicode',
514}
515
516function oneOf<T>(list: readonly T[], value: unknown, fallback: T): T {
517  return list.includes(value as T) ? (value as T) : fallback
518}
519
520export function readPrefs(stored: unknown): Prefs {
521  if (stored === null || typeof stored !== 'object') return DEFAULT_PREFS
522  const s = stored as Partial<Record<keyof Prefs, unknown>>
523  const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
524  return {
525    isOn: bool(s.isOn, DEFAULT_PREFS.isOn),
526    isFocus: bool(s.isFocus, DEFAULT_PREFS.isFocus),
527    tools: oneOf(['summary', 'full'] as const, s.tools, DEFAULT_PREFS.tools),
528    palette: oneOf(PALETTES, s.palette, DEFAULT_PREFS.palette),
529    alerts: oneOf(ALERTS, s.alerts, DEFAULT_PREFS.alerts),
530    band: bool(s.band, DEFAULT_PREFS.band),
531    panel: oneOf(PANELS, s.panel, DEFAULT_PREFS.panel),
532    measure: oneOf(WIDTHS, s.measure, DEFAULT_PREFS.measure),
533    copy: bool(s.copy, DEFAULT_PREFS.copy),
534    font: oneOf(FONTS, s.font, DEFAULT_PREFS.font),
535    icons: oneOf(ICONS, s.icons, DEFAULT_PREFS.icons),
536  }
537}
538
539export const EMPTY_LEDGER: Ledger = {
540  turns: [],
541  prompts: [],
542  files: {},
543  errors: [],
544  lastReply: '',
545  context: null,
546  cost: null,
547  limit: null,
548  stopped: null,
549  todos: [],
550  recent: [],
551  agents: 0,
552  summaries: {},
553}
554
555// Claude's plan, from a TodoWrite call's input; anything malformed is left out.
556export function parseTodos(input: unknown): Todo[] | null {
557  const todos = input !== null && typeof input === 'object' ? (input as { todos?: unknown }).todos : undefined
558  if (!Array.isArray(todos)) return null
559  return todos.flatMap((t: unknown): Todo[] => {
560    const content = field(t, 'content')
561    const status = field(t, 'status')
562    if (content === undefined || (status !== 'pending' && status !== 'in_progress' && status !== 'completed')) return []
563    return [{ content, status, activeForm: field(t, 'activeForm') ?? content }]
564  })
565}
566
567// `task 2/5`: the step Claude is on, counted from one.
568export function planProgress(todos: readonly Todo[]): { at: number; of: number; doing: string } | null {
569  const doing = todos.findIndex(t => t.status === 'in_progress')
570  if (todos.length === 0 || doing < 0) return null
571  return { at: doing + 1, of: todos.length, doing: todos[doing]?.activeForm ?? '' }
572}
573
574export type TurnSnapshot = { tools: number; errors: number; files: Record<string, FileChange> }
575
576// What a turn did: the difference between the session before it and after it.
577export function summarizeTurn(before: TurnSnapshot, after: TurnSnapshot): TurnSummary {
578  let files = 0
579  let added = 0
580  let removed = 0
581  for (const [path, change] of Object.entries(after.files)) {
582    const was = before.files[path] ?? { added: 0, removed: 0, edits: 0 }
583    if (change.edits > was.edits) files += 1
584    added += change.added - was.added
585    removed += change.removed - was.removed
586  }
587  return { tools: after.tools - before.tools, files, added, removed, failed: after.errors - before.errors }
588}
589
590export function runStatus(run: ToolRun): Status {
591  return run.running > 0 ? 'running' : run.failed > 0 ? 'error' : 'ok'
592}
593
594// `5:07 pm`, for the Recent list.
595export function formatClock(ms: number): string {
596  const d = new Date(ms)
597  const h = d.getHours()
598  return `${h % 12 === 0 ? 12 : h % 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'am' : 'pm'}`
599}
600export const IDLE: Activity = { phase: 'idle', tool: '', detail: '', since: 0, tokens: 0 }
601
602// State an earlier version of clarity wrote lacks the fields added since; a hot reload keeps
603// it. Reading through these fills in what is missing, so old state never breaks a hook.
604export function fillLedger(raw: unknown): Ledger {
605  const r = (raw !== null && typeof raw === 'object' ? raw : {}) as Partial<Record<keyof Ledger, unknown>>
606  const list = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : [])
607  const record = <T>(v: unknown): Record<string, T> => (v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, T>) : {})
608  const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
609  return {
610    turns: list(r.turns),
611    prompts: list(r.prompts),
612    files: record(r.files),
613    errors: list(r.errors),
614    lastReply: typeof r.lastReply === 'string' ? r.lastReply : '',
615    context: num(r.context),
616    cost: num(r.cost),
617    limit: readLimit(r.limit),
618    stopped: typeof r.stopped === 'string' ? r.stopped : null,
619    todos: list(r.todos),
620    recent: list(r.recent),
621    agents: num(r.agents) ?? 0,
622    summaries: record(r.summaries),
623  }
624}
625
626function readLimit(v: unknown): Ledger['limit'] {
627  if (v === null || typeof v !== 'object') return null
628  const r = v as { percent?: unknown; resetsAt?: unknown }
629  if (typeof r.percent !== 'number') return null
630  return { percent: r.percent, resetsAt: typeof r.resetsAt === 'string' ? r.resetsAt : null }
631}
632
633// The limit nearest to running out, from Claude Code's report of the plan's limits.
634export function nearestLimit(limits: readonly { percentUsed: number; resetsAt?: string }[]): Ledger['limit'] {
635  const top = [...limits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
636  return top === undefined ? null : { percent: top.percentUsed, resetsAt: top.resetsAt ?? null }
637}
638
639// Text over this many lines folds to its first lines until opened.
640export const FOLD_REPLY = 30
641export const FOLD_PROMPT = 12
642export const FOLD_SHOWN = 12
643
644// The first `shown` lines of a long text, with a code fence it cut open closed again so the
645// rest still draws as prose. Null when the text is short enough to show whole.
646export function foldText(text: string, over: number, shown = FOLD_SHOWN): { head: string; hidden: number } | null {
647  const lines = text.split('\n')
648  if (lines.length <= over) return null
649  const kept = lines.slice(0, shown)
650  const fences = kept.filter(line => /^\s*(```|~~~)/.test(line)).length
651  return { head: kept.join('\n') + (fences % 2 === 1 ? '\n```' : ''), hidden: lines.length - shown }
652}
653
654export function fillActivity(raw: unknown): Activity {
655  const r = (raw !== null && typeof raw === 'object' ? raw : {}) as Partial<Activity>
656  const phases = ['idle', 'thinking', 'writing', 'tool', 'waiting', 'compacting'] as const
657  return {
658    phase: phases.includes(r.phase as never) ? (r.phase as Activity['phase']) : 'idle',
659    tool: typeof r.tool === 'string' ? r.tool : '',
660    detail: typeof r.detail === 'string' ? r.detail : '',
661    since: typeof r.since === 'number' ? r.since : 0,
662    tokens: typeof r.tokens === 'number' ? r.tokens : 0,
663  }
664}
665
666// Every symbol clarity draws, and its plain ASCII stand-in for terminals or fonts without it.
667const ASCII: Record<string, string> = {
668  '▸': '>', '▾': 'v', '▴': '^', '✓': '+', '✗': 'x', '●': '*', '⧉': '#', '▤': '#', '☰': '=',
669  '━': '=', '─': '-', '│': '|', '↓': 'v', '…': '...', '⊘': '-', '▣': '#', '≡': '=', '○': 'o',
670  '❯': '>', '▮': '|', '█': '#', '░': '.', '·': '-', '−': '-', '→': '->', '×': 'x', '⎿': '\\',
671}
672const ASCII_RE = new RegExp(`[${Object.keys(ASCII).join('')}]`, 'g')
673
674export function toAscii(text: string): string {
675  return text.replace(ASCII_RE, ch => ASCII[ch] ?? ch)
676}
677
678// Short tips the band shows, one at a time, while a turn runs long. Claude Code's own tips are
679// not readable by a mod, so these are clarity's.
680export const TIPS = [
681  'Esc stops Claude at any point.',
682  'Type @ in your prompt to add a file.',
683  '/compact frees context when it runs high.',
684  'Shift+Tab switches modes, like plan mode.',
685  'Ctrl+O shows every tool call in full.',
686  'Click a tool strip to open it.',
687  '/clarity panel shows the plan and changed files.',
688  '/clarity opens the settings.',
689]
690
691// The tip for this moment: a new one every 10 seconds, none in the first 6.
692export function tipAt(elapsedMs: number): string | null {
693  if (elapsedMs < 6000) return null
694  return TIPS[Math.floor((elapsedMs - 6000) / 10_000) % TIPS.length] ?? null
695}
696
697// How long the panel's prompt list is before it folds.
698export const PANEL_TURNS = 8
699
hooks/rows.tsx 722 lines
1// Drawing, from the surface's element table: no `$`, so the transcript, the menu's live
2// preview, the band and the panel all draw from the same rows.
3import type { ElementTable, RenderElement } from 'claude-code'
4
5import type { Activity, Ledger, Prefs, TurnSummary } from '../types'
6import { MARK, PANEL_TURNS, activityText, clip, formatClock, formatDuration, formatTokens, kindOf, planProgress, shortPath, toAscii } from './logic'
7import type { Attachment, Block, Palette, Status } from './logic'
8
9export type UI = ElementTable<'terminal'>
10
11export type Look = { prefs: Prefs; colors: Palette; columns: number; isNarrow: boolean }
12
13// The element table with every symbol clarity draws swapped for plain ASCII, and borders drawn
14// in ASCII too. Everything clarity draws goes through the table, so this one wrap covers it all.
15export function asciiUI(ui: UI): UI {
16  const plain = (children: unknown): unknown =>
17    typeof children === 'string' ? toAscii(children) : Array.isArray(children) ? children.map(plain) : children
18  const table = { ...ui } as Record<string, unknown>
19  table.Text = (props: Record<string, unknown>) => (ui.Text as (p: unknown) => RenderElement)({ ...props, children: plain(props.children) })
20  table.Button = (props: Record<string, unknown>) =>
21    (ui.Button as (p: unknown) => RenderElement)({
22      ...props,
23      children: plain(props.children),
24      ...(typeof props.label === 'string' ? { label: toAscii(props.label) } : {}),
25    })
26  table.Box = (props: Record<string, unknown>) =>
27    (ui.Box as (p: unknown) => RenderElement)(props.borderStyle === undefined ? props : { ...props, borderStyle: 'classic' })
28  return table as unknown as UI
29}
30
31// A word on a coloured ground: dark text on the colour, or inverse where the theme has no hex.
32export function badge(ui: UI, colors: Palette, text: string, tone: string): RenderElement {
33  return colors.chipText === undefined ? (
34    <ui.Text color={tone} inverse bold>
35      {text}
36    </ui.Text>
37  ) : (
38    <ui.Text backgroundColor={tone} color={colors.chipText} bold>
39      {text}
40    </ui.Text>
41  )
42}
43
44export type Chip = { key: string; label: string; onPress: () => void; hotkey?: string; tone?: string }
45
46// A button: bracketed in its colour, filling in under the pointer. Filled words with no
47// brackets (badge) are labels, so nothing that only labels looks pressable.
48export function chip(ui: UI, look: Look, c: Chip): RenderElement {
49  const { colors } = look
50  const tone = c.tone ?? colors.you
51  const label = `[ ${c.label} ]`
52  const face =
53    // Under the pointer it lifts onto the bar's ground and brightens, staying in its colour.
54    colors.barBg === undefined ? (
55      <ui.Text color={tone} bold hover={{ color: colors.text, underline: true }}>
56        {label}
57      </ui.Text>
58    ) : (
59      <ui.Text color={tone} bold hover={{ backgroundColor: colors.accentBg, color: colors.text, underline: true }}>
60        {label}
61      </ui.Text>
62    )
63  // The keyed Box is what the pointer hovers, so the chip lights alone.
64  return (
65    <ui.Box key={`chip-${c.key}`}>
66      {c.hotkey === undefined ? (
67        <ui.Button key={c.key} plain onPress={c.onPress}>
68          {face}
69        </ui.Button>
70      ) : (
71        <ui.Button key={c.key} plain hotkey={c.hotkey} onPress={c.onPress}>
72          {face}
73        </ui.Button>
74      )}
75    </ui.Box>
76  )
77}
78
79const ATTACHMENT_ICON: Record<Attachment['kind'], string> = { file: '▤', folder: '▸', image: '▣', paste: '≡' }
80
81// What a prompt brought along, each in a small frame of its own, as files sit under a message
82// on claude.ai: the icon and name, then where it is or how long.
83export function attachmentCards(ui: UI, look: Look, attachments: readonly Attachment[]): RenderElement | null {
84  if (attachments.length === 0) return null
85  const { Box, Text } = ui
86  const { colors } = look
87  const tone = (a: Attachment) =>
88    a.kind === 'image' ? colors.kinds.web : a.kind === 'paste' ? colors.kinds.search : a.kind === 'folder' ? colors.kinds.write : colors.kinds.read
89  return (
90    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
91      {attachments.map(a => (
92        <Box borderStyle="round" borderColor={tone(a)} paddingX={1}>
93          <Text wrap="truncate">
94            <Text color={tone(a)} bold>
95              {ATTACHMENT_ICON[a.kind]} {clip(a.name, 28)}
96            </Text>
97            {a.detail !== '' && <Text color={colors.muted}> {clip(a.detail, 24)}</Text>}
98          </Text>
99        </Box>
100      ))}
101    </Box>
102  )
103}
104
105// The person's prompt: a rounded box in their colour, labelled, with what it brought along.
106export function userRow(ui: UI, look: Look, text: string, attachments: readonly Attachment[] = [], more?: Chip): RenderElement {
107  const { Box, Text } = ui
108  const { colors } = look
109  return (
110    <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={colors.you} paddingX={1} width={replyWidth(look) + (look.isNarrow ? 0 : 1)}>
111      {badge(ui, colors, ' you ', colors.you)}
112      <Text color={colors.text}>{text}</Text>
113      {more !== undefined && chip(ui, look, { ...more, tone: copyTone(colors) })}
114      {attachmentCards(ui, look, attachments)}
115    </Box>
116  )
117}
118
119export function claudeHeader(ui: UI, look: Look): RenderElement {
120  return badge(ui, look.colors, ' ● claude ', look.colors.claude)
121}
122
123// The width a reply's box takes: the person's line width plus the frame, never past the screen.
124function replyWidth(look: Look): number {
125  const room = Math.max(24, look.columns - (look.isNarrow ? 0 : 2))
126  return look.prefs.measure > 0 ? Math.min(room, look.prefs.measure + 4) : room
127}
128
129function copyTone(colors: Palette): string {
130  return colors.rule === 'subtle' ? 'inactive' : colors.muted
131}
132
133// One block of a reply in a soft frame: the `claude` badge on the first, copy chips at the foot.
134// `isOwnBody` false: the body holds Claude Code's own drawing, which may not sit under a sized Box.
135export function replyBox(ui: UI, look: Look, isFirst: boolean, body: RenderElement, chips: readonly Chip[], isOwnBody = true): RenderElement {
136  const { Box } = ui
137  const frame = {
138    flexDirection: 'column' as const,
139    marginTop: isFirst ? 1 : 0,
140    marginLeft: look.isNarrow ? 0 : 1,
141    borderStyle: 'round',
142    borderColor: look.colors.rule,
143    paddingX: 1,
144  }
145  return (
146    <Box {...frame} {...(isOwnBody ? { width: replyWidth(look) } : {})}>
147      {isFirst && claudeHeader(ui, look)}
148      {body}
149      {chips.length > 0 && (
150        <Box flexDirection="row" gap={1}>
151          {chips.map(c => chip(ui, look, { ...c, tone: copyTone(look.colors) }))}
152        </Box>
153      )}
154    </Box>
155  )
156}
157
158// A reply's paragraphs with a line of room between them, and each code block in its own
159// frame with its language and a copy chip.
160export function replyBlocks(ui: UI, look: Look, blocks: readonly Block[], onCopy?: (code: string) => void): RenderElement {
161  const { Box, Markdown } = ui
162  const { colors } = look
163  return (
164    <Box flexDirection="column">
165      {blocks.map((block, i) =>
166        block.kind === 'prose' ? (
167          <Box marginTop={i === 0 ? 0 : 1}>
168            <Markdown text={block.text} />
169          </Box>
170        ) : (
171          <Box flexDirection="column" marginTop={i === 0 ? 0 : 1} borderStyle="single" borderColor={colors.rule} paddingX={1}>
172            <Box flexDirection="row" justifyContent="space-between">
173              {badge(ui, colors, ` ${block.lang === '' ? 'code' : block.lang} `, colors.tool)}
174              {onCopy !== undefined && chip(ui, look, { key: `copy-code-${i}`, label: '⧉ copy', tone: copyTone(colors), onPress: () => onCopy(block.body) })}
175            </Box>
176            <Markdown text={block.text} />
177          </Box>
178        ),
179      )}
180    </Box>
181  )
182}
183
184export type ToolParts = {
185  tools: readonly { tool: string; label: string }[]
186  detail: string
187  status: Status
188  isOpen?: boolean
189  /** An edit's lines in and out, drawn green and red before its file. */
190  delta?: { added: number; removed: number }
191}
192
193function markColor(colors: Palette, status: Status): string {
194  return status === 'error' ? colors.error : status === 'running' ? colors.warn : status === 'interrupted' ? colors.muted : colors.ok
195}
196
197function badgesWidth(parts: ToolParts): number {
198  return parts.tools.reduce((n, t) => n + t.label.length + 3, 0)
199}
200
201// `▸ [Read ×2] [Grep] detail ✓`: each tool a badge in the colour of what it does.
202function toolFace(ui: UI, look: Look, parts: ToolParts, room: number): RenderElement[] {
203  const { Text } = ui
204  const { colors } = look
205  const badges = parts.tools.flatMap((t, i) => [
206    ...(i === 0 ? [] : [<Text> </Text>]),
207    badge(ui, colors, ` ${t.label} `, parts.status === 'error' ? colors.error : colors.kinds[kindOf(t.tool)]),
208  ])
209  const delta = parts.delta
210  return [
211    <Text color={colors.muted}>{parts.isOpen === true ? '▾ ' : '▸ '}</Text>,
212    ...badges,
213    ...(delta === undefined
214      ? []
215      : [
216          <Text color={colors.ok} bold>
217            {' '}+{delta.added}
218          </Text>,
219          <Text color={colors.error} bold>
220            {' '}−{delta.removed}
221          </Text>,
222        ]),
223    <Text color={colors.text}> {clip(parts.detail, room)} </Text>,
224    <Text color={markColor(colors, parts.status)} bold>
225      {MARK[parts.status]}
226    </Text>,
227  ]
228}
229
230// A tool row on a tinted strip; a press opens or folds it where it stands.
231export function toolLine(ui: UI, look: Look, parts: ToolParts, onPress?: () => void): RenderElement {
232  const { Box, Button, Text } = ui
233  const face = toolFace(ui, look, parts, look.columns - badgesWidth(parts) - 12)
234  return (
235    <Box marginLeft={look.isNarrow ? 0 : 2} paddingX={1} backgroundColor={look.colors.accentBg}>
236      {onPress === undefined ? (
237        <Text wrap="truncate">{face}</Text>
238      ) : (
239        <Button key="row" plain onPress={onPress}>
240          {face}
241        </Button>
242      )}
243    </Box>
244  )
245}
246
247// An opened row: a box in the tool colour, its line to fold it, the full row inside.
248export function openedRow(ui: UI, look: Look, parts: ToolParts, onFold: () => void, body: RenderElement): RenderElement {
249  const { Box, Button } = ui
250  return (
251    <Box
252      flexDirection="column"
253      marginLeft={look.isNarrow ? 0 : 2}
254      borderStyle="round"
255      borderColor={look.colors.kinds[kindOf(parts.tools[0]?.tool ?? '')]}
256      paddingX={1}
257    >
258      <Button key="row" plain onPress={onFold}>
259        {toolFace(ui, look, { ...parts, isOpen: true }, look.columns - badgesWidth(parts) - 14)}
260      </Button>
261      {body}
262    </Box>
263  )
264}
265
266// A failure: a red box with what failed on top and the full row inside.
267export function failHeader(ui: UI, look: Look, tool: string, detail: string, body: RenderElement): RenderElement {
268  const { Box, Text } = ui
269  return (
270    <Box flexDirection="column" marginLeft={look.isNarrow ? 0 : 2} borderStyle="round" borderColor={look.colors.error} paddingX={1}>
271      <Text color={look.colors.error} bold wrap="truncate">
272        ✗ {tool} failed{detail === '' ? '' : ` · ${clip(detail, look.columns - tool.length - 18)}`}
273      </Text>
274      {body}
275    </Box>
276  )
277}
278
279// The end of a turn: a rule with the time, so turns read as blocks.
280export function turnRule(ui: UI, look: Look, durationMs: number): RenderElement {
281  const label = ` ${formatDuration(durationMs)} `
282  const right = Math.max(0, Math.max(12, look.columns - 2) - 2 - label.length)
283  return (
284    <ui.Box>
285      <ui.Text color={look.colors.rule}>{'━━' + label + '━'.repeat(right)}</ui.Text>
286    </ui.Box>
287  )
288}
289
290// A slash command's output in a titled card.
291export function card(ui: UI, look: Look, title: string, body: RenderElement, isErrored: boolean): RenderElement {
292  const tone = isErrored ? look.colors.error : look.colors.claude
293  return (
294    <ui.Box flexDirection="column" borderStyle="round" borderColor={tone} paddingX={1}>
295      {badge(ui, look.colors, ` ${title} `, tone)}
296      {body}
297    </ui.Box>
298  )
299}
300
301export function questionBanner(ui: UI, look: Look, body: RenderElement): RenderElement {
302  return (
303    <ui.Box flexDirection="column" borderStyle="double" borderColor={look.colors.warn} paddingX={1}>
304      {badge(ui, look.colors, ' ❯ Claude needs your answer ', look.colors.warn)}
305      {body}
306    </ui.Box>
307  )
308}
309
310// The close of a long turn: what it did while you looked away.
311export function awayCard(ui: UI, look: Look, durationMs: number, summary: TurnSummary): RenderElement {
312  const { Box, Text } = ui
313  const { colors } = look
314  const facts: { text: string; tone: string }[] = [
315    { text: `${summary.tools} tool call${summary.tools === 1 ? '' : 's'}`, tone: colors.tool },
316    ...(summary.files > 0 ? [{ text: `${summary.files} file${summary.files === 1 ? '' : 's'} changed (+${summary.added} −${summary.removed})`, tone: colors.kinds.write }] : []),
317    ...(summary.failed > 0 ? [{ text: `${summary.failed} failed`, tone: colors.error }] : []),
318  ]
319  return (
320    <Box flexDirection="column" marginTop={1} marginLeft={look.isNarrow ? 0 : 1} borderStyle="round" borderColor={colors.claude} paddingX={1}>
321      <Text>
322        {badge(ui, colors, ' While you were away ', colors.claude)}
323        <Text color={colors.muted}> {formatDuration(durationMs)}</Text>
324      </Text>
325      <Text wrap="wrap">
326        {facts.map((f, i) => (
327          <Text>
328            {i === 0 ? '' : <Text color={colors.rule}> · </Text>}
329            <Text color={f.tone} bold>
330              {f.text}
331            </Text>
332          </Text>
333        ))}
334      </Text>
335    </Box>
336  )
337}
338
339function meter(percent: number, cells: number): { full: string; empty: string } {
340  const full = Math.round((Math.max(0, Math.min(100, percent)) / 100) * cells)
341  return { full: '█'.repeat(full), empty: '░'.repeat(cells - full) }
342}
343
344// Green with room to spare, yellow past half, red near the end.
345function levelColor(colors: Palette, percent: number): string {
346  return percent >= 80 ? colors.error : percent >= 50 ? colors.warn : colors.ok
347}
348
349function phaseColor(colors: Palette, activity: Activity): string {
350  if (activity.phase === 'waiting' || activity.phase === 'compacting') return colors.warn
351  if (activity.phase === 'idle') return colors.ok
352  return activity.phase === 'tool' ? colors.kinds[kindOf(activity.tool)] : colors.claude
353}
354
355function phaseBadge(ui: UI, colors: Palette, activity: Activity): RenderElement {
356  return badge(ui, colors, activity.phase === 'idle' ? ' ✓ ' : activity.phase === 'waiting' ? ' ❯ ' : ' ● ', phaseColor(colors, activity))
357}
358
359export type BandActions = { panel: () => void; menu: () => void; copy?: () => void }
360
361// The toolbar above the prompt: what Claude is doing, what changed, context, and chips.
362export type TurnStats = { elapsedMs: number; tokens: number; tip: string | null }
363
364export function band(ui: UI, look: Look, activity: Activity, ledger: Ledger, width: number, stats: TurnStats, actions: BandActions): RenderElement {
365  const { Box, Text } = ui
366  const { colors } = look
367  const files = Object.keys(ledger.files).length
368  // In order of what matters most when room is short.
369  const plan = planProgress(ledger.todos)
370  // A plan usage limit shows once it passes 80%, with when it resets.
371  const limit = ledger.limit !== null && ledger.limit.percent >= 80 ? ledger.limit : null
372  const resets = limit?.resetsAt != null && !Number.isNaN(Date.parse(limit.resetsAt)) ? ` · resets ${formatClock(Date.parse(limit.resetsAt))}` : ''
373  const wanted: { text: string; tone: string }[] = [
374    ...(limit !== null ? [{ text: `limit ${Math.round(limit.percent)}%${resets}`, tone: limit.percent >= 95 ? colors.error : colors.warn }] : []),
375    ...(ledger.errors.length > 0 ? [{ text: `${ledger.errors.length} failed`, tone: colors.error }] : []),
376    ...(plan !== null ? [{ text: `task ${plan.at}/${plan.of}`, tone: colors.claude }] : []),
377    ...(ledger.agents > 0 ? [{ text: `${ledger.agents} agent${ledger.agents === 1 ? '' : 's'}`, tone: colors.kinds.agent }] : []),
378    ...(ledger.context !== null ? [{ text: `context ${Math.round(ledger.context)}%`, tone: levelColor(colors, ledger.context) }] : []),
379    ...(files > 0 ? [{ text: `${files} file${files === 1 ? '' : 's'} changed`, tone: colors.kinds.write }] : []),
380  ]
381  // No hotkeys here: the terminal prints them as `c:` before each chip, which crowds the bar.
382  const chips: Chip[] = [
383    ...(actions.copy === undefined ? [] : [{ key: 'copy', label: '⧉ copy reply', onPress: actions.copy }]),
384    { key: 'panel', label: '▤ session', onPress: actions.panel },
385    { key: 'menu', label: '☰ settings', onPress: actions.menu },
386  ]
387  // Each button is `[ label ]` (four more than its label) with a column between, after a gap of two.
388  const chipsWidth = chips.reduce((n, c) => n + c.label.length + 5, 0) + 2
389  // While a turn runs: what it is doing, how long, and how much it has written, as the
390  // stock spinner says it (`Thinking · 1m 42s · ↓ 8.5k tokens`).
391  const running = activity.phase === 'idle' ? [] : [
392    ...(stats.elapsedMs >= 1000 ? [formatDuration(stats.elapsedMs)] : []),
393    ...(stats.tokens > 0 ? [`↓ ${formatTokens(stats.tokens)} tokens`] : []),
394  ]
395  // A turn that died on an error says so, in red, until the next one starts.
396  const isStopped = activity.phase === 'idle' && ledger.stopped !== null
397  const doingParts = isStopped ? [`Stopped: ${ledger.stopped}`] : [activityText(activity), ...running]
398  const doing = doingParts.join(' · ')
399  // The status comes first: it keeps up to 48 columns (enough for the step, its time and its
400  // tokens) before any fact gets a place; each fact then shows whole or not at all.
401  let room = width - 2 - chipsWidth - 4 - Math.min(doing.length, 48)
402  const facts = wanted.filter(f => {
403    const cost = f.text.length + 3
404    if (cost > room) return false
405    room -= cost
406    return true
407  })
408  const statusRoom = Math.max(8, width - 2 - chipsWidth - 4 - facts.reduce((n, f) => n + f.text.length + 3, 0))
409  // Short of room, the status sheds its tokens, then its time, whole; only the step itself is cut.
410  let shown = doingParts
411  while (shown.length > 1 && shown.join(' · ').length > statusRoom) shown = shown.slice(0, -1)
412  const status = clip(shown.join(' · '), statusRoom)
413  const tone = isStopped ? colors.error : phaseColor(colors, activity)
414  const line = (
415    <Box flexDirection="row" justifyContent="space-between" width={width} paddingX={1} backgroundColor={colors.barBg}>
416      {/* The status gives way first; the chips keep their size so they never wrap. */}
417      <Box flexShrink={1} overflow="hidden">
418      <Text wrap="truncate">
419        {isStopped ? badge(ui, colors, ' ✗ ', colors.error) : phaseBadge(ui, colors, activity)}
420        <Text color={tone} bold>
421          {' '}
422          {status}
423        </Text>
424        {facts.map(f => (
425          <Text>
426            <Text color={colors.rule}> │ </Text>
427            <Text color={f.tone} bold>
428              {f.text}
429            </Text>
430          </Text>
431        ))}
432      </Text>
433      </Box>
434      <Box flexDirection="row" gap={1} marginLeft={2} flexShrink={0}>
435        {chips.map(c => chip(ui, look, c))}
436      </Box>
437    </Box>
438  )
439  // During a long turn, a tip under the bar, one at a time.
440  return activity.phase === 'idle' || stats.tip === null ? (
441    line
442  ) : (
443    <Box flexDirection="column">
444      {line}
445      <Box paddingX={1}>
446        <Text color={colors.muted} italic wrap="truncate">
447          Tip: {stats.tip}
448        </Text>
449      </Box>
450    </Box>
451  )
452}
453
454// The sidebar: what is happening, the turns so far, what changed, what failed.
455export type PanelActions = { copyTurn: (index: number) => void; toggleTurns: () => void; isAllTurns: boolean }
456
457export function panel(ui: UI, look: Look, activity: Activity, ledger: Ledger, width: number, actions: PanelActions): RenderElement {
458  const { Box, Button, Text } = ui
459  const { colors } = look
460  const heading = (title: string, count: number | string, tone: string) => (
461    <Box marginTop={1} paddingX={1} backgroundColor={colors.barBg} width={width} justifyContent="space-between">
462      {badge(ui, colors, ` ${title} `, tone)}
463      <Text color={colors.muted}>{count}</Text>
464    </Box>
465  )
466  const empty = (text: string) => (
467    <Text color={colors.muted} italic>
468      {text}
469    </Text>
470  )
471  const files = Object.entries(ledger.files)
472  const biggest = Math.max(1, ...files.map(([, c]) => c.added + c.removed))
473  const turns = ledger.turns.slice(actions.isAllTurns ? -50 : -PANEL_TURNS)
474  const firstTurn = ledger.turns.length - turns.length + 1
475  const isFolded = ledger.turns.length > PANEL_TURNS
476  const cells = Math.max(6, Math.min(20, width - 20))
477  const level = ledger.context === null ? null : meter(ledger.context, cells)
478  const done = ledger.todos.filter(t => t.status === 'completed').length
479  return (
480    <Box flexDirection="column" width={width}>
481      {/* Now: what Claude is doing, and how full the context is. */}
482      <Box flexDirection="column" borderStyle="round" borderColor={phaseColor(colors, activity)} paddingX={1}>
483        <Text wrap="truncate">
484          {phaseBadge(ui, colors, activity)}
485          <Text color={phaseColor(colors, activity)} bold>
486            {' '}
487            {activityText(activity)}
488          </Text>
489        </Text>
490        {ledger.context !== null && level !== null && (
491          <Text>
492            <Text color={colors.muted}>context </Text>
493            <Text color={levelColor(colors, ledger.context)}>{level.full}</Text>
494            <Text color={colors.rule}>{level.empty}</Text>
495            <Text color={levelColor(colors, ledger.context)} bold>
496              {' '}
497              {Math.round(ledger.context)}%
498            </Text>
499            {ledger.cost !== null && <Text color={colors.muted}>  ${ledger.cost.toFixed(2)}</Text>}
500          </Text>
501        )}
502      </Box>
503      {ledger.todos.length > 0 && heading('Plan', `${done} of ${ledger.todos.length} done`, colors.claude)}
504      {ledger.todos.length > 0 && (
505        <Box flexDirection="column" paddingX={1}>
506          {ledger.todos.slice(0, 10).map(t => (
507            <Text wrap="truncate">
508              {t.status === 'completed' ? (
509                <Text color={colors.ok}>✓ </Text>
510              ) : t.status === 'in_progress' ? (
511                <Text color={colors.claude} bold>
512                  ▸{' '}
513                </Text>
514              ) : (
515                <Text color={colors.rule}>○ </Text>
516              )}
517              <Text color={t.status === 'in_progress' ? colors.text : colors.muted} bold={t.status === 'in_progress'} strikethrough={t.status === 'completed'}>
518                {t.status === 'in_progress' ? t.activeForm : t.content}
519              </Text>
520            </Text>
521          ))}
522        </Box>
523      )}
524      {heading('Changed', files.length === 1 ? '1 file' : `${files.length} files`, colors.kinds.write)}
525      <Box flexDirection="column" paddingX={1}>
526        {files.length === 0 && empty('No files changed yet.')}
527        {files.slice(-12).map(([path, change]) => {
528          const share = Math.max(1, Math.round(((change.added + change.removed) / biggest) * 5))
529          const greens = Math.round((change.added / Math.max(1, change.added + change.removed)) * share)
530          return (
531            <Text wrap="truncate">
532              <Text color={colors.ok}>{'▮'.repeat(greens)}</Text>
533              <Text color={colors.error}>{'▮'.repeat(share - greens)}</Text>
534              <Text>{' '.repeat(6 - share)}</Text>
535              <Text color={colors.text} bold>
536                {shortPath(path)}
537              </Text>
538              <Text color={colors.ok}> +{change.added}</Text>
539              <Text color={colors.error}> −{change.removed}</Text>
540            </Text>
541          )
542        })}
543      </Box>
544      {ledger.errors.length > 0 && heading('Failed', ledger.errors.length, colors.error)}
545      {ledger.errors.length > 0 && (
546        <Box flexDirection="column" paddingX={1}>
547          {ledger.errors.slice(-5).map(err => (
548            <Text wrap="truncate">
549              <Text color={colors.error} bold>
550                ✗ {err.tool}
551              </Text>
552              <Text color={colors.muted}> {err.detail || err.text}</Text>
553            </Text>
554          ))}
555        </Box>
556      )}
557      {ledger.recent.length > 0 && heading('Recent', 'latest first', colors.tool)}
558      {ledger.recent.length > 0 && (
559        <Box flexDirection="column" paddingX={1}>
560          {ledger.recent
561            .slice(-6)
562            .reverse()
563            .map(r => (
564              <Text wrap="truncate">
565                <Text color={colors.rule}>{formatClock(r.at).padStart(8)}  </Text>
566                <Text color={r.isOk ? colors.kinds[kindOf(r.tool)] : colors.error} bold>
567                  {r.tool}
568                </Text>
569                <Text color={colors.muted}> {r.detail}</Text>
570              </Text>
571            ))}
572        </Box>
573      )}
574      {heading('Your prompts', turns.length === 0 ? '' : 'click to copy', colors.you)}
575      <Box flexDirection="column" paddingX={1}>
576        {turns.length === 0 && empty('Nothing asked yet.')}
577        {turns.map((text, i) => {
578          const n = firstTurn + i
579          const isLatest = i === turns.length - 1
580          return (
581            <Box key={`turn-${n}`}>
582              <Button key={`copy-turn-${n}`} plain onPress={() => actions.copyTurn(n - 1)}>
583                <Text color={isLatest ? colors.you : colors.rule} bold>
584                  {String(n).padStart(2)}
585                </Text>
586                <Text color={isLatest ? colors.text : colors.muted} hover={{ color: colors.you, underline: true }}>
587                  {'  '}
588                  {clip(text, Math.max(8, width - 8))}
589                </Text>
590              </Button>
591            </Box>
592          )
593        })}
594        {isFolded &&
595          chip(ui, look, {
596            key: 'all-turns',
597            label: actions.isAllTurns ? '▴ show fewer' : `▾ show all ${Math.min(ledger.turns.length, 50)}`,
598            tone: copyTone(colors),
599            onPress: actions.toggleTurns,
600          })}
601      </Box>
602    </Box>
603  )
604}
605
606export type MenuOption = { value: string; label: string }
607
608// One setting: its label (a press, or its number key, steps to the next choice) and every
609// choice as a chip, the current one filled.
610export type MenuSetting = {
611  key: string
612  hotkey: string
613  label: string
614  hint: string
615  options: readonly MenuOption[]
616  current: string
617  pick: (value: string) => void
618}
619
620export type MenuSection = { title: string; tone: string; settings: readonly MenuSetting[] }
621
622const LABEL_WIDTH = 17
623
624function optionChip(ui: UI, look: Look, key: string, label: string, isCurrent: boolean, onPress: () => void): RenderElement {
625  const { colors } = look
626  const text = ` ${label} `
627  const face = isCurrent ? (
628    badge(ui, colors, text, colors.you)
629  ) : (
630    <ui.Text color={colors.muted} hover={{ color: colors.text, bold: true }}>
631      {text}
632    </ui.Text>
633  )
634  return (
635    <ui.Box key={`opt-${key}`}>
636      <ui.Button key={key} plain onPress={onPress}>
637        {face}
638      </ui.Button>
639    </ui.Box>
640  )
641}
642
643function settingRow(ui: UI, look: Look, setting: MenuSetting): RenderElement {
644  const { Box, Button, Text } = ui
645  const { colors } = look
646  const at = Math.max(0, setting.options.findIndex(o => o.value === setting.current))
647  const next = setting.options[(at + 1) % setting.options.length]
648  return (
649    <Box key={`set-${setting.key}`} flexDirection="column">
650      <Box flexDirection="row">
651        <Box key={`label-${setting.key}`} width={LABEL_WIDTH} flexShrink={0}>
652          <Button key={`cycle-${setting.key}`} plain hotkey={setting.hotkey} onPress={() => next !== undefined && setting.pick(next.value)}>
653            <Text color={colors.text} bold hover={{ color: colors.you }}>
654              {setting.label}
655            </Text>
656          </Button>
657        </Box>
658        <Box flexDirection="row" flexWrap="wrap">
659          {setting.options.map(o => optionChip(ui, look, `${setting.key}-${o.value}`, o.label, o.value === setting.current, () => setting.pick(o.value)))}
660        </Box>
661      </Box>
662      {/* The description sits on its own line under the choices, dim and set in. */}
663      {setting.hint !== '' && (
664        <Box paddingLeft={LABEL_WIDTH}>
665          <Text color={colors.muted} italic wrap="wrap">
666            {setting.hint}
667          </Text>
668        </Box>
669      )}
670    </Box>
671  )
672}
673
674export function menu(ui: UI, look: Look, sections: readonly MenuSection[], footer: readonly string[]): RenderElement {
675  const { Box, Text } = ui
676  const { colors } = look
677  return (
678    <Box flexDirection="column">
679      <Box paddingX={1} backgroundColor={colors.barBg}>
680        {badge(ui, colors, ' clarity ', colors.claude)}
681        <Text color={colors.muted}> settings</Text>
682      </Box>
683      {sections.map(section => (
684        <Box flexDirection="column" marginTop={1} paddingX={1}>
685          {badge(ui, colors, ` ${section.title} `, section.tone)}
686          {section.settings.map((setting, i) => (
687            <Box key={`gap-${setting.key}`} marginTop={i === 0 ? 0 : 1}>
688              {settingRow(ui, look, setting)}
689            </Box>
690          ))}
691        </Box>
692      ))}
693      <Box flexDirection="column" marginTop={1} paddingX={1}>
694        {footer.map(line => (
695          <Text color={colors.muted} wrap="wrap">
696            {line}
697          </Text>
698        ))}
699      </Box>
700      <Box flexDirection="column" marginTop={1} paddingX={1}>
701        {badge(ui, colors, ' Preview ', colors.rule === 'subtle' ? 'inactive' : colors.muted)}
702        {preview(ui, { ...look, columns: Math.min(look.columns - 4, 64) })}
703      </Box>
704    </Box>
705  )
706}
707
708// A sample turn in the current look, so a change in the menu shows at once.
709export function preview(ui: UI, look: Look): RenderElement {
710  const { Box, Text } = ui
711  return (
712    <Box flexDirection="column" borderStyle="round" borderColor={look.colors.rule} paddingX={1}>
713      {userRow(ui, look, 'add rate limiting to the api')}
714      {replyBox(ui, look, true, <Text color={look.colors.text}>I'll check the server first.</Text>, [])}
715      {toolLine(ui, look, { tools: [{ tool: 'Read', label: 'Read ×2' }, { tool: 'Grep', label: 'Grep' }], detail: '', status: 'ok' })}
716      {toolLine(ui, look, { tools: [{ tool: 'Bash', label: 'Bash' }], detail: 'npm test', status: 'ok' })}
717      {toolLine(ui, look, { tools: [{ tool: 'Edit', label: 'Edit' }], detail: 'src/server.ts', status: 'running' })}
718      {turnRule(ui, look, 42000)}
719    </Box>
720  )
721}
722
types/index.d.ts 107 lines
1export type ToolDetail = 'summary' | 'full'
2export type PaletteChoice = 'auto' | 'dark' | 'contrast' | 'colorblind' | 'theme'
3export type PaletteName = Exclude<PaletteChoice, 'auto'>
4export type Alerts = 'off' | 'notify' | 'sound' | 'both'
5export type PanelChoice = 'auto' | 'off'
6/** The editor terminal's font: clarity's readable sizes, or left as the person has it. */
7export type FontChoice = 'leave' | 'readable' | 'large'
8/** Symbols as drawn: unicode, or plain ASCII for terminals and fonts without them. */
9export type IconChoice = 'unicode' | 'ascii'
10
11export type Prefs = {
12  /** The mod draws at all; off hands every row back to the engine. */
13  isOn: boolean
14  /** Focus hides notices and successful tool output entirely. */
15  isFocus: boolean
16  tools: ToolDetail
17  palette: PaletteChoice
18  alerts: Alerts
19  /** The status band above the prompt. */
20  band: boolean
21  /** auto opens the session panel beside the transcript once the terminal is wide enough. */
22  panel: PanelChoice
23  /** The widest a reply's prose runs, in columns; 0 for the full width. (Was `width`, whose
24   *  100-column default read too long: renamed so stored prefs take the new default.) */
25  measure: number
26  copy: boolean
27  font: FontChoice
28  icons: IconChoice
29}
30
31export type Env = {
32  /** The person's /config theme, which `auto` follows. */
33  theme: string
34}
35
36export type Phase = 'idle' | 'thinking' | 'writing' | 'tool' | 'waiting' | 'compacting'
37
38/** What the main loop is doing now, for the band and the spinner. */
39export type Activity = {
40  phase: Phase
41  tool: string
42  detail: string
43  /** When the turn began, ms. */
44  since: number
45  /** Tokens the model has written this turn: estimated as it streams, exact at each step's end. */
46  tokens: number
47}
48
49export type FileChange = { added: number; removed: number; edits: number }
50export type ToolError = { tool: string; detail: string; text: string }
51
52/** One item of the plan Claude keeps with TodoWrite. */
53export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string }
54
55/** One finished tool call, for the panel's Recent list. */
56export type RecentAction = { at: number; tool: string; detail: string; isOk: boolean }
57
58/** What one turn did, for the card that closes a long turn. */
59export type TurnSummary = { tools: number; files: number; added: number; removed: number; failed: number }
60
61/** Tool calls made back to back, with no text between them: drawn as one strip until opened. */
62export type ToolRun = { ids: string[]; tools: string[]; running: number; failed: number }
63
64/** Where one call sits in its run; the run is keyed by its first call's id. */
65export type RunPlace = { run: string; index: number }
66
67/** What this session has done, for the panel and the band. */
68export type Ledger = {
69  /** Each prompt's first line, for the panel; `prompts` holds them whole, for copying. */
70  turns: string[]
71  prompts: string[]
72  files: Record<string, FileChange>
73  errors: ToolError[]
74  lastReply: string
75  /** Percent of the context window in use, when known. */
76  context: number | null
77  /** What the session has cost so far, in US dollars, when known. */
78  cost: number | null
79  /** The plan usage limit nearest to running out, when Claude Code reports one. */
80  limit: { percent: number; resetsAt: string | null } | null
81  /** Why the last turn stopped, when it died on an error (a usage limit, the context limit). */
82  stopped: string | null
83  todos: Todo[]
84  recent: RecentAction[]
85  /** Subagents running now. */
86  agents: number
87  /** Each long turn's summary, by its duration in ms (what the turn's closing row carries). */
88  summaries: Record<string, TurnSummary>
89}
90
91declare module 'claude-code' {
92  interface PluginState {
93    clarity: {
94      prefs: Prefs
95      env: Env
96      activity: Activity
97      ledger: Ledger
98      /** The clock, ticked each second while a turn runs, so the band's timer redraws. */
99      tick: number
100      /** Each tool row (or run, by its first id) the person clicked open, by tool_use_id. */
101      expanded: StateFamily<boolean>
102      runs: StateFamily<ToolRun | null>
103      places: StateFamily<RunPlace | null>
104    }
105  }
106}
107