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

Plugins for Claude Code that add panes, a status band and slash commands to the terminal: switch between several Claude accounts, watch their usage limits and token use, see and clean up what Claude Code keeps on the machine, and keep agents, worktrees, checkpoints, diffs and memory files in one pane, and run a project's builds and commands from buttons. Install one plugin, sc, and you get them all.
[!IMPORTANT] Clicking needs Claude Code's fullscreen mode. The buttons, tabs, tiles and status band cells answer a mouse click only while Claude Code draws in fullscreen mode. Turn it on once with
/tui fullscreen, which is kept as"tui": "fullscreen"in~/.claude/settings.json. In the default mode Claude Code passes no click to the plugins, on every platform, Windows and WSL included. See Clicking and the keyboard.
/sc:accounts~/.claude holds by kind and project, and a cleanup of idle sessions confirmed by typing a word./sc:workspace/sc:toolbox/clear and /compact..toolbox/toolbox.json in the project; share it or ignore it in git. A skill lets Claude write it for you.Anything that cannot be taken back asks in a dialog first; Esc cancels.
| Requirement | Why |
|---|---|
Claude Code in fullscreen mode: /tui fullscreen, or "tui": "fullscreen" in ~/.claude/settings.json | Only the fullscreen mode reads the mouse; in the default mode no click reaches a plugin |
A terminal that passes mouse events to the program running in it; inside tmux, set -g mouse on | A terminal that keeps the mouse for its own text selection sends Claude Code no click to pass on |
Without a click, every control is reached from the keyboard:
ctrl+x then Tab gives the open pane, or the status band, the keyboard.Tab and the arrow keys move between buttons; Enter presses the one with the ring.Esc goes back from a dialog and closes a pane./sc:accounts, /sc:workspace, /sc:toolbox.The first pane a command opens outside fullscreen mode says so in its reply.
claude plugin marketplace add simplecore-inc/claude-mods
claude plugin install sc@simplecore-mods
sc installs sc-accounts, sc-workspace and sc-toolbox with it. The plugins are installed for your user, so they run in every session. Run /reload-plugins in a session that was already open.
To use a local clone instead, add its folder as the marketplace:
git clone https://github.com/simplecore-inc/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install sc@simplecore-mods
claude plugin marketplace update simplecore-mods
claude plugin update sc@simplecore-mods
claude plugin update sc-accounts@simplecore-mods
claude plugin update sc-workspace@simplecore-mods
claude plugin update sc-toolbox@simplecore-mods
Then run /reload-plugins in each open session. With a local clone, git pull in the clone and /reload-plugins are enough: a plugin from a folder marketplace is read from that folder.
claude plugin uninstall sc@simplecore-mods
claude plugin prune
claude plugin marketplace remove simplecore-mods
prune removes sc-accounts, sc-workspace and sc-toolbox, which were installed as dependencies of sc; uninstall them by name instead if you installed them yourself. marketplace remove is only needed when you will not install from it again.
The mods leave some data behind, which you can delete by hand:
| What | Where |
|---|---|
| Saved logins | macOS: keychain items of the service account-switch. Elsewhere: ~/.claude/account-switch/ |
| Webhook token | macOS: the keychain item of the service sc-webhook. Elsewhere: ~/.claude/sc-accounts/webhook-token |
| Webhook template | ~/.claude/sc-accounts/ |
| Settings, account index | ~/.claude/plugins/store/sc-*.json |
| Tools, their logs and remembered values | in each project: .toolbox/ |
| Notes and checkpoint lists | in each repository's git folder: .git/sc-workspace/, a pair of files per worktree |
| Checkpoints | in each repository: the refs under refs/sc/, and the files sc-snapshot-*.index in the git folder and in each worktree's folder under .git/worktrees/ |
~/.claude is CLAUDE_CONFIG_DIR when that is set. To delete a repository's checkpoints, and its notes with them, run in the repository:
git for-each-ref --format='%(refname)' refs/sc/ | xargs -n 1 git update-ref -d
common="$(git rev-parse --git-common-dir)"
rm -f "$common"/sc-snapshot-*.index "$common"/worktrees/*/sc-snapshot-*.index
rm -rf "$common/sc-workspace"
The account you are logged in with stays logged in; only the saved copies go.
The mods show English, and Korean where Claude Code's language setting is Korean; any other language falls back to English. Without that setting, LC_ALL, LC_MESSAGES and LANG decide, in that order.
Developing a mod covers the layout, the rules the engine enforces, the checks and how to release.
hooks/register.tsx 1657 lines1import { 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 lines1/**
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}
255hooks/views/memory.tsx 141 lines1import 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}
141hooks/shared/release.ts 102 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/**
3 * Whether a newer release than the one running has been published: the
4 * latest release of the repository the plugin's manifest names, asked of
5 * GitHub at most every few hours and kept in the plugin's store, so every
6 * session of the machine shares one answer.
7 */
8
9/** How long a latest-release answer is trusted before GitHub is asked again. */
10export const RELEASE_CHECK_MS = 6 * 60 * 60 * 1000
11
12/** The `$.store` key of the latest release last heard of. */
13export const LATEST_RELEASE_KEY = 'latestRelease'
14
15/** The latest release as last heard of: its version, and when GitHub was asked. */
16export type LatestRelease = { version: string | null; checkedAt: number }
17
18/** What asking for the latest release needs: the store, the clock and a fetch, as the hooks module hands them over. */
19export type ReleaseIo = {
20 now: () => Promise<number>
21 get: () => Promise<unknown>
22 set: (value: LatestRelease) => Promise<void>
23 fetch: (url: string, init: { headers: Record<string, string> }) => Promise<{ ok: boolean; status: number; text: string }>
24}
25
26/** GitHub's latest-release endpoint for a repository URL such as `https://github.com/<owner>/<repo>`; null for any other host. */
27export function latestReleaseUrl(repository: unknown): string | null {
28 if (typeof repository !== 'string') return null
29 const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(repository.trim())
30
31 return match ? `https://api.github.com/repos/${match[1]}/${match[2]}/releases/latest` : null
32}
33
34/** The version a latest-release answer names: its tag without the `v`; null when it names none. */
35export function versionOfRelease(text: string): string | null {
36 try {
37 const tag = (JSON.parse(text) as { tag_name?: unknown }).tag_name
38 if (typeof tag !== 'string') return null
39 const version = tag.trim().replace(/^v/, '')
40
41 return /^\d+\.\d+\.\d+$/.test(version) ? version : null
42 } catch (error) {
43 if (error instanceof SyntaxError) return null
44 throw error
45 }
46}
47
48/** Whether `latest` is a later version than `current`, both `major.minor.patch`. */
49export function isNewerVersion(latest: string, current: string): boolean {
50 const parts = (version: string) => version.split('.').map(part => Number.parseInt(part, 10))
51 const [a, b] = [parts(latest), parts(current)]
52 for (let index = 0; index < 3; index += 1) {
53 const [x = 0, y = 0] = [a[index], b[index]]
54 if (x !== y) return x > y
55 }
56
57 return false
58}
59
60function asLatest(value: unknown): LatestRelease | null {
61 const kept = value as Partial<LatestRelease> | undefined
62 if (typeof kept?.checkedAt !== 'number') return null
63
64 return { version: typeof kept.version === 'string' ? kept.version : null, checkedAt: kept.checkedAt }
65}
66
67/**
68 * The latest release's version: the one kept while it is recent, else asked
69 * of GitHub and kept. A failed request keeps what was heard before and is
70 * asked again at the next check; null when nothing was ever heard.
71 */
72export async function latestVersion(io: ReleaseIo, url: string): Promise<string | null> {
73 const now = await io.now()
74 const kept = asLatest(await io.get())
75 if (kept && now - kept.checkedAt < RELEASE_CHECK_MS) return kept.version
76 const response = await io.fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'claude-mods' } })
77 if (!response.ok) return kept?.version ?? null
78 const version = versionOfRelease(response.text)
79 await io.set({ version, checkedAt: now })
80
81 return version
82}
83
84/** The running release as the hooks module reads it from its own files, and the latest heard of. */
85export type RunningRelease = { version?: string; date?: string; repository?: string; latest?: string | null }
86
87/**
88 * What the pane header shows of the release: `v0.6.3 (2026-10-08)`, or, once a
89 * later release is published, `Update Required` in place of the date, marked
90 * for the header to draw in the warning colour.
91 */
92export function releaseHeader(
93 release: RunningRelease,
94 label: (version: string, date?: string) => string,
95 updateRequired: string,
96): { release: string | undefined; isReleaseOutdated: boolean } {
97 if (!release.version) return { release: undefined, isReleaseOutdated: false }
98 const isReleaseOutdated = typeof release.latest === 'string' && isNewerVersion(release.latest, release.version)
99
100 return { release: label(release.version, isReleaseOutdated ? updateRequired : release.date), isReleaseOutdated }
101}
102hooks/shared/claude.ts 30 lines1// 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}
30hooks/git.ts 256 lines1import 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}
256hooks/lists.ts 64 lines1/**
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}
64hooks/i18n.ts 348 lines1import 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}
348hooks/shared/panes.ts 13 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2/** The mods whose panes open side by side; each keeps `paneOpen` in its state while its pane is open. */
3export type ModPane = 'sc-accounts' | 'sc-workspace' | 'sc-toolbox'
4
5/**
6 * Whether another mod's pane is open beside this one, from each mod's
7 * `paneOpen`, read with `$.state.get` while drawing: the engine then draws its tab row above the pane, and the
8 * header keeps a row apart from it.
9 */
10export function isBesideOtherPanes(self: ModPane, open: Record<ModPane, boolean>): boolean {
11 return (Object.keys(open) as ModPane[]).some(plugin => plugin !== self && open[plugin])
12}
13hooks/shared/kit.tsx 1111 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2import type { ElementTable } from 'claude-code'
3
4import { barParts, displayWidth, padCells, printable, severityColor, truncate } from './layout'
5
6/**
7 * The pieces every tab of the pane is built from, so the tabs look like one
8 * screen: one header, one tab bar, one card, one gauge, one button language.
9 * Each takes the surface's element table (`$.ui.resolve(e)`), never `$`, and
10 * draws every string it is given through `printable`, so text from outside
11 * holding a control character never refuses the pane.
12 */
13
14export const theme = {
15 accent: 'cyan',
16 danger: 'red',
17 warn: 'yellow',
18 /** Between warn and danger: a weekly reset two days out. */
19 caution: '#ff8c1a',
20 ok: 'green',
21 track: 'gray',
22 /** The footer tiles' fill, the main tile's, and either under the pointer. */
23 tile: '#2a2f38',
24 tileMain: '#1f4650',
25 tileHover: '#3a4250',
26 /** The selected tab's fill. */
27 tabActive: '#2f3a46',
28 stale: 'yellow',
29 /** An on/off switch's segments: on when current, off when current, and the one not current. */
30 switchOn: '#1f7a3a',
31 switchOffActive: '#5a606b',
32 switchOff: '#2a2f38',
33 /** Dark grounds behind glyph buttons, one per meaning, so a button reads at rest. */
34 glyphAccent: '#1d5566',
35 glyphDanger: '#6b2a31',
36 glyphWarn: '#6b5719',
37 glyphOk: '#28603a',
38}
39
40/** The ground behind a glyph button of each tone; any other tone takes the neutral fill. */
41const GLYPH_GROUND: Record<string, string> = {
42 [theme.accent]: theme.glyphAccent,
43 [theme.danger]: theme.glyphDanger,
44 [theme.warn]: theme.glyphWarn,
45 [theme.ok]: theme.glyphOk,
46}
47
48/** Cells a gauge spans. */
49export const GAUGE_WIDTH = 6
50/** Cells between two cells of a row of gauges. */
51export const CELL_GAP = 3
52/** Cells a card's border and horizontal padding take across. */
53export const CARD_CHROME = 4
54/** Cells between two footer tiles. */
55const TILE_GAP = 1
56
57export type Tile = { key: string; label: string; isMain?: boolean; isDismiss?: boolean; isFocused?: boolean; onPress: () => void }
58export type TabSpec = { key: string; label: string; badge?: string; hotkey: string }
59
60/** A pane's name in its header: the product's short mark, then the mod's name, as `[SC] Workspace`. */
61export function paneTitle(mod: string): string {
62 return `[SC] ${mod}`
63}
64
65/**
66 * What heads a pane: its name, its release, whether the engine draws its tab
67 * row right above (another pane is open beside it), which the header keeps a
68 * row apart from, and the way out: `exit` closes the pane, and a dialog puts
69 * its own Cancel there as `backLabel`. `columns` is the width it has.
70 */
71export type HeaderInfo = {
72 brand: string
73 release: string | undefined
74 /** A later release is published: the release is drawn in the warning colour, not dim. */
75 isReleaseOutdated?: boolean
76 isUnderTabs: boolean
77 columns?: number
78 exit?: { label: string; onPress: () => void }
79 backLabel?: string
80}
81
82/**
83 * A pane's first row: its name at the left and the way out at the right, a
84 * filled button that is the first thing scrolled into view however short the
85 * terminal is. The release sits before it while the row has room for it,
86 * in the warning colour once a later release is published.
87 */
88export function Header(ui: ElementTable, header: HeaderInfo) {
89 const { Box, Button, Text } = ui
90 const brand = printable(header.brand)
91 const release = header.release === undefined ? undefined : printable(header.release)
92 const exit = header.exit ? { ...header.exit, label: printable(header.exit.label) } : undefined
93 const exitWidth = exit ? displayWidth(exit.label) + 2 : 0
94 const room = (header.columns ?? 80) - displayWidth(brand) - exitWidth - 2
95 const isReleaseShown = release !== undefined && displayWidth(release) <= room
96
97 return (
98 <Box key="header" justifyContent="space-between" marginTop={header.isUnderTabs ? 1 : 0}>
99 <Text bold wrap="truncate-end">
100 {brand}
101 </Text>
102 <Box gap={2} flexShrink={0}>
103 {isReleaseShown && (header.isReleaseOutdated ? <Text color={theme.warn}>{release}</Text> : <Text dimColor>{release}</Text>)}
104 {exit && (
105 <Box key="header-exit-ground" paddingX={1} flexShrink={0} backgroundColor={theme.tile} hover={{ backgroundColor: theme.tileHover }}>
106 <Button key="close" label={exit.label} plain role="dismiss" onPress={exit.onPress} />
107 </Box>
108 )}
109 </Box>
110 </Box>
111 )
112}
113
114/** The header a dialog draws: the pane's, its way out being the dialog's own Cancel. */
115export function dialogHeader(header: HeaderInfo, cancel: () => void): HeaderInfo {
116 return { ...header, exit: { label: header.backLabel ?? '←', onPress: cancel } }
117}
118
119/** The tab bar: the selected tab filled, the others dim; a digit presses each. */
120export function TabBar(ui: ElementTable, tabs: TabSpec[], active: string, onSelect: (key: string) => void) {
121 const { Box, Button, Text } = ui
122
123 return (
124 // A tab never wraps inside: on a narrow pane the next tab moves to a new row.
125 // Right under the header: on a short terminal every row the chrome keeps is a row of the tab's body.
126 <Box key="tabs" columnGap={1} flexWrap="wrap">
127 {tabs.map(tab => {
128 const isActive = tab.key === active
129
130 return (
131 <Box
132 key={`tab-${tab.key}`}
133 paddingX={1}
134 flexShrink={0}
135 backgroundColor={isActive ? theme.tabActive : theme.switchOff}
136 hover={{ backgroundColor: theme.tabActive }}
137 >
138 <Button
139 key={`tab-button-${tab.key}`}
140 label={printable(tab.label)}
141 hotkey={tab.hotkey}
142 plain
143 {...(isActive ? {} : { dimColor: true })}
144 onPress={() => onSelect(tab.key)}
145 />
146 {tab.badge && <Text color={isActive ? theme.accent : undefined} dimColor={!isActive}>{` ${printable(tab.badge)}`}</Text>}
147 </Box>
148 )
149 })}
150 </Box>
151 )
152}
153
154/** A dim rule across `width` cells, under the tab bar. */
155export function Rule(ui: ElementTable, key: string, width: number) {
156 const { Text } = ui
157
158 return (
159 <Text key={key} dimColor>
160 {'─'.repeat(Math.max(1, width))}
161 </Text>
162 )
163}
164
165/** A rounded card; the accented one (the account in use, a running agent) in the accent colour. */
166export function Card(ui: ElementTable, key: string, isAccent: boolean, children: unknown) {
167 const { Box } = ui
168
169 return (
170 <Box
171 key={key}
172 flexDirection="column"
173 borderStyle="round"
174 borderColor={isAccent ? theme.accent : undefined}
175 borderDimColor={!isAccent}
176 paddingX={1}
177 >
178 {children as never}
179 </Box>
180 )
181}
182
183/** What a piece of text means, which sets its colour. */
184export type Tone = 'accent' | 'ok' | 'danger' | 'warn' | 'caution' | 'stale'
185
186/**
187 * Text in the colour of what it means: current or selected `accent`, added or
188 * done `ok`, removed or failed `danger`, changed or waiting `warn`, an old
189 * reading `stale`; no tone for plain text. Drawn inside another Text or alone.
190 */
191export function Toned(
192 ui: ElementTable,
193 key: string,
194 text: string,
195 tone?: Tone,
196 style: { isDim?: boolean; isBold?: boolean; wrap?: 'wrap' | 'truncate-end' } = {},
197) {
198 const { Text } = ui
199
200 return (
201 <Text key={key} color={tone ? theme[tone] : undefined} dimColor={style.isDim === true} bold={style.isBold === true} {...(style.wrap ? { wrap: style.wrap } : {})}>
202 {printable(text)}
203 </Text>
204 )
205}
206
207/**
208 * Lines added and removed, `+12 −3`, in green and red. With widths, each
209 * figure is padded at the start to its column, so rows line up.
210 */
211export function ChangeCounts(ui: ElementTable, key: string, added: number, removed: number, widths: { added: number; removed: number } = { added: 0, removed: 0 }) {
212 const { Text } = ui
213
214 return (
215 <Text key={key}>
216 {Toned(ui, `${key}-added`, `+${added}`.padStart(widths.added), 'ok')}
217 <Text> </Text>
218 {Toned(ui, `${key}-removed`, `−${removed}`.padStart(widths.removed), 'danger')}
219 </Text>
220 )
221}
222
223/** A bar of `width` cells: added cells green, removed cells red, the rest a dim dotted track. */
224export function ChangeBar(ui: ElementTable, key: string, cells: { added: number; removed: number }, width: number) {
225 const { Text } = ui
226
227 return (
228 <Text key={key}>
229 {Toned(ui, `${key}-added`, '■'.repeat(cells.added), 'ok')}
230 {Toned(ui, `${key}-removed`, '■'.repeat(cells.removed), 'danger')}
231 {Toned(ui, `${key}-rest`, '·'.repeat(Math.max(0, width - cells.added - cells.removed)), undefined, { isDim: true })}
232 </Text>
233 )
234}
235
236/**
237 * The frame around a text input: round, in the accent colour where it is
238 * the place to type, dim otherwise; an optional glyph before the input. It
239 * takes the whole width it is given, in a row or a column alike.
240 */
241export function InputFrame(ui: ElementTable, key: string, isAccent: boolean, input: unknown, glyph?: string) {
242 const { Box, Text } = ui
243
244 return (
245 <Box
246 key={key}
247 borderStyle="round"
248 borderColor={isAccent ? theme.accent : 'gray'}
249 borderDimColor={!isAccent}
250 paddingX={1}
251 gap={1}
252 flexGrow={1}
253 >
254 {glyph && <Text color={isAccent ? theme.accent : undefined}>{printable(glyph)}</Text>}
255 {input as never}
256 </Box>
257 )
258}
259
260/** A word that is pressed: plain at rest, accent and bold under the pointer. */
261export function LinkButton(ui: ElementTable, key: string, label: string, onPress: () => void, extra: { autoFocus?: boolean } = {}) {
262 const { Button } = ui
263
264 return <Button key={key} label={printable(label)} plain hover={{ color: theme.accent, bold: true }} {...(extra.autoFocus ? { autoFocus: true as const } : {})} onPress={onPress} />
265}
266
267/** A section heading: a title, and a dim detail after it. */
268export function Section(ui: ElementTable, key: string, title: string, detail?: string) {
269 const { Text } = ui
270
271 return (
272 <Text key={key}>
273 <Text bold>{printable(title)}</Text>
274 {detail && <Text dimColor>{` ${printable(detail)}`}</Text>}
275 </Text>
276 )
277}
278
279/** The tone of a weekly window's label and reset as its reset nears: yellow three days out, orange two, red on the day. */
280export function countdownTone(day: 1 | 2 | 3 | undefined): Tone | undefined {
281 return day === 1 ? 'danger' : day === 2 ? 'caution' : day === 3 ? 'warn' : undefined
282}
283
284/** A thin gauge: its label (dim, or bold in `labelTone`), the used part coloured by severity over a dim track, the percentage. */
285export function Gauge(ui: ElementTable, key: string, label: string, percent: number, color = severityColor(percent), labelTone?: Tone) {
286 const { Text } = ui
287 const { filled, rest } = barParts(percent, GAUGE_WIDTH)
288
289 return (
290 <Text key={key}>
291 {labelTone ? <Text color={theme[labelTone]} bold>{`${printable(label)} `}</Text> : <Text dimColor>{`${printable(label)} `}</Text>}
292 <Text color={color}>{filled}</Text>
293 <Text color={theme.track} dimColor>
294 {rest}
295 </Text>
296 <Text>{` ${Math.round(percent)}%`}</Text>
297 </Text>
298 )
299}
300
301/**
302 * A glyph button: the glyph at full strength on a dark ground of its tone's
303 * colour (red to delete or stop, yellow to restore or pin, cyan to compare or
304 * open, green to finish), so what it does reads at rest; the glyph takes the
305 * tone under the pointer. A switch that is off (an unpinned star) sits on the
306 * neutral fill. One cell wide, as the glyph alone.
307 */
308export function IconButton(ui: ElementTable, key: string, glyph: string, tone: string, onPress: () => void, isOff = false) {
309 const { Box, Button } = ui
310 const ground = isOff ? theme.tile : (GLYPH_GROUND[tone] ?? theme.tile)
311
312 return (
313 <Box key={`${key}-ground`} flexShrink={0} backgroundColor={ground} hover={{ backgroundColor: theme.tileHover }}>
314 <Button key={key} label={printable(glyph)} plain hover={{ color: tone, bold: true }} onPress={onPress} />
315 </Box>
316 )
317}
318
319/**
320 * A small filled button for a row's main action: its label on the main
321 * tile's tint, one cell of padding each side, lighter under the pointer.
322 */
323export function TileButton(ui: ElementTable, key: string, label: string, onPress: () => void) {
324 const { Box, Button } = ui
325
326 return (
327 <Box key={`${key}-tile`} paddingX={1} backgroundColor={theme.tileMain} hover={{ backgroundColor: theme.tileHover }}>
328 <Button key={key} label={printable(label)} plain onPress={onPress} />
329 </Box>
330 )
331}
332
333/**
334 * A value that opens a choice dialog when pressed, drawn as a select: the
335 * value and `▾` on the unselected tabs' fill, one cell of padding each side,
336 * lighter under the pointer. It never shrinks; the row it sits in gives way.
337 */
338export function SelectField(ui: ElementTable, key: string, value: string, onPress: () => void) {
339 const { Box } = ui
340
341 return (
342 <Box key={`${key}-field`} paddingX={1} flexShrink={0} backgroundColor={theme.switchOff} hover={{ backgroundColor: theme.tabActive }}>
343 {LinkButton(ui, key, `${value} ▾`, onPress)}
344 </Box>
345 )
346}
347
348/**
349 * A dim second line under a row, indented under its text and cut to one
350 * line: what a row did last, or a fact that does not fit beside it.
351 */
352export function SubLine(ui: ElementTable, key: string, text: string) {
353 const { Box, Text } = ui
354
355 return (
356 <Box key={key} paddingLeft={2}>
357 <Text dimColor wrap="truncate-end">
358 {`↳ ${printable(text).replace(/\s+/g, ' ').trim()}`}
359 </Text>
360 </Box>
361 )
362}
363
364/**
365 * Figures in a row: each value bold with its name dim after it, the row
366 * wrapping whole figures to the next line where the width runs out.
367 */
368export function StatRow(ui: ElementTable, key: string, items: { label: string; value: string }[]) {
369 const { Box, Text } = ui
370
371 return (
372 <Box key={key} columnGap={3} flexWrap="wrap">
373 {items.map(item => (
374 <Text key={`${key}-${item.label}`}>
375 <Text bold>{printable(item.value)}</Text>
376 <Text dimColor>{` ${printable(item.label)}`}</Text>
377 </Text>
378 ))}
379 </Box>
380 )
381}
382
383/** Eighths of a cell, from empty to full, for the bars of a chart. */
384const BAR_EIGHTHS = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
385
386/**
387 * Vertical bars `height` rows tall, one per value, in the accent colour, drawn
388 * with eighth blocks so a small value still shows; each bar is as wide as
389 * `width` allows (one or two cells, a cell between bars), with its label
390 * under it when the bar is two cells wide and on every fifth bar otherwise.
391 * The largest value is named above the bars.
392 */
393export function BarChart(ui: ElementTable, key: string, bars: { label: string; value: number }[], width: number, height: number, largestText: string) {
394 const { Box, Text } = ui
395 const largest = Math.max(1, ...bars.map(bar => bar.value))
396 const barWidth = bars.length * 3 - 1 <= width ? 2 : 1
397 const eighths = bars.map(bar => (bar.value > 0 ? Math.max(1, Math.round((bar.value / largest) * height * 8)) : 0))
398 const rows = Array.from({ length: height }, (_, index) => height - 1 - index)
399 const cell = (filled: number, row: number) => BAR_EIGHTHS[Math.max(0, Math.min(8, filled - row * 8))] ?? ' '
400
401 return (
402 <Box key={key} flexDirection="column">
403 <Text dimColor>{printable(largestText)}</Text>
404 {rows.map(row => (
405 <Text key={`${key}-row-${row}`} color={theme.accent}>
406 {eighths.map(filled => cell(filled, row).repeat(barWidth)).join(' ')}
407 </Text>
408 ))}
409 <Text key={`${key}-labels`} dimColor>
410 {barWidth === 2
411 ? bars.map(bar => printable(bar.label).slice(-2).padStart(2)).join(' ')
412 : // One-cell bars sit two cells apart: a two-character label fits under every fifth.
413 bars.reduce((line, bar, index) => (index % 5 === 0 ? padCells(line, index * 2) + printable(bar.label).slice(-2) : line), '')}
414 </Text>
415 </Box>
416 )
417}
418
419/**
420 * A ranking, one row each: the name, a dim detail after it, and the value at
421 * the right edge. The name keeps its whole width up to half the row; the
422 * detail takes what is left and is cut first.
423 */
424export function RankList(ui: ElementTable, key: string, rows: { name: string; detail: string; value: string; onPress?: () => void }[], width: number) {
425 const { Box, Text } = ui
426 const cleaned = rows.map(row => ({ ...row, name: printable(row.name), detail: printable(row.detail), value: printable(row.value) }))
427 const valueWidth = Math.max(0, ...cleaned.map(row => displayWidth(row.value)))
428
429 return (
430 <Box key={key} flexDirection="column">
431 {cleaned.map((row, index) => {
432 const free = Math.max(12, width - valueWidth - 4)
433 const room = Math.min(displayWidth(row.name), Math.max(Math.floor(free / 2), free - displayWidth(row.detail)))
434 const detail = truncate(row.detail, Math.max(0, free - room))
435
436 // Rows are keyed by place: two rows may share a name (one line found in two files).
437 return (
438 <Box key={`${key}-${index}`} justifyContent="space-between">
439 {/* A row that opens something has its name as the button. */}
440 {row.onPress ? (
441 <Box gap={2} flexShrink={1}>
442 {LinkButton(ui, `${key}-${index}-open`, truncate(row.name, room), row.onPress)}
443 <Text dimColor wrap="truncate-end">
444 {detail}
445 </Text>
446 </Box>
447 ) : (
448 <Text wrap="truncate-end">
449 <Text>{truncate(row.name, room)}</Text>
450 <Text dimColor>{detail === '' ? '' : ` ${detail}`}</Text>
451 </Text>
452 )}
453 <Box flexShrink={0} marginLeft={1}>
454 <Text bold>{padCells(row.value, valueWidth, 'start')}</Text>
455 </Box>
456 </Box>
457 )
458 })}
459 </Box>
460 )
461}
462
463/**
464 * A file's outline under its row: each entry's line number dim in one
465 * column, then its text indented by its depth, every entry one line.
466 */
467export function OutlineList(ui: ElementTable, key: string, entries: { line: number; text: string; level: number }[], width: number) {
468 const { Box, Text } = ui
469 const numberWidth = Math.max(0, ...entries.map(entry => String(entry.line).length))
470
471 return (
472 <Box key={key} flexDirection="column" paddingLeft={2}>
473 {entries.map(entry => {
474 const indent = ' '.repeat(Math.max(0, entry.level - 1))
475
476 return (
477 <Text key={`${key}-${entry.line}`} wrap="truncate-end">
478 <Text dimColor>{`${String(entry.line).padStart(numberWidth)} `}</Text>
479 <Text>{truncate(`${indent}${printable(entry.text)}`, Math.max(6, width - numberWidth - 4))}</Text>
480 </Text>
481 )
482 })}
483 </Box>
484 )
485}
486
487/** A filled badge such as `active`. */
488export function Badge(ui: ElementTable, key: string, text: string, background = theme.accent) {
489 const { Text } = ui
490
491 return (
492 <Text key={key} color="black" backgroundColor={background}>
493 {` ${printable(text)} `}
494 </Text>
495 )
496}
497
498/** What a tab shows with nothing in it. */
499export function Empty(ui: ElementTable, key: string, lines: string[]) {
500 const { Box, Text } = ui
501
502 return (
503 <Box key={key} flexDirection="column" paddingX={2} paddingY={1}>
504 {lines.map((line, index) => (
505 <Text key={`${key}-${index}`} dimColor={index > 0}>
506 {printable(line)}
507 </Text>
508 ))}
509 </Box>
510 )
511}
512
513/** Cells a tile keeps on each side of its label. */
514const TILE_PADDING = 2
515
516/**
517 * The footer: equal filled tiles across the pane, one row tall, each label
518 * centred on one line. Tiles that would squeeze a label onto two lines move
519 * to a further row instead. The main action's tile carries the accent's tint.
520 */
521export function Tiles(ui: ElementTable, bodyColumns: number, given: Tile[], outline?: { focused: string | null }) {
522 const { Box, Button } = ui
523 if (given.length === 0) return null
524 const tiles = given.map(tile => ({ ...tile, label: printable(tile.label) }))
525 const needed = Math.max(...tiles.map(tile => displayWidth(tile.label))) + TILE_PADDING * 2
526 const perRow = Math.max(1, Math.min(tiles.length, Math.floor((bodyColumns + TILE_GAP) / (needed + TILE_GAP))))
527 const width = Math.max(needed, Math.floor((bodyColumns - TILE_GAP * (perRow - 1)) / perRow))
528 const rows: Tile[][] = []
529 for (let start = 0; start < tiles.length; start += perRow) rows.push(tiles.slice(start, start + perRow))
530
531 return (
532 <Box key="footer" flexDirection="column" marginTop={1} rowGap={1}>
533 {rows.map((row, index) => (
534 <Box key={`footer-row-${index}`} gap={TILE_GAP}>
535 {row.map(tile => (
536 <Box
537 key={`tile-${tile.key}`}
538 width={width}
539 justifyContent="center"
540 alignItems="center"
541 backgroundColor={tile.isMain ? theme.tileMain : theme.tile}
542 hover={{ backgroundColor: theme.tileHover }}
543 // Outlined tiles show where the keyboard is: the focused one in the accent colour.
544 {...(outline
545 ? outline.focused === tile.key
546 ? { borderStyle: 'round', borderColor: theme.accent }
547 : { borderStyle: 'round', borderColor: 'gray', borderDimColor: true }
548 : {})}
549 >
550 <Button
551 key={tile.key}
552 label={tile.label}
553 plain
554 {...(tile.isDismiss ? { role: 'dismiss' as const } : {})}
555 {...(tile.isFocused ? { autoFocus: true as const } : {})}
556 onPress={tile.onPress}
557 />
558 </Box>
559 ))}
560 </Box>
561 ))}
562 </Box>
563 )
564}
565
566/** A glyph button on a status tile: what it does and the tone of its ground. */
567export type TileAction = { key: string; glyph: string; tone: string; onPress: () => void }
568
569/**
570 * A tile that says how its thing stands, in two lines: an icon and a title,
571 * with glyph buttons at the right of the same line, then a status line; both
572 * lines press the tile, across its whole width. `ground` fills the tile with the state's
573 * dark colour (`ok` running, `warn` waiting, `danger` failed); `isLit` false
574 * drops it to the neutral fill, so a caller alternating it makes the tile blink.
575 */
576export type StatusTile = {
577 key: string
578 icon: string
579 iconTone?: Tone
580 title: string
581 status: string
582 ground?: 'ok' | 'warn' | 'danger' | 'accent'
583 isLit?: boolean
584 actions?: TileAction[]
585 onPress: () => void
586}
587
588const STATUS_GROUND: Record<NonNullable<StatusTile['ground']>, string> = {
589 ok: theme.glyphOk,
590 warn: theme.glyphWarn,
591 danger: theme.glyphDanger,
592 accent: theme.glyphAccent,
593}
594
595/**
596 * Status tiles, `columns` to a row (two by default, one where the pane is
597 * too narrow for two of `minWidth` cells), every tile the same width and its
598 * text left-aligned and cut to one line each.
599 */
600export function StatusTiles(ui: ElementTable, bodyColumns: number, tiles: StatusTile[], options: { columns?: number; minWidth?: number } = {}) {
601 const { Box, Button } = ui
602 const wanted = options.columns ?? 2
603 const perRow = Math.max(1, Math.min(wanted, Math.floor((bodyColumns + TILE_GAP) / ((options.minWidth ?? 28) + TILE_GAP))))
604 const width = Math.floor((bodyColumns - TILE_GAP * (perRow - 1)) / perRow)
605 const rows: StatusTile[][] = []
606 for (let start = 0; start < tiles.length; start += perRow) rows.push(tiles.slice(start, start + perRow))
607
608 return (
609 <Box key="status-tiles" flexDirection="column" rowGap={1}>
610 {rows.map((row, index) => (
611 <Box key={`status-row-${index}`} gap={TILE_GAP}>
612 {row.map(tile => {
613 const buttons = tile.actions ?? []
614 // The tile keeps a cell of padding each side; each glyph button is one cell with a cell before it.
615 const text = Math.max(4, width - 2)
616 const title = Math.max(1, text - displayWidth(printable(tile.icon)) - 1 - buttons.length * 2)
617 const ground = tile.ground && tile.isLit !== false ? STATUS_GROUND[tile.ground] : theme.tile
618
619 return (
620 <Box key={`status-${tile.key}`} width={width} flexShrink={0} paddingX={1} flexDirection="column" backgroundColor={ground} hover={{ backgroundColor: theme.tileHover }}>
621 <Box justifyContent="space-between">
622 <Box gap={1} flexShrink={1}>
623 {Toned(ui, `status-icon-${tile.key}`, tile.icon, tile.iconTone, { isBold: true })}
624 <Button key={tile.key} label={padCells(truncate(printable(tile.title).replace(/\s+/g, ' '), title), title)} plain onPress={tile.onPress} />
625 </Box>
626 {buttons.length > 0 && (
627 <Box gap={1} flexShrink={0}>
628 {buttons.map(action => IconButton(ui, action.key, action.glyph, action.tone, action.onPress))}
629 </Box>
630 )}
631 </Box>
632 <Button key={`${tile.key}-status`} label={padCells(truncate(printable(tile.status).replace(/\s+/g, ' '), text), text)} plain onPress={tile.onPress} />
633 </Box>
634 )
635 })}
636 </Box>
637 ))}
638 </Box>
639 )
640}
641
642/** One line of a dialog: its text, and a tone for the line that matters most. */
643export type DialogLine = { text: string; tone?: 'danger' | 'ok' | 'muted' }
644
645/**
646 * The frame every dialog shares: the pane's usual header, then a round-bordered
647 * box holding the title, the body and the tiles.
648 */
649export function DialogFrame(
650 ui: ElementTable,
651 header: HeaderInfo,
652 borderColor: string,
653 title: string,
654 body: ReturnType<typeof Header>,
655 tiles: ReturnType<typeof Tiles>,
656) {
657 const { Box, Text } = ui
658
659 return (
660 <Box key="dialog-pane" flexDirection="column">
661 {Header(ui, header)}
662 <Box key="dialog" flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1}>
663 <Text bold>{printable(title)}</Text>
664 {body}
665 {tiles}
666 </Box>
667 </Box>
668 )
669}
670
671/**
672 * A confirmation dialog, drawn in a pane opened with `focus`, `closeOnEscape`
673 * and `holdToasts`: the pane's usual header, then a bordered box holding the
674 * title, the facts the choice rests on, and the two tiles, the confirming one
675 * focused so Enter answers. The border takes the warning colour: what the
676 * dialog confirms is hard to take back. With `defaultFocus: 'cancel'` Cancel
677 * holds the keyboard first, so an Enter meant for the prompt cancels: for an
678 * action that reaches past this pane (every session's login, saved data).
679 */
680export function Dialog(
681 ui: ElementTable,
682 bodyColumns: number,
683 header: HeaderInfo,
684 title: string,
685 lines: DialogLine[],
686 confirm: { label: string; onPress: () => void },
687 cancel: { label: string; onPress: () => void },
688 /** The key of the tile holding the keyboard; the one `defaultFocus` names until the ring moves. */
689 focused: string | null = null,
690 defaultFocus: 'confirm' | 'cancel' = 'confirm',
691) {
692 const { Box, Text } = ui
693 const isCancelFirst = defaultFocus === 'cancel'
694 const color = (tone: DialogLine['tone']) => (tone === 'danger' ? theme.danger : tone === 'ok' ? theme.ok : undefined)
695
696 return DialogFrame(
697 ui,
698 dialogHeader(header, cancel.onPress),
699 theme.warn,
700 title,
701 <Box key="dialog-lines" flexDirection="column" marginTop={1}>
702 {lines.map((line, index) => (
703 <Text key={`dialog-line-${index}`} color={color(line.tone)} dimColor={line.tone === 'muted'} wrap="wrap">
704 {printable(line.text)}
705 </Text>
706 ))}
707 </Box>,
708 Tiles(
709 ui,
710 Math.max(20, bodyColumns - CARD_CHROME),
711 [
712 { key: 'dialog-confirm', label: confirm.label, isMain: true, isFocused: !isCancelFirst, onPress: confirm.onPress },
713 { key: 'dialog-cancel', label: cancel.label, isDismiss: true, isFocused: isCancelFirst, onPress: cancel.onPress },
714 ],
715 { focused: focused ?? (isCancelFirst ? 'dialog-cancel' : 'dialog-confirm') },
716 ),
717 )
718}
719
720/**
721 * A dialog that asks for a line of text, drawn like the others: the title,
722 * the facts the answer is about, a bordered input that holds the keyboard,
723 * and Cancel. Enter submits. Where the surface has no input, `noInput` says
724 * how else to give the text.
725 */
726export function InputDialog(
727 ui: ElementTable,
728 bodyColumns: number,
729 header: HeaderInfo,
730 title: string,
731 lines: DialogLine[],
732 input: {
733 key: string
734 placeholder: string
735 value?: string
736 submitLabel: string
737 onSubmit: (value: string) => void
738 /** Whether the surface draws a text field: every surface but mobile. */
739 hasField: boolean
740 noInput: string
741 },
742 cancel: { label: string; onPress: () => void },
743 focused: string | null = null,
744) {
745 const { Box } = ui
746 // The mobile app draws no input: there the dialog says how else to give the text.
747 const Input = input.hasField && 'Input' in ui ? ui.Input : undefined
748
749 return DialogFrame(
750 ui,
751 dialogHeader(header, cancel.onPress),
752 theme.accent,
753 title,
754 <Box key="dialog-input-body" flexDirection="column" marginTop={1}>
755 {lines.map((line, index) => Toned(ui, `dialog-line-${index}`, line.text, line.tone === 'danger' ? 'danger' : line.tone === 'ok' ? 'ok' : undefined, { isDim: line.tone === 'muted', wrap: 'wrap' }))}
756 <Box key="dialog-input-row" marginTop={1}>
757 {Input
758 ? InputFrame(
759 ui,
760 'dialog-input-frame',
761 true,
762 <Input
763 key={input.key}
764 placeholder={printable(input.placeholder)}
765 {...(input.value !== undefined ? { value: printable(input.value) } : {})}
766 submitLabel={printable(input.submitLabel)}
767 autoFocus
768 onSubmit={input.onSubmit}
769 />,
770 '✎',
771 )
772 : Toned(ui, 'dialog-no-input', input.noInput, undefined, { isDim: true, wrap: 'wrap' })}
773 </Box>
774 </Box>,
775 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), [{ key: 'dialog-cancel', label: cancel.label, isDismiss: true, onPress: cancel.onPress }], { focused }),
776 )
777}
778
779/**
780 * A dialog that reads a document: its title and a dim line under it, one
781 * page of its Markdown drawn as a reply is, and tiles to the previous and
782 * next page, with the page's number, and Close. A longer document comes in
783 * pages cut to a fixed row budget, so the tiles stay in view.
784 */
785export function ReaderDialog(
786 ui: ElementTable,
787 bodyColumns: number,
788 header: HeaderInfo,
789 title: string,
790 subtitle: string,
791 markdown: string,
792 page: { index: number; count: number; label: string },
793 actions: { previous: { label: string; onPress: () => void }; next: { label: string; onPress: () => void }; close: { label: string; onPress: () => void } },
794 focused: string | null = null,
795) {
796 const { Box, Markdown, Text } = ui
797 const tiles: Tile[] = [
798 ...(page.index > 0 ? [{ key: 'reader-previous', label: actions.previous.label, onPress: actions.previous.onPress }] : []),
799 ...(page.index < page.count - 1 ? [{ key: 'reader-next', label: actions.next.label, isMain: true, onPress: actions.next.onPress }] : []),
800 { key: 'dialog-cancel', label: actions.close.label, isDismiss: true, onPress: actions.close.onPress },
801 ]
802
803 return DialogFrame(
804 ui,
805 dialogHeader(header, actions.close.onPress),
806 theme.accent,
807 title,
808 <Box key="reader-body" flexDirection="column">
809 <Text dimColor wrap="truncate-end">
810 {printable(page.count > 1 ? `${subtitle} · ${page.label}` : subtitle)}
811 </Text>
812 <Box key="reader-page" flexDirection="column" marginTop={1}>
813 <Markdown key="reader-markdown" text={printable(markdown)} />
814 </Box>
815 </Box>,
816 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), tiles, { focused }),
817 )
818}
819
820/** One field of a form dialog: a line of text, with suggestions to pick under it, or a pick from options. */
821export type FormField = {
822 key: string
823 label: string
824 value: string
825 placeholder?: string
826 /** A pick from these instead of typed text (every surface but mobile draws it). */
827 options?: { value: string; label: string }[]
828 /** Values to pick under the field, such as paths matching what was typed. */
829 suggestions?: string[]
830 /** Takes the field's new value; the promise it may return settles once the value is kept. */
831 onInput: (value: string) => void | Promise<void>
832 onPick?: (value: string) => void | Promise<void>
833}
834
835/**
836 * A dialog that asks for several values at once: each field its label dim,
837 * then a bordered input or a select, and under a typed field the suggestions
838 * to pick; the main tile submits, Cancel dismisses. Enter in any field submits,
839 * after that field's value is kept.
840 * Where the surface has no field, `noInput` says how else to give the values.
841 */
842export function FormDialog(
843 ui: ElementTable,
844 bodyColumns: number,
845 header: HeaderInfo,
846 title: string,
847 lines: DialogLine[],
848 fields: FormField[],
849 actions: { submit: { label: string; onPress: () => void }; cancel: { label: string; onPress: () => void } },
850 form: { hasField: boolean; noInput: string },
851 focused: string | null = null,
852) {
853 const { Box, Text } = ui
854 const Input = form.hasField && 'Input' in ui ? ui.Input : undefined
855 const Select = form.hasField && 'Select' in ui ? ui.Select : undefined
856
857 return DialogFrame(
858 ui,
859 dialogHeader(header, actions.cancel.onPress),
860 theme.accent,
861 title,
862 <Box key="form-body" flexDirection="column" marginTop={1} gap={1}>
863 {lines.map((line, index) => Toned(ui, `form-line-${index}`, line.text, line.tone === 'danger' ? 'danger' : line.tone === 'ok' ? 'ok' : undefined, { isDim: line.tone === 'muted', wrap: 'wrap' }))}
864 {!Input && Toned(ui, 'form-no-input', form.noInput, undefined, { isDim: true, wrap: 'wrap' })}
865 {Input &&
866 fields.map((field, index) => (
867 <Box key={`form-field-${field.key}`} flexDirection="column">
868 <Text dimColor>{printable(field.label)}</Text>
869 {field.options && Select ? (
870 <Select
871 key={`field-${field.key}`}
872 options={field.options.map(option => ({ ...option, label: printable(option.label) }))}
873 value={field.value}
874 {...(index === 0 ? { autoFocus: true as const } : {})}
875 onSelect={value => void field.onInput(value)}
876 />
877 ) : (
878 InputFrame(
879 ui,
880 `form-frame-${field.key}`,
881 true,
882 <Input
883 key={`field-${field.key}`}
884 value={printable(field.value)}
885 {...(field.placeholder ? { placeholder: printable(field.placeholder) } : {})}
886 {...(index === 0 ? { autoFocus: true as const } : {})}
887 onInput={value => void field.onInput(value)}
888 // Enter submits once the field's value is kept, so the submit reads what was typed.
889 onSubmit={value => void Promise.resolve(field.onInput(value)).then(actions.submit.onPress, actions.submit.onPress)}
890 />,
891 )
892 )}
893 {(field.suggestions ?? []).length > 0 && (
894 <Box key={`form-suggest-${field.key}`} flexDirection="column" paddingLeft={2}>
895 {(field.suggestions ?? []).map((suggestion, row) =>
896 LinkButton(ui, `suggest-${field.key}-${row}`, suggestion, () => void (field.onPick ?? field.onInput)(suggestion)),
897 )}
898 </Box>
899 )}
900 </Box>
901 ))}
902 </Box>,
903 Tiles(
904 ui,
905 Math.max(20, bodyColumns - CARD_CHROME),
906 [
907 ...(Input ? [{ key: 'form-submit', label: actions.submit.label, isMain: true, onPress: actions.submit.onPress }] : []),
908 { key: 'dialog-cancel', label: actions.cancel.label, isDismiss: true, onPress: actions.cancel.onPress },
909 ],
910 { focused },
911 ),
912 )
913}
914
915/**
916 * A dialog that shows a run's output as it arrives: its state on a line, then
917 * the lines in a numbered block, the newest at the bottom while it follows;
918 * tiles to page back and forward, to follow or stop following, to stop the
919 * run while it runs or run it again once it has ended, and Close.
920 */
921export function LogDialog(
922 ui: ElementTable,
923 bodyColumns: number,
924 header: HeaderInfo,
925 title: string,
926 status: { text: string; tone?: Tone },
927 log: { text: string; firstLine: number; empty: string },
928 actions: { older?: Tile; newer?: Tile; follow: Tile; stop?: Tile; again?: Tile; close: Tile },
929 focused: string | null = null,
930) {
931 const { Box, Code } = ui
932
933 return DialogFrame(
934 ui,
935 dialogHeader(header, actions.close.onPress),
936 theme.accent,
937 title,
938 <Box key="log-body" flexDirection="column" marginTop={1}>
939 {Toned(ui, 'log-status', status.text, status.tone)}
940 <Box key="log-lines" flexDirection="column" marginTop={1}>
941 {log.text === '' ? Toned(ui, 'log-empty', log.empty, undefined, { isDim: true }) : <Code key="log-code" source={printable(log.text)} startLine={log.firstLine} wrap="wrap" />}
942 </Box>
943 </Box>,
944 Tiles(
945 ui,
946 Math.max(20, bodyColumns - CARD_CHROME),
947 [actions.older, actions.newer, actions.follow, actions.stop, actions.again, actions.close].filter((tile): tile is Tile => tile !== undefined),
948 { focused },
949 ),
950 )
951}
952
953/**
954 * A dialog that shows one page of code, a diff or a file: a dim line saying
955 * what it is, the page's lines numbered from where the page starts, and tiles
956 * to turn pages, then the caller's own (another file, an action on this one),
957 * then Close. The page is cut by the caller to a fixed number of lines, so the
958 * dialog keeps its height from page to page.
959 */
960export function CodeDialog(
961 ui: ElementTable,
962 bodyColumns: number,
963 header: HeaderInfo,
964 title: string,
965 subtitle: string,
966 code: { source: string; format?: 'diff'; path?: string; empty: string },
967 page: { index: number; count: number; label: string },
968 actions: { previous: { label: string; onPress: () => void }; next: { label: string; onPress: () => void }; extra?: Tile[]; close: { label: string; onPress: () => void } },
969 focused: string | null = null,
970) {
971 const { Box, Code, Text } = ui
972 const tiles: Tile[] = [
973 ...(page.index > 0 ? [{ key: 'code-previous', label: actions.previous.label, onPress: actions.previous.onPress }] : []),
974 ...(page.index < page.count - 1 ? [{ key: 'code-next', label: actions.next.label, isMain: true, onPress: actions.next.onPress }] : []),
975 ...(actions.extra ?? []),
976 { key: 'dialog-cancel', label: actions.close.label, isDismiss: true, onPress: actions.close.onPress },
977 ]
978
979 return DialogFrame(
980 ui,
981 dialogHeader(header, actions.close.onPress),
982 theme.accent,
983 title,
984 <Box key="code-body" flexDirection="column" marginTop={1}>
985 <Text dimColor wrap="truncate-end">
986 {printable(page.count > 1 ? `${subtitle} · ${page.label}` : subtitle)}
987 </Text>
988 <Box key="code-lines" flexDirection="column" marginTop={1}>
989 {printable(code.source).trim() === '' ? (
990 Toned(ui, 'code-empty', code.empty, undefined, { isDim: true })
991 ) : (
992 <Code
993 key="code-source"
994 source={printable(code.source)}
995 {...(code.format ? { format: code.format } : {})}
996 {...(code.path ? { path: printable(code.path) } : {})}
997 />
998 )}
999 </Box>
1000 </Box>,
1001 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), tiles, { focused }),
1002 )
1003}
1004
1005/** One choice of a choice dialog: what pressing it picks, and a dim detail beside it. */
1006export type Choice = { key: string; label: string; detail?: string; isCurrent?: boolean; onPress: () => void }
1007
1008/**
1009 * A dialog that asks for one of several choices, drawn like the confirmation
1010 * dialog: the pane's header, then a bordered box holding the title, one row
1011 * per choice and Cancel. The current choice is marked `●` and holds the focus
1012 * first, so Enter keeps it; the labels share one column so the details line up.
1013 * The border takes the accent colour: choosing changes nothing that cannot be
1014 * chosen back.
1015 */
1016export function ChoiceDialog(
1017 ui: ElementTable,
1018 bodyColumns: number,
1019 header: HeaderInfo,
1020 title: string,
1021 choices: Choice[],
1022 cancel: { label: string; onPress: () => void },
1023 /** The key of the element holding the keyboard, so the Cancel tile can show it. */
1024 focused: string | null = null,
1025) {
1026 const { Box, Text } = ui
1027 // Every row is one line: the details share one column at the right, and the labels
1028 // take what is left of the dialog's width and are cut to it, never wrapped.
1029 const details = choices.map(choice => (choice.detail === undefined ? undefined : printable(choice.detail)))
1030 const detailWidth = Math.max(0, ...details.map(detail => displayWidth(detail ?? '')))
1031 const labelRoom = Math.max(6, bodyColumns - CARD_CHROME - 2 - (detailWidth > 0 ? detailWidth + 1 : 0))
1032 const labels = choices.map(choice => truncate(printable(choice.label).replace(/\s+/g, ' ').trim(), labelRoom))
1033 const labelWidth = Math.max(0, ...labels.map(displayWidth))
1034
1035 return DialogFrame(
1036 ui,
1037 dialogHeader(header, cancel.onPress),
1038 theme.accent,
1039 title,
1040 <Box key="dialog-choices" flexDirection="column" marginTop={1}>
1041 {choices.map((choice, index) => (
1042 <Box key={`choice-${choice.key}`} gap={1}>
1043 <Text color={choice.isCurrent ? theme.accent : undefined} dimColor={!choice.isCurrent}>
1044 {choice.isCurrent ? '●' : '○'}
1045 </Text>
1046 {LinkButton(ui, choice.key, padCells(labels[index] ?? '', labelWidth), choice.onPress, { autoFocus: choice.isCurrent === true })}
1047 {details[index] && (
1048 <Box flexShrink={0}>
1049 <Text dimColor wrap="truncate-end">
1050 {details[index]}
1051 </Text>
1052 </Box>
1053 )}
1054 </Box>
1055 ))}
1056 </Box>,
1057 Tiles(ui, Math.max(20, bodyColumns - CARD_CHROME), [{ key: 'dialog-cancel', label: cancel.label, isDismiss: true, onPress: cancel.onPress }], {
1058 focused,
1059 }),
1060 )
1061}
1062
1063/**
1064 * An on/off switch drawn as two segments, `[ On | Off ]`: the segment for the
1065 * current state filled (green for on, grey for off), the other dim. Pressing
1066 * the other segment switches to it; the setting's name beside it flips it.
1067 */
1068export function Toggle(
1069 ui: ElementTable,
1070 key: string,
1071 isOn: boolean,
1072 label: string,
1073 states: { on: string; off: string },
1074 onToggle: () => void,
1075) {
1076 const { Box, Button } = ui
1077 const segment = (side: 'on' | 'off') => {
1078 const isCurrent = (side === 'on') === isOn
1079
1080 return (
1081 <Box
1082 key={`${key}-${side}-segment`}
1083 paddingX={1}
1084 backgroundColor={isCurrent ? (side === 'on' ? theme.switchOn : theme.switchOffActive) : theme.switchOff}
1085 {...(isCurrent ? {} : { hover: { backgroundColor: theme.tileHover } })}
1086 >
1087 <Button
1088 key={`${key}-${side}`}
1089 label={printable(states[side])}
1090 plain
1091 {...(isCurrent ? {} : { dimColor: true })}
1092 // The current segment is already the state: pressing it changes nothing.
1093 onPress={() => {
1094 if (!isCurrent) onToggle()
1095 }}
1096 />
1097 </Box>
1098 )
1099 }
1100
1101 return (
1102 <Box key={key} gap={1}>
1103 <Box key={`${key}-segments`}>
1104 {segment('on')}
1105 {segment('off')}
1106 </Box>
1107 <Button key={`${key}-label`} label={printable(label)} plain hover={{ color: theme.accent, bold: true }} onPress={onToggle} />
1108 </Box>
1109 )
1110}
1111hooks/notes.ts 39 lines1import 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}
39hooks/shared/locale.ts 72 lines1// Copied from shared/ by scripts/sync.mjs; edit shared/ and run the script.
2export type Locale = 'en' | 'ko'
3
4/** Short weekday names, Sunday first, as `Date#getDay` counts. */
5export const WEEKDAYS: Record<Locale, readonly string[]> = {
6 en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
7 ko: ['일', '월', '화', '수', '목', '금', '토'],
8}
9
10const KOREAN = /^(ko\b|ko[-_]|korean|한국어)/i
11
12/**
13 * Picks the display language. Claude Code's `language` setting wins when set
14 * (any language without a translation here falls back to English); without
15 * it the POSIX locale variables decide, in their own precedence order.
16 */
17export function resolveLocale(language: unknown, localeVariables: (string | undefined)[]): Locale {
18 if (typeof language === 'string' && language.trim() !== '') return KOREAN.test(language.trim()) ? 'ko' : 'en'
19 const locale = localeVariables.find(value => value !== undefined && value !== '')
20
21 return locale !== undefined && KOREAN.test(locale) ? 'ko' : 'en'
22}
23
24/** The release date a changelog heading gives a version: `## 0.1.0 (2026-10-04)`. */
25export function releaseDateOf(changelog: string, version: string): string | undefined {
26 const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27
28 return new RegExp(`^##\\s+v?${escaped}\\s+\\((\\d{4}-\\d{2}-\\d{2})\\)`, 'm').exec(changelog)?.[1]
29}
30
31/** The particles whose form follows the sound before them: after a final consonant, then after a vowel. */
32const PARTICLES: readonly [string, string][] = [
33 ['을', '를'],
34 ['이', '가'],
35 ['은', '는'],
36 ['과', '와'],
37 ['으로', '로'],
38]
39
40/** Whether a Hangul syllable ends in a consonant, and whether that consonant is ㄹ; null for anything else. */
41function finalOf(char: string): { hasFinal: boolean; isRieul: boolean } | null {
42 const code = char.charCodeAt(0) - 0xac00
43 if (code < 0 || code > 11171) return null
44 const final = code % 28
45
46 return { hasFinal: final !== 0, isRieul: final === 8 }
47}
48
49/**
50 * The particles in `text` written right after `word` (a closing quote or
51 * placeholder brace between allowed) that do not fit its last syllable, such
52 * as `목록를`: a message that puts a fixed particle after a value it fills in
53 * is wrong for half the values it takes. Both forms written together
54 * (`을(를)`) fit any word and are not reported, and neither is a particle
55 * after a closing parenthesis, which belongs to the word before it opened.
56 */
57export function particleProblems(text: string, word: string): string[] {
58 const last = finalOf(word.slice(-1))
59 if (!last) return []
60 const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
61 const problems: string[] = []
62 for (const match of text.matchAll(new RegExp(`${escaped}[」』"'”’}]*(으로|로|을|를|이|가|은|는|과|와)(?!\\()(?=[\\s.,!?:;)」』]|$)`, 'g'))) {
63 const particle = match[1] ?? ''
64 const pair = PARTICLES.find(([after, before]) => after === particle || before === particle)
65 if (!pair) continue
66 const fits = pair[0] === '으로' ? (last.hasFinal && !last.isRieul ? '으로' : '로') : last.hasFinal ? pair[0] : pair[1]
67 if (particle !== fits) problems.push(match[0])
68 }
69
70 return problems
71}
72