A toolbox pane for Claude Code: run, stop and watch the project's build tasks, scripts, Claude commands and prompts from one list

Plugins for Claude Code that add panes, a status band and slash commands to the terminal: switch between several Claude accounts, watch their usage limits and token use, see and clean up what Claude Code keeps on the machine, and keep agents, worktrees, checkpoints, diffs and memory files in one pane, and run a project's builds and commands from buttons. Install one plugin, sc, and you get them all.
[!IMPORTANT] Clicking needs Claude Code's fullscreen mode. The buttons, tabs, tiles and status band cells answer a mouse click only while Claude Code draws in fullscreen mode. Turn it on once with
/tui fullscreen, which is kept as"tui": "fullscreen"in~/.claude/settings.json. In the default mode Claude Code passes no click to the plugins, on every platform, Windows and WSL included. See Clicking and the keyboard.
/sc:accounts~/.claude holds by kind and project, and a cleanup of idle sessions confirmed by typing a word./sc:workspace/sc:toolbox/clear and /compact..toolbox/toolbox.json in the project; share it or ignore it in git. A skill lets Claude write it for you.Anything that cannot be taken back asks in a dialog first; Esc cancels.
| Requirement | Why |
|---|---|
Claude Code in fullscreen mode: /tui fullscreen, or "tui": "fullscreen" in ~/.claude/settings.json | Only the fullscreen mode reads the mouse; in the default mode no click reaches a plugin |
A terminal that passes mouse events to the program running in it; inside tmux, set -g mouse on | A terminal that keeps the mouse for its own text selection sends Claude Code no click to pass on |
Without a click, every control is reached from the keyboard:
ctrl+x then Tab gives the open pane, or the status band, the keyboard.Tab and the arrow keys move between buttons; Enter presses the one with the ring.Esc goes back from a dialog and closes a pane./sc:accounts, /sc:workspace, /sc:toolbox.The first pane a command opens outside fullscreen mode says so in its reply.
claude plugin marketplace add simplecore-inc/claude-mods
claude plugin install sc@simplecore-mods
sc installs sc-accounts, sc-workspace and sc-toolbox with it. The plugins are installed for your user, so they run in every session. Run /reload-plugins in a session that was already open.
To use a local clone instead, add its folder as the marketplace:
git clone https://github.com/simplecore-inc/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install sc@simplecore-mods
claude plugin marketplace update simplecore-mods
claude plugin update sc@simplecore-mods
claude plugin update sc-accounts@simplecore-mods
claude plugin update sc-workspace@simplecore-mods
claude plugin update sc-toolbox@simplecore-mods
Then run /reload-plugins in each open session. With a local clone, git pull in the clone and /reload-plugins are enough: a plugin from a folder marketplace is read from that folder.
claude plugin uninstall sc@simplecore-mods
claude plugin prune
claude plugin marketplace remove simplecore-mods
prune removes sc-accounts, sc-workspace and sc-toolbox, which were installed as dependencies of sc; uninstall them by name instead if you installed them yourself. marketplace remove is only needed when you will not install from it again.
The mods leave some data behind, which you can delete by hand:
| What | Where |
|---|---|
| Saved logins | macOS: keychain items of the service account-switch. Elsewhere: ~/.claude/account-switch/ |
| Webhook token | macOS: the keychain item of the service sc-webhook. Elsewhere: ~/.claude/sc-accounts/webhook-token |
| Webhook template | ~/.claude/sc-accounts/ |
| Settings, account index | ~/.claude/plugins/store/sc-*.json |
| Tools, their logs and remembered values | in each project: .toolbox/ |
| Notes and checkpoint lists | in each repository's git folder: .git/sc-workspace/, a pair of files per worktree |
| Checkpoints | in each repository: the refs under refs/sc/, and the files sc-snapshot-*.index in the git folder and in each worktree's folder under .git/worktrees/ |
~/.claude is CLAUDE_CONFIG_DIR when that is set. To delete a repository's checkpoints, and its notes with them, run in the repository:
git for-each-ref --format='%(refname)' refs/sc/ | xargs -n 1 git update-ref -d
common="$(git rev-parse --git-common-dir)"
rm -f "$common"/sc-snapshot-*.index "$common"/worktrees/*/sc-snapshot-*.index
rm -rf "$common/sc-workspace"
The account you are logged in with stays logged in; only the saved copies go.
The mods show English, and Korean where Claude Code's language setting is Korean; any other language falls back to English. Without that setting, LC_ALL, LC_MESSAGES and LANG decide, in that order.
Developing a mod covers the layout, the rules the engine enforces, the checks and how to release.
hooks/register.tsx 1000 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DetectedTask, RunStatus, Tab, Tool, ToolKind, ToolParam } from '../types'
5import { BUILD_FILES, detectTasks, MARKER_FILES, parseGradleTaskList, pnpmWorkspaceGlobs, unreadableFiles, workspaceGlobs } from './detect'
6import { messagesFor, resolveLocale } from './i18n'
7import type { Locale, Messages } from './i18n'
8import { LOG_FILE_BYTES, LogBuffer, newestPart, pathSuggestions, progressOf, splitTyped } from './paths'
9import { releaseHeader } from './shared/release'
10import { LATEST_RELEASE_KEY, RELEASE_CHECK_MS, latestReleaseUrl, latestVersion } from './shared/release'
11import type { RunningRelease } from './shared/release'
12import { isBesideOtherPanes } from './shared/panes'
13import { Dialog, FormDialog, Header, LogDialog, paneTitle, TabBar, Tiles } from './shared/kit'
14import type { FormField, Tile } from './shared/kit'
15import { releaseDateOf } from './shared/locale'
16import {
17 applyToolChange,
18 askedParams,
19 CONFIRMED_COMMANDS,
20 DEFAULT_IGNORE,
21 fill,
22 paramNames,
23 parseTools,
24 settleRuns,
25 summarize,
26 startingValues,
27 TOOLBOX_DIR,
28 TOOLS_FILE,
29 valuesProblem,
30 workingFolder,
31} from './tools'
32import type { ToolChange } from './tools'
33import { AddTab } from './views/add'
34import { elapsed, QUICK_BEAT_MS, QuickView, runText, ToolsTab } from './views/tools'
35
36const tab = atom({ plugin: 'sc-toolbox', key: 'tab' } as const, 'tools')
37const tools = atom({ plugin: 'sc-toolbox', key: 'tools' } as const, [])
38const toolsError = atom({ plugin: 'sc-toolbox', key: 'toolsError' } as const, null)
39const runs = atom({ plugin: 'sc-toolbox', key: 'runs' } as const, {})
40const summary = atom({ plugin: 'sc-toolbox', key: 'summary' } as const, { running: 0, waiting: 0, failed: 0 })
41const seenAt = atom({ plugin: 'sc-toolbox', key: 'seenAt' } as const, 0)
42const paneOpen = atom({ plugin: 'sc-toolbox', key: 'paneOpen' } as const, false)
43const logTick = atom({ plugin: 'sc-toolbox', key: 'logTick' } as const, 0)
44const detected = atom({ plugin: 'sc-toolbox', key: 'detected' } as const, null)
45const unreadable = atom({ plugin: 'sc-toolbox', key: 'unreadable' } as const, [])
46const commands = atom({ plugin: 'sc-toolbox', key: 'commands' } as const, null)
47const addQuery = atom({ plugin: 'sc-toolbox', key: 'addQuery' } as const, '')
48const dialog = atom({ plugin: 'sc-toolbox', key: 'dialog' } as const, null)
49const draft = atom({ plugin: 'sc-toolbox', key: 'draft' } as const, {})
50const suggest = atom({ plugin: 'sc-toolbox', key: 'suggest' } as const, null)
51const follow = atom({ plugin: 'sc-toolbox', key: 'follow' } as const, true)
52const focused = atom({ plugin: 'sc-toolbox', key: 'focused' } as const, null)
53
54const PANE = 'sc-toolbox'
55/** The accounts band's toolbox cell: a row of Buttons, `band-toolbox` then `band-toolbox-1` and on. */
56const BAND_PLUGIN = 'sc-accounts'
57const BAND_CELL = 'band-toolbox'
58const COMMAND = 'sc:toolbox'
59const TAB_LABEL = 'Toolbox'
60/** The mod's name in the pane's header; a name, so it is never translated. */
61const MOD_NAME = 'Toolbox'
62/** How often the drawn log catches up with output while a run writes. */
63const LOG_TICK_MS = 200
64/** How often a running tool's log file is written. */
65const LOG_WRITE_MS = 2000
66/** The characters of output the log dialog draws at once: about a pane's worth, paged with Older and Newer. */
67const LOG_WINDOW_CHARS = 6_000
68/** Lines of the log drawn at once: what fits the pane with the dialog's title, status and buttons. */
69const LOG_WINDOW_LINES = 22
70/** How long a stopped run is given before it is killed outright. */
71const STOP_GRACE_MS = 3000
72/** Rows the pane takes in its settings view, and in the quick view. */
73const SETTINGS_ROWS = 40
74/** The quick view's rows: its header, the summary line, the box's border and the footer, and three for each row of two tiles. */
75const quickRows = (count: number) => Math.min(SETTINGS_ROWS, 12 + Math.ceil(Math.max(1, count) / 2) * 3)
76/** How often the quick view redraws while something runs or waits: the blink and the spinner's step. */
77const BLINK_MS = QUICK_BEAT_MS
78/**
79 * The shell a tool's command runs under: job control on, so the command
80 * gets a process group of its own and stopping it stops everything it
81 * started; the group's number is written first, to stderr, for the stop.
82 * `wait` keeps its own notice of a stopped job out of the command's output.
83 */
84const WRAP = 'set -m\nsh -c "$1" &\npid=$!\necho "__TOOLBOX_GROUP__ $pid" >&2\nwait $pid 2>/dev/null'
85const GROUP_LINE = /^__TOOLBOX_GROUP__ (\d+)\n?/
86
87/** What a dialog asks about: a tool's values, its log, its settings, its removal, or its restart. */
88type Asked = { kind: 'ask' | 'log' | 'edit' | 'remove' | 'restart' | 'confirm'; id: string }
89
90let locale: Locale = 'en'
91let m: Messages = messagesFor(locale)
92/** Whether this session has been told, once, that clicks need fullscreen mode. */
93let isClickHintShown = false
94
95/** Marks the click hint as shown when `text` carries it. */
96function opened(text: string): string {
97 if (text.includes(m.clickHint)) isClickHintShown = true
98
99 return text
100}
101let release: RunningRelease = {}
102/** The project's root: where `.toolbox/` lives and commands run from. */
103let root = ''
104let isWindows = false
105/** Each tool's output as it arrives, by tool id. */
106const buffers = new Map<string, LogBuffer>()
107/** Each running shell tool: its stream, to end it, and its process group, to stop what it started. */
108const running = new Map<string, { stop: () => void; group?: number; stopped?: boolean }>()
109/** Each tool's log file text as written last, so a write adds the new lines. */
110const logFiles = new Map<string, string>()
111/** How each run this module started last stood: a /clear empties the session's state while they go on. */
112const held = new Map<string, RunStatus>()
113/** How long after a /clear ends the old session the new one is taken up, once the engine has switched ids. */
114const CLEAR_SETTLE_MS = 300
115/** The line the log dialog ends at when it does not follow the newest. */
116let logLast: number | undefined
117let tickTimer: { cancel: () => void } | undefined
118
119function message(error: unknown): string {
120 return error instanceof Error ? error.message : String(error)
121}
122
123function debug($: EngineInterface, error: unknown): void {
124 $.ui.log(`sc-toolbox: ${message(error)}`, { to: 'debug' })
125}
126
127const toolboxPath = (name: string) => `${root}/${TOOLBOX_DIR}/${name}`
128
129async function readRelease($: EngineInterface): Promise<RunningRelease> {
130 try {
131 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string; repository?: string }
132 if (typeof manifest.version !== 'string') return {}
133 const changelogPath = `${$.plugin.root}/CHANGELOG.md`
134 const changelog = (await $.fs.exists(changelogPath)) ? await $.fs.read(changelogPath) : ''
135
136 return { version: manifest.version, date: releaseDateOf(changelog, manifest.version), repository: manifest.repository }
137 } catch (error) {
138 debug($, error)
139
140 return {}
141 }
142}
143
144/** Hears of the latest published release, so the pane header says when an update is due. */
145async function followLatestRelease($: EngineInterface): Promise<void> {
146 const url = latestReleaseUrl(release.repository)
147 if (url === null) return
148 const latest = await latestVersion(
149 {
150 now: () => $.clock.now(),
151 get: () => $.store.get(LATEST_RELEASE_KEY),
152 set: value => $.store.set(LATEST_RELEASE_KEY, value),
153 fetch: (target, init) => $.http.fetch(target, init),
154 },
155 url,
156 )
157 release = { ...release, latest }
158}
159
160// ── tools ─────────────────────────────────────────────────────────────────
161
162/** Reads `.toolbox/toolbox.json`; a project without one has no tools yet. */
163async function loadTools($: EngineInterface): Promise<void> {
164 const path = toolboxPath(TOOLS_FILE)
165 try {
166 if (!(await $.fs.exists(path))) {
167 await update($, tools, () => [])
168 await update($, toolsError, () => null)
169
170 return
171 }
172 const { tools: list, problems } = parseTools(await $.fs.read(path))
173 await update($, tools, () => list)
174 await update($, toolsError, () => (problems.length > 0 ? m.toolsProblem(problems.join(', ')) : null))
175 } catch (error) {
176 await update($, toolsError, () => m.toolsUnreadable(message(error)))
177 }
178}
179
180/**
181 * Saves one change to `.toolbox/toolbox.json` as the file holds it now, then
182 * reads the tools back: an edit made to the file since the pane read it (by
183 * hand, or by Claude through the toolbox skill), an entry the pane cannot read
184 * and a field it does not know all stay. Making the folder writes its
185 * `.gitignore` too, keeping the logs and the remembered values out of git;
186 * whether the tools are shared is the person's to decide there.
187 */
188async function changeTools($: EngineInterface, change: ToolChange): Promise<void> {
189 await ensureIgnore($)
190 const path = toolboxPath(TOOLS_FILE)
191 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
192 await $.fs.write(path, applyToolChange(typeof text === 'string' ? text : undefined, change))
193 await loadTools($)
194}
195
196/**
197 * Writes `.toolbox/.gitignore` the first time the toolbox writes anything, so
198 * its logs and remembered values stay out of git even when the person wrote
199 * `toolbox.json` by hand; an existing one is the person's and is left alone.
200 */
201async function ensureIgnore($: EngineInterface): Promise<void> {
202 const ignore = toolboxPath('.gitignore')
203 if (!(await $.fs.exists(ignore))) await $.fs.write(ignore, DEFAULT_IGNORE)
204}
205
206/** The values each tool was last run with, from `.toolbox/state.json`. */
207async function rememberedValues($: EngineInterface): Promise<Record<string, Record<string, string>>> {
208 try {
209 const path = toolboxPath('state.json')
210 if (!(await $.fs.exists(path))) return {}
211 const data = JSON.parse(await $.fs.read(path)) as { values?: Record<string, Record<string, string>> }
212
213 return data.values ?? {}
214 } catch (error) {
215 debug($, error)
216
217 return {}
218 }
219}
220
221async function remember($: EngineInterface, id: string, values: Record<string, string>): Promise<void> {
222 await ensureIgnore($)
223 const all = await rememberedValues($)
224 await $.fs.write(toolboxPath('state.json'), `${JSON.stringify({ values: { ...all, [id]: values } }, null, 2)}\n`)
225}
226
227// ── detection ─────────────────────────────────────────────────────────────
228
229/** Reads the project's build files, a workspace package's `package.json` too, and finds their tasks. */
230async function detect($: EngineInterface): Promise<void> {
231 const files: Record<string, string> = {}
232 const exists = new Set<string>()
233 const readIfThere = async (relative: string) => {
234 const path = `${root}/${relative}`
235 if (!(await $.fs.exists(path))) return
236 exists.add(relative)
237 if (BUILD_FILES.includes(relative) || relative.endsWith('package.json')) files[relative] = await $.fs.read(path)
238 }
239 for (const name of [...BUILD_FILES, ...MARKER_FILES]) await readIfThere(name)
240 // A workspace's packages, by their folder globs (`packages/*` and `apps/web` alike), as package.json or pnpm names them.
241 const globs = [...workspaceGlobs(files['package.json'] ?? '{}'), ...pnpmWorkspaceGlobs(files['pnpm-workspace.yaml'] ?? '')]
242 for (const glob of [...new Set(globs)]) {
243 const base = glob.replace(/\/\*+$/, '')
244 if (glob.endsWith('*') && (await $.fs.exists(`${root}/${base}`))) {
245 for (const entry of await $.fs.list(`${root}/${base}`)) if (entry.kind === 'dir') await readIfThere(`${base}/${entry.name}/package.json`)
246 } else await readIfThere(`${glob}/package.json`)
247 }
248 await update($, detected, () => detectTasks(files, exists))
249 await update($, unreadable, () => unreadableFiles(files))
250}
251
252/** Gradle's own whole task list, asked of Gradle once, added to what was found. */
253async function loadGradleTasks($: EngineInterface): Promise<string | void> {
254 const gradle = (await $.fs.exists(`${root}/gradlew`)) ? './gradlew' : 'gradle'
255 const { exitCode, stdout, stderr } = await $.process.run([gradle, 'tasks', '--all', '-q'], { cwd: root, timeoutMs: 600_000 })
256 if (exitCode !== 0) return stderr.trim().split('\n').pop() ?? `${gradle} exited ${exitCode}`
257 const all = parseGradleTaskList(stdout, gradle)
258 await update($, detected, current => [...(current ?? []).filter(task => task.source !== 'gradle'), ...all])
259}
260
261async function loadCommands($: EngineInterface): Promise<void> {
262 // Claude Code's own commands first, then the user's, plugins' and MCP servers'; a source not
263 // known here (skills) after them all.
264 const order = ['builtin', 'user', 'plugin', 'mcp']
265 const rank = (source: unknown) => {
266 const at = order.indexOf(String(source))
267
268 return at === -1 ? order.length : at
269 }
270 const list = [...(await $.command.list())].sort((a, b) => rank(a.source) - rank(b.source) || a.name.localeCompare(b.name))
271 await update($, commands, () => list.map(command => ({ name: command.name, description: command.description, source: String(command.source) })))
272}
273
274// ── running ───────────────────────────────────────────────────────────────
275
276/** Redraws the log dialog soon, once for a burst of output. */
277function scheduleTick($: EngineInterface): void {
278 if (tickTimer) return
279 tickTimer = $.clock.after(LOG_TICK_MS, () => {
280 tickTimer = undefined
281 void update($, logTick, n => n + 1).catch((error: unknown) => debug($, error))
282 })
283}
284
285/** Writes what a run added to its log file, keeping the file's newest part under the limit. */
286async function writeLog($: EngineInterface, id: string): Promise<void> {
287 const added = buffers.get(id)?.takePending() ?? ''
288 if (added === '') return
289 const kept = newestPart(`${logFiles.get(id) ?? ''}${added}`, LOG_FILE_BYTES)
290 logFiles.set(id, kept)
291 await ensureIgnore($)
292 await $.fs.write(toolboxPath(`logs/${id}.log`), kept)
293}
294
295/** A past run's output from its log file, for a log opened with nothing held: after a reload, or in a new session. */
296async function loadLog($: EngineInterface, id: string): Promise<void> {
297 const path = toolboxPath(`logs/${id}.log`)
298 if (!(await $.fs.exists(path))) return
299 const text = await $.fs.read(path)
300 const buffer = new LogBuffer()
301 buffer.push(text)
302 buffer.end()
303 buffer.takePending()
304 buffers.set(id, buffer)
305 logFiles.set(id, text)
306}
307
308async function setRun($: EngineInterface, id: string, change: (current: RunStatus | undefined) => RunStatus): Promise<void> {
309 const all = await update($, runs, current => ({ ...current, [id]: change(current[id]) }))
310 const status = all[id]
311 if (status) held.set(id, status)
312 await refreshSummary($)
313}
314
315/** The counts the band shows, from the runs and when the toolbox was last looked at. */
316async function refreshSummary($: EngineInterface): Promise<void> {
317 const next = summarize(await read($, runs), await read($, seenAt))
318 await update($, summary, current => (current.running === next.running && current.waiting === next.waiting && current.failed === next.failed ? current : next))
319}
320
321/** Runs a shell tool: its command under the wrapping shell, its output into the log as it comes. */
322async function runShell($: EngineInterface, tool: Tool, command: string): Promise<void> {
323 const cwd = workingFolder(root, tool.cwd)
324 const buffer = new LogBuffer()
325 buffers.set(tool.id, buffer)
326 logFiles.set(tool.id, '')
327 const startedAt = await $.clock.now()
328 await setRun($, tool.id, () => ({ state: 'running', startedAt, command }))
329 const argv = isWindows ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', command] : ['sh', '-c', WRAP, 'sh', command]
330 const stream = $.process.spawn({ argv, cwd })
331 // Ending the stream kills the child it started.
332 const entry: { stop: () => void; group?: number; stopped?: boolean } = { stop: () => void stream.return(undefined as never) }
333 running.set(tool.id, entry)
334 const writer = $.clock.every(LOG_WRITE_MS, () => void writeLog($, tool.id).catch((error: unknown) => debug($, error)))
335 let ending: { code: number | null; signal: string | null } = { code: null, signal: null }
336 try {
337 for (;;) {
338 const next = await stream.next()
339 if (next.done) {
340 if (next.value) ending = next.value
341 break
342 }
343 let text = next.value.text
344 // The wrapper's first word is the run's process group, for a stop.
345 if (next.value.stream === 'stderr' && entry.group === undefined) {
346 const group = GROUP_LINE.exec(text)
347 if (group?.[1]) {
348 entry.group = Number(group[1])
349 text = text.slice(group[0].length)
350 }
351 }
352 if (text !== '') {
353 buffer.push(text)
354 scheduleTick($)
355 }
356 }
357 } catch (error) {
358 buffer.push(`${message(error)}\n`)
359 ending = { code: 1, signal: null }
360 } finally {
361 writer.cancel()
362 buffer.end()
363 running.delete(tool.id)
364 await writeLog($, tool.id).catch((error: unknown) => debug($, error))
365 const endedAt = await $.clock.now()
366 const state = entry.stopped ? 'stopped' : ending.code === 0 ? 'done' : 'failed'
367 await setRun($, tool.id, current => ({ ...(current ?? { startedAt, command }), state, endedAt, code: ending.code, signal: ending.signal }))
368 scheduleTick($)
369 // The end of a run is said even with the pane closed; a stop the person pressed needs no word.
370 if (state === 'done') $.ui.toast(m.finishedToast(tool.name, elapsed(endedAt - startedAt)))
371 if (state === 'failed') $.ui.toast(m.failedToast(tool.name, typeof ending.code === 'number' ? ` (${m.exitCode(ending.code)})` : ''))
372 }
373}
374
375/**
376 * Stops a running shell tool: its whole process group is sent TERM, then
377 * KILL if it is still there after a grace period; the stream is ended either way.
378 */
379async function stopTool($: EngineInterface, tool: Tool): Promise<string> {
380 const entry = running.get(tool.id)
381 if (!entry) return m.stoppedToast(tool.name)
382 entry.stopped = true
383 if (entry.group !== undefined && !isWindows) {
384 const group = entry.group
385 await $.process.run(['kill', '-TERM', `-${group}`]).catch((error: unknown) => debug($, error))
386 $.clock.after(STOP_GRACE_MS, () => {
387 if (!running.has(tool.id)) return
388 void $.process
389 .run(['kill', '-KILL', `-${group}`])
390 .catch((error: unknown) => debug($, error))
391 .finally(() => entry.stop())
392 })
393 } else entry.stop()
394
395 return m.stoppedToast(tool.name)
396}
397
398/** Runs a tool with its values: a shell command in the background, a slash command or a prompt through Claude Code. */
399async function runTool($: EngineInterface, tool: Tool, values: Record<string, string>): Promise<string> {
400 const command = fill(tool, values)
401 if (askedParams(tool).length > 0) await remember($, tool.id, values)
402 if (tool.kind === 'shell') {
403 void runShell($, tool, command).catch((error: unknown) => debug($, error))
404
405 return m.started(tool.name)
406 }
407 if (tool.kind === 'claude') {
408 // Already waiting for the session to be idle: a second press would run it twice.
409 if (waiting.has(tool.id)) return m.alreadyQueued(tool.name)
410 const [name = '', ...rest] = command.trim().replace(/^\//, '').split(/\s+/)
411 const startedAt = await $.clock.now()
412 await setRun($, tool.id, () => ({ state: 'queued', startedAt, command }))
413 waiting.add(tool.id)
414 // A slash command runs once the session is idle: the row says it waits until then.
415 void $.command
416 .run({ command: name, args: rest.join(' ') })
417 .then(async () => {
418 waiting.delete(tool.id)
419 const endedAt = await $.clock.now()
420 await setRun($, tool.id, current => ({ ...(current ?? { startedAt, command }), state: 'done', endedAt }))
421 })
422 .catch(async (error: unknown) => {
423 waiting.delete(tool.id)
424 debug($, error)
425 $.ui.toast(m.failedToast(tool.name, ''))
426 const endedAt = await $.clock.now()
427 await setRun($, tool.id, current => ({ ...(current ?? { startedAt, command }), state: 'failed', endedAt }))
428 })
429
430 return m.queuedToast(tool.name)
431 }
432 if (tool.submit) {
433 await $.prompt.submit({ text: command })
434
435 return m.sent
436 }
437 // Put in at the cursor, so what the person was typing stays.
438 await $.prompt.fill({ text: command, mode: 'insert' })
439
440 return m.filled
441}
442
443// ── pane ──────────────────────────────────────────────────────────────────
444
445// Opened only by what the person did, the pane takes the keyboard: with no mouse nothing else hands it the keys.
446async function openPane($: EngineInterface, next?: Tab | 'quick', focus = true): Promise<void> {
447 if (next === 'quick') await update($, tab, () => 'tools')
448 else if (next) await update($, tab, () => next)
449 // Opened to show the tools: a dialog left from a draw the engine refused, or from a closed pane, goes.
450 if ((await read($, dialog)) !== null) {
451 await update($, dialog, () => null)
452 await update($, suggest, () => null)
453 await update($, focused, () => null)
454 }
455 quick = next === 'quick'
456 // Opening the toolbox shows every failure, so the band stops counting them.
457 const openedAt = await $.clock.now()
458 await update($, seenAt, () => openedAt)
459 await refreshSummary($)
460 await loadTools($)
461 const count = (await read($, tools)).length
462 await $.ui.open({ id: PANE, title: TAB_LABEL, rows: quick ? quickRows(count) : SETTINGS_ROWS, ...(focus ? { focus: true, closeOnEscape: true } : {}) })
463 await update($, paneOpen, () => true)
464 // While the pane is open, something running or waiting redraws it on a beat, for the blink and the elapsed time.
465 blinker ??= $.clock.every(BLINK_MS, () => {
466 if (running.size > 0 || waiting.size > 0) void update($, logTick, n => n + 1).catch((error: unknown) => debug($, error))
467 })
468 // Read afresh each time the Add tab opens: a build file changed, or a plugin was added since.
469 if (!quick && (await read($, tab)) === 'add') await Promise.all([detect($), loadCommands($)])
470}
471
472/** Whether the pane shows the quick tiles rather than the settings. */
473let quick = false
474
475/** The beat that redraws the open pane while a tool runs or waits; none while the pane is closed. */
476let blinker: { cancel: () => void } | undefined
477/** The Claude commands waiting for the session to be idle. */
478const waiting = new Set<string>()
479
480function stopBlinker(): void {
481 blinker?.cancel()
482 blinker = undefined
483}
484
485async function closePane($: EngineInterface): Promise<void> {
486 stopBlinker()
487 await $.ui.close({ id: PANE })
488 await update($, paneOpen, () => false)
489}
490
491/** The fields of the add-or-edit dialog, from the draft: the tool's own, then a few for each `{{name}}` its command holds. */
492function editFields(values: Record<string, string>, kind: ToolKind, set: (key: string, value: string) => Promise<void>): FormField[] {
493 const field = (key: string, label: string, extra: Partial<FormField> = {}): FormField => ({ key, label, value: values[key] ?? '', onInput: value => set(key, value), ...extra })
494 const fields: FormField[] = [field('name', m.fieldName), field('run', m.fieldRun[kind])]
495 if (kind === 'shell') fields.push(field('cwd', m.fieldCwd, { placeholder: '.' }))
496 fields.push(field('confirm', m.fieldConfirm, { options: [{ value: 'run', label: m.confirmRunNow }, { value: 'ask', label: m.confirmAskFirst }] }))
497 if (kind === 'prompt') {
498 fields.push(field('submit', m.fieldSubmit, { options: [{ value: 'fill', label: m.submitFill }, { value: 'send', label: m.submitSend }] }))
499 }
500 for (const name of paramNames(values.run ?? '')) {
501 const type = values[`p:${name}:type`] ?? 'text'
502 const mode = values[`p:${name}:mode`] ?? 'ask'
503 fields.push(
504 field(`p:${name}:type`, m.paramType(name), { options: [{ value: 'text', label: m.typeText }, { value: 'path', label: m.typePath }, { value: 'choice', label: m.typeChoice }] }),
505 field(`p:${name}:mode`, m.paramMode(name), { options: [{ value: 'ask', label: m.modeAsk }, { value: 'fixed', label: m.modeFixed }] }),
506 field(`p:${name}:value`, m.paramValue(name, mode)),
507 )
508 if (type === 'choice') fields.push(field(`p:${name}:choices`, m.paramChoices(name)))
509 if (type === 'path') fields.push(field(`p:${name}:glob`, m.paramGlob(name), { placeholder: '*' }))
510 }
511
512 return fields
513}
514
515/** A tool's settings as the edit dialog's draft. */
516function toDraft(tool: Pick<Tool, 'name' | 'kind' | 'run' | 'cwd' | 'params' | 'submit' | 'confirm'>): Record<string, string> {
517 const values: Record<string, string> = { kind: tool.kind, name: tool.name, run: tool.run, cwd: tool.cwd ?? '', submit: tool.submit ? 'send' : 'fill', confirm: tool.confirm ? 'ask' : 'run' }
518 for (const [name, param] of Object.entries(tool.params ?? {})) {
519 values[`p:${name}:type`] = param.type
520 values[`p:${name}:mode`] = param.mode
521 values[`p:${name}:value`] = param.value ?? ''
522 values[`p:${name}:choices`] = (param.choices ?? []).join(', ')
523 values[`p:${name}:glob`] = param.glob ?? ''
524 }
525
526 return values
527}
528
529/** The edit dialog's draft as a tool. */
530function fromDraft(id: string, values: Record<string, string>, group?: string): Tool {
531 const kind = (values.kind as ToolKind) || 'shell'
532 const params: Record<string, ToolParam> = {}
533 for (const name of paramNames(values.run ?? '')) {
534 const type = (values[`p:${name}:type`] as ToolParam['type']) || 'text'
535 const choices = (values[`p:${name}:choices`] ?? '').split(',').map(one => one.trim()).filter(Boolean)
536 params[name] = {
537 type,
538 mode: values[`p:${name}:mode`] === 'fixed' ? 'fixed' : 'ask',
539 ...(values[`p:${name}:value`] ? { value: values[`p:${name}:value`] } : {}),
540 ...(type === 'choice' && choices.length > 0 ? { choices } : {}),
541 ...(type === 'path' && values[`p:${name}:glob`] ? { glob: values[`p:${name}:glob`] } : {}),
542 }
543 }
544
545 return {
546 id,
547 name: (values.name ?? '').trim(),
548 kind,
549 run: (values.run ?? '').trim(),
550 ...(kind === 'shell' && values.cwd && values.cwd !== '.' ? { cwd: values.cwd } : {}),
551 ...(Object.keys(params).length > 0 ? { params } : {}),
552 ...(kind === 'prompt' && values.submit === 'send' ? { submit: true } : {}),
553 ...(values.confirm === 'ask' ? { confirm: true } : {}),
554 ...(group ? { group } : {}),
555 }
556}
557
558/**
559 * Fills the state a session draws from: the project's tools, whether the
560 * pane is open, and the runs. A reload leaves runs this load does not hold:
561 * none stays marked running or waiting with nothing behind it. A /clear
562 * leaves no runs in the state at all: those this module still holds are put
563 * back as they stand.
564 */
565async function adoptSession($: EngineInterface): Promise<void> {
566 root = (await $.session.root()).replaceAll('\\', '/')
567 await loadTools($)
568 const now = await $.clock.now()
569 // The pane as the engine keeps it, open or not.
570 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
571 await update($, paneOpen, () => isOpen)
572 const isHeld = (id: string) => running.has(id) || waiting.has(id)
573 const going = Object.fromEntries([...held].filter(([id]) => isHeld(id)))
574 await update($, runs, all => ({ ...going, ...settleRuns(all ?? {}, isHeld, now) }))
575 await refreshSummary($)
576}
577
578// ── hooks ─────────────────────────────────────────────────────────────────
579
580export const register: Register = on => {
581 on('session.start', async ($, e, next) => {
582 const { language } = await $.settings.read()
583 locale = resolveLocale(language, [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')])
584 m = messagesFor(locale)
585 release = await readRelease($)
586 // A later published release turns the header's date into Update Required: asked now and every few hours.
587 void followLatestRelease($).catch((error: unknown) => debug($, error))
588 $.clock.every(RELEASE_CHECK_MS, () => {
589 void followLatestRelease($).catch((error: unknown) => debug($, error))
590 })
591 isWindows = (await $.env.get('OS')) === 'Windows_NT'
592 await adoptSession($)
593
594 return next(e)
595 })
596
597 // A /clear goes on in this process under a new session id, with no session.start and none of
598 // the session's state: the new session is taken up here, its tools and the runs still going.
599 on('session.end', async ($, e, next) => {
600 const result = await next(e)
601 if (e.reason === 'clear') {
602 $.clock.after(CLEAR_SETTLE_MS, () => {
603 void adoptSession($).catch((error: unknown) => debug($, error))
604 })
605 }
606
607 return result
608 })
609
610 // Esc (or the close mark) while a dialog asks closes the dialog and keeps the pane.
611 on('ui.close', async ($, e, next) => {
612 if (e.id === PANE && e.origin.kind === 'person' && (await read($, dialog)) !== null) {
613 await update($, dialog, () => null)
614 await update($, suggest, () => null)
615 if (quick) await $.ui.open({ id: PANE, title: TAB_LABEL, rows: quickRows((await read($, tools)).length) })
616
617 return { value: undefined }
618 }
619 if (e.id === PANE) {
620 stopBlinker()
621 await update($, paneOpen, () => false)
622 }
623
624 return next(e)
625 })
626
627 on('ui.focus', async ($, e, next) => {
628 const result = await next(e)
629 if (e.requestId === PANE) await update($, focused, () => e.element ?? null)
630
631 return result
632 })
633
634 // The accounts band's toolbox cell, pressed: the quick tiles open inside the person's press,
635 // so the pane counts as asked for and is placed at any width; pressed again, they close.
636 on('ui.press', async ($, e, next) => {
637 if (e.plugin !== BAND_PLUGIN || e.component !== 'AbovePrompt' || !(e.element === BAND_CELL || e.element?.startsWith(`${BAND_CELL}-`))) return next(e)
638 const pane = (await $.ui.panes()).find(one => one.id === PANE)
639 if (pane?.isShown && pane.isPlaced && quick) await closePane($)
640 else {
641 if (pane) await $.ui.close({ id: PANE })
642 await openPane($, 'quick')
643 }
644
645 return { element: e.element }
646 })
647
648 // `commands/toolbox.md` of the plugin `sc` declares /sc:toolbox; this hook answers it.
649 on('command.run', { command: COMMAND }, async ($, e) => {
650 // Off fullscreen no click reaches a pane: the first pane a command opens says how to press without one.
651 const hint = e.presentation?.isFullscreen === false && !isClickHintShown ? ` ${m.clickHint}` : ''
652 const [verb = ''] = e.args.trim().split(/\s+/)
653 try {
654 // A command opens the Add tab afresh, without a filter typed in an earlier visit.
655 if (verb === 'add') await update($, addQuery, () => '')
656 if (verb === 'add' || verb === 'tools') await openPane($, verb)
657 else await openPane($, 'quick')
658
659 return { text: opened((verb === 'add' ? m.openedAdd : verb === 'tools' ? m.openedSettings : m.openedQuick) + hint) }
660 } catch (error) {
661 return { text: message(error) }
662 }
663 })
664
665 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
666 const ui = $.ui.resolve(e)
667 const { Box } = ui
668 const bodyColumns = e.props.bodyColumns ?? 60
669 const header = {
670 brand: paneTitle(MOD_NAME),
671 ...releaseHeader(release, m.release, m.updateRequired),
672 isUnderTabs: isBesideOtherPanes('sc-toolbox', {
673 'sc-accounts': (await $.state.get({ plugin: 'sc-accounts', key: 'paneOpen' })).value === true,
674 'sc-workspace': (await $.state.get({ plugin: 'sc-workspace', key: 'paneOpen' })).value === true,
675 'sc-toolbox': (await $.state.get({ plugin: 'sc-toolbox', key: 'paneOpen' })).value === true,
676 }),
677 columns: bodyColumns,
678 exit: { label: `✕ ${m.closeButton}`, onPress: () => void closePane($).catch((error: unknown) => $.ui.toast(message(error))) },
679 backLabel: m.backButton,
680 }
681 const hasField = e.surface !== 'mobile'
682 const act = (work: () => Promise<string | void>) => () => {
683 void work()
684 .then(text => text && $.ui.toast(text))
685 .catch((error: unknown) => $.ui.toast(message(error)))
686 }
687 const dismiss = async () => {
688 await update($, dialog, () => null)
689 await update($, suggest, () => null)
690 await update($, focused, () => null)
691 // Back from a dialog, the quick view takes its own height again.
692 if (quick) await $.ui.open({ id: PANE, title: TAB_LABEL, rows: quickRows((await read($, tools)).length) })
693 }
694 const openDialog = async (next: Asked, values: Record<string, string> = {}) => {
695 await update($, draft, () => values)
696 await update($, suggest, () => null)
697 await update($, dialog, () => next)
698 await $.ui.open({ id: PANE, title: TAB_LABEL, rows: SETTINGS_ROWS, focus: true, closeOnEscape: true })
699 }
700 const list = await read($, tools)
701 const byId = (id: string) => list.find(tool => tool.id === id)
702 // Running a tool: one that asks for values asks first, one already running asks to restart.
703 const start = async (tool: Tool) => {
704 if (tool.kind === 'shell' && running.has(tool.id)) return openDialog({ kind: 'restart', id: tool.id })
705 const values = startingValues(tool, (await rememberedValues($))[tool.id] ?? {})
706 // The values dialog is itself the question; a tool with none asks to confirm when set to.
707 if (askedParams(tool).length > 0) return openDialog({ kind: 'ask', id: tool.id }, values)
708 if (tool.confirm) return openDialog({ kind: 'confirm', id: tool.id }, values)
709
710 return runTool($, tool, values)
711 }
712 const asked = await read($, dialog)
713 const values = await read($, draft)
714 // Settles once the value is kept, so Enter in a field submits what was typed.
715 const setDraft = (key: string, value: string): Promise<void> =>
716 update($, draft, current => ({ ...current, [key]: value }))
717 .then(() => undefined)
718 .catch((error: unknown) => debug($, error))
719
720 if (asked?.kind === 'ask' && byId(asked.id)) {
721 const tool = byId(asked.id) as Tool
722 const offered = await read($, suggest)
723 const fields: FormField[] = askedParams(tool).map(name => {
724 const param = tool.params?.[name]
725 const onInput = (value: string): Promise<void> => {
726 const kept = setDraft(name, value)
727 // A path is offered as it is typed: the folder typed into is listed.
728 if (param?.type !== 'path') return kept
729 void (async () => {
730 const { folder } = splitTyped(value)
731 const path = folder === '' ? root : `${root}/${folder}`
732 const entries = (await $.fs.exists(path)) ? await $.fs.list(path) : []
733 await update($, suggest, () => ({ field: name, items: pathSuggestions(value, entries, { pathKind: param.pathKind, glob: param.glob }) }))
734 })().catch((error: unknown) => debug($, error))
735
736 return kept
737 }
738
739 return {
740 key: name,
741 label: name,
742 value: values[name] ?? '',
743 ...(param?.type === 'choice' && param.choices ? { options: param.choices.map(choice => ({ value: choice, label: choice })) } : {}),
744 ...(offered?.field === name ? { suggestions: offered.items } : {}),
745 onInput,
746 onPick: onInput,
747 }
748 })
749
750 return FormDialog(
751 ui,
752 bodyColumns,
753 header,
754 m.askTitle(tool.name),
755 // The command as it will run, filled with what is typed so far.
756 [{ text: m.askPreview(fill(tool, { ...startingValues(tool), ...values })), tone: 'muted' }],
757 fields,
758 {
759 submit: {
760 label: m.runButton,
761 onPress: act(async () => {
762 const current = await read($, draft)
763 const wrong = valuesProblem(tool, current)
764 if (wrong) return m.notChoice(wrong)
765 await dismiss()
766
767 return runTool($, tool, current)
768 }),
769 },
770 cancel: { label: m.cancel, onPress: act(dismiss) },
771 },
772 { hasField, noInput: m.noInput },
773 await read($, focused),
774 )
775 }
776
777 if (asked?.kind === 'log' && byId(asked.id)) {
778 const tool = byId(asked.id) as Tool
779 // Reading the tick redraws the dialog as output arrives.
780 await read($, logTick)
781 const run = (await read($, runs))[tool.id]
782 const isFollowing = await read($, follow)
783 const buffer = buffers.get(tool.id) ?? new LogBuffer()
784 const window = buffer.window(LOG_WINDOW_CHARS, isFollowing ? undefined : logLast, LOG_WINDOW_LINES)
785 const now = await $.clock.now()
786 const tone = run?.state === 'failed' ? 'danger' : run?.state === 'running' ? 'ok' : undefined
787 const tile = (key: string, label: string, onPress: () => void): Tile => ({ key, label, onPress })
788
789 return LogDialog(
790 ui,
791 bodyColumns,
792 header,
793 m.logTitle(tool.name),
794 { text: run ? m.logStatus(runText(run, now, m), run.command) : buffer.lines.length > 0 ? m.logFromFile : m.logNoRun, ...(tone ? { tone } : {}) },
795 { text: window.text, firstLine: window.first + 1, empty: m.logEmpty },
796 {
797 ...(window.first > 0
798 ? { older: tile('log-older', m.logOlder, () => void (async () => {
799 logLast = window.first
800 await update($, follow, () => false)
801 })()) }
802 : {}),
803 ...(!isFollowing && window.last < buffer.lines.length
804 ? { newer: tile('log-newer', m.logNewer, () => void (async () => {
805 logLast = Math.min(buffer.lines.length, window.last + (window.last - window.first))
806 await update($, logTick, n => n + 1)
807 })()) }
808 : {}),
809 follow: { key: 'log-follow', label: isFollowing ? m.logFollowing : m.logFollow, isMain: isFollowing, onPress: () => void update($, follow, isOn => !isOn) },
810 ...(run?.state === 'running'
811 ? { stop: tile('log-stop', m.stopButton, act(() => stopTool($, tool))) }
812 : {
813 again: tile('log-again', m.againButton, act(async () => {
814 await dismiss()
815
816 return start(tool)
817 })),
818 }),
819 close: { key: 'dialog-cancel', label: m.closeButton, isDismiss: true, onPress: act(dismiss) },
820 },
821 await read($, focused),
822 )
823 }
824
825 if (asked?.kind === 'edit') {
826 const existing = byId(asked.id)
827 const kind = (values.kind as ToolKind) || existing?.kind || 'shell'
828 // The parameters' settings change with the command, so the fields follow the draft.
829 return FormDialog(
830 ui,
831 bodyColumns,
832 header,
833 existing ? m.editTitle(existing.name) : m.newTitle,
834 [],
835 editFields(values, kind, setDraft),
836 {
837 submit: {
838 label: m.save,
839 onPress: act(async () => {
840 const current = await read($, draft)
841 const tool = fromDraft(existing?.id ?? '', { ...current, kind }, existing?.group ?? current.group)
842 if (tool.name === '' || tool.run === '') return m.nameMissing
843 // A new tool takes an id no tool in the file holds as it is now.
844 const { id: _unsaved, ...added } = tool
845 await changeTools($, existing ? { put: tool } : { add: added })
846 await dismiss()
847
848 return existing ? m.saved(tool.name) : m.added(tool.name)
849 }),
850 },
851 cancel: { label: m.cancel, onPress: act(dismiss) },
852 },
853 { hasField, noInput: m.noInput },
854 await read($, focused),
855 )
856 }
857
858 if (asked?.kind === 'confirm' && byId(asked.id)) {
859 const tool = byId(asked.id) as Tool
860
861 return Dialog(
862 ui,
863 bodyColumns,
864 header,
865 m.confirmTitle(tool.name),
866 [{ text: fill(tool, values) }, { text: m.confirmHint, tone: 'muted' }],
867 {
868 label: m.runButton,
869 onPress: act(async () => {
870 await dismiss()
871
872 return runTool($, tool, values)
873 }),
874 },
875 { label: m.cancel, onPress: act(dismiss) },
876 await read($, focused),
877 )
878 }
879
880 if ((asked?.kind === 'remove' || asked?.kind === 'restart') && byId(asked.id)) {
881 const tool = byId(asked.id) as Tool
882 const isRemove = asked.kind === 'remove'
883
884 return Dialog(
885 ui,
886 bodyColumns,
887 header,
888 isRemove ? m.removeTitle(tool.name) : m.restartTitle(tool.name),
889 [{ text: isRemove ? m.removeHint : m.restartHint, tone: 'muted' }],
890 {
891 label: isRemove ? m.removeConfirm : m.restartConfirm,
892 onPress: act(async () => {
893 await dismiss()
894 if (isRemove) {
895 await changeTools($, { remove: tool.id })
896
897 return m.removed(tool.name)
898 }
899 await stopTool($, tool)
900 // The old run ends before the new one takes its log.
901 while (running.has(tool.id)) await new Promise<void>(resolve => $.clock.after(100, resolve))
902 const again = startingValues(tool, (await rememberedValues($))[tool.id] ?? {})
903
904 return askedParams(tool).length > 0 ? openDialog({ kind: 'ask', id: tool.id }, again).then(() => undefined) : runTool($, tool, again)
905 }),
906 },
907 { label: m.cancel, onPress: act(dismiss) },
908 await read($, focused),
909 )
910 }
911
912 const now = Math.max(await $.clock.now(), await read($, logTick))
913 // How far each running shell tool says it has got.
914 const activity = Object.fromEntries(
915 [...buffers.entries()].map(([id, buffer]) => [id, { progress: running.has(id) ? progressOf([...buffer.lines, buffer.current]) : undefined }]),
916 )
917 const model = { tools: list, runs: await read($, runs), activity, error: await read($, toolsError), path: `${TOOLBOX_DIR}/${TOOLS_FILE}`, now, m, bodyColumns }
918 const toolActions = {
919 run: (tool: Tool) => act(() => start(tool))(),
920 stop: (tool: Tool) => act(() => stopTool($, tool))(),
921 log: (tool: Tool) =>
922 act(async () => {
923 await update($, follow, () => true)
924 logLast = undefined
925 if (!buffers.has(tool.id)) await loadLog($, tool.id)
926 await openDialog({ kind: 'log', id: tool.id })
927 })(),
928 edit: (tool: Tool) => act(() => openDialog({ kind: 'edit', id: tool.id }, toDraft(tool)))(),
929 remove: (tool: Tool) => act(() => openDialog({ kind: 'remove', id: tool.id }))(),
930 }
931
932 if (quick) {
933 return (
934 <Box flexDirection="column">
935 {Header(ui, header)}
936 <Box key="body-quick" flexDirection="column" marginTop={1}>
937 {QuickView(ui, model, {
938 run: toolActions.run,
939 stop: toolActions.stop,
940 log: toolActions.log,
941 settings: act(() => openPane($, 'tools')),
942 })}
943 </Box>
944 </Box>
945 )
946 }
947
948 const active = await read($, tab)
949 const tabs = [
950 { key: 'tools', label: m.tabTools, hotkey: '1', badge: list.length > 0 ? `${list.length}` : undefined },
951 { key: 'add', label: m.tabAdd, hotkey: '2' },
952 ]
953 const select = (key: string) => act(() => openPane($, key as Tab))()
954 const newTool = (kind: ToolKind, values: Partial<Record<string, string>> = {}, group?: string) =>
955 act(() => openDialog({ kind: 'edit', id: '' }, { kind, name: '', run: '', cwd: '', submit: 'fill', confirm: 'run', ...(group ? { group } : {}), ...values } as Record<string, string>))()
956 const fromTask = (task: DetectedTask) =>
957 act(() =>
958 openDialog(
959 { kind: 'edit', id: '' },
960 { ...toDraft({ name: task.name, kind: 'shell', run: task.run, cwd: task.cwd, params: task.params }), group: task.source },
961 ),
962 )()
963 const body =
964 active === 'add'
965 ? AddTab(
966 ui,
967 {
968 detected: await read($, detected),
969 unreadable: await read($, unreadable),
970 commands: await read($, commands),
971 query: await read($, addQuery),
972 hasGradle: (await read($, detected))?.some(task => task.source === 'gradle') === true,
973 hasField,
974 m,
975 bodyColumns,
976 },
977 {
978 search: query => void update($, addQuery, () => query).catch((error: unknown) => debug($, error)),
979 addTask: fromTask,
980 addCommand: name => newTool('claude', { name: `/${name}`, run: `/${name}`, confirm: CONFIRMED_COMMANDS.includes(name) ? 'ask' : 'run' }, 'claude'),
981 addShell: () => newTool('shell', {}, 'custom'),
982 addPrompt: () => newTool('prompt', {}, 'custom'),
983 loadGradle: act(() => loadGradleTasks($)),
984 },
985 )
986 : ToolsTab(ui, model, toolActions)
987
988 return (
989 <Box flexDirection="column">
990 {Header(ui, header)}
991 {TabBar(ui, tabs, active, select)}
992 <Box key={`body-${active}`} flexDirection="column" marginTop={1}>
993 {body}
994 </Box>
995 {Tiles(ui, bodyColumns, [{ key: 'quick', label: m.quickButton, onPress: act(() => openPane($, 'quick')) }])}
996 </Box>
997 )
998 })
999}
1000hooks/detect.ts 369 lines1/**
2 * The tasks a project's build files offer, read from the files alone: nothing
3 * here runs a build tool. The hooks module reads the files (`relative path →
4 * text`) and says which others exist; each reader below turns one ecosystem's
5 * files into tasks to add as tools.
6 */
7import type { DetectedTask } from '../types'
8
9/** The files the detectors read, from the project's root; a workspace package's `package.json` is added by the caller. */
10export const BUILD_FILES = [
11 'package.json',
12 'pom.xml',
13 'build.gradle',
14 'build.gradle.kts',
15 'settings.gradle',
16 'settings.gradle.kts',
17 'Cargo.toml',
18 'Makefile',
19 'makefile',
20 'GNUmakefile',
21 'justfile',
22 'Justfile',
23 '.justfile',
24 'docker-compose.yml',
25 'docker-compose.yaml',
26 'compose.yml',
27 'compose.yaml',
28 'go.mod',
29 'pyproject.toml',
30 'pnpm-workspace.yaml',
31]
32
33/** Files whose presence alone says something: a lockfile's runner, a wrapper script, a Vite config. */
34export const MARKER_FILES = [
35 'pnpm-lock.yaml',
36 'yarn.lock',
37 'bun.lockb',
38 'bun.lock',
39 'package-lock.json',
40 'mvnw',
41 'gradlew',
42 'uv.lock',
43 'poetry.lock',
44 'vite.config.ts',
45 'vite.config.js',
46 'vite.config.mts',
47 'vite.config.mjs',
48]
49
50/** The package manager a project's lockfile names; npm without one. */
51export function packageRunner(exists: ReadonlySet<string>): 'pnpm' | 'yarn' | 'bun' | 'npm' {
52 if (exists.has('pnpm-lock.yaml')) return 'pnpm'
53 if (exists.has('yarn.lock')) return 'yarn'
54 if (exists.has('bun.lockb') || exists.has('bun.lock')) return 'bun'
55
56 return 'npm'
57}
58
59/** A JSON file's data, or undefined when its text is no JSON: one broken file leaves out its own tasks alone. */
60function parsed(text: string | undefined): unknown {
61 if (text === undefined) return undefined
62 try {
63 return JSON.parse(text)
64 } catch (error) {
65 if (error instanceof SyntaxError) return undefined
66 throw error
67 }
68}
69
70/** The `package.json` files read here whose text is no JSON, so their scripts are left out: the Add tab names them. */
71export function unreadableFiles(files: Record<string, string>): string[] {
72 return Object.keys(files).filter(path => path.endsWith('package.json') && parsed(files[path]) === undefined)
73}
74
75/** The folder globs a root `package.json` names as workspaces (`packages/*`), from either form it takes. */
76export function workspaceGlobs(packageJson: string): string[] {
77 const data = parsed(packageJson) as { workspaces?: unknown } | undefined
78 const raw = Array.isArray(data?.workspaces) ? data.workspaces : (data?.workspaces as { packages?: unknown } | undefined)?.packages
79
80 return Array.isArray(raw) ? raw.filter((one): one is string => typeof one === 'string') : []
81}
82
83/** The folder globs `pnpm-workspace.yaml` lists under `packages:`, an excluded one (`!...`) left out. */
84export function pnpmWorkspaceGlobs(yaml: string): string[] {
85 const globs: string[] = []
86 let isInside = false
87 for (const line of yaml.split('\n')) {
88 if (/^\S/.test(line)) {
89 isInside = /^packages\s*:/.test(line)
90 continue
91 }
92 const item = isInside ? /^\s*-\s*(['"]?)([^'"#]+?)\1\s*(?:#.*)?$/.exec(line) : null
93 if (item?.[2] && !item[2].startsWith('!')) globs.push(item[2].trim())
94 }
95
96 return globs
97}
98
99function npmTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
100 const runner = packageRunner(exists)
101 const tasks: DetectedTask[] = []
102 for (const [path, text] of Object.entries(files)) {
103 if (!path.endsWith('package.json')) continue
104 const data = parsed(text) as { name?: unknown; scripts?: Record<string, unknown> } | undefined
105 if (!data || typeof data !== 'object') continue
106 const folder = path.slice(0, -'package.json'.length).replace(/\/$/, '')
107 for (const [script, command] of Object.entries(data.scripts ?? {})) {
108 if (typeof command !== 'string') continue
109 const where = folder === '' ? '' : ` (${typeof data.name === 'string' ? data.name : folder})`
110 tasks.push({
111 source: 'npm',
112 name: `${script}${where}`,
113 run: runner === 'npm' ? `npm run ${script}` : `${runner} run ${script}`,
114 ...(folder ? { cwd: folder } : {}),
115 description: command,
116 })
117 }
118 }
119
120 return tasks
121}
122
123function viteTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
124 if (![...exists].some(name => /^vite\.config\.[mc]?[jt]s$/.test(name))) return []
125 // A script that already runs Vite is the way the project means it run: those come from npm.
126 const scripts = Object.values((parsed(files['package.json']) as { scripts?: Record<string, unknown> } | undefined)?.scripts ?? {})
127 // Whether a script runs Vite this way: the dev server as `vite`, `vite dev` or `vite serve`.
128 const runsVite = (pattern: RegExp) => scripts.some(command => typeof command === 'string' && pattern.test(command))
129 const runner = packageRunner(exists)
130 const exec = runner === 'npm' ? 'npx' : runner === 'pnpm' ? 'pnpm exec' : runner === 'yarn' ? 'yarn' : 'bunx'
131
132 return [
133 { verb: '', name: 'dev server', description: 'vite', pattern: /\bvite(\s+(dev|serve))?\s*($|&&|;|\s-)/ },
134 { verb: 'build', name: 'build', description: 'vite build', pattern: /\bvite\s+build\b/ },
135 { verb: 'preview', name: 'preview', description: 'vite preview', pattern: /\bvite\s+preview\b/ },
136 ]
137 .filter(({ pattern }) => !runsVite(pattern))
138 .map(({ verb, name, description }) => ({ source: 'vite', name, run: `${exec} vite${verb ? ` ${verb}` : ''}`, description }))
139}
140
141function mavenTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
142 const pom = files['pom.xml']
143 if (!pom) return []
144 const mvn = exists.has('mvnw') ? './mvnw' : 'mvn'
145 const tasks: DetectedTask[] = ['clean', 'compile', 'test', 'package', 'verify', 'install', 'clean install'].map(goal => ({
146 source: 'maven',
147 name: goal,
148 run: `${mvn} ${goal}`,
149 }))
150 const modules = [...pom.matchAll(/<module>\s*([^<\s]+)\s*<\/module>/g)].map(match => match[1] ?? '')
151 if (modules.length > 0) {
152 tasks.push({
153 source: 'maven',
154 name: 'one module: goal',
155 run: `${mvn} -pl {{module}} -am {{goal}}`,
156 description: 'a goal for one module and what it needs',
157 params: {
158 module: { type: 'choice', mode: 'ask', choices: modules },
159 goal: { type: 'choice', mode: 'ask', choices: ['compile', 'test', 'package', 'install'], value: 'test' },
160 },
161 })
162 }
163 if (/<artifactId>\s*spring-boot-maven-plugin\s*<\/artifactId>/.test(pom)) tasks.push({ source: 'maven', name: 'spring-boot:run', run: `${mvn} spring-boot:run` })
164
165 return tasks
166}
167
168/** The subprojects `settings.gradle(.kts)` includes, as `:a:b`. */
169export function gradleProjects(settings: string): string[] {
170 const found: string[] = []
171 for (const line of settings.split('\n')) {
172 if (!/^\s*include\b/.test(line)) continue
173 for (const match of line.matchAll(/["']([^"']+)["']/g)) found.push(`:${(match[1] ?? '').replace(/^:/, '')}`)
174 }
175
176 return [...new Set(found)]
177}
178
179function gradleTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
180 const build = files['build.gradle'] ?? files['build.gradle.kts']
181 const settings = files['settings.gradle'] ?? files['settings.gradle.kts']
182 if (build === undefined && settings === undefined) return []
183 const gradle = exists.has('gradlew') ? './gradlew' : 'gradle'
184 const tasks: DetectedTask[] = ['build', 'clean', 'test', 'check', 'assemble'].map(task => ({ source: 'gradle', name: task, run: `${gradle} ${task}` }))
185 if (/org\.springframework\.boot/.test(build ?? '')) tasks.push({ source: 'gradle', name: 'bootRun', run: `${gradle} bootRun` })
186 if (/\bapplication\b/.test(build ?? '')) tasks.push({ source: 'gradle', name: 'run', run: `${gradle} run` })
187 const projects = gradleProjects(settings ?? '')
188 if (projects.length > 0) {
189 tasks.push({
190 source: 'gradle',
191 name: 'one project: task',
192 run: `${gradle} {{project}}:{{task}}`,
193 description: 'a task for one subproject',
194 params: {
195 project: { type: 'choice', mode: 'ask', choices: projects },
196 task: { type: 'text', mode: 'ask', value: 'test' },
197 },
198 })
199 }
200
201 return tasks
202}
203
204/** Gradle's own task list (`gradle tasks --all`): each `name - description` line under a heading. */
205export function parseGradleTaskList(output: string, gradle: string): DetectedTask[] {
206 const tasks: DetectedTask[] = []
207 for (const line of output.split('\n')) {
208 const match = /^([A-Za-z][\w:.-]*)(?: - (.*))?$/.exec(line.trim())
209 if (!match?.[1] || /^-+$/.test(line.trim()) || line.includes(' tasks') || line.trim().endsWith('tasks')) continue
210 if (line.startsWith(' ') || !match[2]) continue
211 tasks.push({ source: 'gradle', name: match[1], run: `${gradle} ${match[1]}`, description: match[2] })
212 }
213
214 return tasks
215}
216
217/** A TOML array of strings after `key =` in `section`, as found; empty when absent. */
218function tomlStrings(text: string, section: string, key: string): string[] {
219 const body = new RegExp(`^\\[${section.replace(/[.[\]]/g, match => `\\${match}`)}\\]\\s*$([\\s\\S]*?)(?=^\\[|$(?![\\s\\S]))`, 'm').exec(text)?.[1] ?? ''
220 const array = new RegExp(`^\\s*${key}\\s*=\\s*\\[([\\s\\S]*?)\\]`, 'm').exec(body)?.[1] ?? ''
221
222 return [...array.matchAll(/["']([^"']+)["']/g)].map(match => match[1] ?? '')
223}
224
225function cargoTasks(files: Record<string, string>): DetectedTask[] {
226 const toml = files['Cargo.toml']
227 if (toml === undefined) return []
228 const tasks: DetectedTask[] = ['build', 'build --release', 'test', 'check', 'clippy', 'fmt', 'run'].map(command => ({
229 source: 'cargo',
230 name: command,
231 run: `cargo ${command}`,
232 }))
233 const members = tomlStrings(toml, 'workspace', 'members')
234 if (members.length > 0) {
235 tasks.push({
236 source: 'cargo',
237 name: 'one package: command',
238 run: 'cargo {{command}} -p {{package}}',
239 description: 'a command for one workspace member',
240 params: {
241 package: { type: 'choice', mode: 'ask', choices: members.map(member => member.split('/').pop() ?? member) },
242 command: { type: 'choice', mode: 'ask', choices: ['build', 'test', 'check', 'clippy', 'run'], value: 'test' },
243 },
244 })
245 }
246 const bins = [...toml.matchAll(/^\[\[bin\]\][^[]*?^\s*name\s*=\s*["']([^"']+)["']/gms)].map(match => match[1] ?? '')
247 for (const bin of bins) tasks.push({ source: 'cargo', name: `run ${bin}`, run: `cargo run --bin ${bin}` })
248
249 return tasks
250}
251
252function makeTasks(files: Record<string, string>): DetectedTask[] {
253 const name = ['Makefile', 'makefile', 'GNUmakefile'].find(file => files[file] !== undefined)
254 if (!name) return []
255 const targets = new Set<string>()
256 for (const line of (files[name] ?? '').split('\n')) {
257 const match = /^([A-Za-z0-9][\w.-]*)\s*:(?!=)/.exec(line)
258 if (match?.[1] && !match[1].startsWith('.')) targets.add(match[1])
259 }
260
261 return [...targets].map(target => ({ source: 'make', name: target, run: `make ${target}` }))
262}
263
264function justTasks(files: Record<string, string>): DetectedTask[] {
265 const name = ['justfile', 'Justfile', '.justfile'].find(file => files[file] !== undefined)
266 if (!name) return []
267 const recipes: DetectedTask[] = []
268 for (const line of (files[name] ?? '').split('\n')) {
269 // A recipe at the line's start, its parameters, then a colon that is no `:=` (a variable, a setting, an alias).
270 const match = /^@?([A-Za-z_][\w-]*)((?:\s+[^:]+?)?)\s*:(?!=)/.exec(line)
271 if (!match?.[1]) continue
272 // `+args` and `*args` take several words, kept as typed; `$name` is exported; `name='x'` offers x first.
273 const args = (match[2] ?? '')
274 .trim()
275 .split(/\s+/)
276 .filter(Boolean)
277 .map(token => /^([+*]?)\$?([A-Za-z_][\w-]*)(?:=(.*))?$/.exec(token))
278 .filter((arg): arg is RegExpExecArray => arg !== null)
279 .map(arg => ({ name: arg[2] ?? '', isVariadic: arg[1] !== '', value: (arg[3] ?? '').replace(/^(['"])(.*)\1$/, '$2') }))
280 recipes.push({
281 source: 'just',
282 name: match[1],
283 run: `just ${match[1]}${args.map(arg => ` {{${arg.name}${arg.isVariadic ? '|raw' : ''}}}`).join('')}`,
284 ...(args.length > 0
285 ? { params: Object.fromEntries(args.map(arg => [arg.name, { type: 'text' as const, mode: 'ask' as const, ...(arg.value !== '' ? { value: arg.value } : {}) }])) }
286 : {}),
287 })
288 }
289
290 return recipes
291}
292
293/** The service names under a compose file's top-level `services:`. */
294export function composeServices(yaml: string): string[] {
295 const services: string[] = []
296 let isInside = false
297 let indent = -1
298 for (const line of yaml.split('\n')) {
299 if (/^\S/.test(line)) {
300 isInside = /^services\s*:/.test(line)
301 indent = -1
302 continue
303 }
304 if (!isInside) continue
305 const match = /^(\s+)([\w.-]+)\s*:/.exec(line)
306 if (!match?.[1] || !match[2]) continue
307 if (indent === -1) indent = match[1].length
308 if (match[1].length === indent) services.push(match[2])
309 }
310
311 return services
312}
313
314function composeTasks(files: Record<string, string>): DetectedTask[] {
315 const name = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'].find(file => files[file] !== undefined)
316 if (!name) return []
317 const services = composeServices(files[name] ?? '')
318 const tasks: DetectedTask[] = [
319 { source: 'compose', name: 'up', run: 'docker compose up' },
320 { source: 'compose', name: 'down', run: 'docker compose down' },
321 { source: 'compose', name: 'ps', run: 'docker compose ps' },
322 ]
323 if (services.length > 0) {
324 tasks.push({
325 source: 'compose',
326 name: 'logs of a service',
327 run: 'docker compose logs -f {{service}}',
328 params: { service: { type: 'choice', mode: 'ask', choices: services } },
329 })
330 }
331
332 return tasks
333}
334
335function goTasks(files: Record<string, string>): DetectedTask[] {
336 if (files['go.mod'] === undefined) return []
337
338 return ['build ./...', 'test ./...', 'vet ./...', 'run .'].map(command => ({ source: 'go', name: command, run: `go ${command}` }))
339}
340
341function pythonTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
342 const toml = files['pyproject.toml']
343 if (toml === undefined) return []
344 const runner = exists.has('uv.lock') ? 'uv run' : exists.has('poetry.lock') ? 'poetry run' : ''
345 const prefix = runner ? `${runner} ` : ''
346 const body = /^\[project\.scripts\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(toml)?.[1] ?? ''
347 const scripts = [...body.matchAll(/^\s*([\w.-]+)\s*=/gm)].map(match => match[1] ?? '')
348 const tasks: DetectedTask[] = scripts.map(script => ({ source: 'python', name: script, run: `${prefix}${script}` }))
349 if (/\bpytest\b/.test(toml)) tasks.push({ source: 'python', name: 'pytest', run: `${prefix}pytest` })
350
351 return tasks
352}
353
354/** Every task the project's build files offer, grouped by their ecosystem in a fixed order. */
355export function detectTasks(files: Record<string, string>, exists: ReadonlySet<string>): DetectedTask[] {
356 return [
357 ...npmTasks(files, exists),
358 ...viteTasks(files, exists),
359 ...mavenTasks(files, exists),
360 ...gradleTasks(files, exists),
361 ...cargoTasks(files),
362 ...makeTasks(files),
363 ...justTasks(files),
364 ...composeTasks(files),
365 ...goTasks(files),
366 ...pythonTasks(files, exists),
367 ]
368}
369hooks/i18n.ts 231 lines1import type { Locale } from './shared/locale'
2
3export { resolveLocale } from './shared/locale'
4export type { Locale } from './shared/locale'
5
6type Kind = 'shell' | 'claude' | 'prompt'
7
8const en = {
9 release: (version: string, date?: string) => (date ? `v${version} (${date})` : `v${version}`),
10 updateRequired: 'Update Required',
11 loading: 'Loading…',
12 closeButton: 'Close',
13 clickHint: 'Clicks reach the panes only in fullscreen mode (/tui fullscreen); here, ctrl+x tab focuses the pane, then Tab or the arrows move and Enter presses.',
14 backButton: '← Back',
15 cancel: 'Cancel',
16 openedQuick: 'Opened the toolbox.',
17 openedSettings: 'Opened the toolbox settings.',
18 openedAdd: 'Opened the toolbox on Add.',
19 tabTools: 'Tools',
20 tabAdd: 'Add',
21 settingsButton: '⚙ Settings',
22 quickButton: '▶ Quick run',
23 quickEmpty: 'No tools yet. Settings adds them from this project\'s build files, from Claude\'s commands, or as your own.',
24 toolsTitle: 'Tools',
25 toolsDetail: (path: string) => path,
26 toolsEmpty: 'No tools yet.',
27 toolsEmptyHint: 'The Add tab finds this project\'s build tasks and Claude\'s commands, or takes a command of your own.',
28 toolsProblem: (problems: string) => `Left out of .toolbox/toolbox.json, as they lack a name, a kind or a command: ${problems}`,
29 toolsUnreadable: (reason: string) => `.toolbox/toolbox.json could not be read: ${reason}`,
30 kindTitle: { shell: 'Shell', claude: 'Claude commands', prompt: 'Prompts' } as Record<Kind, string>,
31 state: { running: 'running', done: 'done', failed: 'failed', stopped: 'stopped', queued: 'waiting for Claude' } as Record<'running' | 'done' | 'failed' | 'stopped' | 'queued', string>,
32 exitCode: (code: number) => `exit ${code}`,
33 asked: 'asks at each run',
34 asksFirst: 'asks first',
35 pressToRun: 'press to run',
36 took: (time: string) => `took ${time}`,
37 ago: (time: string) => `${time} ago`,
38 quickHint: 'Press a tile to run it. A shell tool that has run opens its log; ▶ runs it again.',
39 summaryRunning: (count: number) => `${count} running`,
40 summaryWaiting: (count: number) => `${count} waiting for Claude`,
41 summaryFailed: (count: number) => `${count} failed`,
42 finishedToast: (name: string, time: string) => `${name} finished in ${time}.`,
43 failedToast: (name: string, code: string) => `${name} failed${code}. Its log is in the toolbox.`,
44 logFromFile: 'The last run, from its log file; its state was not kept.',
45 logNoRun: 'Not run yet.',
46 againButton: '▶ Run again',
47 askPreview: (command: string) => `Runs: ${command}`,
48 kindIdle: { shell: 'shell', claude: 'Claude command', prompt: 'prompt' } as Record<Kind, string>,
49 fieldConfirm: 'Before each run',
50 confirmRunNow: 'Run at once',
51 confirmAskFirst: 'Ask first',
52 confirmTitle: (name: string) => `Run ${name}?`,
53 confirmHint: 'Asked each time, as this command cannot be taken back.',
54 addTitle: 'Add a tool',
55 addSearch: 'Filter by name or command',
56 addDetected: 'From this project',
57 addDetectedNone: 'No build file found here: package.json, pom.xml, build.gradle, Cargo.toml, Makefile, justfile, compose.yaml, go.mod or pyproject.toml.',
58 addGradleAll: 'Load every Gradle task',
59 addUnreadable: (files: string) => `Could not read ${files}: its tasks are left out until it holds valid JSON.`,
60 addClaude: 'Claude commands',
61 addCustom: 'Your own',
62 addShell: '+ Shell command',
63 addPrompt: '+ Prompt',
64 added: (name: string) => `Added ${name}.`,
65 editTitle: (name: string) => `Tool: ${name}`,
66 newTitle: 'New tool',
67 fieldName: 'Name',
68 fieldRun: {
69 shell: 'Command. A word in {{ }}, such as {{file}}, is asked at each run',
70 claude: 'Slash command and arguments. A word in {{ }} is asked at each run',
71 prompt: 'Prompt. A word in {{ }} is asked at each run',
72 } as Record<Kind, string>,
73 fieldCwd: 'Folder to run in, from the project',
74 fieldSubmit: 'When run',
75 submitFill: 'Put it in the prompt box',
76 submitSend: 'Send it at once',
77 paramType: (name: string) => `{{${name}}}: kind`,
78 paramMode: (name: string) => `{{${name}}}: value`,
79 paramValue: (name: string, mode: string) => `{{${name}}}: ${mode === 'fixed' ? 'value' : 'value offered first'}`,
80 paramChoices: (name: string) => `{{${name}}}: choices, one per comma`,
81 paramGlob: (name: string) => `{{${name}}}: names to offer, such as *.ts`,
82 typeText: 'Text',
83 typePath: 'Path',
84 typeChoice: 'Choice',
85 modeAsk: 'Ask at each run',
86 modeFixed: 'Fixed',
87 save: 'Save',
88 saved: (name: string) => `Saved ${name}.`,
89 nameMissing: 'A tool needs a name and a command.',
90 askTitle: (name: string) => `Run ${name}`,
91 runButton: 'Run',
92 notChoice: (name: string) => `{{${name}}} must be one of its choices.`,
93 noInput: 'This tool asks for values, which needs a text field: run it from the terminal or the desktop app.',
94 removeTitle: (name: string) => `Remove ${name}?`,
95 removeHint: 'It is removed from .toolbox/toolbox.json; its log stays.',
96 removeConfirm: 'Remove',
97 removed: (name: string) => `Removed ${name}.`,
98 restartTitle: (name: string) => `${name} is running`,
99 restartHint: 'Restarting stops this run first.',
100 restartConfirm: 'Restart',
101 started: (name: string) => `Started ${name}.`,
102 stoppedToast: (name: string) => `Stopped ${name}.`,
103 queuedToast: (name: string) => `${name} runs once Claude is idle.`,
104 alreadyQueued: (name: string) => `${name} is already waiting for Claude.`,
105 filled: 'Put it in the prompt box.',
106 sent: 'Sent.',
107 logTitle: (name: string) => `Log: ${name}`,
108 logStatus: (state: string, command: string) => `${state} · ${command}`,
109 logEmpty: 'No output yet.',
110 logOlder: '▲ Older',
111 logNewer: '▼ Newer',
112 logFollow: 'Follow',
113 logFollowing: 'Following',
114 stopButton: '■ Stop',
115}
116
117export type Messages = typeof en
118
119const ko: Messages = {
120 release: (version, date) => (date ? `v${version} (${date})` : `v${version}`),
121 updateRequired: '업데이트 필요',
122 loading: '불러오는 중…',
123 closeButton: '닫기',
124 clickHint: '클릭은 전체 화면 모드(/tui fullscreen)에서만 창에 전달됩니다. 지금은 ctrl+x tab으로 창에 포커스를 준 뒤 Tab이나 화살표로 이동하고 Enter로 누르세요.',
125 backButton: '← 돌아가기',
126 cancel: '취소',
127 openedQuick: '툴박스를 열었습니다.',
128 openedSettings: '툴박스 설정을 열었습니다.',
129 openedAdd: '툴박스 추가 탭을 열었습니다.',
130 tabTools: '도구',
131 tabAdd: '추가',
132 settingsButton: '⚙ 설정',
133 quickButton: '▶ 빠른 실행',
134 quickEmpty: '등록한 도구가 없습니다. 설정에서 이 프로젝트의 빌드 파일, Claude 명령, 직접 입력으로 추가할 수 있습니다.',
135 toolsTitle: '도구',
136 toolsDetail: path => path,
137 toolsEmpty: '등록한 도구가 없습니다.',
138 toolsEmptyHint: '추가 탭에서 이 프로젝트의 빌드 작업과 Claude 명령을 찾거나 직접 명령을 입력할 수 있습니다.',
139 toolsProblem: problems => `이름, 종류, 명령 중 하나가 없어 .toolbox/toolbox.json에서 뺀 항목: ${problems}`,
140 toolsUnreadable: reason => `.toolbox/toolbox.json을 읽지 못했습니다: ${reason}`,
141 kindTitle: { shell: '셸', claude: 'Claude 명령', prompt: '프롬프트' },
142 state: { running: '실행 중', done: '완료', failed: '실패', stopped: '중단', queued: 'Claude 대기 중' },
143 exitCode: code => `종료 코드 ${code}`,
144 asked: '실행할 때마다 물음',
145 asksFirst: '실행 전 확인',
146 pressToRun: '눌러서 실행',
147 took: time => `${time} 걸림`,
148 ago: time => `${time} 전`,
149 quickHint: '타일을 누르면 실행합니다. 실행한 적이 있는 셸 도구는 로그를 열고, ▶로 다시 실행합니다.',
150 summaryRunning: count => `실행 중 ${count}`,
151 summaryWaiting: count => `Claude 대기 ${count}`,
152 summaryFailed: count => `실패 ${count}`,
153 finishedToast: (name, time) => `${name}이(가) ${time} 만에 끝났습니다.`,
154 failedToast: (name, code) => `${name}이(가) 실패했습니다${code}. 로그는 툴박스에서 볼 수 있습니다.`,
155 logFromFile: '로그 파일에 남은 마지막 실행입니다. 실행 상태는 남아 있지 않습니다.',
156 logNoRun: '아직 실행하지 않았습니다.',
157 againButton: '▶ 다시 실행',
158 askPreview: command => `실행할 명령: ${command}`,
159 kindIdle: { shell: '셸', claude: 'Claude 명령', prompt: '프롬프트' },
160 fieldConfirm: '실행하기 전에',
161 confirmRunNow: '바로 실행',
162 confirmAskFirst: '먼저 묻기',
163 confirmTitle: name => `${name}을(를) 실행할까요?`,
164 confirmHint: '되돌릴 수 없는 명령이라 실행할 때마다 확인합니다.',
165 addTitle: '도구 추가',
166 addSearch: '이름이나 명령으로 찾기',
167 addDetected: '이 프로젝트에서 찾은 작업',
168 addDetectedNone: '빌드 파일이 없습니다: package.json, pom.xml, build.gradle, Cargo.toml, Makefile, justfile, compose.yaml, go.mod, pyproject.toml',
169 addGradleAll: 'Gradle 작업 전부 불러오기',
170 addUnreadable: files => `${files} 파일을 읽지 못해 그 파일의 작업은 표시하지 않습니다. JSON 형식을 고치면 다시 표시합니다.`,
171 addClaude: 'Claude 명령',
172 addCustom: '직접 입력',
173 addShell: '+ 셸 명령',
174 addPrompt: '+ 프롬프트',
175 added: name => `${name}을(를) 추가했습니다.`,
176 editTitle: name => `도구: ${name}`,
177 newTitle: '새 도구',
178 fieldName: '이름',
179 fieldRun: {
180 shell: '명령. {{file}}처럼 {{ }} 안에 쓴 단어는 실행할 때마다 묻습니다',
181 claude: '슬래시 명령과 인수. {{ }} 안에 쓴 단어는 실행할 때마다 묻습니다',
182 prompt: '프롬프트. {{ }} 안에 쓴 단어는 실행할 때마다 묻습니다',
183 },
184 fieldCwd: '실행할 폴더(프로젝트 기준)',
185 fieldSubmit: '실행할 때',
186 submitFill: '입력창에 채우기',
187 submitSend: '바로 보내기',
188 paramType: name => `{{${name}}}: 종류`,
189 paramMode: name => `{{${name}}}: 값`,
190 paramValue: (name, mode) => `{{${name}}}: ${mode === 'fixed' ? '값' : '먼저 보여 줄 값'}`,
191 paramChoices: name => `{{${name}}}: 선택지(쉼표로 구분)`,
192 paramGlob: name => `{{${name}}}: 보여 줄 이름(예: *.ts)`,
193 typeText: '텍스트',
194 typePath: '경로',
195 typeChoice: '선택',
196 modeAsk: '실행할 때마다 묻기',
197 modeFixed: '고정',
198 save: '저장',
199 saved: name => `${name}을(를) 저장했습니다.`,
200 nameMissing: '도구에는 이름과 명령이 필요합니다.',
201 askTitle: name => `${name} 실행`,
202 runButton: '실행',
203 notChoice: name => `{{${name}}} 값은 선택지 중 하나여야 합니다.`,
204 noInput: '값을 묻는 도구는 입력란이 필요합니다. 터미널이나 데스크톱 앱에서 실행해 주세요.',
205 removeTitle: name => `${name}을(를) 삭제할까요?`,
206 removeHint: '.toolbox/toolbox.json에서 지웁니다. 로그는 남습니다.',
207 removeConfirm: '삭제',
208 removed: name => `${name}을(를) 삭제했습니다.`,
209 restartTitle: name => `${name}이(가) 실행 중입니다`,
210 restartHint: '다시 실행하면 지금 실행 중인 것을 먼저 중단합니다.',
211 restartConfirm: '다시 실행',
212 started: name => `${name}을(를) 시작했습니다.`,
213 stoppedToast: name => `${name}을(를) 중단했습니다.`,
214 queuedToast: name => `${name}은(는) Claude가 응답을 마치면 실행됩니다.`,
215 alreadyQueued: name => `${name}은(는) 이미 실행 대기 중입니다. Claude가 응답을 마치면 실행됩니다.`,
216 filled: '입력창에 채웠습니다.',
217 sent: '보냈습니다.',
218 logTitle: name => `로그: ${name}`,
219 logStatus: (state, command) => `${state} · ${command}`,
220 logEmpty: '아직 출력이 없습니다.',
221 logOlder: '▲ 이전',
222 logNewer: '▼ 다음',
223 logFollow: '따라가기',
224 logFollowing: '따라가는 중',
225 stopButton: '■ 중단',
226}
227
228export function messagesFor(locale: Locale): Messages {
229 return locale === 'ko' ? ko : en
230}
231hooks/paths.ts 190 lines1/**
2 * Path suggestions as a person types, and the output of a run as the log
3 * dialog draws it. Pure: the hooks module lists folders and feeds output in.
4 */
5import { printable } from './shared/layout'
6
7/** A `*`/`?` glob as a test on a file name; every name passes an empty glob. */
8export function globTest(glob: string | undefined): (name: string) => boolean {
9 if (!glob) return () => true
10 const pattern = new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`)
11
12 return name => pattern.test(name)
13}
14
15/** The folder to list for what was typed, and the start of the name being typed in it. */
16export function splitTyped(typed: string): { folder: string; prefix: string } {
17 const slash = typed.lastIndexOf('/')
18
19 return slash === -1 ? { folder: '', prefix: typed } : { folder: typed.slice(0, slash), prefix: typed.slice(slash + 1) }
20}
21
22/** Names never offered: version control and dependency folders, which nobody means to pick. */
23const SKIPPED = new Set(['.git', 'node_modules', 'target', '.gradle', '.idea', '.DS_Store'])
24
25/**
26 * The paths to offer for what was typed, from the folder's entries: those
27 * starting with the name typed (ignoring case), folders with a `/` after them
28 * so a pick goes on into them, files only when files may be picked and only
29 * those matching the glob; folders first, then files, by name, at most `limit`.
30 */
31export function pathSuggestions(
32 typed: string,
33 entries: { name: string; kind: 'file' | 'dir' | 'other' }[],
34 options: { pathKind?: 'file' | 'dir' | 'any'; glob?: string; limit?: number } = {},
35): string[] {
36 const { folder, prefix } = splitTyped(typed)
37 const matches = globTest(options.glob)
38 const start = prefix.toLowerCase()
39 const base = folder === '' ? '' : `${folder}/`
40 const shown = entries.filter(entry => !SKIPPED.has(entry.name) && entry.name.toLowerCase().startsWith(start) && (start.startsWith('.') || !entry.name.startsWith('.')))
41 const dirs = shown.filter(entry => entry.kind === 'dir').map(entry => `${base}${entry.name}/`)
42 const files = options.pathKind === 'dir' ? [] : shown.filter(entry => entry.kind === 'file' && matches(entry.name)).map(entry => `${base}${entry.name}`)
43
44 return [...dirs.sort(), ...files.sort()].slice(0, options.limit ?? 8)
45}
46
47/** Lines of a run's output kept to draw: the log file holds every one. */
48export const LOG_LINES_KEPT = 2_000
49
50/** The most a run's log file keeps, in UTF-8 bytes: its newest part, well under the 4 MiB a write takes. */
51export const LOG_FILE_BYTES = 3_000_000
52
53/** The bytes a string takes in UTF-8. */
54function utf8Size(text: string): number {
55 let size = 0
56 for (let index = 0; index < text.length; index += 1) {
57 const code = text.charCodeAt(index)
58 if (code < 0x80) size += 1
59 else if (code < 0x800) size += 2
60 else if (code >= 0xd800 && code <= 0xdbff) {
61 size += 4
62 index += 1
63 } else size += 3
64 }
65
66 return size
67}
68
69/**
70 * The newest part of a log's text that fits `bytes` in UTF-8, from the start
71 * of a line. Output in Korean takes three bytes a character, so a cut counted
72 * in characters passes the engine's 4 MiB write limit, and the file is then
73 * written no more.
74 */
75export function newestPart(text: string, bytes: number): string {
76 if (utf8Size(text) <= bytes) return text
77 let start = text.length
78 let size = 0
79 while (start > 0) {
80 const from = text.lastIndexOf('\n', start - 2) + 1
81 const lineSize = utf8Size(text.slice(from, start))
82 if (size + lineSize > bytes) break
83 size += lineSize
84 start = from
85 }
86
87 return text.slice(start)
88}
89
90/**
91 * A line as a terminal shows it once a program has rewritten it with carriage
92 * returns (a progress bar): its newest form, the text after the last return
93 * that has any.
94 */
95export function rewritten(line: string): string {
96 const forms = line.split('\r')
97 for (let index = forms.length - 1; index >= 0; index -= 1) if (forms[index] !== '') return forms[index] ?? ''
98
99 return ''
100}
101
102/**
103 * A run's output as it arrives, kept as whole lines: a piece that ends mid-line
104 * waits for the rest. Each whole line is kept as a terminal would show it, its
105 * escape sequences and control characters removed (cleaned whole, so a
106 * sequence cut between two pieces leaves nothing behind). The newest
107 * `LOG_LINES_KEPT` lines are kept to draw, and every line goes to `pending`
108 * for the log file.
109 */
110export class LogBuffer {
111 lines: string[] = []
112 pending = ''
113 private partial = ''
114
115 /** Takes a piece of output as it came, in any size. */
116 push(text: string): void {
117 const parts = (this.partial + text).replace(/\r\n/g, '\n').split('\n')
118 const rest = parts.pop() ?? ''
119 // A line rewritten in place keeps only its newest form; a return at the end may be half of CRLF.
120 this.partial = rest.endsWith('\r') ? `${rewritten(rest)}\r` : rewritten(rest)
121 if (parts.length === 0) return
122 const whole = parts.map(line => printable(rewritten(line)))
123 this.lines.push(...whole)
124 if (this.lines.length > LOG_LINES_KEPT) this.lines.splice(0, this.lines.length - LOG_LINES_KEPT)
125 this.pending += `${whole.join('\n')}\n`
126 }
127
128 /** The line being written now, in its newest form: a progress bar before its newline comes. */
129 get current(): string {
130 return printable(rewritten(this.partial))
131 }
132
133 /** The line still being written, once the run has ended. */
134 end(): void {
135 if (this.partial === '') return
136 this.push('\n')
137 }
138
139 /** The lines written to the log file since it was last written, and none after. */
140 takePending(): string {
141 const text = this.pending
142 this.pending = ''
143
144 return text
145 }
146
147 /**
148 * The lines to draw ending at `last` (the newest when absent), as many as fit
149 * `limit` characters and `lines` lines. Ending at the newest, the line being
150 * written now is drawn below them, so a progress bar shows as it moves.
151 */
152 window(limit: number, last?: number, lines = Infinity): { text: string; first: number; last: number } {
153 const end = Math.min(this.lines.length, last ?? this.lines.length)
154 const live = last === undefined || last >= this.lines.length ? this.current : ''
155 let start = end
156 let size = live === '' ? 0 : live.length + 1
157 const room = live === '' ? lines : lines - 1
158 while (start > 0 && end - start < room) {
159 const line = this.lines[start - 1] ?? ''
160 if (size + line.length + 1 > limit) break
161 size += line.length + 1
162 start -= 1
163 }
164
165 return { text: [...this.lines.slice(start, end), ...(live === '' ? [] : [live])].join('\n'), first: start, last: end }
166 }
167}
168
169/** A share done in a line of output: `45%`, or `3/10` and `[3/10]` read as a count of a total. */
170const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/g
171const COUNT = /(?<![\w/.])(\d+)\s?\/\s?(\d+)(?![\w/.])/g
172
173/**
174 * How far a run has got, 0 to 100, from its newest lines that say so: the
175 * last percentage or count of a total in the newest such line among the last
176 * `lookBack`. Undefined when none says.
177 */
178export function progressOf(lines: readonly string[], lookBack = 5): number | undefined {
179 for (let index = lines.length - 1; index >= Math.max(0, lines.length - lookBack); index -= 1) {
180 const line = lines[index] ?? ''
181 const percents = [...line.matchAll(PERCENT)].map(match => Number(match[1])).filter(value => value <= 100)
182 if (percents.length > 0) return percents[percents.length - 1]
183 const counts = [...line.matchAll(COUNT)].map(match => [Number(match[1]), Number(match[2])] as const).filter(([done, total]) => total > 0 && done <= total)
184 const count = counts[counts.length - 1]
185 if (count) return Math.round((count[0] / count[1]) * 100)
186 }
187
188 return undefined
189}
190hooks/shared/release.ts 102 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/**
3 * Whether a newer release than the one running has been published: the
4 * latest release of the repository the plugin's manifest names, asked of
5 * GitHub at most every few hours and kept in the plugin's store, so every
6 * session of the machine shares one answer.
7 */
8
9/** How long a latest-release answer is trusted before GitHub is asked again. */
10export const RELEASE_CHECK_MS = 6 * 60 * 60 * 1000
11
12/** The `$.store` key of the latest release last heard of. */
13export const LATEST_RELEASE_KEY = 'latestRelease'
14
15/** The latest release as last heard of: its version, and when GitHub was asked. */
16export type LatestRelease = { version: string | null; checkedAt: number }
17
18/** What asking for the latest release needs: the store, the clock and a fetch, as the hooks module hands them over. */
19export type ReleaseIo = {
20 now: () => Promise<number>
21 get: () => Promise<unknown>
22 set: (value: LatestRelease) => Promise<void>
23 fetch: (url: string, init: { headers: Record<string, string> }) => Promise<{ ok: boolean; status: number; text: string }>
24}
25
26/** GitHub's latest-release endpoint for a repository URL such as `https://github.com/<owner>/<repo>`; null for any other host. */
27export function latestReleaseUrl(repository: unknown): string | null {
28 if (typeof repository !== 'string') return null
29 const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(repository.trim())
30
31 return match ? `https://api.github.com/repos/${match[1]}/${match[2]}/releases/latest` : null
32}
33
34/** The version a latest-release answer names: its tag without the `v`; null when it names none. */
35export function versionOfRelease(text: string): string | null {
36 try {
37 const tag = (JSON.parse(text) as { tag_name?: unknown }).tag_name
38 if (typeof tag !== 'string') return null
39 const version = tag.trim().replace(/^v/, '')
40
41 return /^\d+\.\d+\.\d+$/.test(version) ? version : null
42 } catch (error) {
43 if (error instanceof SyntaxError) return null
44 throw error
45 }
46}
47
48/** Whether `latest` is a later version than `current`, both `major.minor.patch`. */
49export function isNewerVersion(latest: string, current: string): boolean {
50 const parts = (version: string) => version.split('.').map(part => Number.parseInt(part, 10))
51 const [a, b] = [parts(latest), parts(current)]
52 for (let index = 0; index < 3; index += 1) {
53 const [x = 0, y = 0] = [a[index], b[index]]
54 if (x !== y) return x > y
55 }
56
57 return false
58}
59
60function asLatest(value: unknown): LatestRelease | null {
61 const kept = value as Partial<LatestRelease> | undefined
62 if (typeof kept?.checkedAt !== 'number') return null
63
64 return { version: typeof kept.version === 'string' ? kept.version : null, checkedAt: kept.checkedAt }
65}
66
67/**
68 * The latest release's version: the one kept while it is recent, else asked
69 * of GitHub and kept. A failed request keeps what was heard before and is
70 * asked again at the next check; null when nothing was ever heard.
71 */
72export async function latestVersion(io: ReleaseIo, url: string): Promise<string | null> {
73 const now = await io.now()
74 const kept = asLatest(await io.get())
75 if (kept && now - kept.checkedAt < RELEASE_CHECK_MS) return kept.version
76 const response = await io.fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'claude-mods' } })
77 if (!response.ok) return kept?.version ?? null
78 const version = versionOfRelease(response.text)
79 await io.set({ version, checkedAt: now })
80
81 return version
82}
83
84/** The running release as the hooks module reads it from its own files, and the latest heard of. */
85export type RunningRelease = { version?: string; date?: string; repository?: string; latest?: string | null }
86
87/**
88 * What the pane header shows of the release: `v0.6.3 (2026-10-08)`, or, once a
89 * later release is published, `Update Required` in place of the date, marked
90 * for the header to draw in the warning colour.
91 */
92export function releaseHeader(
93 release: RunningRelease,
94 label: (version: string, date?: string) => string,
95 updateRequired: string,
96): { release: string | undefined; isReleaseOutdated: boolean } {
97 if (!release.version) return { release: undefined, isReleaseOutdated: false }
98 const isReleaseOutdated = typeof release.latest === 'string' && isNewerVersion(release.latest, release.version)
99
100 return { release: label(release.version, isReleaseOutdated ? updateRequired : release.date), isReleaseOutdated }
101}
102hooks/shared/panes.ts 13 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/** The mods whose panes open side by side; each keeps `paneOpen` in its state while its pane is open. */
3export type ModPane = 'sc-accounts' | 'sc-workspace' | 'sc-toolbox'
4
5/**
6 * Whether another mod's pane is open beside this one, from each mod's
7 * `paneOpen`, read with `$.state.get` while drawing: the engine then draws its tab row above the pane, and the
8 * header keeps a row apart from it.
9 */
10export function isBesideOtherPanes(self: ModPane, open: Record<ModPane, boolean>): boolean {
11 return (Object.keys(open) as ModPane[]).some(plugin => plugin !== self && open[plugin])
12}
13hooks/shared/kit.tsx 1111 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2import type { ElementTable } from 'claude-code'
3
4import { barParts, displayWidth, padCells, printable, severityColor, truncate } from './layout'
5
6/**
7 * The pieces every tab of the pane is built from, so the tabs look like one
8 * screen: one header, one tab bar, one card, one gauge, one button language.
9 * Each takes the surface's element table (`$.ui.resolve(e)`), never `$`, and
10 * draws every string it is given through `printable`, so text from outside
11 * holding a control character never refuses the pane.
12 */
13
14export const theme = {
15 accent: 'cyan',
16 danger: 'red',
17 warn: 'yellow',
18 /** Between warn and danger: a weekly reset two days out. */
19 caution: '#ff8c1a',
20 ok: 'green',
21 track: 'gray',
22 /** The footer tiles' fill, the main tile's, and either under the pointer. */
23 tile: '#2a2f38',
24 tileMain: '#1f4650',
25 tileHover: '#3a4250',
26 /** The selected tab's fill. */
27 tabActive: '#2f3a46',
28 stale: 'yellow',
29 /** An on/off switch's segments: on when current, off when current, and the one not current. */
30 switchOn: '#1f7a3a',
31 switchOffActive: '#5a606b',
32 switchOff: '#2a2f38',
33 /** Dark grounds behind glyph buttons, one per meaning, so a button reads at rest. */
34 glyphAccent: '#1d5566',
35 glyphDanger: '#6b2a31',
36 glyphWarn: '#6b5719',
37 glyphOk: '#28603a',
38}
39
40/** The ground behind a glyph button of each tone; any other tone takes the neutral fill. */
41const GLYPH_GROUND: Record<string, string> = {
42 [theme.accent]: theme.glyphAccent,
43 [theme.danger]: theme.glyphDanger,
44 [theme.warn]: theme.glyphWarn,
45 [theme.ok]: theme.glyphOk,
46}
47
48/** Cells a gauge spans. */
49export const GAUGE_WIDTH = 6
50/** Cells between two cells of a row of gauges. */
51export const CELL_GAP = 3
52/** Cells a card's border and horizontal padding take across. */
53export const CARD_CHROME = 4
54/** Cells between two footer tiles. */
55const TILE_GAP = 1
56
57export type Tile = { key: string; label: string; isMain?: boolean; isDismiss?: boolean; isFocused?: boolean; onPress: () => void }
58export type TabSpec = { key: string; label: string; badge?: string; hotkey: string }
59
60/** A pane's name in its header: the product's short mark, then the mod's name, as `[SC] Workspace`. */
61export function paneTitle(mod: string): string {
62 return `[SC] ${mod}`
63}
64
65/**
66 * What heads a pane: its name, its release, whether the engine draws its tab
67 * row right above (another pane is open beside it), which the header keeps a
68 * row apart from, and the way out: `exit` closes the pane, and a dialog puts
69 * its own Cancel there as `backLabel`. `columns` is the width it has.
70 */
71export type HeaderInfo = {
72 brand: string
73 release: string | undefined
74 /** A later release is published: the release is drawn in the warning colour, not dim. */
75 isReleaseOutdated?: boolean
76 isUnderTabs: boolean
77 columns?: number
78 exit?: { label: string; onPress: () => void }
79 backLabel?: string
80}
81
82/**
83 * A pane's first row: its name at the left and the way out at the right, a
84 * filled button that is the first thing scrolled into view however short the
85 * terminal is. The release sits before it while the row has room for it,
86 * in the warning colour once a later release is published.
87 */
88export function Header(ui: ElementTable, header: HeaderInfo) {
89 const { Box, Button, Text } = ui
90 const brand = printable(header.brand)
91 const release = header.release === undefined ? undefined : printable(header.release)
92 const exit = header.exit ? { ...header.exit, label: printable(header.exit.label) } : undefined
93 const exitWidth = exit ? displayWidth(exit.label) + 2 : 0
94 const room = (header.columns ?? 80) - displayWidth(brand) - exitWidth - 2
95 const isReleaseShown = release !== undefined && displayWidth(release) <= room
96
97 return (
98 <Box key="header" justifyContent="space-between" marginTop={header.isUnderTabs ? 1 : 0}>
99 <Text bold wrap="truncate-end">
100 {brand}
101 </Text>
102 <Box gap={2} flexShrink={0}>
103 {isReleaseShown && (header.isReleaseOutdated ? <Text color={theme.warn}>{release}</Text> : <Text dimColor>{release}</Text>)}
104 {exit && (
105 <Box key="header-exit-ground" paddingX={1} flexShrink={0} backgroundColor={theme.tile} hover={{ backgroundColor: theme.tileHover }}>
106 <Button key="close" label={exit.label} plain role="dismiss" onPress={exit.onPress} />
107 </Box>
108 )}
109 </Box>
110 </Box>
111 )
112}
113
114/** The header a dialog draws: the pane's, its way out being the dialog's own Cancel. */
115export function dialogHeader(header: HeaderInfo, cancel: () => void): HeaderInfo {
116 return { ...header, exit: { label: header.backLabel ?? '←', onPress: cancel } }
117}
118
119/** The tab bar: the selected tab filled, the others dim; a digit presses each. */
120export function TabBar(ui: ElementTable, tabs: TabSpec[], active: string, onSelect: (key: string) => void) {
121 const { Box, Button, Text } = ui
122
123 return (
124 // A tab never wraps inside: on a narrow pane the next tab moves to a new row.
125 // Right under the header: on a short terminal every row the chrome keeps is a row of the tab's body.
126 <Box key="tabs" columnGap={1} flexWrap="wrap">
127 {tabs.map(tab => {
128 const isActive = tab.key === active
129
130 return (
131 <Box
132 key={`tab-${tab.key}`}
133 paddingX={1}
134 flexShrink={0}
135 backgroundColor={isActive ? theme.tabActive : theme.switchOff}
136 hover={{ backgroundColor: theme.tabActive }}
137 >
138 <Button
139 key={`tab-button-${tab.key}`}
140 label={printable(tab.label)}
141 hotkey={tab.hotkey}
142 plain
143 {...(isActive ? {} : { dimColor: true })}
144 onPress={() => onSelect(tab.key)}
145 />
146 {tab.badge && <Text color={isActive ? theme.accent : undefined} dimColor={!isActive}>{` ${printable(tab.badge)}`}</Text>}
147 </Box>
148 )
149 })}
150 </Box>
151 )
152}
153
154/** A dim rule across `width` cells, under the tab bar. */
155export function Rule(ui: ElementTable, key: string, width: number) {
156 const { Text } = ui
157
158 return (
159 <Text key={key} dimColor>
160 {'─'.repeat(Math.max(1, width))}
161 </Text>
162 )
163}
164
165/** A rounded card; the accented one (the account in use, a running agent) in the accent colour. */
166export function Card(ui: ElementTable, key: string, isAccent: boolean, children: unknown) {
167 const { Box } = ui
168
169 return (
170 <Box
171 key={key}
172 flexDirection="column"
173 borderStyle="round"
174 borderColor={isAccent ? theme.accent : undefined}
175 borderDimColor={!isAccent}
176 paddingX={1}
177 >
178 {children as never}
179 </Box>
180 )
181}
182
183/** What a piece of text means, which sets its colour. */
184export type Tone = 'accent' | 'ok' | 'danger' | 'warn' | 'caution' | 'stale'
185
186/**
187 * Text in the colour of what it means: current or selected `accent`, added or
188 * done `ok`, removed or failed `danger`, changed or waiting `warn`, an old
189 * reading `stale`; no tone for plain text. Drawn inside another Text or alone.
190 */
191export function Toned(
192 ui: ElementTable,
193 key: string,
194 text: string,
195 tone?: Tone,
196 style: { isDim?: boolean; isBold?: boolean; wrap?: 'wrap' | 'truncate-end' } = {},
197) {
198 const { Text } = ui
199
200 return (
201 <Text key={key} color={tone ? theme[tone] : undefined} dimColor={style.isDim === true} bold={style.isBold === true} {...(style.wrap ? { wrap: style.wrap } : {})}>
202 {printable(text)}
203 </Text>
204 )
205}
206
207/**
208 * Lines added and removed, `+12 −3`, in green and red. With widths, each
209 * figure is padded at the start to its column, so rows line up.
210 */
211export function ChangeCounts(ui: ElementTable, key: string, added: number, removed: number, widths: { added: number; removed: number } = { added: 0, removed: 0 }) {
212 const { Text } = ui
213
214 return (
215 <Text key={key}>
216 {Toned(ui, `${key}-added`, `+${added}`.padStart(widths.added), 'ok')}
217 <Text> </Text>
218 {Toned(ui, `${key}-removed`, `−${removed}`.padStart(widths.removed), 'danger')}
219 </Text>
220 )
221}
222
223/** A bar of `width` cells: added cells green, removed cells red, the rest a dim dotted track. */
224export function ChangeBar(ui: ElementTable, key: string, cells: { added: number; removed: number }, width: number) {
225 const { Text } = ui
226
227 return (
228 <Text key={key}>
229 {Toned(ui, `${key}-added`, '■'.repeat(cells.added), 'ok')}
230 {Toned(ui, `${key}-removed`, '■'.repeat(cells.removed), 'danger')}
231 {Toned(ui, `${key}-rest`, '·'.repeat(Math.max(0, width - cells.added - cells.removed)), undefined, { isDim: true })}
232 </Text>
233 )
234}
235
236/**
237 * The frame around a text input: round, in the accent colour where it is
238 * the place to type, dim otherwise; an optional glyph before the input. It
239 * takes the whole width it is given, in a row or a column alike.
240 */
241export function InputFrame(ui: ElementTable, key: string, isAccent: boolean, input: unknown, glyph?: string) {
242 const { Box, Text } = ui
243
244 return (
245 <Box
246 key={key}
247 borderStyle="round"
248 borderColor={isAccent ? theme.accent : 'gray'}
249 borderDimColor={!isAccent}
250 paddingX={1}
251 gap={1}
252 flexGrow={1}
253 >
254 {glyph && <Text color={isAccent ? theme.accent : undefined}>{printable(glyph)}</Text>}
255 {input as never}
256 </Box>
257 )
258}
259
260/** A word that is pressed: plain at rest, accent and bold under the pointer. */
261export function LinkButton(ui: ElementTable, key: string, label: string, onPress: () => void, extra: { autoFocus?: boolean } = {}) {
262 const { Button } = ui
263
264 return <Button key={key} label={printable(label)} plain hover={{ color: theme.accent, bold: true }} {...(extra.autoFocus ? { autoFocus: true as const } : {})} onPress={onPress} />
265}
266
267/** A section heading: a title, and a dim detail after it. */
268export function Section(ui: ElementTable, key: string, title: string, detail?: string) {
269 const { Text } = ui
270
271 return (
272 <Text key={key}>
273 <Text bold>{printable(title)}</Text>
274 {detail && <Text dimColor>{` ${printable(detail)}`}</Text>}
275 </Text>
276 )
277}
278
279/** The tone of a weekly window's label and reset as its reset nears: yellow three days out, orange two, red on the day. */
280export function countdownTone(day: 1 | 2 | 3 | undefined): Tone | undefined {
281 return day === 1 ? 'danger' : day === 2 ? 'caution' : day === 3 ? 'warn' : undefined
282}
283
284/** A thin gauge: its label (dim, or bold in `labelTone`), the used part coloured by severity over a dim track, the percentage. */
285export function Gauge(ui: ElementTable, key: string, label: string, percent: number, color = severityColor(percent), labelTone?: Tone) {
286 const { Text } = ui
287 const { filled, rest } = barParts(percent, GAUGE_WIDTH)
288
289 return (
290 <Text key={key}>
291 {labelTone ? <Text color={theme[labelTone]} bold>{`${printable(label)} `}</Text> : <Text dimColor>{`${printable(label)} `}</Text>}
292 <Text color={color}>{filled}</Text>
293 <Text color={theme.track} dimColor>
294 {rest}
295 </Text>
296 <Text>{` ${Math.round(percent)}%`}</Text>
297 </Text>
298 )
299}
300
301/**
302 * A glyph button: the glyph at full strength on a dark ground of its tone's
303 * colour (red to delete or stop, yellow to restore or pin, cyan to compare or
304 * open, green to finish), so what it does reads at rest; the glyph takes the
305 * tone under the pointer. A switch that is off (an unpinned star) sits on the
306 * neutral fill. One cell wide, as the glyph alone.
307 */
308export function IconButton(ui: ElementTable, key: string, glyph: string, tone: string, onPress: () => void, isOff = false) {
309 const { Box, Button } = ui
310 const ground = isOff ? theme.tile : (GLYPH_GROUND[tone] ?? theme.tile)
311
312 return (
313 <Box key={`${key}-ground`} flexShrink={0} backgroundColor={ground} hover={{ backgroundColor: theme.tileHover }}>
314 <Button key={key} label={printable(glyph)} plain hover={{ color: tone, bold: true }} onPress={onPress} />
315 </Box>
316 )
317}
318
319/**
320 * A small filled button for a row's main action: its label on the main
321 * tile's tint, one cell of padding each side, lighter under the pointer.
322 */
323export function TileButton(ui: ElementTable, key: string, label: string, onPress: () => void) {
324 const { Box, Button } = ui
325
326 return (
327 <Box key={`${key}-tile`} paddingX={1} backgroundColor={theme.tileMain} hover={{ backgroundColor: theme.tileHover }}>
328 <Button key={key} label={printable(label)} plain onPress={onPress} />
329 </Box>
330 )
331}
332
333/**
334 * A value that opens a choice dialog when pressed, drawn as a select: the
335 * value and `▾` on the unselected tabs' fill, one cell of padding each side,
336 * lighter under the pointer. It never shrinks; the row it sits in gives way.
337 */
338export function SelectField(ui: ElementTable, key: string, value: string, onPress: () => void) {
339 const { Box } = ui
340
341 return (
342 <Box key={`${key}-field`} paddingX={1} flexShrink={0} backgroundColor={theme.switchOff} hover={{ backgroundColor: theme.tabActive }}>
343 {LinkButton(ui, key, `${value} ▾`, onPress)}
344 </Box>
345 )
346}
347
348/**
349 * A dim second line under a row, indented under its text and cut to one
350 * line: what a row did last, or a fact that does not fit beside it.
351 */
352export function SubLine(ui: ElementTable, key: string, text: string) {
353 const { Box, Text } = ui
354
355 return (
356 <Box key={key} paddingLeft={2}>
357 <Text dimColor wrap="truncate-end">
358 {`↳ ${printable(text).replace(/\s+/g, ' ').trim()}`}
359 </Text>
360 </Box>
361 )
362}
363
364/**
365 * Figures in a row: each value bold with its name dim after it, the row
366 * wrapping whole figures to the next line where the width runs out.
367 */
368export function StatRow(ui: ElementTable, key: string, items: { label: string; value: string }[]) {
369 const { Box, Text } = ui
370
371 return (
372 <Box key={key} columnGap={3} flexWrap="wrap">
373 {items.map(item => (
374 <Text key={`${key}-${item.label}`}>
375 <Text bold>{printable(item.value)}</Text>
376 <Text dimColor>{` ${printable(item.label)}`}</Text>
377 </Text>
378 ))}
379 </Box>
380 )
381}
382
383/** Eighths of a cell, from empty to full, for the bars of a chart. */
384const BAR_EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
385
386/**
387 * Vertical bars `height` rows tall, one per value, in the accent colour, drawn
388 * with eighth blocks so a small value still shows; each bar is as wide as
389 * `width` allows (one or two cells, a cell between bars), with its label
390 * under it when the bar is two cells wide and on every fifth bar otherwise.
391 * The largest value is named above the bars.
392 */
393export function BarChart(ui: ElementTable, key: string, bars: { label: string; value: number }[], width: number, height: number, largestText: string) {
394 const { Box, Text } = ui
395 const largest = Math.max(1, ...bars.map(bar => bar.value))
396 const barWidth = bars.length * 3 - 1 <= width ? 2 : 1
397 const eighths = bars.map(bar => (bar.value > 0 ? Math.max(1, Math.round((bar.value / largest) * height * 8)) : 0))
398 const rows = Array.from({ length: height }, (_, index) => height - 1 - index)
399 const cell = (filled: number, row: number) => BAR_EIGHTHS[Math.max(0, Math.min(8, filled - row * 8))] ?? ' '
400
401 return (
402 <Box key={key} flexDirection="column">
403 <Text dimColor>{printable(largestText)}</Text>
404 {rows.map(row => (
405 <Text key={`${key}-row-${row}`} color={theme.accent}>
406 {eighths.map(filled => cell(filled, row).repeat(barWidth)).join(' ')}
407 </Text>
408 ))}
409 <Text key={`${key}-labels`} dimColor>
410 {barWidth === 2
411 ? bars.map(bar => printable(bar.label).slice(-2).padStart(2)).join(' ')
412 : // One-cell bars sit two cells apart: a two-character label fits under every fifth.
413 bars.reduce((line, bar, index) => (index % 5 === 0 ? padCells(line, index * 2) + printable(bar.label).slice(-2) : line), '')}
414 </Text>
415 </Box>
416 )
417}
418
419/**
420 * A ranking, one row each: the name, a dim detail after it, and the value at
421 * the right edge. The name keeps its whole width up to half the row; the
422 * detail takes what is left and is cut first.
423 */
424export function RankList(ui: ElementTable, key: string, rows: { name: string; detail: string; value: string; onPress?: () => void }[], width: number) {
425 const { Box, Text } = ui
426 const cleaned = rows.map(row => ({ ...row, name: printable(row.name), detail: printable(row.detail), value: printable(row.value) }))
427 const valueWidth = Math.max(0, ...cleaned.map(row => displayWidth(row.value)))
428
429 return (
430 <Box key={key} flexDirection="column">
431 {cleaned.map((row, index) => {
432 const free = Math.max(12, width - valueWidth - 4)
433 const room = Math.min(displayWidth(row.name), Math.max(Math.floor(free / 2), free - displayWidth(row.detail)))
434 const detail = truncate(row.detail, Math.max(0, free - room))
435
436 // Rows are keyed by place: two rows may share a name (one line found in two files).
437 return (
438 <Box key={`${key}-${index}`} justifyContent="space-between">
439 {/* A row that opens something has its name as the button. */}
440 {row.onPress ? (
441 <Box gap={2} flexShrink={1}>
442 {LinkButton(ui, `${key}-${index}-open`, truncate(row.name, room), row.onPress)}
443 <Text dimColor wrap="truncate-end">
444 {detail}
445 </Text>
446 </Box>
447 ) : (
448 <Text wrap="truncate-end">
449 <Text>{truncate(row.name, room)}</Text>
450 <Text dimColor>{detail === '' ? '' : ` ${detail}`}</Text>
451 </Text>
452 )}
453 <Box flexShrink={0} marginLeft={1}>
454 <Text bold>{padCells(row.value, valueWidth, 'start')}</Text>
455 </Box>
456 </Box>
457 )
458 })}
459 </Box>
460 )
461}
462
463/**
464 * A file's outline under its row: each entry's line number dim in one
465 * column, then its text indented by its depth, every entry one line.
466 */
467export function OutlineList(ui: ElementTable, key: string, entries: { line: number; text: string; level: number }[], width: number) {
468 const { Box, Text } = ui
469 const numberWidth = Math.max(0, ...entries.map(entry => String(entry.line).length))
470
471 return (
472 <Box key={key} flexDirection="column" paddingLeft={2}>
473 {entries.map(entry => {
474 const indent = ' '.repeat(Math.max(0, entry.level - 1))
475
476 return (
477 <Text key={`${key}-${entry.line}`} wrap="truncate-end">
478 <Text dimColor>{`${String(entry.line).padStart(numberWidth)} `}</Text>
479 <Text>{truncate(`${indent}${printable(entry.text)}`, Math.max(6, width - numberWidth - 4))}</Text>
480 </Text>
481 )
482 })}
483 </Box>
484 )
485}
486
487/** A filled badge such as `active`. */
488export function Badge(ui: ElementTable, key: string, text: string, background = theme.accent) {
489 const { Text } = ui
490
491 return (
492 <Text key={key} color="black" backgroundColor={background}>
493 {` ${printable(text)} `}
494 </Text>
495 )
496}
497
498/** What a tab shows with nothing in it. */
499export function Empty(ui: ElementTable, key: string, lines: string[]) {
500 const { Box, Text } = ui
501
502 return (
503 <Box key={key} flexDirection="column" paddingX={2} paddingY={1}>
504 {lines.map((line, index) => (
505 <Text key={`${key}-${index}`} dimColor={index > 0}>
506 {printable(line)}
507 </Text>
508 ))}
509 </Box>
510 )
511}
512
513/** Cells a tile keeps on each side of its label. */
514const TILE_PADDING = 2
515
516/**
517 * The footer: equal filled tiles across the pane, one row tall, each label
518 * centred on one line. Tiles that would squeeze a label onto two lines move
519 * to a further row instead. The main action's tile carries the accent's tint.
520 */
521export function Tiles(ui: ElementTable, bodyColumns: number, given: Tile[], outline?: { focused: string | null }) {
522 const { Box, Button } = ui
523 if (given.length === 0) return null
524 const tiles = given.map(tile => ({ ...tile, label: printable(tile.label) }))
525 const needed = Math.max(...tiles.map(tile => displayWidth(tile.label))) + TILE_PADDING * 2
526 const perRow = Math.max(1, Math.min(tiles.length, Math.floor((bodyColumns + TILE_GAP) / (needed + TILE_GAP))))
527 const width = Math.max(needed, Math.floor((bodyColumns - TILE_GAP * (perRow - 1)) / perRow))
528 const rows: Tile[][] = []
529 for (let start = 0; start < tiles.length; start += perRow) rows.push(tiles.slice(start, start + perRow))
530
531 return (
532 <Box key="footer" flexDirection="column" marginTop={1} rowGap={1}>
533 {rows.map((row, index) => (
534 <Box key={`footer-row-${index}`} gap={TILE_GAP}>
535 {row.map(tile => (
536 <Box
537 key={`tile-${tile.key}`}
538 width={width}
539 justifyContent="center"
540 alignItems="center"
541 backgroundColor={tile.isMain ? theme.tileMain : theme.tile}
542 hover={{ backgroundColor: theme.tileHover }}
543 // Outlined tiles show where the keyboard is: the focused one in the accent colour.
544 {...(outline
545 ? outline.focused === tile.key
546 ? { borderStyle: 'round', borderColor: theme.accent }
547 : { borderStyle: 'round', borderColor: 'gray', borderDimColor: true }
548 : {})}
549 >
550 <Button
551 key={tile.key}
552 label={tile.label}
553 plain
554 {...(tile.isDismiss ? { role: 'dismiss' as const } : {})}
555 {...(tile.isFocused ? { autoFocus: true as const } : {})}
556 onPress={tile.onPress}
557 />
558 </Box>
559 ))}
560 </Box>
561 ))}
562 </Box>
563 )
564}
565
566/** A glyph button on a status tile: what it does and the tone of its ground. */
567export type TileAction = { key: string; glyph: string; tone: string; onPress: () => void }
568
569/**
570 * A tile that says how its thing stands, in two lines: an icon and a title,
571 * with glyph buttons at the right of the same line, then a status line; both
572 * lines press the tile, across its whole width. `ground` fills the tile with the state's
573 * dark colour (`ok` running, `warn` waiting, `danger` failed); `isLit` false
574 * drops it to the neutral fill, so a caller alternating it makes the tile blink.
575 */
576export type StatusTile = {
577 key: string
578 icon: string
579 iconTone?: Tone
580 title: string
581 status: string
582 ground?: 'ok' | 'warn' | 'danger' | 'accent'
583 isLit?: boolean
584 actions?: TileAction[]
585 onPress: () => void
586}
587
588const STATUS_GROUND: Record<NonNullable<StatusTile['ground']>, string> = {
589 ok: theme.glyphOk,
590 warn: theme.glyphWarn,
591 danger: theme.glyphDanger,
592 accent: theme.glyphAccent,
593}
594
595/**
596 * Status tiles, `columns` to a row (two by default, one where the pane is
597 * too narrow for two of `minWidth` cells), every tile the same width and its
598 * text left-aligned and cut to one line each.
599 */
600export function StatusTiles(ui: ElementTable, bodyColumns: number, tiles: StatusTile[], options: { columns?: number; minWidth?: number } = {}) {
601 const { Box, Button } = ui
602 const wanted = options.columns ?? 2
603 const perRow = Math.max(1, Math.min(wanted, Math.floor((bodyColumns + TILE_GAP) / ((options.minWidth ?? 28) + TILE_GAP))))
604 const width = Math.floor((bodyColumns - TILE_GAP * (perRow - 1)) / perRow)
605 const rows: StatusTile[][] = []
606 for (let start = 0; start < tiles.length; start += perRow) rows.push(tiles.slice(start, start + perRow))
607
608 return (
609 <Box key="status-tiles" flexDirection="column" rowGap={1}>
610 {rows.map((row, index) => (
611 <Box key={`status-row-${index}`} gap={TILE_GAP}>
612 {row.map(tile => {
613 const buttons = tile.actions ?? []
614 // The tile keeps a cell of padding each side; each glyph button is one cell with a cell before it.
615 const text = Math.max(4, width - 2)
616 const title = Math.max(1, text - displayWidth(printable(tile.icon)) - 1 - buttons.length * 2)
617 const ground = tile.ground && tile.isLit !== false ? STATUS_GROUND[tile.ground] : theme.tile
618
619 return (
620 <Box key={`status-${tile.key}`} width={width} flexShrink={0} paddingX={1} flexDirection="column" backgroundColor={ground} hover={{ backgroundColor: theme.tileHover }}>
621 <Box justifyContent="space-between">
622 <Box gap={1} flexShrink={1}>
623 {Toned(ui, `status-icon-${tile.key}`, tile.icon, tile.iconTone, { isBold: true })}
624 <Button key={tile.key} label={padCells(truncate(printable(tile.title).replace(/\s+/g, ' '), title), title)} plain onPress={tile.onPress} />
625 </Box>
626 {buttons.length > 0 && (
627 <Box gap={1} flexShrink={0}>
628 {buttons.map(action => IconButton(ui, action.key, action.glyph, action.tone, action.onPress))}
629 </Box>
630 )}
631 </Box>
632 <Button key={`${tile.key}-status`} label={padCells(truncate(printable(tile.status).replace(/\s+/g, ' '), text), text)} plain onPress={tile.onPress} />
633 </Box>
634 )
635 })}
636 </Box>
637 ))}
638 </Box>
639 )
640}
641
642/** One line of a dialog: its text, and a tone for the line that matters most. */
643export type DialogLine = { text: string; tone?: 'danger' | 'ok' | 'muted' }
644
645/**
646 * The frame every dialog shares: the pane's usual header, then a round-bordered
647 * box holding the title, the body and the tiles.
648 */
649export function DialogFrame(
650 ui: ElementTable,
651 header: HeaderInfo,
652 borderColor: string,
653 title: string,
654 body: ReturnType<typeof Header>,
655 tiles: ReturnType<typeof Tiles>,
656) {
657 const { Box, Text } = ui
658
659 return (
660 <Box key="dialog-pane" flexDirection="column">
661 {Header(ui, header)}
662 <Box key="dialog" flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1}>
663 <Text bold>{printable(title)}</Text>
664 {body}
665 {tiles}
666 </Box>
667 </Box>
668 )
669}
670
671/**
672 * A confirmation dialog, drawn in a pane opened with `focus`, `closeOnEscape`
673 * and `holdToasts`: the pane's usual header, then a bordered box holding the
674 * title, the facts the choice rests on, and the two tiles, the confirming one
675 * focused so Enter answers. The border takes the warning colour: what the
676 * dialog confirms is hard to take back. With `defaultFocus: 'cancel'` Cancel
677 * holds the keyboard first, so an Enter meant for the prompt cancels: for an
678 * action that reaches past this pane (every session's login, saved data).
679 */
680export function Dialog(
681 ui: ElementTable,
682 bodyColumns: number,
683 header: HeaderInfo,
684 title: string,
685 lines: DialogLine[],
686 confirm: { label: string; onPress: () => void },
687 cancel: { label: string; onPress: () => void },
688 /** The key of the tile holding the keyboard; the one `defaultFocus` names until the ring moves. */
689 focused: string | null = null,
690 defaultFocus: 'confirm' | 'cancel' = 'confirm',
691) {
692 const { Box, Text } = ui
693 const isCancelFirst = defaultFocus === 'cancel'
694 const color = (tone: DialogLine['tone']) => (tone === 'danger' ? theme.danger : tone === 'ok' ? theme.ok : undefined)
695
696 return DialogFrame(
697 ui,
698 dialogHeader(header, cancel.onPress),
699 theme.warn,
700 title,
701 <Box key="dialog-lines" flexDirection="column" marginTop={1}>
702 {lines.map((line, index) => (
703 <Text key={`dialog-line-${index}`} color={color(line.tone)} dimColor={line.tone === 'muted'} wrap="wrap">
704 {printable(line.text)}
705 </Text>
706 ))}
707 </Box>,
708 Tiles(
709 ui,
710 Math.max(20, bodyColumns - CARD_CHROME),
711 [
712 { key: 'dialog-confirm', label: confirm.label, isMain: true, isFocused: !isCancelFirst, onPress: confirm.onPress },
713 { key: 'dialog-cancel', label: cancel.label, isDismiss: true, isFocused: isCancelFirst, onPress: cancel.onPress },
714 ],
715 { focused: focused ?? (isCancelFirst ? 'dialog-cancel' : 'dialog-confirm') },
716 ),
717 )
718}
719
720/**
721 * A dialog that asks for a line of text, drawn like the others: the title,
722 * the facts the answer is about, a bordered input that holds the keyboard,
723 * and Cancel. Enter submits. Where the surface has no input, `noInput` says
724 * how else to give the text.
725 */
726export function InputDialog(
727 ui: ElementTable,
728 bodyColumns: number,
729 header: HeaderInfo,
730 title: string,
731 lines: DialogLine[],
732 input: {
733 key: string
734 placeholder: string
735 value?: string
736 submitLabel: string
737 onSubmit: (value: string) => void
738 /** Whether the surface draws a text field: every surface but mobile. */
739 hasField: boolean
740 noInput: string
741 },
742 cancel: { label: string; onPress: () => void },
743 focused: string | null = null,
744) {
745 const { Box } = ui
746 // The mobile app draws no input: there the dialog says how else to give the text.
747 const Input = input.hasField && 'Input' in ui ? ui.Input : undefined
748
749 return DialogFrame(
750 ui,
751 dialogHeader(header, cancel.onPress),
752 theme.accent,
753 title,
754 <Box key="dialog-input-body" flexDirection="column" marginTop={1}>
755 {lines.map((line, index) => Toned(ui, `dialog-line-${index}`, line.text, line.tone === 'danger' ? 'danger' : line.tone === 'ok' ? 'ok' : undefined, { isDim: line.tone === 'muted', wrap: 'wrap' }))}
756 <Box key="dialog-input-row" marginTop={1}>
757 {Input
758 ? InputFrame(
759 ui,
760 'dialog-input-frame',
761 true,
762 <Input
763 key={input.key}
764 placeholder={printable(input.placeholder)}
765 {...(input.value !== undefined ? { value: printable(input.value) } : {})}
766 submitLabel={printable(input.submitLabel)}
767 autoFocus
768 onSubmit={input.onSubmit}
769 />,
770 '✎',
771 )
772 : Toned(ui, 'dialog-no-input', input.noInput, undefined, { isDim: true, wrap: 'wrap' })}
773 </Box>
774 </Box>,
775 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), [{ key: 'dialog-cancel', label: cancel.label, isDismiss: true, onPress: cancel.onPress }], { focused }),
776 )
777}
778
779/**
780 * A dialog that reads a document: its title and a dim line under it, one
781 * page of its Markdown drawn as a reply is, and tiles to the previous and
782 * next page, with the page's number, and Close. A longer document comes in
783 * pages cut to a fixed row budget, so the tiles stay in view.
784 */
785export function ReaderDialog(
786 ui: ElementTable,
787 bodyColumns: number,
788 header: HeaderInfo,
789 title: string,
790 subtitle: string,
791 markdown: string,
792 page: { index: number; count: number; label: string },
793 actions: { previous: { label: string; onPress: () => void }; next: { label: string; onPress: () => void }; close: { label: string; onPress: () => void } },
794 focused: string | null = null,
795) {
796 const { Box, Markdown, Text } = ui
797 const tiles: Tile[] = [
798 ...(page.index > 0 ? [{ key: 'reader-previous', label: actions.previous.label, onPress: actions.previous.onPress }] : []),
799 ...(page.index < page.count - 1 ? [{ key: 'reader-next', label: actions.next.label, isMain: true, onPress: actions.next.onPress }] : []),
800 { key: 'dialog-cancel', label: actions.close.label, isDismiss: true, onPress: actions.close.onPress },
801 ]
802
803 return DialogFrame(
804 ui,
805 dialogHeader(header, actions.close.onPress),
806 theme.accent,
807 title,
808 <Box key="reader-body" flexDirection="column">
809 <Text dimColor wrap="truncate-end">
810 {printable(page.count > 1 ? `${subtitle} · ${page.label}` : subtitle)}
811 </Text>
812 <Box key="reader-page" flexDirection="column" marginTop={1}>
813 <Markdown key="reader-markdown" text={printable(markdown)} />
814 </Box>
815 </Box>,
816 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), tiles, { focused }),
817 )
818}
819
820/** One field of a form dialog: a line of text, with suggestions to pick under it, or a pick from options. */
821export type FormField = {
822 key: string
823 label: string
824 value: string
825 placeholder?: string
826 /** A pick from these instead of typed text (every surface but mobile draws it). */
827 options?: { value: string; label: string }[]
828 /** Values to pick under the field, such as paths matching what was typed. */
829 suggestions?: string[]
830 /** Takes the field's new value; the promise it may return settles once the value is kept. */
831 onInput: (value: string) => void | Promise<void>
832 onPick?: (value: string) => void | Promise<void>
833}
834
835/**
836 * A dialog that asks for several values at once: each field its label dim,
837 * then a bordered input or a select, and under a typed field the suggestions
838 * to pick; the main tile submits, Cancel dismisses. Enter in any field submits,
839 * after that field's value is kept.
840 * Where the surface has no field, `noInput` says how else to give the values.
841 */
842export function FormDialog(
843 ui: ElementTable,
844 bodyColumns: number,
845 header: HeaderInfo,
846 title: string,
847 lines: DialogLine[],
848 fields: FormField[],
849 actions: { submit: { label: string; onPress: () => void }; cancel: { label: string; onPress: () => void } },
850 form: { hasField: boolean; noInput: string },
851 focused: string | null = null,
852) {
853 const { Box, Text } = ui
854 const Input = form.hasField && 'Input' in ui ? ui.Input : undefined
855 const Select = form.hasField && 'Select' in ui ? ui.Select : undefined
856
857 return DialogFrame(
858 ui,
859 dialogHeader(header, actions.cancel.onPress),
860 theme.accent,
861 title,
862 <Box key="form-body" flexDirection="column" marginTop={1} gap={1}>
863 {lines.map((line, index) => Toned(ui, `form-line-${index}`, line.text, line.tone === 'danger' ? 'danger' : line.tone === 'ok' ? 'ok' : undefined, { isDim: line.tone === 'muted', wrap: 'wrap' }))}
864 {!Input && Toned(ui, 'form-no-input', form.noInput, undefined, { isDim: true, wrap: 'wrap' })}
865 {Input &&
866 fields.map((field, index) => (
867 <Box key={`form-field-${field.key}`} flexDirection="column">
868 <Text dimColor>{printable(field.label)}</Text>
869 {field.options && Select ? (
870 <Select
871 key={`field-${field.key}`}
872 options={field.options.map(option => ({ ...option, label: printable(option.label) }))}
873 value={field.value}
874 {...(index === 0 ? { autoFocus: true as const } : {})}
875 onSelect={value => void field.onInput(value)}
876 />
877 ) : (
878 InputFrame(
879 ui,
880 `form-frame-${field.key}`,
881 true,
882 <Input
883 key={`field-${field.key}`}
884 value={printable(field.value)}
885 {...(field.placeholder ? { placeholder: printable(field.placeholder) } : {})}
886 {...(index === 0 ? { autoFocus: true as const } : {})}
887 onInput={value => void field.onInput(value)}
888 // Enter submits once the field's value is kept, so the submit reads what was typed.
889 onSubmit={value => void Promise.resolve(field.onInput(value)).then(actions.submit.onPress, actions.submit.onPress)}
890 />,
891 )
892 )}
893 {(field.suggestions ?? []).length > 0 && (
894 <Box key={`form-suggest-${field.key}`} flexDirection="column" paddingLeft={2}>
895 {(field.suggestions ?? []).map((suggestion, row) =>
896 LinkButton(ui, `suggest-${field.key}-${row}`, suggestion, () => void (field.onPick ?? field.onInput)(suggestion)),
897 )}
898 </Box>
899 )}
900 </Box>
901 ))}
902 </Box>,
903 Tiles(
904 ui,
905 Math.max(20, bodyColumns - CARD_CHROME),
906 [
907 ...(Input ? [{ key: 'form-submit', label: actions.submit.label, isMain: true, onPress: actions.submit.onPress }] : []),
908 { key: 'dialog-cancel', label: actions.cancel.label, isDismiss: true, onPress: actions.cancel.onPress },
909 ],
910 { focused },
911 ),
912 )
913}
914
915/**
916 * A dialog that shows a run's output as it arrives: its state on a line, then
917 * the lines in a numbered block, the newest at the bottom while it follows;
918 * tiles to page back and forward, to follow or stop following, to stop the
919 * run while it runs or run it again once it has ended, and Close.
920 */
921export function LogDialog(
922 ui: ElementTable,
923 bodyColumns: number,
924 header: HeaderInfo,
925 title: string,
926 status: { text: string; tone?: Tone },
927 log: { text: string; firstLine: number; empty: string },
928 actions: { older?: Tile; newer?: Tile; follow: Tile; stop?: Tile; again?: Tile; close: Tile },
929 focused: string | null = null,
930) {
931 const { Box, Code } = ui
932
933 return DialogFrame(
934 ui,
935 dialogHeader(header, actions.close.onPress),
936 theme.accent,
937 title,
938 <Box key="log-body" flexDirection="column" marginTop={1}>
939 {Toned(ui, 'log-status', status.text, status.tone)}
940 <Box key="log-lines" flexDirection="column" marginTop={1}>
941 {log.text === '' ? Toned(ui, 'log-empty', log.empty, undefined, { isDim: true }) : <Code key="log-code" source={printable(log.text)} startLine={log.firstLine} wrap="wrap" />}
942 </Box>
943 </Box>,
944 Tiles(
945 ui,
946 Math.max(20, bodyColumns - CARD_CHROME),
947 [actions.older, actions.newer, actions.follow, actions.stop, actions.again, actions.close].filter((tile): tile is Tile => tile !== undefined),
948 { focused },
949 ),
950 )
951}
952
953/**
954 * A dialog that shows one page of code, a diff or a file: a dim line saying
955 * what it is, the page's lines numbered from where the page starts, and tiles
956 * to turn pages, then the caller's own (another file, an action on this one),
957 * then Close. The page is cut by the caller to a fixed number of lines, so the
958 * dialog keeps its height from page to page.
959 */
960export function CodeDialog(
961 ui: ElementTable,
962 bodyColumns: number,
963 header: HeaderInfo,
964 title: string,
965 subtitle: string,
966 code: { source: string; format?: 'diff'; path?: string; empty: string },
967 page: { index: number; count: number; label: string },
968 actions: { previous: { label: string; onPress: () => void }; next: { label: string; onPress: () => void }; extra?: Tile[]; close: { label: string; onPress: () => void } },
969 focused: string | null = null,
970) {
971 const { Box, Code, Text } = ui
972 const tiles: Tile[] = [
973 ...(page.index > 0 ? [{ key: 'code-previous', label: actions.previous.label, onPress: actions.previous.onPress }] : []),
974 ...(page.index < page.count - 1 ? [{ key: 'code-next', label: actions.next.label, isMain: true, onPress: actions.next.onPress }] : []),
975 ...(actions.extra ?? []),
976 { key: 'dialog-cancel', label: actions.close.label, isDismiss: true, onPress: actions.close.onPress },
977 ]
978
979 return DialogFrame(
980 ui,
981 dialogHeader(header, actions.close.onPress),
982 theme.accent,
983 title,
984 <Box key="code-body" flexDirection="column" marginTop={1}>
985 <Text dimColor wrap="truncate-end">
986 {printable(page.count > 1 ? `${subtitle} · ${page.label}` : subtitle)}
987 </Text>
988 <Box key="code-lines" flexDirection="column" marginTop={1}>
989 {printable(code.source).trim() === '' ? (
990 Toned(ui, 'code-empty', code.empty, undefined, { isDim: true })
991 ) : (
992 <Code
993 key="code-source"
994 source={printable(code.source)}
995 {...(code.format ? { format: code.format } : {})}
996 {...(code.path ? { path: printable(code.path) } : {})}
997 />
998 )}
999 </Box>
1000 </Box>,
1001 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), tiles, { focused }),
1002 )
1003}
1004
1005/** One choice of a choice dialog: what pressing it picks, and a dim detail beside it. */
1006export type Choice = { key: string; label: string; detail?: string; isCurrent?: boolean; onPress: () => void }
1007
1008/**
1009 * A dialog that asks for one of several choices, drawn like the confirmation
1010 * dialog: the pane's header, then a bordered box holding the title, one row
1011 * per choice and Cancel. The current choice is marked `●` and holds the focus
1012 * first, so Enter keeps it; the labels share one column so the details line up.
1013 * The border takes the accent colour: choosing changes nothing that cannot be
1014 * chosen back.
1015 */
1016export function ChoiceDialog(
1017 ui: ElementTable,
1018 bodyColumns: number,
1019 header: HeaderInfo,
1020 title: string,
1021 choices: Choice[],
1022 cancel: { label: string; onPress: () => void },
1023 /** The key of the element holding the keyboard, so the Cancel tile can show it. */
1024 focused: string | null = null,
1025) {
1026 const { Box, Text } = ui
1027 // Every row is one line: the details share one column at the right, and the labels
1028 // take what is left of the dialog's width and are cut to it, never wrapped.
1029 const details = choices.map(choice => (choice.detail === undefined ? undefined : printable(choice.detail)))
1030 const detailWidth = Math.max(0, ...details.map(detail => displayWidth(detail ?? '')))
1031 const labelRoom = Math.max(6, bodyColumns - CARD_CHROME - 2 - (detailWidth > 0 ? detailWidth + 1 : 0))
1032 const labels = choices.map(choice => truncate(printable(choice.label).replace(/\s+/g, ' ').trim(), labelRoom))
1033 const labelWidth = Math.max(0, ...labels.map(displayWidth))
1034
1035 return DialogFrame(
1036 ui,
1037 dialogHeader(header, cancel.onPress),
1038 theme.accent,
1039 title,
1040 <Box key="dialog-choices" flexDirection="column" marginTop={1}>
1041 {choices.map((choice, index) => (
1042 <Box key={`choice-${choice.key}`} gap={1}>
1043 <Text color={choice.isCurrent ? theme.accent : undefined} dimColor={!choice.isCurrent}>
1044 {choice.isCurrent ? '●' : '○'}
1045 </Text>
1046 {LinkButton(ui, choice.key, padCells(labels[index] ?? '', labelWidth), choice.onPress, { autoFocus: choice.isCurrent === true })}
1047 {details[index] && (
1048 <Box flexShrink={0}>
1049 <Text dimColor wrap="truncate-end">
1050 {details[index]}
1051 </Text>
1052 </Box>
1053 )}
1054 </Box>
1055 ))}
1056 </Box>,
1057 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), [{ key: 'dialog-cancel', label: cancel.label, isDismiss: true, onPress: cancel.onPress }], {
1058 focused,
1059 }),
1060 )
1061}
1062
1063/**
1064 * An on/off switch drawn as two segments, `[ On | Off ]`: the segment for the
1065 * current state filled (green for on, grey for off), the other dim. Pressing
1066 * the other segment switches to it; the setting's name beside it flips it.
1067 */
1068export function Toggle(
1069 ui: ElementTable,
1070 key: string,
1071 isOn: boolean,
1072 label: string,
1073 states: { on: string; off: string },
1074 onToggle: () => void,
1075) {
1076 const { Box, Button } = ui
1077 const segment = (side: 'on' | 'off') => {
1078 const isCurrent = (side === 'on') === isOn
1079
1080 return (
1081 <Box
1082 key={`${key}-${side}-segment`}
1083 paddingX={1}
1084 backgroundColor={isCurrent ? (side === 'on' ? theme.switchOn : theme.switchOffActive) : theme.switchOff}
1085 {...(isCurrent ? {} : { hover: { backgroundColor: theme.tileHover } })}
1086 >
1087 <Button
1088 key={`${key}-${side}`}
1089 label={printable(states[side])}
1090 plain
1091 {...(isCurrent ? {} : { dimColor: true })}
1092 // The current segment is already the state: pressing it changes nothing.
1093 onPress={() => {
1094 if (!isCurrent) onToggle()
1095 }}
1096 />
1097 </Box>
1098 )
1099 }
1100
1101 return (
1102 <Box key={key} gap={1}>
1103 <Box key={`${key}-segments`}>
1104 {segment('on')}
1105 {segment('off')}
1106 </Box>
1107 <Button key={`${key}-label`} label={printable(label)} plain hover={{ color: theme.accent, bold: true }} onPress={onToggle} />
1108 </Box>
1109 )
1110}
1111hooks/shared/locale.ts 72 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2export type Locale = 'en' | 'ko'
3
4/** Short weekday names, Sunday first, as `Date#getDay` counts. */
5export const WEEKDAYS: Record<Locale, readonly string[]> = {
6 en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
7 ko: ['일', '월', '화', '수', '목', '금', '토'],
8}
9
10const KOREAN = /^(ko\b|ko[-_]|korean|한국어)/i
11
12/**
13 * Picks the display language. Claude Code's `language` setting wins when set
14 * (any language without a translation here falls back to English); without
15 * it the POSIX locale variables decide, in their own precedence order.
16 */
17export function resolveLocale(language: unknown, localeVariables: (string | undefined)[]): Locale {
18 if (typeof language === 'string' && language.trim() !== '') return KOREAN.test(language.trim()) ? 'ko' : 'en'
19 const locale = localeVariables.find(value => value !== undefined && value !== '')
20
21 return locale !== undefined && KOREAN.test(locale) ? 'ko' : 'en'
22}
23
24/** The release date a changelog heading gives a version: `## 0.1.0 (2026-10-04)`. */
25export function releaseDateOf(changelog: string, version: string): string | undefined {
26 const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27
28 return new RegExp(`^##\\s+v?${escaped}\\s+\\((\\d{4}-\\d{2}-\\d{2})\\)`, 'm').exec(changelog)?.[1]
29}
30
31/** The particles whose form follows the sound before them: after a final consonant, then after a vowel. */
32const PARTICLES: readonly [string, string][] = [
33 ['을', '를'],
34 ['이', '가'],
35 ['은', '는'],
36 ['과', '와'],
37 ['으로', '로'],
38]
39
40/** Whether a Hangul syllable ends in a consonant, and whether that consonant is ㄹ; null for anything else. */
41function finalOf(char: string): { hasFinal: boolean; isRieul: boolean } | null {
42 const code = char.charCodeAt(0) - 0xac00
43 if (code < 0 || code > 11171) return null
44 const final = code % 28
45
46 return { hasFinal: final !== 0, isRieul: final === 8 }
47}
48
49/**
50 * The particles in `text` written right after `word` (a closing quote or
51 * placeholder brace between allowed) that do not fit its last syllable, such
52 * as `목록를`: a message that puts a fixed particle after a value it fills in
53 * is wrong for half the values it takes. Both forms written together
54 * (`을(를)`) fit any word and are not reported, and neither is a particle
55 * after a closing parenthesis, which belongs to the word before it opened.
56 */
57export function particleProblems(text: string, word: string): string[] {
58 const last = finalOf(word.slice(-1))
59 if (!last) return []
60 const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
61 const problems: string[] = []
62 for (const match of text.matchAll(new RegExp(`${escaped}[」』"'”’}]*(으로|로|을|를|이|가|은|는|과|와)(?!\\()(?=[\\s.,!?:;)」』]|$)`, 'g'))) {
63 const particle = match[1] ?? ''
64 const pair = PARTICLES.find(([after, before]) => after === particle || before === particle)
65 if (!pair) continue
66 const fits = pair[0] === '으로' ? (last.hasFinal && !last.isRieul ? '으로' : '로') : last.hasFinal ? pair[0] : pair[1]
67 if (particle !== fits) problems.push(match[0])
68 }
69
70 return problems
71}
72hooks/tools.ts 238 lines1/**
2 * The project's tools as `.toolbox/toolbox.json` keeps them, and a tool's
3 * command with its parameters filled. Pure: the hooks module reads and writes
4 * the file.
5 */
6import type { RunStatus, Tool, ToolboxSummary, ToolKind, ToolParam } from '../types'
7
8/** The folder in the project that holds the toolbox, and its files. */
9export const TOOLBOX_DIR = '.toolbox'
10export const TOOLS_FILE = 'toolbox.json'
11/** What the toolbox's own `.gitignore` keeps out when it is first made: the logs and the remembered values. */
12export const DEFAULT_IGNORE = '# Run output and remembered values stay on this machine; delete a line to share it.\nlogs/\nstate.json\n'
13
14const KINDS: readonly ToolKind[] = ['shell', 'claude', 'prompt']
15
16/** Claude Code's commands that drop the conversation or reload what runs: added from the list, they ask before each run. */
17export const CONFIRMED_COMMANDS: readonly string[] = ['clear', 'reload-plugins', 'exit', 'logout', 'rewind']
18
19/** A `{{name}}` or `{{name|raw}}` in a command. */
20const PLACEHOLDER = /\{\{\s*([A-Za-z_][\w-]*)\s*(\|\s*raw\s*)?\}\}/g
21
22/** The parameters a command names, in the order they first appear. */
23export function paramNames(run: string): string[] {
24 return [...new Set([...run.matchAll(PLACEHOLDER)].map(match => match[1] ?? ''))]
25}
26
27/** A value as one word of a POSIX shell command: in single quotes, a quote inside written `'\''`. */
28export function shellQuote(value: string): string {
29 return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
30}
31
32/**
33 * The command with each parameter's value in place of its `{{name}}`. In a
34 * shell command a value is quoted as one word unless written `{{name|raw}}`;
35 * a slash command or a prompt takes it as written.
36 */
37export function fill(tool: Pick<Tool, 'kind' | 'run'>, values: Record<string, string>): string {
38 return tool.run.replace(PLACEHOLDER, (_, name: string, raw?: string) => {
39 const value = values[name] ?? ''
40
41 return tool.kind === 'shell' && !raw ? shellQuote(value) : value
42 })
43}
44
45/** The parameters a run asks for: those marked `ask`, and any the command names without a setting. */
46export function askedParams(tool: Pick<Tool, 'run' | 'params'>): string[] {
47 return paramNames(tool.run).filter(name => (tool.params?.[name]?.mode ?? 'ask') === 'ask')
48}
49
50/** The values a run starts from: each fixed value, and for an asked one the remembered value or its default. */
51export function startingValues(tool: Pick<Tool, 'run' | 'params'>, remembered: Record<string, string> = {}): Record<string, string> {
52 return Object.fromEntries(
53 paramNames(tool.run).map(name => {
54 const param = tool.params?.[name]
55 const value = param?.mode === 'fixed' ? (param.value ?? '') : param?.remember !== false && remembered[name] !== undefined ? remembered[name] : (param?.value ?? '')
56
57 return [name, value]
58 }),
59 )
60}
61
62/** Why a set of values cannot run: a choice not among its choices; null when they can. */
63export function valuesProblem(tool: Pick<Tool, 'run' | 'params'>, values: Record<string, string>): string | null {
64 for (const name of paramNames(tool.run)) {
65 const param = tool.params?.[name]
66 if (param?.type === 'choice' && param.choices && param.choices.length > 0 && !param.choices.includes(values[name] ?? '')) return name
67 }
68
69 return null
70}
71
72/** `base`, or with `-2`, `-3` and on after it, whichever no tool in `taken` holds yet. */
73function freeId(base: string, taken: readonly string[]): string {
74 let id = base
75 for (let n = 2; taken.includes(id); n += 1) id = `${base}-${n}`
76
77 return id
78}
79
80/** A short id for a new tool: its name in lowercase words, made unique among `taken`. */
81export function toolId(name: string, taken: readonly string[]): string {
82 return freeId(name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'tool', taken)
83}
84
85/** Whether an entry of the file is a tool the pane can use: a name, a known kind and a command. */
86function isWhole(raw: Partial<Tool> | null): raw is Partial<Tool> & Pick<Tool, 'name' | 'run' | 'kind'> {
87 return raw !== null && typeof raw === 'object' && typeof raw.name === 'string' && typeof raw.run === 'string' && KINDS.includes(raw.kind as ToolKind)
88}
89
90/**
91 * The id each entry of the file's list goes by, in order: its own when given,
92 * else one made from its name, and either made unique, so two entries given
93 * one id are still two tools to run, stop and log apart; null for an entry
94 * left out.
95 */
96export function toolIds(list: readonly unknown[]): (string | null)[] {
97 const taken: string[] = []
98
99 return list.map(item => {
100 const raw = item as Partial<Tool> | null
101 if (!isWhole(raw)) return null
102 const id = typeof raw.id === 'string' && raw.id !== '' ? freeId(raw.id, taken) : toolId(raw.name, taken)
103 taken.push(id)
104
105 return id
106 })
107}
108
109function asParam(value: unknown): ToolParam | null {
110 const raw = value as Partial<ToolParam> | null
111 if (!raw || typeof raw !== 'object') return null
112 const type = raw.type === 'path' || raw.type === 'choice' ? raw.type : 'text'
113
114 return {
115 type,
116 mode: raw.mode === 'fixed' ? 'fixed' : 'ask',
117 ...(typeof raw.value === 'string' ? { value: raw.value } : {}),
118 ...(Array.isArray(raw.choices) ? { choices: raw.choices.filter((one): one is string => typeof one === 'string') } : {}),
119 ...(raw.pathKind === 'file' || raw.pathKind === 'dir' ? { pathKind: raw.pathKind } : {}),
120 ...(typeof raw.glob === 'string' ? { glob: raw.glob } : {}),
121 ...(raw.remember === false ? { remember: false } : {}),
122 }
123}
124
125/**
126 * The tools in a `toolbox.json` text, each checked: one without a name, a
127 * known kind or a command is left out and named in `problems`.
128 */
129export function parseTools(text: string): { tools: Tool[]; problems: string[] } {
130 const data = JSON.parse(text) as { tools?: unknown }
131 const list = Array.isArray(data.tools) ? data.tools : []
132 const ids = toolIds(list)
133 const tools: Tool[] = []
134 const problems: string[] = []
135 list.forEach((item, index) => {
136 const raw = item as Partial<Tool> | null
137 const id = ids[index]
138 if (!isWhole(raw) || !id) {
139 problems.push(`tools[${index}]`)
140
141 return
142 }
143 const params = Object.fromEntries(
144 Object.entries(raw.params ?? {})
145 .map(([name, value]) => [name, asParam(value)] as const)
146 .filter((entry): entry is readonly [string, ToolParam] => entry[1] !== null),
147 )
148 tools.push({
149 id,
150 name: raw.name,
151 kind: raw.kind as ToolKind,
152 run: raw.run,
153 ...(typeof raw.cwd === 'string' && raw.cwd !== '' ? { cwd: raw.cwd } : {}),
154 ...(Object.keys(params).length > 0 ? { params } : {}),
155 ...(raw.submit === true ? { submit: true } : {}),
156 ...(raw.confirm === true ? { confirm: true } : {}),
157 ...(typeof raw.group === 'string' ? { group: raw.group } : {}),
158 })
159 })
160
161 return { tools, problems }
162}
163
164/** The fields of a tool the pane writes; any other field of an entry is the person's and stays. */
165const TOOL_FIELDS: readonly string[] = ['id', 'name', 'kind', 'run', 'cwd', 'params', 'submit', 'confirm', 'group']
166
167/** A change the pane saves: a tool put in place of the one with its id, a new tool, or one removed by id. */
168export type ToolChange = { put: Tool } | { add: Omit<Tool, 'id'> } | { remove: string }
169
170/**
171 * `toolbox.json` with one change made to it as it stands now (`text`, or no
172 * file yet), two-space indented for a person to read and edit too. Only that tool's entry changes: tools added
173 * since the pane read the file, entries it cannot read, fields it does not
174 * know and the file's other keys stay as they are. A new tool goes last under
175 * an id no entry holds. Throws when the text is no JSON object, which is then
176 * left for the person to mend rather than written over.
177 */
178export function applyToolChange(text: string | undefined, change: ToolChange): string {
179 const data: unknown = text === undefined || text.trim() === '' ? {} : JSON.parse(text)
180 if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error(`${TOOLS_FILE} holds no object`)
181 const file = data as Record<string, unknown>
182 const list: unknown[] = Array.isArray(file.tools) ? [...file.tools] : []
183 const ids = toolIds(list)
184 if ('remove' in change) {
185 const at = ids.indexOf(change.remove)
186 if (at !== -1) list.splice(at, 1)
187 } else if ('add' in change) {
188 list.push({ id: toolId(change.add.name, ids.filter((id): id is string => id !== null)), ...change.add })
189 } else {
190 const at = ids.indexOf(change.put.id)
191 if (at === -1) list.push(change.put)
192 else list[at] = { ...change.put, ...Object.fromEntries(Object.entries(list[at] as object).filter(([key]) => !TOOL_FIELDS.includes(key))) }
193 }
194
195 return `${JSON.stringify({ version: 1, ...file, tools: list }, null, 2)}\n`
196}
197
198/**
199 * A working folder for a shell command, from the project's root: refused when
200 * it is absolute or climbs out of the project.
201 */
202export function workingFolder(root: string, cwd: string | undefined): string {
203 if (!cwd || cwd === '.') return root
204 if (cwd.startsWith('/') || /^[A-Za-z]:/.test(cwd) || cwd.split(/[\\/]/).includes('..')) throw new Error(`cwd must stay inside the project: ${cwd}`)
205
206 return `${root}/${cwd.replace(/^\.\//, '')}`
207}
208
209/**
210 * The runs as a fresh load of the module finds them: a shell run it does not
211 * hold was ended with the last load, so it is stopped; a Claude command it is
212 * not waiting on was handed to Claude Code, which runs it whether or not the
213 * module that asked is still there (`/reload-plugins` reloads this one), so
214 * it is done. Runs it holds are left as they are.
215 */
216export function settleRuns(runs: Record<string, RunStatus>, held: (id: string) => boolean, now: number): Record<string, RunStatus> {
217 return Object.fromEntries(
218 Object.entries(runs).map(([id, run]) => {
219 if (held(id)) return [id, run]
220 if (run.state === 'running') return [id, { ...run, state: 'stopped' as const, endedAt: run.endedAt ?? now }]
221 if (run.state === 'queued') return [id, { ...run, state: 'done' as const, endedAt: run.endedAt ?? now }]
222
223 return [id, run]
224 }),
225 )
226}
227
228/** The runs as the band counts them: running, waiting for Claude, and failed after `seenAt`. */
229export function summarize(runs: Record<string, RunStatus>, seenAt: number): ToolboxSummary {
230 const all = Object.values(runs)
231
232 return {
233 running: all.filter(run => run.state === 'running').length,
234 waiting: all.filter(run => run.state === 'queued').length,
235 failed: all.filter(run => run.state === 'failed' && (run.endedAt ?? 0) > seenAt).length,
236 }
237}
238hooks/views/add.tsx 110 lines1import type { ElementTable } from 'claude-code'
2
3import type { DetectedTask } from '../../types'
4import type { Messages } from '../i18n'
5import { Card, CARD_CHROME, Empty, InputFrame, RankList, Section, TileButton, Toned } from '../shared/kit'
6
7export type AddModel = {
8 detected: DetectedTask[] | null
9 /** Build files that hold no JSON, their tasks left out. */
10 unreadable: string[]
11 commands: { name: string; description: string; source: string }[] | null
12 query: string
13 /** Whether Gradle's whole task list can be loaded here. */
14 hasGradle: boolean
15 hasField: boolean
16 m: Messages
17 bodyColumns: number
18}
19
20export type AddActions = {
21 search: (query: string) => void
22 addTask: (task: DetectedTask) => void
23 addCommand: (name: string) => void
24 addShell: () => void
25 addPrompt: () => void
26 loadGradle: () => void
27}
28
29/** Rows of a list shown before the filter is needed to find the rest. */
30const ROWS_SHOWN = 30
31
32/** Whether a row holds every word typed, in its name, command or description. */
33export function matchesQuery(query: string, ...texts: (string | undefined)[]): boolean {
34 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
35 const haystack = texts.filter(Boolean).join(' ').toLowerCase()
36
37 return words.every(word => haystack.includes(word))
38}
39
40/** The Add tab: a filter, then the project's build tasks by ecosystem, Claude's commands, and a tool of one's own. */
41export function AddTab(ui: ElementTable, model: AddModel, actions: AddActions) {
42 const { Box } = ui
43 const { m } = model
44 const Input = model.hasField && 'Input' in ui ? ui.Input : undefined
45 const inner = Math.max(20, model.bodyColumns - CARD_CHROME)
46 const tasks = (model.detected ?? []).filter(task => matchesQuery(model.query, task.name, task.run, task.description, task.source))
47 const sources = [...new Set(tasks.map(task => task.source))]
48 const commands = (model.commands ?? []).filter(command => matchesQuery(model.query, command.name, command.description))
49
50 return (
51 <Box key="add" flexDirection="column" gap={1}>
52 {Section(ui, 'add-title', m.addTitle)}
53 {Input &&
54 InputFrame(
55 ui,
56 'add-search-frame',
57 true,
58 <Input key="add-search" value={model.query} placeholder={m.addSearch} onInput={value => actions.search(value)} onSubmit={value => actions.search(value)} />,
59 '⌕',
60 )}
61 <Box key="add-custom" gap={2}>
62 {Section(ui, 'add-custom-title', m.addCustom)}
63 {TileButton(ui, 'add-shell', m.addShell, actions.addShell)}
64 {TileButton(ui, 'add-prompt', m.addPrompt, actions.addPrompt)}
65 </Box>
66 <Box key="add-detected" flexDirection="column">
67 <Box gap={2}>
68 {Section(ui, 'add-detected-title', m.addDetected)}
69 {model.hasGradle && TileButton(ui, 'add-gradle-all', m.addGradleAll, actions.loadGradle)}
70 </Box>
71 {model.unreadable.length > 0 && Toned(ui, 'add-detected-unreadable', m.addUnreadable(model.unreadable.join(', ')), 'danger', { wrap: 'wrap' })}
72 {model.detected === null && Empty(ui, 'add-detected-loading', [m.loading])}
73 {model.detected !== null && model.detected.length === 0 && Empty(ui, 'add-detected-none', [m.addDetectedNone])}
74 {sources.map(source =>
75 Card(
76 ui,
77 `add-source-${source}`,
78 false,
79 RankList(
80 ui,
81 `add-source-${source}-rows`,
82 tasks
83 .filter(task => task.source === source)
84 .slice(0, ROWS_SHOWN)
85 .map(task => ({ name: `${source} · ${task.name}`, detail: task.description ?? task.run, value: '+', onPress: () => actions.addTask(task) })),
86 inner,
87 ),
88 ),
89 )}
90 </Box>
91 <Box key="add-claude" flexDirection="column">
92 {Section(ui, 'add-claude-title', m.addClaude)}
93 {model.commands === null && Empty(ui, 'add-claude-loading', [m.loading])}
94 {commands.length > 0 &&
95 Card(
96 ui,
97 'add-claude-card',
98 false,
99 RankList(
100 ui,
101 'add-claude-rows',
102 commands.slice(0, ROWS_SHOWN).map(command => ({ name: `/${command.name}`, detail: command.description, value: '+', onPress: () => actions.addCommand(command.name) })),
103 inner,
104 ),
105 )}
106 </Box>
107 </Box>
108 )
109}
110hooks/views/tools.tsx 221 lines1import type { ElementTable } from 'claude-code'
2
3import type { RunStatus, Tool, ToolKind } from '../../types'
4import type { Messages } from '../i18n'
5import type { StatusTile, TileAction, Tone } from '../shared/kit'
6import { Card, CARD_CHROME, Empty, IconButton, LinkButton, Section, StatusTiles, SubLine, theme, Tiles, Toned } from '../shared/kit'
7import { displayWidth, printable, truncate } from '../shared/layout'
8import { askedParams } from '../tools'
9
10/** How far a shell run says it has got. */
11export type Activity = { progress?: number }
12
13export type ToolsModel = {
14 tools: Tool[]
15 runs: Record<string, RunStatus>
16 activity: Record<string, Activity>
17 error: string | null
18 path: string
19 now: number
20 m: Messages
21 bodyColumns: number
22}
23
24export type ToolsActions = {
25 run: (tool: Tool) => void
26 stop: (tool: Tool) => void
27 log: (tool: Tool) => void
28 edit: (tool: Tool) => void
29 remove: (tool: Tool) => void
30}
31
32const KINDS: ToolKind[] = ['shell', 'claude', 'prompt']
33
34/** A run's mark and its tone: running, done, failed, stopped or waiting. */
35export function runMark(run: RunStatus | undefined): { mark: string; tone?: Tone } {
36 if (!run) return { mark: ' ' }
37 if (run.state === 'running') return { mark: '●', tone: 'ok' }
38 if (run.state === 'queued') return { mark: '◐', tone: 'warn' }
39 if (run.state === 'failed') return { mark: '✖', tone: 'danger' }
40 if (run.state === 'stopped') return { mark: '■', tone: 'warn' }
41
42 return { mark: '✔', tone: 'ok' }
43}
44
45/**
46 * A run as both views say it: its state, how it ended, and, while it runs,
47 * how long it has run; once ended, how long it took and how long ago.
48 */
49export function runText(run: RunStatus, now: number, m: Messages): string {
50 const ended = run.state === 'failed' && typeof run.code === 'number' ? ` (${m.exitCode(run.code)})` : ''
51 if (run.endedAt === undefined) return `${m.state[run.state]} · ${elapsed(now - run.startedAt)}`
52
53 return `${m.state[run.state]}${ended} · ${m.took(elapsed(run.endedAt - run.startedAt))} · ${m.ago(elapsed(now - run.endedAt))}`
54}
55
56/** The tools list: each kind its own card, a row a tool with its state and its buttons. */
57export function ToolsTab(ui: ElementTable, model: ToolsModel, actions: ToolsActions) {
58 const { Box, Text } = ui
59 const { m } = model
60 const inner = Math.max(20, model.bodyColumns - CARD_CHROME)
61 const row = (tool: Tool) => {
62 const run = model.runs[tool.id]
63 const mark = runMark(run)
64 const isRunning = run?.state === 'running'
65 const status = run ? runText(run, model.now, m) : askedParams(tool).length > 0 ? m.asked : ''
66 const room = Math.max(10, inner - displayWidth(status) - 16)
67
68 return (
69 <Box key={`tool-${tool.id}`} flexDirection="column">
70 <Box justifyContent="space-between">
71 <Box gap={1} flexShrink={1}>
72 {Toned(ui, `tool-mark-${tool.id}`, mark.mark, mark.tone)}
73 {LinkButton(ui, `tool-run-name-${tool.id}`, truncate(printable(tool.name), room), () => actions.run(tool))}
74 <Text dimColor wrap="truncate-end">
75 {status}
76 </Text>
77 </Box>
78 <Box gap={2} flexShrink={0} marginLeft={1}>
79 {tool.kind === 'shell' && isRunning
80 ? IconButton(ui, `tool-stop-${tool.id}`, '■', theme.danger, () => actions.stop(tool))
81 : IconButton(ui, `tool-run-${tool.id}`, '▶', theme.ok, () => actions.run(tool))}
82 {tool.kind === 'shell' && run !== undefined && IconButton(ui, `tool-log-${tool.id}`, '≡', theme.accent, () => actions.log(tool))}
83 {IconButton(ui, `tool-edit-${tool.id}`, '✎', theme.accent, () => actions.edit(tool))}
84 {IconButton(ui, `tool-remove-${tool.id}`, '✕', theme.danger, () => actions.remove(tool))}
85 </Box>
86 </Box>
87 {SubLine(ui, `tool-command-${tool.id}`, tool.run)}
88 </Box>
89 )
90 }
91
92 return (
93 <Box key="tools" flexDirection="column" gap={1}>
94 {Section(ui, 'tools-title', m.toolsTitle, m.toolsDetail(model.path))}
95 {model.error && Toned(ui, 'tools-error', model.error, 'danger', { wrap: 'wrap' })}
96 {model.tools.length === 0 && Empty(ui, 'tools-empty', [m.toolsEmpty, m.toolsEmptyHint])}
97 {KINDS.map(kind => {
98 const tools = model.tools.filter(tool => tool.kind === kind)
99 if (tools.length === 0) return null
100
101 return (
102 <Box key={`tools-${kind}`} flexDirection="column">
103 {Section(ui, `tools-${kind}-title`, m.kindTitle[kind])}
104 {Card(ui, `tools-${kind}-card`, false, <Box flexDirection="column">{tools.map(row)}</Box>)}
105 </Box>
106 )
107 })}
108 </Box>
109 )
110}
111
112/** A quarter-step circle for a share done, empty to full. */
113const PROGRESS_ICONS = ['○', '◔', '◑', '◕', '●']
114/** A circle turning a quarter each beat, for a run that does not say how far it has got. */
115const SPINNER_ICONS = ['◴', '◷', '◶', '◵']
116/** The beat the quick view redraws on while something runs, in milliseconds. */
117export const QUICK_BEAT_MS = 500
118
119/** The icon of a share done: one of five circles filling by quarters. */
120export function progressIcon(percent: number): string {
121 return PROGRESS_ICONS[Math.min(4, Math.max(0, Math.round(percent / 25)))] ?? '○'
122}
123
124/** Seconds as `12s`, `3m 20s` or `1h 5m`. */
125export function elapsed(ms: number): string {
126 const seconds = Math.max(0, Math.floor(ms / 1000))
127 if (seconds < 60) return `${seconds}s`
128 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
129
130 return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
131}
132
133const IDLE_ICON: Record<ToolKind, string> = { shell: '▶', claude: '›', prompt: '¶' }
134
135/**
136 * A tool as its quick tile shows it: the icon and its tone, the status line,
137 * the ground, and whether the ground is lit on this beat.
138 */
139export function quickTile(tool: Tool, run: RunStatus | undefined, activity: Activity | undefined, now: number, m: Messages): Omit<StatusTile, 'key' | 'onPress' | 'actions'> {
140 const beat = Math.floor(now / QUICK_BEAT_MS)
141 const isOn = beat % 2 === 0
142 if (run?.state === 'running') {
143 const progress = activity?.progress
144
145 return {
146 icon: progress === undefined ? (SPINNER_ICONS[beat % 4] ?? '◴') : progressIcon(progress),
147 ...(isOn ? { iconTone: 'ok' as const } : {}),
148 title: tool.name,
149 status: [runText(run, now, m), progress === undefined ? '' : `${Math.round(progress)}%`].filter(Boolean).join(' · '),
150 ground: 'ok',
151 }
152 }
153 if (run?.state === 'queued') return { icon: isOn ? '◌' : '○', iconTone: 'warn', title: tool.name, status: runText(run, now, m), ground: 'warn', isLit: isOn }
154 if (run) {
155 const tone: Tone = run.state === 'failed' ? 'danger' : run.state === 'stopped' ? 'warn' : 'ok'
156
157 return { icon: runMark(run).mark, iconTone: tone, title: tool.name, status: runText(run, now, m), ...(run.state === 'failed' ? { ground: 'danger' as const } : {}) }
158 }
159 const notes = [m.pressToRun, m.kindIdle[tool.kind], tool.confirm ? m.asksFirst : '', askedParams(tool).length > 0 ? m.asked : ''].filter(Boolean)
160
161 return { icon: IDLE_ICON[tool.kind], iconTone: 'accent', title: tool.name, status: notes.join(' · ') }
162}
163
164/** The quick view's first line: what runs, waits and failed, or how the tiles work when nothing does. */
165export function quickSummary(runs: Record<string, RunStatus>, m: Messages): { text: string; tone?: Tone } {
166 const all = Object.values(runs)
167 const count = (state: RunStatus['state']) => all.filter(run => run.state === state).length
168 const parts: { text: string; tone: Tone }[] = [
169 ...(count('running') > 0 ? [{ text: m.summaryRunning(count('running')), tone: 'ok' as const }] : []),
170 ...(count('queued') > 0 ? [{ text: m.summaryWaiting(count('queued')), tone: 'warn' as const }] : []),
171 ...(count('failed') > 0 ? [{ text: m.summaryFailed(count('failed')), tone: 'danger' as const }] : []),
172 ]
173 if (parts.length === 0) return { text: m.quickHint }
174
175 return { text: parts.map(part => part.text).join(' · '), tone: parts[0]?.tone }
176}
177
178/**
179 * The quick view: a line saying what runs and failed, then every tool a tile,
180 * two to a row, saying how it stands. A tile not yet run, a Claude command and
181 * a prompt run when pressed; a shell tool that has run opens its log, and ▶ on
182 * it runs it again, ■ stops it while it runs. Then Settings; the header closes the pane.
183 */
184export function QuickView(
185 ui: ElementTable,
186 model: ToolsModel,
187 actions: { run: (tool: Tool) => void; stop: (tool: Tool) => void; log: (tool: Tool) => void; settings: () => void },
188) {
189 const { Box } = ui
190 const { m } = model
191 const summary = quickSummary(model.runs, m)
192 const tiles: StatusTile[] = model.tools.map(tool => {
193 const run = model.runs[tool.id]
194 const isRunning = run?.state === 'running'
195 const hasLog = tool.kind === 'shell' && run !== undefined
196 const buttons: TileAction[] = [
197 ...(hasLog && !isRunning ? [{ key: `quick-run-${tool.id}`, glyph: '▶', tone: theme.ok, onPress: () => actions.run(tool) }] : []),
198 ...(isRunning ? [{ key: `quick-stop-${tool.id}`, glyph: '■', tone: theme.danger, onPress: () => actions.stop(tool) }] : []),
199 ...(hasLog ? [{ key: `quick-log-${tool.id}`, glyph: '≡', tone: theme.accent, onPress: () => actions.log(tool) }] : []),
200 ]
201
202 return {
203 key: `quick-${tool.id}`,
204 ...quickTile(tool, run, model.activity[tool.id], model.now, m),
205 actions: buttons,
206 onPress: () => (hasLog ? actions.log(tool) : actions.run(tool)),
207 }
208 })
209
210 return (
211 <Box key="quick" flexDirection="column">
212 {model.error && Toned(ui, 'quick-error', model.error, 'danger', { wrap: 'wrap' })}
213 {model.tools.length === 0 && Empty(ui, 'quick-empty', [m.quickEmpty])}
214 {tiles.length > 0 && Toned(ui, 'quick-summary', summary.text, summary.tone, { isDim: summary.tone === undefined, wrap: 'wrap' })}
215 {/* The tools in a box of their own, apart from the pane's Settings and Close. */}
216 {tiles.length > 0 && Card(ui, 'quick-card', false, StatusTiles(ui, model.bodyColumns - CARD_CHROME, tiles))}
217 {Tiles(ui, model.bodyColumns, [{ key: 'quick-settings', label: m.settingsButton, onPress: actions.settings }])}
218 </Box>
219 )
220}
221hooks/shared/layout.ts 145 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/** Terminal layout: gauges and their colours, cell widths, and packing cells into rows. */
3
4/** A thin bar of `width` cells, in its filled and remaining parts, so each takes its own color. */
5export function barParts(percent: number, width = 8): { filled: string; rest: string } {
6 const cells = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
7
8 return { filled: '━'.repeat(cells), rest: '━'.repeat(width - cells) }
9}
10
11/** The bar as one string: what its cells measure. */
12export function bar(percent: number, width = 8): string {
13 const { filled, rest } = barParts(percent, width)
14
15 return filled + rest
16}
17
18export function severityColor(percent: number): string {
19 if (percent >= 90) return 'red'
20 if (percent >= 70) return 'yellow'
21
22 return 'green'
23}
24
25/**
26 * Characters a terminal draws over the cell before them: combining marks,
27 * Hangul's vowel and final jamo (a decomposed syllable is one wide cell),
28 * zero-width spaces and joiners, and variation selectors.
29 */
30function isZeroWidth(code: number): boolean {
31 return (
32 (code >= 0x0300 && code <= 0x036f) ||
33 (code >= 0x1160 && code <= 0x11ff) ||
34 (code >= 0x1ab0 && code <= 0x1aff) ||
35 (code >= 0x1dc0 && code <= 0x1dff) ||
36 (code >= 0x200b && code <= 0x200f) ||
37 (code >= 0x2060 && code <= 0x2064) ||
38 (code >= 0x20d0 && code <= 0x20ff) ||
39 (code >= 0xd7b0 && code <= 0xd7ff) ||
40 (code >= 0xfe00 && code <= 0xfe0f) ||
41 (code >= 0xfe20 && code <= 0xfe2f) ||
42 code === 0xfeff ||
43 (code >= 0xe0100 && code <= 0xe01ef)
44 )
45}
46
47/** East Asian wide and fullwidth characters, and emoji drawn as pictures: two cells. */
48function isWide(code: number): boolean {
49 return (
50 (code >= 0x1100 && code <= 0x115f) ||
51 (code >= 0x2e80 && code <= 0xa4cf) ||
52 (code >= 0xa960 && code <= 0xa97f) ||
53 (code >= 0xac00 && code <= 0xd7a3) ||
54 (code >= 0xf900 && code <= 0xfaff) ||
55 (code >= 0xfe30 && code <= 0xfe4f) ||
56 (code >= 0xff00 && code <= 0xff60) ||
57 (code >= 0xffe0 && code <= 0xffe6) ||
58 (code >= 0x1f300 && code <= 0x1f64f) ||
59 (code >= 0x1f680 && code <= 0x1f6ff) ||
60 (code >= 0x1f900 && code <= 0x1f9ff) ||
61 (code >= 0x1fa70 && code <= 0x1faff) ||
62 (code >= 0x20000 && code <= 0x3fffd)
63 )
64}
65
66/** Terminal cells a string takes: wide characters take two, combining ones none. */
67export function displayWidth(text: string): number {
68 let width = 0
69 for (const char of text) {
70 const code = char.codePointAt(0) ?? 0
71 width += isZeroWidth(code) ? 0 : isWide(code) ? 2 : 1
72 }
73
74 return width
75}
76
77/**
78 * The escape sequences a program writes for a terminal, each removed whole:
79 * CSI (`ESC [`, or its one-byte form) with its parameters and final byte; OSC,
80 * DCS, SOS, PM and APC strings up to their terminator; and the short escapes,
81 * such as `ESC ( B` from `tput sgr0` and `ESC 7` saving the cursor.
82 */
83const ESCAPE_SEQUENCE =
84 /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|(?:\u001b[\]PX^_]|[\u0090\u0098\u009d-\u009f])[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?|\u001b[ -/]*[0-~]/g
85/** Every control character but tab and newline: C0, DEL and C1. */
86const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
87
88/**
89 * Text from outside the plugin (a file, a prompt, a model's answer, a
90 * program's output) as it may be drawn: its escape sequences and control
91 * characters removed, tab and newline kept. The engine refuses a whole tree
92 * whose text, label, input value or code holds one, and draws the pane blank.
93 */
94export function printable(text: string): string {
95 return text.replace(ESCAPE_SEQUENCE, '').replace(CONTROL, '')
96}
97
98/**
99 * Splits items into rows no wider than `room`, `gap` cells apart, in order;
100 * an item wider than `room` gets a row to itself.
101 */
102export function packRows<T extends { width: number }>(items: T[], room: number, gap: number): T[][] {
103 const rows: T[][] = []
104 let used = 0
105 for (const item of items) {
106 const row = rows[rows.length - 1]
107 if (row && used + gap + item.width <= room) {
108 row.push(item)
109 used += gap + item.width
110 } else {
111 rows.push([item])
112 used = item.width
113 }
114 }
115
116 return rows
117}
118
119/**
120 * `text` cut to `width` terminal cells, an ellipsis marking the cut. Counts
121 * East Asian wide characters as two cells, so a Korean label never overflows
122 * the room it was given.
123 */
124export function truncate(text: string, width: number): string {
125 if (displayWidth(text) <= width) return text
126 if (width <= 1) return width === 1 ? '…' : ''
127 let out = ''
128 let used = 0
129 for (const char of text) {
130 const cells = displayWidth(char)
131 if (used + cells > width - 1) break
132 out += char
133 used += cells
134 }
135
136 return `${out}…`
137}
138
139/** `text` padded with spaces to `width` terminal cells, on the start or the end side. */
140export function padCells(text: string, width: number, side: 'start' | 'end' = 'end'): string {
141 const fill = ' '.repeat(Math.max(0, width - displayWidth(text)))
142
143 return side === 'start' ? fill + text : text + fill
144}
145