SLOPSHOPPER

cc-esol

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…

newpanebandspinnerrowsguard
v1.0.2MITupdated 2026-10-25rapha-rodrigues/cc-esol/plugins/cc-esol
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-esol
│ ┃ esol ✕ › fix the failing auth test and add an audit log call │ ┃ ESOL Model Settings esc ✕ │ ┃ ━━━━━━━━─────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ Last corrections ⎿ Read 6 lines │ ┃ Entries appear here as you write prompts in ⏺ Update(src/auth.ts) │ ┃ Coaching is off: turn it on in Settings. ⎿ Added 2 lines, removed 1 line │ ┃ ╭────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ ⚙ Settings ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────── │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ─────────────────────────────────────────── │ ┃ tab move · enter select · wheel scrolls · e ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /esol │ ┃ ⎿ cc-esol: The sidebar is open. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts 💡 esol ▼ Model Voice

Draws

Pane · esol
ESOL Model Settings esc ✕ ━━━━━━━━────────────────────────────────────────────── Last corrections Entries appear here as you write prompts in English. Coaching is off: turn it on in Settings. ╭────────────────────────────────────────────────────╮ ╭─── │ ⚙ Settings │ │ ↗ ╰────────────────────────────────────────────────────╯ ╰─── ────────────────────────────────────────────────────── tab move · enter select · wheel scrolls · esc close
Prompt hint
? for shortcuts 💡 esol ▼ Model Voice
README

cc-esol

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.

How it works

  1. Open the sidebar, turn coaching On in the ESOL tab and pick your language.
  2. Every prompt you write in English runs exactly as before. Claude, the model you are already talking to, writes a native version of it with the reasons to one local file: ~/.claude/english-learning/learning.md.
  3. The tab lists your corrections, newest first. Review has the model read the log and answer inside the tab: recurring patterns, interference from your language, whether they are fading, three drills.

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

Speak it

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.

Privacy and cost

Where your words go

  • The log is one local file, ~/.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.
  • The corrections are written by the model you are already talking to, as a call to the plugin's 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.
  • Review sends the log (its newest 150,000 characters at most) to the model in one request and keeps the answer in the plugin's store, a JSON file at ~/.claude/plugins/store/cc-esol_*.json. The /cc-esol:english-review command does the same in the transcript, listing every entry.
  • To delete everything: remove the log file, and remove the store file to drop the kept review and the settings (coaching comes back Off). Uninstalling the plugin leaves the log in place.

What it costs (estimates, at about four characters per token)

  • Themes, Model, Status line, Voice and Icons: nothing. They read what Claude Code already emits and never add to a request.
  • ESOL coaching, while On: about 700 tokens of rules in the 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, while On: about 350 tokens for the 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.
  • For scale: every Claude Code request already carries tens of thousands of tokens of system prompt and tool definitions, so the fixed part is small; the corrections are what grows with use. Switching either feature on or off rewrites the prompt cache once.
  • The plugin never changes Claude Code's system prompt: what it tells Claude rides in its own tools' descriptions and beside your prompts.

The rest of the sidebar

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">

Model

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">

Focus

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.

Themes

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">

Status line and Layout Editor

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">

Settings

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">

Requirements and install

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:

  1. Run claude --version; it must be 2.1.299 or higher.
  2. Run claude plugin marketplace add rapha-rodrigues/cc-esol.
  3. Run claude plugin install cc-esol@esol --scope user.
  4. Check 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.

Languages

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.

Troubleshooting

  • No entries appear. Open the ESOL tab's Settings and check that coaching is On. Hooks load at session start: restart Claude Code or run /reload-plugins after installing or updating.
  • A prompt in my language was coached. Its English words reached the cutoff (loanwords like push, merge or fix count). Raise the cutoff in Settings. Claude also skips a message it can see is not English.
  • An English prompt was not coached. Too few of its words are everyday English (short, jargon-heavy prompts). Lower the cutoff or the minimum words in Settings.
  • The Voice button says /voice is unavailable. Sign in with a claude.ai account (/login); dictation does not work with an API key or a third-party provider.
  • The log keeps growing. Nothing prunes it. Archive it with a rename; the next entry recreates the file. Past 4 MiB the tab stops previewing it and Review asks you to archive first.
  • A notice says a cc-esol hook was skipped. Versions before 1.0.1 hooked two classic events that Claude Code's built-in guard (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.
  • More in docs/english-coach.md and docs/statusline.md.

Develop

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.

License and credits

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.

Source 23 files
hooks/register.tsx 312 lines
1// 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}
312
hooks/english.tsx 591 lines
1// 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}
591
hooks/icons.tsx 53 lines
1// 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}
53
hooks/lib/i18n.ts 157 lines
1// 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
157
hooks/pages/ccsl-editor.tsx 975 lines
1// 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}
975
hooks/pages/focus.tsx 611 lines
1// 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
611
hooks/pages/model.tsx 178 lines
1// 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}
178
hooks/pages/settings.tsx 36 lines
1// 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}
36
hooks/pages/statusline.tsx 258 lines
1// 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}
258
hooks/pages/themes.tsx 465 lines
1// 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}
465
hooks/ui/frame.tsx 147 lines
1// 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}
147
hooks/voice.tsx 56 lines
1// 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