A skin for the Claude Code TUI: a docked sidebar with git, context, limits, todo, skills, MCP and agent sections, plus agent and shell toasts and a /ctui menu…

ctui is a skin for the Claude Code terminal UI. It docks a sidebar beside the transcript with git, context, limits, todo, skills, MCP, and agents-and-shells sections, and it restyles the lines under the prompt input. It also raises short toasts when a subagent or a background shell starts, finishes, or fails.
Tested with Claude Code 2.1.292 (all tested versions).
/plugin marketplace add a1exk-dev/claude-tui
/plugin install ctui@claude-tui
The sidebar docks only in fullscreen: set "tui": "fullscreen" in settings.json or start Claude Code with CLAUDE_CODE_NO_FLICKER=1, in a terminal 110 or more columns wide. Below that, or on the main screen, ctui draws no sidebar; the toasts and the restyled rows still show.
tmux. Inside tmux, Claude Code draws with the 256-color palette even when your terminal supports true color, so sidebar backgrounds and theme colors turn into the nearest grey. Turn true color back on in the env block of your user settings.json, then restart Claude Code:
{
"env": {
"CLAUDE_CODE_TMUX_TRUECOLOR": "1"
}
}
Ghostty with a transparent background. With background-opacity below 1, Ghostty draws cells that have their own background color fully opaque, so the sidebar looks like a solid, lighter panel beside the see-through transcript. Add this to ~/.config/ghostty/config and reload Ghostty, so colored cells take the same opacity:
background-opacity-cells = true
Every setting, the sidebar sections and their options, an example Claude Code theme with the colors ctui uses, and how to set the main background in your terminal are in the configuration docs.
/ctui opens the ctui menu at once, even while Claude is replying. Themes lists inherit (the default, which follows your Claude Code theme) and every bundled theme by name, with a filter field: type to narrow the list, pick to switch. Esc goes back one level, and closes the menu at the top.x turns the focused one on or off, k/j move it up or down (saved a second after the last move), and Enter opens its settings.If another command already holds /ctui, ctui says so in a toast once per session; change its settings in /config instead. Each section and the toasts also have a row in /config. Settings are saved to your user settings.json, so a change applies to every session.
The todo section shows Claude's task list. Claude Code gives Claude the Task tools (TaskCreate, TaskList, TaskUpdate) by default only on some models; Opus 5.x and Sonnet 5.x don't get them. So ctui turns them on for each session by setting CLAUDE_CODE_ENABLE_TODO_TOOLS=1, and subagents and anything the session starts inherit it. To turn this off, set Task tools to off under ctui in /config. ctui leaves the variable alone when you set it yourself, for example in the env block of settings.json.
ctui runs as a mod in every Claude Code session where the plugin is enabled. It watches Bash tool calls and subagent events to show background shells and agents, watches Claude's task tool calls and reads the session's task list for the todo section, watches which skills and commands the chat invokes, and reads the skill and command lists and, after a resume, the transcript, for the skills section, runs git in the working directory for the sidebar header, reads the names of your MCP servers and the ones you turned off from ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json), the .mcp.json files from the working directory up, your settings and the managed MCP settings, and which server a finished MCP tool call came from, to show where each server comes from (never a server's config, which can hold secrets), and, with the inherit theme and a custom Claude Code theme, reads that theme's file from ~/.claude/themes/ to tint the sidebar. It makes no network calls and sends nothing anywhere.
Some parts of the design renders are Claude Code's own and stay as Claude Code draws them: the sidebar's frame and close button, the prompt box, the permission-mode pill, the main background, and the rows of tools other than Read, Edit, Write and Bash. See the full list.
MIT. The Themes in themes/ are generated from the built-in theme palettes of Omarchy 4.0.4, also MIT-licensed; both notices are in LICENSE.
hooks/register.tsx 1401 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { plugins, sections } from '../plugins'
4import { colors } from '../plugins/colors'
5import type { GitSnapshot, Menu, SkillRow, Task, Todo, TodoItem, Turn, Usage } from '../types'
6import { type Config, readConfig } from './config'
7import { fitsPromptLine, formatReset, modelEffort, promptLabel, savedEffort } from './format'
8import { parseGit, tildify } from './git'
9import { type McpSources, mcpRows, segmentOf, serverName } from './mcp'
10import {
11 type Cached,
12 COST_PREFIX,
13 costKey,
14 lastCosts,
15 monthOf,
16 monthStart,
17 monthTotal,
18 showsCost,
19 staleTranscripts,
20 type Transcript,
21} from './month'
22import { inheritGlass, themeFile } from './glass'
23import { addSkill, skillsFromMessages, sourceOf } from './skills'
24import { deniedText, HOTKEYS, isMonthlyKey, KEYS, MENU_START, monthlyKey, type Setting, settingKey, settingRows, menuView, moveId, NO_CONFIG, PARENT, rowId, rowKey, takenBy, themeSlug, type ThemeName, topKey, type TopPick, topPick } from './menu'
25import { type Control, controlKey, foldRowKey, sidebar, type SidebarInput } from './sidebar'
26import { assistantMessage } from './skin/assistant'
27import { promptLabelRow } from './skin/prompt'
28import { hasToolRow, toolLine, toolRow } from './skin/tool'
29import { turnOf, turnWord } from './skin/turn'
30import { isOwnPrompt, userMessage } from './skin/user'
31import {
32 applyAgentList,
33 applyNotification,
34 dropEnded,
35 endTask,
36 enqueue,
37 failureReason,
38 firstLine,
39 parseTaskNotification,
40 startedToast,
41 type Toast,
42} from './tasks'
43
44const SIDEBAR = 'sidebar'
45const FOLDED = { plugin: 'ctui', key: 'folded' } as const
46const EXPANDED = { plugin: 'ctui', key: 'expanded' } as const
47const SCROLL = { plugin: 'ctui', key: 'scroll' } as const
48const GIT = { plugin: 'ctui', key: 'git' } as const
49const VERSIONS = { plugin: 'ctui', key: 'versions' } as const
50const USAGE = { plugin: 'ctui', key: 'usage' } as const
51const NOW = { plugin: 'ctui', key: 'now' } as const
52const MONTH = { plugin: 'ctui', key: 'month' } as const
53const MCP = { plugin: 'ctui', key: 'mcp' } as const
54const MCP_OBSERVED = { plugin: 'ctui', key: 'mcpObserved' } as const
55const TODO = { plugin: 'ctui', key: 'todo' } as const
56const SKILLS = { plugin: 'ctui', key: 'skills' } as const
57const ACTIVE_FORMS = { plugin: 'ctui', key: 'activeForms' } as const
58const TODO_ENV_SET = { plugin: 'ctui', key: 'todoEnvSet' } as const
59const TASKS = { plugin: 'ctui', key: 'tasks' } as const
60const MODEL = { plugin: 'ctui', key: 'model' } as const
61const EFFORT = { plugin: 'ctui', key: 'effort' } as const
62const TURNS = { plugin: 'ctui', key: 'turns' } as const
63const GLASS = { plugin: 'ctui', key: 'glass' } as const
64
65// The dock docks from 110 terminal columns; the Sidebar asks 42, or 53 from 160.
66const DOCK_COLUMNS = 110
67const GAP = 2 // columns between the transcript rows ctui draws and the docked Sidebar's rule
68const widthFor = (terminalColumns: number) => (terminalColumns >= 160 ? 53 : 42)
69
70// The `/ctui` menu's pane, its model, and whether this session already toasted a taken `/ctui`.
71const MENU_PANE = 'ctui'
72const MENU = { plugin: 'ctui', key: 'menu' } as const
73const MENU_TAKEN = { plugin: 'ctui', key: 'menuTaken' } as const
74
75// The enterprise MCP file on Linux and macOS.
76const ENTERPRISE_MCP = ['/etc/claude-code/managed-mcp.json', '/Library/Application Support/ClaudeCode/managed-mcp.json']
77
78const GIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
79
80// What `/effort <args>` sets for the session.
81const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
82
83// The month total rescans this often while the cost row shows.
84const SCAN_MS = 60_000
85
86// Toasts come at most this often: the engine drops one within 2 s of the last.
87const TOAST_GAP_MS = 2100
88
89// Module state: a reload starts a fresh environment.
90let tick: { cancel: () => void } | undefined
91let orderTimer: { cancel: () => void } | undefined // the menu's order write, a second after the last move
92let gitRunning = false
93let gitAgain = false // a refresh came while git ran: run once more after it
94let checking = false // a viewport check is queued
95let waiting = false // opened unasked and not placed: open again on the person's prompt
96let requested: number | undefined // the columns last asked for
97let maxScroll = 0 // the sections window's last offset, as last drawn
98let monthRunning = false
99let lastScan = -Infinity // when the month total last scanned
100let mcpRunning = false
101let mcpTimeout: number | undefined // MCP_TIMEOUT, read once
102// `.claude.json`'s disabled list and its user and local MCP server names.
103type ClaudeJson = { path: string; project: string; mtimeMs?: number; disabled: string[]; user: string[]; local: string[] }
104let claudeJson: ClaudeJson | undefined
105// The approved project, managed and enterprise MCP server names, read at start and on /cd.
106let mcpFiles: Pick<McpSources, 'project' | 'managed' | 'enterprise'> = { project: [], managed: [], enterprise: [] }
107// The sources tool runs reported, by segment, also kept in `$.state`: a reload
108// resets this, `/clear` and `/resume` empty that, and the servers outlive both.
109let mcpObserved: Record<string, string> = {}
110let cwdMoved = false // since the last MCP tick: drop the old project's off rows
111let themeSeen: { path?: string; mtimeMs?: number } = {} // the active custom /theme file, last read
112let themeNames: ThemeName[] | undefined // every Theme's slug and name, read once
113const mcpNames: Record<string, string> = {} // segment → /mcp name, from `tool.describe`
114let todoRunning = false
115let todoAgain = false // a reload came while one ran: run once more after it
116let tasksChain: Promise<unknown> = Promise.resolve() // task updates, one at a time
117let carry: Record<string, Task> | undefined // running tasks across /clear and /resume
118let toasts: Toast[] = []
119let toasting = false
120let toastsOn = true // `agents_toasts`
121let lastToast = -Infinity
122let agentsRunning = false
123let themes: readonly string[] | undefined // the `theme` setting's options, read once
124let hint = '' // PromptHint's text, as the engine last drew it
125let dockColumns = 0 // the docked Sidebar's columns, its rule included; 0 while it isn't docked
126let savedLevel: { model: string; level: unknown } | undefined // modelSettings[model].effortLevel at the last poll
127let effortCarry: string | null | undefined // the session's effort across /clear and /resume
128let mode: string | undefined // the latest main-loop permission_mode
129let stepModel: string | undefined // the last main-loop turn.step's model
130let lastTurn: { turn: Turn; at: number } | undefined // the latest main-loop turn.complete's record
131const drawnFooters = new Set<string>() // TurnDuration instances drawn since load
132
133// A run still going takes this refresh as one more run after it ends.
134async function refreshGit($: EngineInterface) {
135 if (gitRunning) {
136 gitAgain = true
137 return
138 }
139 gitRunning = true
140 try {
141 const run = (args: string[]) => $.process.run(['git', '--no-optional-locks', ...args]).catch(() => undefined)
142 const git: GitSnapshot = { path: tildify(await $.session.cwd(), await $.env.get('HOME')) }
143 const status = await run(['status', '--porcelain=v2', '--branch'])
144 if (status?.exitCode === 0) {
145 // On an unborn branch the numstat fails: it counts as 0.
146 const [stash, numstat] = await Promise.all([run(['stash', 'list']), run(['diff', '--numstat', 'HEAD'])])
147 const out = (result: typeof stash) => (result?.exitCode === 0 ? result.stdout : '')
148 git.repo = parseGit(status.stdout, out(stash), out(numstat))
149 }
150 const { value } = await $.state.get(GIT)
151 if (JSON.stringify(value) !== JSON.stringify(git)) await $.state.set(GIT, git)
152 } finally {
153 gitRunning = false
154 }
155 if (gitAgain) {
156 gitAgain = false
157 await refreshGit($)
158 }
159}
160
161// `/clear` and `/resume` empty `$.state` with no `session.start`: the tick refills it.
162async function refillVersions($: EngineInterface, slug: string) {
163 const { value } = await $.state.get(VERSIONS)
164 if (!value) await loadVersions($, slug)
165}
166
167// `slug` is the `theme` setting; the footer shows the Theme file's `name`, as `/theme` lists it.
168async function loadVersions($: EngineInterface, slug: string) {
169 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
170 const name =
171 slug === 'inherit' ? slug : String(JSON.parse(await $.fs.read(`${$.plugin.root}/themes/${slug}.json`)).name)
172 const versions = { ctui: String(manifest.version), claude: (await $.session.version()).version, theme: name }
173 const { value } = await $.state.get(VERSIONS)
174 if (JSON.stringify(value) !== JSON.stringify(versions)) await $.state.set(VERSIONS, versions)
175}
176
177// The context and limits figures, from `$.session.usage()` or a `session.measure`.
178async function setUsage($: EngineInterface, { context, rateLimits, cost }: Usage) {
179 const usage: Usage = { context, rateLimits, ...(cost && { cost }) }
180 const { value } = await $.state.get(USAGE)
181 if (JSON.stringify(value) !== JSON.stringify(usage)) await $.state.set(USAGE, usage)
182}
183
184async function loadUsage($: EngineInterface) {
185 await setUsage($, await $.session.usage())
186}
187
188// `/clear` and `/resume` empty `$.state` with no `session.start`: the tick refills it.
189async function refillUsage($: EngineInterface) {
190 const { value } = await $.state.get(USAGE)
191 if (!value) await loadUsage($)
192}
193
194// `now` moves when a reset time shown would read differently.
195async function setNow($: EngineInterface) {
196 const usage = await $.state.get(USAGE)
197 const resets = usage.value?.rateLimits.flatMap((limit) => (limit.resetsAt ? [limit.resetsAt] : [])) ?? []
198 if (!resets.length) return
199 const now = await $.clock.now()
200 const { value } = await $.state.get(NOW)
201 if (value === undefined || resets.some((at) => formatReset(at, now) !== formatReset(at, value))) {
202 await $.state.set(NOW, now)
203 }
204}
205
206// The main transcripts written since `since`, the current session's left out:
207// `<config dir>/projects/<project>/<session id>.jsonl`.
208async function listTranscripts($: EngineInterface, since: number, current: string) {
209 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
210 const projects = (await $.fs.list(`${dir}/projects`)).filter((entry) => entry.kind === 'dir')
211 const lists = await Promise.all(
212 projects.map(async ({ name }) => {
213 const path = `${dir}/projects/${name}`
214 return (await $.fs.list(path)).flatMap(({ name, kind, mtimeMs, size }): Transcript[] => {
215 const session = name.endsWith('.jsonl') ? name.slice(0, -'.jsonl'.length) : ''
216 return kind === 'file' && session && session !== current && mtimeMs >= since
217 ? [{ session, path: `${path}/${name}`, mtimeMs, size }]
218 : []
219 })
220 }),
221 )
222 return lists.flat()
223}
224
225// The ended sessions' cost this month (#147): each transcript's last
226// `cost-state`, cached per session in `$.store` so unchanged transcripts aren't
227// read again and totals outlive the 30-day transcript sweep. A failed scan
228// keeps the cached totals; the next one retries.
229async function scanMonth($: EngineInterface) {
230 if (monthRunning) return
231 monthRunning = true
232 try {
233 const [now, current] = await Promise.all([$.clock.now(), $.session.id()])
234 lastScan = now
235 const month = monthOf(now)
236 const prefix = costKey(month, '')
237 const keys = await $.store.keys()
238 const cached: Record<string, Cached> = {}
239 for (const key of keys) {
240 if (key.startsWith(prefix)) cached[key.slice(prefix.length)] = (await $.store.get(key)) as Cached
241 // Earlier months' totals go.
242 else if (key.startsWith(COST_PREFIX)) await $.store.delete(key).catch(() => undefined)
243 }
244 try {
245 const stale = staleTranscripts(await listTranscripts($, monthStart(now), current), cached)
246 if (stale.length) {
247 const grep = ['grep', '-h', '-F', '"type":"cost-state"', '--', ...stale.map((t) => t.path)]
248 const { exitCode, stdout, isStdoutTruncated } = await $.process.run(grep)
249 // 1 is no line found.
250 if (exitCode > 1 || isStdoutTruncated) throw new Error(`grep exited ${exitCode}`)
251 const costs = lastCosts(stdout)
252 for (const { session, mtimeMs, size } of stale) {
253 const entry = { usd: costs[session] ?? 0, mtimeMs, size }
254 await $.store.set(costKey(month, session), entry)
255 cached[session] = entry
256 }
257 }
258 } catch {
259 // Failed: the cached totals stand until the next scan.
260 }
261 const total = { month, session: current, usd: monthTotal(cached, current) }
262 const { value } = await $.state.get(MONTH)
263 if (JSON.stringify(value) !== JSON.stringify(total)) await $.state.set(MONTH, total)
264 } catch {
265 // The store can't be read: the last total stands.
266 } finally {
267 monthRunning = false
268 }
269}
270
271// Scans while the cost row shows: once the usage says it does, each SCAN_MS,
272// at the local month's turn, and when `/clear` or `/resume` changed the
273// session, a scan running across the switch included.
274async function refreshMonth($: EngineInterface, choice: Config['limits']['cost']) {
275 const [usage, total, now] = await Promise.all([$.state.get(USAGE), $.state.get(MONTH), $.clock.now()])
276 if (!showsCost(usage.value, choice)) return
277 const session = await $.session.id()
278 const value = total.value
279 if (!value || value.month !== monthOf(now) || value.session !== session || now - lastScan >= SCAN_MS) {
280 await scanMonth($)
281 }
282}
283
284// `.claude.json` keys a project by its git root, found by walking up from the cwd, else the cwd.
285async function projectOf($: EngineInterface) {
286 const cwd = await $.session.cwd()
287 for (let dir = cwd; dir; dir = dir.slice(0, dir.lastIndexOf('/'))) {
288 if (await $.fs.exists(`${dir}/.git`)) return dir
289 }
290 return cwd
291}
292
293// `projects[<project>].disabledMcpServers`, and the user (`mcpServers`) and
294// local (`projects[<project>].mcpServers`) server names, re-read when the
295// file's mtime changes. Claude Code keeps the file in CLAUDE_CONFIG_DIR when that is set.
296async function readClaudeJson($: EngineInterface) {
297 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || (await $.env.get('HOME'))
298 claudeJson ??= { path: `${dir}/.claude.json`, project: await projectOf($), disabled: [], user: [], local: [] }
299 const file = claudeJson
300 const stat = await $.fs.stat(file.path).catch(() => undefined)
301 if (stat?.mtimeMs !== file.mtimeMs) {
302 try {
303 const json = stat ? JSON.parse(await $.fs.read(file.path)) : {}
304 const project = json.projects?.[file.project]
305 file.disabled = project?.disabledMcpServers ?? []
306 file.user = Object.keys(json.mcpServers ?? {})
307 file.local = Object.keys(project?.mcpServers ?? {})
308 file.mtimeMs = stat?.mtimeMs
309 } catch {
310 // Caught mid-write: the next tick reads it again.
311 }
312 }
313 return file
314}
315
316// The server names in an `{ mcpServers }` file, never their entries (they can hold secrets).
317async function serverNames($: EngineInterface, path: string) {
318 try {
319 return Object.keys(JSON.parse(await $.fs.read(path)).mcpServers ?? {})
320 } catch {
321 return [] // No such file.
322 }
323}
324
325// MEMORY.md "The `mcp` Sidebar plugin reads live tools and MCP config names;
326// it never probes": the approved project servers in `.mcp.json` from the cwd
327// up to (not including) `/`, the policy's `managedMcpServers`, and the
328// enterprise `managed-mcp.json`.
329async function readMcpFiles($: EngineInterface, cwd?: string) {
330 const [dir0, settings, policy] = await Promise.all([
331 cwd ?? $.session.cwd(),
332 $.settings.read() as Promise<Record<string, unknown>>,
333 $.settings.read({ source: 'policy' }) as Promise<Record<string, unknown>>,
334 ])
335 const dirs: string[] = []
336 for (let dir = dir0; dir; dir = dir.slice(0, dir.lastIndexOf('/'))) dirs.push(dir)
337 const listed = (key: string) => (Array.isArray(settings[key]) ? (settings[key] as string[]) : [])
338 const approved = (name: string) =>
339 !listed('disabledMcpjsonServers').includes(name) &&
340 (settings.enableAllProjectMcpServers === true || listed('enabledMcpjsonServers').includes(name))
341 const [project, enterprise] = await Promise.all([
342 Promise.all(dirs.map((dir) => serverNames($, `${dir}/.mcp.json`))),
343 Promise.all(ENTERPRISE_MCP.map((path) => serverNames($, path))),
344 ])
345 const managed = policy.managedMcpServers
346 mcpFiles = {
347 project: project.flat().filter(approved),
348 managed: managed && typeof managed === 'object' && !Array.isArray(managed) ? Object.keys(managed) : [],
349 enterprise: enterprise.flat(),
350 }
351}
352
353// Under `inherit`, the glass of the active `/theme` when it's a user custom
354// theme with no dock color (Omarchy's), re-read when the theme or its file's
355// mtime changes: an Omarchy switch rewrites the file.
356async function refreshGlass($: EngineInterface) {
357 const row = (await $.config.list()).find((r) => r.key === 'theme')
358 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
359 const path = themeFile(row?.value, dir)
360 const stat = path ? await $.fs.stat(path).catch(() => undefined) : undefined
361 // `/clear` and `/resume` empty `$.state` with no `session.start`: read again.
362 const { value } = await $.state.get(GLASS)
363 if (value !== undefined && path === themeSeen.path && stat?.mtimeMs === themeSeen.mtimeMs) return
364 let glass: string | null
365 try {
366 glass = (path && stat && inheritGlass(JSON.parse(await $.fs.read(path)).overrides ?? {})) || null
367 } catch {
368 return // Caught mid-write: the next tick reads it again.
369 }
370 themeSeen = { path, mtimeMs: stat?.mtimeMs }
371 // A state value is never undefined: null clears the paint.
372 if (value !== glass) await $.state.set(GLASS, glass)
373}
374
375// MEMORY.md "The `mcp` Sidebar plugin reads live tools and MCP config names; it never probes".
376async function refreshMcp($: EngineInterface) {
377 if (mcpRunning) return
378 mcpRunning = true
379 try {
380 mcpTimeout ??= Number(await $.env.get('MCP_TIMEOUT')) || 30_000
381 const moved = cwdMoved
382 cwdMoved = false
383 const [tools, file, now, { value: rows = [] }, { value: observed = {} }] = await Promise.all([
384 $.tool.list(),
385 readClaudeJson($),
386 $.clock.now(),
387 $.state.get(MCP),
388 $.state.get(MCP_OBSERVED),
389 ])
390 const kept = moved ? rows.filter((row) => row.state !== 'off') : rows
391 const sources = { ...mcpFiles, user: file.user, local: file.local, observed: { ...mcpObserved, ...observed } }
392 const next = mcpRows({ rows: kept, tools, disabled: file.disabled, names: mcpNames, now, timeoutMs: mcpTimeout, sources })
393 if (JSON.stringify(next) !== JSON.stringify(rows)) await $.state.set(MCP, next)
394 } finally {
395 mcpRunning = false
396 }
397}
398
399// MEMORY.md "The `todo` Sidebar plugin turns the task tools on by default":
400// set the variable when it's unset, and unset it only when ctui set it.
401async function setTodoEnv($: EngineInterface, tools: boolean) {
402 const { value: ours } = await $.state.get(TODO_ENV_SET)
403 if (tools && (await $.env.get('CLAUDE_CODE_ENABLE_TODO_TOOLS')) === undefined) {
404 await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1')
405 await $.state.set(TODO_ENV_SET, true)
406 } else if (!tools && ours) {
407 await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', undefined)
408 await $.state.set(TODO_ENV_SET, false)
409 }
410}
411
412const TODO_STATUSES = new Set<string>(['pending', 'in_progress', 'completed'])
413
414const todoToolsOf = (names: readonly string[]): Todo['tools'] =>
415 names.includes('TaskList') ? 'task' : names.includes('TodoWrite') ? 'todowrite' : 'none'
416
417async function setTodo($: EngineInterface, todo: Todo) {
418 const { value } = await $.state.get(TODO)
419 if (JSON.stringify(value) !== JSON.stringify(todo)) await $.state.set(TODO, todo)
420}
421
422// The list as the session's task tools hold it (the map decision for #10).
423// `TaskList` covers a reload, `--resume` and compaction; it lacks `activeForm`,
424// kept from the TaskCreate/TaskUpdate inputs. `TodoWrite` takes the last call's
425// list from the transcript. A run still going takes this load as one more run.
426async function loadTodo($: EngineInterface) {
427 if (todoRunning) {
428 todoAgain = true
429 return
430 }
431 todoRunning = true
432 try {
433 const tools = todoToolsOf((await $.tool.list()).map((tool) => tool.name))
434 let items: TodoItem[] = []
435 if (tools === 'task') {
436 // TaskList throws when the tool isn't listed, as after a `/model` switch.
437 const listed = await $.tool.call({ tool: 'TaskList' }).catch(() => undefined)
438 if (!listed?.result || listed.isError) return
439 const { value: forms = {} } = await $.state.get(ACTIVE_FORMS)
440 // Rows leave out `deleted`, which 2.1.288's TaskList type doesn't list.
441 const shown = listed.result.tasks.filter(({ status }) => TODO_STATUSES.has(status))
442 items = shown.map(({ id, subject, status }) => ({
443 id,
444 subject,
445 status,
446 ...(forms[id] !== undefined && { activeForm: forms[id] }),
447 }))
448 } else if (tools === 'todowrite') {
449 const uses = (await $.session.messages()).flatMap((message) => message.toolUses)
450 const last = uses.findLast((use) => use.tool === 'TodoWrite' && !use.isError)
451 items = todoWriteItems(last?.input.todos)
452 }
453 await setTodo($, { tools, items })
454 } finally {
455 todoRunning = false
456 }
457 if (todoAgain) {
458 todoAgain = false
459 await loadTodo($)
460 }
461}
462
463// A TodoWrite list: its `todos` input, or its `newTodos` result.
464function todoWriteItems(todos: unknown): TodoItem[] {
465 if (!Array.isArray(todos)) return []
466 return todos.map(({ content, status, activeForm }) => ({ subject: content, status, activeForm }))
467}
468
469// Reloads the list when the session's task tools change: a `/model` switch,
470// or `/clear` and `/resume`, which empty `$.state`.
471async function refreshTodo($: EngineInterface) {
472 const [tools, { value }] = await Promise.all([$.tool.list(), $.state.get(TODO)])
473 if (todoToolsOf(tools.map((tool) => tool.name)) !== value?.tools) await loadTodo($)
474}
475
476// Keeps a Task's `activeForm`, which TaskList lacks.
477async function keepActiveForm($: EngineInterface, id: string, activeForm: string | undefined) {
478 if (activeForm === undefined) return
479 const { value = {} } = await $.state.get(ACTIVE_FORMS)
480 if (value[id] !== activeForm) await $.state.set(ACTIVE_FORMS, { ...value, [id]: activeForm })
481}
482
483// Applies `change` to the session's tasks, one change at a time. `change`
484// edits the copy it gets and returns the toasts its edits raise.
485function updateTasks(
486 $: EngineInterface,
487 change: (tasks: Record<string, Task>, now: number) => Toast[] | void | Promise<Toast[] | void>,
488) {
489 const run = tasksChain.then(async () => {
490 const [{ value = {} }, now] = await Promise.all([$.state.get(TASKS), $.clock.now()])
491 const tasks = { ...value }
492 const raised = (await change(tasks, now)) ?? []
493 if (JSON.stringify(tasks) !== JSON.stringify(value)) await $.state.set(TASKS, tasks)
494 for (const toast of raised) showToast($, toast)
495 })
496 tasksChain = run.catch(() => undefined)
497 return run
498}
499
500// One toast at most every TOAST_GAP_MS; an end goes ahead of waiting starts.
501// `always` skips the `agents_toasts` gate, for a toast that isn't a task's.
502function showToast($: EngineInterface, toast: Toast, always = false) {
503 if (!toastsOn && !always) return
504 toasts = enqueue(toasts, toast)
505 // A reload unloads the environment under a waiting toast: the new module starts its own queue.
506 if (!toasting) void drainToasts($).catch(() => undefined)
507}
508
509async function drainToasts($: EngineInterface) {
510 toasting = true
511 try {
512 while (toasts.length) {
513 const wait = lastToast + TOAST_GAP_MS - (await $.clock.now())
514 if (wait > 0) await $.clock.sleep(wait)
515 const [toast, ...rest] = toasts
516 toasts = rest
517 if (!toast) break
518 lastToast = await $.clock.now()
519 await $.ui.toast(toast.text)
520 }
521 } finally {
522 toasting = false
523 }
524}
525
526// Agent status from the list, which has no change event.
527async function refreshAgents($: EngineInterface) {
528 if (agentsRunning) return
529 agentsRunning = true
530 try {
531 const list = (await $.agent.list()).filter((agent) => agent.type !== 'teammate')
532 await updateTasks($, (tasks, now) => applyAgentList(tasks, list, now))
533 } finally {
534 agentsRunning = false
535 }
536}
537
538// The tick's share: merges the /clear carry, drops rows ended ENDED_MS ago,
539// and moves `now` while a row shows.
540async function tickTasks($: EngineInterface, timers: boolean) {
541 const carried = carry
542 carry = undefined
543 let shown = false
544 await updateTasks($, (tasks, now) => {
545 for (const task of Object.values(carried ?? {})) tasks[task.id] ??= task
546 dropEnded(tasks, now)
547 shown = Object.keys(tasks).length > 0
548 })
549 if (timers && shown) await $.state.set(NOW, await $.clock.now())
550}
551
552function onNotification($: EngineInterface, text: string) {
553 const note = parseTaskNotification(text)
554 if (note) return updateTasks($, (tasks, now) => applyNotification(tasks, note, now))
555}
556
557const textOf = (content: readonly { type: string; text?: string }[]) =>
558 content.map((block) => (block.type === 'text' ? (block.text ?? '') : '')).join('\n')
559
560// Folds or unfolds a section, or expands or caps its list.
561async function toggle($: EngineInterface, config: Config, { kind, id }: Control) {
562 if (kind === 'fold') {
563 const { value = {} } = await $.state.get(FOLDED)
564 await $.state.set(FOLDED, { ...value, [id]: !(value[id] ?? config[id].folded ?? false) })
565 } else {
566 const { value = {} } = await $.state.get(EXPANDED)
567 await $.state.set(EXPANDED, { ...value, [id]: !value[id] })
568 }
569}
570
571// The Sidebar's role colors and background: under `inherit` each role's theme
572// key and no background, else the selected Theme's overrides and its glass,
573// from `themes/<slug>.json`.
574async function themeLook($: EngineInterface, theme: string): Promise<Pick<SidebarInput, 'colors' | 'background'>> {
575 if (theme === 'inherit') return { colors: colors() }
576 const { overrides } = JSON.parse(await $.fs.read(`${$.plugin.root}/themes/${theme}.json`))
577 return { colors: colors(overrides), background: overrides.composerSidebarBackground }
578}
579
580// The manifest's `theme` options: under `claude -p` no `/config` row lists them.
581async function themesOf($: EngineInterface) {
582 themes ??= JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)).userConfig.theme
583 .options as string[]
584 return themes
585}
586
587// `claude -p` and the SDK list no `ctui.theme` row, and `$.config.set` throws there.
588async function hasConfig($: EngineInterface) {
589 return (await $.config.list()).some((row) => row.key === 'ctui.theme')
590}
591
592// MEMORY.md "`/ctui` is one instant menu; it survives each settings reload".
593// The name is global: a person's own `/ctui` or an earlier plugin's makes `register` throw.
594async function registerMenu($: EngineInterface) {
595 try {
596 await $.command.register({ name: 'ctui', description: 'ctui: Sidebar plugins and themes', immediate: true })
597 } catch (error) {
598 // A taken name reads `"/ctui" refused: …`.
599 const refusal = error instanceof Error ? error.message : String(error)
600 if (!refusal.includes('refused') || (await $.state.get(MENU_TAKEN)).value) return
601 await $.state.set(MENU_TAKEN, true)
602 const who = takenBy(refusal)
603 showToast($, { id: 'menu-taken', end: true, text: `/ctui is taken by ${who}; change ctui settings in /config` }, true)
604 }
605}
606
607// Opens the menu with the keys: a first open, or one taking them back after Esc's deny.
608async function openMenu($: EngineInterface) {
609 // As wide as the Sidebar it docks over; inline ignores it, and fits the
610 // menu's content up to `rows`.
611 await $.ui.open({ id: MENU_PANE, title: 'Settings', focus: true, closeOnEscape: true, columns: requested ?? widthFor(DOCK_COLUMNS), rows: 24 })
612}
613
614async function menuOf($: EngineInterface): Promise<Menu> {
615 return (await $.state.get(MENU)).value ?? MENU_START
616}
617
618async function setMenu($: EngineInterface, menu: Menu) {
619 await $.state.set(MENU, menu)
620}
621
622// One menu write; a deny toasts its line (a menu pick has no command row),
623// through the queue even with `agents_toasts` off.
624async function writeSetting($: EngineInterface, set: Setting) {
625 const { deny } = await $.config.set(set)
626 if (deny !== undefined) showToast($, { id: 'menu-deny', end: true, text: deniedText(set, deny) }, true)
627 return deny === undefined
628}
629
630// Puts the menu's ring on `key` once the redraw has drawn it, as #160's spike did.
631function focusLater($: EngineInterface, key: string) {
632 $.clock.after(50, () => $.ui.focus({ requestId: MENU_PANE, key }).catch(() => undefined))
633}
634
635// Writes an order the Plugins screen moved, if one waits. Its reload drops
636// the module's timer, so every other write, Esc and the close call this first.
637// The rows keep the pending order until the write lands; the reload's
638// `session.start` then drops it.
639async function flushOrder($: EngineInterface) {
640 orderTimer?.cancel()
641 orderTimer = undefined
642 const { pending } = await menuOf($)
643 if (!pending) return
644 await writeSetting($, { key: 'ctui.order', value: pending.join(',') })
645 const { pending: _, ...menu } = await menuOf($)
646 await setMenu($, menu)
647}
648
649async function themeNamesOf($: EngineInterface) {
650 const slugs = await themesOf($)
651 themeNames ??= await Promise.all(
652 slugs.map(async (slug) =>
653 slug === 'inherit'
654 ? { slug, name: slug }
655 : { slug, name: String(JSON.parse(await $.fs.read(`${$.plugin.root}/themes/${slug}.json`)).name) },
656 ),
657 )
658 return themeNames
659}
660
661// MEMORY.md "The `skills` Sidebar plugin lists every `skill.prompt`": the
662// skill listing and the command list name each skill's source.
663async function skillCatalog($: EngineInterface) {
664 const [usage, commands] = await Promise.all([$.session.usage({ breakdown: 'summary' }), $.command.list()])
665 return { listed: usage.context.breakdown?.skills?.skillFrontmatter ?? [], commands }
666}
667
668let skillsChain: Promise<unknown> = Promise.resolve() // one list write at a time
669
670// Runs `write` after the list's earlier writes, so a rebuild and a
671// `skill.prompt` never race.
672function writeSkills(write: () => Promise<void>) {
673 const run = skillsChain.then(write)
674 skillsChain = run.catch(() => undefined)
675 return run
676}
677
678// A `skill.prompt`'s skill, once; a skill already listed reads nothing more.
679function addSkills($: EngineInterface, name: string) {
680 return writeSkills(async () => {
681 const { value = [] } = await $.state.get(SKILLS)
682 if (value.some((row) => row.name === name)) return
683 const { listed, commands } = await skillCatalog($)
684 await $.state.set(SKILLS, [...addSkill(value, { name, source: sourceOf(name, listed, commands) })])
685 })
686}
687
688// After `--resume` (a new process) and `/resume` or `/clear` (`$.state`
689// emptied), rebuild the list from the transcript's main rows; a reload keeps it.
690function refillSkills($: EngineInterface) {
691 return writeSkills(async () => {
692 if ((await $.state.get(SKILLS)).value) return
693 const [{ listed, commands }, messages] = await Promise.all([skillCatalog($), $.session.messages()])
694 const known = new Set([
695 ...listed.map((skill) => skill.name),
696 ...commands.filter((command) => command.source !== 'builtin').map((command) => command.name),
697 ])
698 const names = skillsFromMessages(messages, known)
699 let rows: readonly SkillRow[] = []
700 for (const name of names) rows = addSkill(rows, { name, source: sourceOf(name, listed, commands) })
701 await $.state.set(SKILLS, [...rows])
702 })
703}
704
705// MEMORY.md "The line under the prompt: `model · effort` is a `SessionMode`
706// label". The model follows `/model` within a tick. An unset effort is a new
707// session (or `/clear`, `/resume`): seed it. Later, a change in the current
708// model's saved level is a `/effort` picker save.
709async function refreshModel($: EngineInterface) {
710 const [model, settings, stored, effort] = await Promise.all([
711 $.session.model(),
712 $.settings.read(),
713 $.state.get(MODEL),
714 $.state.get(EFFORT),
715 ])
716 const level = modelEffort(settings, model)
717 const changed = savedLevel?.model === model && savedLevel.level !== level
718 savedLevel = { model, level }
719 if (effort.value === undefined) {
720 await setEffort($, effortCarry !== undefined ? effortCarry : (savedEffort(settings, model) ?? null))
721 effortCarry = undefined
722 } else if (changed && typeof level === 'string') {
723 await setEffort($, level)
724 }
725 if (stored.value !== model) await $.state.set(MODEL, model)
726}
727
728// A turn's footer draws within this long of its turn.complete.
729const FOOTER_MS = 2000
730
731async function addTurn($: EngineInterface, turn: Turn) {
732 const { value = [] } = await $.state.get(TURNS)
733 await $.state.set(TURNS, [...value, turn])
734}
735
736// The latest main-loop permission mode, for the turn footer.
737function noteMode(e: { agent_id?: string; permission_mode?: string }) {
738 if (e.agent_id === undefined && e.permission_mode) mode = e.permission_mode
739}
740
741// Redraws the line under the prompt on the next tick, never from a render.
742function redrawLater($: EngineInterface) {
743 $.clock.after(0, () => $.ui.invalidate('ui.render'))
744}
745
746async function setEffort($: EngineInterface, effort: string | null) {
747 const { value } = await $.state.get(EFFORT)
748 if (value !== effort) await $.state.set(EFFORT, effort)
749}
750
751async function openSidebar($: EngineInterface, columns: number) {
752 requested = columns
753 const opened = await $.ui.open({ id: SIDEBAR, title: 'Sidebar', columns })
754 waiting = !opened.isPlaced
755}
756
757export const register: Register = (on, options) => {
758 const config = readConfig(options)
759 // The sections in the `order` setting's order; the Sidebar places the header and footer by slot.
760 const enabled = plugins
761 .filter((plugin) => config[plugin.id].enable)
762 .sort((a, b) => config.order.indexOf(a.id) - config.order.indexOf(b.id))
763 const needs = new Set(enabled.flatMap((plugin) => plugin.needs))
764 // Tasks feed the Sidebar section and the toasts.
765 const tracking = needs.has('tasks') || config.agents.toasts
766 toastsOn = config.agents.toasts
767 const inherit = config.theme === 'inherit'
768 let look: ReturnType<typeof themeLook> | undefined // read once per load: a `theme` change reloads
769
770 on('session.start', async ($, e, next) => {
771 void registerMenu($).catch(() => undefined)
772 // A reload from elsewhere (`/config`) drops the order timer: start it again.
773 const { pending, ...menu } = await menuOf($)
774 if (pending?.join(',') === config.order.join(',')) await setMenu($, menu)
775 else if (pending) {
776 orderTimer?.cancel()
777 orderTimer = $.clock.after(1000, () => flushOrder($))
778 }
779 await setTodoEnv($, config.todo.tools)
780 void refreshModel($).catch(() => undefined)
781 if (needs.has('todo')) void loadTodo($)
782 if (needs.has('skills')) void refillSkills($).catch(() => undefined)
783 if (needs.has('git')) void refreshGit($)
784 if (needs.has('mcp')) void readMcpFiles($).catch(() => undefined)
785 if (needs.has('versions')) void loadVersions($, config.theme)
786 if (needs.has('usage')) {
787 void loadUsage($).then(() => {
788 if (needs.has('now')) void setNow($)
789 if (needs.has('monthCost')) void refreshMonth($, config.limits.cost).catch(() => undefined)
790 })
791 }
792 if (inherit) void refreshGlass($).catch(() => undefined)
793 let ticks = 0
794 tick?.cancel()
795 tick = $.clock.every(1000, () => {
796 ticks++
797 void refreshModel($).catch(() => undefined)
798 if (ticks % 5 === 0 && needs.has('git')) void refreshGit($)
799 // The usage says whether the cost row shows: refilled first after `/clear`.
800 if (needs.has('usage')) {
801 void refillUsage($).then(() => (needs.has('monthCost') ? refreshMonth($, config.limits.cost) : undefined)).catch(() => undefined)
802 }
803 if (needs.has('versions')) void refillVersions($, config.theme)
804 if (needs.has('mcp')) void refreshMcp($)
805 if (needs.has('todo')) void refreshTodo($)
806 if (needs.has('skills')) void refillSkills($).catch(() => undefined)
807 if (needs.has('now')) void setNow($)
808 if (inherit) void refreshGlass($).catch(() => undefined)
809 if (tracking) void refreshAgents($).then(() => tickTasks($, needs.has('now')))
810 })
811 return next(e)
812 })
813
814 on('session.measure', async ($, e, next) => {
815 if (needs.has('usage')) await setUsage($, e)
816 if (needs.has('now')) await setNow($)
817 return next(e)
818 })
819
820 // Names a server's row as /mcp does. Observe only.
821 on('tool.describe', ($, e, next) => {
822 const [segment, name] = serverName(e.tool, e.provider.plugin) ?? []
823 if (segment && name) mcpNames[segment] = name
824 return next(e)
825 })
826
827 // After /cd the disabled list is another project's: re-derive its key, and
828 // drop the off rows the old list made. Observe only.
829 on('classic.CwdChanged', ($, e, next) => {
830 claudeJson = undefined
831 cwdMoved = true
832 // Another directory's servers of the same names may come from elsewhere.
833 mcpObserved = {}
834 if (needs.has('mcp')) {
835 void $.state.set(MCP_OBSERVED, {}).catch(() => undefined)
836 void readMcpFiles($, e.new_cwd).catch(() => undefined)
837 }
838 return next(e)
839 })
840
841 // A run of a server's tool names its source; it corrects the guess. Observe only.
842 on('classic.PostToolUse', async ($, e, next) => {
843 if (needs.has('mcp') && e.mcp_server) {
844 const { name, source } = e.mcp_server
845 const server = segmentOf(name)
846 mcpObserved = { ...mcpObserved, [server]: source }
847 const { value = {} } = await $.state.get(MCP_OBSERVED)
848 if (value[server] !== source) await $.state.set(MCP_OBSERVED, { ...value, [server]: source })
849 }
850 return next(e)
851 })
852
853 on('tool.call', async ($, e, next) => {
854 try {
855 return await next(e)
856 } finally {
857 if (needs.has('git') && GIT_TOOLS.has(e.tool)) void refreshGit($)
858 }
859 })
860
861 // A task tool's call reloads the list. Observe only.
862 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
863 const done = await next(e)
864 if (needs.has('todo') && done.result && !done.isError) {
865 await keepActiveForm($, done.result.task.id, e.activeForm)
866 void loadTodo($)
867 }
868 return done
869 })
870
871 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
872 const done = await next(e)
873 if (needs.has('todo') && done.result && !done.isError) {
874 await keepActiveForm($, e.taskId, e.activeForm)
875 void loadTodo($)
876 }
877 return done
878 })
879
880 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
881 const done = await next(e)
882 if (!needs.has('todo') || !done.result || done.isError) return done
883 // Before the first load or after `/clear`, the tick's load replays the transcript.
884 const { value } = await $.state.get(TODO)
885 if (value?.tools === 'todowrite') {
886 await setTodo($, { tools: 'todowrite', items: todoWriteItems(done.result.newTodos) })
887 }
888 return done
889 })
890
891 // MEMORY.md "Agent and shell events go to toasts, the live list goes to the
892 // sidebar", "Task ends come from notifications" and "A Workflow run is one
893 // task row". Every task hook observes only.
894 on('agent.spawn', async ($, e, next) => {
895 const done = await next(e)
896 if (tracking && done.agentId) {
897 const id = done.agentId
898 await updateTasks($, (tasks, now) => {
899 const task: Task = {
900 id,
901 kind: 'agent',
902 type: e.subagentType,
903 label: e.description,
904 ...(e.parentAgentId && { parent: e.parentAgentId }),
905 status: 'running',
906 listed: 'running',
907 startedAt: now,
908 }
909 tasks[id] = task
910 return [startedToast(task)]
911 })
912 }
913 return done
914 })
915
916 // An agent dead on an API error: StopFailure comes before the list says `failed`.
917 on('classic.StopFailure', async ($, e, next) => {
918 const id = e.agent_id
919 if (tracking && id) {
920 await updateTasks($, (tasks) => {
921 const task = tasks[id]
922 if (task?.kind === 'agent') tasks[id] = { ...task, reason: failureReason(e.error) }
923 })
924 }
925 return next(e)
926 })
927
928 // A background shell: from the main loop, a held agent, or an agent of a
929 // running Workflow run (nested quietly under the run whose transcripts hold it).
930 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
931 const done = await next(e)
932 const id = done.isError ? undefined : done.result?.backgroundTaskId
933 if (!tracking || !id) return done
934 const agentId = e.agentId
935 await updateTasks($, async (tasks, now) => {
936 const label = firstLine(e.command)
937 const shell: Task = { id, kind: 'shell', type: 'shell', label, status: 'running', startedAt: now }
938 if (!agentId || tasks[agentId]) {
939 tasks[id] = { ...shell, ...(agentId && { parent: agentId }) }
940 return [startedToast(shell)]
941 }
942 for (const run of Object.values(tasks)) {
943 if (run.kind !== 'workflow' || run.status !== 'running' || !run.transcriptDir) continue
944 const found = await $.fs.stat(`${run.transcriptDir}/agent-${agentId}.jsonl`).catch(() => undefined)
945 if (found) {
946 tasks[id] = { ...shell, parent: run.id }
947 return
948 }
949 }
950 })
951 return done
952 })
953
954 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
955 const done = await next(e)
956 const result = done.isError ? undefined : done.result
957 if (tracking && result?.taskId) {
958 const id = result.taskId
959 await updateTasks($, (tasks, now) => {
960 const task: Task = {
961 id,
962 kind: 'workflow',
963 type: 'workflow',
964 label: result.workflowName ?? 'workflow',
965 ...(result.transcriptDir && { transcriptDir: result.transcriptDir }),
966 status: 'running',
967 startedAt: now,
968 }
969 tasks[id] = task
970 return [startedToast(task)]
971 })
972 }
973 return done
974 })
975
976 // The model's TaskStop sends no notification: its result is the kill.
977 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
978 const done = await next(e)
979 const id = done.isError ? undefined : done.result?.task_id
980 if (tracking && id) {
981 await updateTasks($, (tasks, now) => {
982 const task = tasks[id]
983 return task?.status === 'running' ? endTask(tasks, task, 'killed', now, true) : undefined
984 })
985 }
986 return done
987 })
988
989 on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
990 if (tracking) await onNotification($, e.text)
991 return next(e)
992 })
993
994 // A /tasks kill's notification comes only as this row, with the next prompt.
995 on('session.append', { door: 'delivery', message: { name: 'queued_command' } }, async ($, e, next) => {
996 if (tracking) await onNotification($, textOf(e.message.content))
997 return next(e)
998 })
999
1000 // A subagent's shell reports its end into that subagent's loop, with no
1001 // `prompt.submit`. The main loop's door-`prompt` rows (the synthetic
1002 // `stopped` after /resume) carry no `agentId` and stay unread.
1003 on('session.append', { door: 'prompt', origin: { kind: 'task-notification' } }, async ($, e, next) => {
1004 if (tracking && e.agentId) await onNotification($, textOf(e.message.content))
1005 return next(e)
1006 })
1007
1008 // Every invocation, whoever made it: typed, the Skill tool (a subagent's
1009 // too), a fork, a preload, a markdown command. Observe only.
1010 on('skill.prompt', ($, e, next) => {
1011 if (needs.has('skills')) void addSkills($, e.skill).catch(() => undefined)
1012 return next(e)
1013 })
1014
1015 // /clear and /resume empty `$.state` while running work goes on: the tick
1016 // merges it into the new session.
1017 on('session.end', async ($, e, next) => {
1018 if (e.reason === 'clear' || e.reason === 'resume') effortCarry = (await $.state.get(EFFORT)).value
1019 if (tracking && (e.reason === 'clear' || e.reason === 'resume')) {
1020 const { value = {} } = await $.state.get(TASKS)
1021 carry = Object.fromEntries(Object.entries(value).filter(([, task]) => task.status === 'running'))
1022 }
1023 return next(e)
1024 })
1025
1026 // MEMORY.md "The Sidebar shows only when docked": watch an always-drawn site
1027 // and open the pane once the terminal can dock it. The same hook adds the
1028 // `model · effort` label (MEMORY.md "The line under the prompt").
1029 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1030 const viewport = e.viewport
1031 if (viewport?.isFullscreen === true && viewport.columns >= DOCK_COLUMNS && !checking) {
1032 checking = true
1033 $.clock.after(0, async () => {
1034 try {
1035 // Open or waiting: the pane is placed, or the engine places it as the terminal widens.
1036 if (!(await $.ui.panes()).some((pane) => pane.id === SIDEBAR)) {
1037 await openSidebar($, widthFor(viewport.columns))
1038 }
1039 } finally {
1040 checking = false
1041 }
1042 })
1043 }
1044 const [{ value: model }, { value: effort }] = await Promise.all([$.state.get(MODEL), $.state.get(EFFORT)])
1045 if (model === undefined) return next(e)
1046 const label = promptLabel(model, effort)
1047 // The line spans the terminal, while `viewport.columns` excludes the dock.
1048 if (!viewport || fitsPromptLine(hint, label, viewport.columns + dockColumns)) {
1049 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
1050 }
1051 return promptLabelRow($.ui.resolve(e), label)
1052 })
1053
1054 // The hint's width decides where the label goes. Observe only.
1055 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
1056 if (e.props.hint !== hint) {
1057 hint = e.props.hint
1058 redrawLater($)
1059 }
1060 return next(e)
1061 })
1062
1063 // An open from the person's prompt counts as asked: it docks from 110 columns.
1064 on('prompt.submit', ($, e, next) => {
1065 if (waiting && e.origin.kind === 'composer') void openSidebar($, requested ?? widthFor(DOCK_COLUMNS))
1066 return next(e)
1067 })
1068
1069 on('ui.render', { component: 'Pane', requestId: SIDEBAR }, async ($, e) => {
1070 const ui = $.ui.resolve(e)
1071 if (!('Client' in ui)) return <ui.Box />
1072 const dock = e.props.placement === 'inline' ? 0 : e.props.bodyColumns + 1
1073 if (dock !== dockColumns) {
1074 dockColumns = dock
1075 redrawLater($)
1076 }
1077 if (e.props.placement === 'inline') {
1078 $.clock.after(0, () => void $.ui.close({ id: SIDEBAR }))
1079 return <ui.Box />
1080 }
1081 waiting = false
1082 if (e.viewport) {
1083 // The viewport here is the transcript's width.
1084 const columns = widthFor(e.viewport.columns + e.props.bodyColumns + 1)
1085 if (columns !== requested) {
1086 requested = columns
1087 $.clock.after(0, () => void openSidebar($, columns))
1088 }
1089 }
1090 const [folded, expanded, scroll, git, usage, month, now, mcp, todo, skills, versions, tasks, glass] = await Promise.all([
1091 $.state.get(FOLDED),
1092 $.state.get(EXPANDED),
1093 $.state.get(SCROLL),
1094 $.state.get(GIT),
1095 $.state.get(USAGE),
1096 $.state.get(MONTH),
1097 $.state.get(NOW),
1098 $.state.get(MCP),
1099 $.state.get(TODO),
1100 $.state.get(SKILLS),
1101 $.state.get(VERSIONS),
1102 $.state.get(TASKS),
1103 $.state.get(GLASS),
1104 ])
1105 const drawn = sidebar({
1106 ui,
1107 bodyRows: e.props.scroll.bodyRows,
1108 bodyColumns: e.props.bodyColumns,
1109 plugins: enabled,
1110 data: {
1111 git: git.value,
1112 usage: usage.value,
1113 monthCost: month.value?.usd,
1114 now: now.value,
1115 mcp: mcp.value,
1116 todo: todo.value,
1117 skills: skills.value,
1118 versions: versions.value,
1119 tasks: tasks.value,
1120 },
1121 config,
1122 ...(await (look ??= themeLook($, config.theme))),
1123 // Under `inherit`, the active custom `/theme`'s glass, when it has no dock color.
1124 ...(inherit && glass.value && { background: glass.value }),
1125 folded: folded.value ?? {},
1126 expanded: expanded.value ?? {},
1127 scroll: scroll.value ?? 0,
1128 focused: e.props.isFocused,
1129 onControl: (control) => void toggle($, config, control),
1130 })
1131 maxScroll = drawn.maxScroll
1132 return drawn.tree
1133 })
1134
1135 // A fold arrow's or a list toggle's click (`press.tsx`), or a title row's
1136 // (`foldrow.tsx`), by its key.
1137 const controls = new Map<string, Control>(
1138 enabled
1139 .filter((plugin) => plugin.slot === 'section')
1140 .flatMap((plugin) => {
1141 const fold: Control = { kind: 'fold', id: plugin.id }
1142 const more: Control = { kind: 'more', id: plugin.id }
1143 return [[controlKey(fold), fold], [controlKey(more), more], [foldRowKey(plugin.id), fold]] as const
1144 }),
1145 )
1146 on('ui.message', { component: 'Pane', requestId: SIDEBAR }, async ($, e) => {
1147 const control = controls.get(e.element)
1148 if (control) await toggle($, config, control)
1149 return {}
1150 })
1151
1152 // The person can't close the Sidebar; a plugin close passes.
1153 on('ui.close', { id: SIDEBAR }, ($, e, next) =>
1154 e.origin.kind === 'person' ? { deny: 'The ctui Sidebar stays open while it is docked' } : next(e),
1155 )
1156
1157 // The mod scrolls the sections itself; the engine's window never moves.
1158 on('ui.scroll', { component: 'Pane', requestId: SIDEBAR }, async ($, e) => {
1159 const { value = 0 } = await $.state.get(SCROLL)
1160 const from = Math.min(value, maxScroll)
1161 const offset = Math.min(Math.max(from + e.by, 0), maxScroll)
1162 if (offset !== value) await $.state.set(SCROLL, offset)
1163 return {}
1164 })
1165 // Transcript rewrites (docs/spec/v0.1.md slice 8). `ToolResult` stays the engine's.
1166 // While the Sidebar is docked, the rows ctui draws end `GAP` columns before its rule.
1167 const dockGap = () => (dockColumns ? GAP : 0)
1168 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) =>
1169 assistantMessage($.ui.resolve(e), await next({ ...e, props: { ...e.props, isFirstOfReply: false } }), dockGap()),
1170 )
1171
1172 on('ui.render', { component: 'UserMessage' }, ($, e, next) =>
1173 isOwnPrompt(e.props.origin) ? userMessage($.ui.resolve(e), e.props.text, dockGap()) : next(e),
1174 )
1175
1176 // Each call of a group draws as its own `ToolUse` row.
1177 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => next({ ...e, props: { ...e.props, isExpanded: true } }))
1178
1179 // The cwd is read here: on `--continue` the transcript draws before `session.start`.
1180 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1181 if (!hasToolRow(e.props.tool)) return next(e)
1182 const line = toolLine(e.props, { cwd: await $.session.cwd(), home: await $.env.get('HOME') })
1183 return line ? toolRow($.ui.resolve(e), line, dockGap()) : next(e)
1184 })
1185
1186 // MEMORY.md "The line under the prompt": the latest effort source wins.
1187 // A main-loop step's effort is the one sent; absent, the model takes none.
1188 on('turn.step', async function* ($, e, next) {
1189 if (e.agentId === undefined) {
1190 stepModel = e.model
1191 // A numeric budget has no level to show: the label keeps the last.
1192 if (typeof e.effort !== 'number') await setEffort($, e.effort ?? null)
1193 }
1194 return yield* next(e)
1195 })
1196
1197 on('command.run', { command: 'effort' }, async ($, e, next) => {
1198 const level = e.args.trim()
1199 if (EFFORT_LEVELS.has(level)) await setEffort($, level)
1200 return next(e)plugins/index.ts 20 lines1import type { SidebarPlugin } from './plugin'
2
3import agents from './agents'
4import context from './context'
5import git from './git'
6import limits from './limits'
7import mcp from './mcp'
8import skills from './skills'
9import todo from './todo'
10import versions from './versions'
11
12// Static registry in default Sidebar order: validation rejects dynamic
13// import(). Keep folders and these imports equal, and an `<id>_enable` key in
14// plugin.json for each section; the git header and versions footer are always
15// on (scripts/check.ts checks).
16export const plugins: readonly SidebarPlugin[] = [git, context, limits, todo, skills, mcp, agents, versions]
17
18// The sections between the header and footer, the ones a person can turn off and reorder.
19export const sections = plugins.filter((plugin) => plugin.slot === 'section')
20plugins/colors.ts 21 lines1// The Sidebar's 7 color roles and the Claude Code theme key each draws with
2// (MEMORY.md "Sidebar colors inherit the Claude Code theme").
3const KEYS = {
4 main: 'text',
5 muted: 'inactive',
6 faint: 'subtle',
7 success: 'success',
8 warning: 'warning',
9 error: 'error',
10 accent: 'suggestion',
11} as const
12
13export type Role = keyof typeof KEYS
14export type Colors = Record<Role, string>
15
16// Each role's color: a selected Theme's override for its key, else the key
17// itself, which Claude Code resolves from the person's theme at draw time.
18// Under `inherit` there are no overrides.
19export const colors = (overrides: Readonly<Record<string, string>> = {}): Colors =>
20 Object.fromEntries(Object.entries(KEYS).map(([role, key]) => [role, overrides[key] ?? key])) as Colors
21hooks/config.ts 56 lines1import type { PluginOptions } from 'claude-code'
2
3import { plugins, sections } from '../plugins'
4import type { SidebarId } from '../plugins/plugin'
5
6// One Sidebar plugin's settings: `<id>_enable`, for a foldable one
7// `<id>_folded`, for `todo` `todo_tools`, and for `limits` `limits_cost` and
8// `limits_cost_monthly`.
9export type SectionConfig = { enable: boolean; folded?: boolean; tools?: boolean; cost?: CostChoice; monthly?: number }
10
11// `auto` shows the Limits cost row off a plan; `on` and `off` override that.
12export type CostChoice = 'auto' | 'on' | 'off'
13
14export type Config = Record<SidebarId, SectionConfig> & {
15 agents: SectionConfig & { toasts: boolean }
16 limits: SectionConfig & { cost: CostChoice; monthly: number }
17 todo: SectionConfig & { tools: boolean }
18 theme: string
19 order: SidebarId[] // the section ids, every one once
20}
21
22// The `order` setting's section ids: unknown and repeated ids dropped,
23// missing ones appended in registry order.
24function sectionOrder(order: unknown): SidebarId[] {
25 const ids = sections.map((plugin) => plugin.id)
26 const listed = typeof order === 'string' ? order.split(',').map((id) => id.trim()) : []
27 return [...new Set([...listed, ...ids])].filter((id): id is SidebarId => ids.some((section) => section === id))
28}
29
30// Regroups the flat `userConfig` options (MEMORY.md "Plugin settings are flat
31// `userConfig` keys").
32export function readConfig(options: PluginOptions): Config {
33 const sections = {} as Record<SidebarId, SectionConfig>
34 for (const { id, slot } of plugins) {
35 const folded = options[`${id}_folded`]
36 sections[id] = {
37 // The git header and versions footer are always on.
38 enable: slot !== 'section' || options[`${id}_enable`] !== false,
39 ...(typeof folded === 'boolean' && { folded }),
40 }
41 }
42 return {
43 ...sections,
44 agents: { ...sections.agents, toasts: options.agents_toasts !== false },
45 limits: {
46 ...sections.limits,
47 cost: options.limits_cost === 'on' || options.limits_cost === 'off' ? options.limits_cost : 'auto',
48 // A non-number limit, possible only by hand-editing settings.json, reads as the default.
49 monthly: typeof options.limits_cost_monthly === 'number' ? options.limits_cost_monthly : 100,
50 },
51 todo: { ...sections.todo, tools: options.todo_tools !== false },
52 theme: typeof options.theme === 'string' ? options.theme : 'inherit',
53 order: sectionOrder(options.order),
54 }
55}
56hooks/format.ts 95 lines1// Pure formatters for the Sidebar's rows (docs/spec/v0.1.md).
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7
8export type Level = 'success' | 'warning' | 'error'
9
10// The role of a bar's fill.
11export const level = (percent: number): Level => (percent < 50 ? 'success' : percent < 85 ? 'warning' : 'error')
12
13// `18,402`
14export const formatTokens = (tokens: number) => tokens.toLocaleString('en-US')
15
16// `200k`, `1M`
17export const formatWindow = (tokens: number) =>
18 tokens >= 1_000_000 ? `${+(tokens / 1_000_000).toFixed(1)}M` : `${Math.round(tokens / 1000)}k`
19
20// `0.21$`: the sign after the digits, as everywhere in the Limits section.
21export const formatUsd = (usd: number) => `${usd.toFixed(2)}$`
22
23// `62$`, in whole dollars.
24export const formatDollars = (usd: number) => `${Math.round(usd)}$`
25
26// `resets in 2h 17m`; past a day, `resets in 3d 4h, Mon 09:00` in local 24-hour time.
27export function formatReset(resetsAt: string, now: number): string {
28 const at = new Date(resetsAt)
29 const left = Math.max(at.getTime() - now, 0)
30 if (left <= DAY) {
31 const hours = Math.floor(left / HOUR)
32 const minutes = Math.floor((left % HOUR) / MINUTE)
33 return `resets in ${hours ? `${hours}h ` : ''}${minutes}m`
34 }
35 const pad = (n: number) => String(n).padStart(2, '0')
36 const clock = `${WEEKDAYS[at.getDay()]} ${pad(at.getHours())}:${pad(at.getMinutes())}`
37 return `resets in ${Math.floor(left / DAY)}d ${Math.floor((left % DAY) / HOUR)}h, ${clock}`
38}
39
40// A bar of `width` cells for `percent`: filled `━`, empty `─`. Past 100 it is full.
41export function bar(percent: number, width: number): { filled: string; empty: string } {
42 const cells = Math.max(width, 0)
43 const filled = Math.round((cells * Math.min(Math.max(percent, 0), 100)) / 100)
44 return { filled: '━'.repeat(filled), empty: '─'.repeat(cells - filled) }
45}
46
47// A percent right of a bar, at least 5 columns and always one space from it:
48// ` 9%`, ` 100%`, ` 1133%`. The bar takes what is left of the row.
49export const formatPercent = (percent: number) => ` ${Math.round(percent)}%`.padStart(5)
50
51// `21s`, `1m 15s`, `15m 03s`, `1h 02m`.
52export function formatElapsed(ms: number): string {
53 const seconds = Math.floor(Math.max(ms, 0) / 1000)
54 const pad = (n: number) => String(n).padStart(2, '0')
55 if (seconds < 60) return `${seconds}s`
56 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`
57 return `${Math.floor(seconds / 3600)}h ${pad(Math.floor((seconds % 3600) / 60))}m`
58}
59
60// MEMORY.md "The turn footer": the mode as Claude Code's pill names it.
61const MODES: Record<string, string> = {
62 default: 'manual mode',
63 acceptEdits: 'accept edits',
64 plan: 'plan mode',
65 bypassPermissions: 'bypass permissions',
66 auto: 'auto mode',
67 dontAsk: "don't ask",
68}
69
70export const modeLabel = (mode: string) => MODES[mode] ?? mode
71
72// MEMORY.md "The line under the prompt": `claude-opus-5-5 · high effort`, or
73// the model alone when it takes no effort (`null`) or none is known.
74export const promptLabel = (model: string, effort: string | null | undefined) =>
75 effort ? `${model} · ${effort} effort` : model
76
77// The widest pill, `⏵⏵ bypass permissions on`.
78const PILL = 24
79
80// The label fits the line beside the pill and the hint, from `columns` wide.
81export const fitsPromptLine = (hint: string, label: string, columns: number) =>
82 2 + PILL + 1 + hint.length + 2 + label.length <= columns
83
84type Settings = Readonly<Record<string, unknown>>
85
86// `modelSettings[<model>].effortLevel`: the level `/effort` saved for the model.
87export const modelEffort = (settings: Settings, model: string): unknown =>
88 (settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined)?.[model]?.effortLevel
89
90// The effort a model starts a session with: its own saved level, else the global one.
91export function savedEffort(settings: Settings, model: string): string | undefined {
92 const level = modelEffort(settings, model) ?? settings.effortLevel
93 return typeof level === 'string' ? level : undefined
94}
95hooks/git.ts 40 lines1import type { GitRepo } from '../types'
2
3// Parses `git status --porcelain=v2 --branch`, `git stash list` and
4// `git diff --numstat HEAD` into the git header's repo rows.
5export function parseGit(status: string, stash: string, numstat: string): GitRepo {
6 const repo: GitRepo = { branch: '', staged: 0, modified: 0, untracked: 0, stashes: 0, added: 0, removed: 0 }
7 let oid = ''
8 for (const line of status.split('\n')) {
9 const [kind, field, value = ''] = line.split(' ')
10 if (kind === '#') {
11 if (field === 'branch.oid') oid = value
12 else if (field === 'branch.head') repo.branch = value
13 else if (field === 'branch.ab') {
14 repo.ahead = Number(value.slice(1))
15 repo.behind = Number(line.split(' ')[3]?.slice(1))
16 }
17 } else if (kind === '1' || kind === '2') {
18 if (field?.[0] !== '.') repo.staged++
19 if (field?.[1] !== '.') repo.modified++
20 } else if (kind === 'u') repo.modified++
21 else if (kind === '?') repo.untracked++
22 }
23 if (repo.branch === '(detached)') repo.branch = oid.slice(0, 7)
24 repo.stashes = stash.split('\n').filter(Boolean).length
25 for (const line of numstat.split('\n')) {
26 const [added, removed] = line.split('\t')
27 // Binary files read `-`: they count 0.
28 repo.added += Number(added) || 0
29 repo.removed += Number(removed) || 0
30 }
31 return repo
32}
33
34// The cwd with the home directory shown as `~`.
35export function tildify(cwd: string, home: string | undefined): string {
36 if (!home) return cwd
37 if (cwd === home) return '~'
38 return cwd.startsWith(`${home}/`) ? `~${cwd.slice(home.length)}` : cwd
39}
40hooks/mcp.ts 93 lines1import type { McpRow } from '../types'
2
3// What the `mcp` Sidebar plugin reads each tick (MEMORY.md "The `mcp` Sidebar
4// plugin reads live tools and MCP config names; it never probes").
5export type McpInput = {
6 rows: readonly McpRow[] // the previous tick's
7 tools: readonly { name: string; mcp: boolean }[] // `$.tool.list()`
8 disabled: readonly string[] // `disabledMcpServers`, as /mcp names them
9 names: Readonly<Record<string, string>> // segment → /mcp name, from `tool.describe`
10 now: number
11 timeoutMs: number // MCP_TIMEOUT
12 sources: McpSources
13}
14
15// The `/mcp` names each config source holds (names only: an entry can hold
16// secrets), and the source a tool run reported for a server, by segment.
17export type McpSources = Record<(typeof SCOPES)[number], readonly string[]> & {
18 observed: Readonly<Record<string, string>> // `classic.PostToolUse` `mcp_server.source`
19}
20
21// Claude Code's own lookup order for a name's scope.
22const SCOPES = ['enterprise', 'managed', 'local', 'project', 'user'] as const
23
24export const NO_SOURCES: McpSources = { enterprise: [], managed: [], local: [], project: [], user: [], observed: {} }
25
26// The tools a server offers before its sign-in.
27const AUTH_TOOLS = new Set(['authenticate', 'complete_authentication'])
28
29// A /mcp name as it shows in tool names: `claude.ai Claude Docs` → `claude_ai_Claude_Docs`.
30export const segmentOf = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, '_')
31
32// The server a `tool.describe` provider names, as its tool segment and /mcp
33// name: `mcp:<name>` for a configured server; `<plugin>@<marketplace>` for a
34// plugin's, whose /mcp name is `plugin:<plugin>:<server>`.
35export function serverName(tool: string, provider: string): [segment: string, name: string] | undefined {
36 const configured = /^mcp:(.+)$/.exec(provider)?.[1]
37 if (configured) return [segmentOf(configured), configured]
38 const [mcp, segment = ''] = tool.split('__')
39 const plugin = /^([^@]+)@/.exec(provider)?.[1]
40 const prefix = `plugin_${segmentOf(plugin ?? '')}_`
41 if (mcp === 'mcp' && plugin && segment.startsWith(prefix)) {
42 return [segment, `plugin:${plugin}:${segment.slice(prefix.length)}`]
43 }
44}
45
46// Where a server comes from: what a tool run reported, else `claude.ai` or the
47// plugin from its name, else the first config source holding it, else
48// `dynamic` (`--mcp-config`, which the mod can't read).
49function sourceOf(server: string, name: string | undefined, sources: McpSources) {
50 const plugin = /^plugin:([^:]+):/.exec(name ?? '')?.[1] ?? /^plugin_([^_]+)_/.exec(server)?.[1]
51 const observed = sources.observed[server]
52 if (observed) return observed === 'claudeai' ? 'claude.ai' : observed === 'plugin' && plugin ? plugin : observed
53 if (name?.startsWith('claude.ai ') || server.startsWith('claude_ai_')) return 'claude.ai'
54 if (plugin) return plugin
55 return SCOPES.find((scope) => sources[scope].some((held) => segmentOf(held) === server)) ?? 'dynamic'
56}
57
58const labelOf = (name: string) => name.replace(/^claude\.ai /, '').replace(/^plugin:[^:]+:/, '')
59const labelOfSegment = (segment: string) => segment.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '')
60
61// The rows A–Z by label, off rows last: servers seen with tools this session
62// plus disabled ones. A listed server with no tools connects for `timeoutMs` from
63// the tick it lost them or left the disabled list, then reads down.
64export function mcpRows({ rows, tools, disabled, names, now, timeoutMs, sources }: McpInput): McpRow[] {
65 const offered = new Map<string, string[]>()
66 for (const tool of tools) {
67 const [, server, name = ''] = tool.mcp ? tool.name.split('__') : []
68 if (server) offered.set(server, [...(offered.get(server) ?? []), name])
69 }
70 const off = new Map(disabled.map((name) => [segmentOf(name), name]))
71 const servers = [...new Set([...rows.map((row) => row.server), ...offered.keys(), ...off.keys()])]
72 const all: McpRow[] = servers.map((server) => {
73 const before = rows.find((row) => row.server === server)
74 const name = off.get(server) ?? names[server]
75 const label = name ? labelOf(name) : (before?.label ?? labelOfSegment(server))
76 const row = { server, label, source: sourceOf(server, name, sources) }
77 const toolNames = offered.get(server)
78 if (off.has(server)) return { ...row, state: 'off' }
79 if (toolNames?.length) {
80 return toolNames.every((tool) => AUTH_TOOLS.has(tool))
81 ? { ...row, state: 'auth' }
82 : { ...row, state: 'ok', tools: toolNames.length }
83 }
84 if (before?.state === 'down') return { ...row, state: 'down' }
85 const since = before?.state === 'connecting' ? (before.since ?? now) : now
86 return now - since >= timeoutMs ? { ...row, state: 'down' } : { ...row, state: 'connecting', since }
87 })
88 return all.sort(
89 (a, b) =>
90 +(a.state === 'off') - +(b.state === 'off') || a.label.localeCompare(b.label) || a.server.localeCompare(b.server),
91 )
92}
93hooks/month.ts 61 lines1import type { Usage } from '../types'
2import type { CostChoice } from './config'
3
4// The Limits cost row's month total: every session's last `cost-state` in its
5// transcript, counted in the local month the transcript was last written.
6
7// A session's total as last read from its transcript, kept in `$.store`.
8export type Cached = { usd: number; mtimeMs: number; size: number }
9
10// A main transcript, `<session id>.jsonl`, as `$.fs.list` gives it.
11export type Transcript = { session: string; path: string; mtimeMs: number; size: number }
12
13const pad = (n: number) => String(n).padStart(2, '0')
14
15// The local month, `2026-10`.
16export function monthOf(ms: number) {
17 const date = new Date(ms)
18 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
19}
20
21// Midnight local time on the 1st.
22export function monthStart(ms: number) {
23 const date = new Date(ms)
24 return new Date(date.getFullYear(), date.getMonth(), 1).getTime()
25}
26
27// One `$.store` key per session, so concurrent sessions write apart; the
28// month in it drops earlier months by name.
29export const COST_PREFIX = 'cost:'
30export const costKey = (month: string, session: string) => `${COST_PREFIX}${month}:${session}`
31
32// `totalCostUSD` from the last `cost-state` line per `sessionId` in grep's output.
33export function lastCosts(stdout: string) {
34 const costs: Record<string, number> = {}
35 for (const line of stdout.split('\n')) {
36 try {
37 const { sessionId, totalCostUSD } = JSON.parse(line)
38 if (typeof sessionId === 'string' && typeof totalCostUSD === 'number') costs[sessionId] = totalCostUSD
39 } catch {
40 // Empty, or caught mid-write.
41 }
42 }
43 return costs
44}
45
46// The transcripts to grep: new ones, and ones whose size or mtime moved.
47export const staleTranscripts = (transcripts: readonly Transcript[], cached: Readonly<Record<string, Cached>>) =>
48 transcripts.filter(({ session, mtimeMs, size }) => cached[session]?.mtimeMs !== mtimeMs || cached[session]?.size !== size)
49
50// The ended sessions' total: the current session counts by its live cost instead.
51export const monthTotal = (cached: Readonly<Record<string, Cached>>, current: string) =>
52 Object.entries(cached).reduce((sum, [session, { usd }]) => (session === current ? sum : sum + usd), 0)
53
54// A Claude plan reports a 5-hour or a weekly window; a gateway's `spend_limit`
55// alone isn't one.
56const onPlan = (usage: Usage) => usage.rateLimits.some(({ kind }) => kind === 'five_hour' || kind === 'seven_day')
57
58// Whether the cost row shows: `auto` hides it on a plan, and it needs a cost.
59export const showsCost = (usage: Usage | undefined, choice: CostChoice = 'auto') =>
60 Boolean(usage?.cost && (choice === 'on' || (choice === 'auto' && !onPlan(usage))))
61hooks/glass.ts 35 lines1// The Sidebar's glass (#80): a theme's background mixed 6% toward its
2// foreground, one flat color (MEMORY.md "Sidebar colors inherit the Claude
3// Code theme").
4
5const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
6const HEX = /^#[0-9a-f]{6}$/i
7
8// Omarchy's `mix a b N%`: an sRGB lerp, each channel rounded half up.
9export function mix(a: string, b: string, w: number) {
10 const to = channels(b)
11 const mixed = channels(a).map((x, i) => Math.floor(x * (1 - w) + to[i]! * w + 0.5))
12 return `#${mixed.map((x) => x.toString(16).padStart(2, '0')).join('')}`
13}
14
15// The glass ctui paints under `inherit` for a custom `/theme`'s overrides.
16// Omarchy's template writes the theme's background to `inverseText` and its
17// foreground to `text`. A theme that sets `composerSidebarBackground` already
18// colors the whole dock, so it gets none.
19export function inheritGlass(overrides: Readonly<Record<string, unknown>>): string | undefined {
20 const { composerSidebarBackground, inverseText, text } = overrides
21 if (composerSidebarBackground !== undefined) return undefined
22 if (typeof inverseText !== 'string' || typeof text !== 'string' || !HEX.test(inverseText) || !HEX.test(text)) {
23 return undefined
24 }
25 return mix(inverseText, text, 0.06)
26}
27
28// The file of a user custom theme, `custom:<name>`, in Claude Code's config
29// dir. Built-in themes have none; a plugin's theme (`custom:<plugin>:<slug>`)
30// isn't read.
31export function themeFile(theme: unknown, configDir: string): string | undefined {
32 const name = typeof theme === 'string' ? /^custom:([^:]+)$/.exec(theme)?.[1] : undefined
33 return name === undefined ? undefined : `${configDir}/themes/${name}.json`
34}
35hooks/skills.ts 56 lines1import type { CommandInfo, ContextSkill, SessionMessage } from 'claude-code'
2
3import type { SkillRow } from '../types'
4
5// The `skills` Sidebar plugin's data (MEMORY.md "The `skills` Sidebar plugin
6// lists every `skill.prompt`"): pure, from what register.tsx reads.
7
8// The skill listing's engine words, as the row shows them.
9const LABELS: Record<string, string> = { userSettings: 'user', projectSettings: 'project', syncedSkills: 'synced' }
10
11// A skill's name as `/skills` lists it, less its `plugin:` prefix.
12export const skillName = (name: string) => name.replace(/^[^:]+:/, '')
13
14const prefixOf = (name: string) => /^([^:]+):/.exec(name)?.[1]
15
16// Where a skill comes from: the skill listing, else (a markdown command) the
17// command list, else its `plugin:` prefix, else `user`.
18// `listed` is `$.session.usage` `skillFrontmatter`, `commands` `$.command.list()`.
19export function sourceOf(name: string, listed: readonly ContextSkill[], commands: readonly CommandInfo[]) {
20 const skill = listed.find((entry) => entry.name === name)
21 if (skill) return skill.source === 'plugin' ? (skill.pluginName ?? prefixOf(name) ?? 'plugin') : (LABELS[skill.source] ?? skill.source)
22 const command = commands.find((entry) => entry.name === name)
23 if (command?.source === 'plugin') return command.plugin ?? prefixOf(name) ?? 'plugin'
24 if (command?.source === 'builtin') return 'built-in'
25 return prefixOf(name) ?? 'user'
26}
27
28// The list with `row` once: a skill used again changes nothing.
29export function addSkill(rows: readonly SkillRow[], row: SkillRow): readonly SkillRow[] {
30 return rows.some((held) => held.name === row.name && held.source === row.source) ? rows : [...rows, row]
31}
32
33// A–Z by the shown name, then by source.
34export const sortedSkills = (rows: readonly SkillRow[]) =>
35 [...rows].sort(
36 (a, b) =>
37 skillName(a.name).localeCompare(skillName(b.name), undefined, { sensitivity: 'base' }) ||
38 a.source.localeCompare(b.source),
39 )
40
41// The known skills a resumed transcript's main rows show, first use first:
42// each Skill tool use, and each typed `<command-name>` (built-ins like
43// `/context` leave the same row; a failed Skill call names an unknown skill).
44export function skillsFromMessages(messages: readonly SessionMessage[], known: ReadonlySet<string>): string[] {
45 const names: string[] = []
46 for (const message of messages) {
47 for (const use of message.toolUses) {
48 if (use.tool === 'Skill' && typeof use.input.skill === 'string' && known.has(use.input.skill)) names.push(use.input.skill)
49 }
50 for (const [, name = ''] of message.text.matchAll(/<command-name>\/?([^<]+)<\/command-name>/g)) {
51 if (known.has(name)) names.push(name)
52 }
53 }
54 return [...new Set(names)]
55}
56hooks/menu.tsx 326 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import type { SidebarId } from '../plugins/plugin'
4import type { Colors } from '../plugins/colors'
5import type { Menu, MenuLevel as Level } from '../types'
6import type { Config } from './config'
7
8// The `/ctui` menu (MEMORY.md "`/ctui` is one instant menu; it survives each settings reload"):
9// its model, kept in `$.state` because every write reloads the mod,
10// and its pane body. register.tsx handles the picks, the filter and Esc.
11
12export const MENU_START: Menu = { level: 'top', filter: '', picks: {} }
13
14// Where Esc goes from each level; none at the top, where it closes the menu.
15export const PARENT: Record<Level, Level | undefined> = { top: undefined, plugins: 'top', plugin: 'plugins', themes: 'top' }
16
17// The keys of the menu's elements: the `ui.input`, `ui.press` and `ui.focus`
18// matchers' `element`. A top row is `row-<level>`, a plugin's row `plugin-<id>`,
19// a Theme's row `theme-<slug>`.
20export const KEYS = {
21 filter: 'menu-filter',
22 monthly: 'menu-monthly',
23 toggle: 'menu-toggle',
24 up: 'menu-up',
25 down: 'menu-down',
26} as const
27// A row's key, and what a key names: a Plugins row's plugin, a top row's
28// screen, a Theme row's slug.
29const named = (prefix: string, key: string | undefined) => (key?.startsWith(prefix) ? key.slice(prefix.length) : undefined)
30export const rowKey = (id: string) => `plugin-${id}`
31export const rowId = (key: string | undefined) => named('plugin-', key)
32export type TopPick = 'plugins' | 'themes'
33export const topKey = (level: TopPick) => `row-${level}`
34export const topPick = (key: string) => named('row-', key) as TopPick | undefined
35export const themeKey = (slug: string) => `theme-${slug}`
36export const themeSlug = (key: string) => named('theme-', key)
37// The Plugins screen's hidden hotkeys: the ring never lands on them.
38export const HOTKEYS: readonly string[] = [KEYS.toggle, KEYS.up, KEYS.down]
39
40// `order` with `id` moved `by` places, held at either end.
41export function moveId(order: readonly string[], id: string, by: number): string[] {
42 const from = order.indexOf(id)
43 const to = Math.min(Math.max(from + by, 0), order.length - 1)
44 if (from < 0 || to === from) return [...order]
45 const moved = order.filter((held) => held !== id)
46 moved.splice(to, 0, id)
47 return moved
48}
49
50// A Theme: the `theme` setting's slug and its file's `name`, as `/theme` lists it.
51export type ThemeName = { slug: string; name: string }
52
53// `inherit` first, then the Themes by name A–Z; the filter matches a name
54// anywhere, any case.
55export function themeRows(themes: readonly ThemeName[], filter: string): ThemeName[] {
56 const named = [...themes].filter(({ slug }) => slug !== 'inherit').sort((a, b) => a.name.localeCompare(b.name))
57 const needle = filter.trim().toLowerCase()
58 return [{ slug: 'inherit', name: 'inherit' }, ...named].filter(({ name }) => name.toLowerCase().includes(needle))
59}
60
61export const NO_CONFIG = "Can't change ctui settings in claude -p. Use /config in an interactive session."
62
63// The toast for a `{ deny }` from `$.config.set`.
64export function deniedText(set: Setting, deny: string) {
65 const id = /^ctui\.(\w+)_enable$/.exec(set.key)?.[1]
66 if (id) return `Can't ${set.value ? 'enable' : 'disable'} ${id}: ${deny}`
67 if (set.key === 'ctui.theme') return `Can't switch the Sidebar theme to ${set.value}: ${deny}`
68 if (set.key === 'ctui.order') return `Can't save the Sidebar order: ${deny}`
69 if (set.key === 'ctui.limits_cost_monthly') return `Can't set the monthly cost: ${deny}`
70 const folded = /^ctui\.(\w+)_folded$/.exec(set.key)?.[1]
71 if (folded) return `Can't change Start folded for ${folded}: ${deny}`
72 return `Can't change ${LABELS[set.key] ?? set.key}: ${deny}`
73}
74
75// The plugins' own settings by key, as their rows name them.
76const LABELS: Record<string, string> = { 'ctui.todo_tools': 'Task tools', 'ctui.agents_toasts': 'Toasts', 'ctui.limits_cost': 'Cost' }
77
78// One `$.config.set` the menu makes.
79export type Setting = { key: string; value: string | boolean | number }
80
81// Who holds `/ctui`, from `$.command.register`'s refusal.
82export function takenBy(refusal: string) {
83 const plugin = /the plugin (\S+) registered it/.exec(refusal)?.[1]
84 if (plugin) return `the ${plugin} plugin`
85 if (/it is the user's/.test(refusal)) return 'your own /ctui'
86 return /refused: (.+)$/.exec(refusal)?.[1] ?? 'another command'
87}
88
89// The Monthly cost field's key: a new one after a refused entry, since a
90// field keeps what was typed while its `value` stays the same.
91export const monthlyKey = (entry = 0) => `${KEYS.monthly}-${entry}`
92export const isMonthlyKey = (key: string) => key.startsWith(`${KEYS.monthly}-`)
93
94// A plugin screen's setting row, `setting-<option>`: its label and value,
95// and what Enter writes.
96export type SettingRow = { option: string; label: string; value: string; next: string | boolean }
97export const settingKey = (option: string) => `setting-${option}`
98
99const STEP = { auto: 'on', on: 'off', off: 'auto' } as const
100
101// The settings a plugin's screen lists: `Start folded` for every section, and
102// each plugin's own. Limits' `Monthly cost` is an Input, apart.
103export function settingRows(id: SidebarId, config: Config): SettingRow[] {
104 const flag = (option: string, label: string, on: boolean) => ({ option, label, value: on ? 'on' : 'off', next: !on })
105 const rows: SettingRow[] = [flag(`${id}_folded`, 'Start folded', config[id].folded ?? false)]
106 if (id === 'todo') rows.push(flag('todo_tools', 'Task tools', config.todo.tools))
107 if (id === 'agents') rows.push(flag('agents_toasts', 'Toasts', config.agents.toasts))
108 if (id === 'limits') {
109 rows.push({ option: 'limits_cost', label: 'Cost', value: config.limits.cost, next: STEP[config.limits.cost] })
110 }
111 return rows
112}
113
114const LABEL = 14 // the Monthly cost field's label column
115
116// A section as the Plugins screen lists it.
117export type PluginRow = { id: string; title: string; enable: boolean; folded: boolean }
118
119export type MenuInput = {
120 ui: ElementTable<Exclude<RenderSurface, 'mobile'>> // the surfaces that draw an Input
121 menu: Menu
122 themes: readonly ThemeName[]
123 theme: string // the current `theme` setting
124 plugins: readonly PluginRow[] // in the order shown
125 settings: readonly SettingRow[] // the open plugin's
126 monthly?: number // the saved monthly cost, on Limits' screen
127 colors: Colors // the Sidebar's roles
128 background?: string // the Sidebar's
129 placement: 'dock' | 'inline'
130 bodyRows: number
131 bodyColumns: number
132}
133
134// The keys each screen's footer names.
135const FOOTER: Record<Level, readonly (readonly [string, string])[]> = {
136 top: [['↑↓', 'move'], ['enter', 'open'], ['esc', 'close']],
137 plugins: [['↑↓', 'move'], ['enter', 'settings'], ['x', 'show/hide'], ['k/j', 'move row'], ['esc', 'back']],
138 plugin: [['↑↓', 'move'], ['enter', 'change'], ['esc', 'back']],
139 themes: [['type', 'filter'], ['↑↓', 'move'], ['enter', 'pick'], ['esc', 'back']],
140}
141
142// The lines a footer wraps to in `width`: each pair, with its ` · `, moves whole.
143function footerLines(pairs: readonly (readonly [string, string])[], width: number) {
144 let lines = 1
145 let used = 0
146 pairs.forEach(([key, label], index) => {
147 const cells = `${key}: ${label}`.length + (index < pairs.length - 1 ? 3 : 0)
148 if (used > 0 && used + cells > width) {
149 lines += 1
150 used = 0
151 }
152 used += cells
153 })
154 return lines
155}
156
157// One menu row: a `›` Button that takes the ring, then the row as our own Text.
158type Row = { key: string; label: string; lead?: { text: string; color: string }; right?: string; rightColor?: string }
159
160// The menu pane's body, in the Sidebar's look: a breadcrumb over a `═` rule,
161// the level's rows, and the keys that work on it under a `─` rule, pinned
162// to the bottom when docked.
163export function menuView(input: MenuInput): RenderElement {
164 const { ui, menu, themes, theme, plugins, settings, monthly, colors: c, background, bodyRows, bodyColumns } = input
165 const { Box, Text, Input, Button } = ui
166 const docked = input.placement === 'dock'
167 const width = bodyColumns - 4
168 const footer = FOOTER[menu.level]
169 const opened = plugins.find(({ id }) => id === menu.focus)
170 const crumbs = { top: [], plugins: ['Plugins'], plugin: ['Plugins', opened?.title ?? ''], themes: ['Themes'] }[menu.level]
171
172 const row = ({ key, label, lead, right, rightColor }: Row, ringed: boolean) => (
173 <Box height={1} flexShrink={0} flexDirection="row" columnGap={1}>
174 {/* register.tsx's `ui.press` hook acts on each row. */}
175 <Button key={key} plain dimColor={!ringed} label="›" {...(ringed && { autoFocus: true })} onPress={() => undefined} />
176 <Box flexGrow={1} flexDirection="row" columnGap={1}>
177 <Box flexGrow={1} flexDirection="row">
178 {lead && <Text color={lead.color}>{`${lead.text} `}</Text>}
179 <Text color={ringed ? c.accent : c.main} bold={ringed} wrap="truncate-end">
180 {label}
181 </Text>
182 </Box>
183 {right && (
184 <Box flexShrink={0}>
185 <Text color={rightColor ?? c.muted}>{right}</Text>
186 </Box>
187 )}
188 </Box>
189 </Box>
190 )
191 // The ring sits on `menu.ring` when the list holds it, else on `start`, else on the first row.
192 const rows = (list: readonly Row[], start?: string) => {
193 const has = (key?: string) => list.some((r) => r.key === key)
194 const ringed = has(menu.ring) ? menu.ring : has(start) ? start : list[0]?.key
195 return list.map((r) => row(r, r.key === ringed))
196 }
197
198 const body = (): RenderElement[] => {
199 if (menu.level === 'plugins') {
200 return [
201 ...rows(
202 plugins.map(({ id, title, enable, folded }) => ({
203 key: rowKey(id),
204 label: title,
205 // Nerd Font eye and eye-slash. The terminal draws each two cells wide over
206 // the one the engine counts: the first space takes the overflow, the second parts it from the title.
207 lead: enable ? { text: '\u{f06e}', color: c.success } : { text: '\u{f070}', color: c.muted },
208 right: folded ? 'folded ›' : 'expanded ›',
209 })),
210 menu.focus && rowKey(menu.focus),
211 ),
212 // `x`, `k` and `j`: register.tsx's `ui.focus` hook keeps the ring off them.
213 <Box display="none">
214 <Button key={KEYS.toggle} label="show/hide" hotkey="x" plain onPress={() => undefined} />
215 <Button key={KEYS.up} label="up" hotkey="k" plain onPress={() => undefined} />
216 <Button key={KEYS.down} label="down" hotkey="j" plain onPress={() => undefined} />
217 </Box>,
218 ]
219 }
220 if (menu.level === 'plugin') {
221 const field = monthly !== undefined && monthlyKey(menu.entry)
222 const list = settings.map((s) => ({ key: settingKey(s.option), label: s.label, right: s.value, rightColor: s.value === 'on' ? c.success : c.muted }))
223 return [
224 ...(field && menu.ring === field ? list.map((r) => row(r, false)) : rows(list)),
225 ...(field
226 ? [
227 <Box height={1} flexShrink={0} paddingLeft={2}>
228 <Input
229 key={field}
230 // The field draws `<label>: `, so its value lines up with the rows'.
231 label={'Monthly cost'.padEnd(LABEL - 2)}
232 value={String(monthly)}
233 {...(menu.ring === field && { autoFocus: true })}
234 // Required; register.tsx's `ui.input` hook saves on Enter.
235 onInput={() => undefined}
236 onSubmit={() => undefined}
237 />
238 </Box>,
239 ]
240 : []),
241 ]
242 }
243 if (menu.level === 'themes') {
244 const list = themeRows(themes, menu.filter)
245 // The body rows left for Theme rows: padding, breadcrumb and rule, the
246 // field and its blank row, the two `more` rows, and the footer.
247 const room = Math.max(bodyRows - 2 - 4 - 2 - 2 - 1 - footerLines(footer, width), 1)
248 const ringed = list.findIndex(({ slug }) => themeKey(slug) === menu.ring)
249 // The window centres on the ringed row, else on the current Theme.
250 const at = ringed >= 0 ? ringed : Math.max(list.findIndex(({ slug }) => slug === theme), 0)
251 const from = Math.min(Math.max(at - Math.floor(room / 2), 0), Math.max(list.length - room, 0))
252 const shown = list.slice(from, from + room)
253 return [
254 <Box height={1} flexShrink={0}>
255 <Input
256 key={KEYS.filter}
257 label="filter "
258 placeholder="type to filter"
259 value={menu.filter}
260 {...(ringed < 0 && { autoFocus: true })}
261 // Required; register.tsx's `ui.input` hook handles the typing.
262 onInput={() => undefined}
263 onSubmit={() => undefined}
264 />
265 </Box>,
266 <Box height={1} flexShrink={0} />,
267 ...(list.length
268 ? [
269 <Text color={c.faint}>{from > 0 ? '↑ more' : ' '}</Text>,
270 ...shown.map(({ slug, name }) =>
271 row({ key: themeKey(slug), label: name, ...(slug === theme && { right: '●', rightColor: c.accent }) }, ringed >= 0 && themeKey(slug) === menu.ring),
272 ),
273 <Text color={c.faint}>{from + room < list.length ? '↓ more' : ' '}</Text>,
274 ]
275 : [<Text color={c.faint}>no match</Text>]),
276 ]
277 }
278 const current = themes.find(({ slug }) => slug === theme)?.name ?? theme
279 return rows(
280 [
281 { key: topKey('plugins'), label: 'Plugins', right: `${plugins.filter(({ enable }) => enable).length}/${plugins.length} ›` },
282 { key: topKey('themes'), label: 'Themes', right: `${current} ›` },
283 ],
284 menu.picks.top && topKey(menu.picks.top as TopPick),
285 )
286 }
287
288 return (
289 <Box
290 flexDirection="column"
291 paddingX={2}
292 paddingY={1}
293 width={bodyColumns}
294 {...(docked && { height: bodyRows })}
295 {...(background && { backgroundColor: background })}
296 >
297 <Text color={c.main} bold wrap="truncate-end">
298 Settings
299 {crumbs.map((crumb) => (
300 <Text>
301 <Text color={c.muted}>{' › '}</Text>
302 {crumb}
303 </Text>
304 ))}
305 </Text>
306 <Box height={1} flexShrink={0} />
307 <Text color={c.faint}>{'═'.repeat(width)}</Text>
308 <Box height={1} flexShrink={0} />
309 <Box flexDirection="column" flexShrink={1} overflow="hidden">
310 {body()}
311 </Box>
312 {docked && <Box flexGrow={1} />}
313 <Text color={c.faint}>{'─'.repeat(width)}</Text>
314 <Box flexDirection="row" flexWrap="wrap">
315 {footer.map(([key, label], index) => (
316 <Text color={c.muted}>
317 <Text color={c.main}>{`${key}:`}</Text>
318 {` ${label}`}
319 {index < footer.length - 1 && <Text color={c.faint}>{' · '}</Text>}
320 </Text>
321 ))}
322 </Box>
323 </Box>
324 )
325}
326hooks/sidebar.tsx 223 lines1import type { ElementTable, RenderElement, RenderNode } from 'claude-code'
2
3import type { Colors } from '../plugins/colors'
4import type { SidebarData, SidebarId, SidebarPlugin } from '../plugins/plugin'
5import type { Config } from './config'
6
7export const CAP = 4
8// Space under an expanded section's title, and after an expanded section
9// before the rule, in whole rows: Claude Code cuts layout down to whole
10// cells (#120).
11const GAP = 1
12// A section's fold arrow, one cell wide. `\uFE0E` asks for the text
13// presentation, so no font draws `▶` as an emoji.
14const EXPANDED = '▼'
15const FOLDED = '▶\uFE0E'
16
17export type SidebarInput = {
18 ui: ElementTable<'terminal' | 'desktop'> // the surfaces that draw a Client
19 bodyRows: number // the Pane's `scroll.bodyRows`
20 bodyColumns: number // the Pane's `bodyColumns`
21 plugins: readonly SidebarPlugin[] // enabled, in registry order
22 data: SidebarData
23 config: Config
24 colors: Colors
25 background?: string // a selected Theme's glass; none under `inherit`
26 folded: Record<string, boolean> // unset reads `<id>_folded`
27 expanded: Record<string, boolean>
28 scroll: number
29 focused: boolean // the Pane's `isFocused`
30 onControl: (control: Control) => void
31}
32
33// A Sidebar control's key: `fold-<id>` or `more-<id>`. A section's title
34// row, `foldrow-<id>`, folds it as its `fold-<id>` arrow does.
35export type Control = { kind: 'fold' | 'more'; id: SidebarId }
36export const controlKey = ({ kind, id }: Control) => `${kind}-${id}`
37export const foldRowKey = (id: SidebarId) => `foldrow-${id}`
38
39// The Sidebar body (MEMORY.md "The Sidebar adapts to the dock's engine
40// chrome"): git header, the sections window, flexible space, versions footer
41// on the last row. `maxScroll` is the window's last offset, 0 when it fits.
42export function sidebar(input: SidebarInput): { tree: RenderElement; maxScroll: number } {
43 const { ui, plugins, data, config, colors: c } = input
44 // Padding takes 2 columns each side; section rows indent 2 more.
45 const width = input.bodyColumns - 4
46 const { Box, Text, Button, Client } = ui
47 // A fold arrow or a list toggle. At rest a muted `Client`, whose click
48 // `register.tsx` answers at `ui.message`. A Client is outside the focus
49 // ring, so while the Pane holds the focus it is a dim Button instead.
50 // A fold arrow lights in accent with its title row's hover `scope`; a list
51 // toggle lights main.
52 const control = (key: Control, label: string) =>
53 input.focused ? (
54 <Button key={controlKey(key)} plain dimColor label={label} onPress={() => input.onControl(key)} />
55 ) : (
56 <Client
57 key={controlKey(key)}
58 module="./press.tsx"
59 props={{
60 label,
61 color: c.muted,
62 ...(key.kind === 'fold' ? { hover: c.accent, scope: foldRowKey(key.id) } : { hover: c.main }),
63 }}
64 />
65 )
66 const row = (node: RenderNode, indent = 0) => (
67 <Box height={1} flexShrink={0} paddingLeft={indent}>
68 {typeof node === 'string' ? (
69 <Text color={c.main} wrap="truncate-end">
70 {node}
71 </Text>
72 ) : (
73 node
74 )}
75 </Box>
76 )
77 const line = (node: RenderNode, indent = 0): Stop<RenderElement> => ({ nodes: [row(node, indent)], height: 1 })
78 // An empty Box: the root's paint covers it.
79 const spacer = (height: number) => <Box height={height} flexShrink={0} />
80 const rowsOf = (slot: SidebarPlugin['slot']) =>
81 plugins.filter((p) => p.slot === slot).flatMap((p) => p.view(data, ui, config[p.id], width, c))
82
83 // A rule across the body: `─` between two sections, `═` under the header.
84 const ruleOf = (glyph: string) => (
85 <Box height={1} flexShrink={0}>
86 <Text color={c.faint}>{glyph.repeat(width)}</Text>
87 </Box>
88 )
89 const rule = ruleOf('─')
90
91 // One scroll stop per section row. A title after the first carries the
92 // space after an expanded section and the rule above it, and its own space
93 // under it, so each scroll step moves one row (#130).
94 const sections: Stop<RenderElement>[] = []
95 let previous: 'none' | 'folded' | 'expanded' = 'none'
96 for (const plugin of plugins.filter((p) => p.slot === 'section')) {
97 const cfg = config[plugin.id]
98 const folded = input.folded[plugin.id] ?? cfg.folded ?? false
99 const right = (folded ? plugin.summary : plugin.count)?.(data, ui, cfg, width, c) ?? null
100 // The title and the count or summary are one Client, under the same key
101 // in both focus modes; a click anywhere on it folds the section. Its
102 // `width` leaves the arrow's cell: `flexGrow` alone laid the region out a
103 // cell too wide, into the right padding (checked on 2.1.292).
104 const key = foldRowKey(plugin.id)
105 const title = (
106 <Box height={1} flexShrink={0}>
107 {control({ kind: 'fold', id: plugin.id }, folded ? FOLDED : EXPANDED)}
108 <Client
109 key={key}
110 module="./foldrow.tsx"
111 width={width - 1}
112 flexGrow={1}
113 props={{ title: plugin.title, right, main: c.main, muted: c.muted, accent: c.accent, scope: key }}
114 />
115 </Box>
116 )
117 // Each node with its height in rows.
118 const gap: [RenderElement, number] = [spacer(GAP), GAP]
119 const parts: [RenderElement, number][] = [
120 ...(previous === 'expanded' ? [gap] : []),
121 ...(previous === 'none' ? [] : [[rule, 1] satisfies [RenderElement, number]]),
122 [title, 1],
123 ...(folded ? [] : [gap]),
124 ]
125 sections.push({
126 nodes: parts.map(([node]) => node),
127 height: parts.reduce((sum, [, rows]) => sum + rows, 0),
128 })
129 previous = folded ? 'folded' : 'expanded'
130 if (folded) continue
131 const body = plugin.view(data, ui, cfg, width - 2, c)
132 const expanded = input.expanded[plugin.id] ?? false
133 const capped = plugin.list && body.length > CAP
134 sections.push(...(capped && !expanded ? body.slice(0, CAP) : body).map((node) => line(node, 2)))
135 if (capped) {
136 const label = expanded ? '▾ show less' : `▸ ${body.length - CAP} more`
137 sections.push(line(control({ kind: 'more', id: plugin.id }, label), 2))
138 }
139 }
140
141 const header = rowsOf('header').map((node) => row(node))
142 const footer = rowsOf('footer').map((node) => row(node))
143 // A blank row, the `═` rule and a blank row part the header from the
144 // sections, fixed with the header outside the window (#130).
145 const underHeader: [RenderElement, number][] =
146 header.length > 0 && sections.length > 0 ? [[spacer(GAP), GAP], [ruleOf('═'), 1], [spacer(GAP), GAP]] : []
147 // Padding takes 2 rows; a blank row parts the footer from the sections.
148 const free =
149 input.bodyRows -
150 2 -
151 header.length -
152 underHeader.reduce((sum, [, rows]) => sum + rows, 0) -
153 footer.length -
154 (footer.length ? 1 : 0)
155 const { rows, maxScroll } = scrollWindow(sections, Math.max(free, 0), input.scroll, (text) => (
156 <Box height={1} flexShrink={0} justifyContent="flex-end">
157 <Text color={c.muted}>{text}</Text>
158 </Box>
159 ))
160
161 const tree = (
162 // One flat color on the root covers every body cell, padding included.
163 <Box
164 flexDirection="column"
165 height={input.bodyRows}
166 paddingX={2}
167 paddingY={1}
168 {...(input.background && { backgroundColor: input.background })}
169 >
170 {header}
171 {underHeader.map(([node]) => node)}
172 {rows}
173 <Box flexGrow={1} />
174 {footer}
175 </Box>
176 )
177 return { tree, maxScroll }
178}
179
180// A scroll stop of the sections window: its nodes and their height in rows,
181// a row's 1 plus any spacers' heights.
182export type Stop<T> = { nodes: readonly T[]; height: number }
183
184// Slices `stops` to `free` rows from stop `offset`, summing their heights,
185// with `↑ more` / `↓ more` rows (1 row each) where stops are hidden. At the
186// last offset only `↑ more` shows.
187export function scrollWindow<T>(
188 stops: readonly Stop<T>[],
189 free: number,
190 offset: number,
191 more: (text: string) => T,
192): { rows: T[]; maxScroll: number } {
193 const heightFrom = (start: number) => stops.slice(start).reduce((sum, stop) => sum + stop.height, 0)
194 const nodes = (from: readonly Stop<T>[]) => from.flatMap((stop) => stop.nodes)
195 if (heightFrom(0) <= free) return { rows: nodes(stops), maxScroll: 0 }
196 if (free < 2) return { rows: free ? [more('↓ more')] : [], maxScroll: 0 }
197 // The last offset: the first whose rest fits under `↑ more`.
198 let maxScroll = stops.length
199 while (maxScroll > 1 && heightFrom(maxScroll - 1) <= free - 1) maxScroll--
200 const start = Math.min(Math.max(offset, 0), maxScroll)
201 // The index of the first stop past `room` rows from `start`.
202 const fitEnd = (room: number) => {
203 let index = start
204 let used = 0
205 for (const stop of stops.slice(start)) {
206 if (used + stop.height > room) break
207 used += stop.height
208 index++
209 }
210 return index
211 }
212 const room = free - (start > 0 ? 1 : 0)
213 const below = fitEnd(room) < stops.length
214 return {
215 rows: [
216 ...(start > 0 ? [more('↑ more')] : []),
217 ...nodes(stops.slice(start, fitEnd(below ? room - 1 : room))),
218 ...(below ? [more('↓ more')] : []),
219 ],
220 maxScroll,
221 }
222}
223