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 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.
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.
claude plugin update clarity@clarity
Then run /reload-plugins in any open session.
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.
/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:
| Command | Does |
|---|---|
/clarity panel | Opens the session panel |
/clarity on / off | Turns clarity on or off |
/clarity focus | Hides startup notices and tool output that worked |
/clarity tools full / summary | Full tool rows, or one line each |
/clarity status | Says 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.
The status band, through one turn. It replaces the stock spinner while it is on.
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.
<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:


| Part | What you see |
|---|---|
| Your prompt | A 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 reply | Its 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. |
| Code | Its own frame with the language and a copy button. Long replies get a copy button too. |
| Tool rows | One 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 calls | Fold into one strip, [Bash ×2] [Edit] 3 tools. Click to open, click again to fold. |
| Failures | Always shown, in a red box that says what failed. |
| Questions | A banner, so you notice Claude is waiting on you. |
| Slash commands | Their output sits in a titled card. |
| End of a turn | A rule with the time. After a turn over 45 seconds, a "While you were away" card with tool calls, files changed and failures. |
| Status band | What 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 panel | Now, 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.
| Section | Setting | Choices |
|---|---|---|
| General | clarity | on, off |
| General | focus | on, off |
| Look | colours | auto, dark, contrast, colorblind, theme |
| Look | line width | 70, 80, 100, 120, full |
| Look | terminal font | leave, readable, large |
| Look | tool rows | one line, full |
| Layout | status band | on, off |
| Layout | session panel | auto, off, open now |
| Behaviour | alerts | notify, sound, both, off |
| Behaviour | copy buttons | on, 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 claude plugin validate reports, in words.
| Access | Detail |
|---|---|
| Reads | Your 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. |
| Changes | How rows are drawn. Prompts, tool calls and replies pass through unchanged, and so does what the model reads. |
| Writes | Its 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. |
| Runs | pbcopy when the terminal will not copy for it. ls ~/Library/Fonts after setting the font, to see if JetBrains Mono is there. |
| Environment | Reads HOME, TERM_PROGRAM and VSCODE_GIT_ASKPASS_MAIN to find your editor. |
| Sound and notifications | Only with alerts on. |
| Network | None. |
| Where | Works |
|---|---|
| Terminal, fullscreen layout | Everything, clicks included |
| Terminal, main-screen layout | Everything but clicks |
| Desktop app, VS Code extension, mobile | clarity 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.
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.
None at runtime. clarity uses only Claude Code's mod API.
| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| JetBrains Mono, optional | any | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono |
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.
MIT. Not an official Anthropic product.
hooks/register.tsx 848 lines1import { 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}
848hooks/logic.ts 699 lines1// 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
699hooks/rows.tsx 722 lines1// 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}
722types/index.d.ts 107 lines1export 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