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…

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:
| Tool | What it does |
|---|---|
| Zen mode | Hides tool calls and their output, and shows Claude's plan as a progress band above the prompt |
| Theme | Colors Claude Code with any theme from terminalcolors.com, with live preview |
| Mode | Sets the model, the effort level, and the permission mode new sessions start in |
| Status line | Model, folder, branch, context, 5h/7d limits and cache state, with the button on the same row |
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
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.
- 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.- Add this repository as a plugin marketplace: run
claude plugin marketplace add Jhonatan-de-Souza/ClaudeMods. It should printSuccessfully added marketplace: jdsjh-mods. If the marketplace already exists, continue.- Install the plugin for all sessions: run
claude plugin install extra-mods@jdsjh-mods --scope user. It should printSuccessfully installed plugin: extra-mods@jdsjh-mods.- 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.pyfrom this repository to~/.claude/statusline.py. Then in~/.claude/settings.jsonset"statusLine": { "type": "command", "command": "python ~/.claude/statusline.py", "refreshInterval": 2 }(usepython3on macOS and Linux). It needs Python 3.- Verify: run
claude plugin list.extra-modsmust be listed and enabled.- Tell the user to run
/reload-pluginsor 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.
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.
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.[–] shrinks it to one line.blue, dark, light, green, rainbow, or custom with your own hex colors.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.
~/.claude/extra-mods/themes/.~/.claude/themes/extra-mods.json, and is set as theme in ~/.claude/settings.json. Claude Code reloads it live and remembers it across sessions.settings.json is backed up first, as settings.json.extra-mods.bak.~/.claude/themes/ when that setting is already a custom theme. After that, picking a theme changes Claude Code live./model, so the switch is real: /model and the status line show it too.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.permissions.defaultMode in ~/.claude/settings.json (backed up first).1 shell, 1 monitor). Click it to open /tasks.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).
claude --plugin-dir ./plugins/extra-mods
claude plugin validate ./plugins/extra-mods
claude plugin test ./plugins/extra-modshooks/register.tsx 1348 lines1import { 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 movedhooks/lib/modes.ts 31 lines1import 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
31hooks/lib/status.ts 69 lines1// 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}
69hooks/lib/palette.ts 260 lines1// 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]]))
260hooks/lib/zen.ts 96 lines1// 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}
96types/index.d.ts 65 lines1export 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