SLOPSHOPPER

SimpleCORE Workspace

A workspace pane for Claude Code: agents and worktrees, checkpoints, notes, changes and memory files in one tabbed pane

newpaneguardtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sc-workspace
│ ┃ sc-workspace ✕ › fix the failing auth test and add an audit log call │ ┃ [SC] Workspace ✕ Close │ ┃ 1: Agents ● sc-workspace: sc-workspace: ENOENT: no such file /plugins/sc-worksp │ ┃ ● sc-workspace: sc-workspace: undefined is not an object (evaluating │ ┃ Agents ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No subagents in this session yet. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Worktrees 0 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Loading… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Refresh │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sc-workspace
[SC] Workspace ✕ Close 1: Agents Agents No subagents in this session yet. Worktrees 0 Loading… Refresh
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 21 files
hooks/register.tsx 1657 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentActivity, AgentRow, CheckpointRow, DiffPoint, DiffView, MemoryFile, MemoryKind, MemoryScope, Note, Tab, WorktreeRow } from '../types'
5import { claudeFiles, frontmatterOf, importsOf, isPathRule, markdownPages, outlineOf, pageOfLine, reflow, searchMemory } from './memory'
6import { MemoryTab } from './views/memory'
7import { releaseHeader } from './shared/release'
8import { LATEST_RELEASE_KEY, RELEASE_CHECK_MS, latestReleaseUrl, latestVersion } from './shared/release'
9import type { RunningRelease } from './shared/release'
10import { projectFolder } from './shared/claude'
11import { checkpointRef, clipDiff, diffPages, finishAgents, keptCheckpoints, promptLabel, shortPath } from './git'
12import { adoptStored, changeList, listPaths, readList } from './lists'
13import type { ListFiles } from './lists'
14import { messagesFor, resolveLocale } from './i18n'
15import type { Locale, Messages } from './i18n'
16import { isBesideOtherPanes } from './shared/panes'
17import { ChoiceDialog, CodeDialog, Dialog, Header, InputDialog, paneTitle, ReaderDialog, Rule, TabBar, Tiles } from './shared/kit'
18import { doneMarks, nextSeq, notesContext, numbered } from './notes'
19import type { DialogLine, Tile } from './shared/kit'
20import { releaseDateOf } from './shared/locale'
21import { AgentsTab, toolSummary } from './views/agents'
22import { checkpointLabel, CheckpointsTab, clockOf } from './views/checkpoints'
23import { baseChoices, commitPrompt, DiffTab, targetChoices } from './views/diff'
24import { NotesTab } from './views/notes'
25import {
26  createCheckpoint,
27  deleteRef,
28  diffFiles,
29  diffSummary,
30  fileDiff,
31  listWorktrees,
32  mergeBase,
33  removeWorktree,
34  repoRoot,
35  restoreCheckpoint,
36  restoreFile,
37  snapshotIndexPath,
38  snapshotTree,
39} from './workspace'
40import type { Run } from './workspace'
41import { removeArgv } from './shared/files'
42
43const tab = atom({ plugin: 'sc-workspace', key: 'tab' } as const, 'agents')
44const agents = atom({ plugin: 'sc-workspace', key: 'agents' } as const, [])
45const worktrees = atom({ plugin: 'sc-workspace', key: 'worktrees' } as const, [])
46const repoError = atom({ plugin: 'sc-workspace', key: 'repoError' } as const, null)
47const checkpoints = atom({ plugin: 'sc-workspace', key: 'checkpoints' } as const, [])
48const notes = atom({ plugin: 'sc-workspace', key: 'notes' } as const, [])
49const diff = atom({ plugin: 'sc-workspace', key: 'diff' } as const, null)
50const busy = atom({ plugin: 'sc-workspace', key: 'busy' } as const, null)
51const clock = atom({ plugin: 'sc-workspace', key: 'clock' } as const, 0)
52const dialog = atom({ plugin: 'sc-workspace', key: 'dialog' } as const, null)
53const focused = atom({ plugin: 'sc-workspace', key: 'focused' } as const, null)
54const paneOpen = atom({ plugin: 'sc-workspace', key: 'paneOpen' } as const, false)
55const activity = atom({ plugin: 'sc-workspace', key: 'activity' } as const, {})
56const answers = atom({ plugin: 'sc-workspace', key: 'answers' } as const, {})
57const finished = atom({ plugin: 'sc-workspace', key: 'finished' } as const, [])
58const expandedAgent = atom({ plugin: 'sc-workspace', key: 'expandedAgent' } as const, null)
59const memory = atom({ plugin: 'sc-workspace', key: 'memory' } as const, null)
60const memoryQuery = atom({ plugin: 'sc-workspace', key: 'memoryQuery' } as const, '')
61const memoryScope = atom({ plugin: 'sc-workspace', key: 'memoryScope' } as const, 'all')
62const memoryOpen = atom({ plugin: 'sc-workspace', key: 'memoryOpen' } as const, null)
63const memoryPage = atom({ plugin: 'sc-workspace', key: 'memoryPage' } as const, 0)
64const diffPage = atom({ plugin: 'sc-workspace', key: 'diffPage' } as const, 0)
65/** Where the organisation's policy memory sits, on macOS, Linux and WSL, and Windows: whichever exists is read. */
66const MANAGED_MEMORY = ['/Library/Application Support/ClaudeCode/CLAUDE.md', '/etc/claude-code/CLAUDE.md', 'C:/Program Files/ClaudeCode/CLAUDE.md']
67/** How many hops of `@path` imports Claude Code follows. */
68const IMPORT_HOPS = 4
69/** The largest memory file Claude Code loads whole; a larger one is skipped. */
70const MEMORY_MAX_BYTES = 4 * 1024 * 1024
71/** How deep the rules folders are searched for `.md` files. */
72const RULES_DEPTH = 6
73/** Finished agents kept for the session; older ones are let go. */
74const FINISHED_KEPT = 20
75
76const PANE = 'sc-workspace'
77
78/** The mod's name in the pane's title; a name, so it is never translated. */
79const MOD_NAME = 'Workspace'
80
81/** The pane's label on the engine's tab row, shown while another pane is open beside it. */
82const TAB_LABEL = 'Workspace'
83
84const COMMAND = 'sc:workspace'
85/** How long after a /clear ends the old session the new one is taken up, once the engine has switched ids. */
86const CLEAR_SETTLE_MS = 300
87/**
88 * The accounts mod's band cells, the place and the lines changed, which toggle
89 * this pane on its Diff tab. A cell is a row of Buttons, one per coloured run:
90 * `band-place`, then `band-place-1`, `band-place-2` and on.
91 */
92const BAND = { plugin: 'sc-accounts', cells: ['band-place', 'band-lines'] }
93
94function isBandCell(element: string): boolean {
95  return BAND.cells.some(cell => element === cell || element.startsWith(`${cell}-`))
96}
97/** The `/config` row of the `notesInContext` setting. */
98const NOTES_SETTING = 'sc-workspace.notesInContext'
99const TABS: Tab[] = ['agents', 'checkpoints', 'notes', 'diff', 'memory']
100const AGENTS_POLL_MS = 3000
101const WORKTREES_POLL_MS = 15_000
102/** How often the open pane's Checkpoints, Diff or Notes tab is brought up to date. */
103const LIVE_POLL_MS = 5000
104/** How long file-changing tool calls must settle before the tab is refreshed. */
105const LIVE_SETTLE_MS = 1500
106/** Tools whose calls may change files in the working tree. */
107const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
108/** Checkpoints kept per project; older ones lose their refs. */
109const CHECKPOINTS_KEPT = 50
110/** Checkpoints whose changes since are counted and shown. */
111const CHECKPOINTS_SHOWN = 12
112/** Lines of one file's diff shown in the Diff tab. */
113const DIFF_LINES = 5_000
114/** Characters of a file's diff kept for its dialog, which shows it a page at a time. */
115const DIFF_DIALOG_CHARS = 400_000
116/** Lines of a diff on one page of its dialog: a fixed count, so the dialog keeps its height. */
117const DIFF_PAGE_LINES = 24
118
119let locale: Locale = 'en'
120let m: Messages = messagesFor(locale)
121/** Whether this session has been told, once, that clicks need fullscreen mode. */
122let isClickHintShown = false
123
124/** Marks the click hint as shown when `text` carries it. */
125function opened(text: string): string {
126  if (text.includes(m.clickHint)) isClickHintShown = true
127
128  return text
129}
130let release: RunningRelease = {}
131/** The repository's top directory, or null outside a repository. */
132let root: string | null = null
133let home: string | undefined
134/** Windows proper (not WSL): files are deleted with cmd.exe, which has no `rm`. */
135let isWindows = false
136let sessionId = ''
137/** The checkpoint taken as the session started: the Diff tab's default base. */
138let baseline: CheckpointRow | undefined
139/** This session's snapshot index under the git directory, set as the session starts. */
140let snapshotIndex = ''
141/** When this session first saw each agent, for its elapsed time. */
142const firstSeen = new Map<string, number>()
143
144function message(error: unknown): string {
145  return error instanceof Error ? error.message : String(error)
146}
147
148function debug($: EngineInterface, error: unknown): void {
149  $.ui.log(`sc-workspace: ${message(error)}`, { to: 'debug' })
150}
151
152/** The command runner the git helpers take, over `$.process.run`. */
153function runner($: EngineInterface): Run {
154  return (argv, init) => $.process.run(argv, init)
155}
156
157/** A snapshot of the working tree in this session's own index, after any snapshot already taken in it. */
158async function snapshot($: EngineInterface): Promise<string> {
159  if (!root) throw new Error(m.checkpointsNeedGit)
160
161  return snapshotTree(runner($), root, snapshotIndex)
162}
163
164/**
165 * This session's snapshot index in each other worktree it may have shown the
166 * changes of: deleted with its own when the session ends. Read from the
167 * worktrees listed, so a reload of the plugin between leaves none behind.
168 */
169async function otherSnapshotIndexes($: EngineInterface): Promise<string[]> {
170  const run = runner($)
171  const found: string[] = []
172  for (const row of await read($, worktrees)) {
173    if (row.path === root) continue
174    const path = await snapshotIndexPath(run, row.path, sessionId).catch((error: unknown) => {
175      debug($, error)
176
177      return ''
178    })
179    if (path !== '') found.push(path)
180  }
181
182  return found
183}
184
185/** The plugin store's keys for a project's lists, from before they were files: read once, to move them. */
186const checkpointsKey = (project: string) => `checkpoints:${project}`
187const notesKey = (project: string) => `notes:${project}`
188
189/** The files of this working tree's notes and checkpoint list; undefined outside a repository. */
190let lists: { notes: string; checkpoints: string } | undefined
191
192/** The file calls the lists are kept through, over `$.fs`. */
193function listFiles($: EngineInterface): ListFiles {
194  return {
195    exists: path => $.fs.exists(path),
196    read: async path => {
197      const text = await $.fs.read(path)
198
199      return typeof text === 'string' ? text : ''
200    },
201    write: (path, text) => $.fs.write(path, text),
202  }
203}
204
205function tabLabel(key: Tab): string {
206  return { agents: m.tabAgents, checkpoints: m.tabCheckpoints, notes: m.tabNotes, diff: m.tabDiff, memory: m.tabMemory }[key]
207}
208
209function projectName(): string {
210  return root ? (root.split('/').pop() ?? root) : ''
211}
212
213async function readRelease($: EngineInterface): Promise<RunningRelease> {
214  try {
215    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string; repository?: string }
216    if (typeof manifest.version !== 'string') return {}
217    const changelogPath = `${$.plugin.root}/CHANGELOG.md`
218    const changelog = (await $.fs.exists(changelogPath)) ? await $.fs.read(changelogPath) : ''
219
220    return { version: manifest.version, date: releaseDateOf(changelog, manifest.version), repository: manifest.repository }
221  } catch (error) {
222    debug($, error)
223
224    return {}
225  }
226}
227
228/** Hears of the latest published release, so the pane header says when an update is due. */
229async function followLatestRelease($: EngineInterface): Promise<void> {
230  const url = latestReleaseUrl(release.repository)
231  if (url === null) return
232  const latest = await latestVersion(
233    {
234      now: () => $.clock.now(),
235      get: () => $.store.get(LATEST_RELEASE_KEY),
236      set: value => $.store.set(LATEST_RELEASE_KEY, value),
237      fetch: (target, init) => $.http.fetch(target, init),
238    },
239    url,
240  )
241  release = { ...release, latest }
242}
243
244// ── agents and worktrees ──────────────────────────────────────────────────
245
246async function refreshAgents($: EngineInterface): Promise<void> {
247  const now = await $.clock.now()
248  const rows: AgentRow[] = (await $.agent.list()).map(agent => {
249    if (!firstSeen.has(agent.id)) firstSeen.set(agent.id, now)
250
251    return {
252      id: agent.id,
253      stopIds: [...new Set([agent.id, agent.name, agent.teammateId].filter((one): one is string => typeof one === 'string' && one !== ''))],
254      label: agent.name ?? agent.description,
255      type: agent.type,
256      status: agent.status,
257      firstSeen: firstSeen.get(agent.id) ?? now,
258    }
259  })
260  const current = await read($, agents)
261  if (JSON.stringify(current) !== JSON.stringify(rows)) await update($, agents, () => rows)
262  // An agent the engine stops listing moves to the finished group, with its answer, for the session.
263  const listed = new Set(rows.map(row => row.id))
264  const kept = await read($, answers)
265  const before = await read($, finished)
266  const after = finishAgents(current, rows, before, kept, now, FINISHED_KEPT)
267  if (JSON.stringify(after) !== JSON.stringify(before)) await update($, finished, () => after)
268  // Activity and answers are kept for the agents listed or finished; the rest go.
269  const known = new Set([...listed, ...(await read($, finished)).map(row => row.id)])
270  const recorded = await read($, activity)
271  if (Object.keys(recorded).some(id => !known.has(id))) {
272    await update($, activity, value => Object.fromEntries(Object.entries(value).filter(([id]) => known.has(id))))
273  }
274  const answered = await read($, answers)
275  if (Object.keys(answered).some(id => !known.has(id))) {
276    await update($, answers, value => Object.fromEntries(Object.entries(value).filter(([id]) => known.has(id))))
277  }
278  // While an agent works its elapsed time moves: a clock tick redraws the rows that read it.
279  if (rows.some(row => row.status === 'running' || row.status === 'pending' || row.status === 'waiting')) {
280    await update($, clock, () => now)
281  }
282}
283
284async function refreshWorktrees($: EngineInterface): Promise<void> {
285  if (!root) return
286  const rows = await listWorktrees(runner($), root)
287  const current = await read($, worktrees)
288  if (JSON.stringify(current) !== JSON.stringify(rows)) await update($, worktrees, () => rows)
289}
290
291/**
292 * Stops an agent through Claude Code's TaskStop, by its id first and then by
293 * the other names TaskStop takes. A refusal (the person's, a hook's) is said
294 * at once and not asked again; a stop no name reaches says why.
295 */
296async function stopAgent($: EngineInterface, agent: AgentRow): Promise<string> {
297  let reason = ''
298  try {
299    for (const taskId of agent.stopIds) {
300      const result = await $.tool.call({ tool: 'TaskStop', task_id: taskId })
301      if (result.deny !== undefined) throw new Error(m.stopFailed(agent.label, result.deny))
302      if (result.isError !== true) return m.stopped(agent.label)
303      reason = result.text ?? reason
304    }
305    throw new Error(m.stopFailed(agent.label, reason))
306  } finally {
307    await refreshAgents($)
308  }
309}
310
311async function dropWorktree($: EngineInterface, row: WorktreeRow): Promise<string> {
312  if (!root) throw new Error(m.checkpointsNeedGit)
313  await removeWorktree(runner($), root, row)
314  await refreshWorktrees($)
315
316  return m.worktreeRemoved(row.path)
317}
318
319// ── checkpoints ───────────────────────────────────────────────────────────
320
321/**
322 * Changes the checkpoint list as its file holds it now and shows the result,
323 * with what changed since each as last counted, so a reload or a new session
324 * shows the counts at once. Never a list read earlier: that would write over
325 * a row another session added or a press made since.
326 */
327async function changeCheckpoints($: EngineInterface, change: (list: CheckpointRow[]) => CheckpointRow[]): Promise<CheckpointRow[]> {
328  if (!lists) return read($, checkpoints)
329  const list = await changeList<CheckpointRow>(listFiles($), lists.checkpoints, change)
330  await update($, checkpoints, () => list)
331
332  return list
333}
334
335/** The checkpoint being taken, which the next waits for: two at once would number themselves alike. */
336let checkpointTaking: Promise<unknown> = Promise.resolve()
337
338/**
339 * Takes a checkpoint unless nothing changed since the latest one (or `force`),
340 * one at a time.
341 * @returns the checkpoint taken, or null when there was nothing new
342 */
343function takeCheckpoint($: EngineInterface, kind: CheckpointRow['kind'], label: string, force = false): Promise<CheckpointRow | null> {
344  const taking = checkpointTaking.then(() => takeOneCheckpoint($, kind, label, force))
345  checkpointTaking = taking.catch(() => undefined)
346
347  return taking
348}
349
350async function takeOneCheckpoint($: EngineInterface, kind: CheckpointRow['kind'], label: string, force: boolean): Promise<CheckpointRow | null> {
351  if (!root || !lists) return null
352  const run = runner($)
353  const tree = await snapshot($)
354  const list = await readList<CheckpointRow>(listFiles($), lists.checkpoints)
355  if (!force && list[0]?.tree === tree) return null
356  // This session's rows are written by it alone, one at a time: the next number is free.
357  const own = list.filter(row => row.ref.includes(`/${sessionId}/`))
358  const seq = own.reduce((max, row) => Math.max(max, Number(row.ref.split('/').pop()) || 0), 0) + 1
359  const ref = checkpointRef(sessionId, seq)
360  const { commit } = await createCheckpoint(run, root, ref, label || m.checkpointKind[kind], tree)
361  const row: CheckpointRow = { ref, commit, tree, at: await $.clock.now(), label, kind, since: { files: 0, added: 0, removed: 0 } }
362  // Pinned checkpoints are kept whatever their age, and this session's start, the Diff tab's
363  // default base; the newest 50 others besides. Rows another session wrote meanwhile stay.
364  let gone: CheckpointRow[] = []
365  await changeCheckpoints($, current => {
366    const all = [row, ...current.filter(one => one.ref !== ref)].sort((a, b) => b.at - a.at)
367    const result = keptCheckpoints(all, CHECKPOINTS_KEPT, baseline ? [baseline.ref] : [])
368    gone = result.gone
369
370    return result.kept
371  })
372  for (const old of gone) {
373    await deleteRef(run, root, old.ref).catch((error: unknown) => debug($, error))
374  }
375
376  return row
377}
378
379/** The working tree and the checkpoints last counted against it: while both stay, so do the counts. */
380let lastCounted = ''
381
382/**
383 * Counts what changed since each checkpoint shown against the working tree now,
384 * and since the session's start, which the base dialog offers even past them.
385 */
386async function refreshSince($: EngineInterface): Promise<void> {
387  if (!root || !lists) return
388  const run = runner($)
389  const tree = await snapshot($)
390  const list = await read($, checkpoints)
391  const shown = list.slice(0, CHECKPOINTS_SHOWN)
392  const start = list.find(row => row.ref === baseline?.ref) ?? list.find(row => row.kind === 'session')
393  if (start && !shown.includes(start)) shown.push(start)
394  const counting = [tree, ...shown.map(row => row.commit)].join(' ')
395  if (counting === lastCounted && shown.every(row => row.since !== undefined)) return
396  const counted = new Map<string, CheckpointRow['since']>()
397  for (const row of shown) counted.set(row.ref, await diffSummary(run, root, row.commit, tree))
398  lastCounted = counting
399  const changed = list.some(row => counted.has(row.ref) && JSON.stringify(counted.get(row.ref)) !== JSON.stringify(row.since))
400  if (!changed) return
401  // Applied by ref to the list as its file holds it now: a checkpoint taken while these were counted stays.
402  await changeCheckpoints($, current => current.map(row => (counted.has(row.ref) ? { ...row, since: counted.get(row.ref) } : row)))
403}
404
405/** Pins a checkpoint (`isPinned`), or unpins it: a pinned one is never deleted to make room. */
406async function setPin($: EngineInterface, ref: string, isPinned: boolean): Promise<string> {
407  let isFound = false
408  await changeCheckpoints($, list =>
409    list.map(one => {
410      if (one.ref !== ref) return one
411      isFound = true
412
413      return { ...one, isPinned }
414    }),
415  )
416  if (!isFound) return m.checkpointsEmpty
417
418  return isPinned ? m.pinned : m.unpinned
419}
420
421/** Names a checkpoint and pins it; an empty name takes the name away and leaves the pin. */
422async function nameCheckpoint($: EngineInterface, ref: string, name: string): Promise<string | void> {
423  const clean = name.replace(/\s+/g, ' ').trim()
424  let isFound = false
425  await changeCheckpoints($, list =>
426    list.map(one => {
427      if (one.ref !== ref) return one
428      isFound = true
429      const { name: _old, ...rest } = one
430
431      return clean ? { ...rest, name: clean, isPinned: true } : rest
432    }),
433  )
434  if (!isFound) return m.checkpointsEmpty
435
436  return clean ? m.named(clean) : undefined
437}
438
439async function restore($: EngineInterface, row: CheckpointRow): Promise<string> {
440  if (!root) throw new Error(m.checkpointsNeedGit)
441  const before = await takeCheckpoint($, 'restore', m.checkpointKind.restore, true)
442  if (!before) throw new Error(m.checkpointsNeedGit)
443  await restoreCheckpoint(runner($), root, row.commit, row.tree, before.tree, isWindows)
444  await refreshSince($)
445  await refreshDiff($)
446
447  return m.restored(clockOf(row.at, await $.clock.now(), locale))
448}
449
450/** Puts one file of the Diff tab back as it was at the base, after a checkpoint that undoes it. */
451async function restoreOneFile($: EngineInterface, path: string): Promise<string> {
452  if (!root) throw new Error(m.checkpointsNeedGit)
453  const view = await read($, diff)
454  const file = view?.files.find(one => one.path === path)
455  if (!view || !file) return m.diffEmpty
456  const before = await takeCheckpoint($, 'restore', m.checkpointKind.restore, true)
457  if (!before) throw new Error(m.checkpointsNeedGit)
458  await restoreFile(runner($), root, view.base.commit, file, isWindows)
459  await update($, diff, current => (current && current.selected?.path === path ? { ...current, selected: undefined } : current))
460  await refreshDiff($)
461  await refreshSince($)
462
463  return m.fileRestored(path)
464}
465
466/** Records what an agent did last, for the Agents tab. */
467async function recordActivity($: EngineInterface, agentId: string, entry: Omit<AgentActivity, 'at'>): Promise<void> {
468  const at = await $.clock.now()
469  await update($, activity, current => ({ ...current, [agentId]: { ...entry, at } }))
470}
471
472// ── diff ──────────────────────────────────────────────────────────────────
473
474/** A snapshot of another worktree, untracked files included, in an index of its own for this session. */
475async function worktreeTree($: EngineInterface, path: string): Promise<string> {
476  const run = runner($)
477
478  return snapshotTree(run, path, await snapshotIndexPath(run, path, sessionId))
479}
480
481/** Shows another worktree's changes since its branch parted from the main branch, on the Diff tab. */
482async function openWorktreeDiff($: EngineInterface, row: WorktreeRow): Promise<void> {
483  if (!root || !row.branch) return
484  const main = (await read($, worktrees)).find(one => one.isMain)?.branch
485  if (!main) throw new Error(m.detached)
486  const run = runner($)
487  const commit = await mergeBase(run, root, main, row.branch)
488  const files = await diffFiles(run, root, commit, await worktreeTree($, row.path))
489  const at = await $.clock.now()
490  await update($, tab, () => 'diff')
491  await update($, diff, () => ({
492    base: { commit, label: main, at: 0, isSessionStart: false },
493    worktree: { path: row.path, branch: row.branch ?? '' },
494    files,
495    at,
496  }))
497}
498
499/** The commit or tree the Diff tab compares up to: a checkpoint's commit, or a snapshot of the working tree. */
500async function targetTree($: EngineInterface, target: DiffPoint | undefined): Promise<string> {
501  return target ? target.commit : snapshot($)
502}
503
504/**
505 * Compares `base` (the current one when absent) with `target`: a later
506 * checkpoint, or the working tree when null; the current target when absent.
507 * A base picked at or after the target compares with the working tree. The
508 * open file stays open while the ends stay.
509 */
510async function refreshDiff($: EngineInterface, base?: DiffPoint, target?: DiffPoint | null): Promise<void> {
511  if (!root) return
512  const current = await read($, diff)
513  // Another worktree's view stays on it until it is closed; only its files are counted again.
514  if (current?.worktree && base === undefined && target === undefined) {
515    const files = await diffFiles(runner($), root, current.base.commit, await worktreeTree($, current.worktree.path))
516    if (JSON.stringify(files) !== JSON.stringify(current.files)) await update($, diff, view => (view ? { ...view, files, selected: undefined } : view))
517
518    return
519  }
520  const start = baseline ?? (await read($, checkpoints)).find(row => row.kind === 'session')
521  const from = base ?? current?.base ?? (start ? { commit: start.commit, label: m.checkpointKind.session, at: start.at, isSessionStart: true } : undefined)
522  if (!from) return
523  const wanted = target === null ? undefined : (target ?? current?.target)
524  const to = wanted && wanted.at > from.at ? wanted : undefined
525  const run = runner($)
526  const tree = await targetTree($, to)
527  const files = await diffFiles(run, root, from.commit, tree)
528  const isSameEnds = !base && target === undefined
529  const openPath = isSameEnds ? current?.selected?.path : undefined
530  let selected: DiffView['selected']
531  if (openPath && files.some(file => file.path === openPath)) {
532    selected = { path: openPath, ...clipDiff(await fileDiff(run, root, from.commit, tree, openPath), DIFF_LINES, DIFF_DIALOG_CHARS) }
533  }
534  const isSame =
535    current !== null &&
536    current.base.commit === from.commit &&
537    current.target?.commit === to?.commit &&
538    JSON.stringify(current.files) === JSON.stringify(files) &&
539    JSON.stringify(current.selected) === JSON.stringify(selected)
540  if (!isSame) {
541    const at = await $.clock.now()
542    await update($, diff, () => ({ base: from, target: to, files, selected, at }))
543  }
544}
545
546/** Opens a file's diff in its dialog, on its first page: however long the file list, the diff is never below it. */
547async function openFile($: EngineInterface, path: string): Promise<void> {
548  const current = await read($, diff)
549  if (!root || !current) return
550  const run = runner($)
551  const tree = current.worktree ? await worktreeTree($, current.worktree.path) : await targetTree($, current.target)
552  const selected = { path, ...clipDiff(await fileDiff(run, root, current.base.commit, tree, path), DIFF_LINES, DIFF_DIALOG_CHARS) }
553  await update($, diff, view => (view ? { ...view, selected } : view))
554  await update($, diffPage, () => 0)
555  await update($, dialog, () => ({ kind: 'diff', ref: path }))
556  await $.ui.open({ id: PANE, title: TAB_LABEL, focus: true, closeOnEscape: true, rows: READER_PANE_ROWS })
557}
558
559// ── memory ────────────────────────────────────────────────────────────────
560
561/**
562 * The rows a reader page takes at the pane's width: the reader opens the pane
563 * 40 rows tall, and the header, the frame, the title, its line and the tiles
564 * take the rest. Fixed, not read from the pane: a pane sizes itself to what it
565 * draws, so a page cut to the rows on screen would shrink with every page.
566 */
567const READER_PAGE_ROWS = 26
568/** The pane's rows while the reader is open. */
569const READER_PANE_ROWS = 40
570/** The columns a reader page wraps at, as the pane last drew; a page is cut to them. */
571let readerRoom: { rows: number; columns?: number } = { rows: READER_PAGE_ROWS }
572
573/** Each memory file's text as last read, for the search; the files themselves are in the `memory` state. */
574const memoryTexts = new Map<string, string>()
575
576/** A file's text when it is a file Claude Code would load whole; null otherwise. */
577async function memoryText($: EngineInterface, path: string): Promise<string | null> {
578  try {
579    if (!(await $.fs.exists(path))) return null
580    const stat = await $.fs.stat(path)
581    if (stat.kind !== 'file' || stat.size > MEMORY_MAX_BYTES) return null
582
583    return await $.fs.read(path)
584  } catch (error) {
585    debug($, error)
586
587    return null
588  }
589}
590
591/** Every `.md` file under `folder`, its subfolders included. */
592async function markdownUnder($: EngineInterface, folder: string, depth = 1): Promise<string[]> {
593  if (depth > RULES_DEPTH || !(await $.fs.exists(folder))) return []
594  const found: string[] = []
595  for (const entry of await $.fs.list(folder)) {
596    const path = `${folder}/${entry.name}`
597    if (entry.kind === 'dir') found.push(...(await markdownUnder($, path, depth + 1)))
598    else if (entry.name.endsWith('.md')) found.push(path)
599  }
600
601  return found.sort()
602}
603
604/** A path as the Memory tab shows it: from the project when inside it, from home with `~` when under it. */
605function memoryDisplay(path: string, project: string): string {
606  if (path.startsWith(`${project}/`)) return path.slice(project.length + 1)
607  if (home && path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`
608
609  return path
610}
611
612/** The folders from `folder` up to the filesystem's root, nearest first. */
613function foldersUp(folder: string): string[] {
614  const found: string[] = []
615  let current = folder
616  while (current !== '' && current !== '/') {
617    found.push(current)
618    const parent = current.slice(0, current.lastIndexOf('/'))
619    if (parent === current) break
620    current = parent
621  }
622
623  return found
624}
625
626/**
627 * Reads every memory file Claude Code loads in this session, as its memory
628 * documentation lists them, with the files each imports, and keeps their
629 * outlines for the Memory tab.
630 */
631async function loadMemory($: EngineInterface): Promise<void> {
632  const run = runner($)
633  const cwd = (await $.session.root()).replaceAll('\\', '/')
634  const project = root ?? cwd
635  const configDir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home ?? ''}/.claude`).replaceAll('\\', '/')
636  const files: MemoryFile[] = []
637  const seen = new Set<string>()
638  memoryTexts.clear()
639  const add = async (path: string, scope: MemoryScope, kind: MemoryKind, hops = 0, note?: string): Promise<boolean> => {
640    if (seen.has(path)) return true
641    const text = await memoryText($, path)
642    if (text === null) return false
643    seen.add(path)
644    memoryTexts.set(path, text)
645    const front = frontmatterOf(text)
646    // A rule with `paths` in its frontmatter loads only when Claude works on a file it matches.
647    const isForMatching = kind === 'rule' && isPathRule(text)
648    files.push({
649      path,
650      display: memoryDisplay(path, project),
651      scope,
652      kind: isForMatching ? 'pathRule' : kind,
653      bytes: new TextEncoder().encode(text).length,
654      lines: text.split(/\r?\n/).length,
655      outline: outlineOf(text),
656      ...(note ? { note } : kind === 'auto' && front.description ? { note: front.description } : {}),
657    })
658    if (hops < IMPORT_HOPS) {
659      for (const imported of importsOf(text, path, home ?? '')) await add(imported, scope, 'imported', hops + 1, memoryDisplay(path, project))
660    }
661
662    return true
663  }
664  for (const path of MANAGED_MEMORY) await add(path, 'global', 'managed')
665  await add(`${configDir}/CLAUDE.md`, 'global', 'user')
666  for (const path of await markdownUnder($, `${configDir}/rules`)) await add(path, 'global', 'userRule')
667  // CLAUDE.md and CLAUDE.local.md load from the working directory and every folder above it.
668  let hasInstructions = false
669  for (const folder of foldersUp(cwd)) {
670    const isInside = folder === project || folder.startsWith(`${project}/`)
671    if (await add(`${folder}/CLAUDE.md`, 'project', isInside ? 'project' : 'parent')) hasInstructions = true
672    if (await add(`${folder}/CLAUDE.local.md`, 'project', isInside ? 'local' : 'parent')) hasInstructions = true
673  }
674  await add(`${project}/.claude/CLAUDE.md`, 'project', 'project')
675  for (const path of await markdownUnder($, `${project}/.claude/rules`)) await add(path, 'project', 'rule')
676  // AGENTS.md is read only where no CLAUDE.md or CLAUDE.local.md is.
677  if (!hasInstructions) await add(`${cwd}/AGENTS.md`, 'project', 'agents')
678  // A subfolder's CLAUDE.md loads when Claude reads files in that folder.
679  if (root) {
680    // Only those files are asked for, NUL-separated: the list stays short in any repository, and a Korean folder's name is as it is.
681    const listed = await run(['git', 'ls-files', '-z', '-co', '--exclude-standard', '--', ':(glob)**/CLAUDE.md', ':(glob)**/CLAUDE.local.md'], {
682      cwd: root,
683      timeoutMs: 30_000,
684    })
685    if (listed.exitCode === 0) {
686      for (const relative of claudeFiles(listed.stdout)) await add(`${root}/${relative}`, 'project', 'subfolder')
687    }
688  }
689  // Auto memory is kept per git repository, so every worktree of one shares it.
690  const settings = (await $.settings.read()) as { autoMemoryDirectory?: unknown }
691  let memoryRoot = project
692  if (root) {
693    const common = await run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root })
694    if (common.exitCode === 0) memoryRoot = common.stdout.trim().replace(/\/\.git\/?$/, '')
695  }
696  const autoFolder = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory.replace(/^~(?=\/)/, home ?? '~') : `${configDir}/projects/${projectFolder(memoryRoot)}/memory`
697  await add(`${autoFolder}/MEMORY.md`, 'project', 'autoIndex', 0, memoryDisplay(autoFolder, project))
698  for (const path of await markdownUnder($, autoFolder)) if (!path.endsWith('/MEMORY.md')) await add(path, 'project', 'auto')
699  // Auto memory's folder is long and named once, under its index: each file is shown from it.
700  const shortened = files.map(file =>
701    file.path.startsWith(`${autoFolder}/`) ? { ...file, display: `memory/${file.path.slice(autoFolder.length + 1)}` } : file,
702  )
703  await update($, memory, () => shortened)
704}
705
706// ── notes ─────────────────────────────────────────────────────────────────
707
708/**
709 * Changes the project's notes as their file holds them now, each numbered,
710 * and shows the result. Never a list read earlier: that would write over a
711 * note another session added or a press made since. Outside a repository
712 * the notes live in the session alone.
713 */
714async function changeNotes($: EngineInterface, change: (list: Note[]) => Note[]): Promise<Note[]> {
715  if (!lists) return update($, notes, list => change(numbered(list)))
716  const list = await changeList<Note>(listFiles($), lists.notes, stored => change(numbered(stored)))
717  await update($, notes, () => list)
718
719  return list
720}
721
722async function addNote($: EngineInterface, text: string): Promise<string | void> {
723  const clean = text.trim()
724  if (!clean) return
725  const at = await $.clock.now()
726  await changeNotes($, list => [{ id: crypto.randomUUID(), text: clean, isDone: false, at, seq: nextSeq(list) }, ...list])
727
728  return m.noteAdded(clean.length > 40 ? `${clean.slice(0, 39)}…` : clean)
729}
730
731/** Marks a note done, or open again: what the person saw it as, turned over. */
732function setNoteDone($: EngineInterface, id: string, isDone: boolean): Promise<Note[]> {
733  return changeNotes($, list => list.map(one => (one.id === id ? { ...one, isDone, isSuggestedDone: false } : one)))
734}
735
736/** Takes the project's notes as their file holds them, so a note another session wrote shows here. */
737async function reloadNotes($: EngineInterface): Promise<void> {
738  if (!lists) return
739  const stored = await readList<Note>(listFiles($), lists.notes)
740  const list = numbered(stored)
741  // A note made before numbering gets its number once, in the file too, so it keeps it.
742  if (JSON.stringify(list) !== JSON.stringify(stored)) await changeNotes($, current => current)
743  else if (JSON.stringify(list) !== JSON.stringify(await read($, notes))) await update($, notes, () => list)
744}
745
746/** Marks the notes an answer says it finished, for the person to confirm. */
747async function suggestDone($: EngineInterface, answer: string): Promise<void> {
748  const marked = new Set(doneMarks(answer))
749  if (marked.size === 0) return
750  const isMarked = (note: Note) => !note.isDone && note.seq !== undefined && marked.has(note.seq)
751  if (!(await read($, notes)).some(isMarked)) return
752  await changeNotes($, list => list.map(note => (isMarked(note) ? { ...note, isSuggestedDone: true } : note)))
753}
754
755/** Whether a refresh of the visible tab is running: a second one asked meanwhile is dropped. */
756let isLiveRefreshing = false
757/** The pending refresh after a burst of file-changing tool calls. */
758let liveRefreshTimer: { cancel: () => void } | undefined
759
760/**
761 * Brings the tab on screen up to date with the working tree and the notes'
762 * file, when the pane is open. The Memory tab is read when it opens and when
763 * Refresh is pressed: reading every memory file and listing the repository's
764 * on each beat costs a large repository more than its few changes are worth.
765 */
766async function refreshVisible($: EngineInterface): Promise<void> {
767  if (isLiveRefreshing || !(await isPaneOpen($))) return
768  isLiveRefreshing = true
769  try {
770    const active = await read($, tab)
771    if (active === 'checkpoints') await refreshSince($)
772    if (active === 'diff') await refreshDiff($)
773    if (active === 'notes') await reloadNotes($)
774  } finally {
775    isLiveRefreshing = false
776  }
777}
778
779/** Refreshes once a burst of file-changing tool calls has settled. */
780function scheduleLiveRefresh($: EngineInterface): void {
781  liveRefreshTimer?.cancel()
782  liveRefreshTimer = $.clock.after(LIVE_SETTLE_MS, () => {
783    liveRefreshTimer = undefined
784    void refreshVisible($).catch((error: unknown) => debug($, error))
785  })
786}
787
788async function openPane($: EngineInterface, next?: Tab): Promise<void> {
789  if (next) await update($, tab, () => next)
790  // Opened to show a tab: a dialog left from a draw the engine refused, or from a closed pane, goes.
791  if ((await read($, dialog)) !== null) {
792    await update($, dialog, () => null)
793    await update($, focused, () => null)
794  }
795  // Opened only by what the person did, the pane takes the keyboard: with no mouse nothing else hands it the keys.
796  await $.ui.open({ id: PANE, title: TAB_LABEL, rows: 30, focus: true, closeOnEscape: true })
797  if (!(await read($, paneOpen))) await update($, paneOpen, () => true)
798  const active = await read($, tab)
799  if (active === 'checkpoints') await refreshSince($)
800  if (active === 'diff') await refreshDiff($)
801  if (active === 'notes') await reloadNotes($)
802  if (active === 'memory') await loadMemory($)
803  if (active === 'agents') await Promise.all([refreshAgents($), refreshWorktrees($)])
804}
805
806/**
807 * Closes the pane when it is in view, else opens it on `next` (the tab last
808 * shown without one); says which. A pane behind another pane's tab, or waiting
809 * undrawn from an open nobody asked for, is opened afresh: in front, and placed.
810 */
811async function togglePane($: EngineInterface, next?: Tab): Promise<string> {
812  const pane = (await $.ui.panes()).find(one => one.id === PANE)
813  if (pane?.isShown && pane.isPlaced) {
814    // A dialog left asking would greet the next open.
815    if ((await read($, dialog)) !== null) await update($, dialog, () => null)
816    await closePane($)
817
818    return m.paneClosed
819  }
820  if (pane) await $.ui.close({ id: PANE })
821  await openPane($, next)
822
823  return m.paneOpened(tabLabel(next ?? (await read($, tab))))
824}
825
826/** The files of a working tree's lists, under its repository's git folder; undefined when git cannot say where that is. */
827async function listPathsOf($: EngineInterface, top: string): Promise<{ notes: string; checkpoints: string } | undefined> {
828  const common = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: top })
829  if (common.exitCode !== 0 || common.stdout.trim() === '') return undefined
830
831  return listPaths(common.stdout.trim().replaceAll('\\', '/'), top)
832}
833
834/**
835 * Fills the state a session draws from: its id and snapshot index, the
836 * project's notes and checkpoints, a checkpoint of the session's start, and
837 * whether the pane is open. Run at session start, and again after a /clear,
838 * whose new session starts with none.
839 */
840async function adoptSession($: EngineInterface): Promise<void> {
841  sessionId = await $.session.id()
842  root = await repoRoot(runner($), await $.session.root())
843  snapshotIndex = root ? await snapshotIndexPath(runner($), root, sessionId) : ''
844  await update($, repoError, () => (root ? null : m.checkpointsNeedGit))
845  await update($, diff, () => null)
846  lists = root ? await listPathsOf($, root) : undefined
847  if (root && lists) {
848    // Lists the plugin store held, from before they were files, move to their files once.
849    for (const [key, path] of [
850      [notesKey(root), lists.notes],
851      [checkpointsKey(root), lists.checkpoints],
852    ] as const) {
853      const stored = await $.store.get(key)
854      if (stored === undefined) continue
855      await adoptStored(listFiles($), path, stored)
856      await $.store.delete(key)
857    }
858    await reloadNotes($)
859    const kept = await readList<CheckpointRow>(listFiles($), lists.checkpoints)
860    await update($, checkpoints, () => kept)
861    // A reload runs this again in the same session: its start is the checkpoint already taken, never a new one.
862    const started = (await read($, checkpoints)).find(row => row.kind === 'session' && row.ref.includes(`/${sessionId}/`))
863    try {
864      baseline = started ?? (await takeCheckpoint($, 'session', m.checkpointKind.session)) ?? (await read($, checkpoints))[0]
865    } catch (error) {
866      debug($, error)
867    }
868  }
869  if (await syncPaneOpen($)) await refreshVisible($)
870}
871
872/** Closes the pane and says so at once: the engine raises no ui.close to the plugin that asked. */
873async function closePane($: EngineInterface): Promise<void> {
874  await $.ui.close({ id: PANE })
875  if (await read($, paneOpen)) await update($, paneOpen, () => false)
876}
877
878/** Brings `paneOpen` in line with the panes the engine holds, however the pane was closed. */
879async function syncPaneOpen($: EngineInterface): Promise<boolean> {
880  const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
881  if ((await read($, paneOpen)) !== isOpen) await update($, paneOpen, () => isOpen)
882
883  return isOpen
884}
885
886async function isPaneOpen($: EngineInterface): Promise<boolean> {
887  return (await $.ui.panes()).some(pane => pane.id === PANE)
888}
889
890/**
891 * What the dialog asks about: one checkpoint to restore, note to delete, agent
892 * to stop, worktree to remove or file to put back (`file`, by path); or, for
893 * `base` and `target`, which checkpoints the Diff tab compares (`ref` unused).
894 */
895type Dialog = { kind: 'restore' | 'note' | 'stop' | 'worktree' | 'base' | 'target' | 'file' | 'name' | 'memoryScope' | 'memoryRead' | 'diff'; ref: string }
896
897/** The checkpoints the Diff tab's base is picked from: the newest shown, and the session's own start. */
898async function baseCandidates($: EngineInterface) {
899  const rows = await read($, checkpoints)
900  const start = baseline ?? rows.find(row => row.kind === 'session')
901  const listed = rows.slice(0, CHECKPOINTS_SHOWN)
902  if (start && !listed.some(row => row.commit === start.commit)) listed.push(start)
903
904  return listed.map(row => ({
905    commit: row.commit,
906    label: checkpointLabel(row, m),
907    at: row.at,
908    isSessionStart: row.kind === 'session',
909    since: row.since,
910  }))
911}
912
913/**
914 * What the dialog says for what it asks about, and what its confirming button
915 * runs; null when that thing is gone (the dialog then shows nothing to confirm).
916 */
917async function dialogSpec(
918  $: EngineInterface,
919  asked: Dialog,
920): Promise<{ title: string; lines: DialogLine[]; confirm: string; run: () => Promise<string | void> } | null> {
921  if (asked.kind === 'base' || asked.kind === 'target' || asked.kind === 'name' || asked.kind === 'memoryScope' || asked.kind === 'memoryRead' || asked.kind === 'diff') return null
922  const now = await $.clock.now()
923  if (asked.kind === 'restore') {
924    const row = (await read($, checkpoints)).find(one => one.ref === asked.ref)
925    if (!row) return null
926    const since = row.since
927
928    return {
929      title: m.restoreTitle(clockOf(row.at, now, locale)),
930      lines: [
931        { text: m.restoreFrom(checkpointLabel(row, m)) },
932        ...(since && since.files > 0 ? [{ text: m.restoreUndoes(m.filesCount(since.files), since.added, since.removed), tone: 'danger' as const }] : []),
933        { text: m.restoreUndoHint, tone: 'muted' as const },
934      ],
935      confirm: m.restoreConfirm,
936      run: () => restore($, row),
937    }
938  }
939  if (asked.kind === 'file') {
940    const view = await read($, diff)
941    const file = view && !view.target ? view.files.find(one => one.path === asked.ref) : undefined
942    if (!view || !file) return null
943
944    return {
945      title: m.fileRestoreTitle(file.path, clockOf(view.base.at, now, locale)),
946      lines: [
947        { text: m.restoreFrom(view.base.label) },
948        file.status === 'added'
949          ? { text: m.fileRestoreAdded, tone: 'danger' as const }
950          : file.status === 'renamed' && file.from
951            ? { text: m.fileRestoreRenamed(file.from), tone: 'danger' as const }
952            : { text: m.fileRestoreUndoes(file.added ?? 0, file.removed ?? 0), tone: 'danger' as const },
953        { text: m.restoreUndoHint, tone: 'muted' as const },
954      ],
955      confirm: m.fileRestoreConfirm,
956      run: () => restoreOneFile($, file.path),
957    }
958  }
959  if (asked.kind === 'note') {
960    const note = (await read($, notes)).find(one => one.id === asked.ref)
961    if (!note) return null
962
963    return {
964      title: m.noteDeleteTitle,
965      lines: [{ text: `“${note.text}”` }, { text: m.cannotUndo, tone: 'muted' }],
966      confirm: m.deleteConfirm,
967      run: async () => {
968        await changeNotes($, list => list.filter(one => one.id !== note.id))
969      },
970    }
971  }
972  if (asked.kind === 'stop') {
973    const agent = (await read($, agents)).find(one => one.id === asked.ref)
974    if (!agent) return null
975
976    return {
977      title: m.stopTitle(agent.label),
978      lines: [{ text: `${agent.type} · ${m.agentStatus[agent.status]}` }, { text: m.stopHint, tone: 'muted' }],
979      confirm: m.stopConfirm,
980      run: () => stopAgent($, agent),
981    }
982  }
983  const row = (await read($, worktrees)).find(one => one.path === asked.ref)
984  if (!row) return null
985
986  return {
987    title: m.worktreeTitle(row.branch ?? m.detached),
988    lines: [{ text: shortPath(row.path, root ?? '', home) }, { text: m.worktreeHint, tone: 'danger' }],
989    confirm: m.removeConfirm,
990    run: () => dropWorktree($, row),
991  }
992}
993
994// ── hooks ─────────────────────────────────────────────────────────────────
995
996export const register: Register = (on, options) => {
997  const isCheckpointEveryPrompt = options.checkpointEveryPrompt !== false
998  const isNotesInContext = options.notesInContext === true
999
1000  on('session.start', async ($, e, next) => {
1001    const { language } = await $.settings.read()
1002    locale = resolveLocale(language, [await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')])
1003    m = messagesFor(locale)
1004    release = await readRelease($)
1005    // A later published release turns the header's date into Update Required: asked now and every few hours.
1006    void followLatestRelease($).catch((error: unknown) => debug($, error))
1007    $.clock.every(RELEASE_CHECK_MS, () => {
1008      void followLatestRelease($).catch((error: unknown) => debug($, error))
1009    })
1010    // Windows has USERPROFILE and backslashes; git prints its paths with forward slashes.
1011    home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')))?.replaceAll('\\', '/')
1012    isWindows = (await $.env.get('OS')) === 'Windows_NT'
1013    // A pane an earlier build opened and this one no longer draws (the old dialog pane) would
1014    // stay on screen empty, past the workspace's own Close: close every pane but the workspace.
1015    for (const pane of await $.ui.panes()) {
1016      if (pane.id !== PANE) await $.ui.close({ id: pane.id }).catch((error: unknown) => debug($, error))
1017    }
1018    await adoptSession($)
1019    // Read at once, not at the first poll: a row a reload left in an earlier shape is replaced before it is pressed.
1020    await refreshAgents($).catch((error: unknown) => debug($, error))
1021    $.clock.every(AGENTS_POLL_MS, () => void refreshAgents($).catch((error: unknown) => debug($, error)))
1022    // Edits made outside the session (an editor, a script) show within a few seconds too.
1023    $.clock.every(LIVE_POLL_MS, () => {
1024      void syncPaneOpen($)
1025        .then(() => refreshVisible($))
1026        .catch((error: unknown) => debug($, error))
1027    })
1028    $.clock.every(WORKTREES_POLL_MS, () => {
1029      void isPaneOpen($)
1030        .then(isOpen => (isOpen ? refreshWorktrees($) : undefined))
1031        .catch((error: unknown) => debug($, error))
1032    })
1033
1034    return next(e)
1035  })
1036
1037  // Esc (or the close mark) while the dialog asks cancels the dialog and keeps the pane.
1038  on('ui.close', async ($, e, next) => {
1039    if (e.id === PANE && e.origin.kind === 'person' && (await read($, dialog)) !== null) {
1040      await update($, dialog, () => null)
1041      await update($, focused, () => null)
1042
1043      return { value: undefined }
1044    }
1045    const result = await next(e)
1046    if (e.id === PANE && (await read($, paneOpen))) await update($, paneOpen, () => false)
1047
1048    return result
1049  })
1050
1051  // The session's snapshot indexes go with it; checkpoints are commits and stay. A /clear goes
1052  // on in this process under a new session id with no session.start: the new session is taken
1053  // up here, once the old one has ended.
1054  on('session.end', async ($, e, next) => {
1055    const indexes = [snapshotIndex, ...(await otherSnapshotIndexes($))].filter(path => path !== '')
1056    if (indexes.length > 0) {
1057      await $.process.run(removeArgv(indexes, isWindows), { timeoutMs: 5000 }).catch((error: unknown) => debug($, error))
1058    }
1059    const result = await next(e)
1060    if (e.reason === 'clear') {
1061      $.clock.after(CLEAR_SETTLE_MS, () => {
1062        void adoptSession($).catch((error: unknown) => debug($, error))
1063      })
1064    }
1065
1066    return result
1067  })
1068
1069  // Where the keyboard is in the pane, so outlined tiles can show it.
1070  on('ui.focus', async ($, e, next) => {
1071    const result = await next(e)
1072    if (e.requestId === PANE) await update($, focused, () => e.element ?? null)
1073
1074    return result
1075  })
1076
1077  // A file-changing tool call, a subagent's too, refreshes the visible tab once the burst settles.
1078  on('tool.call', async ($, e, next) => {
1079    // A subagent's or a teammate's call is what it is doing now.
1080    if (e.agentId) {
1081      void recordActivity($, e.agentId, { text: toolSummary(String(e.tool), e as unknown as Record<string, unknown>), isAnswer: false }).catch(
1082        (error: unknown) => debug($, error),
1083      )
1084    }
1085    const result = await next(e)
1086    if (FILE_TOOLS.has(String(e.tool))) scheduleLiveRefresh($)
1087
1088    return result
1089  })
1090
1091  // A new agent shows at once, not at the next poll.
1092  on('agent.spawn', async ($, e, next) => {
1093    const result = await next(e)
1094    void refreshAgents($).catch((error: unknown) => debug($, error))
1095
1096    return result
1097  })
1098
1099  // A checkpoint before every prompt, so whatever the turn changes can be undone.
1100  on('turn.start', async ($, e, next) => {
1101    if (isCheckpointEveryPrompt && root) {
1102      try {
1103        await takeCheckpoint($, 'turn', promptLabel(e.text))
1104      } catch (error) {
1105        debug($, error)
1106      }
1107    }
1108
1109    return next(e)
1110  })
1111
1112  // After a turn the open pane catches up: changes since each checkpoint, the diff, the worktrees.
1113  on('turn.complete', async ($, e, next) => {
1114    const result = await next(e)
1115    // An agent's turn ends with its answer: its first line is what it did last, and the whole is
1116    // kept for the finished group. Its status is read again at once, so the row never shows
1117    // `running` beside an answer.
1118    if (e.agentId) {
1119      const agentId = e.agentId
1120      const answer = e.answer.trim()
1121      void (async () => {
1122        if (answer !== '') {
1123          await recordActivity($, agentId, { text: answer.split('\n')[0] ?? '', isAnswer: true })
1124          await update($, answers, value => ({ ...value, [agentId]: answer }))
1125        }
1126        await refreshAgents($)
1127      })().catch((error: unknown) => debug($, error))
1128    } else if (isNotesInContext) {
1129      // The main loop's answer may say it finished a note sent with the prompt.
1130      void suggestDone($, e.answer).catch((error: unknown) => debug($, error))
1131    }
1132    void (async () => {
1133      if (!(await isPaneOpen($))) return
1134      const active = await read($, tab)
1135      if (active === 'checkpoints') await refreshSince($)
1136      if (active === 'diff') await refreshDiff($)
1137      if (active === 'agents') await refreshWorktrees($)
1138    })().catch((error: unknown) => debug($, error))
1139
1140    return result
1141  })
1142
1143  // Open notes ride along with every prompt when the setting asks for it.
1144  on('prompt.submit', async ($, e, next) => {
1145    if (!isNotesInContext) return next(e)
1146    const open = (await read($, notes)).filter(note => !note.isDone)
1147    if (open.length === 0) return next(e)
1148    const block = notesContext(projectName(), open)
1149
1150    return next({ ...e, context: [...(e.context ?? []), block] })
1151  })
1152
1153  // `commands/workspace.md` of the plugin `sc` declares /sc:workspace; this hook answers it.
1154  on('command.run', { command: COMMAND }, async ($, e) => {
1155    // Off fullscreen no click reaches a pane: the first pane a command opens says how to press without one.
1156    const hint = e.presentation?.isFullscreen === false && !isClickHintShown ? ` ${m.clickHint}` : ''
1157    const [first = '', ...rest] = e.args.trim().split(/\s+/)
1158    try {
1159      if (first === '') {
1160        await openPane($)
1161
1162        return { text: opened(m.paneOpened(tabLabel(await read($, tab))) + hint) }
1163      }
1164      // `toggle [tab]`: closes the pane when it is open, else opens it, on `tab` when one is named.
1165      if (first === 'toggle') {
1166        const named = rest[0] && (TABS as string[]).includes(rest[0]) ? (rest[0] as Tab) : undefined
1167        const text = await togglePane($, named)
1168
1169        return { text: text === m.paneClosed ? text : opened(text + hint) }
1170      }
1171      if (first === 'notes' && rest.length > 0) {
1172        const added = await addNote($, rest.join(' '))
1173        await openPane($, 'notes')
1174
1175        return { text: opened((added ?? m.paneOpened(tabLabel('notes'))) + hint) }
1176      }
1177      // `name <text>`: names the newest checkpoint and pins it, where the surface has no field for the dialog.
1178      if (first === 'name' && rest.length > 0) {
1179        const newest = (await read($, checkpoints))[0]
1180        if (!newest) return { text: m.checkpointsEmpty }
1181        const named = await nameCheckpoint($, newest.ref, rest.join(' '))
1182        await openPane($, 'checkpoints')
1183
1184        return { text: opened((named ?? m.paneOpened(tabLabel('checkpoints'))) + hint) }
1185      }
1186      if ((TABS as string[]).includes(first)) {
1187        await openPane($, first as Tab)
1188
1189        return { text: opened(m.paneOpened(tabLabel(first as Tab)) + hint) }
1190      }
1191
1192      return { text: m.unknownTab(first) }
1193    } catch (error) {
1194      return { text: message(error) }
1195    }
1196  })
1197
1198  // The accounts band's place or lines changed, pressed: toggled here, inside the person's
1199  // press, so the pane counts as asked for and is placed at any width.
1200  on('ui.press', async ($, e, next) => {
hooks/memory.ts 255 lines
1/**
2 * Claude Code's memory files as the Memory tab shows them: each file's
3 * outline, and every line a search finds, with where it is. Pure: the hooks
4 * module finds and reads the files.
5 */
6import { displayWidth } from './shared/layout'
7
8/**
9 * The fence a line opens or closes, by CommonMark's rule: up to three spaces
10 * of indent, then three or more backticks or tildes. With `open` (the marker
11 * of the fence open now), the line closes it when it is the same character,
12 * at least as long, with nothing after it; it opens one otherwise. Returns the
13 * open fence's marker after the line, or null with none open.
14 */
15export function fenceAfter(line: string, open: string | null): string | null {
16  const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
17  if (!match?.[1]) return open
18  const marker = match[1]
19  if (open === null) return marker[0] === '`' && (match[2] ?? '').includes('`') ? null : marker
20  const closes = marker[0] === open[0] && marker.length >= open.length && (match[2] ?? '').trim() === ''
21
22  return closes ? null : open
23}
24
25/** One line of a file's outline: a heading, or an auto-memory entry's name. */
26export type OutlineEntry = { line: number; text: string; level: number }
27
28/** One line a search found: the file it is in, its number and its text. */
29export type MemoryHit = { path: string; line: number; text: string }
30
31/** An auto-memory file's frontmatter: `name`, `description` and the type under `metadata`. */
32export type MemoryFront = { name?: string; description?: string; type?: string }
33
34/** The frontmatter between the first two `---` lines, read key by key; nothing when the file has none. */
35export function frontmatterOf(text: string): MemoryFront {
36  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
37  if (!match) return {}
38  const front: MemoryFront = {}
39  for (const line of (match[1] ?? '').split(/\r?\n/)) {
40    const pair = /^\s*(name|description|type):\s*(.*)$/.exec(line)
41    if (pair?.[1] && pair[2] !== undefined) front[pair[1] as keyof MemoryFront] = pair[2].trim().replace(/^["']|["']$/g, '')
42  }
43
44  return front
45}
46
47/** Whether a rule file loads only while Claude works on files it matches: its frontmatter, and only that, names `paths`. */
48export function isPathRule(text: string): boolean {
49  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)
50
51  return front !== null && /^paths\s*:/m.test(front[1] ?? '')
52}
53
54/**
55 * A file's outline: its Markdown headings with their depth, outside fenced
56 * code; for a MEMORY.md index, each linked entry (`- [Title](file.md)`).
57 */
58export function outlineOf(text: string): OutlineEntry[] {
59  const entries: OutlineEntry[] = []
60  let fence: string | null = null
61  text.split(/\r?\n/).forEach((line, index) => {
62    const before = fence
63    fence = fenceAfter(line, fence)
64    if (before !== null || fence !== null) return
65    const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line)
66    if (heading?.[1] && heading[2]) {
67      entries.push({ line: index + 1, text: heading[2], level: heading[1].length })
68
69      return
70    }
71    const link = /^\s*[-*]\s+\[([^\]]+)\]\(([^)]+)\)\s*(?:[—–-]\s*(.*))?$/.exec(line)
72    if (link?.[1]) entries.push({ line: index + 1, text: link[3] ? `${link[1]}: ${link[3]}` : link[1], level: 3 })
73  })
74
75  return entries
76}
77
78/**
79 * The files a memory file imports with `@path`, as Claude Code reads them:
80 * outside fenced code and code spans, unquoted, a space in a path written as
81 * `\\ `; `~/` from `home`, a relative path from the importing file's folder.
82 */
83export function importsOf(text: string, file: string, home: string): string[] {
84  const folder = file.slice(0, file.lastIndexOf('/'))
85  const found: string[] = []
86  let fence: string | null = null
87  for (const line of text.split(/\r?\n/)) {
88    const before = fence
89    fence = fenceAfter(line, fence)
90    if (before !== null || fence !== null) continue
91    const outsideSpans = line.replace(/`[^`]*`/g, ' ')
92    for (const match of outsideSpans.matchAll(/(?:^|\s)@((?:\\ |[^\s"'`])+)/g)) {
93      const path = (match[1] ?? '').replace(/\\ /g, ' ').replace(/[.,;:)]+$/, '')
94      // An address or a handle (`@user`) is not a path: a path has a slash or a file extension.
95      if (!path.includes('/') && !/\.\w+$/.test(path)) continue
96      const absolute = path.startsWith('~/') ? `${home}/${path.slice(2)}` : path.startsWith('/') ? path : `${folder}/${path}`
97      found.push(normalize(absolute))
98    }
99  }
100
101  return [...new Set(found)]
102}
103
104/** A path with `.` and `..` parts resolved. */
105export function normalize(path: string): string {
106  const parts: string[] = []
107  for (const part of path.split('/')) {
108    if (part === '..') parts.pop()
109    else if (part !== '.' && part !== '') parts.push(part)
110  }
111
112  return `${path.startsWith('/') ? '/' : ''}${parts.join('/')}`
113}
114
115/**
116 * The CLAUDE.md and CLAUDE.local.md files `git ls-files -z` lists, from the
117 * repository's top: NUL-separated, so a name under a Korean folder is as it is.
118 */
119export function claudeFiles(listed: string): string[] {
120  return listed.split('\0').filter(path => /(^|\/)CLAUDE(\.local)?\.md$/.test(path))
121}
122
123/** Every line of `files` holding `query`, ignoring case, in file order; at most `limit`. */
124export function searchMemory(files: { path: string; text: string }[], query: string, limit = 50): MemoryHit[] {
125  const needle = query.trim().toLowerCase()
126  if (needle === '') return []
127  const hits: MemoryHit[] = []
128  for (const file of files) {
129    const lines = file.text.split(/\r?\n/)
130    for (let index = 0; index < lines.length && hits.length < limit; index += 1) {
131      const line = lines[index] ?? ''
132      if (line.toLowerCase().includes(needle)) hits.push({ path: file.path, line: index + 1, text: line.trim() })
133    }
134  }
135
136  return hits
137}
138
139/** One page of a file for the reader: its Markdown and the file's line it starts on. */
140export type MarkdownPage = { text: string; firstLine: number }
141
142/** The most characters a reader page asked for more than `READER_PAGE_CHARS` may hold. */
143export const MARKDOWN_LIMIT = 10_000
144/** The characters a reader page holds: about what a pane shows without scrolling, so its tiles stay in view. */
145export const READER_PAGE_CHARS = 3_000
146
147/**
148 * Joins the lines a paragraph was wrapped into, as Markdown means them: a
149 * line of text after a line of text is the same paragraph. Headings, list
150 * items, quotes, tables, rules, HTML, indented and fenced code keep their
151 * lines.
152 */
153export function reflow(text: string): string {
154  const out: string[] = []
155  let fence: string | null = null
156  let isParagraph = false
157  for (const line of text.split('\n')) {
158    const before = fence
159    fence = fenceAfter(line, fence)
160    if (before !== null || fence !== null) {
161      out.push(line)
162      isParagraph = false
163      continue
164    }
165    const isBlock = line.trim() === '' || /^(\s{4,}|\t)/.test(line) || /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||<|(-{3,}|\*{3,}|_{3,})\s*$)/.test(line)
166    if (!isBlock && isParagraph && out.length > 0) out[out.length - 1] = `${out[out.length - 1]} ${line.trim()}`
167    else out.push(line)
168    // A list item's continuation lines join it too; a blank line, another block or a hard break
169    // (two spaces or a backslash at the end) ends it.
170    const isHardBreak = / {2,}$/.test(line) || /\\$/.test(line)
171    isParagraph = line.trim() !== '' && !isHardBreak && !/^\s*(#{1,6}\s|\||<|(-{3,}|\*{3,}|_{3,})\s*$)/.test(line) && !/^(\s{4,}|\t)/.test(line)
172  }
173
174  return out.join('\n')
175}
176
177/**
178 * A memory file as pages the Markdown element can draw: the frontmatter left
179 * out, control characters but tab and newline removed, and the text cut into
180 * pages of at most `limit` characters, at a heading where one fits and at a
181 * line otherwise. A page cut inside a fenced block closes the fence, and the
182 * next page opens it again with the same marker.
183 */
184export function markdownPages(text: string, room: { chars?: number; rows?: number; columns?: number } = {}): MarkdownPage[] {
185  const limit = Math.min(room.chars ?? READER_PAGE_CHARS, MARKDOWN_LIMIT - 500)
186  // A line wraps at the reader's width; the rows a page may take leave a margin for that guess.
187  const columns = Math.max(20, room.columns ?? 80)
188  const rowLimit = room.rows === undefined ? Number.POSITIVE_INFINITY : Math.max(6, Math.floor(room.rows * 0.85))
189  // Wrapped lines are drawn joined into paragraphs, so a line of text costs its share of rows, a blank line one;
190  // a wide character takes two cells of a row.
191  const rowsOf = (line: string) => (line.trim() === '' ? 1 : displayWidth(line) / columns)
192  let rows = 0
193  const front = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(text)
194  const skipped = front ? front[0].split('\n').length - 1 : 0
195  const body = (front ? text.slice(front[0].length) : text).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
196  const lines = body.split('\n')
197  const pages: MarkdownPage[] = []
198  let current: string[] = []
199  let size = 0
200  let firstLine = skipped + 1
201  // The open fence's marker closes a page; its opening line, language and all, opens the next.
202  let fence: string | null = null
203  let opener = ''
204  const flush = (nextLine: number) => {
205    if (current.length === 0) return
206    pages.push({ text: fence ? `${current.join('\n')}\n${fence}` : current.join('\n'), firstLine })
207    current = fence ? [opener] : []
208    size = fence ? opener.length + 1 : 0
209    rows = fence ? 1 : 0
210    firstLine = nextLine
211  }
212  lines.forEach((raw, index) => {
213    const lineNumber = skipped + index + 1
214    // A line longer than a page is cut; such a line is rare in a memory file.
215    const line = raw.length > limit - 20 ? `${raw.slice(0, limit - 21)}…` : raw
216    const isHeading = fence === null && /^#{1,3}\s/.test(line)
217    // A heading starts a new page once the page is past half; any line does once it would overflow.
218    const isFull = size + line.length + 1 > limit || rows + rowsOf(line) > rowLimit
219    const isPastHalf = size > limit / 2 || rows > rowLimit / 2
220    if (isHeading && isPastHalf) flush(lineNumber)
221    else if (isFull) {
222      // A full page ends at its last blank line, so a paragraph is not cut, when that line is in its second half.
223      const blank = fence === null ? current.lastIndexOf('') : -1
224      if (blank > current.length / 2) {
225        const carried = current.slice(blank + 1)
226        current = current.slice(0, blank)
227        flush(lineNumber - carried.length)
228        current.push(...carried)
229        size = carried.reduce((sum, kept) => sum + kept.length + 1, 0)
230        rows = carried.reduce((sum, kept) => sum + rowsOf(kept), 0)
231      } else flush(lineNumber)
232    }
233    current.push(line)
234    size += line.length + 1
235    rows += rowsOf(line)
236    const before = fence
237    fence = fenceAfter(line, fence)
238    if (before === null && fence !== null) opener = line.trim()
239  })
240  fence = null
241  flush(skipped + lines.length + 1)
242
243  return pages.length > 0 ? pages : [{ text: '', firstLine: 1 }]
244}
245
246/** The page that holds `line` of the file. */
247export function pageOfLine(pages: MarkdownPage[], line: number): number {
248  let found = 0
249  pages.forEach((page, index) => {
250    if (page.firstLine <= line) found = index
251  })
252
253  return found
254}
255
hooks/views/memory.tsx 141 lines
1import type { ElementTable } from 'claude-code'
2
3import type { MemoryFile, MemoryScope } from '../../types'
4import type { Messages } from '../i18n'
5import type { MemoryHit } from '../memory'
6import { displayWidth, printable, truncate } from '../shared/layout'
7import { Card, CARD_CHROME, Empty, IconButton, InputFrame, LinkButton, OutlineList, RankList, Section, SelectField, SubLine, theme } from '../shared/kit'
8
9export type MemoryModel = {
10  files: MemoryFile[] | null
11  scope: 'all' | MemoryScope
12  query: string
13  /** The lines the search found; empty with no query. */
14  hits: MemoryHit[]
15  open: string | null
16  /** Whether the surface draws a text field (every surface but mobile). */
17  hasField: boolean
18  m: Messages
19  bodyColumns: number
20}
21
22export type MemoryActions = {
23  chooseScope: () => void
24  search: (query: string) => void
25  toggle: (file: MemoryFile) => void
26  /** Opens the reader on the file, at the page that holds `line` when one is given. */
27  read: (file: MemoryFile, line?: number) => void
28  /** Puts `@path` in the prompt, so Claude reads the file. */
29  insert: (file: MemoryFile) => void
30}
31
32/** Outline entries shown under an open file. */
33const OUTLINE_SHOWN = 40
34
35export function MemoryTab(ui: ElementTable, model: MemoryModel, actions: MemoryActions) {
36  const { Box, Text } = ui
37  const { m } = model
38  if (!model.files) return Empty(ui, 'memory-loading', [m.loading])
39  const Input = model.hasField && 'Input' in ui ? ui.Input : undefined
40  const inner = Math.max(20, model.bodyColumns - CARD_CHROME)
41  const shown = model.files.filter(file => model.scope === 'all' || file.scope === model.scope)
42  const byPath = new Map(model.files.map(file => [file.path, file]))
43  const row = (file: MemoryFile) => {
44    const isOpen = model.open === file.path
45    const facts = `${m.memoryKind[file.kind]} · ${m.memoryLines(file.lines)}`
46    // The path is cut to what the facts and the insert button leave, so the row stays one line.
47    const label = truncate(printable(file.display), Math.max(12, inner - displayWidth(facts) - 8))
48
49    return (
50      <Box key={`memory-${file.path}`} flexDirection="column">
51        <Box justifyContent="space-between">
52          <Box gap={1} flexShrink={1}>
53            {/* ▸ unfolds the outline; the name opens the whole file to read. */}
54            {IconButton(ui, `memory-outline-toggle-${file.path}`, isOpen ? '▾' : '▸', theme.accent, () => actions.toggle(file))}
55            {LinkButton(ui, `memory-open-${file.path}`, label, () => actions.read(file))}
56            <Text dimColor wrap="truncate-end">
57              {facts}
58            </Text>
59          </Box>
60          <Box flexShrink={0} marginLeft={1}>
61            {IconButton(ui, `memory-insert-${file.path}`, '↵', theme.accent, () => actions.insert(file))}
62          </Box>
63        </Box>
64        {file.note && SubLine(ui, `memory-note-${file.path}`, file.kind === 'imported' ? m.memoryImportedBy(file.note) : file.note)}
65        {isOpen &&
66          (file.outline.length > 0
67            ? OutlineList(ui, `memory-outline-${file.path}`, file.outline.slice(0, OUTLINE_SHOWN), inner)
68            : SubLine(ui, `memory-outline-${file.path}`, m.memoryEmptyOutline))}
69      </Box>
70    )
71  }
72  const group = (scope: MemoryScope) => {
73    const files = shown.filter(file => file.scope === scope)
74    if (files.length === 0) return null
75    const title = scope === 'global' ? m.memoryGlobal : m.memoryProject
76    const detail = scope === 'global' ? m.memoryGlobalDetail : m.memoryProjectDetail
77
78    return (
79      <Box key={`memory-group-${scope}`} flexDirection="column">
80        {Section(ui, `memory-${scope}-title`, title, detail)}
81        {Card(ui, `memory-${scope}-card`, false, <Box flexDirection="column">{files.map(row)}</Box>)}
82      </Box>
83    )
84  }
85
86  return (
87    <Box key="memory" flexDirection="column" gap={1}>
88      <Box key="memory-head" flexDirection="column">
89        {Section(ui, 'memory-title', m.memoryTitle, m.memoryDetail(model.files.length))}
90        <Box key="memory-scope" gap={1} marginTop={1}>
91          <Text dimColor>{m.memoryScopeLabel}</Text>
92          {SelectField(ui, 'memory-scope-field', m.memoryScope[model.scope], actions.chooseScope)}
93        </Box>
94        {Input && (
95          <Box key="memory-search" marginTop={1}>
96            {InputFrame(
97              ui,
98              'memory-search-frame',
99              true,
100              <Input key="memory-search-input" value={model.query} placeholder={m.memorySearch} onInput={value => actions.search(value)} onSubmit={value => actions.search(value)} />,
101              '⌕',
102            )}
103          </Box>
104        )}
105      </Box>
106      {model.query.trim() !== '' ? (
107        model.hits.length === 0 ? (
108          Empty(ui, 'memory-no-hits', [m.memoryNoHits(model.query.trim())])
109        ) : (
110          <Box key="memory-hits" flexDirection="column">
111            {Section(ui, 'memory-hits-title', m.memoryHits(model.hits.length, model.query.trim()))}
112            {Card(
113              ui,
114              'memory-hits-card',
115              false,
116              RankList(
117                ui,
118                'memory-hit-rows',
119                model.hits.map(hit => {
120                  const file = byPath.get(hit.path)
121
122                  // A line found opens the file at the page that holds it.
123                  return { name: hit.text, detail: `${file?.display ?? hit.path}:${hit.line}`, value: '', ...(file ? { onPress: () => actions.read(file, hit.line) } : {}) }
124                }),
125                inner,
126              ),
127            )}
128          </Box>
129        )
130      ) : shown.length === 0 ? (
131        Empty(ui, 'memory-none', [m.memoryNone])
132      ) : (
133        <Box key="memory-groups" flexDirection="column" gap={1}>
134          {group('global')}
135          {group('project')}
136        </Box>
137      )}
138    </Box>
139  )
140}
141
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/claude.ts 30 lines
1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/** Where Claude Code keeps what it writes per project under its config directory. */
3
4/** The longest project folder name Claude Code writes before it cuts the name and adds a hash. */
5const PROJECT_NAME_MAX = 200
6/** Claude Code's 32-bit string hash, the one its long project folder names end with. */
7function nameHash(text: string): number {
8  let hash = 0
9  for (let index = 0; index < text.length; index += 1) hash = ((hash << 5) - hash + text.charCodeAt(index)) | 0
10
11  return hash
12}
13
14/**
15 * The project folder Claude Code names after `root`: every character but a
16 * letter or digit becomes `-`, and a name over 200 characters is cut there
17 * and ends with `-` and a hash of the whole path, in base 36.
18 */
19export function projectFolder(root: string): string {
20  const name = root.replace(/[^a-zA-Z0-9]/g, '-')
21  if (name.length <= PROJECT_NAME_MAX) return name
22
23  return `${name.slice(0, PROJECT_NAME_MAX)}-${Math.abs(nameHash(root)).toString(36)}`
24}
25
26/** The transcript Claude Code writes for a session started in `root`, under its config directory. */
27export function transcriptPath(configDirectory: string, root: string, session: string): string {
28  return `${configDirectory}/projects/${projectFolder(root)}/${session}.jsonl`
29}
30
hooks/git.ts 256 lines
1import type { AgentRow, CheckpointRow, DiffFile, FinishedAgent, WorktreeRow } from '../types'
2import { printable } from './shared/layout'
3
4/** One worktree as `git worktree list --porcelain` describes it. */
5export type WorktreeEntry = { path: string; head: string; branch?: string; isBare: boolean; isDetached: boolean; isLocked: boolean }
6
7/** Parses `git worktree list --porcelain`: blank-line separated records of `key value` lines. */
8export function parseWorktrees(porcelain: string): WorktreeEntry[] {
9  return porcelain
10    .split(/\n\s*\n/)
11    .map(block => block.split('\n').filter(Boolean))
12    .filter(lines => lines.some(line => line.startsWith('worktree ')))
13    .map(lines => {
14      const value = (key: string) => lines.find(line => line.startsWith(`${key} `))?.slice(key.length + 1)
15      const branch = value('branch')
16
17      return {
18        path: value('worktree') ?? '',
19        head: value('HEAD') ?? '',
20        branch: branch?.replace(/^refs\/heads\//, ''),
21        isBare: lines.includes('bare'),
22        isDetached: lines.includes('detached'),
23        isLocked: lines.some(line => line === 'locked' || line.startsWith('locked ')),
24      }
25    })
26}
27
28/** `git rev-list --left-right --count main...branch` → commits only on main (behind), only on branch (ahead). */
29export function parseLeftRight(output: string): { behind: number; ahead: number } | undefined {
30  const match = /^(\d+)\s+(\d+)/.exec(output.trim())
31
32  return match ? { behind: Number(match[1]), ahead: Number(match[2]) } : undefined
33}
34
35/** Lines of `git status --porcelain`: one per changed or untracked path. */
36export function countChanged(porcelain: string): number {
37  return porcelain.split('\n').filter(line => line.trim() !== '').length
38}
39
40/** Whether a worktree can be removed without losing anything: clean, and nothing only on its branch. */
41export function isRemovable(row: WorktreeRow): boolean {
42  return !row.isMain && !row.isLocked && row.changed === 0 && (row.ahead ?? 1) === 0
43}
44
45/**
46 * Joins `git diff -z --name-status -M` and `git diff -z --numstat -M` for the
47 * same range into one row per file, a rename keyed by its new path. The NUL
48 * form names every path as it is; the line form quotes a path holding a
49 * non-ASCII byte, a quote, a backslash or a control character, and the quoted
50 * name then matches no file to show, restore or delete.
51 */
52export function parseDiffFiles(nameStatus: string, numstat: string): DiffFile[] {
53  const counts = new Map<string, { added: number | null; removed: number | null }>()
54  const stats = numstat.split('\0')
55  for (let index = 0; index < stats.length; index += 1) {
56    const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(stats[index] ?? '')
57    if (!match) continue
58    // A rename leaves the path empty here: its old and new paths follow as fields of their own.
59    let path = match[3] ?? ''
60    if (path === '') {
61      path = stats[index + 2] ?? ''
62      index += 2
63    }
64    counts.set(path, { added: match[1] === '-' ? null : Number(match[1]), removed: match[2] === '-' ? null : Number(match[2]) })
65  }
66  const files: DiffFile[] = []
67  const fields = nameStatus.split('\0')
68  for (let index = 0; index < fields.length; index += 1) {
69    const code = (fields[index] ?? '').charAt(0)
70    if (code === '') continue
71    const isMove = code === 'R' || code === 'C'
72    const from = isMove ? fields[index + 1] : undefined
73    const path = (isMove ? fields[index + 2] : fields[index + 1]) ?? ''
74    index += isMove ? 2 : 1
75    const count = counts.get(path) ?? { added: null, removed: null }
76    const status: DiffFile['status'] = code === 'A' ? 'added' : code === 'D' ? 'deleted' : code === 'R' ? 'renamed' : 'modified'
77    files.push({ path, ...(from !== undefined ? { from } : {}), status, added: count.added, removed: count.removed })
78  }
79
80  return files
81}
82
83/** Blocks the harness wraps around text it sends as a prompt; none of it is what the person wrote. */
84const HARNESS_BLOCK = /<(task-notification|system-reminder|local-command-[\w-]+|command-[\w-]+)\b[^>]*>[\s\S]*?<\/\1>/g
85/** Text pasted into the prompt, wrapped by the harness. */
86const PASTED_BLOCK = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content>/g
87
88/** The first non-empty line of `text` with any tag left in it removed, and anything a terminal would act on. */
89function firstLine(text: string): string {
90  for (const line of printable(text.replace(/<\/?[A-Za-z][\w-]*\b[^>]*>/g, ' ')).split('\n')) {
91    const clean = line.replace(/\s+/g, ' ').trim()
92    if (clean !== '') return clean
93  }
94
95  return ''
96}
97
98/**
99 * A checkpoint's label from the prompt it was taken before: the first line
100 * the person wrote, cut to `width` characters. Harness blocks are left out;
101 * pasted text stands in when nothing else was written, and a task
102 * notification's summary when the prompt was only that. Empty when nothing
103 * readable is left, so the caller names the checkpoint by its kind.
104 */
105export function promptLabel(text: string, width = 60): string {
106  const pasted = [...text.matchAll(PASTED_BLOCK)].map(match => match[1] ?? '').join('\n')
107  const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text)?.[1] ?? ''
108  const own = text.replace(HARNESS_BLOCK, '\n').replace(PASTED_BLOCK, '\n')
109  // A label stored cut short keeps an opening tag whose block never closes: it reads as nothing.
110  const line = firstLine(own) || firstLine(pasted) || firstLine(summary)
111  if (line.length <= width) return line
112
113  return `${line.slice(0, width - 1)}…`
114}
115
116/** A checkpoint's ref: per session, numbered so they sort in order. */
117export function checkpointRef(sessionId: string, seq: number): string {
118  return `refs/sc/checkpoints/${sessionId}/${String(seq).padStart(4, '0')}`
119}
120
121/** A path for display: inside `root` it is relative, under `home` it starts with `~`. */
122export function shortPath(path: string, root: string, home: string | undefined): string {
123  if (path === root) return '.'
124  if (path.startsWith(`${root}/`)) return path.slice(root.length + 1)
125  if (home && path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`
126
127  return path
128}
129
130/** The longest diff line shown whole; past it the line is cut and marked `…`. */
131export const DIFF_LINE_CHARS = 400
132
133/**
134 * The diff text cut to `limit` lines and to `chars` characters, each line to
135 * `DIFF_LINE_CHARS` (a minified file or an SVG is one line of thousands),
136 * saying how many lines were left out. The trailing newline goes: a diff
137 * renderer reads the empty line after it as a malformed hunk line.
138 */
139export function clipDiff(text: string, limit: number, chars: number): { text: string; omitted: number } {
140  const lines = text.replace(/\n+$/, '').split('\n')
141  const kept: string[] = []
142  let size = 0
143  for (const line of lines) {
144    const shown = line.length > DIFF_LINE_CHARS ? `${line.slice(0, DIFF_LINE_CHARS)}…` : line
145    if (kept.length === limit || size + shown.length + 1 > chars) break
146    kept.push(shown)
147    size += shown.length + 1
148  }
149
150  return { text: kept.join('\n'), omitted: lines.length - kept.length }
151}
152
153/** A hunk side's range as its header writes it: `start,count`, an empty side named by the line before it. */
154function hunkRange(next: number, count: number): string {
155  return count === 0 ? `${Math.max(0, next - 1)},0` : `${next},${count}`
156}
157
158/**
159 * A file's diff cut into pages of at most `size` lines that the engine reads
160 * as a diff. A page cut inside a hunk does not parse and is drawn as plain
161 * code, so each piece of a hunk goes under a header of its own, its counts
162 * those of the lines it holds and its starts where the piece begins (the
163 * section heading stays on the first piece). The lines before the first hunk,
164 * the file's own headers, open the first page; a diff with no hunk (a binary
165 * file, a mode change) is cut by lines. A `\ No newline at end of file` line
166 * stays with the line it follows.
167 */
168export function diffPages(text: string, size: number): string[] {
169  const lines = text === '' ? [] : text.split('\n')
170  const first = lines.findIndex(line => line.startsWith('@@'))
171  const pages: string[][] = []
172  let page: string[] = []
173  const add = (piece: string[]) => {
174    if (page.length > 0 && page.length + piece.length > size) {
175      pages.push(page)
176      page = []
177    }
178    page.push(...piece)
179  }
180  for (const line of first === -1 ? lines : lines.slice(0, first)) add([line])
181  let index = first === -1 ? lines.length : first
182  while (index < lines.length) {
183    const header = lines[index] ?? ''
184    let end = index + 1
185    while (end < lines.length && !(lines[end] ?? '').startsWith('@@')) end += 1
186    const body = lines.slice(index + 1, end)
187    index = end
188    const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(header)
189    if (!match) {
190      for (const line of [header, ...body]) add([line])
191      continue
192    }
193    // The next line of each side: an empty side's header names the line before it.
194    let oldNext = Number(match[1]) + (match[2] === '0' ? 1 : 0)
195    let newNext = Number(match[3]) + (match[4] === '0' ? 1 : 0)
196    let heading = match[5] ?? ''
197    let at = 0
198    do {
199      if (page.length > 0 && page.length + 2 > size) {
200        pages.push(page)
201        page = []
202      }
203      let take = Math.min(Math.max(1, size - page.length - 1), body.length - at)
204      if ((body[at + take] ?? '').startsWith('\\')) take = take > 1 ? take - 1 : take + 1
205      const piece = body.slice(at, at + take)
206      const oldCount = piece.filter(line => line.startsWith(' ') || line.startsWith('-')).length
207      const newCount = piece.filter(line => line.startsWith(' ') || line.startsWith('+')).length
208      page.push(`@@ -${hunkRange(oldNext, oldCount)} +${hunkRange(newNext, newCount)} @@${heading}`, ...piece)
209      oldNext += oldCount
210      newNext += newCount
211      heading = ''
212      at += take
213    } while (at < body.length)
214  }
215  if (page.length > 0) pages.push(page)
216
217  return pages.length > 0 ? pages.map(one => one.join('\n')) : ['']
218}
219
220/**
221 * Which checkpoints stay when one more is taken: every pinned one, every one
222 * of `keep` (refs), and the newest `limit` of the rest; the others are
223 * `gone`, their refs to delete.
224 */
225export function keptCheckpoints(all: CheckpointRow[], limit: number, keep: readonly string[] = []): { kept: CheckpointRow[]; gone: CheckpointRow[] } {
226  const over = new Set(
227    all
228      .filter(one => !one.isPinned && !keep.includes(one.ref))
229      .slice(limit)
230      .map(one => one.ref),
231  )
232
233  return { kept: all.filter(one => !over.has(one.ref)), gone: all.filter(one => over.has(one.ref)) }
234}
235
236/**
237 * The finished group after the engine's list is read again: the agents it
238 * listed before and lists no more go first, with their answers, newest first;
239 * an agent listed again leaves the group; at most `limit` are kept.
240 */
241export function finishAgents(
242  before: AgentRow[],
243  listed: AgentRow[],
244  finished: FinishedAgent[],
245  answers: Record<string, string>,
246  now: number,
247  limit: number,
248): FinishedAgent[] {
249  const ids = new Set(listed.map(row => row.id))
250  const ended = before
251    .filter(row => !ids.has(row.id))
252    .map(row => ({ ...row, endedAt: now, ...(answers[row.id] !== undefined ? { answer: answers[row.id] } : {}) }))
253
254  return [...ended, ...finished.filter(one => !ids.has(one.id) && !ended.some(row => row.id === one.id))].slice(0, limit)
255}
256
hooks/lists.ts 64 lines
1/**
2 * What a project keeps across sessions, its notes and its checkpoint list: a
3 * JSON file each under the repository's own git folder, one pair per working
4 * tree, so they grow with the project and go with it, never with the machine's
5 * history in the plugin store (4 MiB for everything a plugin keeps). Pure: the
6 * hooks module hands in the file calls over `$.fs`.
7 */
8import { projectFolder } from './shared/claude'
9
10/** The file calls a list is kept through. */
11export type ListFiles = {
12  exists: (path: string) => Promise<boolean>
13  read: (path: string) => Promise<string>
14  write: (path: string, text: string) => Promise<void>
15}
16
17/** Where a working tree's notes and checkpoint list are kept: in the git folder every worktree of the repository shares. */
18export function listPaths(commonDir: string, root: string): { notes: string; checkpoints: string } {
19  const folder = `${commonDir}/sc-workspace`
20  const name = projectFolder(root)
21
22  return { notes: `${folder}/notes-${name}.json`, checkpoints: `${folder}/checkpoints-${name}.json` }
23}
24
25/** A list as its file holds it: empty with no file. Rejects when the file holds anything but a list, which is then left as it is. */
26export async function readList<T>(files: ListFiles, path: string): Promise<T[]> {
27  if (!(await files.exists(path))) return []
28  const data: unknown = JSON.parse(await files.read(path))
29  if (!Array.isArray(data)) throw new Error(`${path} holds no list`)
30
31  return data as T[]
32}
33
34/** The change running on each file, which the next change to it waits for. */
35const chains = new Map<string, Promise<unknown>>()
36
37/**
38 * Applies `change` to the list as its file holds it at that moment and writes
39 * the result, one change at a time per file. A change worked out from a list
40 * read earlier (a second press before the pane redrew, a session that read
41 * the file before another wrote to it) would write back what came since.
42 * @returns the list as written
43 */
44export function changeList<T>(files: ListFiles, path: string, change: (list: T[]) => T[] | Promise<T[]>): Promise<T[]> {
45  const run = (chains.get(path) ?? Promise.resolve()).then(async () => {
46    const next = await change(await readList<T>(files, path))
47    await files.write(path, `${JSON.stringify(next)}\n`)
48
49    return next
50  })
51  chains.set(
52    path,
53    run.catch(() => undefined),
54  )
55
56  return run
57}
58
59/** Moves a list the plugin store held into its file, unless the file is there already: the store kept them before files did. */
60export async function adoptStored(files: ListFiles, path: string, stored: unknown): Promise<void> {
61  if (!Array.isArray(stored) || (await files.exists(path))) return
62  await files.write(path, `${JSON.stringify(stored)}\n`)
63}
64
hooks/i18n.ts 348 lines
1import type { Locale } from './shared/locale'
2
3export { resolveLocale } from './shared/locale'
4export type { Locale } from './shared/locale'
5
6const en = {
7  paneOpened: (tab: string) => `Opened the workspace on ${tab}.`,
8  paneClosed: 'Closed the workspace.',
9  release: (version: string, date?: string) => (date ? `v${version} (${date})` : `v${version}`),
10  updateRequired: 'Update Required',
11  loading: 'Loading…',
12  refreshButton: 'Refresh',
13  refreshingButton: 'Refreshing',
14  closeButton: 'Close',
15  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.',
16  backButton: '← Back',
17  tabAgents: 'Agents',
18  tabCheckpoints: 'Checkpoints',
19  tabNotes: 'Notes',
20  tabDiff: 'Diff',
21  unknownTab: (tab: string) => `Unknown tab: ${tab}. Tabs: agents, checkpoints, notes, diff, memory.`,
22  agentsTitle: 'Agents',
23  agentsCount: (total: number, active: number) => (total === 0 ? '' : `${total} · ${active} active`),
24  agentsEmpty: 'No subagents in this session yet.',
25  agentStatus: {
26    pending: 'starting',
27    running: 'running',
28    waiting: 'waiting',
29    idle: 'idle',
30    completed: 'done',
31    failed: 'failed',
32    killed: 'stopped',
33  } as Record<'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed', string>,
34  worktreesTitle: 'Worktrees',
35  worktreesCount: (total: number) => `${total}`,
36  changedFiles: (count: number) => `${count} changed`,
37  clean: 'clean',
38  detached: 'detached HEAD',
39  mainWorktree: 'main',
40  merged: 'merged',
41  stopped: (label: string) => `Stopped ${label}.`,
42  stopFailed: (label: string, reason: string) => `Did not stop ${label}: ${reason || 'no reason was given'}`,
43  worktreeRemoved: (path: string) => `Removed the worktree ${path}.`,
44  checkpointsTitle: 'Checkpoints',
45  checkpointsDetail: (count: number, isEveryPrompt: boolean): string => (isEveryPrompt ? `taken before every prompt · ${count} kept` : `taken by Checkpoint now · ${count} kept`),
46  checkpointsEmpty: 'No checkpoints yet.',
47  checkpointsEmptyHint: (isEveryPrompt: boolean): string => (isEveryPrompt ? 'One is taken before each prompt; Checkpoint now takes one at once.' : 'Checkpoint now takes one.'),
48  checkpointsNeedGit: 'Checkpoints and changes need a git repository.',
49  checkpointKind: {
50    turn: 'Prompt',
51    session: 'Session start',
52    manual: 'Checkpoint',
53    restore: 'Before restore',
54  } as Record<'turn' | 'session' | 'manual' | 'restore', string>,
55  noChangesSince: 'no changes since',
56  filesCount: (count: number) => (count === 1 ? '1 file' : `${count} files`),
57  olderCheckpoints: (count: number) => `${count} older not shown`,
58  checkpointNow: 'Checkpoint now',
59  checkpointTaken: 'Checkpoint taken.',
60  nothingChanged: 'Nothing changed since the last checkpoint.',
61  restored: (clock: string) => `Restored to ${clock}. The "Before restore" checkpoint undoes it.`,
62  restoreTitle: (clock: string) => `Restore the working tree to ${clock}?`,
63  restoreFrom: (label: string) => `Checkpoint: ${label}`,
64  restoreUndoes: (files: string, added: number, removed: number) => `Undoes what changed since: ${files}, +${added} −${removed}.`,
65  restoreUndoHint: 'A "Before restore" checkpoint is taken first, so the restore can be undone.',
66  restoreConfirm: 'Restore',
67  noteDeleteTitle: 'Delete this note?',
68  cannotUndo: 'This cannot be undone.',
69  deleteConfirm: 'Delete',
70  stopTitle: (label: string) => `Stop ${label}?`,
71  stopHint: 'The agent ends where it stands; what it has done so far stays.',
72  stopConfirm: 'Stop',
73  worktreeTitle: (branch: string) => `Remove the worktree ${branch}?`,
74  worktreeHint: 'Its folder is deleted, and its branch too: the main branch already holds every commit of it.',
75  removeConfirm: 'Remove',
76  cancel: 'Cancel',
77  notesTitle: 'Notes',
78  notesDetail: (project: string, open: number, total: number) => (total === 0 ? project : `${project} · ${open} ${open === 1 ? 'note' : 'notes'}`),
79  notePlaceholder: 'Write a note and press Enter',
80  noteAdd: 'Add',
81  notesNoInput: 'Add a note with /sc:workspace notes <text>.',
82  notesPurpose: 'Things to remember in this project: follow-ups, ideas, instructions for Claude. They stay across sessions.',
83  switchOn: 'On',
84  switchOff: 'Off',
85  notesSendToggle: 'Send with prompts',
86  notesOpenTitle: 'Notes',
87  notesDoneTitle: 'Done',
88  notesDoneDetail: (count: number) => `${count} · not sent with prompts`,
89  notesSendOn: 'Open notes now go to Claude with every prompt.',
90  notesSendOff: 'Notes are no longer sent with prompts.',
91  notesEmpty: 'No notes for this project yet.',
92  notesEmptyHint: 'Write one in the field above and press Enter.',
93  clearDone: 'Clear done',
94  noteAdded: (text: string) => `Noted: ${text}`,
95  diffTitle: 'Changes',
96  baseTitle: 'Compare the changes with',
97  diffBaseLabel: 'compared with',
98  diffEmpty: 'Nothing changed since then.',
99  binary: 'binary',
100  diffDialogSince: (base: string) => `since ${base}`,
101  diffPreviousFile: '◂ Previous file',
102  diffNextFile: 'Next file ▸',
103  diffRestoreFile: '↺ Restore',
104  linesOmitted: (count: number) => `${count} more lines not shown`,
105  diffTargetLabel: 'up to',
106  workingTree: 'Working tree',
107  targetTitle: 'Compare the changes up to',
108  fileRestoreTitle: (path: string, clock: string) => `Put ${path} back as it was at ${clock}?`,
109  fileRestoreUndoes: (added: number, removed: number) => `Undoes +${added} −${removed} in this file.`,
110  fileRestoreAdded: 'The file did not exist then, so it is deleted.',
111  fileRestoreRenamed: (from: string) => `The rename is undone: the file goes back to ${from}.`,
112  fileRestoreConfirm: 'Restore file',
113  fileRestored: (path: string) => `Restored ${path}. The "Before restore" checkpoint undoes it.`,
114  draftCommit: 'Draft commit message',
115  draftCommitFilled: 'Put a request for a commit message in the prompt.',
116  draftCommitPrompt: (range: string, files: string) =>
117    `Draft a commit message for the changes ${range}:\n${files}\nRead the diffs with git, follow this repository's commit conventions, and show the message without committing.`,
118  draftCommitRange: (base: string, target: string) => `from ${base} to ${target}`,
119  activityAgo: (duration: string) => `${duration} ago`,
120  tabMemory: 'Memory',
121  memoryTitle: 'Memory',
122  memoryDetail: (files: number) => (files === 1 ? '1 file Claude Code reads here' : `${files} files Claude Code reads here`),
123  memoryScopeLabel: 'show',
124  memoryScope: { all: 'Global and project', global: 'Global', project: 'Project' } as Record<'all' | 'global' | 'project', string>,
125  memoryScopeTitle: 'Show the memory files of',
126  memorySearch: 'Search every memory file',
127  memoryGlobal: 'Global',
128  memoryGlobalDetail: 'every project',
129  memoryProject: 'Project',
130  memoryProjectDetail: 'this project',
131  memoryKind: {
132    managed: 'organisation policy',
133    user: 'your instructions',
134    userRule: 'your rule',
135    project: 'project instructions',
136    local: 'your local instructions',
137    rule: 'project rule',
138    pathRule: 'rule for matching files only',
139    parent: 'parent folder',
140    subfolder: 'read when Claude works in its folder',
141    agents: 'AGENTS.md, read as there is no CLAUDE.md',
142    imported: 'imported',
143    autoIndex: 'auto memory index',
144    auto: 'auto memory',
145  } as Record<'managed' | 'user' | 'userRule' | 'project' | 'local' | 'rule' | 'pathRule' | 'parent' | 'subfolder' | 'agents' | 'imported' | 'autoIndex' | 'auto', string>,
146  memoryLines: (lines: number) => (lines === 1 ? '1 line' : `${lines} lines`),
147  memoryImportedBy: (path: string) => `from ${path}`,
148  memoryNone: 'No memory file here.',
149  memoryNoHits: (query: string) => `No memory line holds "${query}".`,
150  memoryHits: (count: number, query: string) => `${count} ${count === 1 ? 'line holds' : 'lines hold'} "${query}"`,
151  memoryEmptyOutline: 'No headings.',
152  memoryInserted: (path: string) => `Put @${path} in the prompt.`,
153  readerPrevious: 'Previous page',
154  readerNext: 'Next page',
155  readerPage: (page: number, pages: number) => `page ${page} of ${pages}`,
156  readerSubtitle: (path: string, kind: string, lines: string) => `${path} · ${kind} · ${lines}`,
157  finishedTitle: 'Finished',
158  finishedDetail: (count: number) => `${count} · this session`,
159  noAnswer: 'No answer was recorded.',
160  worktreeDiffTitle: (branch: string) => `Changes in ${branch}`,
161  worktreeDiffSince: (main: string) => `since it parted from ${main}`,
162  noteSuggestedDone: 'Claude says this is done',
163  confirmDone: 'Done',
164  nameTitle: (clock: string) => `Name the checkpoint at ${clock}`,
165  nameHint: 'A named checkpoint is pinned: it is kept past the newest 50.',
166  namePlaceholder: 'Write a name and press Enter',
167  nameSave: 'Save',
168  nameNoInput: 'Name the newest checkpoint with /sc:workspace name <text>.',
169  named: (name: string) => `Named and pinned: ${name}`,
170  pinned: 'Pinned: kept past the newest 50.',
171  unpinned: 'Unpinned.',
172}
173
174export type Messages = typeof en
175
176const ko: Messages = {
177  paneOpened: tab => `작업 공간의 ${tab} 탭을 열었습니다.`,
178  paneClosed: '작업 공간을 닫았습니다.',
179  release: (version, date) => (date ? `v${version} (${date})` : `v${version}`),
180  updateRequired: '업데이트 필요',
181  loading: '불러오는 중…',
182  refreshButton: '새로고침',
183  refreshingButton: '새로고침 중',
184  closeButton: '닫기',
185  clickHint: '클릭은 전체 화면 모드(/tui fullscreen)에서만 창에 전달됩니다. 지금은 ctrl+x tab으로 창에 포커스를 준 뒤 Tab이나 화살표로 이동하고 Enter로 누르세요.',
186  backButton: '← 돌아가기',
187  tabAgents: '에이전트',
188  tabCheckpoints: '체크포인트',
189  tabNotes: '메모',
190  tabDiff: '변경',
191  unknownTab: tab => `알 수 없는 탭입니다: ${tab}. 탭: agents, checkpoints, notes, diff, memory`,
192  agentsTitle: '에이전트',
193  agentsCount: (total, active) => (total === 0 ? '' : `${total}개 · 작업 중 ${active}개`),
194  agentsEmpty: '이 세션에서 실행한 서브에이전트가 없습니다.',
195  agentStatus: {
196    pending: '시작 중',
197    running: '작업 중',
198    waiting: '대기 중',
199    idle: '유휴',
200    completed: '완료',
201    failed: '실패',
202    killed: '중지됨',
203  },
204  worktreesTitle: '작업 트리',
205  worktreesCount: total => `${total}개`,
206  changedFiles: count => `변경 ${count}개`,
207  clean: '변경 없음',
208  detached: '분리된 HEAD',
209  mainWorktree: '기본',
210  merged: '병합됨',
211  stopped: label => `${label}을(를) 중지했습니다.`,
212  stopFailed: (label, reason) => `${label}을(를) 중지하지 못했습니다. ${reason || '실패 이유는 알 수 없습니다.'}`,
213  worktreeRemoved: path => `작업 트리 ${path}을(를) 제거했습니다.`,
214  checkpointsTitle: '체크포인트',
215  checkpointsDetail: (count, isEveryPrompt) => (isEveryPrompt ? `프롬프트마다 저장 · ${count}개 보관` : `「지금 저장」으로 저장 · ${count}개 보관`),
216  checkpointsEmpty: '체크포인트가 아직 없습니다.',
217  checkpointsEmptyHint: isEveryPrompt =>
218    isEveryPrompt ? '프롬프트를 보낼 때마다 저장합니다. 지금 저장하려면 「지금 저장」을 누르세요.' : '「지금 저장」을 누르면 체크포인트를 저장합니다.',
219  checkpointsNeedGit: '체크포인트와 변경 보기는 git 저장소에서만 쓸 수 있습니다.',
220  checkpointKind: {
221    turn: '프롬프트',
222    session: '세션 시작',
223    manual: '체크포인트',
224    restore: '되돌리기 전',
225  },
226  noChangesSince: '이후 변경 없음',
227  filesCount: count => `파일 ${count}개`,
228  olderCheckpoints: count => `이전 체크포인트 ${count}개는 표시하지 않습니다`,
229  checkpointNow: '지금 저장',
230  checkpointTaken: '체크포인트를 저장했습니다.',
231  nothingChanged: '마지막 체크포인트 이후 바뀐 내용이 없습니다.',
232  restored: clock => `${clock} 상태로 되돌렸습니다. 「되돌리기 전」 체크포인트로 취소할 수 있습니다.`,
233  restoreTitle: clock => `작업 트리를 ${clock} 상태로 되돌릴까요?`,
234  restoreFrom: label => `체크포인트: ${label}`,
235  restoreUndoes: (files, added, removed) => `그 이후의 변경을 되돌립니다: ${files}, +${added} −${removed}`,
236  restoreUndoHint: '먼저 「되돌리기 전」 체크포인트를 저장하므로, 되돌린 뒤에도 취소할 수 있습니다.',
237  restoreConfirm: '되돌리기',
238  noteDeleteTitle: '이 메모를 삭제할까요?',
239  cannotUndo: '삭제한 메모는 복구할 수 없습니다.',
240  deleteConfirm: '삭제',
241  stopTitle: label => `${label}을(를) 중지할까요?`,
242  stopHint: '에이전트가 지금 위치에서 멈춥니다. 지금까지 한 작업은 남습니다.',
243  stopConfirm: '중지',
244  worktreeTitle: branch => `작업 트리 ${branch}을(를) 제거할까요?`,
245  worktreeHint: '폴더를 삭제하고, 브랜치도 함께 삭제합니다. 이 브랜치의 커밋은 모두 기본 브랜치에 있습니다.',
246  removeConfirm: '제거',
247  cancel: '취소',
248  notesTitle: '메모',
249  notesDetail: (project, open, total) => (total === 0 ? project : `${project} · 메모 ${open}개`),
250  notePlaceholder: '메모를 쓰고 Enter를 눌러 주세요',
251  noteAdd: '추가',
252  notesNoInput: '/sc:workspace notes <내용>으로 메모를 추가할 수 있습니다.',
253  notesPurpose: '이 프로젝트에서 기억할 것(할 일, 아이디어, Claude에게 줄 지시)을 적어 둡니다. 세션이 바뀌어도 남습니다.',
254  switchOn: '켬',
255  switchOff: '끔',
256  notesSendToggle: '프롬프트와 함께 보내기',
257  notesOpenTitle: '메모',
258  notesDoneTitle: '완료',
259  notesDoneDetail: count => `${count}개 · 프롬프트에 보내지 않음`,
260  notesSendOn: '프롬프트를 보낼 때마다 남은 메모를 Claude에게 함께 보냅니다.',
261  notesSendOff: '메모를 프롬프트와 함께 보내지 않습니다.',
262  notesEmpty: '이 프로젝트에 저장한 메모가 없습니다.',
263  notesEmptyHint: '위 입력란에 쓰고 Enter를 눌러 주세요.',
264  clearDone: '완료 지우기',
265  noteAdded: text => `메모를 추가했습니다: ${text}`,
266  diffTitle: '변경',
267  baseTitle: '변경을 비교할 기준',
268  diffBaseLabel: '비교 기준',
269  diffEmpty: '그 이후 바뀐 내용이 없습니다.',
270  binary: '바이너리',
271  diffDialogSince: base => `${base} 이후`,
272  diffPreviousFile: '◂ 이전 파일',
273  diffNextFile: '다음 파일 ▸',
274  diffRestoreFile: '↺ 되돌리기',
275  linesOmitted: count => `나머지 ${count}줄은 표시하지 않습니다`,
276  diffTargetLabel: '비교 대상',
277  workingTree: '현재 작업 트리',
278  targetTitle: '변경을 비교할 대상',
279  fileRestoreTitle: (path, clock) => `${path} 파일을 ${clock} 상태로 되돌릴까요?`,
280  fileRestoreUndoes: (added, removed) => `이 파일의 변경(+${added} −${removed})을 되돌립니다.`,
281  fileRestoreAdded: '그 시점에는 없던 파일이므로 삭제합니다.',
282  fileRestoreRenamed: from => `이름 변경을 취소하고 원래 경로 ${from}에 되돌립니다.`,
283  fileRestoreConfirm: '파일 되돌리기',
284  fileRestored: path => `${path} 파일을 되돌렸습니다. 「되돌리기 전」 체크포인트로 취소할 수 있습니다.`,
285  draftCommit: '커밋 메시지 초안',
286  draftCommitFilled: '커밋 메시지 요청을 프롬프트에 넣었습니다.',
287  draftCommitPrompt: (range, files) =>
288    `${range}의 변경에 맞는 커밋 메시지 초안을 작성해 주세요.\n${files}\ngit으로 diff를 읽고 이 저장소의 커밋 규칙을 따르며, 커밋하지 말고 메시지만 보여 주세요.`,
289  draftCommitRange: (base, target) => `${base}부터 ${target}까지`,
290  activityAgo: duration => `${duration} 전`,
291  tabMemory: '메모리',
292  memoryTitle: '메모리',
293  memoryDetail: files => `여기서 Claude Code가 읽는 파일 ${files}개`,
294  memoryScopeLabel: '보기',
295  memoryScope: { all: '전역과 프로젝트', global: '전역', project: '프로젝트' },
296  memoryScopeTitle: '볼 메모리 파일',
297  memorySearch: '모든 메모리 파일에서 찾기',
298  memoryGlobal: '전역',
299  memoryGlobalDetail: '모든 프로젝트',
300  memoryProject: '프로젝트',
301  memoryProjectDetail: '이 프로젝트',
302  memoryKind: {
303    managed: '조직 정책',
304    user: '사용자 지침',
305    userRule: '사용자 규칙',
306    project: '프로젝트 지침',
307    local: '개인 로컬 지침',
308    rule: '프로젝트 규칙',
309    pathRule: '해당 파일을 다룰 때만 읽는 규칙',
310    parent: '상위 폴더',
311    subfolder: '그 폴더에서 작업할 때 읽음',
312    agents: 'CLAUDE.md가 없어 읽는 AGENTS.md',
313    imported: '가져온 파일',
314    autoIndex: '자동 메모리 목차',
315    auto: '자동 메모리',
316  },
317  memoryLines: lines => `${lines}줄`,
318  memoryImportedBy: path => `${path}에서 가져옴`,
319  memoryNone: '여기서 읽는 메모리 파일이 없습니다.',
320  memoryNoHits: query => `메모리 파일에서 「${query}」 검색 결과가 없습니다.`,
321  memoryHits: (count, query) => `「${query}」 검색 결과 ${count}줄`,
322  memoryEmptyOutline: '제목이 없습니다.',
323  memoryInserted: path => `@${path} 참조를 프롬프트에 넣었습니다.`,
324  readerPrevious: '이전 쪽',
325  readerNext: '다음 쪽',
326  readerPage: (page, pages) => `${pages}쪽 중 ${page}쪽`,
327  readerSubtitle: (path, kind, lines) => `${path} · ${kind} · ${lines}`,
328  finishedTitle: '끝난 에이전트',
329  finishedDetail: count => `${count}개 · 이번 세션`,
330  noAnswer: '기록된 답변이 없습니다.',
331  worktreeDiffTitle: branch => `${branch}의 변경`,
332  worktreeDiffSince: main => `${main}에서 갈라진 뒤`,
333  noteSuggestedDone: 'Claude가 완료했다고 답했습니다',
334  confirmDone: '완료',
335  nameTitle: clock => `${clock} 체크포인트 이름 지정`,
336  nameHint: '이름을 지정하면 체크포인트를 고정해 최근 50개 한도와 상관없이 보관합니다.',
337  namePlaceholder: '이름을 쓰고 Enter를 눌러 주세요',
338  nameSave: '저장',
339  nameNoInput: '가장 최근 체크포인트에 이름을 지정하려면 /sc:workspace name <이름>을 입력하세요.',
340  named: name => `이름을 지정하고 고정했습니다: ${name}`,
341  pinned: '고정했습니다. 최근 50개 한도와 상관없이 보관합니다.',
342  unpinned: '고정을 풀었습니다.',
343}
344
345export function messagesFor(locale: Locale): Messages {
346  return locale === 'ko' ? ko : en
347}
348
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/notes.ts 39 lines
1import type { Note } from '../types'
2
3/** Gives every note without a number the next one, oldest first, keeping the numbers already given. */
4export function numbered(notes: Note[]): Note[] {
5  let next = notes.reduce((max, note) => Math.max(max, note.seq ?? 0), 0)
6  const missing = new Set(
7    [...notes]
8      .filter(note => note.seq === undefined)
9      .sort((a, b) => a.at - b.at)
10      .map(note => note.id),
11  )
12  const seqs = new Map<string, number>()
13  for (const note of [...notes].sort((a, b) => a.at - b.at)) if (missing.has(note.id)) seqs.set(note.id, (next += 1))
14
15  return notes.map(note => (seqs.has(note.id) ? { ...note, seq: seqs.get(note.id) } : note))
16}
17
18/** The next number for a new note. */
19export function nextSeq(notes: Note[]): number {
20  return notes.reduce((max, note) => Math.max(max, note.seq ?? 0), 0) + 1
21}
22
23/**
24 * The block sent with a prompt: the project's open notes, each by its number,
25 * and how Claude says it finished one. The person confirms in the Notes tab.
26 */
27export function notesContext(project: string, open: Note[]): string {
28  return [
29    `Open notes for ${project}:`,
30    ...open.map(note => `- N${note.seq ?? '?'}: ${note.text}`),
31    'When your answer finishes one of these notes, write [done N<number>] for it, such as [done N3].',
32  ].join('\n')
33}
34
35/** The note numbers an answer marks finished with `[done N<number>]`. */
36export function doneMarks(answer: string): number[] {
37  return [...answer.matchAll(/\[done N(\d+)\]/g)].map(match => Number(match[1]))
38}
39
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