SLOPSHOPPER

SimpleCORE Toolbox

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

newpanetoastprocessnetworktimer
v0.6.4MITupdated 2026-10-08simplecore-inc/claude-mods/mods/toolbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sc-toolbox
│ ┃ sc-toolbox ✕ › fix the failing auth test and add an audit log call │ ┃ [SC] Toolbox ✕ Close │ ┃ 1: Tools ● sc-toolbox: sc-toolbox: ENOENT: no such file /plugins/sc-toolbox/.c │ ┃ ⏺ Read(src/auth.ts) │ ┃ Tools .toolbox/toolbox.json ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ No tools yet. ⏺ Bash(bun test) │ ┃ The Add tab finds this project's build ⎿ 3 pass, 1 fail │ ┃ tasks and Claude's commands, or takes a │ ┃ command of your own. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ▶ Quick run │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sc-toolbox
[SC] Toolbox ✕ Close 1: Tools Tools .toolbox/toolbox.json No tools yet. The Add tab finds this project's build tasks and Claude's commands, or takes a command of your own. ▶ Quick run
README

SimpleCORE Claude Mods

macOS: tested Linux: untested WSL: tested Windows: untested License: MIT

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.

What's inside

Accounts · /sc:accounts

The accounts pane

The status band above the prompt

  • Keep several Claude logins and switch every running session to another in one step.
  • See each account's five-hour, weekly and per-model usage, with reset times, and what it spent past its plan when Claude reports it.
  • Usage: the tokens used on this machine by day, model and project, counted from Claude Code's transcripts.
  • Storage: what ~/.claude holds by kind and project, and a cleanup of idle sessions confirmed by typing a word.
  • A status band above the prompt: account, model and effort, task, context and usage, branch and PR, lines changed. Press the account's name to open the accounts pane, or the branch or the lines changed to open the workspace.
  • An optional webhook sends the status to an external system. It is off by default; leave it off if you have nothing to send to.

Workspace · /sc:workspace

The workspace pane

  • Agents: the session's subagents, what each did last and the answers of those that finished, and the repository's worktrees; stop an agent, open a worktree's changes, remove a merged one.
  • Checkpoints: a snapshot of the working tree before every prompt; compare with one, see one turn's changes, name and pin one, or restore it.
  • Notes: numbered notes kept per project, optionally sent to Claude with every prompt; Claude can mark one done for you to confirm.
  • Diff: the files changed since the session started or since a checkpoint, up to now or a later checkpoint; each file's diff, restoring one file, and a request for a commit message.
  • Memory: the memory files Claude Code reads, global and project apart, to read as Markdown and search line by line.

Toolbox · /sc:toolbox

The toolbox buttons

  • The project's commands as buttons of one size, from ⚒ Toolbox on the status band.
  • Run and stop shell commands, and watch their log live in a dialog.
  • Add tasks found in npm, Vite, Maven, Gradle, Cargo, make, just, Compose, Go and Python projects, or Claude's own commands such as /clear and /compact.
  • Values fixed or asked at each run, with paths completed as you type.
  • Kept in .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.

Clicking and the keyboard

RequirementWhy
Claude Code in fullscreen mode: /tui fullscreen, or "tui": "fullscreen" in ~/.claude/settings.jsonOnly 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 onA 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.
  • A digit picks a tab; Esc goes back from a dialog and closes a pane.
  • Every pane also opens from its command: /sc:accounts, /sc:workspace, /sc:toolbox.

The first pane a command opens outside fullscreen mode says so in its reply.

Install

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

Update

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.

Uninstall

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:

WhatWhere
Saved loginsmacOS: keychain items of the service account-switch. Elsewhere: ~/.claude/account-switch/
Webhook tokenmacOS: 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 valuesin each project: .toolbox/
Notes and checkpoint listsin each repository's git folder: .git/sc-workspace/, a pair of files per worktree
Checkpointsin 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.

Language

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

Developing a mod covers the layout, the rules the engine enforces, the checks and how to release.

License

MIT

Source 13 files
hooks/register.tsx 1000 lines
1import { 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}
1000
hooks/detect.ts 369 lines
1/**
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}
369
hooks/i18n.ts 231 lines
1import 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}
231
hooks/paths.ts 190 lines
1/**
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}
190
hooks/shared/release.ts 102 lines
1// 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}
102
hooks/shared/panes.ts 13 lines
1// 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}
13
hooks/shared/kit.tsx 1111 lines
1// 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}
1111
hooks/shared/locale.ts 72 lines
1// 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}
72
hooks/tools.ts 238 lines
1/**
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}
238
hooks/views/add.tsx 110 lines
1import 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}
110
hooks/views/tools.tsx 221 lines
1import 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}
221
hooks/shared/layout.ts 145 lines
1// 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