Desktop look in terminal: restyles Claude Code to resemble the Claude desktop app, with a welcome stats card, chat bubbles, bullet-free replies, compact tool…

Desktop look in terminal. A Claude Code mod that restyles the terminal to resemble the Claude desktop app: a welcome stats card, chat bubbles, bullet-free replies, compact tool rows and a Changes sidebar.
The install id is desktop-look, since Claude Code reserves plugin names that start with claude-.
Tested with Claude Code 2.1.288. Mods need 2.1.287 or newer in the terminal, or 2.1.286 or newer in the Desktop app's Code tab. The render surfaces this mod uses are marked early access, so a newer build can change them without notice.
claude plugin marketplace add albertzhangz10/claude-code-desktop-look-mod
claude plugin install desktop-look@desktop-look-marketplace
To update later:
claude plugin update desktop-look@desktop-look-marketplace
One optional setting, name, which appears in the welcome greeting ("What's up next, Sam?"). Set it through /config or the /plugin config dialog. Leave it blank and the greeting just reads "What's up next?".
Settings are stored per install id, so they do not travel with the repo. Everyone sets their own.
Mods are not sandboxed. They run with your full user permissions. Inspect this one before installing, which reports its surface without executing any code:
claude plugin validate .
For this mod that prints:
❯ ./register.tsx hooks: ui.render{component=AbovePrompt}, session.start,
command.run{command=changes}, tool.call, ui.render{component=UserMessage},
ui.render{component=AssistantMessage}, ui.render{component=ToolUse},
ui.render{component=Pane, requestId=desktop-look-changes}
❯ ./register.tsx calls: $.clock.now, $.command.register, $.env.get, $.fs.read,
$.session.turns, $.state.get, $.state.set, $.ui.open, $.ui.resolve
❯ ./register.tsx env reads: HOME
❯ ./register.tsx env writes: nothing
No process spawning, no network, no credential store access.
Hooks run as soon as the plugin is installed and enabled, with no separate approval step. Three things switch them off:
"disableAllHooks": true in settings--safe-mode or --bareallowManagedModsOnly or allowManagedHooksOnlyNo build step. Claude Code loads the .tsx hook modules directly, and tsconfig.json is type checking only.
Work against a live directory so edits hot reload:
claude --plugin-dir /path/to/desktop-look
An installed copy is cached by version, so edits will not show up there until you bump version in .claude-plugin/plugin.json and reinstall. That version bump is also what releases an update to anyone who installed from the marketplace. Pushing commits without changing version reaches nobody.
Two notes on types:
types/index.d.ts is hand written and required, because the mod uses $.state. It is committed..claude-plugin/types/ is generated per Claude Code version and is git ignored. tsconfig.json extends it, so on a fresh clone tsc fails until you load the mod once with --plugin-dir, which regenerates the directory.Because marketplace.json and plugin.json share the .claude-plugin/ directory, claude plugin validate . reports on the marketplace manifest only. To validate the plugin and its hooks, copy the directory elsewhere, delete marketplace.json from the copy, and validate that.
Tests live in hooks/*.test.ts and hooks/*.test.tsx:
claude plugin test .
This needs an unwrapped claude on PATH. Some corporate installs wrap the binary and inject options ahead of the subcommand, which makes plugin test fail to parse.
Claude Code mod, Claude Code theme, Claude Code plugin, Claude desktop app look, terminal UI, TUI theme, chat bubbles, welcome screen, usage stats, Changes sidebar, Anthropic Claude, AI coding assistant customization
hooks/register.tsx 229 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Change } from '../types'
5import { registerWelcome } from './welcome'
6
7const PANE = 'desktop-look-changes'
8const changes = atom({ plugin: 'desktop-look', key: 'changes' } as const, [])
9
10// Tools whose own drawing (diffs, questions, plans, todos) carries the content: left alone.
11const KEEP_ENGINE_ROW = new Set([
12 'Edit',
13 'MultiEdit',
14 'Write',
15 'NotebookEdit',
16 'AskUserQuestion',
17 'ExitPlanMode',
18 'TodoWrite',
19])
20// The Claude desktop app's light palette.
21const TEXT = '#141413'
22const SECONDARY = '#3d3d3a'
23const MUTED = '#87867f'
24const ACCENT = '#d97757'
25const USER_BUBBLE = '#f0eee6'
26// Symbols for Legacy Computing: strips a third of a row tall, half-cell corner wedges
27// that meet them, and half blocks that pull the side edges in to line up with the wedges.
28const CORNER = {
29 top: '\u{1FB2D}',
30 topLeft: '\u{1FB47}',
31 topRight: '\u{1FB3C}',
32 bottom: '\u{1FB02}',
33 bottomLeft: '\u{1FB62}',
34 bottomRight: '\u{1FB57}',
35 left: '▐',
36 right: '▌',
37}
38
39const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
40
41// Greedy word wrap that keeps the person's own line breaks and splits overlong words.
42export const wrap = (text: string, width: number) => {
43 const lines: string[] = []
44 for (const paragraph of text.replace(/\t/g, ' ').split('\n')) {
45 let line = ''
46 for (const word of paragraph.split(' ')) {
47 let rest = word
48 while (rest.length > width) {
49 if (line) lines.push(line)
50 lines.push(rest.slice(0, width))
51 rest = rest.slice(width)
52 line = ''
53 }
54 if (!line) line = rest
55 else if (line.length + 1 + rest.length <= width) line += ` ${rest}`
56 else {
57 lines.push(line)
58 line = rest
59 }
60 }
61 lines.push(line)
62 }
63
64 return lines
65}
66
67const shortPath = (path: string) => {
68 const parts = path.split('/').filter(Boolean)
69
70 return parts.length <= 2 ? path : `…/${parts.slice(-2).join('/')}`
71}
72
73const field = (input: unknown, key: string): string | undefined => {
74 if (input === null || typeof input !== 'object') {
75 return undefined
76 }
77 const value = (input as Record<string, unknown>)[key]
78
79 return typeof value === 'string' && value.length > 0 ? value : undefined
80}
81
82const summarize = (input: unknown): string => {
83 const path = field(input, 'file_path') ?? field(input, 'notebook_path')
84 if (path) {
85 return shortPath(path)
86 }
87
88 return (
89 field(input, 'description') ??
90 field(input, 'command') ??
91 field(input, 'pattern') ??
92 field(input, 'query') ??
93 field(input, 'url') ??
94 field(input, 'prompt') ??
95 field(input, 'path') ??
96 ''
97 ).split('\n')[0]!
98}
99
100export const register: Register = (on, options) => {
101 registerWelcome(on, options)
102
103 on('session.start', async ($, e, next) => {
104 await $.command.register({
105 name: 'changes',
106 description: 'Show the files changed this session in a sidebar, like the desktop app',
107 })
108 void $.ui.open({ id: PANE, title: 'Changes' })
109
110 return next(e)
111 })
112
113 on('command.run', { command: 'changes' }, async $ => {
114 await $.ui.open({ id: PANE, title: 'Changes' })
115
116 return { text: 'Changes sidebar opened.' }
117 })
118
119 on('tool.call', async ($, e, next) => {
120 const ran = await next(e)
121 const path = field(e, 'file_path') ?? field(e, 'notebook_path')
122
123 if (EDIT_TOOLS.has(e.tool) && path && !('deny' in ran) && !ran.isError) {
124 await update($, changes, list => {
125 const found = list.find(one => one.path === path)
126 if (found) {
127 return list.map(one =>
128 one.path === path ? { ...one, tool: e.tool, edits: one.edits + 1 } : one,
129 )
130 }
131 const change: Change = { path, tool: e.tool, edits: 1 }
132
133 return [...list, change]
134 })
135 }
136
137 return ran
138 })
139
140 // The person's prompt as a right-aligned bubble. A third-of-a-row strip pads it above
141 // and below, and a half-cell wedge at each corner stands for the desktop's 4px radius
142 // (at SF Mono 14pt a cell is about 8px wide and 20px tall).
143 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
144 if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer') {
145 return next(e)
146 }
147 const { Box, Text } = $.ui.resolve(e)
148 const columns = e.viewport?.columns ?? 80
149 const lines = wrap(e.props.text, Math.max(10, Math.floor(columns * 0.7) - 4))
150 const width = Math.max(...lines.map(line => line.length))
151
152 return (
153 <Box flexDirection="column" alignItems="flex-end" marginTop={1} marginRight={1}>
154 <Text color={USER_BUBBLE}>{`${CORNER.topLeft}${CORNER.top.repeat(width + 2)}${CORNER.topRight}`}</Text>
155 {lines.map((line, i) => (
156 <Box key={`line-${i}`}>
157 <Text color={USER_BUBBLE}>{CORNER.left}</Text>
158 <Text color={TEXT} backgroundColor={USER_BUBBLE}>
159 {` ${line.padEnd(width)} `}
160 </Text>
161 <Text color={USER_BUBBLE}>{CORNER.right}</Text>
162 </Box>
163 ))}
164 <Text color={USER_BUBBLE}>
165 {`${CORNER.bottomLeft}${CORNER.bottom.repeat(width + 2)}${CORNER.bottomRight}`}
166 </Text>
167 </Box>
168 )
169 })
170
171 // Replies as plain text, without the leading bullet.
172 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
173 if (e.surface !== 'terminal') {
174 return next(e)
175 }
176 const { Box, Markdown } = $.ui.resolve(e)
177
178 return (
179 <Box marginTop={e.props.isFirstOfReply ? 1 : 0} paddingLeft={1}>
180 <Markdown text={e.props.text} />
181 </Box>
182 )
183 })
184
185 // Tool calls as one quiet row each, the way the desktop app collapses them.
186 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
187 if (e.surface !== 'terminal' || KEEP_ENGINE_ROW.has(e.props.tool) || e.props.isErrored) {
188 return next(e)
189 }
190 const { Box, Text } = $.ui.resolve(e)
191 const status = e.props.isRunning ? '◌' : e.props.isInterrupted ? '◇' : '◆'
192
193 return (
194 <Box paddingLeft={1}>
195 <Text color={e.props.isRunning ? ACCENT : MUTED}>{status} </Text>
196 <Text bold color={SECONDARY}>
197 {e.props.tool}{' '}
198 </Text>
199 <Text color={MUTED} wrap="truncate-end">
200 {summarize(e.props.input)}
201 </Text>
202 </Box>
203 )
204 })
205
206 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
207 const { Box, Text } = $.ui.resolve(e)
208 const list = await read($, changes)
209 const room = Math.max(1, e.props.scroll.bodyRows - 2)
210
211 return (
212 <Box flexDirection="column">
213 <Text dimColor>
214 {list.length === 0 ? 'No files changed yet.' : `${list.length} files changed`}
215 </Text>
216 {list.slice(-room).map(change => (
217 <Box key={change.path}>
218 <Text color={change.tool === 'Write' ? 'green' : 'yellow'}>
219 {change.tool === 'Write' ? 'A ' : 'M '}
220 </Text>
221 <Text wrap="truncate-start">{shortPath(change.path)}</Text>
222 <Text dimColor> ×{change.edits}</Text>
223 </Box>
224 ))}
225 </Box>
226 )
227 })
228}
229hooks/welcome.tsx 346 lines1import { atom, read, update } from 'claude-code'
2import type { On, PluginOptions } from 'claude-code'
3
4import type { Range, Tab } from '../types'
5
6const tab = atom({ plugin: 'desktop-look', key: 'tab' } as const, 'overview')
7const range = atom({ plugin: 'desktop-look', key: 'range' } as const, 'all')
8
9const DAY_MS = 86_400_000
10// Dune runs about 188k words, roughly 245k tokens.
11const DUNE_TOKENS = 245_000
12const ACCENT = '#d97757'
13// The built-in /stats heatmap: gray dots for quiet days, four shades in the accent.
14const ACCENT_HEX = 0xd97757
15const DOT_HEX = 0xc2c0b6
16const DEFAULT = 0x01000000
17const SHADES = ['░', '▒', '▓', '█']
18const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
19const LABEL_COLUMNS = 4
20
21type Stats = {
22 lastComputedDate?: string
23 totalSessions?: number
24 totalMessages?: number
25 firstSessionDate?: string
26 longestSession?: { duration: number }
27 dailyActivity?: { date: string; messageCount: number; sessionCount: number }[]
28 dailyModelTokens?: { date: string; tokensByModel: Record<string, number> }[]
29 modelUsage?: Record<
30 string,
31 {
32 inputTokens: number
33 outputTokens: number
34 cacheReadInputTokens: number
35 cacheCreationInputTokens: number
36 }
37 >
38 hourCounts?: Record<string, number>
39}
40export type { Stats }
41
42const RANGES: { key: Range; label: string; days: number | null }[] = [
43 { key: 'all', label: 'All time', days: null },
44 { key: '7d', label: 'Last 7 days', days: 7 },
45 { key: '30d', label: 'Last 30 days', days: 30 },
46]
47
48const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10)
49
50export const compact = (n: number) => {
51 if (n >= 1e9) return `${(n / 1e9).toFixed(1)}b`
52 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}m`
53 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
54
55 return String(n)
56}
57
58const duration = (ms: number) => {
59 const minutes = Math.floor(ms / 60_000)
60 const d = Math.floor(minutes / 1440)
61 const h = Math.floor((minutes % 1440) / 60)
62 const m = minutes % 60
63
64 return d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}h ${m}m` : `${m}m`
65}
66
67const shortDate = (day: string) => `${MONTHS[Number(day.slice(5, 7)) - 1]} ${Number(day.slice(8))}`
68
69// "claude-opus-4-8" -> "Opus 4.8", "claude-fable-5" -> "Fable 5"
70export const modelLabel = (id: string) => {
71 const [family = id, ...version] = id.replace(/^claude-/, '').replace(/-\d{8}$/, '').split('-')
72
73 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${version.join('.')}`.trim()
74}
75
76const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
77
78const toBase64 = (bytes: Uint8Array) => {
79 let out = ''
80 for (let i = 0; i < bytes.length; i += 3) {
81 const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
82 const n = (a << 16) | (b << 8) | c
83 out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
84 out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
85 out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
86 }
87
88 return out
89}
90
91export const summarize = (stats: Stats, now: number, rangeKey: Range) => {
92 const days = RANGES.find(one => one.key === rangeKey)?.days ?? null
93 const since = days === null ? '' : isoDay(now - (days - 1) * DAY_MS)
94 const activity = (stats.dailyActivity ?? []).filter(day => day.date >= since)
95 const active = activity.filter(day => day.messageCount > 0)
96 const tokens = (stats.dailyModelTokens ?? []).filter(day => day.date >= since)
97
98 const byModel: Record<string, number> = {}
99 for (const day of tokens) {
100 for (const [model, count] of Object.entries(day.tokensByModel)) {
101 byModel[model] = (byModel[model] ?? 0) + count
102 }
103 }
104 const models = Object.entries(byModel).sort((a, b) => b[1] - a[1])
105
106 const first = days === null ? (stats.firstSessionDate ?? isoDay(now)).slice(0, 10) : since
107 const spanDays = Math.max(1, Math.round((Date.parse(isoDay(now)) - Date.parse(first)) / DAY_MS) + 1)
108
109 // Streaks over consecutive active dates; the current one may end today or yesterday.
110 const activeDates = new Set(active.map(day => day.date))
111 let longestStreak = 0
112 let run = 0
113 let previous = ''
114 for (const date of [...activeDates].sort()) {
115 run = previous && Date.parse(date) - Date.parse(previous) === DAY_MS ? run + 1 : 1
116 longestStreak = Math.max(longestStreak, run)
117 previous = date
118 }
119 let currentStreak = 0
120 let cursor = Date.parse(isoDay(now))
121 if (!activeDates.has(isoDay(cursor))) cursor -= DAY_MS
122 while (activeDates.has(isoDay(cursor))) {
123 currentStreak += 1
124 cursor -= DAY_MS
125 }
126
127 const busiest = [...active].sort((a, b) => b.messageCount - a.messageCount)[0]
128 const usage = Object.values(stats.modelUsage ?? {})
129 const sum = (pick: (one: (typeof usage)[number]) => number) =>
130 usage.reduce((total, one) => total + pick(one), 0)
131
132 return {
133 sessions:
134 days === null
135 ? (stats.totalSessions ?? 0)
136 : activity.reduce((total, day) => total + day.sessionCount, 0),
137 totalTokens: models.reduce((total, [, count]) => total + count, 0),
138 favorite: models[0] ? modelLabel(models[0][0]) : '-',
139 models,
140 activeDays: active.length,
141 spanDays,
142 longestStreak,
143 currentStreak,
144 mostActiveDay: busiest ? shortDate(busiest.date) : '-',
145 longestSession: stats.longestSession ? duration(stats.longestSession.duration) : '-',
146 breakdown: {
147 input: sum(one => one.inputTokens),
148 output: sum(one => one.outputTokens),
149 cacheRead: sum(one => one.cacheReadInputTokens),
150 cacheWrite: sum(one => one.cacheCreationInputTokens),
151 },
152 }
153}
154
155// Month labels on top, Mon/Wed/Fri down the side, one column per week, the current week last.
156export const heatmap = (stats: Stats, now: number, weeks: number) => {
157 const counts = new Map((stats.dailyActivity ?? []).map(day => [day.date, day.messageCount]))
158 const busy = [...counts.values()].filter(count => count > 0).sort((a, b) => a - b)
159 const cut = (q: number) => busy[Math.floor(q * (busy.length - 1))] ?? 1
160 const thresholds = [cut(0.25), cut(0.5), cut(0.75)]
161 const start = Date.parse(isoDay(now)) - (new Date(now).getUTCDay() + (weeks - 1) * 7) * DAY_MS
162 const columns = LABEL_COLUMNS + weeks
163 const rows = 8
164 const words = new Uint32Array(columns * rows * 3)
165 const put = (row: number, column: number, char: string, color: number) =>
166 words.set([char.codePointAt(0)!, color, DEFAULT], (row * columns + column) * 3)
167
168 for (let row = 0; row < rows; row++) {
169 for (let column = 0; column < columns; column++) put(row, column, ' ', DEFAULT)
170 }
171 for (const [row, label] of [[2, 'Mon'], [4, 'Wed'], [6, 'Fri']] as const) {
172 ;[...label].forEach((char, i) => put(row, i, char, DEFAULT))
173 }
174
175 let lastLabelEnd = LABEL_COLUMNS
176 for (let week = 0; week < weeks; week++) {
177 const weekStart = start + week * 7 * DAY_MS
178 const month = new Date(weekStart).getUTCMonth()
179 const column = LABEL_COLUMNS + week
180 const isNewMonth = week === 0 || new Date(weekStart - 7 * DAY_MS).getUTCMonth() !== month
181 if (isNewMonth && column >= lastLabelEnd && column + 3 <= columns) {
182 ;[...MONTHS[month]!].forEach((char, i) => put(0, column + i, char, DEFAULT))
183 lastLabelEnd = column + 4
184 }
185
186 for (let weekday = 0; weekday < 7; weekday++) {
187 const at = weekStart + weekday * DAY_MS
188 if (at > now) continue
189 const count = counts.get(isoDay(at)) ?? 0
190 const level = thresholds.filter(threshold => count > threshold).length
191 put(1 + weekday, column, count > 0 ? SHADES[level]! : '·', count > 0 ? ACCENT_HEX : DOT_HEX)
192 }
193 }
194
195 return { cells: toBase64(new Uint8Array(words.buffer)), columns, rows }
196}
197
198export const registerWelcome = (on: On, options: PluginOptions) => {
199 const name = typeof options.name === 'string' ? options.name.trim() : ''
200
201 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202 if (
203 e.surface !== 'terminal' ||
204 e.props.hasSurvey ||
205 e.props.isWorking ||
206 (await $.session.turns()) > 0
207 ) {
208 return next(e)
209 }
210
211 let stats: Stats
212 try {
213 stats = JSON.parse(await $.fs.read(`${await $.env.get('HOME')}/.claude/stats-cache.json`))
214 } catch {
215 return next(e)
216 }
217
218 const { Box, Button, Raster, Text } = $.ui.resolve(e)
219 const now = await $.clock.now()
220 const tabKey = await read($, tab)
221 const rangeKey = await read($, range)
222 const s = summarize(stats, now, rangeKey)
223 const inner = Math.min(e.props.bodyColumns - 4, 120)
224 const weeks = Math.max(8, Math.min(53, inner - LABEL_COLUMNS))
225 const map = heatmap(stats, now, weeks)
226 const hasRoomForHeatmap = e.props.maxRows >= 20
227 const column = Math.max(28, Math.floor(inner / 2) - 4)
228 const tokensOfInputOutput = s.breakdown.input + s.breakdown.output
229
230 const pair = (left: string, right: string) => (
231 <Box>
232 <Box width={column}>
233 <Text>{left}</Text>
234 </Box>
235 <Text>{right}</Text>
236 </Box>
237 )
238
239 return (
240 <Box flexDirection="column" paddingX={1}>
241 <Box marginBottom={1}>
242 <Text color={ACCENT} bold>
243 ✻{' '}
244 </Text>
245 <Text bold>{name ? `What’s up next, ${name}?` : 'What’s up next?'}</Text>
246 </Box>
247
248 <Box marginBottom={1} gap={2}>
249 {(['overview', 'models'] as const).map(key =>
250 tabKey === key ? (
251 <Button
252 key={`tab-${key}`}
253 label={key === 'overview' ? 'Overview' : 'Models'}
254 variant="primary"
255 onPress={() => update($, tab, () => key as Tab)}
256 />
257 ) : (
258 <Button
259 key={`tab-${key}`}
260 label={key === 'overview' ? 'Overview' : 'Models'}
261 plain
262 dimColor
263 onPress={() => update($, tab, () => key as Tab)}
264 />
265 ),
266 )}
267 </Box>
268
269 {hasRoomForHeatmap && (
270 <Box flexDirection="column" marginBottom={1}>
271 <Raster key="heatmap" columns={map.columns} rows={map.rows} cells={map.cells} />
272 <Box marginLeft={LABEL_COLUMNS}>
273 <Text dimColor>Less </Text>
274 <Text color={ACCENT}>{SHADES.join(' ')}</Text>
275 <Text dimColor> More</Text>
276 </Box>
277 </Box>
278 )}
279
280 <Box marginBottom={1}>
281 {RANGES.map((one, i) => (
282 <Box key={`range-wrap-${one.key}`}>
283 {i > 0 && <Text dimColor> · </Text>}
284 <Button
285 key={`range-${one.key}`}
286 label={one.label}
287 plain
288 dimColor={rangeKey !== one.key}
289 onPress={() => update($, range, () => one.key)}
290 />
291 </Box>
292 ))}
293 </Box>
294
295 {tabKey === 'overview' ? (
296 <Box flexDirection="column">
297 {pair(`Favorite model: ${s.favorite}`, `Total tokens: ${compact(s.totalTokens)}`)}
298 {pair(`Sessions: ${s.sessions.toLocaleString('en-US')}`, `Longest session: ${s.longestSession}`)}
299 {pair(`Active days: ${s.activeDays}/${s.spanDays}`, `Longest streak: ${s.longestStreak} days`)}
300 {pair(`Most active day: ${s.mostActiveDay}`, `Current streak: ${s.currentStreak} days`)}
301 {rangeKey === 'all' && (
302 <Text dimColor>
303 Input {compact(s.breakdown.input)} · Output {compact(s.breakdown.output)} · Cache read{' '}
304 {compact(s.breakdown.cacheRead)} · Cache write {compact(s.breakdown.cacheWrite)}
305 </Text>
306 )}
307 </Box>
308 ) : (
309 <Box flexDirection="column">
310 {s.models.length === 0 && <Text dimColor>No model usage in this range.</Text>}
311 {s.models.slice(0, 6).map(([model, count]) => {
312 const share = s.totalTokens === 0 ? 0 : count / s.totalTokens
313 const barRoom = Math.max(10, Math.min(40, inner - 34))
314 const bar = Math.max(1, Math.round(share * barRoom))
315
316 return (
317 <Box key={model}>
318 <Box width={16}>
319 <Text>{modelLabel(model)}</Text>
320 </Box>
321 <Text color={ACCENT}>{'█'.repeat(bar)}</Text>
322 <Text color="#c2c0b6">{'·'.repeat(Math.max(0, barRoom - bar))}</Text>
323 <Text dimColor>
324 {' '}
325 {compact(count).padStart(6)} {String(Math.round(share * 100)).padStart(3)}%
326 </Text>
327 </Box>
328 )
329 })}
330 </Box>
331 )}
332
333 {rangeKey === 'all' && tokensOfInputOutput > 0 && (
334 <Box marginTop={1}>
335 <Text dimColor>
336 Your input and output are ~
337 {Math.round(tokensOfInputOutput / DUNE_TOKENS).toLocaleString('en-US')}x the tokens in
338 Dune
339 </Text>
340 </Box>
341 )}
342 </Box>
343 )
344 })
345}
346types/index.d.ts 10 lines1export type Change = { path: string; tool: string; edits: number }
2export type Tab = 'overview' | 'models'
3export type Range = 'all' | '30d' | '7d'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'desktop-look': { changes: Change[]; tab: Tab; range: Range }
8 }
9}
10