SLOPSHOPPER

looks

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…

newpanespinnerrowscommandtoast
★ 1v0.1.0MITupdated 2026-10-05theonly1me/claude-code-mods/plugins/looks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · looks
│ ┃ Theme ✕ › fix the failing auth test and add an audit log call │ ┃ Press a letter to apply a theme. Tab moves │ ┃ between themes and shows a preview. Esc ⏺ Read(src/auth.ts) │ ┃ closes the picker. ⎿ Read 6 lines │ ┃ ⏺ Edit src/auth.ts │ ┃ r: Retro CRT p: Punk s: Synthwave z: Z ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Preview: Retro CRT │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ C:\> make the cart total include tax │ │ ┃ │ > Edit src/cart.ts +4 -1 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ > Run npm test 12 lines │ │ ┃ │ ⏺ Totals now include tax, and the cart │ › /looks │ ┃ │ tests pass. │ ⎿ looks: Theme picker open: press a letter to apply a theme, Esc t │ ┃ │ Thinking… (12s) │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ Base theme: yours. A theme switches it to a │ ┃ matching one, and Off restores it. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Theme
Press a letter to apply a theme. Tab moves between themes and shows a preview. Esc closes the picker. r: Retro CRT p: Punk s: Synthwave z: Zen paper o: Of ╭──────────────────────────────────────────────────────────╮ │ Preview: Retro CRT │ │ C:\> make the cart total include tax │ │ > Edit src/cart.ts +4 -1 │ │ > Run npm test 12 lines │ │ ⏺ Totals now include tax, and the cart tests pass. │ │ Thinking… (12s) │ ╰──────────────────────────────────────────────────────────╯ Base theme: yours. A theme switches it to a matching one, and Off restores it.
Tool row
⏺ Edit src/auth.ts
README

Looks

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.

Looks preview

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.

How it works

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.

Synthwave with name tags, a framed reply, and a themed divider

Tool calls with the default tidy view

  • Tidy tool calls (setting 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.
  • Compact tool calls (setting 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.
  • Plain tool calls (setting toolCalls: plain) leave every tool row to Claude Code.
  • Centered layout (setting layout: centered) puts replies, prompts, and tool lines in one column at the reading width (100 characters by default), centered in the transcript.
  • Themes give each look its own character. With the default themeStyle: rich, a theme draws:
  • a name tag on your prompt and on each reply, and a colored tag on each tool name;
  • the reply in a framed, tinted panel (double green lines for Retro CRT, bold pink for Punk, rounded neon for Synthwave, thin ink for Zen paper);
  • a themed divider between turns that keeps Claude Code's own turn word and the time;
  • result bars in the theme's own glyph and color.

themeStyle: simple only recolors text. Themes keep Claude Code's own spinner and turn footer words, unless you turn on themeWords.

  • Base theme: a theme also sets the Claude Code base theme that suits it, in the same tone (dark or light) as yours. Retro CRT uses the ANSI variant, Zen paper the colorblind-friendly one, and Punk and Synthwave the plain one. If your base theme is auto, Looks leaves it alone. /theme off and /exit put your own base theme back.

How to use

  • /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.

How to interact

In the picker:

  1. Press r, p, s, or z to apply Retro CRT, Punk, Synthwave, or Zen paper, or o for Off.
  2. Press Tab to move between themes. The preview box shows the theme under the focus before you apply it.
  3. Press b to open the Claude Code base themes.
  4. Press Esc to close the picker.

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.

Themes

Each image is a real session with the theme on.

Retro CRT: phosphor green on black, double-line panels, an amber accent, and ═ dividers.

Retro CRT

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

Punk

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

Synthwave

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.

Zen paper

Settings

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.

Limits

  • The text inside Claude's replies keeps Claude Code's own colors. A theme frames a reply but does not recolor it.
  • With 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.
  • The tidy view draws its own result lines for edits and shell commands, so it does not show Claude Code's syntax-highlighted diff. ctrl+o does.
  • If you quit with ctrl+c twice, the base theme stays on the theme's match until your next session with Looks, or until you run /theme off. /exit restores it.
  • Looks draws only in the terminal. The desktop app and the IDE extensions keep their own look.

See the marketplace README for configuration, privacy, updates, and removal.

Source 17 files
hooks/register.ts 18 lines
1import 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}
18
hooks/chrome.ts 42 lines
1import 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}
42
hooks/looks/state.ts 156 lines
1import { 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}
156
hooks/reply.tsx 53 lines
1import 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}
53
hooks/rows.tsx 80 lines
1import 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}
80
hooks/switcher.tsx 195 lines
1import 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}
195
hooks/watchers.ts 27 lines
1import 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}
27
hooks/looks/rich.tsx 92 lines
1import 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}
92
hooks/looks/themes.ts 138 lines
1export 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}
138
hooks/looks/look.ts 62 lines
1import 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}
62
hooks/looks/frame.tsx 142 lines
1import 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}
142
hooks/looks/group.ts 26 lines
1import { 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