SLOPSHOPPER

ctui

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…

newpanespinnerrowsguardcommand
v0.4.0MITupdated 2026-10-09a1exk-dev/claude-tui/ctui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctui
│ ┃ sidebar ✕ │ ┃ ┃ │ ┃ /work/app ┃ fix the failing auth test and add an audit log call │ ┃ ┃ │ ┃ ┃ │ ┃ ┃ │ ┃ ════════════════════════════════════════ ┃ │ ┃ ════════════ ┃ │ ┃ ┃ │ ┃ ▣ client module ./press.tsx▣ client module ┃ │ ┃ ┃ │ ┃ ━━━━━━━━━━━━━━━━━━━━━━──────────────── ┃ │ ┃ ─────── 49% ┃ │ ┃ 97,400 / 200k tokens ┃ │ ┃ ┃ │ ┃ ──────────────────────────────────────── ┃ │ ┃ ──────────── ┃ │ ┃ ▣ client module ./press.tsx▣ client module ┃ │ ┃ ┃ │ ┃ 5h ┃ │ ┃ ━━━━━━━━━━━━────────────────────────── ┃ │ ┃ ─ 31% ┃ │ ┃ resets in 59m ┃ │ ┃ ┃ │ ┃ ──────────────────────────────────────── ┃ │ ┃ ──────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidebar
/work/app ════════════════════════════════════════════════════ ▣ client module ./press.tsx▣ client module ./foldrow.tsx ━━━━━━━━━━━━━━━━━━━━━━─────────────────────── 49% 97,400 / 200k tokens ──────────────────────────────────────────────────── ▣ client module ./press.tsx▣ client module ./foldrow.tsx 5h ━━━━━━━━━━━━─────────────────────────── 31% resets in 59m ──────────────────────────────────────────────────── ▣ client module ./press.tsx▣ client module ./foldrow.tsx no task tools in this session ──────────────────────────────────────────────────── ▣ client module ./press.tsx▣ client module ./foldrow.tsx no skills used yet ↓ more
Your message
┃ ┃ fix the failing auth test and add an audit log call ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃ ┃
Claude's reply
⟨Claude Code's own drawing⟩
Tool row
← Edit src/auth.ts
README

ctui

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

Install

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

Terminal setup

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

Configuration

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.

Commands

  • /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.
  • Plugins in the same menu lists the sidebar sections in their order: 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.

Task tools

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.

What ctui watches

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.

Known differences

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.

License

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.

Source 30 files
hooks/register.tsx 1401 lines
1import 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 lines
1import 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')
20
plugins/colors.ts 21 lines
1// 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
21
hooks/config.ts 56 lines
1import 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}
56
hooks/format.ts 95 lines
1// 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}
95
hooks/git.ts 40 lines
1import 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}
40
hooks/mcp.ts 93 lines
1import 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}
93
hooks/month.ts 61 lines
1import 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))))
61
hooks/glass.ts 35 lines
1// 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}
35
hooks/skills.ts 56 lines
1import 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}
56
hooks/menu.tsx 326 lines
1import 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}
326
hooks/sidebar.tsx 223 lines
1import 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