Learn English from your daily Claude Code use: a sidebar with an ESOL coach that logs nativized corrections of your prompts, plus Focus mode, terminal themes…

Learn English from your daily Claude Code use.
ESOL stands for English for Speakers of Other Languages. cc-esol is a sidebar for Claude Code with a passive English coach: you keep prompting as you always do, and the English you type or dictate is corrected into a learning log you read when you want. The same sidebar holds Focus mode, terminal themes, model and effort controls, a status line editor and a voice toggle.
~/.claude/english-learning/learning.md.<img src="docs/screenshots/esol-tab.png" alt="The ESOL tab: the latest corrections, one open with the original, the native version and the reasons" width="420">
Prompts in your own language are left alone, and so are slash commands, short replies ("yes", "continue") and prompts written in another script. Already-natural English is not logged. Missing apostrophes in contractions ("dont", "im", "thats") are ignored as typing speed; the word choices (its/it's, your/you're, their/they're) are coached. A single word from your language inside an English prompt is treated as a vocabulary gap: the entry gives you the English term.
The fastest way to practice is to dictate. Claude Code has voice dictation of its own (/voice), and the Voice button beside the corner button turns it on and off and shows its state. The coach corrects the English of the transcript, not your pronunciation; a word the recognizer probably misheard is tagged [STT?] instead of being counted as a grammar mistake. Keep the dictation language set to English in /config: speaking your own language with it produces garbled text, which nudges you toward English.
Voice dictation is Claude Code's feature, not this plugin's. It needs a claude.ai sign-in (it does not work with an API key, Bedrock, Vertex AI or Foundry), a local microphone (not over SSH or on the web) and Claude Code 2.1.69 or newer; transcription itself costs no tokens. Details in Claude Code's voice dictation docs.
Where your words go
~/.claude/english-learning/learning.md, shared by every project by default. Set ENGLISH_COACH_LOG to move it, for instance into a synced notes folder. The plugin sends nothing anywhere on its own.english_log tool that is part of your conversation. Your prompt and its correction travel through the same API as the rest of the session, nowhere else.~/.claude/plugins/store/cc-esol_*.json. The /cc-esol:english-review command does the same in the transcript, listing every entry.What it costs (estimates, at about four characters per token)
english_log tool's description, sent with the tool list and cached; about 40 tokens beside each English prompt; and each correction is one tool call of roughly 150 to 300 output tokens that stays in the conversation. Off: nothing, the tool waits behind ToolSearch.focus_progress tool, also cached; about 35 tokens beside each prompt; and Claude's updates, roughly 100 output tokens for a plan plus 25 per step, sent alongside its other tool calls. Off: nothing.Click 💡 esol at the bottom right, or type /esol (also /esol model, /esol settings, /esol themes, /esol focus, /esol statusline, /esol editor). Three tabs: ESOL, Model, Settings. Tab moves, Enter selects, Esc closes. Beside the corner button, Model jumps to the Model page and Voice toggles dictation.
<img src="docs/screenshots/footer.png" alt="The footer: ccstatusline's three lines, then the corner button, Model and Voice on" width="720">
The model (Fable, Opus, Sonnet, Haiku, or Claude Code's default), the effort from low to max, and the permission mode new sessions start in. The model switch runs Claude Code's own /model; the effort applies to every request and is remembered; the running session's mode changes with shift+tab, the pick here is saved as permissions.defaultMode.
<img src="docs/screenshots/model.png" alt="The Model page: model chips, effort chips low to max, the running mode and the mode new sessions start in" width="420">
A band above the prompt with the task's title, a clock, a progress bar and the steps. While Focus is On, Claude posts its plan to the band's own focus_progress tool before a task of more than two tool calls, then the number of each step as it starts, so each line moves from pending to running to done; a todo list, the task tools or a plan approved in plan mode feed the band too where the session has them. Under the running step, a feed of the last tool calls as Claude itself described them ("Read the settings page and its tests"): click a row, or reach it with ctrl+x tab, to open what the call ran and the first lines it printed, and again to close it; the calls stay until your next prompt. Tool calls and tool results are hidden from the transcript while Focus is On, except a call that failed or was interrupted; ctrl+o shows every call in full. The turn's end with steps still pending pauses the band, and a turn that was interrupted or failed says so; your next prompt resumes it. [–] shrinks it to one line. Colors: blue, dark, light, green, pink, rainbow or a custom one.
<img src="docs/screenshots/focus-band.png" alt="The Focus band above the prompt: the task's title, clock and call count, the progress bar, the steps with their clocks, the running step tinted, and its calls under it" width="860">
In the fullscreen layout, three open Claude Code issues (#102893, #104197, #104462) can make the wheel jump or the last row blink on hidden rows.
Over a hundred terminal color themes from terminalcolors.com, downloaded once to ~/.claude/cc-esol/themes/. Click one: Claude Code's colors follow (written as ~/.claude/themes/cc-esol.json and set in ~/.claude/settings.json; restart once the first time, live after that) and so does the terminal's background on Windows Terminal (for the session, then restored) and on terminals that take OSC colors (Ghostty, iTerm2, kitty, WezTerm, Terminal.app). Claude Code default puts the original colors back.
<img src="docs/screenshots/themes.png" alt="The Themes page: a searchable list of themes, each with its eight-color swatch, the kept one marked" width="420">
The Status line page installs, checks and removes ccstatusline, a third-party status line. Install writes the statusLine block into ~/.claude/settings.json (keeping what was there, for Uninstall) and a starter config where none exists; a status line you set yourself is shown and left alone. Layout Editor edits ~/.config/ccstatusline/settings.json in place: lines, widgets, colors, powerline and layout, with a live preview drawn by your own status line command. Save dry-runs the candidate the same way, backs the file up and replaces it in one step, keeping everything it does not edit. Details in docs/statusline.md.
<img src="docs/screenshots/statusline.png" alt="The Status line page: Layout Editor, Check version, Remove the statusLine setting, and the ccstatusline disclaimer" width="420"> <img src="docs/screenshots/layout-editor.png" alt="The Layout Editor: the three lines, the widgets of one with their colors, the live preview" width="420">
The Focus switch and its color, the theme as a chip with its name, the status line, and the icon set (Nerd Font, Unicode, or Auto: Nerd Font when your ccstatusline config draws powerline, so no font is ever installed or required).
<img src="docs/screenshots/settings.png" alt="The Settings tab: Focus On/Off and the Focus color chip, the theme as a chip with its name, the status line chip, and the icon set last" width="420">
Claude Code 2.1.299 or newer, on macOS, Linux or Windows. Developed and tested on 2.1.306.
claude plugin marketplace add rapha-rodrigues/cc-esol
claude plugin install cc-esol@esol --scope user
Run /reload-plugins or start a new session, then click 💡 esol or type /esol. To update:
claude plugin marketplace update esol && claude plugin update cc-esol@esol
Upgrading from a --plugin-dir checkout: the plugin's store is keyed by where the plugin was installed from, so the marketplace install starts with default settings (coaching Off, no kept review). The learning log is untouched: it lives outside the store.
Point your coding agent at this repository and ask it to install the plugin. Steps for the agent:
claude --version; it must be 2.1.299 or higher.claude plugin marketplace add rapha-rodrigues/cc-esol.claude plugin install cc-esol@esol --scope user.claude plugin list shows cc-esol enabled, then tell the user to run /reload-plugins.On Windows, use forward slashes in paths when running in Git Bash.
Twenty languages to pick from in the ESOL settings, or Other with the name typed in. The language only tells the coach whose interference habits to look for; the gate that decides whether a prompt is English knows nothing about your language, so it works for any. The sidebar itself speaks English.
/reload-plugins after installing or updating./voice is unavailable. Sign in with a claude.ai account (/login); dictation does not work with an API key or a third-party provider.cc-plugin-sec-default) keeps from user plugins on Team and Enterprise logins, and the guard said so on every prompt and tool call. Update the plugin and the notice is gone; cc-esol works the same on every account.claude --plugin-dir ./plugins/cc-esol
claude plugin validate ./plugins/cc-esol
claude plugin test ./plugins/cc-esol
Releases follow docs/releasing.md; changes are in CHANGELOG.md.
MIT.
ccstatusline is a separate project by Matthew Breedlove, MIT-licensed; nothing of it ships in this plugin, which only recommends it, installs it and edits its config. The terminal themes come from terminalcolors.com and are downloaded at runtime, on your request; none of them ships here either.
hooks/register.tsx 312 lines1// The hooks module: the pane and its tabs, the session's lifecycle, the
2// prompt and turn hooks, and the tracking of the model, effort and mode. The
3// pages draw themselves; this file routes to them.
4
5import type { EngineInterface, On, Register, RenderChildren, RenderInput } from 'claude-code'
6
7import type { Panel } from '../types'
8import { clearEnglishPending, drawEnglishTab, focusEsol, loadEnglish, registerEnglish, registerEnglishTool, scrollEsol, withCoaching } from './english'
9import { loadIcons } from './icons'
10import { phrase, type Phrase } from './lib/i18n'
11import { drawCcslPage, focusCcsl, isCcslPanel, openEditor, scrollCcsl } from './pages/ccsl-editor'
12import { drawFocusBandPage, focusPromptSubmit, focusSessionEnd, focusTurnComplete, loadFocus, registerFocus, registerFocusTool } from './pages/focus'
13import { drawModeTab, loadModel, readEffort, trackEffort, trackModel } from './pages/model'
14import { drawSettingsPage } from './pages/settings'
15import { drawStatusLinePage, loadStatusLine } from './pages/statusline'
16import { drawThemesTab, focusThemes, loadThemes, openThemes, readPaneBg, refreshPaneBg, scrollThemesBy, themesSessionEnd } from './pages/themes'
17import { drawPromptHint, frame, markRestored, MENU_NAME, PANE, PANE_BACKGROUND, PANE_SIZE, readLang, readMenu, readRestored, setMenu } from './ui/frame'
18import { loadVoice, registerVoice, toggleVoice } from './voice'
19
20// Pages ------------------------------------------------------------------------
21
22type Tab = 'esol' | 'model' | 'settings'
23const TABS: readonly { tab: Tab; label: Phrase }[] = [
24 { tab: 'esol', label: 'tabEsol' },
25 { tab: 'model', label: 'tabModel' },
26 { tab: 'settings', label: 'tabSettings' },
27]
28
29// The tab a page lights: the sub-pages belong to Settings.
30const tabOf = (panel: Panel): Tab => {
31 if (panel === 'main' || panel === 'esol') return 'esol'
32 if (panel === 'model') return 'model'
33 return 'settings'
34}
35
36// Pages whose own list scrolls inside a pane of fixed height.
37const SCROLLING_PAGES: readonly Panel[] = ['themes', 'esol', 'ccsl-editor', 'ccsl-add']
38
39// Some pages do work when opened.
40async function arriveAt($: EngineInterface, panel: Panel) {
41 if (panel === 'themes') await openThemes($)
42 if (panel === 'ccsl-editor') await openEditor($)
43}
44
45async function showPage($: EngineInterface, panel: Panel) {
46 await setMenu($, panel)
47 await arriveAt($, panel)
48}
49
50// The pane opened on a page, or closed.
51async function toggleMenu($: EngineInterface, panel: Panel = 'esol') {
52 if ((await readMenu($)) !== null) {
53 // The plugin's own close skips its own ui.close hook: the page resets here too.
54 await $.ui.close({ id: PANE })
55 await setMenu($, null)
56 return
57 }
58 await showPage($, panel)
59 await $.ui.open({ id: PANE, closeOnEscape: true, focus: true, title: MENU_NAME, ...PANE_SIZE })
60}
61
62// A shortcut button: opens the pane on its page, moves there, or closes it when already there.
63async function jumpTo($: EngineInterface, panel: Panel) {
64 const page = await readMenu($)
65 if (page === null) await toggleMenu($, panel)
66 else if (page !== panel) await showPage($, panel)
67 else await toggleMenu($)
68}
69
70// The tab strip: the open tab in the accent color with a mark, the others
71// dim, the close at the right; under them a rule, solid under the open tab.
72function drawTabStrip($: EngineInterface, e: RenderInput<'Pane'>, open: Tab, t: (key: Phrase) => string, columns: number, close: () => unknown) {
73 const { Box, Text, Button } = $.ui.resolve(e)
74 const labels = TABS.map(({ tab, label }) => ({ tab, text: tab === open ? `◆ ${t(label)}` : t(label) }))
75 const width = Math.max(10, columns - 2)
76 const underline = labels.map(({ tab, text }) => (tab === open ? '━'.repeat(text.length + 2) : '─'.repeat(text.length + 2))).join('─')
77 return (
78 <Box key="tabs" flexDirection="column">
79 <Box gap={1}>
80 {labels.map(({ tab, text }) => (
81 <Button key={`tab-${tab}`} plain label={t(TABS.find(x => x.tab === tab)!.label)} autoFocus={tab === open ? true : undefined} onPress={() => showPage($, tab)}>
82 {tab === open ? (
83 <Text bold color="claude">{` ${text} `}</Text>
84 ) : (
85 <Text dimColor>{` ${text} `}</Text>
86 )}
87 </Button>
88 ))}
89 <Box flexGrow={1} />
90 <Button key="esol-close" plain dimColor label={t('close')} onPress={close} />
91 </Box>
92 <Text dimColor>
93 {underline}
94 {'─'.repeat(Math.max(0, width - underline.length))}
95 </Text>
96 </Box>
97 )
98}
99
100// The pane: the tab strip, the page, the footer with the keys.
101async function drawSidebar($: EngineInterface, e: RenderInput<'Pane'>, panel: Panel) {
102 const drawable = e.surface === 'terminal' || e.surface === 'desktop'
103 if (!drawable) return null
104 const { Box, Text } = $.ui.resolve(e)
105 const page: Panel = panel === 'main' ? 'esol' : panel
106 const lng = await readLang($)
107 const t = (key: Phrase, vars?: Record<string, string | number>) => phrase(lng, key, vars)
108 const columns = e.props.bodyColumns ?? PANE_SIZE.columns
109 const ui = frame($, e, lng, t, columns)
110 const go = (to: Panel) => showPage($, to)
111
112 let body: RenderChildren[]
113 if (page === 'model') body = await drawModeTab($, e, ui)
114 else if (page === 'esol') body = await drawEnglishTab($, e, { heading: ui.heading, chip: ui.chip, chips: ui.chips, t, columns })
115 else if (page === 'settings') body = await drawSettingsPage($, e, ui, go)
116 else if (page === 'focus-band') body = await drawFocusBandPage($, e, ui, go)
117 else if (page === 'themes') body = await drawThemesTab($, e, ui, go)
118 else if (page === 'statusline') body = await drawStatusLinePage($, e, ui, go)
119 else if (isCcslPanel(page)) body = await drawCcslPage($, e, ui, go, page)
120 else body = []
121
122 const scrolls = SCROLLING_PAGES.includes(page)
123 return (
124 <Box
125 flexDirection="column"
126 paddingX={1}
127 backgroundColor={(await readPaneBg($)) ?? PANE_BACKGROUND}
128 height={scrolls ? e.props.scroll.bodyRows : undefined}
129 width={e.props.bodyColumns}
130 minHeight={scrolls ? undefined : e.props.scroll.bodyRows}
131 >
132 {drawTabStrip($, e, tabOf(panel), t, columns, () => toggleMenu($))}
133 <Box flexDirection="column" flexGrow={1} flexShrink={1} overflow={scrolls ? 'hidden' : undefined}>
134 {body}
135 </Box>
136 <Box key="footer" flexDirection="column" marginTop={1} flexShrink={0}>
137 {ui.rule}
138 <Text dimColor>{t('keys')}</Text>
139 </Box>
140 </Box>
141 )
142}
143
144// Lifecycle --------------------------------------------------------------------
145
146// The kept settings into this session's values: at its start, and again
147// after a /clear, which starts a session without a session.start.
148async function restore($: EngineInterface) {
149 await loadFocus($)
150 await loadModel($)
151 await loadThemes($)
152 await loadStatusLine($)
153 await loadEnglish($)
154 await loadVoice($)
155 await loadIcons($)
156 try {
157 const id = await $.session.model()
158 await trackModel($, id)
159 } catch {}
160 await refreshPaneBg($)
161 await markRestored($)
162}
163
164const panelFor = (arg: string): Panel => {
165 switch (arg) {
166 case 'model':
167 case 'settings':
168 case 'themes':
169 case 'statusline':
170 return arg
171 case 'status':
172 return 'statusline'
173 case 'focus':
174 return 'focus-band'
175 case 'editor':
176 return 'ccsl-editor'
177 default:
178 return 'esol'
179 }
180}
181
182function registerLifecycle(on: On) {
183 on('session.start', async ($, e, next) => {
184 const started = await next(e)
185 await restore($)
186 await registerEnglishTool($)
187 await registerFocusTool($)
188 await $.command.register({
189 name: 'esol',
190 description: 'Open the sidebar: model and effort, English coach, settings',
191 argumentHint: '[model|esol|settings|themes|focus|statusline|editor|debug]',
192 immediate: true,
193 })
194 return started
195 })
196
197 // A /clear ends the session and starts one with no session.start: the
198 // pane, which outlives the session, is closed here, and the kept settings
199 // come back at the first prompt (restore). Seen at session.end, which
200 // reaches a user's mod on every account; the classic SessionStart event
201 // does not on a Team or Enterprise login, where Claude Code's built-in
202 // guard keeps the classic events from user mods.
203 on('session.end', async ($, e, next) => {
204 if (e.reason === 'clear') {
205 await $.ui.close({ id: PANE }).catch(() => {})
206 await setMenu($, null)
207 focusSessionEnd()
208 } else await themesSessionEnd($)
209 return next(e)
210 }).catch(($, e, next) => next(e))
211
212 on('command.run', { command: 'esol' }, async ($, e) => {
213 const arg = e.args.trim().toLowerCase()
214 if (arg === 'debug') {
215 const settings = await $.config.list()
216 const lines = settings.map(row => `${row.key} (${row.kind}) = ${JSON.stringify(row.value)}`)
217 return { text: lines.join('\n') }
218 }
219 const panel = panelFor(arg)
220 if ((await readMenu($)) === null) await toggleMenu($, panel)
221 else await showPage($, panel)
222 return { text: 'The sidebar is open.' }
223 })
224}
225
226// The pane -------------------------------------------------------------------------
227
228// The pane body's rows as last drawn, for the focus hook's measure of a list.
229let bodyRowsSeen: number | undefined
230
231function registerPane(on: On) {
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 bodyRowsSeen = e.props.scroll.bodyRows
234 const panel = await readMenu($)
235 return (panel === null ? null : await drawSidebar($, e, panel)) ?? $.ui.resolve(e).Text({ children: '' })
236 })
237
238 // However the pane closes (esc, its close mark, the lamp), the page resets.
239 on('ui.close', { id: PANE }, async ($, e, next) => {
240 const closed = await next(e)
241 await setMenu($, null)
242 return closed
243 }).catch(($, e, next) => next(e))
244
245 // The wheel moves a page's own list, never the pane, so the tabs stay put.
246 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
247 if (e.by === 0) return next(e)
248 const page = await readMenu($)
249 if (page === 'esol') await scrollEsol($, e.by, e.bodyRows)
250 else if (page === 'themes') await scrollThemesBy($, e.by, e.bodyRows)
251 else if (page !== null && isCcslPanel(page)) await scrollCcsl($, page, e.by, e.bodyRows)
252 else return next(e)
253 return {}
254 }).catch(($, e, next) => next(e))
255
256 // The focus ring reaching a row outside a list's window brings it into view.
257 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
258 const moved = await next(e)
259 const page = await readMenu($)
260 const rows = bodyRowsSeen ?? PANE_SIZE.rows
261 if (page === 'esol') await focusEsol($, e.element, rows)
262 else if (page === 'themes') await focusThemes($, e.element, rows)
263 else if (page !== null && isCcslPanel(page)) await focusCcsl($, page, e.element, rows)
264 return moved
265 }).catch(($, e, next) => next(e))
266
267 on('ui.render', { component: 'PromptHint' }, async ($, e) => drawPromptHint($, e, () => toggleMenu($), () => jumpTo($, 'model'), () => toggleVoice($)))
268}
269
270// Prompts, turns and tracking -----------------------------------------------------
271
272function registerTurns(on: On) {
273 // The prompt as submitted: the ESOL coaching, then Focus's task.
274 on('prompt.submit', async ($, e, next) => {
275 // After a /clear every session value is at its default: restore here
276 // (a /clear starts a session without a session.start).
277 if (!(await readRestored($))) await restore($)
278 return next(await focusPromptSubmit($, await withCoaching($, e)))
279 }).catch(($, e, next) => next(e))
280
281 // The main loop's end, and why it ended: a subagent's end (e.agentId) is
282 // heard too and is not the turn's.
283 on('turn.complete', async ($, e, next) => {
284 if ((e as { agentId?: string }).agentId === undefined) {
285 await focusTurnComplete($, (e as { reason?: 'answer' | 'aborted' | 'refusal' | 'error' }).reason ?? 'answer')
286 await clearEnglishPending($)
287 }
288 return next(e)
289 })
290
291 // The chosen effort rides on every request of the main loop; the model and
292 // the level the engine would have used are noted for the Model page.
293 on('turn.step', async function* ($, e, next) {
294 const level = await readEffort($)
295 const mainLoop = e.agentId === undefined
296 if (mainLoop) {
297 await trackEffort($, e.effort)
298 await trackModel($, e.model)
299 }
300 return yield* next(level !== null && e.effort !== undefined ? { ...e, effort: level } : e)
301 })
302}
303
304export const register: Register = on => {
305 registerFocus(on)
306 registerEnglish(on)
307 registerVoice(on)
308 registerLifecycle(on)
309 registerPane(on)
310 registerTurns(on)
311}
312hooks/english.tsx 591 lines1// The English tab: passive American English coaching for someone whose first
2// language is not English. Every prompt in English runs as usual; the rules
3// travel in the english_log tool's description while coaching is on, a
4// one-line marker rides beside each prompt the gate passed, and Claude logs
5// a nativized correction through that tool. The gate, the contract and the
6// formats are in ./lib/english.ts.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, On, PromptSubmitInput, RenderChildren, RenderInput } from 'claude-code'
10
11import type { EnglishLog, EnglishLogEntry, EnglishPending, EsolReview, EsolView } from '../types'
12import {
13 appendArgv,
14 appendWinArgv,
15 asEntryInput,
16 basename,
17 classify,
18 coachingContract,
19 DEFAULT_EN_CUTOFF,
20 DEFAULT_MIN_WORDS,
21 DEFAULT_NATIVE,
22 LANGUAGES,
23 ENGLISH_TOOL,
24 ENGLISH_TOOL_NAME,
25 ENGLISH_TOOL_SCHEMA,
26 formatEntry,
27 MAX_LIST,
28 MAX_PREVIEW_BYTES,
29 MIN_WORDS_CHOICES,
30 parseLog,
31 EN_CUTOFFS,
32 resolveLogPath,
33 reviewPrompt,
34 reviewTail,
35 shortDate,
36 timestamp,
37 wrapLines,
38} from './lib/english'
39import type { Phrase } from './lib/i18n'
40import { homeDir, isWindows } from './lib/platform'
41import { readableModel } from './lib/session-controls'
42import { TAB_STRIP_ROWS } from './ui/frame'
43
44// Session values; the settings among them are mirrored to $.store.
45export const englishOn = atom({ plugin: 'cc-esol', key: 'englishOn' } as const, false)
46export const englishEnCutoff = atom({ plugin: 'cc-esol', key: 'englishEnCutoff' } as const, DEFAULT_EN_CUTOFF)
47export const englishMinWords = atom({ plugin: 'cc-esol', key: 'englishMinWords' } as const, DEFAULT_MIN_WORDS)
48export const englishNative = atom({ plugin: 'cc-esol', key: 'englishNative' } as const, DEFAULT_NATIVE)
49export const englishLog = atom({ plugin: 'cc-esol', key: 'englishLog' } as const, null)
50export const englishPending = atom({ plugin: 'cc-esol', key: 'englishPending' } as const, null)
51export const esolView = atom({ plugin: 'cc-esol', key: 'esolView' } as const, 'list')
52export const esolScroll = atom({ plugin: 'cc-esol', key: 'esolScroll' } as const, 0)
53export const esolOpen = atom({ plugin: 'cc-esol', key: 'esolOpen' } as const, null)
54export const esolReview = atom({ plugin: 'cc-esol', key: 'esolReview' } as const, null)
55export const esolReviewBusy = atom({ plugin: 'cc-esol', key: 'esolReviewBusy' } as const, false)
56export const esolReviewError = atom({ plugin: 'cc-esol', key: 'esolReviewError' } as const, null)
57export const esolReviewScroll = atom({ plugin: 'cc-esol', key: 'esolReviewScroll' } as const, 0)
58
59// Paths ------------------------------------------------------------------------
60
61export async function logPath($: EngineInterface) {
62 return resolveLogPath(await $.env.get('ENGLISH_COACH_LOG'), await homeDir($))
63}
64
65// Settings --------------------------------------------------------------------
66
67export async function setEnglishOn($: EngineInterface, isOn: boolean) {
68 await update($, englishOn, () => isOn)
69 await $.store.set('englishOn', isOn)
70 if (!isOn) await update($, englishPending, () => null)
71 // The tool's place (the prompt's list, or behind ToolSearch) follows the
72 // switch; the rules section needs no asking, composed anew for every request.
73 $.ui.invalidate('tool.describe')
74}
75
76// The contract for this session's language and log.
77async function contractOf($: EngineInterface) {
78 const path = (await read($, englishLog))?.path ?? (await logPath($))
79 return coachingContract(await read($, englishNative), path)
80}
81
82async function setEnCutoff($: EngineInterface, cutoff: number) {
83 await update($, englishEnCutoff, () => cutoff)
84 await $.store.set('englishEnCutoff', cutoff)
85}
86
87async function setNative($: EngineInterface, name: string) {
88 const native = name.trim() === '' ? DEFAULT_NATIVE : name.trim()
89 await update($, englishNative, () => native)
90 await $.store.set('englishNative', native)
91 // The rules name the language: the tool is registered again with them.
92 await registerEnglishTool($)
93 $.ui.invalidate('tool.describe')
94}
95
96async function setMinWords($: EngineInterface, minWords: number) {
97 await update($, englishMinWords, () => minWords)
98 await $.store.set('englishMinWords', minWords)
99}
100
101// The log as the tab previews it: where it is, how many entries, the newest.
102// Read once here and after each write, never while drawing.
103export async function refreshLog($: EngineInterface) {
104 const path = await logPath($)
105 try {
106 if (!(await $.fs.exists(path))) {
107 await update($, englishLog, () => ({ path, count: 0, tooLarge: false, last: null, entries: [] }))
108 return
109 }
110 const stat = await $.fs.stat(path)
111 if (stat.size > MAX_PREVIEW_BYTES) {
112 await update($, englishLog, () => ({ path, count: 0, tooLarge: true, last: null, entries: [] }))
113 return
114 }
115 const summary = parseLog(await $.fs.read(path))
116 await update($, englishLog, () => ({ path, tooLarge: false, ...summary }))
117 } catch {}
118}
119
120// The kept settings, back into the session's values: at the session's start
121// and after a /clear (from restore in register.tsx).
122export async function loadEnglish($: EngineInterface) {
123 const isOn = await $.store.get('englishOn')
124 if (typeof isOn === 'boolean') await update($, englishOn, () => isOn)
125 const cutoff = await $.store.get('englishEnCutoff')
126 if (typeof cutoff === 'number' && EN_CUTOFFS.includes(cutoff)) await update($, englishEnCutoff, () => cutoff)
127 const minWords = await $.store.get('englishMinWords')
128 if (typeof minWords === 'number' && MIN_WORDS_CHOICES.includes(minWords)) await update($, englishMinWords, () => minWords)
129 const native = await $.store.get('englishNative')
130 if (typeof native === 'string' && native.trim() !== '') await update($, englishNative, () => native)
131 await update($, englishPending, () => null)
132 const review = await $.store.get('esolReview')
133 if (isReview(review)) await update($, esolReview, () => review)
134 await update($, esolView, () => 'list')
135 await update($, esolOpen, () => null)
136 await update($, esolScroll, () => 0)
137 await refreshLog($)
138}
139
140const isReview = (v: unknown): v is EsolReview => {
141 const r = v as { at?: unknown; model?: unknown; text?: unknown } | null
142 return r !== null && typeof r === 'object' && typeof r.at === 'string' && typeof r.model === 'string' && typeof r.text === 'string'
143}
144
145// The log ------------------------------------------------------------------------
146
147async function appendEntry($: EngineInterface, entry: string, path: string) {
148 if (await isWindows($)) {
149 // No sh: the entry goes to a sibling file and cmd appends it (`>>`
150 // opens the log for appending, never rewriting what is there).
151 const tmp = `${path}.entry-${Math.random().toString(36).slice(2, 8)}`
152 await $.fs.write(tmp, entry)
153 const ran = await $.process.run(appendWinArgv(tmp, path), { timeoutMs: 10_000 })
154 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `cmd exited with ${ran.exitCode}`)
155 return
156 }
157 const ran = await $.process.run(appendArgv(entry, path))
158 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `sh exited with ${ran.exitCode}`)
159}
160
161// The person's own words: typed or dictated into the composer, or submitted
162// on their behalf by a plugin that says so. Never a slash command, a prompt
163// the person does not see, a notification or a peer's message.
164export const isOwnWords = (e: { command?: unknown; isMeta?: true; origin: { kind: string; asUser?: true } }): boolean =>
165 e.command === undefined && e.isMeta !== true && (e.origin.kind === 'composer' || (e.origin.kind === 'plugin' && e.origin.asUser === true))
166
167// Drawing ----------------------------------------------------------------------
168
169// The listed label for a language name, or the name itself for an Other.
170const languageLabel = (native: string) => LANGUAGES.find(l => l.name === native)?.label ?? native
171const slug = (label: string) => label.toLowerCase().replace(/[^a-z]+/g, '-').replace(/-$/, '')
172
173// What drawPane in register.tsx lends the tab: its chip and heading idiom,
174// the phrase function and the pane's width.
175export type TabHelpers = {
176 heading: (text: string, extra?: RenderChildren) => RenderChildren
177 chip: (key: string, label: string, isOn: boolean, onPress: () => unknown, color?: string) => RenderChildren
178 chips: (key: string, items: RenderChildren[]) => RenderChildren
179 t: (key: Phrase, vars?: Record<string, string | number>) => string
180 columns: number
181}
182
183// Rows the list page spends besides the list: the tab strip (3), the header
184// (1), the chips row (3), the footer (3) and one spare in case a row wraps.
185const LIST_OVERHEAD = TAB_STRIP_ROWS + 1 + 3 + 3 + 1
186// The review page: the tab strip, the chips row, the status line, the footer, one spare.
187const REVIEW_OVERHEAD = TAB_STRIP_ROWS + 3 + 1 + 3 + 1
188// Columns a row loses to the pane's padding and the marker and date in front.
189const ROW_INDENT = 2
190const MARKER_WIDTH = 2 + 6
191
192export const listRoom = (bodyRows: number) => Math.max(3, bodyRows - LIST_OVERHEAD)
193export const reviewRoom = (bodyRows: number) => Math.max(3, bodyRows - REVIEW_OVERHEAD)
194
195// The pane's width as last drawn: the scroll and focus hooks wrap with it.
196let paneColumns = 56
197
198const textWidth = (columns: number) => Math.max(10, columns - 2 - ROW_INDENT)
199
200// The entries newest first, as the list shows them.
201const newestFirst = (log: EnglishLog | null): EnglishLogEntry[] => (log === null ? [] : [...log.entries].reverse())
202
203// The rows one entry takes: one collapsed, or its heading and every wrapped
204// line of the original, the native version and the reasons.
205const entryRows = (entry: EnglishLogEntry, isOpen: boolean, columns: number): number => {
206 if (!isOpen) return 1
207 const w = textWidth(columns)
208 return (
209 1 +
210 wrapLines(entry.original, w).length +
211 wrapLines(entry.native, w).length +
212 entry.why.reduce((n, why) => n + wrapLines(why, w - 2).length, 0)
213 )
214}
215
216// The entries from `top` that fit in `room` rows: [from, to), always at least one.
217export const windowOf = (list: readonly EnglishLogEntry[], top: number, open: number | null, room: number, columns: number) => {
218 const from = Math.max(0, Math.min(top, Math.max(0, list.length - 1)))
219 let rows = 0
220 let to = from
221 while (to < list.length) {
222 const r = entryRows(list[to]!, to === open, columns)
223 if (rows + r > room && to > from) break
224 rows += r
225 to++
226 }
227 return { from, to }
228}
229
230async function goTo($: EngineInterface, view: EsolView) {
231 await update($, esolView, () => view)
232}
233
234async function toggleOpen($: EngineInterface, i: number) {
235 await update($, esolOpen, open => (open === i ? null : i))
236}
237
238// The wheel and the scroll keys move the list's or the review's own window,
239// never the pane, so the tabs stay put (from register.tsx's ui.scroll hook).
240export async function scrollEsol($: EngineInterface, by: number, bodyRows: number) {
241 const view = await read($, esolView)
242 if (view === 'review') {
243 const review = await read($, esolReview)
244 if (review === null) return
245 const lines = wrapLines(review.text, Math.max(10, paneColumns - 2))
246 const last = Math.max(0, lines.length - reviewRoom(bodyRows))
247 await update($, esolReviewScroll, top => Math.max(0, Math.min(last, top + by)))
248 } else if (view === 'list') {
249 const list = newestFirst(await read($, englishLog))
250 const last = Math.max(0, list.length - listRoom(bodyRows))
251 await update($, esolScroll, top => Math.max(0, Math.min(last, top + by)))
252 }
253}
254
255// The focus ring stepping onto an entry past either end of the window brings
256// it into view (from register.tsx's ui.focus hook).
257export async function focusEsol($: EngineInterface, element: string | undefined, bodyRows: number) {
258 if ((await read($, esolView)) !== 'list' || !element?.startsWith('esol:')) return
259 const i = Number(element.slice('esol:'.length))
260 if (!Number.isInteger(i)) return
261 const list = newestFirst(await read($, englishLog))
262 const top = await read($, esolScroll)
263 const room = listRoom(bodyRows)
264 const { to } = windowOf(list, top, await read($, esolOpen), room, paneColumns)
265 if (i < top) await update($, esolScroll, () => i)
266 else if (i >= to) await update($, esolScroll, () => Math.max(0, Math.min(i, list.length - 1) - room + 1))
267}
268
269const modelLabel = readableModel
270
271// The review: the model reads the whole log and writes the analysis the tab
272// shows. Kept in $.store, so the tab opens on the last one until it is run again.
273export async function runReview($: EngineInterface) {
274 if (await read($, esolReviewBusy)) return
275 await update($, esolReviewBusy, () => true)
276 await update($, esolReviewError, () => null)
277 await update($, esolView, () => 'review')
278 try {
279 const path = await logPath($)
280 if (!(await $.fs.exists(path))) throw new Error('the log does not exist yet')
281 const stat = await $.fs.stat(path)
282 if (stat.size > MAX_PREVIEW_BYTES) throw new Error('the log is over 4 MiB: archive it first')
283 const text = await $.fs.read(path)
284 if (text.trim() === '') throw new Error('the log is empty')
285 let model = 'sonnet'
286 try {
287 model = (await $.session.model()) || model
288 } catch {}
289 const reply = await $.model.complete({ model, prompt: reviewPrompt(reviewTail(text), await read($, englishNative)), effort: 'medium', maxTokens: 2500, timeoutMs: 180_000 })
290 if (!reply.isAnswered) {
291 throw new Error(reply.reason === 'api-error' ? `API error${reply.status === null ? '' : ` ${reply.status}`}` : reply.reason)
292 }
293 const review: EsolReview = { at: timestamp(new Date()), model: modelLabel(model), text: reply.text.trim() }
294 await update($, esolReview, () => review)
295 await update($, esolReviewScroll, () => 0)
296 await $.store.set('esolReview', review)
297 } catch (error) {
298 await update($, esolReviewError, () => (error instanceof Error ? error.message : String(error)))
299 } finally {
300 await update($, esolReviewBusy, () => false)
301 }
302}
303
304export async function drawEnglishTab($: EngineInterface, e: RenderInput<'Pane'>, tab: TabHelpers): Promise<RenderChildren[]> {
305 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
306 const { Box, Text, Button, Input } = $.ui.resolve(e)
307 paneColumns = tab.columns
308 const bodyRows = e.props.scroll.bodyRows
309 const view = await read($, esolView)
310 const isOn = await read($, englishOn)
311 const log = await read($, englishLog)
312 const rows: RenderChildren[] = []
313
314 const native = await read($, englishNative)
315
316 if (view === 'settings') {
317 const cutoff = await read($, englishEnCutoff)
318 const minWords = await read($, englishMinWords)
319 const count =
320 log === null || log.count === 0 || log.last === null
321 ? tab.t('noEntries')
322 : log.tooLarge
323 ? tab.t('logTooLarge')
324 : log.count === 1
325 ? tab.t('entryOne', { date: log.last.ts })
326 : tab.t('entries', { n: log.count, date: log.last.ts })
327 rows.push(
328 tab.chips('esol-nav', [tab.chip('esol-back', tab.t('back'), false, () => goTo($, 'list'))]),
329 tab.heading(tab.t('coaching')),
330 tab.chips('english-onoff', [
331 tab.chip('english-on', tab.t('on'), isOn, () => setEnglishOn($, true)),
332 tab.chip('english-off', tab.t('off'), !isOn, () => setEnglishOn($, false)),
333 ]),
334 <Text key="english-note" dimColor>
335 {tab.t('coachingNote')}
336 </Text>,
337 tab.heading(tab.t('enCutoff')),
338 tab.chips(
339 'english-cutoff',
340 EN_CUTOFFS.map(n => tab.chip(`english-cutoff-${n}`, `${n}%`, n === cutoff, () => setEnCutoff($, n))),
341 ),
342 tab.heading(tab.t('minWords')),
343 tab.chips(
344 'english-min',
345 MIN_WORDS_CHOICES.map(n => tab.chip(`english-min-${n}`, String(n), n === minWords, () => setMinWords($, n))),
346 ),
347 tab.heading(tab.t('logFile')),
348 <Text key="english-log-path" dimColor wrap="truncate">
349 {log?.path ?? ''}
350 </Text>,
351 <Text key="english-log-count">{count}</Text>,
352 tab.heading(tab.t('languageRow')),
353 <Button key="esol-language" plain label={tab.t('languageRow')} onPress={() => goTo($, 'language')}>
354 <Text bold>{languageLabel(native)}</Text>
355 <Text dimColor>{' ›'}</Text>
356 </Button>,
357 )
358 return rows
359 }
360
361 if (view === 'language') {
362 const isOther = LANGUAGES.every(l => l.name !== native)
363 rows.push(
364 tab.chips('esol-nav', [tab.chip('esol-back', tab.t('back'), false, () => goTo($, 'settings'))]),
365 tab.heading(tab.t('languageTitle')),
366 tab.chips('english-langs', [
367 ...LANGUAGES.map(l => tab.chip(`english-lang-${slug(l.label)}`, l.label, l.name === native, () => setNative($, l.name))),
368 tab.chip('english-lang-other', tab.t('other'), isOther, () => setNative($, isOther ? native : tab.t('other'))),
369 ]),
370 )
371 if (isOther) {
372 rows.push(
373 <Input key="english-lang-name" label={tab.t('languageName')} value={native} placeholder={DEFAULT_NATIVE} submitLabel="set" onSubmit={value => setNative($, value)} />,
374 )
375 }
376 return rows
377 }
378
379 if (view === 'review') {
380 const review = await read($, esolReview)
381 const busy = await read($, esolReviewBusy)
382 const error = await read($, esolReviewError)
383 const top = await read($, esolReviewScroll)
384 const room = reviewRoom(bodyRows)
385 const lines = review === null ? [] : wrapLines(review.text, Math.max(10, tab.columns - 2))
386 const below = Math.max(0, lines.length - top - room)
387 rows.push(
388 tab.chips('esol-nav', [
389 tab.chip('esol-back', tab.t('back'), false, () => goTo($, 'list')),
390 tab.chip('esol-review-run', tab.t('reviewAgain'), busy, () => runReview($)),
391 ]),
392 <Box key="esol-review-status" justifyContent="space-between" gap={1} flexShrink={0}>
393 <Text wrap="truncate" color={error === null ? undefined : 'red'} dimColor={error === null}>
394 {busy
395 ? tab.t('reviewRunning')
396 : error !== null
397 ? tab.t('reviewFailed', { reason: error })
398 : review === null
399 ? ''
400 : tab.t('reviewAt', { date: review.at, model: review.model })}
401 </Text>
402 <Text dimColor>{top > 0 || below > 0 ? `${top + 1}–${Math.min(lines.length, top + room)}/${lines.length}` : ''}</Text>
403 </Box>,
404 )
405 if (review === null && !busy && error === null) {
406 rows.push(
407 <Text key="esol-review-none" dimColor>
408 {tab.t('reviewNone')}
409 </Text>,
410 )
411 }
412 rows.push(...lines.slice(top, top + room).map((line, k) => <Text key={`esol-review-line-${top + k}`}>{line === '' ? ' ' : line}</Text>))
413 return rows
414 }
415
416 // The list: the newest corrections first, one row each, the chosen one open.
417 const list = newestFirst(log)
418 const open = await read($, esolOpen)
419 const top = await read($, esolScroll)
420 const room = listRoom(bodyRows)
421 const { from, to } = windowOf(list, top, open, room, tab.columns)
422 const w = textWidth(tab.columns)
423 const clip = (s: string, width: number) => (s.length > width ? `${s.slice(0, Math.max(1, width - 1))}…` : s)
424 rows.push(
425 <Box key="esol-head" justifyContent="space-between" gap={1} flexShrink={0}>
426 <Text dimColor>{tab.t('lastCorrections')}</Text>
427 <Text dimColor>{list.length > to - from ? `${from + 1}–${to}/${list.length}` : ''}</Text>
428 </Box>,
429 )
430 if (list.length === 0) {
431 rows.push(
432 <Text key="esol-none" dimColor>
433 {log?.tooLarge ? tab.t('logTooLarge') : tab.t('noEntriesHint')}
434 </Text>,
435 )
436 }
437 if (!isOn) {
438 rows.push(
439 <Text key="esol-off" color="claude">
440 {tab.t('coachingOffHint')}
441 </Text>,
442 )
443 }
444 for (let i = from; i < to; i++) {
445 const entry = list[i]!
446 const isOpen = i === open
447 rows.push(
448 <Button key={`esol:${i}`} plain label={entry.original} onPress={() => toggleOpen($, i)}>
449 <Text color={isOpen ? 'claude' : undefined}>{isOpen ? '▾ ' : '▸ '}</Text>
450 <Text dimColor>{`${shortDate(entry.ts)} `}</Text>
451 {!isOpen && <Text wrap="truncate">{clip(entry.original, Math.max(10, tab.columns - 2 - MARKER_WIDTH))}</Text>}
452 </Button>,
453 )
454 if (!isOpen) continue
455 rows.push(
456 ...wrapLines(entry.original, w).map((line, k) => (
457 <Text key={`esol:${i}:o${k}`}>
458 {' '}
459 {line}
460 </Text>
461 )),
462 ...wrapLines(entry.native, w).map((line, k) => (
463 <Text key={`esol:${i}:n${k}`} color="claude">
464 {' '}
465 {line}
466 </Text>
467 )),
468 ...entry.why.flatMap((why, j) =>
469 wrapLines(why, w - 2).map((line, k) => (
470 <Text key={`esol:${i}:w${j}-${k}`} dimColor>
471 {k === 0 ? ' · ' : ' '}
472 {line}
473 </Text>
474 )),
475 ),
476 )
477 }
478 rows.push(
479 <Box key="esol-spacer" flexGrow={1} />,
480 tab.chips('esol-actions', [
481 tab.chip('esol-settings', tab.t('settings'), false, () => goTo($, 'settings')),
482 tab.chip('esol-review', tab.t('review'), false, () => runReviewOrShow($)),
483 ]),
484 )
485 return rows
486}
487
488// The Review chip: the last review when there is one, a fresh one otherwise.
489async function runReviewOrShow($: EngineInterface) {
490 if ((await read($, esolReview)) === null) await runReview($)
491 else await goTo($, 'review')
492}
493
494// Hooks ------------------------------------------------------------------------
495
496// The engine takes one hook per event without a matcher from a plugin, so the
497// English parts of session.start, prompt.submit and turn.complete are these
498// functions, each called from the hook register.tsx already has.
499
500// Claude's own logging tool, registered at the session's start. Where it
501// waits (the prompt's list, or behind ToolSearch) is the tool.describe hook's.
502export async function registerEnglishTool($: EngineInterface) {
503 try {
504 const { toolDescription } = await contractOf($)
505 await $.tool.register({ name: ENGLISH_TOOL_NAME, description: toolDescription, inputSchema: ENGLISH_TOOL_SCHEMA })
506 } catch (error) {
507 // A refusal (an organization's policy on tool names, say) would leave
508 // the coach silently mute: say so once.
509 try {
510 $.ui.toast(`cc-esol: the english_log tool could not be registered, so coaching cannot log: ${error instanceof Error ? error.message : String(error)}`)
511 } catch {}
512 }
513}
514
515// The prompt as submitted, the gate's marker attached when it is one of the
516// person's English prompts and coaching is on; read with the prompt, never
517// shown. The rules it points at are in the system prompt. A failure here
518// leaves the prompt as it was.
519export async function withCoaching($: EngineInterface, e: PromptSubmitInput): Promise<PromptSubmitInput> {
520 try {
521 if (!(await read($, englishOn)) || !isOwnWords(e)) return e
522 const enCutoff = await read($, englishEnCutoff)
523 const verdict = classify(e.text, { minWords: await read($, englishMinWords), enCutoff })
524 if (verdict.skip !== null) return e
525 const pending: EnglishPending = { ts: timestamp(new Date()), project: basename(await $.session.cwd()) }
526 await update($, englishPending, () => pending)
527 const { marker } = await contractOf($)
528 return { ...e, context: [...(e.context ?? []), marker({ enTokens: verdict.enTokens, tokens: verdict.tokens, enCutoff })] }
529 } catch {
530 return e
531 }
532}
533
534
535// A coached prompt whose turn ended without a write stays unlogged.
536export async function clearEnglishPending($: EngineInterface) {
537 try {
538 if ((await read($, englishPending)) !== null) await update($, englishPending, () => null)
539 } catch {}
540}
541
542// Numbers the english_log calls of this process, for the claim.
543let claimSeq = 0
544
545export function registerEnglish(on: On) {
546 // The tool's description is the contract's, for this session's language
547 // and log; its schema sits in the prompt's list while coaching is on and
548 // behind ToolSearch while it is off. setEnglishOn and setNative ask again.
549 on('tool.describe', { tool: 'mcp__cc-esol__english_log' }, async ($, e, next) => {
550 const described = await next(e)
551 return { ...described, description: (await contractOf($)).toolDescription, isDeferred: !(await read($, englishOn)) }
552 })
553
554 // One entry per coached prompt, only while coaching is on: the pending
555 // stamp is set when the block was attached and consumed by the first write.
556 // The literal, not ENGLISH_TOOL, so `claude plugin validate` reads the matcher.
557 on('tool.call', { tool: 'mcp__cc-esol__english_log' }, async ($, e) => {
558 if (!(await read($, englishOn))) return { result: 'English coaching is off: nothing logged.' }
559 if ((e as { agentId?: string }).agentId !== undefined) return { result: 'english_log is for the main conversation, not a subagent: nothing logged.' }
560 const input = asEntryInput(e)
561 if (input === null) return { result: 'Invalid input: original, native and why are required and must not be empty.' }
562 // The claim: one call takes the stamp (update applies in order, again
563 // after a miss), the others find it taken.
564 const claim = ++claimSeq
565 await update($, englishPending, p => (p === null || p.claim !== undefined ? p : { ...p, claim }))
566 const pending = await read($, englishPending)
567 if (pending === null || pending.claim !== claim) {
568 return { result: 'No coached prompt is pending (one entry per coached prompt): nothing logged.' }
569 }
570 const path = await logPath($)
571 const entry = { ts: pending.ts, project: pending.project, ...input }
572 try {
573 await appendEntry($, formatEntry(entry), path)
574 } catch (error) {
575 // The stamp is given back, so a retry can log.
576 await update($, englishPending, p => (p !== null && p.claim === claim ? { ts: p.ts, project: p.project } : p))
577 return { result: `Could not write the learning log at ${path}: ${String(error)}` }
578 }
579 await update($, englishPending, p => (p !== null && p.claim === claim ? null : p))
580 const listed: EnglishLogEntry = { ts: entry.ts, project: entry.project, original: entry.original, native: entry.native, why: [...entry.why] }
581 await update($, englishLog, log => ({
582 path,
583 tooLarge: log?.tooLarge ?? false,
584 count: (log?.count ?? 0) + 1,
585 last: listed,
586 entries: [...(log?.entries ?? []), listed].slice(-MAX_LIST),
587 }))
588 return { result: 'Logged.' }
589 }).catch(() => ({ result: 'Could not log the entry: the english_log hook failed.' }))
590}
591hooks/icons.tsx 53 lines1// The icons the panel draws with: Nerd Font glyphs (one cell, monochrome)
2// where the terminal's font has them, Unicode emoji elsewhere. No mod can
3// see the terminal's font, so Auto follows the ccstatusline config: a
4// powerline status line already relies on such glyphs. Settings overrides.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface } from 'claude-code'
8
9import type { IconSet } from '../types'
10import { readStatusLinePowerline } from './pages/statusline'
11
12const icons = atom({ plugin: 'cc-esol', key: 'icons' } as const, 'auto')
13
14export const ICON_SETS: readonly IconSet[] = ['auto', 'nerd', 'unicode']
15export type IconName = 'brain' | 'lamp' | 'done' | 'failed'
16// fa-brain U+EE9C, fa-lightbulb U+F0EB, fa-check U+F00C and fa-times U+F00D
17// from the Nerd Fonts, all in the Basic Multilingual Plane: a glyph of the
18// Material Design set (U+F0000 on, a supplementary plane) was seen drawn
19// wider than Claude Code measured it, wrapping the band's lines. The check
20// and the cross have plain marks instead of emoji, since they sit in the
21// band one per row.
22export const GLYPHS: Record<IconName, { nerd: string; unicode: string }> = {
23 brain: { nerd: '\uEE9C', unicode: '🧠' },
24 lamp: { nerd: '\uF0EB', unicode: '💡' },
25 done: { nerd: '\uF00C', unicode: '✓' },
26 failed: { nerd: '\uF00D', unicode: '✗' },
27}
28
29export function readIconSet($: EngineInterface) {
30 return read($, icons)
31}
32
33export async function setIconSet($: EngineInterface, set: IconSet) {
34 await update($, icons, () => set)
35 await $.store.set('icons', set)
36}
37
38export async function loadIcons($: EngineInterface) {
39 const kept = await $.store.get('icons')
40 if (ICON_SETS.includes(kept as IconSet)) await update($, icons, () => kept as IconSet)
41}
42
43// The set in force now: Auto resolves by the status line's powerline.
44export async function iconSetNow($: EngineInterface): Promise<'nerd' | 'unicode'> {
45 const set = await read($, icons)
46 if (set !== 'auto') return set
47 return (await readStatusLinePowerline($)) ? 'nerd' : 'unicode'
48}
49
50export async function glyph($: EngineInterface, name: IconName): Promise<string> {
51 return GLYPHS[name][await iconSetNow($)]
52}
53hooks/lib/i18n.ts 157 lines1// The panel's words, in English: the pane speaks English. Pure; the hooks are in register.tsx.
2
3import type { Lang } from '../../types'
4
5const EN = {
6 tabModel: 'Model',
7 tabEsol: 'ESOL',
8 tabSettings: 'Settings',
9 backSettings: '← Settings',
10 iconsTitle: 'Icons',
11 iconsAuto: 'Auto',
12 iconsNerd: 'Nerd Font',
13 iconsUnicode: 'Unicode',
14 focusBandRow: 'Focus color',
15 themeRow: 'Theme',
16 statusLineRow: 'Status line',
17 slIntro: 'The status line under the prompt, drawn by ccstatusline: install or remove it here, check its version and edit the layout it draws.',
18 slAbout: 'ccstatusline is a separate, third-party project by Matthew Breedlove (sirmalloc), MIT-licensed: a highly customizable status line for Claude Code. Nothing of it ships in this plugin; this page only recommends it, installs it and looks after it.',
19 slNone: 'Not installed.',
20 slOther: 'Another status line is set; Install keeps it for Uninstall.',
21 slUnmanaged: 'ccstatusline, installed by you.',
22 slManaged: 'ccstatusline, installed from here.',
23 slInstall: '⇩ Install ccstatusline',
24 slUninstall: 'Uninstall',
25 slRemove: 'Remove the statusLine setting',
26 slRemoveConfirm: 'Press again to remove',
27 slPutBack: 'Put it back',
28 slCheckVersion: 'Check version',
29 slConfigurator: 'Full configurator, in a terminal',
30 slLines: 'Lines',
31 slPowerline: 'powerline',
32 slVersionChecking: 'Checking the version…',
33 slConfigNone: 'No ccstatusline config yet: the configurator makes one.',
34 slConfigBroken: 'The ccstatusline config does not parse; it renders defaults.',
35 slNoNpx: 'npx is not on PATH: install Node.js first.',
36 slNote: 'Changes to the status line setting take effect when a session starts.',
37 ccslRow: 'Layout Editor',
38 ccslBackStatus: '← Status line',
39 ccslBackEditor: '← Editor',
40 ccslBackWidget: '← Widget',
41 ccslWidgetTitle: '{name} · line {n} · {i} of {m}',
42 ccslMoveLeft: '◂ Move left',
43 ccslMoveRight: 'Move right ▸',
44 ccslRemove: '✕ Remove',
45 ccslBoldChip: 'Bold',
46 ccslRawChip: 'Raw value',
47 ccslColorRow: 'Color',
48 ccslBgRow: 'Background',
49 ccslNoWidget: 'No widget chosen.',
50 ccslLine: 'Line {n}',
51 ccslAddLine: '+ line',
52 ccslRemoveLine: '✕ line',
53 ccslSave: 'Save',
54 ccslSaved: 'Saved',
55 ccslSaving: 'Saving…',
56 ccslRevert: 'Revert',
57 ccslAdd: '+ Add widget',
58 ccslPowerline: 'Powerline',
59 ccslLayout: 'Layout',
60 ccslMore: 'More',
61 ccslEmptyLine: 'An empty line: Add widget puts one here.',
62 ccslPreviewRun: '⟳ Preview',
63 ccslPreviewTitle: 'Preview',
64 ccslPreviewUpdating: '⟳ updating…',
65 ccslPreviewHint: 'Preview draws the draft through your status line command, with this session\'s model and folder and sample usage numbers. Nothing is saved.',
66 ccslMissing: 'No ccstatusline config file. More › Reset to starter makes one.',
67 ccslBroken: 'The config file does not parse, so ccstatusline draws its defaults. More › restores a backup or resets to the starter.',
68 ccslOld: 'The config is from an older ccstatusline; it migrates at its next render. Open the editor again after.',
69 ccslAddTitle: 'Add to line {n}',
70 ccslVersionChecking: 'Checking the ccstatusline version…',
71 ccslVersionGate: 'Version unknown or older than {v}: only the widgets already in your file, and separators.',
72 ccslColorTitle: 'Color of {name}',
73 ccslBgTitle: 'Background of {name}',
74 ccslCurrent: 'Now',
75 ccslNone: 'none',
76 ccslKept: 'A value outside the palette: kept until another is chosen.',
77 ccslOtherTitle: 'Other: ansi256:N (0 to 255) or hex:RRGGBB',
78 ccslOtherLabel: 'other',
79 ccslTheme: 'Theme',
80 ccslAutoAlign: 'Auto-align',
81 ccslContinue: 'Continue the theme across lines',
82 ccslFlex: 'Flex mode',
83 ccslCompact: 'Compact threshold',
84 ccslMinimalist: 'Minimalist mode',
85 ccslRestoreTitle: 'Restore a backup (the file as it is gets backed up first)',
86 ccslNoBackups: 'No backups yet: each Save makes one.',
87 ccslReset: 'Reset to starter',
88 ccslSaveBlind: 'Save without preview',
89 ccslNoAdapter: 'The status line command is not one the editor can run for a preview.',
90 ccslBlindNote: 'Writes the draft after the local check only; no dry run.',
91 close: 'esc ✕',
92 keys: 'tab move · enter select · wheel scrolls · esc close',
93 language: 'Language',
94 on: 'On',
95 off: 'Off',
96 focusMode: 'Focus mode',
97 bandColor: 'Focus color',
98 colorSuffix: 'color',
99 kept: 'Kept',
100 search: 'Search',
101 themesCount: '{n} themes · click one to apply it',
102 themesWait: 'Getting the themes from terminalcolors.com…',
103 themesRetry: '↻ Try again',
104 themesNone: 'No theme matches “{q}”.',
105 defaultTheme: '↺ Claude Code default',
106 downloadAgain: '⟳ Download again',
107 terminalDefault: 'Terminal default',
108 model: 'Model',
109 modelDefault: "↺ Claude Code's default",
110 effort: 'Effort',
111 shiftTab: 'shift+tab switches the running session',
112 newSessions: 'New sessions start in',
113 bypassWarning: 'Bypass skips every permission check. Claude Code asks you to confirm it when the next session starts.',
114 m_default: 'ask',
115 m_plan: 'plan',
116 m_acceptEdits: 'accept edits',
117 m_auto: 'auto',
118 m_dontAsk: "don't ask",
119 m_bypassPermissions: 'bypass permissions',
120 coaching: 'English coaching',
121 coachingNote: 'Each prompt in English runs as usual; a nativized correction is logged silently. Prompts in your own language are left alone.',
122 enCutoff: 'Coach when English is at least',
123 languageRow: 'Your language',
124 languageTitle: 'Your language: the coaching and the review speak of it',
125 other: 'Other',
126 languageName: 'Language name',
127 minWords: 'Minimum words',
128 logFile: 'Learning log',
129 entries: '{n} entries · last {date}',
130 entryOne: '1 entry · last {date}',
131 noEntries: 'No entries yet',
132 logTooLarge: 'Too large to preview (over 4 MiB): archive it',
133 lastCorrections: 'Last corrections',
134 noEntriesHint: 'Entries appear here as you write prompts in English.',
135 coachingOffHint: 'Coaching is off: turn it on in Settings.',
136 settings: '⚙ Settings',
137 back: '← Back',
138 review: '↗ Review',
139 reviewAgain: '↻ Run again',
140 reviewRunning: 'Reviewing the log… this takes a minute.',
141 reviewNone: 'No review yet. Run one: the model reads the whole log and sums up the patterns, the interference habits, the trend and three drills.',
142 reviewFailed: 'The review failed: {reason}',
143 reviewAt: 'Reviewed {date} · {model}',
144}
145
146export type Phrase = keyof typeof EN
147
148const PHRASES: Record<Lang, Record<Phrase, string>> = { en: EN }
149
150// A phrase in `lang`, each {name} filled from `vars`.
151export const phrase = (lang: Lang, key: Phrase, vars: Record<string, string | number> = {}) =>
152 PHRASES[lang][key].replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? ''))
153
154// A permission mode's name in `lang`: `acceptEdits` → `accept edits`.
155export const modeName = (lang: Lang, mode: string | null) =>
156 mode === null ? '—' : `m_${mode}` in EN ? phrase(lang, `m_${mode}` as Phrase) : mode
157hooks/pages/ccsl-editor.tsx 975 lines1// The ccstatusline layout editor, opened from the Status line page: the
2// config's lines and widgets, each widget's colors and flags, powerline and
3// layout. Nothing touches the file until Save, which dry-runs the candidate
4// through the person's own status line command first, backs the file up and
5// replaces it in one step. The rules are in ../lib/ccsl-editor.ts.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, RenderChildren, RenderInput, Timer } from 'claude-code'
9
10import type { CcslConfig, CcslDraft, CcslMessage, CcslPanel, CcslWidget, Panel, StatusLineVersion } from '../../types'
11import {
12 type Adapter,
13 addLine,
14 addWidget,
15 BACKGROUNDS,
16 backupName,
17 backupStamp,
18 CATALOG,
19 CATALOG_VERSION,
20 CATEGORIES,
21 catalogName,
22 commandAdapter,
23 COMPACT_THRESHOLDS,
24 configKey,
25 dryRunArgv,
26 dryRunVerdict,
27 FLEX_MODES,
28 FOREGROUNDS,
29 isBackupName,
30 isColor,
31 isKnownType,
32 MAX_LINES,
33 moveWidget,
34 normalizeColorInput,
35 parseAnsi,
36 parseConfig,
37 POWERLINE_THEMES,
38 previewLines,
39 removeLine,
40 removeWidget,
41 sameConfigIgnoringUpdateMessage,
42 sampleStatusJson,
43 serialize,
44 setLayout,
45 setPowerline,
46 setWidgetField,
47 swatchOf,
48 token,
49 validate,
50 versionArgv,
51 versionAtLeast,
52 withDiskUpdateMessage,
53} from '../lib/ccsl-editor'
54import { CONFIG_PATH, STARTER_CONFIG } from '../lib/ccstatusline'
55import { copyFileArgv, copyFileWinArgv, homeDir, isWindows, removeFileArgv, writePrivateArgv } from '../lib/platform'
56import { replaceFile } from '../lib/settings-file'
57import { TAB_STRIP_ROWS, type Frame } from '../ui/frame'
58import { detectStatusLine, readStatusLineCommand } from './statusline'
59
60const draft = atom({ plugin: 'cc-esol', key: 'ccslDraft' } as const, null)
61const line = atom({ plugin: 'cc-esol', key: 'ccslLine' } as const, 0)
62const widget = atom({ plugin: 'cc-esol', key: 'ccslWidget' } as const, null)
63const scroll = atom({ plugin: 'cc-esol', key: 'ccslScroll' } as const, 0)
64const addScroll = atom({ plugin: 'cc-esol', key: 'ccslAddScroll' } as const, 0)
65const addCategory = atom({ plugin: 'cc-esol', key: 'ccslAddCategory' } as const, null)
66const colorField = atom({ plugin: 'cc-esol', key: 'ccslColorField' } as const, 'color')
67const busy = atom({ plugin: 'cc-esol', key: 'ccslBusy' } as const, false)
68const message = atom({ plugin: 'cc-esol', key: 'ccslMessage' } as const, null)
69const preview = atom({ plugin: 'cc-esol', key: 'ccslPreview' } as const, null)
70const previewBusy = atom({ plugin: 'cc-esol', key: 'ccslPreviewBusy' } as const, false)
71const backups = atom({ plugin: 'cc-esol', key: 'ccslBackups' } as const, [])
72const version = atom({ plugin: 'cc-esol', key: 'ccslVersion' } as const, { state: 'idle', text: '' } satisfies StatusLineVersion)
73
74const DRY_RUN_MS = 20_000
75// A run of changes becomes one preview, this long after the last one.
76const PREVIEW_DEBOUNCE_MS = 600
77const PREVIEW_KEY = 'ccslPreview'
78const BACKUPS_SHOWN = 5
79// Rows around the editor's scrolling list: the tabs, the Back/Save/Revert
80// chips, the message, the line chips, the page chips, the footer.
81const EDITOR_OVERHEAD = TAB_STRIP_ROWS + 3 + 1 + 3 + 3 + 3
82// Around the Add page's list: the tabs, the Back chip, the heading, the
83// version, the categories, the footer.
84const ADD_OVERHEAD = TAB_STRIP_ROWS + 3 + 2 + 1 + 2 + 3
85
86const dirOf = (path: string) => path.slice(0, path.lastIndexOf('/'))
87const fileOf = (path: string) => path.slice(path.lastIndexOf('/') + 1)
88const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
89export const isCcslPanel = (panel: Panel): panel is CcslPanel => panel.startsWith('ccsl-')
90
91// The file the editor works on: the one the status line command names
92// with --config (a leading ~ expanded), else ccstatusline's default.
93async function configPath($: EngineInterface) {
94 const home = await homeDir($)
95 const named = (await adapterOf($))?.configPath ?? null
96 if (named === null) return `${home}/${CONFIG_PATH}`
97 const slashed = named.split('\\').join('/')
98 return slashed === '~' || slashed.startsWith('~/') ? `${home}${slashed.slice(1)}` : slashed
99}
100
101// The file as it is now: its text, or null when it is not there.
102async function readDisk($: EngineInterface, path: string): Promise<string | null> {
103 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
104}
105
106// The draft's reading of the file against the file now, `updatemessage`
107// aside (ccstatusline rewrites that field on its own); a file that does not
108// parse compares by its text.
109const sameOnDisk = (draftText: string | null, diskText: string | null): boolean => {
110 if (draftText === null || diskText === null) return draftText === diskText
111 const a = parseConfig(draftText)
112 const b = parseConfig(diskText)
113 return a.ok && b.ok ? sameConfigIgnoringUpdateMessage(a.config, b.config) : draftText === diskText
114}
115
116async function refreshBackups($: EngineInterface, path: string) {
117 let names: string[] = []
118 try {
119 const base = fileOf(path)
120 names = (await $.fs.list(dirOf(path)))
121 .filter(entry => entry.kind === 'file' && entry.name.startsWith(base) && isBackupName(entry.name))
122 .map(entry => entry.name)
123 .sort()
124 .reverse()
125 .slice(0, BACKUPS_SHOWN)
126 } catch {}
127 await update($, backups, () => names)
128}
129
130// The file into a fresh draft; the selections start over.
131export async function loadDraft($: EngineInterface) {
132 const path = await configPath($)
133 const text = await readDisk($, path)
134 let next: CcslDraft
135 if (text === null) next = { path, text, config: null, reason: 'missing', dirty: false }
136 else {
137 const parsed = parseConfig(text)
138 next = parsed.ok ? { path, text, config: parsed.config, reason: null, dirty: false } : { path, text, config: null, reason: parsed.reason, dirty: false }
139 }
140 await update($, draft, () => next)
141 await update($, line, l => Math.max(0, Math.min(l, (next.config?.lines.length ?? 1) - 1)))
142 await update($, widget, () => null)
143 await update($, scroll, () => 0)
144 previewTimer?.cancel()
145 previewSeq++
146 await update($, previewBusy, () => false)
147 const kept = next.config === null ? null : await keptPreview($, next.config)
148 await update($, preview, () => kept)
149 await refreshBackups($, path)
150 // No preview kept for this config: one is drawn now, so every page has it.
151 if (kept === null && next.config !== null) schedulePreview($)
152}
153
154// The preview kept in the store, when it was drawn from this very config.
155async function keptPreview($: EngineInterface, config: CcslConfig): Promise<string[] | null> {
156 const kept = (await $.store.get(PREVIEW_KEY)) as { key?: unknown; lines?: unknown } | undefined
157 if (kept === undefined || kept.key !== configKey(config) || !Array.isArray(kept.lines)) return null
158 return kept.lines.filter((l): l is string => typeof l === 'string')
159}
160
161async function keepPreview($: EngineInterface, config: CcslConfig, lines: string[]) {
162 await $.store.set(PREVIEW_KEY, { key: configKey(config), lines })
163}
164
165// On the way into the editor: a draft with changes is kept; one without is
166// reloaded when the file changed (or never loaded).
167export async function openEditor($: EngineInterface) {
168 const d = await read($, draft)
169 if (d === null) return loadDraft($)
170 if (d.dirty) return
171 if ((await readDisk($, d.path)) !== d.text) await loadDraft($)
172}
173
174// A change to the draft's config: a new object from the pure operation, and
175// the draft marked dirty when the object changed.
176async function edit($: EngineInterface, fn: (c: CcslConfig) => CcslConfig) {
177 let changed = false
178 await update($, draft, d => {
179 if (d === null || d.config === null) return d
180 const config = fn(d.config)
181 if (config === d.config) return d
182 changed = true
183 return { ...d, config, dirty: true }
184 })
185 if (changed) schedulePreview($)
186}
187
188// The preview follows the draft: a change schedules a dry run a moment
189// later, a run of changes becoming one; a result older than the newest
190// change is dropped. Module variables: they start over with a reload.
191let previewSeq = 0
192let previewTimer: Timer | undefined
193
194function schedulePreview($: EngineInterface) {
195 previewTimer?.cancel()
196 previewSeq++
197 void update($, previewBusy, () => true)
198 previewTimer = $.clock.after(PREVIEW_DEBOUNCE_MS, () => {
199 void refreshPreview($)
200 })
201}
202
203async function refreshPreview($: EngineInterface) {
204 const seq = previewSeq
205 try {
206 const d = await read($, draft)
207 if (d === null || d.config === null || validate(d.config).length > 0) return
208 const adapter = await adapterOf($)
209 if (adapter === null) return
210 const result = await dryRun($, adapter, d.path, serialize(withDiskUpdateMessage(d.config, null)), await isWindows($))
211 if (seq !== previewSeq) return
212 if (result.kind === 'ok') {
213 const lines = previewLines(result.raw)
214 await update($, preview, () => lines)
215 await keepPreview($, d.config, lines)
216 } else await update($, message, () => ({ kind: 'warn', text: `Preview: ${result.reason}` }) satisfies CcslMessage)
217 } catch (error) {
218 if (seq === previewSeq) await update($, message, () => ({ kind: 'warn', text: `Preview: ${errorText(error)}` }) satisfies CcslMessage)
219 } finally {
220 if (seq === previewSeq) await update($, previewBusy, () => false)
221 }
222}
223
224async function run($: EngineInterface, work: () => Promise<void>) {
225 if (await read($, busy)) return
226 await update($, busy, () => true)
227 await update($, message, () => null)
228 try {
229 await work()
230 } catch (error) {
231 await update($, message, () => ({ kind: 'error', text: errorText(error) }) satisfies CcslMessage)
232 } finally {
233 await update($, busy, () => false)
234 }
235}
236
237async function adapterOf($: EngineInterface): Promise<Adapter | null> {
238 const command = await readStatusLineCommand($)
239 return command === null ? null : commandAdapter(command)
240}
241
242type DryRun = { kind: 'ok'; raw: string } | { kind: 'failed'; reason: string } | { kind: 'unavailable'; reason: string }
243
244// What the sample status carries from this session: its folder, so the git
245// widgets draw the real branch, and its model.
246async function sessionFacts($: EngineInterface) {
247 const home = await homeDir($)
248 let cwd = home
249 let modelId = 'claude-opus-5-5'
250 try {
251 cwd = (await $.session.cwd()) || home
252 } catch {}
253 try {
254 modelId = (await $.session.model()) || modelId
255 } catch {}
256 return { cwd, modelId }
257}
258
259// The candidate, in a private file of its own beside the config, rendered by
260// the person's own command with a sample status on stdin. The file goes
261// after. The call rejecting (a timeout, a command that cannot start) is not
262// a verdict on the config: the page then offers Save without preview.
263async function dryRun($: EngineInterface, adapter: Adapter, path: string, candidate: string, win: boolean): Promise<DryRun> {
264 const candidatePath = `${path}.cc-esol.candidate-${token()}`
265 if (win) await $.fs.write(candidatePath, candidate)
266 else {
267 const wrote = await $.process.run(writePrivateArgv(candidatePath), { stdin: candidate, timeoutMs: 10_000 })
268 if (wrote.exitCode !== 0) throw new Error(`Could not write the candidate: ${wrote.stderr.trim() || `exit ${wrote.exitCode}`}`)
269 }
270 try {
271 const facts = await sessionFacts($)
272 const ran = await $.process.run(dryRunArgv(adapter, candidatePath), { stdin: sampleStatusJson(facts), cwd: facts.cwd, timeoutMs: DRY_RUN_MS })
273 const verdict = dryRunVerdict(ran.exitCode, ran.stdout, ran.stderr)
274 return verdict.ok ? { kind: 'ok', raw: verdict.raw } : { kind: 'failed', reason: verdict.reason }
275 } catch (error) {
276 return { kind: 'unavailable', reason: errorText(error) }
277 } finally {
278 try {
279 await $.process.run(removeFileArgv(candidatePath, win), { timeoutMs: 5000 })
280 } catch {}
281 }
282}
283
284// A private copy of the file, mode kept, named by the time.
285async function backup($: EngineInterface, path: string, win: boolean): Promise<string> {
286 const to = backupName(path, new Date())
287 const ran = await $.process.run(win ? copyFileWinArgv(path, to) : copyFileArgv(path, to), { timeoutMs: 10_000 })
288 if (ran.exitCode !== 0) throw new Error(`Could not back the file up: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
289 return to
290}
291
292const changedOutside = () => new Error('The file changed outside the editor (the configurator, perhaps): Revert reloads it')
293
294// Save: the local check, the file compared with the draft's reading of it,
295// the candidate (the draft with the `updatemessage` now on disk) dry-run,
296// a backup, the compare again, the one-step replace, the draft reloaded.
297// The bytes the dry run saw are the bytes written, and the replace goes ahead
298// only while the file is still the text the candidate was built from.
299async function save($: EngineInterface, mode: 'preview' | 'blind') {
300 await run($, async () => {
301 const d = await read($, draft)
302 if (d === null || d.config === null) throw new Error('Nothing to save')
303 const errors = validate(d.config)
304 if (errors.length > 0) throw new Error(`Not saved: ${errors.slice(0, 3).join('; ')}`)
305 const win = await isWindows($)
306 const disk = await readDisk($, d.path)
307 if (!sameOnDisk(d.text, disk)) throw changedOutside()
308 const parsedDisk = disk === null ? null : parseConfig(disk)
309 const candidate = serialize(withDiskUpdateMessage(d.config, parsedDisk?.ok ? parsedDisk.config : null))
310 if (mode === 'preview') {
311 const adapter = await adapterOf($)
312 if (adapter === null) throw new Error('No preview: the status line command is not one the editor can run. More › Save without preview.')
313 const result = await dryRun($, adapter, d.path, candidate, win)
314 if (result.kind === 'failed') throw new Error(`Not saved: ${result.reason}`)
315 if (result.kind === 'unavailable') throw new Error(`No preview (${result.reason}). More › Save without preview writes it anyway.`)
316 await keepPreview($, d.config, previewLines(result.raw))
317 }
318 if (disk !== null) await backup($, d.path, win)
319 if (!sameOnDisk(d.text, await readDisk($, d.path))) throw changedOutside()
320 await replaceFile($, d.path, candidate, { text: disk, changed: changedOutside })
321 await loadDraft($)
322 await update($, message, () => ({ kind: 'ok', text: mode === 'preview' ? 'Saved: the status line follows at its next refresh' : 'Saved without a preview' }) satisfies CcslMessage)
323 await detectStatusLine($)
324 })
325}
326
327// The draft drawn by the status line command, nothing written.
328async function previewDraft($: EngineInterface) {
329 await run($, async () => {
330 const d = await read($, draft)
331 if (d === null || d.config === null) throw new Error('Nothing to preview')
332 const errors = validate(d.config)
333 if (errors.length > 0) throw new Error(`No preview: ${errors.slice(0, 3).join('; ')}`)
334 const adapter = await adapterOf($)
335 if (adapter === null) throw new Error('No preview: the status line command is not one the editor can run.')
336 const result = await dryRun($, adapter, d.path, serialize(withDiskUpdateMessage(d.config, null)), await isWindows($))
337 if (result.kind !== 'ok') throw new Error(`No preview: ${result.reason}`)
338 const lines = previewLines(result.raw)
339 await update($, preview, () => lines)
340 await keepPreview($, d.config, lines)
341 await update($, message, () => ({ kind: 'ok', text: d.dirty ? 'Preview of the draft; nothing saved yet' : 'Preview of the file as saved' }) satisfies CcslMessage)
342 })
343}
344
345// A backup over the file, the file backed up first; then the draft reloads.
346async function restore($: EngineInterface, name: string) {
347 await run($, async () => {
348 const d = await read($, draft)
349 if (d === null) return
350 const text = await $.fs.read(`${dirOf(d.path)}/${name}`)
351 if (!parseConfig(text).ok) throw new Error('That backup does not parse: not restoring it')
352 const win = await isWindows($)
353 if (await $.fs.exists(d.path)) await backup($, d.path, win)
354 await replaceFile($, d.path, text)
355 await loadDraft($)
356 await update($, message, () => ({ kind: 'ok', text: `Restored ${backupStamp(name)}; the file before it is backed up` }) satisfies CcslMessage)
357 await detectStatusLine($)
358 })
359}
360
361// The plugin's starter config as the draft, Save still to come.
362async function resetToStarter($: EngineInterface) {
363 await update($, draft, d => (d === null ? d : { ...d, config: JSON.parse(JSON.stringify(STARTER_CONFIG)) as CcslConfig, reason: null, dirty: true }))
364 await update($, line, () => 0)
365 await update($, widget, () => null)
366 await update($, scroll, () => 0)
367 await update($, message, () => ({ kind: 'warn', text: 'The starter layout is the draft now: Save writes it' }) satisfies CcslMessage)
368 schedulePreview($)
369}
370
371// The installed version, through the adapter (no shell): the catalog opens
372// only from the release it was read from.
373async function checkVersion($: EngineInterface) {
374 const adapter = await adapterOf($)
375 if (adapter === null) {
376 await update($, version, () => ({ state: 'failed', text: 'not a command the editor can run' }))
377 return
378 }
379 await update($, version, () => ({ state: 'checking', text: '' }))
380 try {
381 const ran = await $.process.run(versionArgv(adapter), { timeoutMs: DRY_RUN_MS })
382 const text = (ran.exitCode === 0 ? ran.stdout : ran.stderr).trim().split('\n')[0] ?? ''
383 await update($, version, () => (ran.exitCode === 0 ? { state: 'ok', text } : { state: 'failed', text: text || `exit ${ran.exitCode}` }))
384 } catch (error) {
385 await update($, version, () => ({ state: 'failed', text: errorText(error) }))
386 }
387}
388
389// Selections -------------------------------------------------------------------
390
391async function chooseLine($: EngineInterface, i: number) {
392 await update($, line, () => i)
393 await update($, widget, () => null)
394 await update($, scroll, () => 0)
395}
396
397async function removeCurrentLine($: EngineInterface) {
398 const li = await read($, line)
399 await edit($, c => removeLine(c, li))
400 const d = await read($, draft)
401 await chooseLine($, Math.max(0, Math.min(li, (d?.config?.lines.length ?? 1) - 1)))
402}
403
404async function moveChosen($: EngineInterface, delta: -1 | 1) {
405 const li = await read($, line)
406 const wi = await read($, widget)
407 if (wi === null) return
408 const before = await read($, draft)
409 await edit($, c => moveWidget(c, li, wi, delta))
410 if ((await read($, draft)) !== before) await update($, widget, () => wi + delta)
411}
412
413async function removeChosen($: EngineInterface) {
414 const li = await read($, line)
415 const wi = await read($, widget)
416 if (wi === null) return
417 await edit($, c => removeWidget(c, li, wi))
418 await update($, widget, () => null)
419}
420
421async function toggleFlag($: EngineInterface, field: 'bold' | 'rawValue') {
422 const li = await read($, line)
423 const wi = await read($, widget)
424 if (wi === null) return
425 await edit($, c => {
426 const w = c.lines[li]?.[wi]
427 return w === undefined ? c : setWidgetField(c, li, wi, field, w[field] ? undefined : true)
428 })
429}
430
431async function pickColor($: EngineInterface, value: string | undefined) {
432 const li = await read($, line)
433 const wi = await read($, widget)
434 const field = await read($, colorField)
435 if (wi === null) return
436 await edit($, c => setWidgetField(c, li, wi, field, value))
437}
438
439async function addChosen($: EngineInterface, type: string) {
440 const li = await read($, line)
441 await edit($, c => addWidget(c, li, type))
442 const d = await read($, draft)
443 await update($, widget, () => Math.max(0, (d?.config?.lines[li]?.length ?? 1) - 1))
444}
445
446// The widgets the Add page offers: the catalog from CATALOG_VERSION on; an
447// older or unknown version, only the types the file holds and the separators.
448type Offer = { gated: boolean; entries: { type: string; name: string }[] }
449const offerFor = (config: CcslConfig | null, v: StatusLineVersion, category: string | null): Offer => {
450 if (v.state === 'ok' && versionAtLeast(v.text, CATALOG_VERSION)) {
451 const cat = category ?? CATEGORIES[0]
452 return { gated: false, entries: CATALOG.filter(c => c.category === cat) }
453 }
454 const present = new Set((config?.lines ?? []).flat().map(w => w.type))
455 present.add('separator')
456 present.add('flex-separator')
457 return { gated: true, entries: [...present].sort().map(type => ({ type, name: catalogName(type) })) }
458}
459
460// Scrolling ----------------------------------------------------------------------
461
462// The editor list's items, by height: one row a widget (or the empty-line
463// notice), a blank row, the Preview chip (three rows), then the preview's
464// lines or the hint under it. The scroll window is a run of whole items.
465const PREVIEW_CHIP_ROWS = 3
466const editorHeights = (widgetCount: number, previewRows: number): number[] => [
467 ...Array<number>(Math.max(1, widgetCount)).fill(1),
468 1,
469 PREVIEW_CHIP_ROWS,
470 ...Array<number>(Math.max(1, previewRows)).fill(1),
471]
472async function editorHeightsNow($: EngineInterface): Promise<number[]> {
473 const d = await read($, draft)
474 const li = await read($, line)
475 const shown = await read($, preview)
476 return editorHeights((d?.config?.lines[li] ?? []).length, shown?.length ?? 0)
477}
478// The items from `top` that fit `room` rows: [from, to), at least one.
479const windowByRows = (heights: readonly number[], top: number, room: number) => {
480 const from = Math.max(0, Math.min(top, heights.length - 1))
481 let rows = 0
482 let to = from
483 while (to < heights.length) {
484 if (rows + heights[to]! > room && to > from) break
485 rows += heights[to]!
486 to++
487 }
488 return { from, to }
489}
490// The highest `top` that still fills the room from the end.
491const maxTop = (heights: readonly number[], room: number) => {
492 let rows = 0
493 let i = heights.length
494 while (i > 0 && rows + heights[i - 1]! <= room) rows += heights[--i]!
495 return Math.min(i, Math.max(0, heights.length - 1))
496}
497
498const clamp = (top: number, count: number, room: number) => Math.max(0, Math.min(top, count - room))
499
500// The wheel and the scroll keys move the page's own list window (from
501// register.tsx's ui.scroll hook).
502export async function scrollCcsl($: EngineInterface, page: CcslPanel, by: number, bodyRows: number) {
503 if (page === 'ccsl-editor') {
504 const heights = await editorHeightsNow($)
505 const room = Math.max(3, bodyRows - EDITOR_OVERHEAD)
506 await update($, scroll, top => Math.max(0, Math.min(top + by, maxTop(heights, room))))
507 } else if (page === 'ccsl-add') {
508 const d = await read($, draft)
509 const { entries } = offerFor(d?.config ?? null, await read($, version), await read($, addCategory))
510 await update($, addScroll, top => clamp(top + by, entries.length, Math.max(3, bodyRows - ADD_OVERHEAD)))
511 }
512}
513
514// The focus ring stepping onto a row past either end of the window brings it
515// into view (from register.tsx's ui.focus hook).
516export async function focusCcsl($: EngineInterface, page: CcslPanel, element: string | undefined, bodyRows: number) {
517 if (element === undefined) return
518 if (page === 'ccsl-editor') {
519 const heights = await editorHeightsNow($)
520 let at: number
521 if (element.startsWith('ccsl-w:')) at = Number(element.slice('ccsl-w:'.length))
522 else if (element === 'ccsl-preview-run') at = heights.length - Math.max(1, (await read($, preview))?.length ?? 0) - 1
523 else return
524 if (!Number.isInteger(at) || at < 0 || at >= heights.length) return
525 const room = Math.max(3, bodyRows - EDITOR_OVERHEAD)
526 const top = await read($, scroll)
527 const { to } = windowByRows(heights, top, room)
528 if (at < top) await update($, scroll, () => at)
529 else if (at >= to) {
530 // The lowest top from which `at` is in the window.
531 let newTop = at
532 let rows = heights[at]!
533 while (newTop > 0 && rows + heights[newTop - 1]! <= room) rows += heights[--newTop]!
534 await update($, scroll, () => newTop)
535 }
536 } else if (page === 'ccsl-add' && element.startsWith('ccsl-add:')) {
537 const type = element.slice('ccsl-add:'.length)
538 const d = await read($, draft)
539 const { entries } = offerFor(d?.config ?? null, await read($, version), await read($, addCategory))
540 const at = entries.findIndex(en => en.type === type)
541 if (at < 0) return
542 const room = Math.max(3, bodyRows - ADD_OVERHEAD)
543 const top = await read($, addScroll)
544 if (at < top) await update($, addScroll, () => at)
545 else if (at >= top + room) await update($, addScroll, () => clamp(at - room + 1, entries.length, room))
546 }
547}
548
549// Drawing ------------------------------------------------------------------------
550// One shape throughout: chips for actions and small choices, the Settings
551// page's `label value ›` rows to open a page, plain rows for lists.
552
553const detailOf = (w: CcslWidget) => `${w.color ?? '·'}/${(w.backgroundColor ?? '·').replace(/^bg/, '')}${w.bold ? ' B' : ''}${w.rawValue ? ' R' : ''}`
554
555type Go = (panel: Panel) => unknown
556
557export async function drawCcslPage($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go, page: CcslPanel): Promise<RenderChildren[]> {
558 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
559 if (page === 'ccsl-widget') return drawWidget($, e, ui, go)
560 if (page === 'ccsl-add') return drawAdd($, e, ui, go)
561 if (page === 'ccsl-color') return drawColor($, e, ui, go)
562 if (page === 'ccsl-powerline') return drawPowerline($, e, ui, go)
563 if (page === 'ccsl-layout') return drawLayout($, e, ui, go)
564 if (page === 'ccsl-more') return drawMore($, e, ui, go)
565 return drawEditor($, e, ui, go)
566}
567
568// A row that opens a page: the label, the value now, the chevron.
569function navRow($: EngineInterface, e: RenderInput<'Pane'>, key: string, label: string, value: string, onPress: () => unknown) {
570 const { Button, Text } = $.ui.resolve(e)
571 return (
572 <Button key={key} plain label={label} onPress={onPress}>
573 <Text>{label.padEnd(14)}</Text>
574 <Text bold wrap="truncate">
575 {value}
576 </Text>
577 <Text dimColor>{' ›'}</Text>
578 </Button>
579 )
580}
581
582// The lines as the status line drew them, in their colors.
583function previewRows($: EngineInterface, e: RenderInput<'Pane'>, shown: readonly string[]): RenderChildren[] {
584 const { Text } = $.ui.resolve(e)
585 return shown.map((l, i) => (
586 <Text key={`ccsl-preview:${i}`} wrap="truncate">
587 {parseAnsi(l).map(seg => (
588 <Text color={seg.color} backgroundColor={seg.backgroundColor} bold={seg.bold} dimColor={seg.dim} inverse={seg.inverse}>
589 {seg.text}
590 </Text>
591 ))}
592 </Text>
593 ))
594}
595
596// The preview under a sub-page: its title, "updating" while a refresh is
597// due or running, the lines; nothing before the first preview.
598async function previewBlock($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame): Promise<RenderChildren[]> {
599 const { Box, Text } = $.ui.resolve(e)
600 const shown = await read($, preview)
601 const isBusy = await read($, previewBusy)
602 if (!isBusy && (shown === null || shown.length === 0)) return []
603 return [
604 <Box key="ccsl-preview-head" gap={1} marginTop={1}>
605 <Text dimColor>{ui.t('ccslPreviewTitle')}</Text>
606 {isBusy && <Text dimColor>{ui.t('ccslPreviewUpdating')}</Text>}
607 </Box>,
608 ...previewRows($, e, shown ?? []),
609 ]
610}
611
612// The last action's result; `fallback`, dim, while there is none.
613function messageRow($: EngineInterface, e: RenderInput<'Pane'>, note: CcslMessage | null, fallback = '') {
614 const { Text } = $.ui.resolve(e)
615 if (note === null)
616 return (
617 <Text key="ccsl-message" dimColor wrap="truncate">
618 {fallback}
619 </Text>
620 )
621 return (
622 <Text key="ccsl-message" color={note.kind === 'error' ? 'error' : note.kind === 'warn' ? 'warning' : 'success'} wrap="truncate">
623 {note.text}
624 </Text>
625 )
626}
627
628async function drawEditor($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
629 const { Box, Text, Button } = $.ui.resolve(e)
630 const { t, chip, chips } = ui
631 const d = await read($, draft)
632 const li = await read($, line)
633 const isBusy = await read($, busy)
634 const note = await read($, message)
635 const shown = await read($, preview)
636 const updating = await read($, previewBusy)
637 const top = await read($, scroll)
638 const home = await homeDir($)
639 const back = chip('ccsl-back', t('ccslBackStatus'), false, () => go('statusline'))
640 if (d === null) return [chips('ccsl-top', [back]), <Text key="ccsl-wait" dimColor>…</Text>]
641
642 const c = d.config
643 const shortPath = d.path.startsWith(home) ? `~${d.path.slice(home.length)}` : d.path
644 const rows: RenderChildren[] = [
645 chips(
646 'ccsl-top',
647 c === null
648 ? [back]
649 : [
650 back,
651 chip('ccsl-save', isBusy ? t('ccslSaving') : d.dirty ? t('ccslSave') : t('ccslSaved'), d.dirty && !isBusy, () => save($, 'preview')),
652 chip('ccsl-revert', t('ccslRevert'), false, () => loadDraft($)),
653 ],
654 ),
655 messageRow($, e, note, shortPath),
656 ]
657 const more = chip('ccsl-more', t('ccslMore'), false, () => go('ccsl-more'))
658 if (c === null) {
659 // The reduced state: no config to edit; More restores a backup or resets.
660 rows.push(
661 <Text key="ccsl-reduced" color="warning">
662 {d.reason === 'missing' ? t('ccslMissing') : d.reason === 'old-version' ? t('ccslOld') : t('ccslBroken')}
663 </Text>,
664 chips('ccsl-nav', [more]),
665 )
666 return rows
667 }
668
669 const widgets = c.lines[li] ?? []
670 const lineChips = c.lines.map((_, i) => chip(`ccsl-line:${i}`, t('ccslLine', { n: i + 1 }), i === li, () => chooseLine($, i)))
671 if (c.lines.length < MAX_LINES) lineChips.push(chip('ccsl-add-line', t('ccslAddLine'), false, () => edit($, addLine).then(() => chooseLine($, c.lines.length))))
672 if (c.lines.length > 1 && widgets.length === 0) lineChips.push(chip('ccsl-remove-line', t('ccslRemoveLine'), false, () => removeCurrentLine($)))
673 rows.push(
674 chips('ccsl-lines', lineChips),
675 chips('ccsl-nav', [
676 chip('ccsl-add', t('ccslAdd'), false, () => openAdd($, go), 'claude'),
677 chip('ccsl-powerline', `${t('ccslPowerline')}${c.powerline?.enabled ? ' ✓' : ''}`, false, () => go('ccsl-powerline')),
678 chip('ccsl-layout', t('ccslLayout'), false, () => go('ccsl-layout')),
679 more,
680 ]),
681 )
682
683 // The chosen line's widgets, one row each, opening the widget's page.
684 const items: RenderChildren[] = widgets.map((w, i) => (
685 <Button key={`ccsl-w:${i}`} plain label={catalogName(w.type)} onPress={() => update($, widget, () => i).then(() => go('ccsl-widget'))}>
686 <Text>{`${i + 1}. ${catalogName(w.type)}${isKnownType(w.type) ? '' : ' ?'}`.padEnd(22)}</Text>
687 <Text dimColor wrap="truncate">
688 {detailOf(w)}
689 </Text>
690 <Text dimColor>{' ›'}</Text>
691 </Button>
692 ))
693 if (widgets.length === 0)
694 items.push(
695 <Text key="ccsl-empty" dimColor>
696 {t('ccslEmptyLine')}
697 </Text>,
698 )
699 // A gap, the Preview chip, then the lines as the status line drew them, in
700 // their colors.
701 items.push(
702 <Text key="ccsl-gap"> </Text>,
703 chips('ccsl-preview-row', [chip('ccsl-preview-run', updating ? t('ccslPreviewUpdating') : t('ccslPreviewRun'), updating, () => previewDraft($), 'claude')]),
704 )
705 if (shown === null || shown.length === 0)
706 items.push(
707 <Text key="ccsl-preview-hint" dimColor wrap="wrap">
708 {t('ccslPreviewHint')}
709 </Text>,
710 )
711 else items.push(...previewRows($, e, shown))
712 const room = Math.max(3, e.props.scroll.bodyRows - EDITOR_OVERHEAD)
713 const { from, to } = windowByRows(editorHeights(widgets.length, shown?.length ?? 0), top, room)
714 rows.push(
715 <Box key="ccsl-list" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
716 {items.slice(from, to)}
717 </Box>,
718 )
719 return rows
720}
721
722// One widget: where it sits, its moves, its flags, its colors.
723async function drawWidget($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
724 const { Box, Text } = $.ui.resolve(e)
725 const { t, chip, chips, heading } = ui
726 const d = await read($, draft)
727 const li = await read($, line)
728 const wi = await read($, widget)
729 const widgets = d?.config?.lines[li] ?? []
730 const w = wi === null ? undefined : widgets[wi]
731 const back = chip('ccsl-widget-back', t('ccslBackEditor'), false, () => go('ccsl-editor'))
732 if (w === undefined || wi === null)
733 return [
734 chips('ccsl-widget-top', [back]),
735 <Text key="ccsl-widget-none" dimColor>
736 {t('ccslNoWidget')}
737 </Text>,
738 ]
739 const name = catalogName(w.type)
740 const actions: RenderChildren[] = []
741 if (wi > 0) actions.push(chip('ccsl-left', t('ccslMoveLeft'), false, () => moveChosen($, -1)))
742 if (wi < widgets.length - 1) actions.push(chip('ccsl-right', t('ccslMoveRight'), false, () => moveChosen($, 1)))
743 actions.push(chip('ccsl-remove', t('ccslRemove'), false, () => removeChosen($).then(() => go('ccsl-editor')), 'error'))
744 return [
745 chips('ccsl-widget-top', [back]),
746 heading(t('ccslWidgetTitle', { name, n: li + 1, i: wi + 1, m: widgets.length })),
747 chips('ccsl-widget-actions', actions),
748 chips('ccsl-widget-flags', [
749 chip('ccsl-bold', t('ccslBoldChip'), w.bold === true, () => toggleFlag($, 'bold')),
750 chip('ccsl-raw', t('ccslRawChip'), w.rawValue === true, () => toggleFlag($, 'rawValue')),
751 ]),
752 <Box key="ccsl-widget-rows" flexDirection="column" marginTop={1}>
753 {navRow($, e, 'ccsl-color', t('ccslColorRow'), w.color ?? t('ccslNone'), () => update($, colorField, () => 'color').then(() => go('ccsl-color')))}
754 {navRow($, e, 'ccsl-bg', t('ccslBgRow'), w.backgroundColor ?? t('ccslNone'), () => update($, colorField, () => 'backgroundColor').then(() => go('ccsl-color')))}
755 </Box>,
756 ...(await previewBlock($, e, ui)),
757 ]
758}
759
760async function openAdd($: EngineInterface, go: Go) {
761 await update($, addScroll, () => 0)
762 await go('ccsl-add')
763 if ((await read($, version)).state === 'idle') await checkVersion($)
764}
765
766async function drawAdd($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
767 const { Box, Text, Button } = $.ui.resolve(e)
768 const { t, heading, chip, chips } = ui
769 const d = await read($, draft)
770 const li = await read($, line)
771 const v = await read($, version)
772 const cat = (await read($, addCategory)) ?? CATEGORIES[0]!
773 const top = await read($, addScroll)
774 const { gated, entries } = offerFor(d?.config ?? null, v, cat)
775 const rows: RenderChildren[] = [
776 chips('ccsl-add-top', [chip('ccsl-add-back', t('ccslBackEditor'), false, () => go('ccsl-editor'))]),
777 heading(t('ccslAddTitle', { n: li + 1 })),
778 v.state === 'checking' ? (
779 <Text key="ccsl-add-version" dimColor>
780 {t('ccslVersionChecking')}
781 </Text>
782 ) : gated ? (
783 <Text key="ccsl-add-version" color="warning">
784 {t('ccslVersionGate', { v: CATALOG_VERSION })}
785 </Text>
786 ) : (
787 <Text key="ccsl-add-version" dimColor>
788 {`ccstatusline ${v.text}`}
789 </Text>
790 ),
791 ]
792 if (!gated)
793 rows.push(
794 <Box key="ccsl-cats" flexWrap="wrap" columnGap={2}>
795 {CATEGORIES.map(name => (
796 <Button key={`ccsl-cat:${name}`} plain label={name} onPress={() => update($, addCategory, () => name).then(() => update($, addScroll, () => 0))}>
797 <Text bold={name === cat} color={name === cat ? 'claude' : undefined} dimColor={name !== cat}>
798 {name}
799 </Text>
800 </Button>
801 ))}
802 </Box>,
803 )
804 const room = Math.max(3, e.props.scroll.bodyRows - ADD_OVERHEAD)
805 const from = Math.max(0, Math.min(top, entries.length - 1))
806 rows.push(
807 <Box key="ccsl-add-list" flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
808 {entries.slice(from, from + room).map(en => (
809 <Button key={`ccsl-add:${en.type}`} plain label={en.name} onPress={() => addChosen($, en.type).then(() => go('ccsl-editor'))}>
810 <Text>{en.name.padEnd(22)}</Text>
811 <Text dimColor>{en.type}</Text>
812 </Button>
813 ))}
814 </Box>,
815 )
816 return rows
817}
818
819// A color typed in, in any of ccstatusline's spellings.
820async function setOtherColor($: EngineInterface, raw: string) {
821 const field = await read($, colorField)
822 const value = normalizeColorInput(raw)
823 if (value === null || !isColor(value, field === 'backgroundColor')) {
824 $.ui.toast(`${raw.trim()} is not a color: ansi256:N (0 to 255), N, hex:RRGGBB or #RRGGBB`)
825 return
826 }
827 await pickColor($, value)
828}
829
830async function drawColor($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
831 // Input exists on the terminal and desktop surfaces only.
832 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
833 const { Box, Text, Button, Input } = $.ui.resolve(e)
834 const { t, heading, chip, chips } = ui
835 const d = await read($, draft)
836 const li = await read($, line)
837 const wi = await read($, widget)
838 const field = await read($, colorField)
839 const w = wi === null ? undefined : d?.config?.lines[li]?.[wi]
840 const name = w === undefined ? '' : catalogName(w.type)
841 const current = w?.[field]
842 const palette = field === 'color' ? FOREGROUNDS : BACKGROUNDS
843 const pick = (value: string | undefined) => pickColor($, value)
844 const rows: RenderChildren[] = [
845 chips('ccsl-color-top', [chip('ccsl-color-back', t('ccslBackWidget'), false, () => go('ccsl-widget'))]),
846 heading(t(field === 'color' ? 'ccslColorTitle' : 'ccslBgTitle', { name })),
847 <Text key="ccsl-color-current" dimColor>
848 {`${t('ccslCurrent')}: `}
849 {current !== undefined && <Text color={swatchOf(current)}>{'■ '}</Text>}
850 {current ?? t('ccslNone')}
851 </Text>,
852 ]
853 if (current !== undefined && !palette.includes(current))
854 rows.push(
855 <Text key="ccsl-color-kept" color="warning">
856 {t('ccslKept')}
857 </Text>,
858 )
859 rows.push(
860 <Box key="ccsl-palette" flexWrap="wrap" marginTop={1}>
861 <Button key="ccsl-pal:none" plain label={t('ccslNone')} onPress={() => pick(undefined)}>
862 <Text bold={current === undefined} color={current === undefined ? 'claude' : undefined}>
863 {` ${t('ccslNone')}`.padEnd(26)}
864 </Text>
865 </Button>
866 {palette.map(value => (
867 <Button key={`ccsl-pal:${value}`} plain label={value} onPress={() => pick(value)}>
868 <Text color={swatchOf(value)}>{'■ '}</Text>
869 <Text bold={value === current} color={value === current ? 'claude' : undefined}>
870 {value.replace(/^bg/, '').padEnd(24)}
871 </Text>
872 </Button>
873 ))}
874 </Box>,
875 heading(t('ccslOtherTitle')),
876 <Input
877 key="ccsl-color-other"
878 label={t('ccslOtherLabel')}
879 value={current !== undefined && !palette.includes(current) ? current : ''}
880 placeholder="ansi256:235"
881 submitLabel="set"
882 onSubmit={value => setOtherColor($, value)}
883 />,
884 ...(await previewBlock($, e, ui)),
885 )
886 return rows
887}
888
889async function drawPowerline($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
890 const { Box, Text, Button } = $.ui.resolve(e)
891 const { t, heading, chip, chips } = ui
892 const d = await read($, draft)
893 const pl = d?.config?.powerline ?? {}
894 const enabled = pl.enabled === true
895 const theme = pl.theme ?? 'nord-aurora'
896 return [
897 chips('ccsl-pl-top', [chip('ccsl-pl-back', t('ccslBackEditor'), false, () => go('ccsl-editor'))]),
898 heading(t('ccslPowerline')),
899 chips('ccsl-pl-switch', [
900 chip('ccsl-pl-on', t('on'), enabled, () => edit($, c => setPowerline(c, { enabled: true }))),
901 chip('ccsl-pl-off', t('off'), !enabled, () => edit($, c => setPowerline(c, { enabled: false }))),
902 ]),
903 heading(t('ccslTheme')),
904 <Box key="ccsl-themes" flexWrap="wrap">
905 {POWERLINE_THEMES.map(name => (
906 <Button key={`ccsl-theme:${name}`} plain label={name} onPress={() => edit($, c => setPowerline(c, { theme: name }))}>
907 <Text bold={name === theme} color={name === theme ? 'claude' : undefined} dimColor={name !== theme}>
908 {`${name === theme ? '◆' : ' '} ${name}`.padEnd(17)}
909 </Text>
910 </Button>
911 ))}
912 </Box>,
913 chips('ccsl-pl-flags', [
914 chip('ccsl-pl-align', t('ccslAutoAlign'), pl.autoAlign === true, () => edit($, c => setPowerline(c, { autoAlign: !(c.powerline?.autoAlign === true) }))),
915 chip('ccsl-pl-continue', t('ccslContinue'), pl.continueThemeAcrossLines === true, () =>
916 edit($, c => setPowerline(c, { continueThemeAcrossLines: !(c.powerline?.continueThemeAcrossLines === true) })),
917 ),
918 ]),
919 ...(await previewBlock($, e, ui)),
920 ]
921}
922
923async function drawLayout($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
924 const { t, heading, chip, chips } = ui
925 const d = await read($, draft)
926 const c = d?.config
927 const flex = c?.flexMode ?? 'full'
928 const compact = c?.compactThreshold ?? 60
929 return [
930 chips('ccsl-layout-top', [chip('ccsl-layout-back', t('ccslBackEditor'), false, () => go('ccsl-editor'))]),
931 heading(t('ccslFlex')),
932 chips(
933 'ccsl-flex',
934 FLEX_MODES.map(mode => chip(`ccsl-flex:${mode}`, mode, mode === flex, () => edit($, x => setLayout(x, { flexMode: mode })))),
935 ),
936 heading(t('ccslCompact')),
937 chips(
938 'ccsl-compact',
939 COMPACT_THRESHOLDS.map(n => chip(`ccsl-compact:${n}`, `${n}%`, n === compact, () => edit($, x => setLayout(x, { compactThreshold: n })))),
940 ),
941 heading(t('ccslMinimalist')),
942 chips('ccsl-minimalist-row', [chip('ccsl-minimalist', t('ccslMinimalist'), c?.minimalistMode === true, () => edit($, x => setLayout(x, { minimalistMode: !(x.minimalistMode === true) })))]),
943 ...(await previewBlock($, e, ui)),
944 ]
945}
946
947async function drawMore($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: Go): Promise<RenderChildren[]> {
948 const { Box, Text } = $.ui.resolve(e)
949 const { t, heading, chip, chips } = ui
950 const names = await read($, backups)
951 const note = await read($, message)
952 const adapter = await adapterOf($)
953 const d = await read($, draft)
954 return [
955 chips('ccsl-more-top', [chip('ccsl-more-back', t('ccslBackEditor'), false, () => go('ccsl-editor'))]),
956 heading(t('ccslRestoreTitle')),
957 <Box key="ccsl-backups" flexDirection="column">
958 {names.length === 0 ? (
959 <Text dimColor>{t('ccslNoBackups')}</Text>
960 ) : (
961 names.map(name => navRow($, e, `ccsl-restore:${name}`, backupStamp(name), '', () => restore($, name).then(() => go('ccsl-editor'))))
962 )}
963 </Box>,
964 heading(t('ccslMore')),
965 chips('ccsl-more-actions', [
966 chip('ccsl-reset', t('ccslReset'), false, () => resetToStarter($).then(() => go('ccsl-editor'))),
967 chip('ccsl-save-blind', t('ccslSaveBlind'), false, () => save($, 'blind'), d?.dirty === true ? undefined : 'inactive'),
968 ]),
969 <Text key="ccsl-more-note" dimColor>
970 {adapter === null ? t('ccslNoAdapter') : t('ccslBlindNote')}
971 </Text>,
972 messageRow($, e, note),
973 ]
974}
975hooks/pages/focus.tsx 611 lines1// Focus mode: a progress band above the prompt and the tool rows hidden
2// from the transcript while it is on. The steps come from the band's own
3// focus_progress tool (Claude posts its plan, then the number of each step
4// it starts), and from the todo list, the task tools and an approved plan
5// where the session has them; every tool call joins a feed of Claude's own
6// one-line descriptions, and a row of it opens on what the call ran and
7// printed. Costs, while on: the tool in the tool list, one line beside each
8// prompt typed, and the tool's calls; off, nothing.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, On, PromptSubmitInput, RenderChildren, RenderInput, Timer } from 'claude-code'
12
13import type { FocusAction, FocusColors, FocusStep, FocusTask, FocusTheme, Panel, StepSource, TurnStop } from '../../types'
14import { glyph, iconSetNow } from '../icons'
15import {
16 actionOf,
17 bandColors,
18 barRuns,
19 callsText,
20 canPost,
21 clockText,
22 detailOf,
23 endsOnLastStep,
24 feedInView,
25 FEED_DETAIL_LINES,
26 FOCUS_MARKER,
27 FOCUS_THEMES,
28 FOCUS_TOOL_DESCRIPTION,
29 FOCUS_TOOL_NAME,
30 FOCUS_TOOL_SCHEMA,
31 isApprovedPlan,
32 isWaiting,
33 outputOf,
34 parsePlanSteps,
35 progressOf,
36 progressPost,
37 progressReply,
38 RAINBOW,
39 stampSteps,
40 stepCaption,
41 stepColor,
42 stepsAfterPost,
43 stepsFromTodos,
44 stepsInView,
45 stepTime,
46 stepTint,
47 stopLabel,
48 taskElapsed,
49 toStatus,
50 withCall,
51 withCallDone,
52} from '../lib/focus'
53import { rainbow, type Frame } from '../ui/frame'
54
55const focusOn = atom({ plugin: 'cc-esol', key: 'focusOn' } as const, false)
56const theme = atom({ plugin: 'cc-esol', key: 'focusTheme' } as const, 'blue')
57const customColors = atom({ plugin: 'cc-esol', key: 'focusCustom' } as const, { bar: '#60a5fa', text: '#e2e8f0', border: '#3b82f6' } satisfies FocusColors)
58const collapsed = atom({ plugin: 'cc-esol', key: 'focusCollapsed' } as const, false)
59const task = atom({ plugin: 'cc-esol', key: 'task' } as const, null)
60const feed = atom({ plugin: 'cc-esol', key: 'feed' } as const, [])
61// The feed row opened on its detail, by the call's id.
62const feedOpen = atom({ plugin: 'cc-esol', key: 'feedOpen' } as const, null)
63const now = atom({ plugin: 'cc-esol', key: 'now' } as const, 0)
64
65const HEX_COLOR = /^#[0-9a-f]{6}$/i
66// How long "Done" stays on the band before it clears.
67const DONE_LINGER_MS = 8000
68// Rows the band gives its steps, and its feed of calls: alone, or under the running step.
69const STEP_ROWS = 6
70const FEED_ROWS = 4
71const FEED_ROWS_UNDER_STEP = 2
72// Columns the band leaves free at the right of the body.
73const BAND_SLACK = 1
74
75// Settings ---------------------------------------------------------------------
76
77async function switchFocus($: EngineInterface, on: boolean) {
78 await update($, focusOn, () => on)
79 await $.store.set('focusOn', on)
80 if (!on) await update($, task, () => null)
81 // focus_progress sits in the tool list only while on.
82 $.ui.invalidate('tool.describe')
83}
84
85async function pickTheme($: EngineInterface, next: FocusTheme) {
86 await update($, theme, () => next)
87 await $.store.set('focusTheme', next)
88}
89
90async function pickCustomColor($: EngineInterface, part: keyof FocusColors, raw: string) {
91 const value = raw.trim()
92 if (!HEX_COLOR.test(value)) {
93 $.ui.toast(`${raw} is not a hex color like #3b82f6`)
94 return
95 }
96 const colors = await update($, customColors, c => ({ ...c, [part]: value }))
97 await $.store.set('focusCustom', colors)
98}
99
100export async function loadFocus($: EngineInterface) {
101 const on = await $.store.get('focusOn')
102 if (typeof on === 'boolean') await update($, focusOn, () => on)
103 const kept = await $.store.get('focusTheme')
104 if ((FOCUS_THEMES as readonly unknown[]).includes(kept)) await update($, theme, () => kept as FocusTheme)
105 const colors = await $.store.get('focusCustom')
106 if (colors !== undefined) await update($, customColors, () => colors as FocusColors)
107}
108
109// The clock -------------------------------------------------------------------
110
111// One pulse per module load, moving `now` once a second while a task runs.
112let pulse: Timer | undefined
113
114function stopClock() {
115 pulse?.cancel()
116 pulse = undefined
117}
118
119function runClock($: EngineInterface) {
120 stopClock()
121 const tick = () => void $.clock.now().then(at => update($, now, () => at))
122 pulse = $.clock.every(1000, tick)
123}
124
125// A /clear ends the session: its clock stops with it. The task and the feed
126// are session values and start over on their own.
127export function focusSessionEnd() {
128 stopClock()
129}
130
131// The task ---------------------------------------------------------------------
132
133// The first line of the last prompt typed: the band's title.
134let promptTitle = ''
135
136// The feed starts over with each prompt: the calls of the turn before stay
137// on the band, open to inspection, until then.
138async function clearFeed($: EngineInterface) {
139 await update($, feed, () => [])
140 await update($, feedOpen, () => null)
141}
142
143async function openTask($: EngineInterface, title: string) {
144 const at = await $.clock.now()
145 const opened: FocusTask = { title: title || 'Working', startedAt: at, doneAt: null, steps: [], waitingAt: null, pausedMs: 0 }
146 await update($, task, () => opened)
147 await clearFeed($)
148 await update($, now, () => at)
149 runClock($)
150}
151
152// A waiting task goes on; the time it waited leaves the clock.
153async function continueTask($: EngineInterface) {
154 const at = await $.clock.now()
155 await update($, task, t => (t === null || !isWaiting(t) ? t : { ...t, waitingAt: null, stoppedBy: null, pausedMs: (t.pausedMs ?? 0) + (at - t.waitingAt!) }))
156 await clearFeed($)
157 await update($, now, () => at)
158 runClock($)
159}
160
161// New steps land on the task under way; after a finished one, or none, on a new task.
162async function landingTask($: EngineInterface) {
163 const current = await read($, task)
164 if (current === null || current.doneAt !== null) await openTask($, promptTitle)
165 else if (isWaiting(current)) await continueTask($)
166}
167
168// The steps replaced, their clocks stamped against the ones before, the
169// title when given, the source noted. A source that may not post (the band's
170// own tool owns the task) leaves it as it is.
171async function replaceSteps($: EngineInterface, source: StepSource, make: (prev: FocusStep[]) => FocusStep[], title?: string) {
172 const at = await $.clock.now()
173 await update($, task, t =>
174 t === null || !canPost(source, t.source) ? t : { ...t, ...(title === undefined ? {} : { title }), source, steps: stampSteps(t.steps, make(t.steps), at, t.startedAt) },
175 )
176}
177
178// Whether a source may post to the task under way; a finished one gives
179// way to a new task.
180async function mayPost($: EngineInterface, source: StepSource): Promise<boolean> {
181 const t = await read($, task)
182 return t === null || t.doneAt !== null || canPost(source, t.source)
183}
184
185// Each prompt typed continues the waiting task or opens a new one, and
186// carries the one line that points Claude at focus_progress. From the
187// prompt.submit hook in register.tsx.
188export async function focusPromptSubmit($: EngineInterface, e: PromptSubmitInput): Promise<PromptSubmitInput> {
189 const typed = e.text.trim()
190 if (e.origin.kind !== 'composer' || typed === '' || typed.startsWith('/')) return e
191 promptTitle = (typed.split('\n')[0] ?? '').slice(0, 200)
192 if (!(await read($, focusOn))) return e
193 const underway = await read($, task)
194 if (underway !== null && underway.doneAt === null && isWaiting(underway)) await continueTask($)
195 else await openTask($, promptTitle)
196 return { ...e, context: [...(e.context ?? []), FOCUS_MARKER] }
197}
198
199// The turn's end: waiting when steps are still pending (the clock pauses),
200// done when every step is or there were none. A turn that answered while
201// focus_progress had its last step running finishes that step: the tool
202// needs no call of its own to say done. A turn that ended without an answer
203// (interrupted, refused, an error) says so, unless every step of its list
204// was done: an empty list is no proof of success. With calls on the feed
205// the band stays, open to inspection, until the next prompt; with none,
206// Done stays a moment and clears. From the turn.complete hook.
207export async function focusTurnComplete($: EngineInterface, reason: TurnStop | 'answer' = 'answer') {
208 stopClock()
209 const before = await read($, task)
210 if (before === null || before.doneAt !== null || isWaiting(before)) return
211 const at = await $.clock.now()
212 const stop = reason === 'answer' ? null : reason
213 if (stop === null && endsOnLastStep(before)) await replaceSteps($, 'progress', steps => steps.map(s => ({ ...s, status: 'completed' })))
214 const underway = (await read($, task)) ?? before
215 await update($, now, () => at)
216 if (underway.steps.some(s => s.status !== 'completed')) {
217 await update($, task, t => (t === null ? t : { ...t, waitingAt: at, stoppedBy: stop }))
218 return
219 }
220 await update($, task, t => (t === null ? t : { ...t, doneAt: at, stoppedBy: t.steps.length === 0 ? stop : null }))
221 if ((await read($, feed)).length > 0) return
222 $.clock.after(DONE_LINGER_MS, () => {
223 void update($, task, t => (t !== null && t.doneAt === at ? null : t))
224 })
225}
226
227// Claude's own progress tool, registered at the session's start. Where it
228// waits (the tool list, or behind ToolSearch) is the tool.describe hook's.
229export async function registerFocusTool($: EngineInterface) {
230 try {
231 await $.tool.register({ name: FOCUS_TOOL_NAME, description: FOCUS_TOOL_DESCRIPTION, inputSchema: FOCUS_TOOL_SCHEMA })
232 } catch (error) {
233 try {
234 $.ui.toast(`cc-esol: the focus_progress tool could not be registered, so the Focus band shows no steps of its own: ${error instanceof Error ? error.message : String(error)}`)
235 } catch {}
236 }
237}
238
239// Drawing ----------------------------------------------------------------------
240
241type Look = { colors: FocusColors; theme: FocusTheme; isLight: boolean }
242
243async function lookNow($: EngineInterface): Promise<Look> {
244 const current = await read($, theme)
245 return { colors: bandColors(current, await read($, customColors)), theme: current, isLight: current === 'light' }
246}
247
248// The band above the prompt: null when Focus is off or nothing is running.
249async function drawBand($: EngineInterface, e: RenderInput<'AbovePrompt'>) {
250 const t = await read($, task)
251 if (t === null || !(await read($, focusOn))) return null
252 const { Box, Text, Button } = $.ui.resolve(e)
253 const look = await lookNow($)
254 const { colors } = look
255 const isFolded = await read($, collapsed)
256 const clock = await read($, now)
257 // The band stops one column short of the body: a glyph the terminal draws
258 // a cell wider than Claude Code measures then still fits its line, where a
259 // full-width line would wrap and leave the rows below drawn a line off.
260 const columns = Math.max((e.props.bodyColumns ?? 80) - BAND_SLACK, 30)
261 const frameProps = { borderStyle: 'round', borderColor: colors.border, paddingX: 1, width: columns } as const
262 const titleText = (text: string) => (
263 <Text color={colors.text} bold wrap="truncate">
264 {text}
265 </Text>
266 )
267 const paused = isWaiting(t)
268 const percent = progressOf(t)
269 // The title is the prompt: a star while the task runs, the done mark once
270 // it is over, the cross when its last turn stopped without an answer. The
271 // steps and the calls take one mark each: the brain until they are done,
272 // the done mark then, the cross for a call that failed. The brain emoji
273 // takes two cells and the marks one, so those are padded to keep the rows
274 // lined up; Nerd glyphs are one cell each.
275 const brain = await glyph($, 'brain')
276 const doneMark = await glyph($, 'done')
277 const failedMark = await glyph($, 'failed')
278 const mark = t.doneAt === null ? '✶' : t.stoppedBy ? failedMark : doneMark
279 const pad = (await iconSetNow($)) === 'unicode' ? ' ' : ''
280 const rowMark = (done: boolean) => (done ? `${doneMark}${pad}` : brain)
281 const callMark = (call: FocusAction) => (call.doneAt == null ? brain : call.failed ? `${failedMark}${pad}` : `${doneMark}${pad}`)
282 const pauseWord = t.stoppedBy ? stopLabel(t.stoppedBy) : 'Paused'
283
284 // The bar takes the room the title, the caption and the percent leave.
285 const titleCells = isFolded ? Math.min(30, t.title.length + 2) : 0
286 const barWidth = Math.max(8, columns - 26 - titleCells)
287 const { filled, runs } = barRuns(look.theme, colors.bar, barWidth, percent)
288 const bar = (
289 <Box>
290 {runs.map(run => (
291 <Text color={run.color}>{'█'.repeat(run.cells)}</Text>
292 ))}
293 <Text dimColor>{'░'.repeat(barWidth - filled)}</Text>
294 </Box>
295 )
296 const percentText = (
297 <Text color={colors.text} bold>
298 {`${percent}%`.padStart(4)}
299 </Text>
300 )
301 const foldButton = <Button key="focus-collapse" plain dimColor label={isFolded ? '[+]' : '[–]'} onPress={() => update($, collapsed, v => !v)} />
302
303 if (isFolded)
304 return (
305 <Box {...frameProps} gap={1}>
306 <Box width={titleCells}>{titleText(`${mark} ${t.title}`)}</Box>
307 {t.steps.length > 0 ? bar : <Text dimColor>{`${stepCaption(t)}${callsText(t) === '' ? '' : ` · ${callsText(t)}`}`}</Text>}
308 {t.steps.length > 0 && percentText}
309 {foldButton}
310 </Box>
311 )
312
313 // The feed: the last calls, each as Claude described it, the ones done
314 // dimmed and the one under way in the band's color with its clock. Under
315 // the running step, after the steps while none runs, or in their place
316 // without a list; it stays after the turn, until the next prompt. A row is
317 // a button: pressed, it opens on what its call ran and printed (the
318 // command or the path, then the first lines out), and closes again.
319 const calls = await read($, feed)
320 const opened = await read($, feedOpen)
321 const detailRows = (call: FocusAction, indent: number): RenderChildren => {
322 const ran = (call.detail ?? '').split('\n').filter(l => l !== '')
323 const out = (call.output ?? []).slice(0, FEED_DETAIL_LINES)
324 const left = (call.output?.length ?? 0) - out.length + (call.moreLines ?? 0)
325 const quiet = call.doneAt != null && out.length === 0
326 return (
327 <Box key={`feed-detail-${call.id}`} flexDirection="column" paddingLeft={indent + 2}>
328 {ran.map((line, i) => (
329 <Text key={`feed-ran-${call.id}-${i}`} color={colors.text} wrap="truncate">
330 {`${i === 0 && call.tool === 'Bash' ? '$ ' : ' '}${line}`}
331 </Text>
332 ))}
333 {out.map((line, i) => (
334 <Text key={`feed-out-${call.id}-${i}`} dimColor wrap="truncate">
335 {line === '' ? ' ' : line}
336 </Text>
337 ))}
338 {call.doneAt == null && <Text dimColor>running…</Text>}
339 {quiet && <Text dimColor>(no output)</Text>}
340 {left > 0 && <Text dimColor>{`… ${left} more line${left === 1 ? '' : 's'} · ctrl+o shows the whole call`}</Text>}
341 </Box>
342 )
343 }
344 const feedRows = (indent: number, rows: number): RenderChildren[] =>
345 feedInView(calls, rows).flatMap(call => {
346 const running = call.doneAt == null
347 const row = (
348 <Box key={`feed-row-${call.id}`} paddingLeft={indent} gap={1} justifyContent="space-between">
349 <Button key={`feed-${call.id}`} plain label={`${call.tool} ${call.target}`.trim()} onPress={() => update($, feedOpen, id => (id === call.id ? null : call.id))}>
350 <Text wrap="truncate" dimColor={!running} color={running ? colors.text : undefined}>
351 {`${callMark(call)} `}
352 <Text bold={running}>{call.tool}</Text>
353 {call.target === '' ? '' : ` ${call.target}`}
354 </Text>
355 </Button>
356 {running && <Text dimColor>{clockText(clock - call.startedAt)}</Text>}
357 </Box>
358 )
359 return opened === call.id ? [row, detailRows(call, indent)] : [row]
360 })
361 const callCount = callsText(t)
362 const shown = stepsInView(t, STEP_ROWS)
363 const runningRow = shown.findIndex(step => step.status === 'in_progress')
364 const finished = t.steps.filter(s => s.status === 'completed').length
365
366 // One row a step: its mark and number in the gutter, then a strip tinted
367 // by state with the dot, the title, Working on the running one (Paused
368 // while the turn is over, or how it stopped) and the step's clock.
369 const stepRow = (step: FocusStep, i: number) => {
370 const running = step.status === 'in_progress'
371 const number = String(t.steps.indexOf(step) + 1).padStart(2)
372 const time = stepTime(step, clock)
373 return (
374 <Box key={`step-${step.id}`} flexDirection="column">
375 <Box gap={1}>
376 <Text dimColor>{`${rowMark(step.status === 'completed')} ${number}`}</Text>
377 <Box flexGrow={1} backgroundColor={stepTint(step.status, colors.bar, look.isLight)} paddingX={1} justifyContent="space-between" gap={1}>
378 <Text wrap="truncate">
379 <Text color={stepColor(step.status, colors.bar)}>{'● '}</Text>
380 <Text color={running ? colors.text : undefined} bold={running} dimColor={step.status === 'pending'}>
381 {step.text}
382 </Text>
383 </Text>
384 <Box gap={2} flexShrink={0}>
385 {running && <Text color={colors.text}>{paused ? pauseWord : 'Working'}</Text>}
386 {time !== '' && <Text dimColor>{time}</Text>}
387 </Box>
388 </Box>
389 </Box>
390 {i === runningRow && feedRows(5, FEED_ROWS_UNDER_STEP)}
391 </Box>
392 )
393 }
394
395 return (
396 <Box {...frameProps} flexDirection="column">
397 <Box gap={1} justifyContent="space-between">
398 {titleText(`${mark} ${t.title}`)}
399 <Box flexShrink={0} gap={1}>
400 <Text dimColor>{paused ? pauseWord.toLowerCase() : clockText(taskElapsed(t, clock))}</Text>
401 {callCount !== '' && <Text dimColor>{`· ${callCount}`}</Text>}
402 {foldButton}
403 </Box>
404 </Box>
405 {t.steps.length > 0 ? (
406 <Box gap={1}>
407 <Text dimColor>{stepCaption(t).padEnd(14)}</Text>
408 {bar}
409 {percentText}
410 </Box>
411 ) : (
412 // No list: the caption, then the feed in the steps' place, no bar to fill.
413 <Text dimColor>{stepCaption(t)}</Text>
414 )}
415 {t.steps.length === 0 && feedRows(0, FEED_ROWS)}
416 {shown.map(stepRow)}
417 {t.steps.length > shown.length && <Text dimColor>{`${brain} +${t.steps.length - shown.length} more · ${finished} done`}</Text>}
418 {t.steps.length > 0 && runningRow === -1 && feedRows(5, FEED_ROWS_UNDER_STEP)}
419 </Box>
420 )
421}
422
423// Focus on and off, on the Settings page, and the chip that opens the color
424// page, framed and written in the band's color (rainbow: one color a letter).
425export async function drawFocusSettings($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: (panel: Panel) => unknown): Promise<RenderChildren[]> {
426 const { Text, Button } = $.ui.resolve(e)
427 const { t, heading, chips, chip, framed } = ui
428 const on = await read($, focusOn)
429 const picked = await read($, theme)
430 const { bar } = bandColors(picked, await read($, customColors))
431 const label = t('focusBandRow')
432 const painted = framed(
433 'focus-band',
434 true,
435 <Button key="focus-band" plain label={label} onPress={() => go('focus-band')}>
436 <Text color={bar}>{picked === 'rainbow' ? rainbow(label).map(({ ch, color }) => <Text color={color}>{ch}</Text>) : label}</Text>
437 </Button>,
438 bar,
439 )
440 return [
441 heading(t('focusMode')),
442 chips('focus-switch', [chip('focus-on', t('on'), on, () => switchFocus($, true)), chip('focus-off', t('off'), !on, () => switchFocus($, false)), painted]),
443 ]
444}
445
446// The Focus color page, opened from Settings: the presets as rows of
447// swatches, and three fields when the custom one is picked.
448export async function drawFocusBandPage($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: (panel: Panel) => unknown): Promise<RenderChildren[]> {
449 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
450 const { Box, Text, Button, Input } = $.ui.resolve(e)
451 const { t, heading, chips, chip, check, swatches } = ui
452 const picked = await read($, theme)
453 const custom = await read($, customColors)
454 const rows: RenderChildren[] = [chips('focus-band-nav', [chip('focus-band-back', t('backSettings'), false, () => go('settings'))]), heading(t('bandColor'))]
455 for (const option of FOCUS_THEMES) {
456 const isOn = option === picked
457 const sample = option === 'rainbow' ? RAINBOW.slice(0, 6) : Array<string>(6).fill(bandColors(option, custom).bar)
458 rows.push(
459 <Button key={`focus-theme-${option}`} plain label={option} onPress={() => pickTheme($, option)}>
460 {swatches(sample)}
461 <Text bold={isOn}>{option}</Text>
462 {check(isOn)}
463 </Button>,
464 )
465 }
466 if (picked === 'custom') {
467 const parts: (keyof FocusColors)[] = ['bar', 'text', 'border']
468 const fields = parts.map(part => (
469 <Input key={`focus-color-${part}`} label={`${part} ${t('colorSuffix')}`} value={custom[part]} placeholder="#3b82f6" submitLabel="set" onSubmit={value => pickCustomColor($, part, value)} />
470 ))
471 rows.push(
472 <Box key="focus-custom" flexDirection="column">
473 {fields}
474 </Box>,
475 )
476 }
477 return rows
478}
479
480// Hooks ------------------------------------------------------------------------
481
482// Nothing drawn in a tool row's place while Focus is on.
483const NOTHING = { display: 'none' } as const
484
485export function registerFocus(on: On) {
486 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
487 if (e.props.hasSurvey) return next(e)
488 return (await drawBand($, e)) ?? next(e)
489 })
490 // Tool rows draw nothing while Focus is on, except where they are worth
491 // the room: the ctrl+o transcript (isExpanded) shows every call whole, and
492 // a call that errored or was interrupted, or a group holding more than
493 // plain calls, stays in the transcript. The run-in-background pill
494 // (ToolProgress) is a control, never hidden.
495 on('ui.render', { component: 'ToolUse' }, async ($, e, next) =>
496 (await read($, focusOn)) && e.props.isExpanded !== true && !e.props.isErrored && !e.props.isInterrupted ? $.ui.resolve(e).Box(NOTHING) : next(e),
497 )
498 on('ui.render', { component: 'ToolResult' }, async ($, e, next) =>
499 (await read($, focusOn)) && e.props.isExpanded !== true && !e.props.isErrored ? $.ui.resolve(e).Box(NOTHING) : next(e),
500 )
501 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) =>
502 (await read($, focusOn)) && !e.props.isExpanded && e.props.isCallsOnly !== false ? $.ui.resolve(e).Box(NOTHING) : next(e),
503 )
504
505 // The band's own tool: the plan as short steps, then the number of each
506 // step as it starts, done at the end. Only while Focus is on (a call found
507 // through ToolSearch while off changes nothing) and from the main loop.
508 // Once it posts, the task is its own: the other sources give way.
509 // The literal, not FOCUS_TOOL, so `claude plugin validate` reads the matcher.
510 on('tool.call', { tool: 'mcp__cc-esol__focus_progress' }, async ($, e) => {
511 if (!(await read($, focusOn))) return { result: 'Focus is off: the band is hidden, nothing updated.' }
512 if (isAgentCall(e)) return { result: 'focus_progress is for the main conversation, not a subagent: nothing updated.' }
513 const post = progressPost(e)
514 if (post === null) return { result: 'Nothing to update: send steps (the plan), current (the step starting now) or done.' }
515 const underway = await read($, task)
516 const prev = underway !== null && underway.doneAt === null ? underway.steps : []
517 const steps = stepsAfterPost(prev, post)
518 if (steps === null) return { result: 'No plan on the band yet: send steps first, with current: 1.' }
519 await landingTask($)
520 await replaceSteps($, 'progress', () => steps, post.title ?? undefined)
521 return { result: progressReply(steps) }
522 }).catch(() => ({ result: 'Could not update the band: the focus_progress hook failed.' }))
523
524 // focus_progress waits in the tool list while Focus is on and behind
525 // ToolSearch while it is off; switchFocus asks again on a change.
526 on('tool.describe', { tool: 'mcp__cc-esol__focus_progress' }, async ($, e, next) => {
527 const described = await next(e)
528 return { ...described, description: FOCUS_TOOL_DESCRIPTION, isDeferred: !(await read($, focusOn)) }
529 })
530
531 // Claude's todo list, whole, each time it changes. A subagent's list
532 // (e.agentId) is its own, not the band's.
533 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
534 if (isAgentCall(e) || !(await mayPost($, 'todo'))) return next(e)
535 const steps = stepsFromTodos(e.todos)
536 if (steps.length > 0 && (await read($, focusOn))) await landingTask($)
537 await replaceSteps($, 'todo', () => steps)
538 return next(e)
539 }).catch(($, e, next) => next(e))
540
541 // A plan approved in plan mode, read as steps until focus_progress (or a
542 // todo list) moves them.
543 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
544 const ran = await next(e)
545 const plan = isApprovedPlan(e as { agentId?: string }, ran as { deny?: unknown; isError?: unknown; result?: unknown })
546 if (plan === null || !(await read($, focusOn)) || !(await mayPost($, 'plan'))) return ran
547 const parsed = parsePlanSteps(plan)
548 if (parsed.steps.length > 0) await landingTask($)
549 if (parsed.steps.length > 0 || parsed.title !== null) await replaceSteps($, 'plan', prev => (parsed.steps.length > 0 ? parsed.steps : prev), parsed.title ?? undefined)
550 return ran
551 }).catch(($, e, next) => next(e))
552
553 // Tasks: one step each, created pending, updated or removed by id.
554 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
555 if (isAgentCall(e) || !(await mayPost($, 'tasks'))) return next(e)
556 const ran = await next(e)
557 const created = ran.result as { task?: { id?: string } } | undefined
558 const id = created?.task?.id
559 if (id === undefined) return ran
560 if (await read($, focusOn)) await landingTask($)
561 await replaceSteps($, 'tasks', prev => [...prev.filter(s => s.id !== id), { id, text: e.subject, status: 'pending' }])
562 return ran
563 }).catch(($, e, next) => next(e))
564 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
565 if (isAgentCall(e) || !(await mayPost($, 'tasks'))) return next(e)
566 const ran = await next(e)
567 const refused = ran.deny !== undefined || ran.isError === true
568 if (refused) return ran
569 await replaceSteps($, 'tasks', prev =>
570 e.status === 'deleted'
571 ? prev.filter(s => s.id !== e.taskId)
572 : prev.map(s => (s.id === e.taskId ? { ...s, text: e.subject ?? s.text, status: e.status === undefined ? s.status : toStatus(e.status) } : s)),
573 )
574 return ran
575 }).catch(($, e, next) => next(e))
576
577 // Every tool call of the main loop joins the band's feed while it runs,
578 // stays there once it returns, failed when it errored or was refused, with
579 // what it ran and printed for its row to open on, and is counted on the
580 // task. The plugin's own tools are the band's business, not the feed's.
581 on('tool.call', async ($, e, next) => {
582 if (isAgentCall(e) || e.tool.startsWith('mcp__cc-esol__') || !(await read($, focusOn))) return next(e)
583 const at = await $.clock.now()
584 const input = e as unknown as Record<string, unknown>
585 const call: FocusAction = { id: ++callSeq, ...actionOf(e.tool, input, at), detail: detailOf(e.tool, input) }
586 await update($, feed, list => withCall(list, call))
587 await update($, task, t => (t === null || t.doneAt !== null ? t : { ...t, calls: (t.calls ?? 0) + 1 }))
588 let end: { failed: boolean; output?: ReturnType<typeof outputOf> } = { failed: true }
589 try {
590 const ran = await next(e)
591 end =
592 ran.deny !== undefined
593 ? { failed: true, output: outputOf(ran.deny) }
594 : ran.isError === true
595 ? { failed: true, output: outputOf(ran.text ?? ran.result) }
596 : { failed: false, output: outputOf(ran.result) }
597 return ran
598 } finally {
599 const ended = await $.clock.now()
600 await update($, feed, list => withCallDone(list, call.id, ended, end))
601 }
602 }).catch(($, e, next) => next(e))
603}
604
605// Numbers the calls of this process, so the feed tells parallel ones apart.
606let callSeq = 0
607
608// A subagent's call carries its agent's id; the main loop's carries none.
609const isAgentCall = (e: unknown): boolean => (e as { agentId?: string }).agentId !== undefined
610
611hooks/pages/model.tsx 178 lines1// The Model page: the model and effort this session runs with, the permission
2// mode it is in, and the mode new sessions start in.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, RenderChildren, RenderInput } from 'claude-code'
6
7import type { Effort } from '../../types'
8import { RAINBOW } from '../lib/focus'
9import { modeName } from '../lib/i18n'
10import { EFFORT_LEVELS, EFFORT_TINT, familyOf, isEffort, MODEL_CHOICES, modeWords, readableModel, START_MODES, type StartMode } from '../lib/session-controls'
11import { readSettings, writeSettings } from '../lib/settings-file'
12import { rainbow, type Frame } from '../ui/frame'
13
14const effortChoice = atom({ plugin: 'cc-esol', key: 'effort' } as const, null)
15const startMode = atom({ plugin: 'cc-esol', key: 'defaultMode' } as const, null)
16const effortSeen = atom({ plugin: 'cc-esol', key: 'sessionEffort' } as const, null)
17const modelSeen = atom({ plugin: 'cc-esol', key: 'sessionModel' } as const, null)
18
19const EFFORT_KEY = 'effort'
20const LAST_EFFORT_KEY = 'lastSessionEffort'
21
22// The theme token Claude Code draws each permission mode with.
23const MODE_TOKEN: Readonly<Record<string, string>> = {
24 default: 'text',
25 plan: 'planMode',
26 acceptEdits: 'autoAccept',
27 auto: 'warning',
28 dontAsk: 'warning',
29 bypassPermissions: 'error',
30}
31const modeToken = (mode: string) => MODE_TOKEN[mode] ?? 'text'
32
33export function readEffort($: EngineInterface) {
34 return read($, effortChoice)
35}
36
37// Changing ---------------------------------------------------------------------
38
39// The effort every request of this session goes out with. The /config row
40// for effort, where there is one, is set too, so Claude Code shows the same.
41async function chooseEffort($: EngineInterface, level: Effort) {
42 await update($, effortChoice, () => level)
43 await $.store.set(EFFORT_KEY, level)
44 try {
45 const rows = await $.config.list()
46 const effortRow = rows.find(({ key, options }) => /effort/i.test(key) && options?.includes(level) === true)
47 if (effortRow !== undefined) await $.config.set({ key: effortRow.key, value: level })
48 } catch {}
49 $.ui.toast(`Effort set to ${level}`)
50}
51
52// The model, through Claude Code's own /model, so everything agrees on it.
53async function chooseModel($: EngineInterface, alias: string) {
54 await $.command.run({ args: alias, command: 'model' })
55 const id = await $.session.model()
56 await update($, modelSeen, () => id)
57 $.ui.toast(`Model: ${readableModel(id)}`)
58}
59
60// `permissions.defaultMode` in the user's settings: what new sessions start
61// in. The running session's mode is shift+tab's; no plugin can set it.
62async function chooseStartMode($: EngineInterface, mode: StartMode) {
63 const snapshot = await readSettings($)
64 const permissions = (snapshot.data.permissions as Record<string, unknown> | undefined) ?? {}
65 await writeSettings($, snapshot, { ...snapshot.data, permissions: { ...permissions, defaultMode: mode } })
66 await update($, startMode, () => mode)
67 $.ui.toast(`New sessions start in ${modeWords(mode)} mode`)
68}
69
70// Tracking ---------------------------------------------------------------------
71
72export async function trackModel($: EngineInterface, id: string) {
73 if (id !== (await read($, modelSeen))) await update($, modelSeen, () => id)
74}
75
76// The level a request actually ran at, as the engine reports it.
77export async function trackEffort($: EngineInterface, level: unknown) {
78 if (!isEffort(level) || level === (await read($, effortSeen))) return
79 await update($, effortSeen, () => level)
80 await $.store.set(LAST_EFFORT_KEY, level)
81}
82
83export async function loadModel($: EngineInterface) {
84 const kept = await $.store.get(EFFORT_KEY)
85 if (isEffort(kept)) await update($, effortChoice, () => kept)
86 try {
87 const user = (await $.settings.read({ source: 'user' })) as { permissions?: { defaultMode?: string } }
88 const configured = user.permissions?.defaultMode
89 await update($, startMode, () => configured ?? 'default')
90 } catch {}
91}
92
93// Drawing ----------------------------------------------------------------------
94
95export async function drawModeTab($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame): Promise<RenderChildren[]> {
96 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
97 const { Text, Button } = $.ui.resolve(e)
98 const { lng, t, heading, chips, chip, framed } = ui
99 const family = familyOf(await read($, modelSeen))
100 const level = (await read($, effortChoice)) ?? (await read($, effortSeen))
101 // The start mode marked: the one settings.json holds. The running session's
102 // mode is not shown: only the classic hook events carry it, and Claude Code's
103 // built-in guard keeps those from user plugins on Team and Enterprise logins.
104 const marked = await read($, startMode)
105 const onModel = (alias: string) => () => chooseModel($, alias).catch((error: unknown) => $.ui.toast(`Could not switch the model: ${String(error)}`))
106 const onStartMode = (mode: StartMode) => () => chooseStartMode($, mode).catch((error: unknown) => $.ui.toast(`Could not save the mode: ${String(error)}`))
107
108 // Five cells, lit up to the level in its tint; max lights them as a rainbow.
109 const litUpTo = level === null ? -1 : EFFORT_LEVELS.indexOf(level)
110 const meter = (
111 <Text>
112 {EFFORT_LEVELS.map((each, i) =>
113 i <= litUpTo ? <Text color={level === 'max' ? RAINBOW[i * 2] : (EFFORT_TINT[each] ?? undefined)}>▰</Text> : <Text dimColor>▱</Text>,
114 )}
115 </Text>
116 )
117
118 // An effort chip: its word in its tint, filled when chosen; max as ✦ and a rainbow word.
119 const effortChip = (each: Effort) => {
120 const tint = EFFORT_TINT[each]
121 const isOn = each === level
122 const word =
123 tint === null ? (
124 <Text bold={isOn}>
125 {isOn && '✦ '}
126 {rainbow(each).map(({ ch, color }) => (
127 <Text color={color}>{ch}</Text>
128 ))}
129 </Text>
130 ) : isOn ? (
131 <Text bold color="#111111" backgroundColor={tint}>{` ${each} `}</Text>
132 ) : (
133 <Text color={tint}>{each}</Text>
134 )
135 return framed(
136 `effort:${each}`,
137 isOn,
138 <Button key={`effort:${each}`} plain label={each} onPress={() => chooseEffort($, each)}>
139 {word}
140 </Button>,
141 tint ?? RAINBOW[7],
142 )
143 }
144
145 const startModeChip = (mode: StartMode) =>
146 framed(
147 `start-mode:${mode}`,
148 mode === marked,
149 <Button key={`start-mode:${mode}`} plain label={modeName(lng, mode)} onPress={onStartMode(mode)}>
150 <Text color={modeToken(mode)} bold={mode === marked}>
151 {modeName(lng, mode)}
152 </Text>
153 </Button>,
154 )
155
156 const rows: RenderChildren[] = [
157 heading(t('model')),
158 chips('model-choices', [
159 ...MODEL_CHOICES.map(m => chip(`pick-model:${m.alias}`, m.label, m.alias === family, onModel(m.alias))),
160 <Button key="model-reset" plain dimColor label={t('modelDefault')} onPress={onModel('default')} />,
161 ]),
162 heading(t('effort'), meter),
163 chips('effort-choices', EFFORT_LEVELS.map(effortChip)),
164 heading(t('newSessions')),
165 chips('start-mode-choices', START_MODES.map(startModeChip)),
166 <Text key="shift-tab" dimColor>
167 {t('shiftTab')}
168 </Text>,
169 ]
170 if (marked === 'bypassPermissions')
171 rows.push(
172 <Text key="mode-caution" color="warning">
173 {t('bypassWarning')}
174 </Text>,
175 )
176 return rows
177}
178hooks/pages/settings.tsx 36 lines1// The Settings page: Focus on and off with the chip to its color, the Theme
2// block with the chosen theme's chip, the status line's chip, then the icon
3// sets. What opens a page comes back with the Back chip.
4
5import type { EngineInterface, RenderChildren, RenderInput } from 'claude-code'
6
7import type { Panel } from '../../types'
8import type { Frame } from '../ui/frame'
9import { drawFocusSettings } from './focus'
10import { readStatusLineIsCcsl } from './statusline'
11import { readThemeName } from './themes'
12import { ICON_SETS, readIconSet, setIconSet } from '../icons'
13
14export async function drawSettingsPage($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: (panel: Panel) => unknown): Promise<RenderChildren[]> {
15 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
16 const { t, heading, chip, chips } = ui
17 const iconSet = await readIconSet($)
18 const theme = (await readThemeName($)) ?? t('terminalDefault')
19 const isCcsl = await readStatusLineIsCcsl($)
20
21 return [
22 ...(await drawFocusSettings($, e, ui, go)),
23 // The theme chosen, as the chip that opens the page; plain while it is the terminal's own.
24 heading(t('themeRow')),
25 chips('settings-theme-chips', [chip('settings-theme', theme, (await readThemeName($)) !== null, () => go('themes'))]),
26 // Lit while ccstatusline draws the status line.
27 heading(t('statusLineRow')),
28 chips('settings-statusline-chips', [chip('settings-statusline', 'ccstatusline', isCcsl, () => go('statusline'))]),
29 heading(t('iconsTitle')),
30 chips(
31 'settings-icons',
32 ICON_SETS.map(set => chip(`settings-icons-${set}`, t(set === 'auto' ? 'iconsAuto' : set === 'nerd' ? 'iconsNerd' : 'iconsUnicode'), set === iconSet, () => setIconSet($, set))),
33 ),
34 ]
35}
36hooks/pages/statusline.tsx 258 lines1// The Status line page, opened from Settings: ccstatusline detected, installed,
2// looked after and removed, and its layout edited from here.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, RenderChildren, RenderInput } from 'claude-code'
6
7import type { Panel, StatusLineInfo, StatusLineVersion } from '../../types'
8import {
9 classify,
10 commandOf,
11 CONFIG_PATH,
12 CONFIGURATOR_COMMAND,
13 INSTALL_COMMAND,
14 installBlock,
15 isOp,
16 REPO_URL,
17 sameBlock,
18 STARTER_CONFIG,
19 type StatusLineOp,
20 summarizeConfig,
21 versionCommand,
22} from '../lib/ccstatusline'
23import { hasTool, homeDir, isWindows, shellArgv } from '../lib/platform'
24import { readSettings, writeSettings } from '../lib/settings-file'
25import type { Frame } from '../ui/frame'
26
27const statusLine = atom({ plugin: 'cc-esol', key: 'statusLine' } as const, null)
28const statusLineVersion = atom({ plugin: 'cc-esol', key: 'statusLineVersion' } as const, { state: 'idle', text: '' } satisfies StatusLineVersion)
29const statusLineBusy = atom({ plugin: 'cc-esol', key: 'statusLineBusy' } as const, false)
30const statusLineConfirm = atom({ plugin: 'cc-esol', key: 'statusLineConfirm' } as const, false)
31
32const OP_KEY = 'statusLineOp'
33const REMOVED_KEY = 'statusLineRemoved'
34
35async function configPath($: EngineInterface) {
36 return `${await homeDir($)}/${CONFIG_PATH}`
37}
38
39async function readOp($: EngineInterface): Promise<StatusLineOp | null> {
40 const op = await $.store.get(OP_KEY)
41 return isOp(op) ? op : null
42}
43
44// What the settings and the machine say now.
45export async function detectStatusLine($: EngineInterface) {
46 let info: StatusLineInfo
47 try {
48 const { data } = await readSettings($)
49 const op = await readOp($)
50 const path = await configPath($)
51 const configExists = await $.fs.exists(path)
52 const summary = configExists ? summarizeConfig(await $.fs.read(path)) : null
53 info = {
54 kind: classify(data.statusLine, op),
55 command: commandOf(data.statusLine),
56 configExists,
57 summary,
58 hasNpx: await hasTool($, 'npx'),
59 removed: (await $.store.get(REMOVED_KEY)) !== undefined,
60 }
61 } catch (error) {
62 info = { kind: 'none', command: null, configExists: false, summary: null, hasNpx: false, removed: false, error: String(error instanceof Error ? error.message : error) }
63 }
64 await update($, statusLine, () => info)
65 await update($, statusLineConfirm, () => false)
66}
67
68// An Install that did not finish: installed after all when the settings hold
69// its block, otherwise forgotten (the starter config, if written, stays; it is
70// harmless without the setting).
71async function reconcile($: EngineInterface) {
72 const op = await readOp($)
73 if (op === null || op.phase !== 'installing') return
74 try {
75 const { data } = await readSettings($)
76 if (sameBlock(data.statusLine, op.block)) await $.store.set(OP_KEY, { ...op, phase: 'installed' } satisfies StatusLineOp)
77 else await $.store.delete(OP_KEY)
78 } catch {}
79}
80
81export async function loadStatusLine($: EngineInterface) {
82 await reconcile($)
83 await detectStatusLine($)
84}
85
86// For the icons: whether the ccstatusline config draws powerline glyphs.
87export async function readStatusLinePowerline($: EngineInterface): Promise<boolean> {
88 return (await read($, statusLine))?.summary?.powerline === true
89}
90
91// For the editor: the configured command, whatever set it.
92export async function readStatusLineCommand($: EngineInterface): Promise<string | null> {
93 return (await read($, statusLine))?.command ?? null
94}
95
96// For the Settings chip: whether ccstatusline draws the status line.
97export async function readStatusLineIsCcsl($: EngineInterface): Promise<boolean> {
98 const kind = (await read($, statusLine))?.kind
99 return kind === 'managed' || kind === 'unmanaged'
100}
101
102// Actions ----------------------------------------------------------------------
103
104async function busy($: EngineInterface, work: () => Promise<void>) {
105 if (await read($, statusLineBusy)) return
106 await update($, statusLineBusy, () => true)
107 try {
108 await work()
109 } catch (error) {
110 $.ui.toast(error instanceof Error ? error.message : String(error))
111 } finally {
112 await update($, statusLineBusy, () => false)
113 }
114}
115
116// The record first, then the starter config where none exists, then the
117// setting; idempotent: a record already installed is left alone.
118async function install($: EngineInterface) {
119 await busy($, async () => {
120 const existing = await readOp($)
121 if (existing?.phase === 'installed') return
122 const snapshot = await readSettings($)
123 const op: StatusLineOp = { phase: 'installing', predecessor: snapshot.data.statusLine ?? null, block: installBlock(), wroteConfig: false }
124 await $.store.set(OP_KEY, op)
125 const path = await configPath($)
126 if (!(await $.fs.exists(path))) {
127 await $.fs.write(path, `${JSON.stringify(STARTER_CONFIG, null, 2)}\n`)
128 op.wroteConfig = true
129 await $.store.set(OP_KEY, op)
130 }
131 await writeSettings($, snapshot, { ...snapshot.data, statusLine: op.block })
132 await $.store.set(OP_KEY, { ...op, phase: 'installed' } satisfies StatusLineOp)
133 await $.store.delete(REMOVED_KEY)
134 await detectStatusLine($)
135 $.ui.toast('ccstatusline installed: it draws from the next session on')
136 })
137}
138
139// Only what this page installed, and only while the setting is still the block
140// it wrote: the predecessor comes back (or the key goes). The ccstatusline
141// config folder is never touched.
142async function uninstall($: EngineInterface) {
143 await busy($, async () => {
144 const op = await readOp($)
145 if (op === null || op.phase !== 'installed') throw new Error('Nothing installed from here to uninstall')
146 const snapshot = await readSettings($)
147 if (!sameBlock(snapshot.data.statusLine, op.block)) throw new Error('The status line was changed outside this page: not touching it')
148 const data = { ...snapshot.data }
149 if (op.predecessor === null || op.predecessor === undefined) delete data.statusLine
150 else data.statusLine = op.predecessor
151 await writeSettings($, snapshot, data)
152 await $.store.delete(OP_KEY)
153 await detectStatusLine($)
154 $.ui.toast(op.predecessor ? 'Your previous status line is back' : 'ccstatusline uninstalled')
155 })
156}
157
158// A status line the person set themselves: removed only after a second press,
159// and kept in the store so it can be put back.
160async function removeUnmanaged($: EngineInterface) {
161 if (!(await read($, statusLineConfirm))) {
162 await update($, statusLineConfirm, () => true)
163 return
164 }
165 await busy($, async () => {
166 const snapshot = await readSettings($)
167 if (snapshot.data.statusLine === undefined) return
168 await $.store.set(REMOVED_KEY, snapshot.data.statusLine)
169 const data = { ...snapshot.data }
170 delete data.statusLine
171 await writeSettings($, snapshot, data)
172 await detectStatusLine($)
173 $.ui.toast('statusLine removed from settings.json; "Put it back" restores it')
174 })
175}
176
177async function putBack($: EngineInterface) {
178 await busy($, async () => {
179 const removed = await $.store.get(REMOVED_KEY)
180 if (removed === undefined) return
181 const snapshot = await readSettings($)
182 if (snapshot.data.statusLine !== undefined) throw new Error('A status line is set already')
183 await writeSettings($, snapshot, { ...snapshot.data, statusLine: removed })
184 await $.store.delete(REMOVED_KEY)
185 await detectStatusLine($)
186 $.ui.toast('Status line put back')
187 })
188}
189
190// The configured command with --version, through the platform's shell.
191async function checkVersion($: EngineInterface) {
192 const info = await read($, statusLine)
193 const command = info?.command ?? INSTALL_COMMAND
194 await update($, statusLineVersion, () => ({ state: 'checking', text: '' }))
195 try {
196 const ran = await $.process.run(shellArgv(versionCommand(command), await isWindows($)), { timeoutMs: 20_000 })
197 const line = (ran.exitCode === 0 ? ran.stdout : ran.stderr).trim().split('\n')[0] ?? ''
198 await update($, statusLineVersion, () => (ran.exitCode === 0 ? { state: 'ok', text: line } : { state: 'failed', text: line || `exit ${ran.exitCode}` }))
199 } catch (error) {
200 await update($, statusLineVersion, () => ({ state: 'failed', text: error instanceof Error ? error.message : String(error) }))
201 }
202}
203
204// Drawing ----------------------------------------------------------------------
205
206export async function drawStatusLinePage($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: (panel: Panel) => unknown): Promise<RenderChildren[]> {
207 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
208 const { Box, Text } = $.ui.resolve(e)
209 const { t, heading, chips, chip } = ui
210 const info = await read($, statusLine)
211 const version = await read($, statusLineVersion)
212 const isBusy = await read($, statusLineBusy)
213 const confirm = await read($, statusLineConfirm)
214 const rows: RenderChildren[] = [
215 chips('statusline-nav', [chip('statusline-back', t('backSettings'), false, () => go('settings'))]),
216 <Text key="statusline-intro">{t('slIntro')}</Text>,
217 ]
218 if (info === null) {
219 rows.push(<Text key="statusline-wait" dimColor>…</Text>)
220 return rows
221 }
222 // With ccstatusline in place the chips say it all; without it, a word on
223 // what Install will do.
224 const isCcsl = info.kind === 'unmanaged' || info.kind === 'managed'
225 if (!isCcsl) rows.push(<Text key="statusline-state">{info.kind === 'none' ? t('slNone') : t('slOther')}</Text>)
226 if (info.error !== undefined) rows.push(<Text key="statusline-error" color="red">{info.error}</Text>)
227 if (isCcsl) rows.push(chips('statusline-edit-row', [chip('statusline-edit', t('ccslRow'), true, () => go('ccsl-editor'))]))
228
229 const actions: RenderChildren[] = []
230 if (!isCcsl) actions.push(chip('statusline-install', t('slInstall'), isBusy, () => install($)))
231 if (isCcsl) actions.push(chip('statusline-version-check', t('slCheckVersion'), version.state === 'checking', () => checkVersion($)))
232 if (info.kind === 'managed') actions.push(chip('statusline-uninstall', t('slUninstall'), false, () => uninstall($)))
233 if (info.kind === 'unmanaged') actions.push(chip('statusline-remove', confirm ? t('slRemoveConfirm') : t('slRemove'), confirm, () => removeUnmanaged($)))
234 if (info.kind === 'none' && info.removed) actions.push(chip('statusline-putback', t('slPutBack'), false, () => putBack($)))
235 rows.push(chips('statusline-actions', actions))
236 if (version.state !== 'idle')
237 rows.push(
238 <Text key="statusline-version" color={version.state === 'failed' ? 'red' : undefined} dimColor={version.state !== 'failed'}>
239 {version.state === 'checking' ? t('slVersionChecking') : version.text}
240 </Text>,
241 )
242 if (!info.hasNpx && info.kind === 'none') rows.push(<Text key="statusline-nonpx" color="warning">{t('slNoNpx')}</Text>)
243 rows.push(
244 heading(t('slConfigurator')),
245 <Box key="statusline-configurator">
246 <Text bold>{CONFIGURATOR_COMMAND}</Text>
247 </Box>,
248 <Text key="statusline-note" dimColor>
249 {t('slNote')}
250 </Text>,
251 <Box key="statusline-about" flexDirection="column" marginTop={1}>
252 <Text dimColor>{t('slAbout')}</Text>
253 <Text color="claude">{REPO_URL}</Text>
254 </Box>,
255 )
256 return rows
257}
258hooks/pages/themes.tsx 465 lines1// The Theme page: palettes from terminalcolors.com kept on disk, one chosen
2// for Claude Code (as its custom theme) and for the terminal itself (OSC
3// sequences, or the Windows Terminal profile this tab runs in).
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, RenderChildren, RenderInput } from 'claude-code'
7
8import type { Panel, ThemeEntry } from '../../types'
9import { homeDir, isWindows } from '../lib/platform'
10import { setSettingsKey } from '../lib/settings-file'
11import {
12 claudeThemeOf,
13 type Palette,
14 parseLooseJson,
15 profileByGuid,
16 profileToTheme,
17 readWarpTheme,
18 setProfileTheme,
19 terminalOscFor,
20 WT_SCHEME,
21 type WtSettings,
22} from '../lib/terminal-themes'
23import { familyPages, keptFileName, slugOf, THEME_SITE, variantPages, warpFileOn } from '../lib/theme-site'
24import { TAB_STRIP_ROWS, type Frame } from '../ui/frame'
25
26const catalog = atom({ plugin: 'cc-esol', key: 'themes' } as const, [])
27const chosenName = atom({ plugin: 'cc-esol', key: 'themeName' } as const, null)
28const query = atom({ plugin: 'cc-esol', key: 'themeQuery' } as const, '')
29const windowTop = atom({ plugin: 'cc-esol', key: 'themeScroll' } as const, 0)
30const notice = atom({ plugin: 'cc-esol', key: 'themeStatus' } as const, '')
31const fetching = atom({ plugin: 'cc-esol', key: 'isDownloading' } as const, false)
32const paneBackground = atom({ plugin: 'cc-esol', key: 'paneBg' } as const, null)
33
34// The custom theme Claude Code is pointed at: ~/.claude/themes/<CLAUDE_THEME>.json.
35const CLAUDE_THEME = 'cc-esol'
36const KEPT_KEY = 'theme'
37const BEFORE_KEY = 'themeBefore'
38const WT_ORIGINALS_KEY = 'wtOriginal'
39const DOWNLOADS_AT_ONCE = 6
40
41type Kept = { slug: string; name: string }
42
43export function readThemeName($: EngineInterface) {
44 return read($, chosenName)
45}
46export function readPaneBg($: EngineInterface) {
47 return read($, paneBackground)
48}
49
50async function keptTheme($: EngineInterface): Promise<Kept | null> {
51 const kept = (await $.store.get(KEPT_KEY)) as Kept | undefined
52 return kept === undefined ? null : kept
53}
54
55async function say($: EngineInterface, text: string) {
56 await update($, notice, () => text)
57}
58
59// Where the YAML files live, and one of them read as a palette.
60async function catalogDir($: EngineInterface) {
61 return `${await homeDir($)}/.claude/cc-esol/themes`
62}
63async function paletteOf($: EngineInterface, slug: string): Promise<Palette | null> {
64 const path = `${await catalogDir($)}/${slug}.yaml`
65 return (await $.fs.exists(path)) ? readWarpTheme(await $.fs.read(path)) : null
66}
67
68// Catalog ------------------------------------------------------------------------
69
70// The kept files, as the picker lists them: by name, each with its eight colors.
71async function reindex($: EngineInterface): Promise<ThemeEntry[]> {
72 const dir = await catalogDir($)
73 const entries: ThemeEntry[] = []
74 const present = await $.fs.exists(dir)
75 if (present) {
76 for (const file of await $.fs.list(dir)) {
77 if (file.kind !== 'file' || !file.name.endsWith('.yaml')) continue
78 const palette = readWarpTheme(await $.fs.read(`${dir}/${file.name}`))
79 if (palette !== null) entries.push({ slug: slugOf(file.name), name: palette.name, colors: palette.normal })
80 }
81 }
82 entries.sort((a, b) => a.name.localeCompare(b.name))
83 await update($, catalog, () => entries)
84 return entries
85}
86
87async function pageText($: EngineInterface, url: string): Promise<string> {
88 const response = await $.http.fetch(url)
89 if (!response.ok) throw new Error(`${response.status} for ${url}`)
90 return response.text
91}
92
93// Every theme on the site, saved as its Warp file: the home page's variants
94// plus each family page's, fetched a few at a time.
95async function fetchCatalog($: EngineInterface) {
96 const dir = await catalogDir($)
97 await say($, 'Reading terminalcolors.com…')
98 const home = await pageText($, `${THEME_SITE}/`)
99 const variants = new Set(variantPages(home))
100 for (const family of familyPages(home)) {
101 try {
102 for (const variant of variantPages(await pageText($, THEME_SITE + family))) variants.add(variant)
103 } catch {}
104 }
105 const queue = [...variants]
106 let seen = 0
107 let kept = 0
108 const saveOne = async (page: string) => {
109 try {
110 const filePath = warpFileOn(await pageText($, THEME_SITE + page))
111 if (filePath === null) return
112 const yaml = await pageText($, THEME_SITE + filePath)
113 if (readWarpTheme(yaml) === null) return
114 await $.fs.write(`${dir}/${keptFileName(filePath)}`, yaml)
115 kept++
116 } catch {
117 } finally {
118 seen++
119 }
120 }
121 for (let at = 0; at < queue.length; at += DOWNLOADS_AT_ONCE) {
122 await Promise.all(queue.slice(at, at + DOWNLOADS_AT_ONCE).map(saveOne))
123 await say($, `Downloading themes… ${seen}/${queue.length}`)
124 }
125 await reindex($)
126 await say($, `Saved ${kept} themes to ${dir}`)
127}
128
129// The first visit fetches the catalog; later visits find it on disk.
130async function fetchCatalogOnce($: EngineInterface) {
131 if ((await read($, catalog)).length > 0 || (await read($, fetching))) return
132 await update($, fetching, () => true)
133 try {
134 await fetchCatalog($)
135 } catch (error) {
136 await say($, `Download failed: ${error instanceof Error ? error.message : String(error)}`)
137 } finally {
138 await update($, fetching, () => false)
139 }
140}
141
142// Windows Terminal ----------------------------------------------------------------
143
144// Where Windows Terminal keeps its settings: the store app, its preview,
145// or the unpackaged install, whichever holds this tab's profile.
146async function windowsTerminalSettings($: EngineInterface, profileGuid: string | null): Promise<string | null> {
147 const appData = ((await $.env.get('LOCALAPPDATA')) ?? '').replaceAll('\\', '/')
148 if (appData === '') return null
149 const places = [
150 `${appData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
151 `${appData}/Packages/Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe/LocalState/settings.json`,
152 `${appData}/Microsoft/Windows Terminal/settings.json`,
153 ]
154 let firstFound: string | null = null
155 for (const path of places) {
156 if (!(await $.fs.exists(path))) continue
157 firstFound ??= path
158 if (profileGuid !== null && (await $.fs.read(path)).toLowerCase().includes(profileGuid.toLowerCase())) return path
159 }
160 return firstFound
161}
162
163// The backgrounds of Windows Terminal's built-in schemes.
164const WT_STOCK_BACKGROUNDS: Record<string, string> = {
165 Campbell: '#0C0C0C',
166 'Campbell Powershell': '#012456',
167 'Dark+': '#1E1E1E',
168 Dimidium: '#141414',
169 'One Half Dark': '#282C34',
170 'One Half Light': '#FFFFFF',
171 Ottosson: '#000000',
172 'Solarized Dark': '#002B36',
173 'Solarized Light': '#FDF6E3',
174 'Tango Dark': '#000000',
175 'Tango Light': '#FFFFFF',
176 Vintage: '#000000',
177}
178
179// The background this tab draws on, from its profile, the defaults, the
180// scheme either names, or the stock scheme's known color.
181async function windowsBackground($: EngineInterface): Promise<string | null> {
182 const guid = (await $.env.get('WT_PROFILE_ID')) ?? null
183 const path = await windowsTerminalSettings($, guid)
184 if (path === null) return null
185 const settings = parseLooseJson(await $.fs.read(path)) as WtSettings
186 type Colored = { background?: unknown; colorScheme?: unknown }
187 const profile = profileToTheme(settings, guid) as Colored
188 const profiles = settings.profiles
189 const defaults = (profiles === undefined || Array.isArray(profiles) ? undefined : profiles.defaults) as Colored | undefined
190 for (const holder of [profile, defaults]) if (typeof holder?.background === 'string') return holder.background
191 const named = profile.colorScheme ?? defaults?.colorScheme ?? 'Campbell'
192 const schemeName = typeof named === 'string' ? named : ((named as { dark?: string }).dark ?? 'Campbell')
193 const scheme = (settings.schemes ?? []).find(s => s.name === schemeName) as { background?: unknown } | undefined
194 return typeof scheme?.background === 'string' ? scheme.background : (WT_STOCK_BACKGROUNDS[schemeName] ?? null)
195}
196
197// This tab's Windows Terminal profile takes the palette (background too):
198// Windows Terminal reloads its settings live. Its own scheme is kept in the
199// store before the first change and comes back with null, at session end.
200async function paintWindowsProfile($: EngineInterface, palette: Palette | null) {
201 const guid = (await $.env.get('WT_PROFILE_ID')) ?? null
202 if (guid === null) return
203 const path = await windowsTerminalSettings($, guid)
204 if (path === null) return
205 const before = await $.fs.read(path)
206 const settings = parseLooseJson(before) as WtSettings
207 const profile = profileByGuid(settings, guid)
208 if (profile === undefined) return
209 const id = guid.toLowerCase()
210 const originals = ((await $.store.get(WT_ORIGINALS_KEY)) ?? {}) as Record<string, { colorScheme?: unknown }>
211 if (palette === null && originals[id] === undefined) return
212 if (originals[id] === undefined) {
213 const own = profile.colorScheme
214 originals[id] = own === undefined || own === WT_SCHEME ? {} : { colorScheme: own }
215 await $.store.set(WT_ORIGINALS_KEY, originals)
216 }
217 setProfileTheme(settings, profile, palette, originals[id]!.colorScheme)
218 const after = JSON.stringify(settings, null, 4)
219 if (after !== JSON.stringify(parseLooseJson(before), null, 4)) {
220 const backup = `${path}.cc-esol.bak`
221 if (!(await $.fs.exists(backup))) await $.fs.write(backup, before)
222 await $.fs.write(path, after)
223 }
224 if (palette === null) {
225 delete originals[id]
226 await $.store.set(WT_ORIGINALS_KEY, originals)
227 }
228 await refreshPaneBg($)
229}
230
231// OSC terminals -------------------------------------------------------------------
232
233// The terminal device this session draws on: the nearest ancestor process
234// with one (/dev/ttys002, /dev/pts/3). A command the engine runs has none of
235// its own, so /dev/tty cannot be used.
236const TTY_OF_ANCESTOR = [
237 'p=$$',
238 'while [ "$p" -gt 1 ]; do',
239 ' t=$(ps -o tty= -p "$p" | tr -d " ")',
240 ' case "$t" in ""|"?"|"??") ;; *) printf "/dev/%s" "$t"; exit 0 ;; esac',
241 ' p=$(ps -o ppid= -p "$p" | tr -d " ")',
242 'done',
243 'exit 1',
244].join('\n')
245
246let ttyDevice: string | null | undefined
247
248async function ttyOfSession($: EngineInterface): Promise<string | null> {
249 if (ttyDevice === undefined) {
250 const ran = await $.process.run(['sh', '-c', TTY_OF_ANCESTOR])
251 ttyDevice = ran.exitCode === 0 && ran.stdout.startsWith('/dev/') ? ran.stdout : null
252 }
253 return ttyDevice
254}
255
256async function paintWithOsc($: EngineInterface, palette: Palette | null) {
257 const tty = await ttyOfSession($)
258 if (tty === null) return
259 await $.process.run(['sh', '-c', 'printf "%s" "$1" > "$2"', 'sh', terminalOscFor(palette), tty])
260}
261
262async function paintTerminal($: EngineInterface, palette: Palette | null) {
263 if (await isWindows($)) await paintWindowsProfile($, palette)
264 else await paintWithOsc($, palette)
265}
266
267// Claude Code's theme ---------------------------------------------------------------
268
269// The background the menu's pane sits on: Windows Terminal's, or the kept
270// palette's where the terminal is painted by OSC.
271export async function refreshPaneBg($: EngineInterface) {
272 try {
273 let bg: string | null = null
274 if (await isWindows($)) bg = await windowsBackground($)
275 else {
276 const kept = await keptTheme($)
277 bg = kept === null ? null : ((await paletteOf($, kept.slug))?.background ?? null)
278 }
279 await update($, paneBackground, () => bg)
280 } catch {}
281}
282
283// Points Claude Code at a theme by its /theme value. The row in /config
284// takes the built-in names; a custom one is written to settings.json, which
285// Claude Code reads at startup (hence the one restart the first time). The
286// theme in use before the plugin's first is kept, for the way back.
287async function chooseClaudeTheme($: EngineInterface, value: string) {
288 const rows = await $.config.list()
289 const row = rows.find(({ key }) => key === 'theme')
290 if (row === undefined) throw new Error('No theme row in /config to set')
291 if (row.value === value) return
292 const isOurs = value === `custom:${CLAUDE_THEME}`
293 if (isOurs && (await $.store.get(BEFORE_KEY)) === undefined) await $.store.set(BEFORE_KEY, row.value)
294 const rowTakesIt = !value.startsWith('custom:') || row.options?.includes(value) === true
295 if (rowTakesIt) {
296 const answer = await $.config.set({ key: 'theme', value })
297 if (answer.deny === undefined) return
298 if (!value.startsWith('custom:')) throw new Error(answer.deny)
299 }
300 const written = await setSettingsKey($, 'theme', value)
301 if (written) $.ui.toast(`One restart of Claude Code puts the ${CLAUDE_THEME} theme in place; from then on themes change live`)
302}
303
304// A palette on everything: Claude Code (the custom theme file, reloaded
305// live once chosen) and the terminal. Null undoes both.
306async function applyPalette($: EngineInterface, slug: string | null): Promise<Palette | null> {
307 if (slug === null) {
308 const before = (await $.store.get(BEFORE_KEY)) as string | undefined
309 await chooseClaudeTheme($, before ?? 'dark')
310 await $.store.delete(BEFORE_KEY)
311 await paintTerminal($, null)
312 return null
313 }
314 const palette = await paletteOf($, slug)
315 if (palette === null) throw new Error(`${slug}.yaml is not a theme this plugin can read`)
316 await $.fs.write(`${await homeDir($)}/.claude/themes/${CLAUDE_THEME}.json`, `${JSON.stringify(claudeThemeOf(palette), null, 2)}\n`)
317 await chooseClaudeTheme($, `custom:${CLAUDE_THEME}`)
318 await paintTerminal($, palette)
319 return palette
320}
321
322// Chooses a theme for this and later sessions (null: the terminal's own colors).
323async function chooseTheme($: EngineInterface, slug: string | null) {
324 const palette = await applyPalette($, slug)
325 await update($, chosenName, () => palette?.name ?? null)
326 if (palette === null || slug === null) await $.store.delete(KEPT_KEY)
327 else await $.store.set(KEPT_KEY, { slug, name: palette.name } satisfies Kept)
328 await refreshPaneBg($)
329 await say($, '')
330}
331
332// At a session's start: the catalog indexed, the kept theme painted on the terminal.
333export async function loadThemes($: EngineInterface) {
334 const entries = await reindex($)
335 const kept = await keptTheme($)
336 if (kept === null || !entries.some(entry => entry.slug === kept.slug)) return
337 await update($, chosenName, () => kept.name)
338 try {
339 await paintTerminal($, await paletteOf($, kept.slug))
340 } catch {}
341}
342
343// At a session's end the terminal gets its own colors back; a /clear keeps
344// the session's process and tab, so the caller skips this then.
345export async function themesSessionEnd($: EngineInterface) {
346 try {
347 await paintTerminal($, null)
348 } catch {}
349}
350
351// The picker ----------------------------------------------------------------------
352
353// Rows around the list: the tabs, the Back chip, the kept line and the search,
354// the actions, the notice when there is one, the footer, one spare.
355const roomFor = (bodyRows: number, hasNotice: boolean) => Math.max(3, bodyRows - (TAB_STRIP_ROWS + 3 + 3 + (hasNotice ? 1 : 0) + 3 + 1))
356
357async function matches($: EngineInterface): Promise<ThemeEntry[]> {
358 const needle = (await read($, query)).trim().toLowerCase()
359 return (await read($, catalog)).filter(entry => entry.name.toLowerCase().includes(needle))
360}
361
362async function moveWindow($: EngineInterface, top: number, bodyRows: number) {
363 const room = roomFor(bodyRows, (await read($, notice)) !== '')
364 const last = Math.max(0, (await matches($)).length - room)
365 const next = Math.max(0, Math.min(last, top))
366 if (next !== (await read($, windowTop))) await update($, windowTop, () => next)
367}
368
369// Opening the page: the kept theme a few rows into view; the catalog fetched on the first visit.
370export async function openThemes($: EngineInterface) {
371 const kept = await keptTheme($)
372 const at = kept === null ? -1 : (await matches($)).findIndex(entry => entry.slug === kept.slug)
373 await update($, windowTop, () => Math.max(0, at - 3))
374 void fetchCatalogOnce($)
375}
376
377// From register.tsx's ui.scroll hook: the wheel moves the list's window, not the pane.
378export async function scrollThemesBy($: EngineInterface, by: number, bodyRows: number) {
379 await moveWindow($, (await read($, windowTop)) + by, bodyRows)
380}
381
382export const THEME_ROW_PREFIX = 'pick-theme:'
383
384// From register.tsx's ui.focus hook: a row the focus ring reached outside the window brings it in.
385export async function focusThemes($: EngineInterface, element: string | undefined, bodyRows: number) {
386 if (element === undefined || !element.startsWith(THEME_ROW_PREFIX)) return
387 const slug = element.slice(THEME_ROW_PREFIX.length)
388 const at = (await matches($)).findIndex(entry => entry.slug === slug)
389 if (at < 0) return
390 const top = await read($, windowTop)
391 const room = roomFor(bodyRows, (await read($, notice)) !== '')
392 if (at < top) await moveWindow($, at, bodyRows)
393 else if (at >= top + room) await moveWindow($, at - room + 1, bodyRows)
394}
395
396// A press that may fail reports in the notice line instead of throwing.
397function tryFromButton($: EngineInterface, work: () => Promise<unknown>) {
398 return () => {
399 void work().catch((error: unknown) => say($, `Failed: ${error instanceof Error ? error.message : String(error)}`))
400 }
401}
402
403export async function drawThemesTab($: EngineInterface, e: RenderInput<'Pane'>, ui: Frame, go: (panel: Panel) => unknown): Promise<RenderChildren[]> {
404 if (e.surface !== 'terminal' && e.surface !== 'desktop') return []
405 const { Box, Text, Button, Input } = $.ui.resolve(e)
406 const { t, check, swatches, chips, chip } = ui
407 const rows: RenderChildren[] = [chips('themes-nav', [chip('themes-back', t('backSettings'), false, () => go('settings'))])]
408 const entries = await read($, catalog)
409 const status = await read($, notice)
410 if (entries.length === 0) {
411 rows.push(
412 <Text key="theme-empty" dimColor>
413 {status || t('themesWait')}
414 </Text>,
415 )
416 if (!(await read($, fetching))) rows.push(<Button key="theme-fetch" plain label={t('themesRetry')} onPress={() => fetchCatalogOnce($)} />)
417 return rows
418 }
419 const found = await matches($)
420 const top = await read($, windowTop)
421 const room = roomFor(e.props.scroll.bodyRows, status !== '')
422 const hidden = top > 0 || found.length - top - room > 0
423 const keptSlug = (await keptTheme($))?.slug ?? null
424 const typed = await read($, query)
425 const search = (value: string) => update($, query, () => value).then(() => update($, windowTop, () => 0))
426 rows.push(
427 <Box key="theme-kept" justifyContent="space-between" gap={1} flexShrink={0}>
428 <Text wrap="truncate">
429 <Text dimColor>{t('kept')} </Text>
430 <Text bold>{(await read($, chosenName)) ?? t('terminalDefault')}</Text>
431 </Text>
432 <Text dimColor>{hidden ? `${top + 1}–${Math.min(found.length, top + room)}/${found.length}` : ''}</Text>
433 </Box>,
434 <Input key="theme-search" label={t('search')} value={typed} placeholder={t('themesCount', { n: entries.length })} onInput={search} onSubmit={search} />,
435 <Box key="theme-actions" columnGap={2} flexWrap="wrap" flexShrink={0}>
436 <Button key="theme-default" plain dimColor label={t('defaultTheme')} onPress={tryFromButton($, () => chooseTheme($, null))} />
437 <Button key="theme-redownload" plain dimColor label={t('downloadAgain')} onPress={tryFromButton($, () => fetchCatalog($))} />
438 </Box>,
439 )
440 if (status !== '')
441 rows.push(
442 <Text key="theme-notice" color="claude" wrap="truncate">
443 {status}
444 </Text>,
445 )
446 if (found.length === 0)
447 rows.push(
448 <Text key="theme-none" dimColor>
449 {t('themesNone', { q: typed })}
450 </Text>,
451 )
452 // Two rows past the room, so the focus ring can step off the window's edge and pull it along.
453 for (const entry of found.slice(top, top + room + 2)) {
454 const isKept = entry.slug === keptSlug
455 rows.push(
456 <Button key={`${THEME_ROW_PREFIX}${entry.slug}`} plain label={entry.name} onPress={tryFromButton($, () => chooseTheme($, entry.slug))}>
457 {swatches(entry.colors)}
458 <Text bold={isKept}>{entry.name}</Text>
459 {check(isKept)}
460 </Button>,
461 )
462 }
463 return rows
464}
465hooks/ui/frame.tsx 147 lines1// What every page of the pane draws with: the open page and the language
2// (atoms live here, read through accessors elsewhere), the pane's constants,
3// the row under the prompt, and the chip, heading and swatch idiom built
4// once per drawing from the surface's own elements.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, RenderChildren, RenderInput } from 'claude-code'
8
9import type { Lang, Panel } from '../../types'
10import { glyph } from '../icons'
11import { RAINBOW } from '../lib/focus'
12import type { Phrase } from '../lib/i18n'
13import { readVoiceOn } from '../voice'
14
15const openPage = atom({ plugin: 'cc-esol', key: 'menu' } as const, null)
16const language = atom({ plugin: 'cc-esol', key: 'lang' } as const, 'en')
17// Whether this session's values were refilled from the store. A /clear
18// resets every session value, this one too, so the next prompt restores
19// whether or not a session.start event reached the plugin.
20const refilled = atom({ plugin: 'cc-esol', key: 'restored' } as const, false)
21
22export function readMenu($: EngineInterface) {
23 return read($, openPage)
24}
25export function setMenu($: EngineInterface, panel: Panel | null) {
26 return update($, openPage, () => panel)
27}
28export function readRestored($: EngineInterface) {
29 return read($, refilled)
30}
31export function markRestored($: EngineInterface) {
32 return update($, refilled, () => true)
33}
34export function readLang($: EngineInterface) {
35 return read($, language)
36}
37
38// The pane: a sidebar in the fullscreen layout, a block above the prompt otherwise.
39export const PANE = 'cc-esol'
40export const PANE_SIZE = { rows: 26, columns: 56 }
41// The pane's background until the terminal's own is known.
42export const PANE_BACKGROUND = '#0b0b0b'
43export const MENU_NAME = 'esol'
44// The tab strip's rows: the tabs and the rule under them.
45export const TAB_STRIP_ROWS = 2
46
47// A word in the rainbow, one color a letter.
48export const rainbow = (text: string) => [...text].map((ch, i) => ({ ch, color: RAINBOW[i % RAINBOW.length]! }))
49
50export type T = (key: Phrase, vars?: Record<string, string | number>) => string
51
52export type Frame = {
53 lng: Lang
54 t: T
55 columns: number
56 rule: RenderChildren
57 framed: (key: string, isOn: boolean, button: RenderChildren, frame?: string) => RenderChildren
58 chip: (key: string, label: string, isOn: boolean, onPress: () => unknown, color?: string) => RenderChildren
59 chips: (key: string, items: RenderChildren[]) => RenderChildren
60 check: (isOn: boolean) => RenderChildren
61 swatches: (colors: readonly string[]) => RenderChildren
62 heading: (text: string, extra?: RenderChildren) => RenderChildren
63}
64
65// The engine's hint on one row (a second line holding only a separator
66// drew as a stray dot).
67const oneLineHint = (hint: string): string => {
68 const lines = hint.split(/\r?\n/).map(l => l.trim())
69 const spoken = lines.filter(l => /[\p{L}\p{N}]/u.test(l))
70 return spoken.join(' · ')
71}
72
73// The row under the prompt: the engine's hint at the left; at the right the
74// lamp that opens the sidebar, the Model shortcut, and the Voice toggle.
75export async function drawPromptHint($: EngineInterface, e: RenderInput<'PromptHint'>, toggleMenu: () => unknown, openModel: () => unknown, toggleVoice: () => unknown) {
76 const { Box, Text, Button } = $.ui.resolve(e)
77 const page = await read($, openPage)
78 const voice = await readVoiceOn($)
79 const lamp = `${await glyph($, 'lamp')} ${MENU_NAME} ${page === null ? '▲' : '▼'}`
80 const onModel = page === 'model'
81 return (
82 <Box flexGrow={1} gap={1} justifyContent="space-between">
83 <Text wrap="truncate" dimColor>
84 {oneLineHint(e.props.hint)}
85 </Text>
86 <Box gap={2} flexShrink={0}>
87 <Button key="esol-toggle" plain label={lamp} onPress={toggleMenu} />
88 <Button key="esol-model" plain label="Model" onPress={openModel}>
89 <Text bold={onModel} color={onModel ? 'claude' : undefined}>
90 Model
91 </Text>
92 </Button>
93 <Button key="esol-voice" plain label="Voice" onPress={toggleVoice}>
94 <Text>Voice </Text>
95 <Text bold={voice === true} color={voice === true ? 'claude' : undefined} dimColor={voice !== true}>
96 {voice === null ? '·' : voice ? 'on' : 'off'}
97 </Text>
98 </Button>
99 </Box>
100 </Box>
101 )
102}
103
104// The drawing idiom, bound to one drawing's elements. A chip is a Button in
105// a rounded frame (keyed `<key>-box`): the chosen one framed and written in
106// the accent color, or in `frame`; the rest dim. Chips sit in a wrapping row.
107export function frame($: EngineInterface, e: RenderInput<'Pane'>, lng: Lang, t: T, columns: number): Frame {
108 const { Box, Text, Button } = $.ui.resolve(e)
109 const width = Math.max(10, columns - 2)
110 const rule = <Text dimColor>{'─'.repeat(width)}</Text>
111 const framed: Frame['framed'] = (key, isOn, button, frameColor) => (
112 <Box key={`${key}-box`} borderStyle="round" borderColor={isOn ? (frameColor ?? 'claude') : undefined} borderDimColor={!isOn} paddingX={1}>
113 {button}
114 </Box>
115 )
116 const chip: Frame['chip'] = (key, label, isOn, onPress, color) =>
117 framed(
118 key,
119 isOn,
120 <Button key={key} plain label={label} onPress={onPress}>
121 <Text bold={isOn} color={isOn ? 'claude' : color}>
122 {label}
123 </Text>
124 </Button>,
125 )
126 const chips: Frame['chips'] = (key, items) => (
127 <Box key={key} flexWrap="wrap" columnGap={1}>
128 {items}
129 </Box>
130 )
131 const check: Frame['check'] = isOn => isOn && <Text color="claude">{' ✓'}</Text>
132 const swatches: Frame['swatches'] = colors => (
133 <Text>
134 {colors.map(color => (
135 <Text color={color}>{'■ '}</Text>
136 ))}
137 </Text>
138 )
139 const heading: Frame['heading'] = (text, extra) => (
140 <Box key={`h-${text}`} marginTop={1} gap={1}>
141 <Text dimColor>{text}</Text>
142 {extra}
143 </Box>
144 )
145 return { lng, t, columns, rule, framed, chip, chips, check, swatches, heading }
146}
147hooks/voice.tsx 56 lines1// The Voice button on the row under the prompt: Claude Code's own /voice
2// (dictation) toggled from here, with its state beside it. The state is what
3// Claude Code persists in the settings (`voice.enabled`, or the older
4// `voiceEnabled`), read at the start and after every /voice, typed or pressed.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, On } from 'claude-code'
8
9const voiceOn = atom({ plugin: 'cc-esol', key: 'voiceOn' } as const, null)
10
11export function readVoiceOn($: EngineInterface) {
12 return read($, voiceOn)
13}
14
15// `voice.enabled` as Claude Code writes it; `voiceEnabled` from older builds beneath.
16export const voiceEnabledIn = (settings: unknown): boolean | null => {
17 const s = settings as { voice?: { enabled?: unknown }; voiceEnabled?: unknown } | null | undefined
18 if (typeof s?.voice?.enabled === 'boolean') return s.voice.enabled
19 if (typeof s?.voiceEnabled === 'boolean') return s.voiceEnabled
20 return null
21}
22
23// What /voice said: "Voice mode enabled (hold)…" or "Voice mode disabled".
24export const voiceStateIn = (said: string): boolean | null => (/\benabled\b/i.test(said) ? true : /\bdisabled\b/i.test(said) ? false : null)
25
26export async function loadVoice($: EngineInterface) {
27 try {
28 const state = voiceEnabledIn(await $.settings.read())
29 await update($, voiceOn, () => state)
30 } catch {}
31}
32
33// Claude Code's /voice with no argument: a toggle, the mode kept. Its answer
34// says what happened; the settings it wrote are the fallback.
35export async function toggleVoice($: EngineInterface) {
36 try {
37 const result = await $.command.run({ command: 'voice', args: '' })
38 const fromAnswer = voiceStateIn(result.text ?? '')
39 const fromSettings = voiceEnabledIn(await $.settings.read())
40 await update($, voiceOn, () => fromAnswer ?? fromSettings)
41 } catch (error) {
42 $.ui.toast(`/voice: ${error instanceof Error ? error.message : String(error)}`)
43 }
44}
45
46export function registerVoice(on: On) {
47 // A /voice typed at the prompt (or run from here): the button follows.
48 on('command.run', { command: 'voice' }, async ($, e, next) => {
49 const result = await next(e)
50 const state = voiceStateIn(result.text ?? '')
51 if (state !== null) await update($, voiceOn, () => state)
52 else await loadVoice($)
53 return result
54 }).catch(($, e, next) => next(e))
55}
56