SLOPSHOPPER

tint

Restyles your message and Claude's reply, in two looks that follow macOS dark and light.

newspinnerrowscommandprocesstimer
v0.1.0MITupdated 2026-10-15krishna-goutham-tls/cc-mods/tint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tint
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tint ⎿ tint: pick Tint (auto) in /theme first ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tint

A Claude Code mod that restyles the terminal session. Your message and Claude's reply show as blocks, the small rows around them start on the same left edge, and a status line under the prompt shows the model, the folder and how much context is used.

Warm darkWarm light
Warm darkWarm light
Grok darkGrok light
Grok darkGrok light

The pictures are from a test terminal that draws in Menlo, on a made-up project called demo-app.

What you get

  • Two looks. Warm has a brown block for your message, a grey block for the reply and an orange accent. Grok has a grey block for your message, no block for the reply and a blue accent.
  • A dark and a light set of colors for each look. On macOS the mod follows the system setting.
  • /tint, which switches the look in every open session.
  • A status line: model, folder, git branch, context used, effort and 5-hour usage.

The status line

Install

In a Claude Code terminal session:

/plugin install tint --marketplace krishna-goutham-tls/cc-mods

Answer y to add the marketplace, then pick a scope. The user scope loads tint in every session.

Then start a new session. At the start of a session the mod puts two files in Claude Code's config folder, ~/.claude:

  • themes/tint.json, the theme you pick in the next step but one.
  • tint/statusline.sh, a copy of the status line script.

The session looks the same as before until you pick the theme.

Turn the status line on

Add one line to ~/.claude/settings.json:

"statusLine": { "type": "command", "command": "bash ~/.claude/tint/statusline.sh" }

The mod itself lives in a folder that has its version in the path, so that path changes with every update. ~/.claude/tint/statusline.sh stays the same. The mod copies the script there at the start of a session, and copies it again when an update changes it.

The status line works without the theme, and the theme works without the status line.

Pick the theme

Type /theme and choose "Tint (auto)". Claude Code stores the choice as custom:tint.

Claude Code does not let a mod set a custom theme, so you pick it once by hand. From then on the mod rewrites the theme's file, ~/.claude/themes/tint.json, when the look or the mode changes. Claude Code reads a theme file again when it changes, in every open session.

"Tint (auto)" is the only tint theme in /theme. You pick the look with /tint, and the mode comes from macOS.

The colors assume that your terminal is dark when macOS is dark, and light when macOS is light. Claude Code draws the text of a reply in the terminal's own text color, and the mod cannot change that. If the terminal and macOS disagree, the reply in the warm look is hard to read.

With any other theme the session looks standard.

/tint

You typeWhat happens
/tint, /tint nextSwitches to the other look
/tint warm, /tint grokSwitches to that look

The answer is one line with the look and the mode, for example grok dark. The blocks and the colors change in every open session within 5 seconds, and the status line follows.

/tint works while the theme is "Tint (auto)". With another theme it answers pick Tint (auto) in /theme first.

Running /tint does not call the model. Claude Code keeps the command and its one line of output in the conversation, so Claude reads them with your next prompt. That is a few dozen tokens for each run.

Dark and light

On macOS the mod asks the system for the mode at the start of a session and then every 5 seconds, with defaults read -g AppleInterfaceStyle. When the mode or the look has changed, it writes tint.json again. The mod API has no event for a change of mode, so the mod polls. It writes nothing while the file is already right.

On Linux there is no defaults command. The mod keeps the mode that tint.json already holds, and a new tint.json is dark. /tint still switches the look. There is no way to get the light colors there yet.

On Windows the mod needs HOME and the mv command, which Git Bash and WSL have. Without them it writes nothing. The status line script needs bash.

tint has not been run on Linux or Windows. The two paragraphs above come from the code and its tests.

Requirements

  • Claude Code 2.1.296. tint is built and tested on that version.
  • A terminal with 24-bit color. The theme colors are hex values.
  • For the folder and branch icons in the status line, a Nerd Font, or a terminal that has those symbols built in, such as Ghostty. Without one the two icons do not draw and the rest of the line is fine.
  • jq is optional. The status line script uses jq when it finds it and python3 when it does not.

What tint cannot change

  • The logo and header at the top of a session, and the prompt box. A mod has no way to draw them. The theme sets the color of the prompt box's border and nothing else there.
  • The colors inside a reply. Claude Code's markdown renderer draws headings bold in the terminal's text color, inline code in a fixed blue, and code fences in the terminal's ANSI colors. The theme files do not reach them.
  • The row that repeats a slash command you typed. Claude Code draws it with a highlight as wide as the text. The mod restyles only the output under it.
  • The left edge of a tool row. Its text already starts on the text edge, and the mark in front of it stands in the two columns to the left.

A turn in progress

The ctrl+o view keeps the standard drawing for your message and the tool rows.

The ctrl+o view

Early access

The mod API of Claude Code is early access. A later release can change it and break tint. If a session looks wrong after an update, pick another theme in /theme and the mod stops drawing.

What it can reach

A mod runs inside Claude Code, so it is fair to ask what this one does.

  • It writes three files, all in Claude Code's config folder: themes/tint.json, tint/active, which holds the look, and tint/statusline.sh. It writes a draft of tint.json next to tint/active and moves it into place with mv.
  • It reads those files, the theme setting, and its own looks/ and statusline/ folders.
  • It runs two programs: defaults, to ask macOS for the mode, and mv.
  • It makes no network call, reads nothing in your project and changes no tool call.
  • It never writes settings.json.

claude plugin validate tint, run from the root of the repo, prints the calls from the source.

The config folder is ~/.claude, or the folder in CLAUDE_CONFIG_DIR when Claude Code was started with one. The status line script reads the same folder.

How it works

  • looks/ has the four sets of colors. tint.json always holds one of them with the name set to "Tint (auto)". To change a color, change it in these four files.
  • hooks/register.tsx has the hooks: the blocks, /tint, and the timer that keeps tint.json in step.
  • hooks/palette.ts has the two colors that no theme key holds: the text color in your block and the fill of the reply.
  • statusline/statusline.sh reads the session JSON on stdin and prints one line. The field list is at https://code.claude.com/docs/en/statusline.

Every open session runs the same timer. Each one computes the same text for tint.json from the look file, the mode and the four source files, so two sessions write the same bytes. If two sessions disagree about the mode, each writes once and then leaves the file alone for 60 seconds.

The mod draws three rows itself: your prompt, Claude's reply and the output of a slash command. Claude Code's own markdown renderer draws the reply inside the block, so tables, code fences and links work as before. The spinner and the line that ends a turn keep the standard drawing and move two columns right. A task notification, a message from another agent and an error line keep the standard drawing.

Work on it

tint is one of two mods in the cc-mods repo. Its folder is tint/.

git clone https://github.com/krishna-goutham-tls/cc-mods
cp -R cc-mods/tint tint-dev && cd tint-dev
cp -R looks themes
claude --plugin-dir . --settings '{"theme":"custom:tint:tint-warm-dark"}'

The copy of looks/ as themes/ gives that session four fixed themes, one for each look and mode. The --settings part forces one of them, so the mod writes no theme file while you try a change. AGENTS.md says why that matters.

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .

Test the status line by hand:

echo '{"model":{"display_name":"Opus"},"workspace":{"current_dir":"'"$PWD"'"},"context_window":{"used_percentage":25}}' | statusline/statusline.sh

Open work is in TRACKER.md.

License

MIT

Source 3 files
hooks/register.tsx 290 lines
1// tint: your message and Claude's reply as blocks, in two looks, warm and grok,
2// and the small rows around them on the same left edge. The mod draws while the
3// theme is "Tint (auto)" (`custom:tint`) or one of the fixed tint themes. For "Tint (auto)" it
4// keeps the theme file in step with the look and with macOS dark and light.
5import { atom, read } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { TintActive, TintLook, TintMode } from '../types'
9import { LIVE, PALETTE, fixedOf, liveText, lookOf, modeOf } from './palette'
10
11/** How often the mod reads the theme, the look file and the macOS mode. */
12const POLL_MS = 5000
13
14/** After a write, how long the mod leaves a theme file another writer changed, while its own look and mode stay the same. */
15const QUIET_MS = 60000
16
17/** The column where the text of every row starts. */
18const EDGE = 2
19
20/** The look and the mode of this session's last write of the theme file, and when. */
21type Wrote = { last?: { look: TintLook; mode: TintMode; at: number } }
22
23const active = atom({ plugin: 'tint', key: 'active' } as const, null)
24
25const themeNow = async ($: EngineInterface): Promise<unknown> => (await $.config.list()).find(row => row.key === 'theme')?.value
26
27/** The macOS mode, or undefined when the answer is not clear. */
28const modeNow = async ($: EngineInterface): Promise<TintMode | undefined> => {
29  const run = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle']).catch(() => undefined)
30
31  if (run === undefined) {
32    return undefined
33  }
34
35  if (run.exitCode === 0) {
36    return run.stdout.trim() === 'Dark' ? 'dark' : undefined
37  }
38
39  // In light mode the key is absent and `defaults` says so.
40  return run.stderr.includes('does not exist') ? 'light' : undefined
41}
42
43const remember = async ($: EngineInterface, next: TintActive): Promise<void> => {
44  const was = await read($, active)
45
46  if (was?.look !== next?.look || was?.mode !== next?.mode) {
47    await $.state.set({ plugin: 'tint', key: 'active' } as const, next)
48  }
49}
50
51const textOf = ($: EngineInterface, path: string): Promise<string | undefined> =>
52  $.fs
53    .read(path)
54    .then(text => (typeof text === 'string' ? text : undefined))
55    .catch(() => undefined)
56
57/** Claude Code's config folder: `CLAUDE_CONFIG_DIR` when it is set, `~/.claude` otherwise. */
58const configDir = async ($: EngineInterface): Promise<string | undefined> => {
59  const home = await $.env.get('HOME')
60
61  return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
62}
63
64/**
65 * Brings `tint.json` to the look and the macOS mode, and answers what it holds.
66 * `look` is a look the person just asked for. Without it the look file decides.
67 */
68const settle = async ($: EngineInterface, wrote: Wrote, look?: TintLook): Promise<TintActive> => {
69  // Claude Code reads themes from its config folder, so the mod writes there too.
70  const dir = await configDir($)
71
72  if (dir === undefined) {
73    return null
74  }
75
76  const lookFile = `${dir}/tint/active`
77  const themeFile = `${dir}/themes/tint.json`
78  const asked = await modeNow($)
79
80  // The look file is read after the slow call above, so a look another session just wrote is seen.
81  const want = look ?? lookOf(await textOf($, lookFile))
82  const held = await textOf($, themeFile)
83  const mode = asked ?? modeOf(held) ?? 'dark'
84  const text = liveText(await $.fs.read(`${$.plugin.root}/looks/tint-${want}-${mode}.json`))
85
86  if (look !== undefined) {
87    await $.fs.write(lookFile, `${look}\n`)
88  }
89
90  if (text !== held) {
91    const now = await $.clock.now()
92    const { last } = wrote
93
94    // Another writer put the file back, and nothing changed here since this session wrote it.
95    // A second write would start a loop between the two, so the file stays as it is for a while.
96    if (look === undefined && last?.look === want && last.mode === mode && now - last.at < QUIET_MS) {
97      return { look: want, mode }
98    }
99
100    // Claude Code reads the theme file when it changes. A rename puts the new file in place whole.
101    const draft = `${dir}/tint/tint.json.${Math.random().toString(36).slice(2)}`
102
103    await $.fs.write(draft, text)
104
105    if ((await $.process.run(['mv', draft, themeFile])).exitCode !== 0) {
106      throw new Error('tint: the theme file was not written')
107    }
108
109    wrote.last = { look: want, mode, at: now }
110  }
111
112  return { look: want, mode }
113}
114
115/**
116 * Puts the two files a new install lacks in the config folder. A `tint.json`, so that /theme lists
117 * "Tint (auto)". And a copy of the status line script, because the folder of an installed mod has its version
118 * in the path, and `settings.json` needs a path that stays the same.
119 */
120const prepare = async ($: EngineInterface, wrote: Wrote): Promise<void> => {
121  const dir = await configDir($)
122
123  if (dir === undefined) {
124    return
125  }
126
127  if ((await textOf($, `${dir}/themes/tint.json`)) === undefined) {
128    await settle($, wrote)
129  }
130
131  const shipped = await textOf($, `${$.plugin.root}/statusline/statusline.sh`)
132
133  if (shipped !== undefined && shipped !== (await textOf($, `${dir}/tint/statusline.sh`))) {
134    await $.fs.write(`${dir}/tint/statusline.sh`, shipped)
135  }
136}
137
138/**
139 * A fixed name counts only while its file is in the mod's `themes/` folder. A name an older build
140 * left in the settings has no theme behind it, so the mod draws nothing for it.
141 */
142const fixedNow = async ($: EngineInterface, theme: unknown): Promise<TintActive> => {
143  const fixed = fixedOf(theme)
144
145  if (fixed === null) {
146    return null
147  }
148
149  return (await textOf($, `${$.plugin.root}/themes/tint-${fixed.look}-${fixed.mode}.json`)) === undefined ? null : fixed
150}
151
152/** Reads the theme and brings the mod, and for "Tint (auto)" the theme file, in step with it. */
153const follow = async ($: EngineInterface, wrote: Wrote): Promise<void> => {
154  const theme = await themeNow($)
155
156  await remember($, theme === LIVE ? await settle($, wrote) : await fixedNow($, theme))
157}
158
159export const register: Register = on => {
160  let isFollowing = false
161  const wrote: Wrote = {}
162
163  on('session.start', async ($, e, next) => {
164    // A headless run draws no transcript, so it gets no command and no timer.
165    if (e.surface !== 'terminal') {
166      return next(e)
167    }
168
169    await $.command.register({ name: 'tint', description: 'Switch the look', argumentHint: '[warm|grok|next]' })
170    await prepare($, wrote).catch(() => undefined)
171    await follow($, wrote).catch(() => undefined)
172
173    $.clock.every(POLL_MS, () => {
174      if (isFollowing) {
175        return
176      }
177
178      isFollowing = true
179      void follow($, wrote)
180        .catch(() => undefined)
181        .finally(() => {
182          isFollowing = false
183        })
184    })
185
186    return next(e)
187  })
188
189  // A theme picked in /config shows at once, with no wait for the timer.
190  on('config.set', { key: 'theme' }, async ($, e, next) => {
191    const result = await next(e)
192
193    await follow($, wrote).catch(() => undefined)
194
195    return result
196  }).catch(($, e, next) => next(e))
197
198  on('command.run', { command: 'tint' }, async ($, e) => {
199    if ((await themeNow($)) !== LIVE) {
200      return { text: 'pick Tint (auto) in /theme first' }
201    }
202
203    const asked = e.args.trim().toLowerCase()
204    const now = await read($, active)
205    const look = asked === '' || asked === 'next' ? (now?.look === 'warm' ? 'grok' : 'warm') : asked
206
207    if (look !== 'warm' && look !== 'grok') {
208      return { text: 'warm, grok or next' }
209    }
210
211    const next = await settle($, wrote, look)
212
213    await remember($, next)
214
215    return { text: next === null ? 'off' : `${next.look} ${next.mode}` }
216  })
217
218  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
219    const tint = await read($, active)
220
221    if (tint === null || e.surface !== 'terminal' || e.props.isExpanded || e.props.origin.kind !== 'composer') {
222      return next(e)
223    }
224
225    const { Box, Text } = $.ui.resolve(e)
226
227    return (
228      <Box marginTop={1} paddingX={2} paddingY={1} width="100%" backgroundColor="userMessageBackground">
229        <Text color={PALETTE[tint.look][tint.mode].userFg}>{e.props.text}</Text>
230      </Box>
231    )
232  })
233
234  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
235    const tint = await read($, active)
236
237    if (tint === null || e.surface !== 'terminal' || e.props.text === '') {
238      return next(e)
239    }
240
241    const { Box, Markdown, Text } = $.ui.resolve(e)
242    const fill = PALETTE[tint.look][tint.mode].replyBg
243    const padY = fill === undefined ? 0 : 1
244
245    // A tree of the mod's own loses the engine's summary mark, so it is drawn here.
246    return (
247      <Box flexDirection="column" marginTop={e.props.isFirstOfReply ? 1 : 0} paddingX={2} paddingY={padY} width="100%" backgroundColor={fill}>
248        <Markdown text={e.props.text} />
249        {e.props.isSummary ? <Text dimColor>· summary</Text> : null}
250      </Box>
251    )
252  })
253
254  // What a slash command printed: dim, on the text edge. An error line keeps the engine's color.
255  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
256    if ((await read($, active)) === null || e.surface !== 'terminal' || e.props.isErrored || e.props.text === '') {
257      return next(e)
258    }
259
260    const { Box, Markdown } = $.ui.resolve(e)
261
262    return (
263      <Box paddingX={EDGE} width="100%">
264        <Markdown text={e.props.text} dimColor />
265      </Box>
266    )
267  })
268
269  // The two rows below keep the engine's own drawing, moved right so they start on the text edge.
270  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
271    if ((await read($, active)) === null || e.surface !== 'terminal') {
272      return next(e)
273    }
274
275    const { Box } = $.ui.resolve(e)
276
277    return <Box paddingLeft={EDGE}>{await next(e)}</Box>
278  })
279
280  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
281    if ((await read($, active)) === null || e.surface !== 'terminal') {
282      return next(e)
283    }
284
285    const { Box } = $.ui.resolve(e)
286
287    return <Box paddingLeft={EDGE}>{await next(e)}</Box>
288  })
289}
290
hooks/palette.ts 47 lines
1import type { TintActive, TintLook, TintMode } from '../types'
2
3// The colors no theme key holds. Every other color comes from the theme files.
4// `replyBg` is absent where the reply has no fill.
5export const PALETTE: Record<TintLook, Record<TintMode, { userFg: string; replyBg?: string }>> = {
6  warm: {
7    dark: { userFg: '#f2e8e1', replyBg: '#1f1f1f' },
8    light: { userFg: '#2e1d14', replyBg: '#ffffff' },
9  },
10  grok: {
11    dark: { userFg: '#ffffff' },
12    light: { userFg: '#111111' },
13  },
14}
15
16/** The one theme the mod rewrites. Its file is `themes/tint.json` in Claude Code's config folder. */
17export const LIVE = 'custom:tint'
18
19// The names Claude Code gives the four files in `looks/` when a copy of them is in a `themes/` folder
20// of the mod. The mod ships no such folder, so /theme lists only "Tint (auto)". A test session makes
21// the copy and forces one of these names, because the mod draws for them and never writes for them.
22const FIXED: Record<string, NonNullable<TintActive>> = {
23  'custom:tint:tint-warm-dark': { look: 'warm', mode: 'dark' },
24  'custom:tint:tint-warm-light': { look: 'warm', mode: 'light' },
25  'custom:tint:tint-grok-dark': { look: 'grok', mode: 'dark' },
26  'custom:tint:tint-grok-light': { look: 'grok', mode: 'light' },
27}
28
29export const fixedOf = (theme: unknown): TintActive => (typeof theme === 'string' ? (FIXED[theme] ?? null) : null)
30
31/** Reads the look file, `tint/active`. The status line reads it the same way: anything but grok is warm. */
32export const lookOf = (text: string | undefined): TintLook => (text?.trim() === 'grok' ? 'grok' : 'warm')
33
34/** The text of `tint.json` for one of the four source files in `themes/`. */
35export const liveText = (source: string): string => `${JSON.stringify({ ...(JSON.parse(source) as object), name: 'Tint (auto)' }, null, 2)}\n`
36
37/** Which mode a theme file holds, by its `base`. */
38export const modeOf = (text: string | undefined): TintMode | undefined => {
39  try {
40    const base = (JSON.parse(text ?? '') as { base?: unknown }).base
41
42    return base === 'dark' || base === 'light' ? base : undefined
43  } catch {
44    return undefined
45  }
46}
47
types/index.d.ts 12 lines
1export type TintLook = 'warm' | 'grok'
2export type TintMode = 'dark' | 'light'
3
4/** The tint theme that is active, or null while the person uses another theme. */
5export type TintActive = { look: TintLook; mode: TintMode } | null
6
7declare module 'claude-code' {
8  interface PluginState {
9    tint: { active: TintActive }
10  }
11}
12