Make Claude Code look the way you like: four themes (Retro CRT, Punk, Synthwave, Zen paper) switched with /theme, tidier tool calls, and optional one-line tool…

Make Claude Code look the way you like: four themes you switch with /theme, and two optional layout changes, one line per tool call and replies in a centered reading column.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install looks@claude-code-mods
Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.
Looks redraws the rows of the transcript. It never changes what Claude reads or does. Out of the box it tidies tool calls and leaves the layout alone. Themes, compact rows, and the centered layout are choices you make.


toolCalls: full, the default) draw each call as a header line: a mark, the tool, its target, and a short result such as +4 -1 or failed. Under it, the output sits in a short dim bar. An edit shows only its changed lines in green and red. A shell command shows its first 6 lines and N more lines (ctrl+o for all). Reads, searches, and other calls with no output add nothing under the header. A failed call shows the first lines of its error in red.toolCalls: compact) draw one line per call and hide the output. A run of reads and searches folds into one line such as Read 3 files, ran 1 command. Any other tool, MCP tools included, gets one line with its name and its main argument.toolCalls: plain) leave every tool row to Claude Code.layout: centered) puts replies, prompts, and tool lines in one column at the reading width (100 characters by default), centered in the transcript.themeStyle: rich, a theme draws:themeStyle: simple only recolors text. Themes keep Claude Code's own spinner and turn footer words, unless you turn on themeWords.
auto, Looks leaves it alone. /theme off and /exit put your own base theme back./theme opens the theme picker./theme retro, /theme punk, /theme synthwave, /theme zen switch at once. /theme off turns themes off and restores your base theme./theme base opens the Claude Code base theme picker. /looks does the same as /theme.Your choice is kept for the next sessions. If you change the base theme in /config while a Looks theme is on, Looks keeps your new choice as the one to restore.
In the picker:
r, p, s, or z to apply Retro CRT, Punk, Synthwave, or Zen paper, or o for Off.b to open the Claude Code base themes.With tidy or compact tool calls, press ctrl+o to see every tool call in full, with diffs and output. Press ctrl+o again to return.
Each image is a real session with the theme on.
Retro CRT: phosphor green on black, double-line panels, an amber accent, and ═ dividers.

Punk: hot pink and acid yellow, bold frames, yellow name tags, and zigzag dividers.

Synthwave: neon magenta and cyan, rounded frames, and a divider that fades from magenta to cyan.

Zen paper: quiet ink and moss tones, thin frames, sage name tags, and dotted dividers. It also has a light paper variant when your terminal is light.

Change these in /plugin:
theme (off): the theme for new sessions until you pick one with /theme.toolCalls (full): full is the tidy view above. compact draws one line per call. plain keeps Claude Code's own tool rows.layout (left): centered puts rows and replies in one column at the reading width. left keeps them at the left edge.readingWidth (100): the column width in characters.themeStyle (rich): rich is the full look above. simple only recolors text.themeWords (off): on uses the theme's spinner and footer words, such as CRUISING or Shredded. Off keeps Claude Code's own words.verbose on in /config, Claude Code reports every row as expanded, so ctrl+o cannot switch back to full rows. The tidy view then shows up to 30 lines of output per call. Set toolCalls to plain to see Claude Code's own rows./theme off. /exit restores it.See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.ts 18 lines1import type { Register } from 'claude-code'
2
3import { installChrome } from './chrome'
4import { configureLooks, settingsFrom } from './looks/state'
5import { installReply } from './reply'
6import { installRows } from './rows'
7import { installSwitcher } from './switcher'
8import { installWatchers } from './watchers'
9
10export const register: Register = (on, options) => {
11 configureLooks(settingsFrom(options))
12 installSwitcher(on)
13 installWatchers(on)
14 installRows(on)
15 installReply(on)
16 installChrome(on)
17}
18hooks/chrome.ts 42 lines1import type { On } from 'claude-code'
2
3import { divider } from './looks/rich'
4import { currentLook, currentTheme, isCentered, isThemeWords, looksSettings } from './looks/state'
5import { pickWord } from './looks/themes'
6
7export function installChrome(on: On): void {
8 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
9 const theme = currentTheme()
10 if (e.surface !== 'terminal' || !theme || !isThemeWords() || e.props.message !== null) {
11 return next(e)
12 }
13 const word = pickWord({ words: theme.spinnerWords, seed: e.props.word })
14 return next({ ...e, props: { ...e.props, word, suffix: theme.spinnerSuffix } })
15 })
16
17 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
18 const theme = currentTheme()
19 const look = currentLook()
20 if (e.surface === 'terminal' && look) {
21 const { Box, Text } = $.ui.resolve(e)
22 const columns = e.viewport?.columns ?? looksSettings().readingWidth
23 const width = isCentered() ? Math.min(looksSettings().readingWidth, columns) : columns
24 const word = theme && isThemeWords() ? pickWord({ words: theme.doneWords, seed: e.requestId }) : e.props.word
25 return divider({ Box, Text, look, width, word, durationMs: e.props.durationMs })
26 }
27 if (e.surface !== 'terminal' || !theme || !isThemeWords()) {
28 return next(e)
29 }
30 return next({ ...e, props: { ...e.props, word: pickWord({ words: theme.doneWords, seed: e.requestId }) } })
31 })
32
33 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
34 const theme = currentTheme()
35 if (e.surface !== 'terminal' || !theme) {
36 return next(e)
37 }
38 const tail = [e.props.tail, `${theme.toolMark} ${theme.label}`].filter(part => part !== undefined && part !== '').join(' ')
39 return next({ ...e, props: { ...e.props, tail } })
40 })
41}
42hooks/looks/state.ts 156 lines1import { lookOf } from './look'
2import type { Look } from './look'
3import { paletteOf, themeChoiceFrom, themeOf } from './themes'
4import type { Palette, Theme, ThemeChoice, Tone } from './themes'
5
6export type ToolCallsMode = 'compact' | 'full' | 'plain'
7export type ToolView = 'compact' | 'tidy' | 'plain'
8export type LayoutMode = 'centered' | 'left'
9export type ThemeStyle = 'rich' | 'simple'
10
11export type LooksSettings = {
12 theme: ThemeChoice
13 toolCalls: ToolCallsMode
14 layout: LayoutMode
15 readingWidth: number
16 themeWords: boolean
17 themeStyle: ThemeStyle
18}
19
20export const SAVED_BASE_KEY = 'savedBaseTheme'
21const MIN_READING_WIDTH = 40
22const DEFAULT_READING_WIDTH = 100
23const RESULT_LINES = 6
24const VERBOSE_RESULT_LINES = 30
25
26let settings: LooksSettings = { theme: 'off', toolCalls: 'full', layout: 'left', readingWidth: DEFAULT_READING_WIDTH, themeWords: false, themeStyle: 'rich' }
27let activeTheme: ThemeChoice = 'off'
28let highlighted: ThemeChoice = 'retro'
29let tone: Tone = 'dark'
30let isTranscriptExpanded = false
31let isVerbose = false
32let projectRoot = ''
33let baseInfo: { current?: string; saved?: string } = {}
34
35export function settingsFrom(options: Readonly<Record<string, unknown>>): LooksSettings {
36 const width = Number(options.readingWidth)
37 return {
38 theme: themeChoiceFrom(options.theme) ?? 'off',
39 toolCalls: options.toolCalls === 'compact' || options.toolCalls === 'plain' ? options.toolCalls : 'full',
40 layout: options.layout === 'centered' ? 'centered' : 'left',
41 readingWidth: Number.isFinite(width) ? Math.max(MIN_READING_WIDTH, Math.round(width)) : DEFAULT_READING_WIDTH,
42 themeWords: options.themeWords === true,
43 themeStyle: options.themeStyle === 'simple' ? 'simple' : 'rich',
44 }
45}
46
47export function configureLooks(next: LooksSettings): void {
48 settings = next
49 activeTheme = next.theme
50 highlighted = next.theme === 'off' ? 'retro' : next.theme
51 isTranscriptExpanded = false
52}
53
54export function looksSettings(): LooksSettings {
55 return settings
56}
57
58export function activeChoice(): ThemeChoice {
59 return activeTheme
60}
61
62export function currentTheme(): Theme | undefined {
63 return themeOf(activeTheme)
64}
65
66export function setActiveChoice(choice: ThemeChoice): void {
67 activeTheme = choice
68 highlighted = choice
69}
70
71export function highlightedChoice(): ThemeChoice {
72 return highlighted
73}
74
75export function setHighlighted(choice: ThemeChoice): boolean {
76 const isChanged = highlighted !== choice
77 highlighted = choice
78 return isChanged
79}
80
81export function setTone(next: Tone): void {
82 tone = next
83}
84
85export function palette(): Palette {
86 return paletteOf({ choice: activeTheme, tone })
87}
88
89export function noteTranscriptExpanded(isExpanded: boolean): boolean {
90 const isChanged = isTranscriptExpanded !== isExpanded
91 isTranscriptExpanded = isExpanded
92 return isChanged
93}
94
95export function setVerbose(next: boolean): void {
96 isVerbose = next
97}
98
99export function toolView(): ToolView {
100 if (!isVerbose && isTranscriptExpanded) {
101 return 'plain'
102 }
103 return settings.toolCalls === 'compact' ? 'compact' : settings.toolCalls === 'full' ? 'tidy' : 'plain'
104}
105
106export function isCompactView(): boolean {
107 return toolView() === 'compact'
108}
109
110export function resultLimit(): number {
111 return isVerbose ? VERBOSE_RESULT_LINES : RESULT_LINES
112}
113
114export function isGroupUnfolded(isExpanded: boolean): boolean {
115 return isExpanded && !isVerbose
116}
117
118export function baseThemeInfo(): { current?: string; saved?: string } {
119 return baseInfo
120}
121
122export function setBaseThemeInfo(next: { current?: string; saved?: string }): void {
123 baseInfo = next
124}
125
126export function currentLook(): Look | undefined {
127 return activeTheme !== 'off' && settings.themeStyle === 'rich' ? lookOf({ name: activeTheme, tone }) : undefined
128}
129
130export function currentTone(): Tone {
131 return tone
132}
133
134export function isThemeWords(): boolean {
135 return settings.themeWords
136}
137
138export function isCentered(): boolean {
139 return settings.layout === 'centered'
140}
141
142export function isStyled(): boolean {
143 return activeTheme !== 'off'
144}
145
146export function setProjectRoot(root: string): void {
147 projectRoot = root
148}
149
150export function displayPath(path: string): string {
151 if (projectRoot !== '' && path.startsWith(`${projectRoot}/`)) {
152 return path.slice(projectRoot.length + 1)
153 }
154 return path
155}
156hooks/reply.tsx 53 lines1import type { On } from 'claude-code'
2
3import { promptLine, readingColumn, replyFrame } from './looks/frame'
4import { replyPanel, userRow } from './looks/rich'
5import { currentLook, currentTheme, isCentered, looksSettings, noteTranscriptExpanded, palette } from './looks/state'
6
7const MARKDOWN_LIMIT = 10000
8const TABLE_ROW = /^\s*\|.*\|\s*$/m
9const TABLE_RULE = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/m
10
11function hasTable(text: string): boolean {
12 return TABLE_ROW.test(text) && TABLE_RULE.test(text)
13}
14
15export function installReply(on: On): void {
16 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
17 const theme = currentTheme()
18 if (e.surface !== 'terminal' || (!theme && !isCentered()) || e.props.text.length > MARKDOWN_LIMIT) {
19 return next(e)
20 }
21 const { Box, Text, Markdown } = $.ui.resolve(e)
22 const isWide = hasTable(e.props.text)
23 const layout = { Box, width: looksSettings().readingWidth, isCentered: isCentered() && !isWide, columns: e.viewport?.columns }
24 const body = <Markdown text={e.props.text} />
25 const look = currentLook()
26 if (look) {
27 return readingColumn({ ...layout, gapAbove: e.props.isFirstOfReply ? 1 : 0, children: replyPanel({ Box, Text, look, isFirst: e.props.isFirstOfReply, body }) })
28 }
29 return readingColumn({ ...layout, gapAbove: 1, children: replyFrame({ Box, Text, theme, colors: palette(), isFirst: e.props.isFirstOfReply, body }) })
30 })
31
32 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
33 if (noteTranscriptExpanded(e.props.isExpanded)) {
34 $.ui.invalidate('ui.render')
35 }
36 const theme = currentTheme()
37 if (e.surface !== 'terminal' || (!theme && !isCentered())) {
38 return next(e)
39 }
40 const { Box, Text } = $.ui.resolve(e)
41 const layout = { Box, width: looksSettings().readingWidth, isCentered: isCentered(), columns: e.viewport?.columns }
42 const isPrompt = e.props.origin.kind === 'composer' && e.props.task === undefined && e.props.from === undefined
43 if (!isPrompt) {
44 return readingColumn({ ...layout, isEngineNode: true, children: await next(e) })
45 }
46 const look = currentLook()
47 if (look) {
48 return readingColumn({ ...layout, gapAbove: 1, children: userRow({ Box, Text, look, text: e.props.text, promptText: palette().promptText }) })
49 }
50 return readingColumn({ ...layout, gapAbove: 1, children: promptLine({ Box, Text, theme, colors: palette(), text: e.props.text }) })
51 })
52}
53hooks/rows.tsx 80 lines1import type { On } from 'claude-code'
2
3import { readingColumn, resultBlock, toolLine } from './looks/frame'
4import { summarizeGroup } from './looks/group'
5import { resultView } from './looks/result'
6import { currentLook, currentTheme, displayPath, isCentered, isGroupUnfolded, looksSettings, palette, resultLimit, toolView } from './looks/state'
7import { isSummarized, summarizeTool } from './looks/summary'
8
9const DEFAULT_GROUP_MARK = '\u23fa'
10const RUNNING = { isRunning: true, isErrored: false, isInterrupted: false }
11
12export function installRows(on: On): void {
13 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
14 if (e.surface !== 'terminal') {
15 return next(e)
16 }
17 const { Box, Text } = $.ui.resolve(e)
18 const layout = { Box, width: looksSettings().readingWidth, isCentered: isCentered(), columns: e.viewport?.columns }
19 const state = { isRunning: e.props.isRunning, isErrored: e.props.isErrored, isInterrupted: e.props.isInterrupted }
20 const summary = toolView() !== 'plain'
21 ? summarizeTool({ tool: e.props.tool, input: e.props.input, output: e.props.output, state, path: displayPath })
22 : undefined
23 if (!summary) {
24 return isCentered() ? readingColumn({ ...layout, isEngineNode: true, children: await next(e) }) : next(e)
25 }
26 return readingColumn({ ...layout, children: toolLine({ Text, summary, state, theme: currentTheme(), colors: palette(), look: currentLook() }) })
27 })
28
29 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
30 if (e.surface !== 'terminal') {
31 return next(e)
32 }
33 const { Box, Text } = $.ui.resolve(e)
34 const view = toolView()
35 if (view === 'compact' && isSummarized(e.props.tool)) {
36 return <Box />
37 }
38 const layout = { Box, width: looksSettings().readingWidth, isCentered: isCentered(), columns: e.viewport?.columns }
39 if (view === 'tidy' && isSummarized(e.props.tool)) {
40 const shown = resultView({ tool: e.props.tool, output: e.props.output, isErrored: e.props.isErrored, limit: resultLimit() })
41 if (shown.kind === 'none') {
42 return <Box />
43 }
44 if (shown.kind === 'lines') {
45 return readingColumn({ ...layout, children: resultBlock({ Box, Text, colors: palette(), lines: shown.lines, more: shown.more, look: currentLook() }) })
46 }
47 }
48 return isCentered() ? readingColumn({ ...layout, isEngineNode: true, children: await next(e) }) : next(e)
49 })
50
51 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
52 if (e.surface !== 'terminal') {
53 return next(e)
54 }
55 const { Box, Text } = $.ui.resolve(e)
56 const layout = { Box, width: looksSettings().readingWidth, isCentered: isCentered(), columns: e.viewport?.columns }
57 if (isGroupUnfolded(e.props.isExpanded) || toolView() === 'plain') {
58 return isCentered() ? readingColumn({ ...layout, isEngineNode: true, children: await next(e) }) : next(e)
59 }
60 const theme = currentTheme()
61 const colors = palette()
62 const group = summarizeGroup(e.props.calls)
63 const last = e.props.calls.at(-1)
64 const lastSummary = last && (e.props.isActive || e.props.calls.length === 1) ? summarizeTool({ tool: last.tool, input: last.input, output: undefined, state: RUNNING, path: displayPath }) : undefined
65 const hint = group.running > 0 ? '\u2026' : group.failed > 0 ? `${group.failed} failed` : ''
66 const markColor = group.failed > 0 ? colors.failed : group.running > 0 ? colors.muted : colors.accent
67 return readingColumn({
68 ...layout,
69 children: (
70 <Text wrap="truncate-end">
71 <Text color={markColor}>{`${theme?.toolMark ?? DEFAULT_GROUP_MARK} `}</Text>
72 <Text bold color={theme ? colors.secondary : undefined}>{group.text}</Text>
73 {lastSummary && lastSummary.target !== '' && <Text dimColor>{` ${lastSummary.target}`}</Text>}
74 {hint !== '' && <Text color={group.failed > 0 ? colors.failed : undefined} dimColor={group.failed === 0}>{` ${hint}`}</Text>}
75 </Text>
76 ),
77 })
78 })
79}
80hooks/switcher.tsx 195 lines1import type { CommandRunInput, CommandRunResult, EngineInterface, On } from 'claude-code'
2
3import { baseLine, labelOf, themePreview } from './looks/preview'
4import { activeChoice, baseThemeInfo, currentTone, highlightedChoice, looksSettings, SAVED_BASE_KEY, setActiveChoice, setBaseThemeInfo, setHighlighted, setProjectRoot, setTone, setVerbose } from './looks/state'
5import { baseThemeFor, THEME_CHOICES, themeChoiceFrom, themeOf, toneOf } from './looks/themes'
6import type { ThemeChoice } from './looks/themes'
7import { noop } from './shared/noop'
8
9const PICKER_PANE = 'looks-theme'
10const PICKER_ROWS = 15
11const THEME_KEY = 'theme'
12const THEME_SETTING_KEY = 'themeSetting'
13const BASE_ARGUMENT = 'base'
14const ALIASES: Readonly<Record<string, ThemeChoice>> = { crt: 'retro', paper: 'zen', none: 'off' }
15const USAGE = 'Use /theme retro, punk, synthwave, zen, or off. /theme alone opens the picker, and /theme base opens the Claude Code base themes.'
16
17async function configValue($: EngineInterface, options: { key: string }): Promise<unknown> {
18 const rows = await $.config.list().catch(() => [])
19 return rows.find(row => row.key === options.key)?.value
20}
21
22async function baseThemeNow($: EngineInterface): Promise<string | undefined> {
23 const value = await configValue($, { key: 'theme' })
24 return typeof value === 'string' ? value : undefined
25}
26
27async function applyBase($: EngineInterface, options: { choice: ThemeChoice }): Promise<string> {
28 const current = await baseThemeNow($)
29 if (current === undefined) {
30 return 'The base theme is not available here, so it stays as it is.'
31 }
32 const stored = await $.store.get(SAVED_BASE_KEY)
33 const saved = typeof stored === 'string' ? stored : current
34 await $.store.set(SAVED_BASE_KEY, saved)
35 setTone(toneOf(saved))
36 const target = baseThemeFor({ choice: options.choice, saved })
37 if (target === undefined || target === current) {
38 setBaseThemeInfo({ current, saved })
39 return `The base theme stays ${current}.`
40 }
41 const result = await $.config.set({ key: 'theme', value: target })
42 if (result.deny !== undefined) {
43 setBaseThemeInfo({ current, saved })
44 return `The base theme stays ${current}: ${result.deny}`
45 }
46 setBaseThemeInfo({ current: target, saved })
47 return `Base theme ${target}; /theme off restores ${saved}.`
48}
49
50async function restoreBase($: EngineInterface, options: { isForgotten: boolean }): Promise<string> {
51 const stored = await $.store.get(SAVED_BASE_KEY)
52 if (typeof stored !== 'string') {
53 return ''
54 }
55 if ((await baseThemeNow($)) !== stored) {
56 await $.config.set({ key: 'theme', value: stored })
57 }
58 setBaseThemeInfo({ current: stored, saved: options.isForgotten ? undefined : stored })
59 if (options.isForgotten) {
60 await $.store.delete(SAVED_BASE_KEY)
61 }
62 return `Base theme restored to ${stored}.`
63}
64
65async function leaveBase($: EngineInterface): Promise<void> {
66 const { current, saved } = baseThemeInfo()
67 if (activeChoice() === 'off' || saved === undefined || current === saved) {
68 return
69 }
70 const result = await $.config.set({ key: 'theme', value: saved }).catch(noop)
71 if (result?.value !== undefined) {
72 setBaseThemeInfo({ current: saved, saved })
73 }
74}
75
76async function switchTheme($: EngineInterface, options: { choice: ThemeChoice }): Promise<string> {
77 setActiveChoice(options.choice)
78 await $.store.set(THEME_KEY, options.choice)
79 await $.store.set(THEME_SETTING_KEY, looksSettings().theme)
80 $.ui.invalidate('ui.render')
81 const message = options.choice === 'off'
82 ? `Looks theme off. ${await restoreBase($, { isForgotten: true })}`.trim()
83 : `Looks theme: ${labelOf(options.choice)}. ${await applyBase($, { choice: options.choice })}`
84 $.ui.invalidate('ui.render')
85 return message
86}
87
88async function runThemeCommand($: EngineInterface, options: { e: CommandRunInput; next: (e: CommandRunInput) => Promise<CommandRunResult> }): Promise<CommandRunResult> {
89 const action = options.e.args.trim().toLowerCase()
90 if (action === BASE_ARGUMENT && options.e.command === 'theme') {
91 return options.next({ ...options.e, args: '' })
92 }
93 if (action === '') {
94 setHighlighted(activeChoice() === 'off' ? 'retro' : activeChoice())
95 $.ui.invalidate('ui.render')
96 const opened = await $.ui.open({ id: PICKER_PANE, title: 'Theme', focus: true, closeOnEscape: true, rows: PICKER_ROWS })
97 return { text: opened.isPlaced ? 'Theme picker open: press a letter to apply a theme, Esc to close.' : `Widen the terminal to see the theme picker. ${USAGE}` }
98 }
99 const choice = themeChoiceFrom(ALIASES[action] ?? action)
100 if (!choice) {
101 return { text: USAGE }
102 }
103 return { text: await switchTheme($, { choice }) }
104}
105
106export function installSwitcher(on: On): void {
107 on('session.start', async ($, e, next) => {
108 setProjectRoot(e.cwd)
109 await $.command.register({
110 name: 'looks',
111 description: 'Looks: pick a theme. retro, punk, synthwave, zen, or off switches at once. /theme does the same.',
112 argumentHint: '[retro|punk|synthwave|zen|off]',
113 immediate: true,
114 }).catch(noop)
115 setVerbose((await configValue($, { key: 'verbose' })) === true)
116 const stored = themeChoiceFrom(await $.store.get(THEME_KEY))
117 const isStoredCurrent = (await $.store.get(THEME_SETTING_KEY)) === looksSettings().theme
118 const choice = stored !== undefined && isStoredCurrent ? stored : looksSettings().theme
119 setActiveChoice(choice)
120 if (!e.isInteractive) {
121 return next(e)
122 }
123 if (choice === 'off') {
124 await restoreBase($, { isForgotten: true })
125 const current = await baseThemeNow($)
126 setTone(toneOf(current))
127 setBaseThemeInfo({ current })
128 } else {
129 await applyBase($, { choice })
130 }
131 $.ui.invalidate('ui.render')
132 return next(e)
133 })
134
135 on('command.run', { command: 'exit' }, async ($, e, next) => {
136 await leaveBase($)
137 return next(e)
138 })
139
140 on('session.end', async ($, e, next) => {
141 await leaveBase($)
142 return next(e)
143 })
144
145 on('command.run', { command: 'theme' }, async ($, e, next) => runThemeCommand($, { e, next }))
146
147 on('command.run', { command: 'looks' }, async ($, e, next) => runThemeCommand($, { e, next }))
148
149 on('ui.focus', { component: 'Pane', requestId: PICKER_PANE }, async ($, e, next) => {
150 const result = await next(e)
151 const choice = themeChoiceFrom(e.element?.replace(/^theme-/, ''))
152 if (choice && setHighlighted(choice)) {
153 $.ui.invalidate('ui.render')
154 }
155 return result
156 })
157
158 on('ui.render', { component: 'Pane', requestId: PICKER_PANE }, async ($, e) => {
159 const { Box, Text, Button } = $.ui.resolve(e)
160 const active = activeChoice()
161 return (
162 <Box flexDirection="column">
163 <Text dimColor>Press a letter to apply a theme. Tab moves between themes and shows a preview. Esc closes the picker.</Text>
164 <Box flexDirection="row" columnGap={3} marginY={1} flexWrap="wrap">
165 {THEME_CHOICES.map(choice => (
166 <Button
167 key={`theme-${choice}`}
168 plain
169 hotkey={themeOf(choice)?.hotkey ?? 'o'}
170 label={choice === active ? `${labelOf(choice)} (on)` : labelOf(choice)}
171 autoFocus={choice === active || undefined}
172 onPress={() => {
173 setHighlighted(choice)
174 switchTheme($, { choice }).then(message => $.ui.toast(message)).catch(noop)
175 }}
176 />
177 ))}
178 <Button
179 key="base-themes"
180 plain
181 hotkey="b"
182 label="Base themes"
183 dimColor
184 onPress={() => {
185 $.command.run({ command: 'theme', args: '' }).catch(noop)
186 }}
187 />
188 </Box>
189 {themePreview({ Box, Text, choice: highlightedChoice(), tone: currentTone() })}
190 <Text dimColor>{baseLine({ choice: active, ...baseThemeInfo() })}</Text>
191 </Box>
192 )
193 })
194}
195hooks/watchers.ts 27 lines1import type { On } from 'claude-code'
2
3import { activeChoice, SAVED_BASE_KEY, setBaseThemeInfo, setTone, setVerbose } from './looks/state'
4import { toneOf } from './looks/themes'
5
6export function installWatchers(on: On): void {
7 on('config.set', { key: 'theme' }, async ($, e, next) => {
8 const result = await next(e)
9 if (e.origin?.kind === 'composer' && typeof result.value === 'string' && activeChoice() !== 'off') {
10 setBaseThemeInfo({ current: result.value, saved: result.value })
11 setTone(toneOf(result.value))
12 await $.store.set(SAVED_BASE_KEY, result.value)
13 $.ui.invalidate('ui.render')
14 }
15 return result
16 })
17
18 on('config.set', { key: 'verbose' }, async ($, e, next) => {
19 const result = await next(e)
20 if (typeof result.value === 'boolean') {
21 setVerbose(result.value)
22 $.ui.invalidate('ui.render')
23 }
24 return result
25 })
26}
27hooks/looks/rich.tsx 92 lines1import type { BoxProps, ElementConstructor, RenderElement, RenderNode, TextProps } from 'claude-code'
2
3import { mixHex } from './look'
4import type { Look } from './look'
5
6const DIVIDER_MIN = 6
7
8type Elements = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
9
10export function chip(options: { Text: ElementConstructor<TextProps>; look: Look; text: string }): RenderElement {
11 const { Text, look } = options
12 return (
13 <Text bold color={look.chipText} backgroundColor={look.chipBackground}>
14 {` ${options.text} `}
15 </Text>
16 )
17}
18
19export function gradientText(options: { Text: ElementConstructor<TextProps>; look: Look; text: string; bold?: boolean }): RenderElement {
20 const { Text, look } = options
21 const characters = Array.from(options.text)
22 return (
23 <Text bold={options.bold ?? false}>
24 {characters.map((character, index) => (
25 <Text key={`g-${index}`} color={mixHex({ from: look.divider.from, to: look.divider.to, amount: characters.length < 2 ? 0 : index / (characters.length - 1) })}>
26 {character}
27 </Text>
28 ))}
29 </Text>
30 )
31}
32
33function rule(options: { Text: ElementConstructor<TextProps>; look: Look; length: number }): RenderElement {
34 const { pattern } = options.look.divider
35 const text = pattern.repeat(Math.ceil(options.length / pattern.length)).slice(0, options.length)
36 return gradientText({ Text: options.Text, look: options.look, text })
37}
38
39export function replyPanel(options: Elements & { look: Look; isFirst: boolean; body: RenderNode }): RenderElement {
40 const { Box, Text, look } = options
41 return (
42 <Box flexDirection="column">
43 {options.isFirst && (
44 <Box flexDirection="row" marginBottom={0}>
45 {chip({ Text, look, text: look.replyLabel })}
46 </Box>
47 )}
48 <Box flexDirection="column" borderStyle={look.border} borderColor={look.borderColor} backgroundColor={look.panel} paddingX={1}>
49 {options.body}
50 </Box>
51 </Box>
52 )
53}
54
55export function userRow(options: Elements & { look: Look; text: string; promptText: string }): RenderElement {
56 const { Box, Text, look } = options
57 return (
58 <Box flexDirection="row">
59 <Box flexShrink={0} marginRight={1}>
60 {chip({ Text, look, text: look.userLabel })}
61 </Box>
62 <Box flexShrink={1}>
63 <Text bold color={options.promptText}>
64 {options.text}
65 </Text>
66 </Box>
67 </Box>
68 )
69}
70
71export function formatDuration(durationMs: number): string {
72 const seconds = Math.max(1, Math.round(durationMs / 1000))
73 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
74}
75
76export function divider(options: Elements & { look: Look; width: number; word: string; durationMs: number }): RenderElement {
77 const { Box, Text, look } = options
78 const label = ` ${options.word} for ${formatDuration(options.durationMs)} `
79 const side = Math.max(DIVIDER_MIN, Math.floor((options.width - label.length) / 2))
80 return (
81 <Box marginTop={1}>
82 <Text wrap="truncate-end">
83 {rule({ Text, look, length: side })}
84 <Text bold color={look.divider.to}>
85 {label}
86 </Text>
87 {rule({ Text, look, length: side })}
88 </Text>
89 </Box>
90 )
91}
92hooks/looks/themes.ts 138 lines1export type ThemeName = 'retro' | 'punk' | 'synthwave' | 'zen'
2export type ThemeChoice = ThemeName | 'off'
3export type Tone = 'dark' | 'light'
4
5export type Palette = {
6 accent: string
7 secondary: string
8 muted: string
9 promptText: string
10 added: string
11 removed: string
12 failed: string
13}
14
15export type Theme = {
16 name: ThemeName
17 label: string
18 hotkey: string
19 promptMark: string
20 toolMark: string
21 baseVariant: '' | '-ansi' | '-daltonized'
22 spinnerWords: readonly string[]
23 spinnerSuffix: string
24 doneWords: readonly string[]
25 palettes: Record<Tone, Palette>
26}
27
28export const THEME_NAMES: readonly ThemeName[] = ['retro', 'punk', 'synthwave', 'zen']
29export const THEME_CHOICES: readonly ThemeChoice[] = [...THEME_NAMES, 'off']
30
31const NEUTRAL: Palette = {
32 accent: 'green',
33 secondary: 'white',
34 muted: 'gray',
35 promptText: 'white',
36 added: 'green',
37 removed: 'red',
38 failed: 'red',
39}
40
41export const THEMES: Record<ThemeName, Theme> = {
42 retro: {
43 name: 'retro',
44 label: 'Retro CRT',
45 hotkey: 'r',
46 promptMark: 'C:\\>',
47 toolMark: '>',
48 baseVariant: '-ansi',
49 spinnerWords: ['PROCESSING', 'COMPUTING', 'LOADING', 'COMPILING', 'BOOTING', 'DIALING UP'],
50 spinnerSuffix: ' \u2588',
51 doneWords: ['Computed', 'Processed', 'Compiled', 'Booted'],
52 palettes: {
53 dark: { accent: '#39ff7a', secondary: '#ffb000', muted: '#2f8f4e', promptText: '#9dffb8', added: '#39ff7a', removed: '#ff6b4a', failed: '#ff6b4a' },
54 light: { accent: '#0f8a3c', secondary: '#a35f00', muted: '#4d7a5c', promptText: '#0b6b2e', added: '#0f8a3c', removed: '#c2410c', failed: '#c2410c' },
55 },
56 },
57 punk: {
58 name: 'punk',
59 label: 'Punk',
60 hotkey: 'p',
61 promptMark: '\u2716',
62 toolMark: '\u2716',
63 baseVariant: '',
64 spinnerWords: ['SHREDDING', 'SMASHING', 'RIOTING', 'THRASHING', 'MOSHING', 'SCREAMING'],
65 spinnerSuffix: ' !!',
66 doneWords: ['Smashed', 'Shredded', 'Thrashed', 'Wrecked'],
67 palettes: {
68 dark: { accent: '#ff2e88', secondary: '#f5ff3b', muted: '#a8326a', promptText: '#ffffff', added: '#c6ff00', removed: '#ff2e88', failed: '#ff3b30' },
69 light: { accent: '#d6006b', secondary: '#7a5c00', muted: '#8a2252', promptText: '#111111', added: '#4d7c0f', removed: '#d6006b', failed: '#c1121f' },
70 },
71 },
72 synthwave: {
73 name: 'synthwave',
74 label: 'Synthwave',
75 hotkey: 's',
76 promptMark: '\u25b6',
77 toolMark: '\u25c6',
78 baseVariant: '',
79 spinnerWords: ['CRUISING', 'DRIFTING', 'GLOWING', 'RIDING THE GRID', 'CHASING THE SUNSET', 'NEON DREAMING'],
80 spinnerSuffix: ' ~',
81 doneWords: ['Cruised', 'Drifted', 'Glowed', 'Arrived'],
82 palettes: {
83 dark: { accent: '#ff4fd8', secondary: '#2de2e6', muted: '#7b5cff', promptText: '#9ef6ff', added: '#2de2e6', removed: '#ff8f40', failed: '#ff5c8a' },
84 light: { accent: '#c2189f', secondary: '#0b7f86', muted: '#5b3fd6', promptText: '#0b6b72', added: '#0b7f86', removed: '#c25a00', failed: '#c2185b' },
85 },
86 },
87 zen: {
88 name: 'zen',
89 label: 'Zen paper',
90 hotkey: 'z',
91 promptMark: '\u25cb',
92 toolMark: '\u00b7',
93 baseVariant: '-daltonized',
94 spinnerWords: ['breathing', 'listening', 'raking the sand', 'sitting', 'brewing tea', 'watching the pond'],
95 spinnerSuffix: '\u2026',
96 doneWords: ['Breathed', 'Sat', 'Listened', 'Settled'],
97 palettes: {
98 dark: { accent: '#d8cfc0', secondary: '#a3b18a', muted: '#8a8378', promptText: '#e8e1d5', added: '#a3b18a', removed: '#c98b7a', failed: '#c98b7a' },
99 light: { accent: '#5c554b', secondary: '#55704a', muted: '#9a9286', promptText: '#3d3a35', added: '#55704a', removed: '#9c4a3a', failed: '#9c4a3a' },
100 },
101 },
102}
103
104export function themeChoiceFrom(value: unknown): ThemeChoice | undefined {
105 return THEME_CHOICES.find(choice => choice === value)
106}
107
108export function themeOf(choice: ThemeChoice): Theme | undefined {
109 return choice === 'off' ? undefined : THEMES[choice]
110}
111
112export function paletteOf(options: { choice: ThemeChoice; tone: Tone }): Palette {
113 return themeOf(options.choice)?.palettes[options.tone] ?? NEUTRAL
114}
115
116export function toneOf(baseTheme: string | undefined): Tone {
117 return baseTheme?.startsWith('light') ? 'light' : 'dark'
118}
119
120export function baseThemeFor(options: { choice: ThemeChoice; saved: string }): string | undefined {
121 const theme = themeOf(options.choice)
122 if (!theme) {
123 return options.saved
124 }
125 if (!options.saved.startsWith('dark') && !options.saved.startsWith('light')) {
126 return undefined
127 }
128 return `${toneOf(options.saved)}${theme.baseVariant}`
129}
130
131function hashOf(text: string): number {
132 return Array.from(text).reduce((total, character) => (total * 31 + character.charCodeAt(0)) % 100003, 7)
133}
134
135export function pickWord(options: { words: readonly string[]; seed: string }): string {
136 return options.words[hashOf(options.seed) % options.words.length] ?? options.seed
137}
138hooks/looks/look.ts 62 lines1import type { ThemeName, Tone } from './themes'
2
3export type Look = {
4 name: ThemeName
5 border: string
6 borderColor: string
7 panel: string
8 chipBackground: string
9 chipText: string
10 bar: string
11 userLabel: string
12 replyLabel: string
13 divider: { pattern: string; from: string; to: string }
14}
15
16type Variant = Omit<Look, 'name' | 'border' | 'bar' | 'userLabel' | 'replyLabel' | 'divider'> & { divider: { from: string; to: string } }
17type Shape = Pick<Look, 'border' | 'bar' | 'userLabel' | 'replyLabel'> & { pattern: string }
18
19const SHAPES: Record<ThemeName, Shape> = {
20 retro: { border: 'double', bar: '║', userLabel: 'USER', replyLabel: 'CLAUDE', pattern: '═' },
21 punk: { border: 'bold', bar: '┃', userLabel: 'YOU', replyLabel: 'CLAUDE', pattern: '/\\' },
22 synthwave: { border: 'round', bar: '▌', userLabel: 'YOU', replyLabel: 'CLAUDE', pattern: '▀' },
23 zen: { border: 'single', bar: '│', userLabel: 'you', replyLabel: 'claude', pattern: '· ' },
24}
25
26const VARIANTS: Record<ThemeName, Record<Tone, Variant>> = {
27 retro: {
28 dark: { borderColor: '#39ff7a', panel: '#04120a', chipBackground: '#39ff7a', chipText: '#04120a', divider: { from: '#39ff7a', to: '#1c6b38' } },
29 light: { borderColor: '#0f8a3c', panel: '#e3f4e8', chipBackground: '#0f8a3c', chipText: '#ffffff', divider: { from: '#0f8a3c', to: '#8fc9a2' } },
30 },
31 punk: {
32 dark: { borderColor: '#ff2e88', panel: '#16040f', chipBackground: '#f5ff3b', chipText: '#16040f', divider: { from: '#ff2e88', to: '#f5ff3b' } },
33 light: { borderColor: '#d6006b', panel: '#fde4f0', chipBackground: '#d6006b', chipText: '#ffffff', divider: { from: '#d6006b', to: '#b89500' } },
34 },
35 synthwave: {
36 dark: { borderColor: '#ff4fd8', panel: '#150a2e', chipBackground: '#ff4fd8', chipText: '#1a0733', divider: { from: '#ff4fd8', to: '#2de2e6' } },
37 light: { borderColor: '#c2189f', panel: '#f1e6ff', chipBackground: '#c2189f', chipText: '#ffffff', divider: { from: '#c2189f', to: '#0b7f86' } },
38 },
39 zen: {
40 dark: { borderColor: '#8a8378', panel: '#1b1a16', chipBackground: '#a3b18a', chipText: '#14130f', divider: { from: '#a3b18a', to: '#4a463f' } },
41 light: { borderColor: '#9a9286', panel: '#f4eddc', chipBackground: '#55704a', chipText: '#f4eddc', divider: { from: '#55704a', to: '#c9c0ac' } },
42 },
43}
44
45export function lookOf(options: { name: ThemeName; tone: Tone }): Look {
46 const shape = SHAPES[options.name]
47 const variant = VARIANTS[options.name][options.tone]
48 return { ...variant, ...shape, name: options.name, divider: { pattern: shape.pattern, ...variant.divider } }
49}
50
51function channels(hex: string): [number, number, number] {
52 const value = Number.parseInt(hex.slice(1), 16)
53 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
54}
55
56export function mixHex(options: { from: string; to: string; amount: number }): string {
57 const [fromRed, fromGreen, fromBlue] = channels(options.from)
58 const [toRed, toGreen, toBlue] = channels(options.to)
59 const blend = (start: number, end: number): string => Math.round(start + (end - start) * options.amount).toString(16).padStart(2, '0')
60 return `#${blend(fromRed, toRed)}${blend(fromGreen, toGreen)}${blend(fromBlue, toBlue)}`
61}
62hooks/looks/frame.tsx 142 lines1import type { BoxProps, ElementConstructor, RenderElement, RenderNode, TextProps } from 'claude-code'
2
3import { moreHint } from './result'
4import type { LineTone, ResultLine } from './result'
5import type { Look } from './look'
6import type { Palette, Theme } from './themes'
7import type { ToolState, ToolSummary } from './summary'
8
9const DEFAULT_TOOL_MARK = '\u23fa'
10const DEFAULT_PROMPT_MARK = '\u276f'
11
12export type ColumnLayout = { Box: ElementConstructor<BoxProps>; width: number; isCentered: boolean; columns: number | undefined }
13
14export function readingColumn(options: ColumnLayout & { children: RenderNode; isEngineNode?: boolean; gapAbove?: number }): RenderElement {
15 const { Box } = options
16 const pad = options.isCentered && options.columns !== undefined ? Math.max(0, Math.floor((options.columns - options.width) / 2)) : 0
17 if (options.isEngineNode) {
18 return (
19 <Box flexDirection="column" paddingLeft={pad}>
20 {options.children}
21 </Box>
22 )
23 }
24 return (
25 <Box flexDirection="row" paddingLeft={pad} marginTop={options.gapAbove ?? 0}>
26 {options.isCentered ? (
27 <Box flexDirection="column" width={options.width} flexShrink={1}>
28 {options.children}
29 </Box>
30 ) : (
31 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
32 {options.children}
33 </Box>
34 )}
35 </Box>
36 )
37}
38
39function markColor(options: { state: ToolState; colors: Palette }): string {
40 if (options.state.isErrored || options.state.isInterrupted) {
41 return options.colors.failed
42 }
43 return options.state.isRunning ? options.colors.muted : options.colors.accent
44}
45
46export function toolLine(options: {
47 Text: ElementConstructor<TextProps>
48 summary: ToolSummary
49 state: ToolState
50 theme: Theme | undefined
51 colors: Palette
52 look?: Look
53}): RenderElement {
54 const { Text, summary, colors } = options
55 const isFailed = options.state.isErrored || options.state.isInterrupted
56 return (
57 <Text wrap="truncate-end">
58 <Text color={markColor({ state: options.state, colors })}>{`${options.theme?.toolMark ?? DEFAULT_TOOL_MARK} `}</Text>
59 {options.look ? (
60 <Text bold color={options.look.chipText} backgroundColor={options.look.chipBackground}>{` ${summary.verb} `}</Text>
61 ) : (
62 <Text bold color={options.theme ? colors.secondary : undefined}>{summary.verb}</Text>
63 )}
64 {summary.target !== '' && <Text>{` ${summary.target}`}</Text>}
65 {summary.added > 0 && <Text color={colors.added}>{` +${summary.added}`}</Text>}
66 {summary.removed > 0 && <Text color={colors.removed}>{` -${summary.removed}`}</Text>}
67 {summary.hint !== '' && <Text color={isFailed ? colors.failed : undefined} dimColor={!isFailed}>{` ${summary.hint}`}</Text>}
68 </Text>
69 )
70}
71
72export function promptLine(options: { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; theme: Theme | undefined; colors: Palette; text: string }): RenderElement {
73 const { Box, Text, theme } = options
74 return (
75 <Box flexDirection="row">
76 <Box flexShrink={0}>
77 <Text bold color={theme ? options.colors.accent : undefined} dimColor={!theme}>{`${theme?.promptMark ?? DEFAULT_PROMPT_MARK} `}</Text>
78 </Box>
79 <Box flexShrink={1}>
80 <Text color={theme ? options.colors.promptText : undefined}>{options.text}</Text>
81 </Box>
82 </Box>
83 )
84}
85
86export function replyFrame(options: {
87 Box: ElementConstructor<BoxProps>
88 Text: ElementConstructor<TextProps>
89 theme: Theme | undefined
90 colors: Palette
91 isFirst: boolean
92 body: RenderNode
93}): RenderElement {
94 const { Box, Text, theme, colors } = options
95 return (
96 <Box flexDirection="row">
97 <Box width={2} flexShrink={0}>
98 <Text color={theme ? colors.accent : undefined}>{options.isFirst ? DEFAULT_TOOL_MARK : ' '}</Text>
99 </Box>
100 {theme && <Box width={1} flexShrink={0} marginRight={1} backgroundColor={colors.muted} />}
101 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
102 {options.body}
103 </Box>
104 </Box>
105 )
106}
107
108function toneColor(options: { tone: LineTone; colors: Palette }): string | undefined {
109 const { tone, colors } = options
110 return tone === 'added' ? colors.added : tone === 'removed' ? colors.removed : tone === 'failed' ? colors.failed : undefined
111}
112
113export function resultBlock(options: {
114 Box: ElementConstructor<BoxProps>
115 Text: ElementConstructor<TextProps>
116 colors: Palette
117 lines: readonly ResultLine[]
118 more: number
119 look?: Look
120}): RenderElement {
121 const { Box, Text, colors } = options
122 const bar = <Text color={options.look?.borderColor ?? colors.muted}>{`${options.look?.bar ?? '\u2502'} `}</Text>
123 return (
124 <Box flexDirection="column" paddingLeft={2}>
125 {options.lines.map((line, index) => (
126 <Text key={`line-${index}`} wrap="truncate-end">
127 {bar}
128 <Text color={toneColor({ tone: line.tone, colors })} dimColor={line.tone === 'plain'}>
129 {line.text}
130 </Text>
131 </Text>
132 ))}
133 {options.more > 0 && (
134 <Text wrap="truncate-end">
135 {bar}
136 <Text dimColor>{moreHint(options.more)}</Text>
137 </Text>
138 )}
139 </Box>
140 )
141}
142hooks/looks/group.ts 26 lines1import { plural } from './fields'
2
3const GROUP_PHRASES: Readonly<Record<string, readonly [string, string]>> = {
4 Read: ['read', 'file'],
5 Grep: ['searched', 'pattern'],
6 Glob: ['matched', 'glob'],
7 Bash: ['ran', 'command'],
8 WebFetch: ['fetched', 'page'],
9 WebSearch: ['searched the web', 'time'],
10}
11
12export function summarizeGroup(calls: readonly { tool: string; isErrored: boolean; isRunning: boolean }[]): { text: string; failed: number; running: number } {
13 const counts = new Map<string, number>()
14 calls.forEach(call => counts.set(call.tool, (counts.get(call.tool) ?? 0) + 1))
15 const parts = [...counts.entries()].map(([tool, count]) => {
16 const [verb, noun] = GROUP_PHRASES[tool] ?? [`used ${tool}`, 'time']
17 return `${verb} ${plural({ count, word: noun })}`
18 })
19 const text = parts.join(', ')
20 return {
21 text: text.charAt(0).toUpperCase() + text.slice(1),
22 failed: calls.filter(call => call.isErrored).length,
23 running: calls.filter(call => call.isRunning).length,
24 }
25}
26