SLOPSHOPPER

Claude Code Desktop Look Mod

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…

newpanebandrowsguardcommand
v0.2.2no licenseupdated 2026-10-08albertzhangz10/claude-code-desktop-look-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desktop-look
│ ┃ Changes ✕ │ ┃ 3 files changed 🭇🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬼 │ ┃ M …/src/auth.ts ×1 ▐ fix the failing auth test and add an audit log call ▌ │ ┃ A …/src/audit.ts ×1 🭢🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🭗 │ ┃ A …/src/cache.ts ×1 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ Done. I made refresh reject expired claims and added an audit call. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ desktop-look: Changes sidebar opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
3 files changed M …/src/auth.ts ×1 A …/src/audit.ts ×1 A …/src/cache.ts ×1
Your message
🭇🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬭🬼 ▐ fix the failing auth test and add an audit log call ▌ 🭢🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🬂🭗
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

Claude Code Desktop Look Mod

Claude Code Version Claude Code 2.1.287+

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.

Install

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

Configure

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.

What it does to your machine

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.

If it appears to do nothing

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
  • running with --safe-mode or --bare
  • managed settings in a corporate environment, specifically allowManagedModsOnly or allowManagedHooksOnly

Development

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

Keywords

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

Source 3 files
hooks/register.tsx 229 lines
1import { 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}
229
hooks/welcome.tsx 346 lines
1import { 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}
346
types/index.d.ts 10 lines
1export 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