SLOPSHOPPER

extra-mods

An extra-mods menu in the bottom corner: Zen mode with a progress band, Claude Code themes from terminalcolors.com, model, effort and permission mode controls…

newpanebandspinnerrowsguard
★ 1v0.4.1no licenseupdated 2026-10-07Jhonatan-de-Souza/ClaudeMods/plugins/extra-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · extra-mods
│ ┃ extra-mods ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ extra-mods │ ┃ ⏺ Read(src/auth.ts) │ ┃ 1: Zen mode › ⎿ Read 6 lines │ ┃ off ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 2: Theme › ⏺ Bash(bun test) │ ┃ Terminal default ⎿ 3 pass, 1 fail │ ┃ │ ┃ 3: Mode › ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Opus 5.5 · — · effort default │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 4: Status line › │ ┃ on › /tools │ ┃ ⎿ extra-mods: extra-mods is open. │ ┃ ↑↓ move · enter select · esc close │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 · app │ ctx ████░░░░░░ 49% 97k/200k │ 5h ███░░░░░░░ 31% (resets NaNdNaNh) ◆ extra-mods ▼

Draws

Pane · extra-mods
◆ extra-mods 1: Zen mode › off 2: Theme › Terminal default 3: Mode › Opus 5.5 · — · effort default 4: Status line › on ↑↓ move · enter select · esc close
Prompt hint
Opus 5.5 · app │ ctx ████░░░░░░ 49% 97k/200k │ 5h ███░░░░░░░ 31% (resets NaNdNaNh) ◆ extra-mods
README

Claude Mods

English · Português

Mods for Claude Code. One plugin, extra-mods, adds an ◆ extra-mods ▲ button in the bottom-right corner, under the prompt. It opens a sidebar with four tools:

ToolWhat it does
Zen modeHides tool calls and their output, and shows Claude's plan as a progress band above the prompt
ThemeColors Claude Code with any theme from terminalcolors.com, with live preview
ModeSets the model, the effort level, and the permission mode new sessions start in
Status lineModel, folder, branch, context, 5h/7d limits and cache state, with the button on the same row

Install

You need Claude Code 2.1.292 or newer. Run these in a terminal:

claude plugin marketplace add Jhonatan-de-Souza/ClaudeMods
claude plugin install extra-mods@jdsjh-mods --scope user

Then start a new Claude Code session, or run /reload-plugins in an open one. The button shows up at the bottom right of every session.

To update to the latest version later:

claude plugin marketplace update jdsjh-mods
claude plugin update extra-mods@jdsjh-mods

Install with an AI agent

You can point your coding agent at this repository and ask it to install the mod. The steps below are written for the agent.

Agent instructions. Follow these steps in order. Ask the user before step 4, because it changes their settings.

  1. Check Claude Code is installed and recent enough: run claude --version. It must print 2.1.292 or higher. If it doesn't, stop and tell the user to update Claude Code.
  2. Add this repository as a plugin marketplace: run claude plugin marketplace add Jhonatan-de-Souza/ClaudeMods. It should print Successfully added marketplace: jdsjh-mods. If the marketplace already exists, continue.
  3. Install the plugin for all sessions: run claude plugin install extra-mods@jdsjh-mods --scope user. It should print Successfully installed plugin: extra-mods@jdsjh-mods.
  4. Optional, ask first, and only if the user does not want the mod's built-in status line: install the standalone status line script. Copy statusline/statusline.py from this repository to ~/.claude/statusline.py. Then in ~/.claude/settings.json set "statusLine": { "type": "command", "command": "python ~/.claude/statusline.py", "refreshInterval": 2 } (use python3 on macOS and Linux). It needs Python 3.
  5. Verify: run claude plugin list. extra-mods must be listed and enabled.
  6. Tell the user to run /reload-plugins or start a new session, then click ◆ extra-mods ▲ at the bottom right, or type /tools.

On Windows, write paths with forward slashes (C:/Users/...) when a command runs in a POSIX shell such as Git Bash.

Using it

Click ◆ extra-mods ▲, or type /tools, to open the sidebar. In Claude Code's fullscreen layout it docks on the right; otherwise it opens above the prompt. The sidebar takes the keyboard: ↑↓ move, Enter selects, b goes back, Esc closes. Its background follows your terminal's.

1. Zen mode

  • Hides every tool call and its output. You see only what Claude writes.
  • Gives Claude a zen_progress tool and asks it, with every prompt you send, to post its plan first and update it as each step starts and finishes.
  • Shows a progress band above the prompt: the task, the current step, a progress bar, the percentage and the elapsed time. [–] shrinks it to one line.
  • Band colors: blue, dark, light, green, rainbow, or custom with your own hex colors.

2. Theme

Tested on Windows only so far (Windows Terminal). On macOS and Linux the theme colors Claude Code's interface, but the terminal's background isn't changed yet; that part will come once it's tested on a Mac.

  • The first time you open it, it downloads every theme from terminalcolors.com to ~/.claude/extra-mods/themes/.
  • Moving through the list with the arrow keys or the mouse wheel previews each theme on Claude Code. Enter keeps one. Leaving without keeping one puts your kept theme back.
  • Each theme becomes a Claude Code custom theme, ~/.claude/themes/extra-mods.json, and is set as theme in ~/.claude/settings.json. Claude Code reloads it live and remembers it across sessions.
  • It sets every color Claude Code's interface has: text, the accent, borders, success/error/warning, the permission modes, diffs, selection, spinners, subagents and your message background.
  • A Claude Code theme can't change the terminal's background. On Windows Terminal, the mod also puts the theme, background included, on the profile Claude Code runs in, for as long as the session lasts: previews and picks repaint the tab live, and the profile gets its own colors back when the session ends (or, after a crash, when the next one ends). Windows Terminal colors whole profiles, so other open tabs on the same profile take the theme too while Claude Code runs. Windows Terminal's settings.json is backed up first, as settings.json.extra-mods.bak.
  • The first time, restart Claude Code once: it reads the theme setting at startup, and only loads ~/.claude/themes/ when that setting is already a custom theme. After that, picking a theme changes Claude Code live.
  • Back to Claude Code's previous theme selects the theme you had before, and gives the Windows Terminal profile its own colors back.

3. Mode

  • Model: Fable, Opus, Sonnet or Haiku, or ↺ Claude Code's default. The model the session runs on is marked. Picking one runs Claude Code's own /model, so the switch is real: /model and the status line show it too.
  • Effort: low / medium / high / xhigh / max. The level the session is using is marked. A level you pick applies to every request from then on, and is remembered.
  • Running in: the current permission mode. A mod can't switch the mode of a running session, so use shift+tab for that.
  • New sessions start in: ask, plan, accept edits, auto or bypass permissions, each in Claude Code's own color. The current mode is marked · now. Your pick is saved as permissions.defaultMode in ~/.claude/settings.json (backed up first).

4. Status line

  • One row, next to Claude Code's own mode label: the model, the folder and the git branch, then context, the 5h and 7d limits with their reset times, and the cache state, with ◆ extra-mods at the end.
  • ● cache ok (green) while Claude's last response is under an hour old; ● cache over (red) after that.
  • When the terminal narrows, the bars go first, then the reset times, then the model and folder.
  • While Claude has a background shell or monitor running, a second row under it shows them (1 shell, 1 monitor). Click it to open /tasks.
  • It's on by default, so ◆ extra-mods sits on the status row. On takes your own statusLine command out of ~/.claude/settings.json so the two don't stack (backed up first). Off puts it back.

statusline/statusline.py is the same status line as a standalone script, for Claude Code setups without the mod (see step 4 of the agent instructions).

Develop

claude --plugin-dir ./plugins/extra-mods
claude plugin validate ./plugins/extra-mods
claude plugin test ./plugins/extra-mods
Source 6 files
hooks/register.tsx 1348 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, RenderInput, Timer } from 'claude-code'
3
4import type { Effort, Panel, RateLimit, StatusSnapshot, ThemeEntry, ZenColors, ZenStep, ZenTask, ZenTheme } from '../types'
5import { EFFORTS, type Mode, MODELS, MODES, modeLabel, modelFamily } from './lib/modes'
6import {
7  backgroundPill,
8  BAR_WIDTH,
9  barFill,
10  cacheState,
11  colorFor,
12  type Detail,
13  fmtDuration,
14  fmtTokens,
15  LIMIT_LABELS,
16  STATUS_CYAN,
17  STATUS_GREEN,
18  STATUS_RED,
19  statsWidth,
20} from './lib/status'
21import {
22  findProfile,
23  matchAll,
24  type Palette,
25  parseJsonc,
26  targetProfile,
27  toPalette,
28  toClaudeTheme,
29  withProfileTheme,
30  WT_SCHEME,
31  type WtSettings,
32} from './lib/palette'
33import {
34  asStatus,
35  barCells,
36  colorsFor,
37  elapsed,
38  percentOf,
39  stepLabel,
40  visibleSteps,
41  ZEN_PROMPT,
42  ZEN_TOOL_DESCRIPTION,
43  ZEN_TOOL_NAME,
44  ZEN_TOOL_SCHEMA,
45  ZEN_THEMES,
46} from './lib/zen'
47
48// Every value the drawings read. Settings worth keeping across sessions are
49// mirrored to $.store under the same key.
50const menu = atom({ plugin: 'extra-mods', key: 'menu' } as const, null)
51const zenOn = atom({ plugin: 'extra-mods', key: 'zenOn' } as const, false)
52const zenTheme = atom({ plugin: 'extra-mods', key: 'zenTheme' } as const, 'blue')
53const zenCustom = atom(
54  { plugin: 'extra-mods', key: 'zenCustom' } as const,
55  { bar: '#3b82f6', text: '#e2e8f0', border: '#3b82f6' } satisfies ZenColors,
56)
57const zenCollapsed = atom({ plugin: 'extra-mods', key: 'zenCollapsed' } as const, false)
58const task = atom({ plugin: 'extra-mods', key: 'task' } as const, null)
59const now = atom({ plugin: 'extra-mods', key: 'now' } as const, 0)
60const themes = atom({ plugin: 'extra-mods', key: 'themes' } as const, [])
61const themeName = atom({ plugin: 'extra-mods', key: 'themeName' } as const, null)
62const themeQuery = atom({ plugin: 'extra-mods', key: 'themeQuery' } as const, '')
63const themeStatus = atom({ plugin: 'extra-mods', key: 'themeStatus' } as const, '')
64const effort = atom({ plugin: 'extra-mods', key: 'effort' } as const, null)
65const mode = atom({ plugin: 'extra-mods', key: 'mode' } as const, null)
66const defaultMode = atom({ plugin: 'extra-mods', key: 'defaultMode' } as const, null)
67const preview = atom({ plugin: 'extra-mods', key: 'preview' } as const, null)
68const isDownloading = atom({ plugin: 'extra-mods', key: 'isDownloading' } as const, false)
69const sessionEffort = atom({ plugin: 'extra-mods', key: 'sessionEffort' } as const, null)
70const sessionModel = atom({ plugin: 'extra-mods', key: 'sessionModel' } as const, null)
71const paneBg = atom({ plugin: 'extra-mods', key: 'paneBg' } as const, null)
72const defaultModeChosen = atom({ plugin: 'extra-mods', key: 'defaultModeChosen' } as const, false)
73const statusOn = atom({ plugin: 'extra-mods', key: 'statusOn' } as const, false)
74const status = atom({ plugin: 'extra-mods', key: 'status' } as const, null)
75const lastResponseAt = atom({ plugin: 'extra-mods', key: 'lastResponseAt' } as const, null)
76const statusClock = atom({ plugin: 'extra-mods', key: 'statusClock' } as const, 0)
77
78// The menu's pane: a sidebar in the fullscreen layout, a block above the prompt otherwise.
79// Claude's progress tool, as the model calls it.
80const ZEN_TOOL = 'mcp__extra-mods__zen_progress'
81
82const PANE = 'claude-tools'
83const PANE_SIZE = { rows: 26, columns: 56 }
84// Used until the terminal's own background is known: just short of pure black.
85const PANE_BACKGROUND = '#0b0b0b'
86const MENU_NAME = 'extra-mods'
87// Room the engine's own mode label takes left of the hint row (⏵⏵ auto mode on · ).
88const MODE_LABEL_COLUMNS = 22
89// The slug of the Claude Code theme the mod writes: ~/.claude/themes/extra-mods.json.
90const CLAUDE_THEME = 'extra-mods'
91
92// Windows Terminal's built-in schemes, by name: their backgrounds.
93const WT_BUILTIN_BACKGROUNDS: Record<string, string> = {
94  Campbell: '#0C0C0C',
95  'Campbell Powershell': '#012456',
96  'Dark+': '#1E1E1E',
97  Dimidium: '#141414',
98  'One Half Dark': '#282C34',
99  'One Half Light': '#FFFFFF',
100  Ottosson: '#000000',
101  'Solarized Dark': '#002B36',
102  'Solarized Light': '#FDF6E3',
103  'Tango Dark': '#000000',
104  'Tango Light': '#FFFFFF',
105  Vintage: '#000000',
106}
107
108// Each permission mode in the color Claude Code draws it with.
109const MODE_COLORS: Record<string, string> = {
110  default: 'text',
111  plan: 'planMode',
112  acceptEdits: 'autoAccept',
113  auto: 'warning',
114  dontAsk: 'warning',
115  bypassPermissions: 'error',
116}
117
118const SITE = 'https://terminalcolors.com'
119const HEX = /^#[0-9a-f]{6}$/i
120
121// Paths and platform --------------------------------------------------------
122
123async function homeDir($: EngineInterface) {
124  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
125  return home.replaceAll('\\', '/')
126}
127
128async function themesDir($: EngineInterface) {
129  return `${await homeDir($)}/.claude/extra-mods/themes`
130}
131
132async function isWindows($: EngineInterface) {
133  return (await $.env.get('OS')) === 'Windows_NT'
134}
135
136// Zen settings ---------------------------------------------------------------
137
138async function setZenOn($: EngineInterface, isOn: boolean) {
139  await update($, zenOn, () => isOn)
140  await $.store.set('zenOn', isOn)
141  if (!isOn) await update($, task, () => null)
142}
143
144async function setZenTheme($: EngineInterface, theme: ZenTheme) {
145  await update($, zenTheme, () => theme)
146  await $.store.set('zenTheme', theme)
147}
148
149async function setZenColor($: EngineInterface, part: keyof ZenColors, value: string) {
150  if (!HEX.test(value.trim())) {
151    $.ui.toast(`${value} is not a hex color like #3b82f6`)
152    return
153  }
154  const colors = await update($, zenCustom, c => ({ ...c, [part]: value.trim() }))
155  await $.store.set('zenCustom', colors)
156}
157
158// Themes ---------------------------------------------------------------------
159
160async function fetchText($: EngineInterface, url: string) {
161  const res = await $.http.fetch(url)
162  if (!res.ok) throw new Error(`${res.status} for ${url}`)
163  return res.text
164}
165
166async function sayTheme($: EngineInterface, text: string) {
167  await update($, themeStatus, () => text)
168}
169
170// Reads the saved YAML files into the list the picker shows.
171async function indexThemes($: EngineInterface) {
172  const dir = await themesDir($)
173  const list: ThemeEntry[] = []
174  if (await $.fs.exists(dir)) {
175    for (const entry of await $.fs.list(dir)) {
176      if (entry.kind !== 'file' || !entry.name.endsWith('.yaml')) continue
177      const palette = toPalette(await $.fs.read(`${dir}/${entry.name}`))
178      if (palette !== null) list.push({ slug: entry.name.replace(/\.yaml$/, ''), name: palette.name })
179    }
180  }
181  list.sort((a, b) => a.name.localeCompare(b.name))
182  await update($, themes, () => list)
183  return list
184}
185
186// Scrapes every theme page on terminalcolors.com and saves each theme's YAML.
187async function downloadThemes($: EngineInterface) {
188  const dir = await themesDir($)
189  await sayTheme($, 'Reading terminalcolors.com…')
190  const homePage = await fetchText($, `${SITE}/`)
191  const variants = new Set(matchAll(homePage, /href="(\/themes\/[\w-]+\/[\w-]+\/)"/g))
192  // A family page can list variants the home page leaves out.
193  for (const family of new Set(matchAll(homePage, /href="(\/themes\/[\w-]+\/)"/g))) {
194    try {
195      for (const v of matchAll(await fetchText($, SITE + family), /href="(\/themes\/[\w-]+\/[\w-]+\/)"/g)) {
196        variants.add(v)
197      }
198    } catch {}
199  }
200
201  const pages = [...variants]
202  let done = 0
203  let saved = 0
204  // Six downloads at a time.
205  for (let i = 0; i < pages.length; i += 6) {
206    await Promise.all(
207      pages.slice(i, i + 6).map(async page => {
208        try {
209          const yamlPath = matchAll(await fetchText($, SITE + page), /href="(\/downloads\/warp\/[^"]+\.ya?ml)"/g)[0]
210          if (yamlPath === undefined) return
211          const yaml = await fetchText($, SITE + yamlPath)
212          if (toPalette(yaml) === null) return
213          const file = yamlPath.split('/').pop()!.replace(/\.yml$/, '.yaml')
214          await $.fs.write(`${dir}/${file}`, yaml)
215          saved++
216        } catch {
217        } finally {
218          done++
219        }
220      }),
221    )
222    await sayTheme($, `Downloading themes… ${done}/${pages.length}`)
223  }
224
225  await indexThemes($)
226  await sayTheme($, `Saved ${saved} themes to ${dir}`)
227}
228
229async function wtSettingsPath($: EngineInterface, profileId: string | null) {
230  const local = ((await $.env.get('LOCALAPPDATA')) ?? '').replaceAll('\\', '/')
231  const candidates = [
232    `${local}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
233    `${local}/Packages/Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe/LocalState/settings.json`,
234    `${local}/Microsoft/Windows Terminal/settings.json`,
235  ]
236  let first: string | null = null
237  for (const path of candidates) {
238    if (local === '' || !(await $.fs.exists(path))) continue
239    first ??= path
240    const text = await $.fs.read(path)
241    if (profileId !== null && text.toLowerCase().includes(profileId.toLowerCase())) return path
242  }
243  return first
244}
245
246// The background of the terminal Claude Code runs in: Windows Terminal's
247// profile and scheme, else the kept theme's.
248async function terminalBackground($: EngineInterface): Promise<string | null> {
249  if (await isWindows($)) {
250    const profileId = (await $.env.get('WT_PROFILE_ID')) ?? null
251    const path = await wtSettingsPath($, profileId)
252    if (path === null) return null
253    const settings = parseJsonc(await $.fs.read(path)) as WtSettings
254    const profile = targetProfile(settings, profileId) as { background?: unknown; colorScheme?: unknown }
255    if (typeof profile.background === 'string') return profile.background
256    const defaults = (Array.isArray(settings.profiles) ? undefined : settings.profiles?.defaults) as
257      | { background?: unknown; colorScheme?: unknown }
258      | undefined
259    if (typeof defaults?.background === 'string') return defaults.background
260    const raw = profile.colorScheme ?? defaults?.colorScheme ?? 'Campbell'
261    const name = typeof raw === 'string' ? raw : ((raw as { dark?: string }).dark ?? 'Campbell')
262    const scheme = (settings.schemes ?? []).find(sc => sc.name === name) as { background?: unknown } | undefined
263    if (typeof scheme?.background === 'string') return scheme.background
264    return WT_BUILTIN_BACKGROUNDS[name] ?? null
265  }
266  const kept = (await $.store.get('theme')) as { slug: string } | undefined
267  if (kept === undefined) return null
268  return toPalette(await $.fs.read(`${await themesDir($)}/${kept.slug}.yaml`))?.background ?? null
269}
270
271async function refreshPaneBg($: EngineInterface) {
272  try {
273    const bg = await terminalBackground($)
274    await update($, paneBg, () => bg)
275  } catch {}
276}
277
278// The `theme` in ~/.claude/settings.json, written directly: /config's theme row
279// takes only the built-in themes ("For custom themes, use /theme"), so a custom
280// one can only be chosen here. Claude Code reads it once, at startup, and loads
281// ~/.claude/themes/ at startup only when that theme is a custom one.
282async function writeThemeSetting($: EngineInterface, value: string): Promise<boolean> {
283  const path = `${await homeDir($)}/.claude/settings.json`
284  const text = (await $.fs.exists(path)) ? await $.fs.read(path) : '{}'
285  const settings = JSON.parse(text) as { theme?: unknown }
286  if (settings.theme === value) return false
287  const backup = `${path}.extra-mods.bak`
288  if (!(await $.fs.exists(backup))) await $.fs.write(backup, text)
289  settings.theme = value
290  await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
291  return true
292}
293
294// Selects a Claude Code theme by its /theme value (`dark`, `custom:<slug>`),
295// first keeping the one it replaces, so a reset can put it back.
296async function selectClaudeTheme($: EngineInterface, value: string) {
297  const row = (await $.config.list()).find(r => r.key === 'theme')
298  if (row === undefined) throw new Error('Claude Code has no theme setting to change')
299  if (row.value === value) return
300  if (value === `custom:${CLAUDE_THEME}` && (await $.store.get('themeBefore')) === undefined) {
301    await $.store.set('themeBefore', row.value)
302  }
303  if (!value.startsWith('custom:') || row.options?.includes(value)) {
304    const set = await $.config.set({ key: 'theme', value })
305    if (set.deny === undefined) return
306    if (!value.startsWith('custom:')) throw new Error(set.deny)
307  }
308  if (await writeThemeSetting($, value)) {
309    $.ui.toast(`Restart Claude Code once to switch to the ${CLAUDE_THEME} theme; after that, themes change live`)
310  }
311}
312
313// Windows: the kept theme, background included, on the Windows Terminal
314// profile this tab runs in. Windows Terminal reloads its settings live, so the
315// tab repaints at once; other open tabs on the same profile take the theme too
316// while the session runs. The profile's own scheme is kept in the store before
317// the first change and goes back with null, at session end; a session that
318// crashed leaves it there, and the next session's end puts it back.
319async function paintWindowsTerminal($: EngineInterface, palette: Palette | null) {
320  const profileId = (await $.env.get('WT_PROFILE_ID')) ?? null
321  if (profileId === null) return
322  const path = await wtSettingsPath($, profileId)
323  if (path === null) return
324  const text = await $.fs.read(path)
325  const settings = parseJsonc(text) as WtSettings
326  const profile = findProfile(settings, profileId)
327  if (profile === undefined) return
328  const key = profileId.toLowerCase()
329  const originals = ((await $.store.get('wtOriginal')) ?? {}) as Record<string, { colorScheme?: unknown }>
330  if (palette === null && originals[key] === undefined) return
331  if (originals[key] === undefined) {
332    originals[key] = profile.colorScheme === undefined || profile.colorScheme === WT_SCHEME ? {} : { colorScheme: profile.colorScheme }
333    await $.store.set('wtOriginal', originals)
334  }
335  withProfileTheme(settings, profile, palette, originals[key].colorScheme)
336  const out = JSON.stringify(settings, null, 4)
337  if (out !== JSON.stringify(parseJsonc(text), null, 4)) {
338    const backup = `${path}.extra-mods.bak`
339    if (!(await $.fs.exists(backup))) await $.fs.write(backup, text)
340    await $.fs.write(path, out)
341  }
342  if (palette === null) {
343    delete originals[key]
344    await $.store.set('wtOriginal', originals)
345  }
346  await refreshPaneBg($)
347}
348
349// Paints Claude Code with a saved theme: written as the custom theme
350// ~/.claude/themes/extra-mods.json, which Claude Code reloads live, and
351// selected. On Windows this tab's Windows Terminal profile takes the theme
352// too, background included. null selects the theme that was in use before
353// the mod's first one, and gives the profile its own colors back.
354async function paintTerminal($: EngineInterface, slug: string | null) {
355  if (slug === null) {
356    const before = (await $.store.get('themeBefore')) as string | undefined
357    await selectClaudeTheme($, before ?? 'dark')
358    await $.store.delete('themeBefore')
359    if (await isWindows($)) await paintWindowsTerminal($, null)
360    return null
361  }
362  const palette = toPalette(await $.fs.read(`${await themesDir($)}/${slug}.yaml`))
363  if (palette === null) throw new Error(`${slug}.yaml is not a theme this mod can read`)
364  const dir = `${await homeDir($)}/.claude/themes`
365  await $.fs.write(`${dir}/${CLAUDE_THEME}.json`, `${JSON.stringify(toClaudeTheme(palette), null, 2)}\n`)
366  await selectClaudeTheme($, `custom:${CLAUDE_THEME}`)
367  if (await isWindows($)) await paintWindowsTerminal($, palette)
368  return palette
369}
370
371// Keeps a theme (null: the terminal's own colors) for this and later sessions.
372async function applyTheme($: EngineInterface, slug: string | null) {
373  const palette = await paintTerminal($, slug)
374  await update($, preview, () => null)
375  await update($, themeName, () => palette?.name ?? null)
376  if (palette === null) await $.store.delete('theme')
377  else await $.store.set('theme', { slug: slug!, name: palette.name })
378  await sayTheme($, palette ? `Kept ${palette.name}` : "Back to Claude Code's previous theme")
379}
380
381// Shows a theme on the terminal without keeping it.
382async function previewTheme($: EngineInterface, slug: string) {
383  const palette = await paintTerminal($, slug)
384  await update($, preview, () => slug)
385  await sayTheme($, `Previewing ${palette?.name ?? slug}: Enter or click keeps it`)
386}
387
388// Puts the kept theme back after a preview that was not kept.
389async function endPreview($: EngineInterface) {
390  if ((await read($, preview)) === null) return
391  const kept = (await $.store.get('theme')) as { slug: string } | undefined
392  await paintTerminal($, kept?.slug ?? null)
393  await update($, preview, () => null)
394  await sayTheme($, '')
395}
396
397// The first look at the themes downloads them; once, however often it is asked.
398async function ensureThemes($: EngineInterface) {
399  if ((await read($, themes)).length > 0 || (await read($, isDownloading))) return
400  await update($, isDownloading, () => true)
401  try {
402    await downloadThemes($)
403  } catch (error) {
404    await sayTheme($, `Download failed: ${error instanceof Error ? error.message : String(error)}`)
405  } finally {
406    await update($, isDownloading, () => false)
407  }
408}
409
410// Runs a theme action from a button, reporting a failure in the panel.
411function themeAction($: EngineInterface, work: () => Promise<unknown>) {
412  return () => {
413    void work().catch((error: unknown) =>
414      sayTheme($, `Failed: ${error instanceof Error ? error.message : String(error)}`),
415    )
416  }
417}
418
419async function loadThemes($: EngineInterface) {
420  const list = await indexThemes($)
421  const saved = (await $.store.get('theme')) as { slug: string; name: string } | undefined
422  if (saved === undefined) return
423  if (!list.some(t => t.slug === saved.slug)) return
424  await update($, themeName, () => saved.name)
425  // This tab's Windows Terminal profile takes the kept theme for the session.
426  try {
427    if (await isWindows($)) await paintWindowsTerminal($, toPalette(await $.fs.read(`${await themesDir($)}/${saved.slug}.yaml`)))
428  } catch {}
429}
430
431// Mode and effort --------------------------------------------------------------
432
433// The effort every request of this session is sent with. When /config has an
434// effort row, it is changed too, so Claude Code's own display agrees.
435async function setEffort($: EngineInterface, level: Effort) {
436  await update($, effort, () => level)
437  await $.store.set('effort', level)
438  try {
439    const row = (await $.config.list()).find(r => /effort/i.test(r.key) && r.options?.includes(level))
440    if (row !== undefined) await $.config.set({ key: row.key, value: level })
441  } catch {}
442  $.ui.toast(`Effort: ${level}`)
443}
444
445// Switches the session's model through Claude Code's own `/model`, so /model,
446// the status line and the requests all agree. It runs once the session is idle.
447async function setModel($: EngineInterface, alias: string) {
448  await $.command.run({ command: 'model', args: alias })
449  const id = await $.session.model()
450  await update($, sessionModel, () => id)
451  if (await read($, statusOn)) void refreshStatus($).catch(() => {})
452  $.ui.toast(`Model: ${prettyModel(id)}`)
453}
454
455async function trackModel($: EngineInterface, id: string) {
456  if (id !== (await read($, sessionModel))) await update($, sessionModel, () => id)
457}
458
459// permissions.defaultMode in ~/.claude/settings.json: the mode new sessions
460// start in. A running session's mode is shift+tab's; a mod cannot set it.
461async function setDefaultMode($: EngineInterface, next: Mode) {
462  const path = `${await homeDir($)}/.claude/settings.json`
463  const text = (await $.fs.exists(path)) ? await $.fs.read(path) : '{}'
464  const backup = `${path}.extra-mods.bak`
465  if (!(await $.fs.exists(backup))) await $.fs.write(backup, text)
466  const settings = JSON.parse(text) as { permissions?: Record<string, unknown> }
467  settings.permissions = { ...settings.permissions, defaultMode: next }
468  await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
469  await update($, defaultMode, () => next)
470  await update($, defaultModeChosen, () => true)
471  $.ui.toast(`New sessions start in ${modeLabel(next)} mode`)
472}
473
474async function trackMode($: EngineInterface, current: string | undefined) {
475  if (current !== undefined && current !== (await read($, mode))) await update($, mode, () => current)
476}
477
478// The session's own effort level before any request. First match wins: a
479// /config row, the effortLevel setting, CLAUDE_EFFORT, the last level seen.
480async function loadSessionEffort($: EngineInterface) {
481  const found: unknown[] = []
482  try {
483    found.push((await $.config.list()).find(r => /effort/i.test(r.key))?.value)
484  } catch {}
485  try {
486    found.push(((await $.settings.read()) as { effortLevel?: unknown }).effortLevel)
487  } catch {}
488  found.push(await $.env.get('CLAUDE_EFFORT'))
489  found.push(await $.store.get('lastSessionEffort'))
490  const level = found.find((v): v is Effort => typeof v === 'string' && EFFORTS.includes(v as Effort))
491  if (level !== undefined) await update($, sessionEffort, () => level)
492}
493
494// Records the level a request actually ran at, as the engine reports it.
495async function trackEffort($: EngineInterface, level: unknown) {
496  if (typeof level !== 'string' || !EFFORTS.includes(level as Effort)) return
497  const own = level as Effort
498  if (own === (await read($, sessionEffort))) return
499  await update($, sessionEffort, () => own)
500  await $.store.set('lastSessionEffort', own)
501}
502
503async function loadSettings($: EngineInterface) {
504  const saved = {
505    zenOn: (await $.store.get('zenOn')) as boolean | undefined,
506    zenTheme: (await $.store.get('zenTheme')) as ZenTheme | undefined,
507    zenCustom: (await $.store.get('zenCustom')) as ZenColors | undefined,
508    effort: (await $.store.get('effort')) as Effort | undefined,
509  }
510  if (saved.zenOn !== undefined) await update($, zenOn, () => saved.zenOn!)
511  if (saved.zenTheme !== undefined && ZEN_THEMES.includes(saved.zenTheme)) await update($, zenTheme, () => saved.zenTheme!)
512  if (saved.zenCustom !== undefined) await update($, zenCustom, () => saved.zenCustom!)
513  if (saved.effort !== undefined && EFFORTS.includes(saved.effort)) await update($, effort, () => saved.effort!)
514  try {
515    const { permissions } = await $.settings.read({ source: 'user' })
516    const configured = (permissions as { defaultMode?: string } | undefined)?.defaultMode
517    await update($, defaultMode, () => configured ?? 'default')
518  } catch {}
519}
520
521// Status line -------------------------------------------------------------------
522
523// Gathers what the status line draws: the model, the folder and branch, the
524// context window and the rate limits, as Claude Code's own status line has them.
525async function refreshStatus($: EngineInterface) {
526  const [model, cwd, usage] = await Promise.all([$.session.model(), $.session.cwd(), $.session.usage()])
527  let branch = ''
528  try {
529    const ran = await $.process.run(['git', '-C', cwd, 'branch', '--show-current'])
530    if (ran.exitCode === 0) branch = ran.stdout.trim()
531  } catch {}
532  const limits: RateLimit[] = usage.rateLimits
533    .filter(l => LIMIT_LABELS[l.kind] !== undefined)
534    .map(l => ({
535      label: LIMIT_LABELS[l.kind]!,
536      percent: Math.round(l.percentUsed),
537      resetsAt: l.resetsAt === undefined ? null : Date.parse(l.resetsAt),
538    }))
539  const snapshot: StatusSnapshot = {
540    model,
541    dir: cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd,
542    branch,
543    ctxPercent: usage.context.percent ?? null,
544    ctxTokens: usage.context.tokens ?? null,
545    ctxWindow: usage.context.window,
546    limits,
547  }
548  await update($, status, () => snapshot)
549}
550
551type SavedStatusLine = { statusLine: unknown }
552
553// On: the mod draws the status line, so Claude Code's own statusLine command is
554// taken out of ~/.claude/settings.json (kept, to put back when it goes off).
555async function setStatusOn($: EngineInterface, isOn: boolean) {
556  const path = `${await homeDir($)}/.claude/settings.json`
557  const text = (await $.fs.exists(path)) ? await $.fs.read(path) : '{}'
558  const backup = `${path}.extra-mods.bak`
559  if (!(await $.fs.exists(backup))) await $.fs.write(backup, text)
560  const settings = JSON.parse(text) as { statusLine?: unknown }
561  const saved = (await $.store.get('savedStatusLine')) as SavedStatusLine | undefined
562  if (isOn && settings.statusLine !== undefined) {
563    await $.store.set('savedStatusLine', { statusLine: settings.statusLine } satisfies SavedStatusLine)
564    delete settings.statusLine
565    await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
566  } else if (!isOn && saved !== undefined && settings.statusLine === undefined) {
567    settings.statusLine = saved.statusLine
568    await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
569    await $.store.delete('savedStatusLine')
570  }
571  await update($, statusOn, () => isOn)
572  await $.store.set('statusOn', isOn)
573  if (isOn) await refreshStatus($)
574}
575
576async function loadStatus($: EngineInterface) {
577  const isOn = (await $.store.get('statusOn')) as boolean | undefined
578  if (isOn === true) await update($, statusOn, () => true)
579  // Never chosen: on, so the button shares the stats row (the status line
580  // command, if any, is kept and comes back with Off).
581  if (isOn === undefined) await setStatusOn($, true).catch(() => {})
582  await update($, statusClock, () => Date.now())
583  try {
584    await refreshStatus($)
585  } catch {}
586}
587
588// Drawing ----------------------------------------------------------------------
589
590// Zen's progress band; null when there is nothing to show.
591async function drawZenBand($: EngineInterface, e: RenderInput<'AbovePrompt'>) {
592  const t = await read($, task)
593  if (!(await read($, zenOn)) || t === null) return null
594
595  const theme = await read($, zenTheme)
596  const colors = colorsFor(theme, await read($, zenCustom))
597  const isCollapsed = await read($, zenCollapsed)
598  const clock = await read($, now)
599  const { Box, Text, Button } = $.ui.resolve(e)
600
601  const columns = Math.max(30, e.props.bodyColumns ?? 80)
602  const percent = percentOf(t)
603  const mark = t.doneAt !== null ? '✓' : '✶'
604  const width = Math.max(8, columns - 4 - 16 - 6 - (isCollapsed ? Math.min(30, t.title.length + 2) : 0))
605  const { filled, runs } = barCells(theme, colors.bar, width, percent)
606  const bar = (
607    <Box>
608      {runs.map(r => (
609        <Text color={r.color}>{'█'.repeat(r.cells)}</Text>
610      ))}
611      <Text dimColor>{'░'.repeat(width - filled)}</Text>
612    </Box>
613  )
614  const toggle = (
615    <Button
616      key="zen-collapse"
617      plain
618      dimColor
619      label={isCollapsed ? '[+]' : '[–]'}
620      onPress={() => update($, zenCollapsed, c => !c)}
621    />
622  )
623
624  if (isCollapsed) {
625    return (
626      <Box borderStyle="round" borderColor={colors.border} paddingX={1} gap={1}>
627        <Box width={Math.min(30, t.title.length + 2)}>
628          <Text color={colors.text} bold wrap="truncate">
629            {mark} {t.title}
630          </Text>
631        </Box>
632        {bar}
633        <Text color={colors.text} bold>{`${percent}%`.padStart(4)}</Text>
634        {toggle}
635      </Box>
636    )
637  }
638
639  const shown = visibleSteps(t)
640  return (
641    <Box flexDirection="column" borderStyle="round" borderColor={colors.border} paddingX={1}>
642      <Box justifyContent="space-between" gap={1}>
643        <Text color={colors.text} bold wrap="truncate">
644          {mark} {t.title}
645        </Text>
646        <Box gap={1} flexShrink={0}>
647          <Text dimColor>{elapsed((t.doneAt ?? clock) - t.startedAt)}</Text>
648          {toggle}
649        </Box>
650      </Box>
651      <Box gap={1}>
652        <Text dimColor>{stepLabel(t).padEnd(14)}</Text>
653        {bar}
654        <Text color={colors.text} bold>{`${percent}%`.padStart(4)}</Text>
655      </Box>
656      {shown.map(step => {
657        const isNow = step.status === 'in_progress'
658        return (
659          <Box key={`step-${step.id}`} justifyContent="space-between" gap={1}>
660            <Text color={isNow ? colors.text : undefined} bold={isNow} dimColor={step.status === 'pending'} wrap="truncate">
661              {step.status === 'completed' ? '●' : isNow ? '◉' : '○'} {step.text}
662            </Text>
663            <Text color={isNow ? colors.text : undefined} dimColor={!isNow}>
664              {step.status === 'completed' ? 'Done' : isNow ? 'Working' : 'Next'}
665            </Text>
666          </Box>
667        )
668      })}
669      {t.steps.length > shown.length && <Text dimColor>{t.steps.length - shown.length} more</Text>}
670    </Box>
671  )
672}
673
674// Moves the menu to another page; leaving the themes ends any preview.
675async function goTo($: EngineInterface, panel: Panel) {
676  const from = await read($, menu)
677  if (from === 'themes' && panel !== 'themes') await endPreview($)
678  await update($, menu, () => panel)
679  if (panel === 'themes') void ensureThemes($)
680}
681
682// Opens the menu in its pane with the keyboard, or closes it.
683async function toggleMenu($: EngineInterface, panel: Panel = 'main') {
684  if ((await read($, menu)) !== null) {
685    // A plugin's own close skips its own ui.close hook, so reset here too.
686    await $.ui.close({ id: PANE })
687    await endPreview($)
688    await update($, menu, () => null)
689    return
690  }
691  await update($, menu, () => panel)
692  if (panel === 'themes') void ensureThemes($)
693  await $.ui.open({ id: PANE, title: MENU_NAME, focus: true, closeOnEscape: true, ...PANE_SIZE })
694}
695
696// The menu, one item per row so the arrow keys walk it top to bottom.
697async function drawPane($: EngineInterface, e: RenderInput<'Pane'>, panel: Panel) {
698  if (e.surface !== 'terminal' && e.surface !== 'desktop') return null
699  const { Box, Text, Button, Input } = $.ui.resolve(e)
700  const isZenOn = await read($, zenOn)
701  const zenThemeNow = await read($, zenTheme)
702  const level = (await read($, effort)) ?? (await read($, sessionEffort))
703  const background = (await read($, paneBg)) ?? PANE_BACKGROUND
704  const running = await read($, mode)
705  const model = await read($, sessionModel)
706  const theme = (await read($, themeName)) ?? 'Terminal default'
707
708  // A radio row: ● for the chosen one, focusable and pressable.
709  const option = (key: string, label: string, isOn: boolean, onPress: () => unknown, swatch?: string) => (
710    <Box key={`row-${key}`} gap={1}>
711      {swatch !== undefined && <Text color={swatch}>■</Text>}
712      <Button key={key} plain label={`${isOn ? '●' : '○'} ${label}`} dimColor={!isOn} onPress={onPress} />
713    </Box>
714  )
715  const heading = (text: string) => (
716    <Box key={`h-${text}`} marginTop={1}>
717      <Text bold>{text}</Text>
718    </Box>
719  )
720  const page = (title: string, rows: RenderChildren[]) => (
721    <Box
722      flexDirection="column"
723      paddingX={1}
724      backgroundColor={background}
725      width={e.props.bodyColumns}
726      minHeight={e.props.scroll.bodyRows}
727    >
728      <Box justifyContent="space-between">
729        <Text bold color="claude">
730          ◆ {title}
731        </Text>
732        {title !== MENU_NAME && (
733          <Button key="tools-back" plain dimColor hotkey="b" label="← back" onPress={() => goTo($, 'main')} />
734        )}
735      </Box>
736      {rows}
737      <Box marginTop={1}>
738        <Text dimColor>↑↓ move · enter select · esc close</Text>
739      </Box>
740    </Box>
741  )
742
743  if (panel === 'zen') {
744    const custom = await read($, zenCustom)
745    return page('Zen mode', [
746      <Text key="zen-about" dimColor>
747        Hides tool calls and their output. Shows Claude's text and a progress band above the prompt.
748      </Text>,
749      heading('Zen mode'),
750      option('zen-on', 'On', isZenOn, () => setZenOn($, true)),
751      option('zen-off', 'Off', !isZenOn, () => setZenOn($, false)),
752      heading('Band color'),
753      ...ZEN_THEMES.map(t => option(`zen-theme-${t}`, t, t === zenThemeNow, () => setZenTheme($, t), colorsFor(t, custom).bar)),
754      zenThemeNow === 'custom' && (
755        <Box key="zen-custom" flexDirection="column">
756          {(['bar', 'text', 'border'] as const).map(part => (
757            <Input
758              key={`zen-color-${part}`}
759              label={`${part} color`}
760              value={custom[part]}
761              placeholder="#3b82f6"
762              submitLabel="set"
763              onSubmit={value => setZenColor($, part, value)}
764            />
765          ))}
766        </Box>
767      ),
768    ])
769  }
770
771  if (panel === 'themes') {
772    const list = await read($, themes)
773    const query = await read($, themeQuery)
774    const status = await read($, themeStatus)
775    const previewing = await read($, preview)
776    const busy = await read($, isDownloading)
777    const kept = ((await $.store.get('theme')) as { slug: string } | undefined)?.slug ?? null
778    const found = list.filter(t => t.name.toLowerCase().includes(query.trim().toLowerCase()))
779    if (list.length === 0) {
780      return page('Theme', [
781        <Text key="themes-wait" dimColor>
782          {status || 'Getting the themes from terminalcolors.com…'}
783        </Text>,
784        !busy && <Button key="themes-download" plain label="↻ Try again" onPress={() => ensureThemes($)} />,
785      ])
786    }
787    return page('Theme', [
788      <Box key="themes-current" gap={1}>
789        <Text dimColor>Kept</Text>
790        <Text bold>{theme}</Text>
791      </Box>,
792      <Input
793        key="themes-search"
794        label="Search"
795        value={query}
796        placeholder={`${list.length} themes`}
797        onInput={value => update($, themeQuery, () => value)}
798        onSubmit={value => update($, themeQuery, () => value)}
799      />,
800      <Text key="themes-hint" dimColor>
801        Arrow through the list to preview. Enter keeps one.
802      </Text>,
803      status !== '' && (
804        <Text key="themes-status" color="claude">
805          {status}
806        </Text>
807      ),
808      found.length === 0 && (
809        <Text key="themes-none" dimColor>
810          No theme matches “{query}”.
811        </Text>
812      ),
813      ...found.map(t => (
814        <Button
815          key={`theme:${t.slug}`}
816          plain
817          autoFocus={t.slug === (previewing ?? kept) ? true : undefined}
818          label={`${t.slug === kept ? '●' : t.slug === previewing ? '◉' : '○'} ${t.name}`}
819          dimColor={t.slug !== kept && t.slug !== previewing}
820          onPress={themeAction($, () => applyTheme($, t.slug))}
821        />
822      )),
823      heading('More'),
824      <Button
825        key="themes-reset"
826        plain
827        dimColor
828        label="Back to Claude Code's previous theme"
829        onPress={themeAction($, () => applyTheme($, null))}
830      />,
831      <Button key="themes-refresh" plain dimColor label="Download the themes again" onPress={themeAction($, () => downloadThemes($))} />,
832    ])
833  }
834
835  if (panel === 'status') {
836    const isOn = await read($, statusOn)
837    return page('Status line', [
838      <Text key="status-about" dimColor>
839        Model, folder and branch, then context, 5h and 7d limits and cache, with extra-mods on the same row.
840      </Text>,
841      heading('Status line'),
842      option('status-on', 'On', isOn, () =>
843        setStatusOn($, true).catch((error: unknown) => $.ui.toast(`Could not turn it on: ${String(error)}`)),
844      ),
845      option('status-off', 'Off', !isOn, () =>
846        setStatusOn($, false).catch((error: unknown) => $.ui.toast(`Could not turn it off: ${String(error)}`)),
847      ),
848      <Text key="status-note" dimColor>
849        On takes your own statusLine command out of settings.json; Off puts it back.
850      </Text>,
851    ])
852  }
853
854  if (panel === 'mode') {
855    // Marked: what this session runs in, until a new-session mode is chosen here.
856    const next = (await read($, defaultModeChosen)) ? await read($, defaultMode) : (running ?? (await read($, defaultMode)))
857    const family = modelFamily(model)
858    return page('Mode', [
859      heading('Model'),
860      ...MODELS.map(m =>
861        option(`model-${m.alias}`, m.label, m.alias === family, () =>
862          setModel($, m.alias).catch((error: unknown) => $.ui.toast(`Could not switch the model: ${String(error)}`)),
863        ),
864      ),
865      <Button
866        key="model-default"
867        plain
868        dimColor
869        label="↺ Claude Code's default"
870        onPress={() =>
871          setModel($, 'default').catch((error: unknown) => $.ui.toast(`Could not switch the model: ${String(error)}`))
872        }
873      />,
874      heading('Effort'),
875      ...EFFORTS.map(l => option(`effort-${l}`, l, l === level, () => setEffort($, l))),
876      heading('Running in'),
877      <Text key="mode-running">
878        {running === null ? (
879          'Shown after your next prompt'
880        ) : (
881          <Text color={MODE_COLORS[running] ?? 'text'}>{modeLabel(running)}</Text>
882        )}
883        <Text dimColor> · shift+tab switches it</Text>
884      </Text>,
885      heading('New sessions start in'),
886      ...MODES.map(m => (
887        <Box key={`row-default-mode-${m}`} gap={1}>
888          <Button
889            key={`default-mode-${m}`}
890            plain
891            label={m === next ? '●' : '○'}
892            onPress={() =>
893              setDefaultMode($, m).catch((error: unknown) => $.ui.toast(`Could not save the mode: ${String(error)}`))
894            }
895          />
896          <Text color={MODE_COLORS[m] ?? 'text'} bold={m === next}>
897            {modeLabel(m)}
898          </Text>
899          {m === running && <Text dimColor>· now</Text>}
900        </Box>
901      )),
902      next === 'bypassPermissions' && (
903        <Text key="mode-warning" color="warning">
904          Bypass skips every permission check. Claude Code asks you to confirm it when the next session starts.
905        </Text>
906      ),
907    ])
908  }
909
910  const row = (hotkey: string, name: string, value: string, target: Panel) => (
911    <Box key={`row-${target}`} flexDirection="column" marginTop={1}>
912      <Button
913        key={`open-${target}`}
914        plain
915        hotkey={hotkey}
916        autoFocus={target === 'zen' ? true : undefined}
917        label={`${name}  ›`}
918        onPress={() => goTo($, target)}
919      />
920      <Text dimColor>   {value}</Text>
921    </Box>
922  )
923  return page(MENU_NAME, [
924    row('1', 'Zen mode', isZenOn ? `on · ${zenThemeNow} band` : 'off', 'zen'),
925    row('2', 'Theme', theme, 'themes'),
926    row(
927      '3',
928      'Mode',
929      `${model === null ? '' : `${prettyModel(model)} · `}${running === null ? '—' : modeLabel(running)} · effort ${level ?? 'default'}`,
930      'mode',
931    ),
932    row('4', 'Status line', (await read($, statusOn)) ? 'on' : 'off', 'status'),
933  ])
934}
935
936// The row under the prompt: with the status line on, the status line with the
937// extra-mods button at its end, and under it, while Claude has a background
938// shell or monitor running, the engine's pill for them; otherwise the hint and
939// the button.
940// The engine's hint, on one row: it can carry a second line holding only a
941// separator, which drew as a stray dot under the hint.
942function hintText(hint: string) {
943  return hint
944    .split(/\r?\n/)
945    .map(line => line.trim())
946    .filter(line => /[\p{L}\p{N}]/u.test(line))
947    .join(' · ')
948}
949
950async function drawPromptHint($: EngineInterface, e: RenderInput<'PromptHint'>) {
951  const { Box, Text, Button } = $.ui.resolve(e)
952  const isOpen = (await read($, menu)) !== null
953  const label = `◆ ${MENU_NAME} ${isOpen ? '▼' : '▲'}`
954  const button = <Button key="tools-toggle" plain label={label} onPress={() => toggleMenu($)} />
955  const snapshot = (await read($, statusOn)) ? await read($, status) : null
956
957  // The engine keeps its mode label (⏵⏵ auto mode on ·) in a column of its own
958  // to the left of this tree, so the tree grows into the rest of one row: a
959  // full-width or multi-row tree wraps that label and pushes everything right.
960  if (snapshot === null) {
961    return (
962      <Box justifyContent="space-between" flexGrow={1} gap={1}>
963        <Text dimColor wrap="truncate">
964          {hintText(e.props.hint)}
965        </Text>
966        {button}
967      </Box>
968    )
969  }
970
971  const now = await read($, statusClock)
972  const cache = cacheState(await read($, lastResponseAt), now)
973  const columns = e.viewport?.columns ?? 120
974  const sep = <Text dimColor>{'  │  '}</Text>
975  const bar = (percent: number, color: string) => (
976    <Text>
977      <Text color={color}>{'█'.repeat(barFill(percent))}</Text>
978      <Text dimColor>{'░'.repeat(BAR_WIDTH - barFill(percent))}</Text>{' '}
979    </Text>
980  )
981
982  const ctxPercent = Math.round(snapshot.ctxPercent ?? 0)
983  const ctxTokens =
984    snapshot.ctxTokens === null ? '' : ` ${fmtTokens(snapshot.ctxTokens)}${snapshot.ctxWindow ? `/${fmtTokens(snapshot.ctxWindow)}` : ''}`
985  const limits = snapshot.limits.map(l => ({
986    ...l,
987    percentText: `${l.percent}%`,
988    resetText: l.resetsAt === null ? '' : ` (resets ${fmtDuration(l.resetsAt - now)})`,
989  }))
990  const cacheText = cache === null ? '' : `● cache ${cache}`
991  const model = prettyModel(snapshot.model)
992  const head = `${model} · ${snapshot.dir}${snapshot.branch === '' ? '' : ` · ${snapshot.branch}`}`
993  // One row beside the engine's mode label (about MODE_LABEL_COLUMNS wide):
994  // the most detailed level that leaves room for the button; past the least
995  // detailed one, the model and folder go too.
996  const room = columns - MODE_LABEL_COLUMNS - label.length - 4
997  const fits = (d: Detail, withHead: boolean) =>
998    statsWidth(`${ctxPercent}%${ctxTokens}`, limits, cacheText, d) + (withHead ? head.length + 5 : 0) <= room
999  const showHead = fits(2, true)
1000  const detail: Detail = ([0, 1, 2] as const).find(d => fits(d, showHead)) ?? 2
1001
1002  const stats = [
1003    showHead && (
1004      <Text key="head">
1005        <Text color={STATUS_CYAN}>{model}</Text>
1006        <Text dimColor> · </Text>
1007        <Text>{snapshot.dir}</Text>
1008        {snapshot.branch !== '' && <Text dimColor> · </Text>}
1009        {snapshot.branch !== '' && <Text color={STATUS_GREEN}>{snapshot.branch}</Text>}
1010        {sep}
1011      </Text>
1012    ),
1013    <Text key="ctx">
1014      <Text>ctx </Text>
1015      {detail === 0 && bar(ctxPercent, colorFor(ctxPercent))}
1016      <Text bold color={colorFor(ctxPercent)}>{`${ctxPercent}%`}</Text>
1017      <Text dimColor>{ctxTokens}</Text>
1018    </Text>,
1019    ...limits.map(l => (
1020      <Text key={`limit-${l.label}`}>
1021        {sep}
1022        <Text>{l.label} </Text>
1023        {detail === 0 && bar(l.percent, colorFor(l.percent))}
1024        <Text bold color={colorFor(l.percent)}>{l.percentText}</Text>
1025        {detail < 2 && <Text dimColor>{l.resetText}</Text>}
1026      </Text>
1027    )),
1028    cache !== null && (
1029      <Text key="cache">
1030        {sep}
1031        <Text color={cache === 'ok' ? STATUS_GREEN : STATUS_RED}>●</Text>
1032        <Text dimColor> cache {cache}</Text>
1033      </Text>
1034    ),
1035  ]
1036
1037  const statsRow = (
1038    <Box justifyContent="space-between" flexGrow={1} gap={1}>
1039      <Text wrap="truncate">{stats}</Text>
1040      {button}
1041    </Box>
1042  )
1043  // The status line takes the hint's place, pill and all, so the pill gets a
1044  // row of its own under it. Still no wider than the space the label leaves.
1045  const pill = backgroundPill(e.props.hint)
1046  if (pill === null) return statsRow
1047  return (
1048    <Box flexDirection="column" flexGrow={1}>
1049      {statsRow}
1050      <Box gap={1}>
1051        <Button key="background-tasks" plain label={`⎿ ${pill}`} onPress={() => openTasks($)} />
1052        <Text dimColor wrap="truncate">
1053          · /tasks to manage
1054        </Text>
1055      </Box>
1056    </Box>
1057  )
1058}
1059
1060async function openTasks($: EngineInterface) {
1061  try {
1062    await $.command.run({ command: 'tasks' })
1063  } catch (error) {
1064    $.ui.toast(`Could not open /tasks: ${String(error)}`)
1065  }
1066}
1067
1068// `claude-opus-5-5` (what $.session.model() gives) as `Opus 5.5`; a name
1069// that is not a Claude model id stays as it is.
1070function prettyModel(id: string) {
1071  const match = id.match(/^claude-([a-z]+)-(\d+)(?:-(\d+))?/i)
1072  if (match === null) return id
1073  const [, family = '', major = '', minor] = match
1074  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`
1075}
1076
1077// The status line's clock: reset countdowns and the cache state move with it.
1078let statusTicker: Timer | undefined
1079
1080// Zen's elapsed-time ticker, one per module load.
1081let ticker: Timer | undefined
1082
1083function stopTicker() {
1084  ticker?.cancel()
1085  ticker = undefined
1086}
1087
1088// Puts a fresh task on the band and starts its clock.
1089async function startTask($: EngineInterface, title: string) {
1090  const startedAt = await $.clock.now()
1091  const fresh: ZenTask = { title: title || 'Working', startedAt, doneAt: null, steps: [] }
1092  await update($, task, () => fresh)
1093  await update($, now, () => startedAt)
1094  stopTicker()
1095  ticker = $.clock.every(1000, () => {
1096    void $.clock.now().then(t => update($, now, () => t))
1097  })
1098}
1099
1100// Hooks ------------------------------------------------------------------------
1101
1102// Watching hooks end in .catch(... next(e)): when one fails, the event goes on.
1103
1104export const register: Register = on => {
1105
1106  on('session.end', async ($, e, next) => {
1107    // A preview left open as the session ends gives way to the kept theme,
1108    // and this tab's Windows Terminal profile gets its own colors back.
1109    try {
1110      await endPreview($)
1111      if (await isWindows($)) await paintWindowsTerminal($, null)
1112    } catch {}
1113    return next(e)
1114  }).catch(($, e, next) => next(e))
1115
1116  on('session.start', async ($, e, next) => {
1117    const started = await next(e)
1118    await loadSettings($)
1119    await loadThemes($)
1120    await loadStatus($)
1121    await loadSessionEffort($)
1122    try {
1123      await trackModel($, await $.session.model())
1124    } catch {}
1125    await refreshPaneBg($)
1126    statusTicker?.cancel()
1127    statusTicker = $.clock.every(15_000, () => {
1128      void update($, statusClock, () => Date.now())
1129    })
1130    try {
1131      await $.tool.register({ name: ZEN_TOOL_NAME, description: ZEN_TOOL_DESCRIPTION, inputSchema: ZEN_TOOL_SCHEMA })
1132    } catch {}
1133    await $.command.register({
1134      name: 'tools',
1135      description: 'Open the extra-mods menu: Zen mode, themes, mode and status line',
1136      argumentHint: '[zen|themes|mode|debug]',
1137      immediate: true,
1138    })
1139    return started
1140  })
1141
1142  on('command.run', { command: 'tools' }, async ($, e) => {
1143    const arg = e.args.trim().toLowerCase()
1144    if (arg === 'debug') {
1145      const rows = await $.config.list()
1146      return { text: rows.map(r => `${r.key} (${r.kind}) = ${JSON.stringify(r.value)}`).join('\n') }
1147    }
1148    const panel: Panel = arg === 'zen' || arg === 'themes' || arg === 'mode' ? arg : 'main'
1149    if ((await read($, menu)) !== null) await goTo($, panel)
1150    else await toggleMenu($, panel)
1151    return { text: 'extra-mods is open.' }
1152  })
1153
1154  // The menu's pane.
1155  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1156    const panel = await read($, menu)
1157    const drawn = panel === null ? null : await drawPane($, e, panel)
1158    return drawn ?? $.ui.resolve(e).Text({ children: '' })
1159  })
1160
1161  // However the pane closes (esc, its close mark, the corner button), the menu
1162  // resets and an unkept theme preview is undone.
1163  on('ui.close', { id: PANE }, async ($, e, next) => {
1164    const closed = await next(e)
1165    await endPreview($)
1166    await update($, menu, () => null)
1167    return closed
1168  }).catch(($, e, next) => next(e))
1169
1170  // Themes: the focus ring landing on a theme previews it on the terminal,
1171  // once the arrows rest for a moment (each preview rewrites the terminal's settings).
1172  let previewTimer: Timer | undefined
1173  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
1174    const moved = await next(e)
1175    const slug = e.element?.startsWith('theme:') ? e.element.slice('theme:'.length) : null
1176    previewTimer?.cancel()
1177    if (slug !== null && slug !== (await read($, preview))) {
1178      previewTimer = $.clock.after(250, () => {
1179        void previewTheme($, slug).catch((error: unknown) =>
1180          sayTheme($, `Preview failed: ${error instanceof Error ? error.message : String(error)}`),
1181        )
1182      })
1183    }
1184    return moved
1185  }).catch(($, e, next) => next(e))
1186
1187  // Themes: each wheel tick over the pane steps the preview to the next or
1188  // previous theme in the list (the list scrolls along as usual).
1189  let wheelSlug: string | null = null
1190  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
1191    const moved = await next(e)
1192    if (e.pointer === undefined || e.by === 0 || (await read($, menu)) !== 'themes') return moved
1193    const query = (await read($, themeQuery)).trim().toLowerCase()
1194    const found = (await read($, themes)).filter(t => t.name.toLowerCase().includes(query))
1195    if (found.length === 0) return moved
1196    const kept = ((await $.store.get('theme')) as { slug: string } | undefined)?.slug ?? null
1197    const current = wheelSlug ?? (await read($, preview)) ?? kept
1198    const at = found.findIndex(t => t.slug === current)
1199    const target = found[Math.max(0, Math.min(found.length - 1, at + Math.sign(e.by)))]
1200    if (target === undefined) return moved
hooks/lib/modes.ts 31 lines
1import type { Effort } from '../../types'
2
3export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
4
5export const MODES = ['default', 'plan', 'acceptEdits', 'auto', 'bypassPermissions'] as const
6
7export type Mode = (typeof MODES)[number]
8
9export const MODE_LABELS: Record<string, string> = {
10  default: 'ask',
11  plan: 'plan',
12  acceptEdits: 'accept edits',
13  auto: 'auto',
14  dontAsk: "don't ask",
15  bypassPermissions: 'bypass permissions',
16}
17
18export const modeLabel = (m: string | null): string => (m === null ? 'unknown' : (MODE_LABELS[m] ?? m))
19
20// The models the Mode page offers, by the alias `/model` takes.
21export const MODELS = [
22  { alias: 'fable', label: 'Fable' },
23  { alias: 'opus', label: 'Opus' },
24  { alias: 'sonnet', label: 'Sonnet' },
25  { alias: 'haiku', label: 'Haiku' },
26] as const
27
28// `claude-opus-5-5` → `opus`; null for an id that is not a Claude model's.
29export const modelFamily = (id: string | null): string | null =>
30  id?.match(/^claude-([a-z]+)-/i)?.[1]?.toLowerCase() ?? null
31
hooks/lib/status.ts 69 lines
1// The status line's arithmetic, ported from statusline-command.py. Pure; the
2// drawing and the data gathering are in register.tsx.
3
4export const STATUS_GREEN = '#22c55e'
5export const STATUS_YELLOW = '#eab308'
6export const STATUS_RED = '#ef4444'
7export const STATUS_CYAN = '#22d3ee'
8export const BAR_WIDTH = 10
9
10// Claude Code's main-thread prompt cache lives for an hour on a subscription.
11export const CACHE_TTL_MS = 60 * 60 * 1000
12
13export const colorFor = (percent: number): string =>
14  percent >= 90 ? STATUS_RED : percent >= 70 ? STATUS_YELLOW : STATUS_GREEN
15
16export const fmtTokens = (n: number): string => {
17  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
18  if (n >= 1000) return `${Math.round(n / 1000)}k`
19  return String(n)
20}
21
22// Short countdown: 45s, 12m, 4h38m, 5d18h.
23export const fmtDuration = (ms: number): string => {
24  const secs = Math.max(0, Math.floor(ms / 1000))
25  if (secs < 60) return `${secs}s`
26  const m = Math.floor(secs / 60)
27  if (m < 60) return `${m}m`
28  const h = Math.floor(m / 60)
29  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
30  return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, '0')}h`
31}
32
33// How many of the bar's cells are filled at `percent`.
34export const barFill = (percent: number): number =>
35  Math.floor((Math.min(Math.max(percent, 0), 100) * BAR_WIDTH) / 100)
36
37// "ok" while the last response is younger than the cache, "over" after;
38// null before the first response, when there is no cache to speak of.
39export const cacheState = (lastResponseAt: number | null, now: number): 'ok' | 'over' | null =>
40  lastResponseAt === null ? null : now - lastResponseAt < CACHE_TTL_MS ? 'ok' : 'over'
41
42export const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
43
44// The engine's background-work pill as its hint reads it: `1 shell`,
45// `2 shells, 1 monitor`, `1 monitor`, or `3 background tasks` when the kinds
46// mix. null while no shell or monitor Claude started is running. (`ran 2 shell
47// commands` is a finished tool group, not a running shell.)
48const BACKGROUND_PILL = /\b\d+ (?:shells?(?:, \d+ monitors?)?|(?:Artifact comment )?monitors?|background tasks?)\b(?! commands?\b)/
49
50export const backgroundPill = (hint: string): string | null => hint.match(BACKGROUND_PILL)?.[0] ?? null
51
52// Detail levels, most detailed first: bars and reset times, then no bars,
53// then no reset times. The widest that fits `columns` wins.
54export type Detail = 0 | 1 | 2
55
56export const statsWidth = (
57  ctxText: string,
58  limits: readonly { label: string; percentText: string; resetText: string }[],
59  cacheText: string,
60  detail: Detail,
61): number => {
62  const bar = detail === 0 ? BAR_WIDTH + 1 : 0
63  const sep = 5
64  let width = 4 + bar + ctxText.length
65  for (const l of limits) width += sep + l.label.length + 1 + bar + l.percentText.length + (detail < 2 ? l.resetText.length : 0)
66  if (cacheText !== '') width += sep + cacheText.length
67  return width
68}
69
hooks/lib/palette.ts 260 lines
1// Terminal palettes: terminalcolors.com's YAML in, Windows Terminal schemes and
2// OSC sequences out. Pure functions; nothing here touches the engine.
3
4const ANSI = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const
5
6export type Palette = {
7  name: string
8  background: string
9  foreground: string
10  cursor: string
11  accent: string
12  normal: string[]
13  bright: string[]
14}
15
16// The themes' YAML (Warp's format): flat `key: value` lines, nested by indentation.
17export const parseYaml = (text: string): Record<string, string> => {
18  const flat: Record<string, string> = {}
19  const parents: { indent: number; key: string }[] = []
20  for (const line of text.split(/\r?\n/)) {
21    const body = line.trim()
22    if (body === '' || body.startsWith('#')) continue
23    const match = body.match(/^([\w-]+):\s*(.*)$/)
24    if (match === null) continue
25    const [, key = '', raw = ''] = match
26    const indent = line.length - line.trimStart().length
27    while ((parents.at(-1)?.indent ?? -1) >= indent) parents.pop()
28    const value = raw.replace(/^(["'])(.*)\1$/, '$2')
29    if (value === '') parents.push({ indent, key })
30    else flat[[...parents.map(p => p.key), key].join('.')] = value
31  }
32  return flat
33}
34
35export const toPalette = (yaml: string): Palette | null => {
36  const flat = parseYaml(yaml)
37  const normal = ANSI.map(c => flat[`terminal_colors.normal.${c}`] ?? '')
38  const bright = ANSI.map(c => flat[`terminal_colors.bright.${c}`] ?? '')
39  if (!flat.background || !flat.foreground || [...normal, ...bright].some(c => c === '')) return null
40  return {
41    name: flat.name ?? 'Unnamed',
42    background: flat.background,
43    foreground: flat.foreground,
44    cursor: flat.cursor ?? flat.foreground,
45    accent: flat.accent ?? normal[5] ?? flat.foreground,
46    normal,
47    bright,
48  }
49}
50
51// Claude Code themes ------------------------------------------------------------
52
53const rgb = (hex: string): [number, number, number] => {
54  const h = hex.replace('#', '')
55  const full = h.length === 3 ? [...h].map(c => c + c).join('') : h.slice(0, 6)
56  const n = Number.parseInt(full, 16)
57  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
58}
59
60const hex = (c: readonly number[]): string =>
61  `#${c.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('')}`
62
63// `amount` of the way from color `a` to color `b`.
64export const mix = (a: string, b: string, amount: number): string => {
65  const [x, y] = [rgb(a), rgb(b)]
66  return hex(x.map((v, i) => v + ((y[i] ?? v) - v) * amount))
67}
68
69export const isLight = (color: string): boolean => {
70  const [r, g, b] = rgb(color)
71  return 0.2126 * r + 0.7152 * g + 0.0722 * b > 140
72}
73
74export type ClaudeTheme = { name: string; base: 'dark' | 'light'; overrides: Record<string, string> }
75
76// A Claude Code custom theme (~/.claude/themes/<slug>.json) in the palette's
77// colors. Claude Code keeps the terminal's own background; the colors it draws
78// on it (text, accents, borders, diffs, message backgrounds) come from here.
79// Every color token Claude Code has (2.1.292) is set: a token left out keeps
80// the base theme's color, so a partial theme looks like dark/light with a tint.
81export const toClaudeTheme = (p: Palette): ClaudeTheme => {
82  const fg = p.foreground
83  const bg = p.background
84  const [, red = fg, green = fg, yellow = fg, blue = fg, magenta = fg, cyan = fg] = p.normal
85  const [gray = mix(bg, fg, 0.5), redB = red, greenB = green, yellowB = yellow, blueB = blue, magentaB = magenta] = p.bright
86  const orange = mix(red, yellow, 0.5)
87  const indigo = mix(blue, magenta, 0.5)
88  // The lighter (dark themes) or darker (light themes) twin an animation pulses to.
89  const shimmer = (c: string) => mix(c, fg, 0.35)
90  const rainbow = { red, orange, yellow, green, blue: cyan, indigo, violet: magenta }
91  return {
92    name: p.name,
93    base: isLight(bg) ? 'light' : 'dark',
94    overrides: {
95      claude: p.accent,
96      claudeShimmer: shimmer(p.accent),
97      claudeBlue_FOR_SYSTEM_SPINNER: blue,
98      claudeBlueShimmer_FOR_SYSTEM_SPINNER: shimmer(blue),
99      text: fg,
100      inverseText: bg,
101      inactive: gray,
102      inactiveShimmer: shimmer(gray),
103      subtle: mix(bg, fg, 0.3),
104      background: cyan,
105      suggestion: blue,
106      permission: blue,
107      permissionShimmer: shimmer(blue),
108      remember: magenta,
109      skill: magentaB,
110      success: green,
111      error: red,
112      warning: yellow,
113      warningShimmer: shimmer(yellow),
114      planMode: cyan,
115      autoAccept: magenta,
116      autoAcceptShimmer: shimmer(magenta),
117      promptBorder: mix(bg, fg, 0.35),
118      promptBorderShimmer: mix(bg, fg, 0.6),
119      bashBorder: magenta,
120      ide: blue,
121      merged: magenta,
122      professionalBlue: blueB,
123      chromeYellow: yellowB,
124      fastMode: orange,
125      fastModeShimmer: shimmer(orange),
126      effortUltra: magenta,
127      briefLabelYou: blue,
128      briefLabelClaude: p.accent,
129      clawd_body: p.accent,
130      clawd_background: bg,
131      rate_limit_fill: blue,
132      rate_limit_empty: mix(bg, blue, 0.3),
133      red_FOR_SUBAGENTS_ONLY: red,
134      blue_FOR_SUBAGENTS_ONLY: blue,
135      green_FOR_SUBAGENTS_ONLY: green,
136      yellow_FOR_SUBAGENTS_ONLY: yellow,
137      purple_FOR_SUBAGENTS_ONLY: magenta,
138      orange_FOR_SUBAGENTS_ONLY: orange,
139      pink_FOR_SUBAGENTS_ONLY: magentaB,
140      cyan_FOR_SUBAGENTS_ONLY: cyan,
141      ...Object.fromEntries(
142        Object.entries(rainbow).flatMap(([name, c]) => [
143          [`rainbow_${name}`, c],
144          [`rainbow_${name}_shimmer`, shimmer(c)],
145        ]),
146      ),
147      userMessageBackground: mix(bg, fg, 0.08),
148      userMessageBackgroundHover: mix(bg, fg, 0.12),
149      composerSidebarBackground: mix(bg, fg, 0.05),
150      bashMessageBackgroundColor: mix(bg, magenta, 0.08),
151      memoryBackgroundColor: mix(bg, cyan, 0.08),
152      selectionBg: mix(bg, blue, 0.35),
153      diffAdded: mix(bg, green, 0.22),
154      diffRemoved: mix(bg, red, 0.22),
155      diffAddedDimmed: mix(bg, green, 0.12),
156      diffRemovedDimmed: mix(bg, red, 0.12),
157      diffAddedWord: mix(bg, greenB, 0.45),
158      diffRemovedWord: mix(bg, redB, 0.45),
159    },
160  }
161}
162
163// Windows Terminal's settings.json is JSON with comments and trailing commas.
164export const parseJsonc = (text: string): unknown => {
165  let out = ''
166  let inString = false
167  for (let i = 0; i < text.length; i++) {
168    const c = text[i]
169    if (inString) {
170      out += c
171      if (c === '\\') out += text[++i] ?? ''
172      else if (c === '"') inString = false
173    } else if (c === '"') {
174      inString = true
175      out += c
176    } else if (c === '/' && text[i + 1] === '/') {
177      while (i < text.length && text[i] !== '\n') i++
178      out += '\n'
179    } else if (c === '/' && text[i + 1] === '*') {
180      i = text.indexOf('*/', i + 2)
181      if (i < 0) break
182      i++
183    } else out += c
184  }
185  return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
186}
187
188export type WtProfile = { guid?: string; colorScheme?: unknown }
189export type WtSettings = {
190  schemes?: Record<string, unknown>[]
191  profiles?: { defaults?: WtProfile; list?: WtProfile[] } | WtProfile[]
192}
193
194// The profile this Claude Code runs in, else the defaults every profile inherits.
195export const targetProfile = (settings: WtSettings, profileId: string | null): WtProfile => {
196  const profiles = settings.profiles
197  const list = Array.isArray(profiles) ? profiles : (profiles?.list ?? [])
198  const id = profileId?.toLowerCase()
199  const mine = id ? list.find(p => p.guid?.toLowerCase() === id) : undefined
200  if (mine !== undefined) return mine
201  if (Array.isArray(profiles) || profiles === undefined) settings.profiles = { defaults: {}, list }
202  const holder = settings.profiles as { defaults?: WtProfile }
203  holder.defaults ??= {}
204  return holder.defaults
205}
206
207// The color scheme the mod writes into Windows Terminal's settings: the kept
208// theme, put on the profile Claude Code runs in while a session lasts.
209export const WT_SCHEME = 'extra-mods'
210
211const profileList = (settings: WtSettings): WtProfile[] =>
212  Array.isArray(settings.profiles) ? settings.profiles : (settings.profiles?.list ?? [])
213
214export const findProfile = (settings: WtSettings, profileId: string): WtProfile | undefined =>
215  profileList(settings).find(p => p.guid?.toLowerCase() === profileId.toLowerCase())
216
217// Themes `profile` with the palette, or with null gives it back `original`,
218// the scheme it had (undefined: none of its own, so it follows the defaults).
219// The mod's scheme stays in the settings only while some profile uses it.
220export const withProfileTheme = (settings: WtSettings, profile: WtProfile, palette: Palette | null, original?: unknown): void => {
221  if (palette !== null) profile.colorScheme = WT_SCHEME
222  else if (original === undefined) delete profile.colorScheme
223  else profile.colorScheme = original
224  const schemes = (settings.schemes ?? []).filter(s => s.name !== WT_SCHEME)
225  const defaults = Array.isArray(settings.profiles) ? undefined : settings.profiles?.defaults
226  const inUse = [...profileList(settings), defaults].some(p => p?.colorScheme === WT_SCHEME)
227  if (palette !== null) schemes.push({ ...toWtScheme(palette), name: WT_SCHEME })
228  settings.schemes = palette !== null || !inUse ? schemes : (settings.schemes ?? [])
229}
230
231export const toWtScheme = (p: Palette): Record<string, string> => {
232  const names = ['black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white']
233  const scheme: Record<string, string> = {
234    name: p.name,
235    background: p.background,
236    foreground: p.foreground,
237    cursorColor: p.cursor,
238    selectionBackground: p.bright[0] ?? p.foreground,
239  }
240  names.forEach((n, i) => {
241    scheme[n] = p.normal[i] ?? p.foreground
242    scheme[`bright${n.charAt(0).toUpperCase()}${n.slice(1)}`] = p.bright[i] ?? p.foreground
243  })
244  return scheme
245}
246
247const OSC = (body: string) => `\u001b]${body}\u0007`
248
249// The sequences that recolor the terminal (null resets it to its own colors).
250export const oscFor = (p: Palette | null): string =>
251  p === null
252    ? OSC('104') + OSC('110') + OSC('111') + OSC('112')
253    : [...p.normal, ...p.bright].map((c, i) => OSC(`4;${i};${c}`)).join('') +
254      OSC(`10;${p.foreground}`) +
255      OSC(`11;${p.background}`) +
256      OSC(`12;${p.cursor}`)
257
258export const matchAll = (html: string, re: RegExp): string[] =>
259  [...html.matchAll(re)].flatMap(m => (m[1] === undefined ? [] : [m[1]]))
260
hooks/lib/zen.ts 96 lines
1// Zen mode's look and arithmetic. Pure; the hooks are in register.tsx.
2
3import type { StepStatus, ZenColors, ZenTask, ZenTheme } from '../../types'
4
5export const ZEN_THEMES: readonly ZenTheme[] = ['blue', 'dark', 'light', 'green', 'rainbow', 'custom']
6
7export const RAINBOW = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7', '#ec4899']
8
9const PRESETS: Record<Exclude<ZenTheme, 'custom'>, ZenColors> = {
10  blue: { bar: '#3b82f6', text: '#bfdbfe', border: '#3b82f6' },
11  dark: { bar: '#e5e7eb', text: '#d1d5db', border: '#6b7280' },
12  light: { bar: '#2563eb', text: '#1f2937', border: '#9ca3af' },
13  green: { bar: '#22c55e', text: '#bbf7d0', border: '#16a34a' },
14  rainbow: { bar: '#ef4444', text: '#f9a8d4', border: '#ec4899' },
15}
16
17export const ZEN_TOOL_NAME = 'zen_progress'
18
19export const ZEN_PROMPT = [
20  'Zen mode is on: the user sees only your text messages, not your tool calls or their output.',
21  'They follow your work on a progress band fed by the zen_progress tool (mcp__extra-mods__zen_progress).',
22  'Before you start, call it with your plan as a list of short steps. Call it again with the whole list each time a step starts or finishes,',
23  'so exactly one step is in_progress while you work and every step is completed when you are done.',
24  'Before acting, say in a sentence what you are about to do and why.',
25].join(' ')
26
27export const ZEN_TOOL_DESCRIPTION =
28  'Updates the Zen mode progress band the user watches. Send your whole plan each call: every step with its status ' +
29  '(pending, in_progress or completed). Call it when you start a task and whenever a step starts or finishes.'
30
31export const ZEN_TOOL_SCHEMA = {
32  type: 'object',
33  properties: {
34    title: { type: 'string', description: 'Optional short name for the whole task.' },
35    steps: {
36      type: 'array',
37      description: 'The full plan, in order.',
38      items: {
39        type: 'object',
40        properties: {
41          text: { type: 'string', description: 'The step, in a few words.' },
42          status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
43        },
44        required: ['text', 'status'],
45      },
46    },
47  },
48  required: ['steps'],
49}
50
51export const colorsFor = (theme: ZenTheme, custom: ZenColors): ZenColors =>
52  theme === 'custom' ? custom : PRESETS[theme]
53
54export const percentOf = (t: ZenTask): number => {
55  if (t.doneAt !== null) return 100
56  if (t.steps.length === 0) return 0
57  const done = t.steps.filter(s => s.status === 'completed').length
58  return Math.round((done / t.steps.length) * 100)
59}
60
61const STATUSES: readonly StepStatus[] = ['pending', 'in_progress', 'completed']
62export const asStatus = (s: unknown): StepStatus =>
63  STATUSES.includes(s as StepStatus) ? (s as StepStatus) : 'pending'
64
65export const elapsed = (ms: number): string => {
66  const s = Math.max(0, Math.floor(ms / 1000))
67  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
68}
69
70export const stepLabel = (t: ZenTask): string => {
71  if (t.doneAt !== null) return 'Done'
72  if (t.steps.length === 0) return 'Working…'
73  const current = t.steps.findIndex(s => s.status === 'in_progress')
74  const done = t.steps.filter(s => s.status === 'completed').length
75  return `Step ${Math.min(t.steps.length, (current >= 0 ? current : done) + 1)} of ${t.steps.length}`
76}
77
78// How many cells of each color a bar `width` wide fills at `percent`.
79export const barCells = (theme: ZenTheme, bar: string, width: number, percent: number) => {
80  const filled = Math.round((width * Math.max(0, Math.min(100, percent))) / 100)
81  if (theme !== 'rainbow') return { filled, runs: filled > 0 ? [{ color: bar, cells: filled }] : [] }
82  const runs = RAINBOW.map((color, i) => {
83    const from = Math.floor((width * i) / RAINBOW.length)
84    const to = Math.floor((width * (i + 1)) / RAINBOW.length)
85    return { color, cells: Math.max(0, Math.min(to, filled) - from) }
86  }).filter(r => r.cells > 0)
87  return { filled, runs }
88}
89
90// At most `room` steps, keeping the current one in view.
91export const visibleSteps = (t: ZenTask, room = 6) => {
92  const current = t.steps.findIndex(s => s.status === 'in_progress')
93  const first = Math.max(0, Math.min(current - 2, t.steps.length - room))
94  return t.steps.slice(first, first + room)
95}
96
types/index.d.ts 65 lines
1export type Panel = 'main' | 'zen' | 'themes' | 'mode' | 'status'
2
3export type ZenTheme = 'blue' | 'dark' | 'light' | 'green' | 'rainbow' | 'custom'
4
5export type ZenColors = { bar: string; text: string; border: string }
6
7export type StepStatus = 'pending' | 'in_progress' | 'completed'
8
9export type ZenStep = { id: string; text: string; status: StepStatus }
10
11export type ZenTask = {
12  title: string
13  startedAt: number
14  doneAt: number | null
15  steps: ZenStep[]
16}
17
18export type ThemeEntry = { slug: string; name: string }
19
20export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
21
22export type RateLimit = { label: string; percent: number; resetsAt: number | null }
23
24// What the status line draws, refreshed after each model response and on a timer.
25export type StatusSnapshot = {
26  model: string
27  dir: string
28  branch: string
29  ctxPercent: number | null
30  ctxTokens: number | null
31  ctxWindow: number | null
32  limits: RateLimit[]
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    'extra-mods': {
38      menu: Panel | null
39      zenOn: boolean
40      zenTheme: ZenTheme
41      zenCustom: ZenColors
42      zenCollapsed: boolean
43      task: ZenTask | null
44      now: number
45      themes: ThemeEntry[]
46      themeName: string | null
47      themeQuery: string
48      themeStatus: string
49      effort: Effort | null
50      mode: string | null
51      defaultMode: string | null
52      preview: string | null
53      isDownloading: boolean
54      statusOn: boolean
55      status: StatusSnapshot | null
56      lastResponseAt: number | null
57      statusClock: number
58      sessionEffort: Effort | null
59      sessionModel: string | null
60      paneBg: string | null
61      defaultModeChosen: boolean
62    }
63  }
64}
65