Silent git snapshot before each turn's first file write; /checkpoints lists turns and undoes one after a confirm

Takes a silent snapshot of your project before Claude's first file write of every turn, so you can undo any turn.
/plugin marketplace add partalemre/MODZ
/plugin install checkpoint@modz
Or in one step: /plugin install checkpoint --marketplace partalemre/MODZ.
The MODZ panel (modz) shows its line here: how many checkpoints are saved and how long ago the newest was taken.
/checkpoints opens a pane with a timeline of turns, newest first. Each row has the turn number and age, the first line of your prompt, the number of files the turn touched, and a small letter chip for each kind of tool it used (edit, write, bash, read, search, web, agent, todo). Turns an undo went back past are struck through. The snapshot an undo takes first appears as "Before undo".
Undo on a row opens a confirm card: how many files will be restored and how many new files removed, a diffstat with added and removed lines per file, and two buttons.
r) goes back to before that turn.c) has the focus, so Enter changes nothing.After an undo, Redo brings the files back as they were. Undoing an older turn also rolls back every later turn, because the whole tree returns to the state before it.
On the desktop app the pane is drawn as pictures; in the terminal it is text.
| Command | Effect | |
|---|---|---|
| `/checkpoint [on\ | off]` | Turns snapshots on or off; a toast confirms, and the choice is kept across sessions |
/checkpoints | Opens the checkpoint pane |
There are no plugin options. Set MODZ_THEME=light or MODZ_THEME=dark in the env block of ~/.claude/settings.json to pin the pane's theme.
Write, Edit, MultiEdit or NotebookEdit call waits for one snapshot. Writes in parallel wait for the same snapshot, so none of them lands in it.refs/checkpoints/<session>/<time>. Your branch, HEAD, staging area and stash are never touched, and checkpoint refs are not pushed.~/.cache/claude-checkpoint/<folder>.git is used. It never runs on your home folder or /..gitignore is respected. node_modules and files over 5 MB are skipped.gc collects the objects.git restore; files created after the checkpoint are removed. Ignored files and files over 5 MB are left alone./usr/bin/git fails on macOS (the Xcode shim does this until the license is accepted), it falls back to the Command Line Tools git.git log --all in a real repo, the refs/checkpoints/* commits are listed like any other ref.Uses no model tokens: it runs plain git and adds nothing to the conversation.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 302 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { apply, gitBinary, list, locate, numstat, plan, prune, slug, snapshot } from './git'
5import type { Repo, Run } from './git'
6import { HUB_PANE, hubOf, isOpenFor } from './hub'
7import { glyphOf, keepListed, markUndone, pendingOf, sortGlyphs } from './meta'
8import type { MetaMap } from './meta'
9import { clip, motion, paneTree, targetOf } from './pane'
10import { withHome } from './home'
11import { pinned } from './pin'
12import { shownOf } from './stage'
13import { hookStage } from './stage-hook'
14import { setLang } from './lang'
15import { AUTO, themeFrom } from './tidemark'
16import { words } from './words'
17import type { CheckpointEntry, CheckpointStatus, CheckpointView, HubCard } from '../types'
18
19const PANE = 'checkpoints'
20const TOGGLE = 'checkpoint'
21const STORE_KEY = 'checkpoint.enabled'
22const META_KEY = 'checkpoint.meta'
23const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
24const GIT_TIMEOUT_MS = 60_000
25const PROMPT_LABEL_MAX = 100
26const EMPTY_VIEW: CheckpointView = { entries: [], status: null, isBusy: false, pending: null }
27const failed = (): CheckpointStatus => ({ text: words().failed, tone: 'error' })
28
29const viewAtom = atom({ plugin: 'checkpoint', key: 'view' } as const, EMPTY_VIEW)
30const enabledAtom = atom({ plugin: 'checkpoint', key: 'isEnabled' } as const, true)
31const metaAtom = atom({ plugin: 'checkpoint', key: 'meta' } as const, {} as MetaMap)
32const HUB = { plugin: 'checkpoint', key: 'hub' } as const
33const HOME = { plugin: 'checkpoint', key: 'home' } as const
34const themeAtom = atom({ plugin: 'checkpoint', key: 'theme' } as const, AUTO)
35
36// Armed by each prompt; the first write after it takes the snapshot, later writes wait for it.
37// `ref` is the turn's checkpoint once taken; `files` and `tools` what the turn did after it.
38const turn = {
39 isArmed: false, label: '', pending: null as Promise<void> | null, bin: undefined as string | null | undefined,
40 number: 0, ref: null as string | null, files: new Set<string>(), tools: new Set<string>(),
41}
42
43function runnerOf($: EngineInterface): Run {
44 return (argv, init) => $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS, ...init })
45}
46
47async function repoOf($: EngineInterface, isProbeOnly = false): Promise<Repo | null> {
48 const home = (await $.env.get('HOME')) ?? ''
49 const cwd = await $.session.cwd()
50 turn.bin ??= await gitBinary(runnerOf($), cwd)
51 if (turn.bin === null) return null
52
53 return locate(runnerOf($), { cwd, home, bin: turn.bin, isProbeOnly })
54}
55
56// The main panel's card (DESIGN.md §9), written only when what it shows changes so the panel does not redraw for nothing.
57async function writeHub($: EngineInterface, entries: CheckpointEntry[]): Promise<void> {
58 const now = await $.clock.now()
59 const shown = hubOf(entries, now)
60 const { value } = await $.state.get(HUB)
61 if (value !== undefined && value.detail === shown.detail && value.tone === shown.tone) return
62 const card: HubCard = { pane: HUB_PANE, ...shown, at: now }
63 await $.state.set(HUB, card)
64}
65
66// The main panel's card at session start: the checkpoints already there, read without making a shadow repo.
67async function greetHub($: EngineInterface): Promise<void> {
68 try {
69 const repo = await repoOf($, true)
70 await writeHub($, repo === null ? [] : await list(runnerOf($), repo))
71 } catch (error) {
72 $.ui.log(`checkpoint: could not count checkpoints (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
73 }
74}
75
76async function refName($: EngineInterface): Promise<string> {
77 return `${slug(await $.session.id())}/${await $.clock.now()}`
78}
79
80async function setView($: EngineInterface, change: Partial<CheckpointView>): Promise<void> {
81 await update($, viewAtom, view => ({ ...view, ...change }))
82}
83
84async function saveMeta($: EngineInterface, change: (meta: MetaMap) => MetaMap): Promise<void> {
85 await update($, metaAtom, meta => change(meta ?? {}))
86 await $.store.set(META_KEY, await read($, metaAtom))
87}
88
89async function take($: EngineInterface, label: string): Promise<void> {
90 try {
91 const repo = await repoOf($)
92 if (repo === null) {
93 $.ui.log('checkpoint: no working git, or folder too broad for a shadow copy; skipped', { to: 'debug' })
94
95 return
96 }
97 const ref = await refName($)
98 await snapshot(runnerOf($), repo, { ref, label })
99 const full = `refs/checkpoints/${ref}`
100 turn.ref = full
101 const entries = await prune(runnerOf($), repo)
102 const number = turn.number
103 await saveMeta($, meta => ({ ...keepListed(meta, entries), [full]: { turn: number } }))
104 await setView($, { entries })
105 await writeHub($, entries)
106 } catch (error) {
107 $.ui.log(`checkpoint: snapshot failed (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
108 }
109}
110
111// Step one of an undo: work out what it would change and put that before the person in the pane.
112async function ask($: EngineInterface, entry: CheckpointEntry): Promise<void> {
113 if ((await read($, viewAtom)).isBusy) return
114 await setView($, { isBusy: true, pending: null, status: { text: words().checking, tone: 'busy' } })
115 let change: Partial<CheckpointView> = { status: failed() }
116 try {
117 const repo = await repoOf($)
118 const changes = repo === null ? null : await plan(runnerOf($), repo, entry.commit)
119 if (repo === null || changes === null) change = { status: { text: words().noRepo, tone: 'error' } }
120 else if (changes.restore.length + changes.remove.length === 0) change = { status: { text: words().alreadyThere, tone: 'info' } }
121 else {
122 const stats = await numstat(runnerOf($), repo, { tree: changes.tree, commit: entry.commit })
123 const turnOf = (await read($, metaAtom))[entry.ref]?.turn ?? null
124 change = { status: null, pending: pendingOf(entry, { changes, stats, turn: turnOf }) }
125 }
126 } catch (error) {
127 $.ui.log(`checkpoint: undo check failed (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
128 }
129 await setView($, { isBusy: false, ...change })
130}
131
132// Step two, on the confirm button: the plan is made again (files may have changed since), a safety snapshot first.
133async function restore($: EngineInterface, entry: CheckpointEntry): Promise<CheckpointStatus> {
134 const repo = await repoOf($)
135 if (repo === null) return { text: words().noRepo, tone: 'error' }
136 const run = runnerOf($)
137 const changes = await plan(run, repo, entry.commit)
138 const total = changes.restore.length + changes.remove.length
139 if (total === 0) return { text: words().alreadyThere, tone: 'info' }
140 const safety = await refName($)
141 const label = `${words().safetyPrefix}${clip(entry.label)}`
142 const commit = await snapshot(run, repo, { ref: safety, label })
143 await apply(run, repo, { commit: entry.commit, plan: changes })
144 const entries = await prune(run, repo)
145 const safetyRef = `refs/checkpoints/${safety}`
146 await saveMeta($, meta => keepListed(markUndone(meta, { entries, target: entry, safetyRef }), entries))
147 await setView($, { entries })
148 await writeHub($, entries)
149 const number = (await read($, metaAtom))[entry.ref]?.turn
150
151 const redo = entries.find(item => item.ref === safetyRef) ?? { ref: safetyRef, commit, at: await $.clock.now(), label }
152
153 return { text: words().restored(total, targetOf(number, entry.label)), tone: 'success', redo }
154}
155
156async function confirm($: EngineInterface): Promise<void> {
157 const view = await read($, viewAtom)
158 if (view.isBusy || view.pending === null) return
159 await setView($, { isBusy: true, status: { text: words().undoing, tone: 'busy' } })
160 let status = failed()
161 try {
162 status = await restore($, view.pending.entry)
163 } catch (error) {
164 $.ui.log(`checkpoint: undo failed (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
165 }
166 await setView($, { isBusy: false, pending: null, status })
167}
168
169async function wantedOn($: EngineInterface, arg: string): Promise<boolean> {
170 if (arg === 'on') return true
171 if (arg === 'off') return false
172
173 return !(await read($, enabledAtom))
174}
175
176async function toggle($: EngineInterface, arg: string): Promise<void> {
177 const isOn = await wantedOn($, arg)
178 await update($, enabledAtom, () => isOn)
179 await $.store.set(STORE_KEY, isOn)
180 $.ui.toast(words().toggled(isOn))
181}
182
183// `isFocused` for the main panel's request: an open pane comes forward. Once up it takes a running hub's panel (kit/stage.ts).
184async function openPane($: EngineInterface, isFocused = false): Promise<void> {
185 try {
186 const repo = await repoOf($)
187 const entries = repo === null ? [] : await list(runnerOf($), repo)
188 await setView($, { entries, status: null, pending: null })
189 await writeHub($, entries)
190 } catch {
191 $.ui.log('checkpoint: could not list checkpoints', { to: 'debug' })
192 }
193 motion.reset()
194 const opened = await $.ui.open({ id: PANE, title: words().title, ...(isFocused ? { focus: true } : {}) })
195 try {
196 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
197 if (opened.isPlaced && isHubOn) await $.state.set({ plugin: 'checkpoint', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
198 } catch (error) {
199 $.ui.log(`checkpoint: shown failed (${String(error)})`, { to: 'debug' })
200 }
201}
202
203type On = Parameters<Register>[0]
204
205// Commands answer with no text: a transcript row would be read by the model on its next request.
206function hookCommands(on: On): void {
207 on('session.start', async ($, e, next) => {
208 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
209 try {
210 const saved = await $.store.get(STORE_KEY)
211 await update($, enabledAtom, () => saved !== false)
212 const meta = await $.store.get(META_KEY)
213 if (typeof meta === 'object' && meta !== null) await update($, metaAtom, () => meta as MetaMap)
214 const pinned = await $.env.get('MODZ_THEME').catch(() => undefined)
215 await update($, themeAtom, () => themeFrom(pinned))
216 await $.command.register({ name: TOGGLE, description: words().toggleLine, argumentHint: '[on|off]' })
217 await $.command.register({ name: PANE, description: words().paneLine })
218 } catch {
219 $.ui.log('checkpoint: could not load settings', { to: 'debug' })
220 }
221 const done = await next(e)
222 await greetHub($)
223
224 return done
225 })
226
227 on('command.run', { command: TOGGLE }, async ($, e) => (await toggle($, e.args.trim().toLowerCase()), {}))
228
229 on('command.run', { command: PANE }, async $ => (await openPane($), {}))
230}
231
232// Both hooks hand the event on untouched: nothing reaches the prompt or a tool result.
233function hookTurns(on: On): void {
234 on('prompt.submit', async ($, e, next) => {
235 turn.isArmed = true
236 turn.label = clip(e.text.split('\n')[0]?.trim() || words().promptFallback, PROMPT_LABEL_MAX)
237 turn.number += 1
238 turn.ref = null
239 turn.files.clear()
240 turn.tools.clear()
241
242 return next(e)
243 }).catch(($, e, next) => next(e))
244
245 on('tool.call', async ($, e, next) => {
246 const glyph = glyphOf(String(e.tool))
247 if (glyph !== undefined) turn.tools.add(glyph)
248 if (!WRITE_TOOLS.has(String(e.tool))) return next(e)
249 const path = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
250 if (typeof path === 'string') turn.files.add(path)
251 if (turn.isArmed && (await read($, enabledAtom))) {
252 turn.isArmed = false
253 turn.pending = take($, turn.label)
254 }
255 if (turn.pending !== null) await turn.pending
256
257 return next(e)
258 }).catch(($, e, next) => next(e))
259
260 // The turn's files and tools join its checkpoint once it ends.
261 on('turn.complete', async ($, e, next) => {
262 const ref = turn.ref
263 if (e.agentId === undefined && ref !== null) {
264 const done = { files: turn.files.size, tools: sortGlyphs(turn.tools) }
265 await saveMeta($, meta => ({ ...meta, [ref]: { ...meta[ref], ...done } })).catch(() => $.ui.log('checkpoint: could not keep turn notes', { to: 'debug' }))
266 }
267
268 return next(e)
269 })
270}
271
272export const register: Register = on => {
273 hookCommands(on)
274 hookTurns(on)
275 hookStage(on)
276
277 // The main panel's open request: opens only, so a pane already open comes forward instead of closing.
278 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
279 const done = await next(e)
280 if (isOpenFor(e.value, HUB_PANE)) await openPane($, true)
281
282 return done
283 }).catch(($, e, next) => next(e))
284
285 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
286 const actions = {
287 onUndo: (entry: CheckpointEntry) => void ask($, entry),
288 onConfirm: () => void confirm($),
289 onCancel: () => void setView($, { pending: null, status: null }),
290 }
291 const look = { theme: await read($, themeAtom), columns: e.props.bodyColumns, isRich: e.surface !== 'terminal' }
292
293 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
294 // Written when pressed, never while drawing; a failure only goes to the debug log.
295 const goHome = () => void $.clock.now().then(at => $.state.set(HOME, { at })).catch(() => $.ui.log('checkpoint: could not go back to MODZ', { to: 'debug' }))
296 const ui = $.ui.resolve(e)
297 const tree = paneTree(ui, await read($, viewAtom), { now: await $.clock.now(), meta: await read($, metaAtom), look, actions })
298
299 return pinned(e, withHome(ui, tree, isHubOn ? goHome : null))
300 })
301}
302hooks/git.ts 187 lines1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import type { CheckpointEntry } from '../types'
4
5export type Run = (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
6
7// A real repository, or a shadow one (bare git dir + work tree) for a folder that is not a repo.
8export type Repo = { root: string; gitDir: string; isShadow: boolean; bin: string }
9
10export type RestorePlan = { restore: string[]; remove: string[] }
11
12// Lines an undo would add and drop in one file (binary files count none).
13export type FileStat = { path: string; added: number; removed: number }
14
15export const KEEP = 30
16const REF_ROOT = 'refs/checkpoints/'
17const BIG_FILE = '+5M'
18// macOS: /usr/bin/git is an Xcode shim that fails until the Xcode license is accepted.
19const GIT_BINARIES = ['git', '/Library/Developer/CommandLineTools/usr/bin/git']
20const ARGV_CHUNK = 200
21const IDENTITY = {
22 GIT_AUTHOR_NAME: 'checkpoint', GIT_AUTHOR_EMAIL: 'checkpoint@localhost',
23 GIT_COMMITTER_NAME: 'checkpoint', GIT_COMMITTER_EMAIL: 'checkpoint@localhost',
24}
25
26function argvOf(repo: Repo, args: string[]): string[] {
27 return repo.isShadow ? [repo.bin, `--git-dir=${repo.gitDir}`, `--work-tree=${repo.root}`, ...args] : [repo.bin, ...args]
28}
29
30export async function gitBinary(run: Run, cwd: string): Promise<string | null> {
31 for (const bin of GIT_BINARIES) {
32 const out = await run([bin, '--version'], { cwd }).catch(() => null)
33 if (out !== null && out.exitCode === 0) return bin
34 }
35
36 return null
37}
38
39async function gitWith(run: Run, repo: Repo, call: { args: string[]; init: ProcessRunInit }): Promise<string> {
40 const { args, init } = call
41 const out = await run(argvOf(repo, args), { cwd: repo.root, ...init })
42 if (out.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${out.stderr.trim().slice(0, 200)}`)
43
44 return out.stdout.trim()
45}
46
47const git = (run: Run, repo: Repo, args: string[]): Promise<string> => gitWith(run, repo, { args, init: {} })
48
49function chunks<T>(items: T[]): T[][] {
50 const out: T[][] = []
51 for (let i = 0; i < items.length; i += ARGV_CHUNK) out.push(items.slice(i, i + ARGV_CHUNK))
52
53 return out
54}
55
56const literal = (path: string) => `:(literal)${path}`
57
58export function slug(text: string): string {
59 return text.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 120) || 'root'
60}
61
62// `isProbeOnly` finds a shadow repo made before but never makes one (a look at what exists, not a snapshot).
63export async function locate(run: Run, opts: { cwd: string; home: string; bin: string; isProbeOnly?: boolean }): Promise<Repo | null> {
64 const { cwd, home, bin } = opts
65 const found = await run([bin, 'rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd })
66 const [root, gitDir] = found.stdout.trim().split('\n')
67 if (found.exitCode === 0 && root !== undefined && gitDir !== undefined) return { root, gitDir, isShadow: false, bin }
68 // A shadow repo of a home folder or the disk root would copy far too much.
69 if (home === '' || cwd === home || cwd === '/') return null
70 const repo: Repo = { root: cwd, gitDir: `${home}/.cache/claude-checkpoint/${slug(cwd)}.git`, isShadow: true, bin }
71 const probe = await run([bin, `--git-dir=${repo.gitDir}`, 'rev-parse', '--git-dir'], { cwd })
72 if (probe.exitCode !== 0 && opts.isProbeOnly === true) return null
73 if (probe.exitCode !== 0) {
74 await git(run, { ...repo, isShadow: false }, ['init', '-q', '--bare', repo.gitDir])
75 await git(run, { ...repo, isShadow: false }, [`--git-dir=${repo.gitDir}`, 'config', 'core.bare', 'false'])
76 }
77
78 return repo
79}
80
81async function bigFiles(run: Run, repo: Repo): Promise<string[]> {
82 const argv = ['find', '.', '(', '-name', '.git', '-o', '-name', 'node_modules', ')', '-prune', '-o', '-type', 'f', '-size', BIG_FILE, '-print']
83 const out = await run(argv, { cwd: repo.root })
84
85 return out.stdout.split('\n').filter(line => line !== '').map(line => line.replace(/^\.\//, ''))
86}
87
88// Writes the work tree as git sees it (ignores respected, big files left out) to a tree object,
89// through a private index so the repository's own index and branch are never touched.
90export async function writeTree(run: Run, repo: Repo): Promise<string> {
91 const index = `${repo.gitDir}/checkpoint.index`
92 const env = { GIT_INDEX_FILE: index }
93 await run(['rm', '-f', index], { cwd: repo.root })
94 if (!repo.isShadow) await run(['cp', `${repo.gitDir}/index`, index], { cwd: repo.root })
95 const big = await bigFiles(run, repo)
96 await gitWith(run, repo, { args: ['add', '-A', '--', '.', ...big.map(path => `:(exclude,literal)${path}`)], init: { env } })
97 for (const part of chunks(big)) {
98 await gitWith(run, repo, { args: ['rm', '--cached', '-q', '--ignore-unmatch', '--', ...part.map(literal)], init: { env } })
99 }
100 const tree = await gitWith(run, repo, { args: ['write-tree'], init: { env } })
101 await run(['rm', '-f', index], { cwd: repo.root })
102
103 return tree
104}
105
106export async function snapshot(run: Run, repo: Repo, opts: { ref: string; label: string }): Promise<string> {
107 const tree = await writeTree(run, repo)
108 const head = await run(argvOf(repo, ['rev-parse', '--verify', '-q', 'HEAD']), { cwd: repo.root })
109 const parent = head.exitCode === 0 ? ['-p', head.stdout.trim()] : []
110 const commit = await gitWith(run, repo, { args: ['commit-tree', tree, ...parent, '-m', opts.label], init: { env: IDENTITY } })
111 await git(run, repo, ['update-ref', `${REF_ROOT}${opts.ref}`, commit])
112
113 return commit
114}
115
116export async function list(run: Run, repo: Repo): Promise<CheckpointEntry[]> {
117 const format = '%(refname)%09%(objectname)%09%(committerdate:unix)%09%(subject)'
118 const out = await git(run, repo, ['for-each-ref', '--sort=-committerdate', `--format=${format}`, REF_ROOT])
119
120 return out.split('\n').filter(line => line !== '').map(line => {
121 const [ref = '', commit = '', unix = '0', ...rest] = line.split('\t')
122
123 return { ref, commit, at: Number(unix) * 1000, label: rest.join('\t') }
124 })
125}
126
127// Drops checkpoints past `keep`, newest kept, and answers the ones left. A shadow repo, which no
128// git command of the person's ever collects, is also asked to gc once refs were dropped.
129export async function prune(run: Run, repo: Repo, keep = KEEP): Promise<CheckpointEntry[]> {
130 const all = await list(run, repo)
131 const stale = all.slice(keep)
132 if (stale.length === 0) return all
133 const stdin = stale.map(entry => `delete ${entry.ref}\n`).join('')
134 await gitWith(run, repo, { args: ['update-ref', '--stdin'], init: { stdin } })
135 if (repo.isShadow) await run(argvOf(repo, ['gc', '--auto', '--quiet']), { cwd: repo.root })
136
137 return all.slice(0, keep)
138}
139
140export function parseNameStatus(out: string): RestorePlan {
141 const parts = out.split('\0').filter(part => part !== '')
142 const plan: RestorePlan = { restore: [], remove: [] }
143 for (let i = 0; i + 1 < parts.length; i += 2) {
144 const path = parts[i + 1] ?? ''
145 if (parts[i] === 'A') plan.remove.push(path)
146 else plan.restore.push(path)
147 }
148
149 return plan
150}
151
152// What undoing to `commit` changes: files to bring back from it, and files made since to remove.
153// `tree` is the work tree as written now, for a diffstat against the same state.
154export async function plan(run: Run, repo: Repo, commit: string): Promise<RestorePlan & { tree: string }> {
155 const now = await writeTree(run, repo)
156 const out = await run(argvOf(repo, ['diff', '--name-status', '--no-renames', '-z', `${commit}^{tree}`, now]), { cwd: repo.root })
157 if (out.exitCode !== 0) throw new Error(`git diff failed: ${out.stderr.trim().slice(0, 200)}`)
158
159 return { ...parseNameStatus(out.stdout), tree: now }
160}
161
162// `--numstat -z` rows: "added<TAB>removed<TAB>path" each ended by NUL; "-" for a binary file.
163export function parseNumstat(out: string): FileStat[] {
164 return out.split('\0').flatMap(row => {
165 const found = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(row.replace(/^\n/, ''))
166 if (found === null) return []
167
168 return [{ path: found[3] ?? '', added: Number(found[1]) || 0, removed: Number(found[2]) || 0 }]
169 })
170}
171
172// The lines undoing to `commit` changes per file: from the tree as it is now to the checkpoint's.
173export async function numstat(run: Run, repo: Repo, spec: { tree: string; commit: string }): Promise<FileStat[]> {
174 const out = await run(argvOf(repo, ['diff', '--numstat', '--no-renames', '-z', spec.tree, `${spec.commit}^{tree}`]), { cwd: repo.root })
175
176 return out.exitCode === 0 ? parseNumstat(out.stdout) : []
177}
178
179export async function apply(run: Run, repo: Repo, opts: { commit: string; plan: RestorePlan }): Promise<void> {
180 for (const part of chunks(opts.plan.restore)) {
181 await git(run, repo, ['restore', `--source=${opts.commit}`, '--worktree', '--', ...part.map(literal)])
182 }
183 for (const part of chunks(opts.plan.remove)) {
184 await run(['rm', '-f', '--', ...part.map(path => `${repo.root}/${path}`)], { cwd: repo.root })
185 }
186}
187hooks/hub.ts 23 lines1import { ago } from './pane'
2import type { CheckpointEntry, HubTone } from '../types'
3import { words } from './words'
4
5// The MODZ main panel's side of this mod (DESIGN.md §9): a one-line summary card it collects, and its
6// "open" requests, which name a pane by key.
7
8export const HUB_PANE = 'checkpoints'
9
10export type HubShown = { detail: string; tone: HubTone }
11
12// "12 saved · 3m ago"; nothing to say while no checkpoint exists.
13export function hubOf(entries: CheckpointEntry[], now: number): HubShown {
14 const newest = entries[0]
15 if (newest === undefined) return { detail: '', tone: 'idle' }
16
17 return { detail: words().hubDetail(entries.length, ago(now - newest.at)), tone: 'ok' }
18}
19
20export function isOpenFor(value: unknown, pane: string): boolean {
21 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
22}
23hooks/meta.ts 47 lines1import type { FileStat, RestorePlan } from './git'
2import type { CheckpointEntry, CheckpointMeta, PendingUndo } from '../types'
3
4// What the timeline knows beyond git's label: turn numbers, files, tools, undone turns.
5export type MetaMap = Record<string, CheckpointMeta>
6
7export const SAMPLE_MAX = 5
8
9const TOOL_GLYPH: Record<string, string> = {
10 Edit: 'E', MultiEdit: 'E', NotebookEdit: 'E', Write: 'W', Bash: '$', Read: 'R', Grep: 'S', Glob: 'S',
11 WebFetch: 'G', WebSearch: 'G', Task: 'A', Agent: 'A', TodoWrite: 'T',
12}
13const GLYPH_ORDER = ['E', 'W', '$', 'R', 'S', 'G', 'A', 'T']
14
15export const glyphOf = (tool: string): string | undefined => TOOL_GLYPH[tool]
16
17export function sortGlyphs(glyphs: Iterable<string>): string[] {
18 return [...new Set(glyphs)].sort((a, b) => GLYPH_ORDER.indexOf(a) - GLYPH_ORDER.indexOf(b))
19}
20
21// Only the refs git still has keep their notes.
22export function keepListed(meta: MetaMap, entries: CheckpointEntry[]): MetaMap {
23 return Object.fromEntries(entries.filter(entry => meta[entry.ref] !== undefined).map(entry => [entry.ref, meta[entry.ref] as CheckpointMeta]))
24}
25
26// After going back to `target`: its turn and every later one are undone, earlier ones are not.
27export function markUndone(meta: MetaMap, spec: { entries: CheckpointEntry[]; target: CheckpointEntry; safetyRef: string }): MetaMap {
28 const out: MetaMap = { ...meta, [spec.safetyRef]: { ...meta[spec.safetyRef], isSafety: true } }
29 for (const entry of spec.entries) {
30 if (entry.ref === spec.safetyRef || out[entry.ref]?.isSafety === true) continue
31 out[entry.ref] = { ...out[entry.ref], isUndone: entry.at >= spec.target.at }
32 }
33
34 return out
35}
36
37export function pendingOf(entry: CheckpointEntry, spec: { changes: RestorePlan; stats: FileStat[]; turn: number | null }): PendingUndo {
38 const { changes, stats } = spec
39 const statOf = (path: string) => stats.find(stat => stat.path === path)
40 const files = [...changes.restore.map(path => ({ path, isRemoved: false })), ...changes.remove.map(path => ({ path, isRemoved: true }))]
41 .slice(0, SAMPLE_MAX)
42 .map(file => ({ ...file, added: statOf(file.path)?.added ?? 0, removed: statOf(file.path)?.removed ?? 0 }))
43 const sum = (key: 'added' | 'removed') => stats.reduce((total, stat) => total + stat[key], 0)
44
45 return { entry, turn: spec.turn, restore: changes.restore.length, remove: changes.remove.length, added: sum('added'), removed: sum('removed'), files }
46}
47hooks/pane.tsx 170 lines1import { paneMotion } from './kit/moving'
2import type { MoveOpts } from './kit/moving'
3import type { MetaMap } from './meta'
4import { diffstatSvg, headSvg, rowFits, rowSvg } from './pane-svg'
5import type { Drawing, Frame, RowSpec } from './pane-svg'
6import { svgWidth } from './svg'
7import { boxColor } from './tidemark'
8import type { Theme } from './tidemark'
9import { textPane } from './pane-text'
10import type { CheckpointEntry, CheckpointStatus, CheckpointView, PendingUndo } from '../types'
11import { SAFETY_PREFIXES, words } from './words'
12
13// The resolved element table of the surface that draws the pane.
14export type Ui = Record<string, any>
15
16export type PaneActions = {
17 onUndo: (entry: CheckpointEntry) => void
18 onConfirm: () => void
19 onCancel: () => void
20}
21
22export type PaneLook = { theme: Theme; columns: number; isRich: boolean }
23export type PaneOpts = { now: number; meta: MetaMap; look: PaneLook; actions: PaneActions }
24
25const LABEL_MAX = 60
26const WIDTH = { min: 240, max: 860 }
27// Columns the row's Undo button takes; the card's padding and border.
28const ROW_RESERVE = 12
29const CARD_RESERVE = 6
30
31export const ago = (ms: number): string => words().ago(ms)
32
33export function clip(text: string, max = LABEL_MAX): string {
34 return text.length > max ? `${text.slice(0, max - 1)}…` : text
35}
36
37export const changedText = (pending: PendingUndo): string => words().changed(pending.restore + pending.remove)
38
39// The confirm button says what it does (verb and object); Cancel holds the focus, so Enter changes nothing.
40export const confirmOf = (pending: PendingUndo): { label: string; hotkey: string } => words().confirm(pending.restore + pending.remove)
41
42export const emptyText = (): string => `${words().emptyLead} · ${words().emptyRest}`
43
44/** What the undo goes back to: the turn when it is known, else the prompt's opening words. */
45export const targetOf = (turn: number | null | undefined, label: string, max = LABEL_MAX): string =>
46 turn === null || turn === undefined ? `"${clip(label, max)}"` : words().turn(turn)
47
48const QUESTION_LABEL_MAX = 40
49
50// A turn's checkpoint is the tree before that turn wrote: "Go back to before Turn 12?". Going there undoes
51// that turn and every later one.
52export const questionOf = (pending: PendingUndo): string => words().question(targetOf(pending.turn, pending.entry.label, QUESTION_LABEL_MAX))
53
54export function titleOf(entry: CheckpointEntry, meta: MetaMap): { title: string; label: string } {
55 const own = meta[entry.ref] ?? {}
56 const prefix = SAFETY_PREFIXES.find(start => entry.label.startsWith(start))
57 if (own.isSafety === true || prefix !== undefined) return { title: words().beforeUndo, label: entry.label.slice((prefix ?? words().safetyPrefix).length) }
58
59 return { title: own.turn === undefined ? words().saved : words().turn(own.turn), label: entry.label }
60}
61
62export const headerMeta = (view: CheckpointView, now: number): string =>
63 view.entries[0] === undefined ? '' : words().headerMeta(view.entries.length, ago(now - view.entries[0].at))
64
65// The pane's drawings on the desktop (kit/moving.ts): reset as the pane opens, so they cascade in each time.
66export const motion = paneMotion()
67
68function picture(ui: Ui, drawing: Drawing, key: string, opts?: MoveOpts) {
69 const { Svg } = ui
70
71 return <Svg key={key} {...motion.pic(key, drawing, opts)} />
72}
73
74function confirmCard(ui: Ui, pending: PendingUndo, opts: PaneOpts) {
75 const { Box, Button } = ui
76 const frame: Frame = { width: svgWidth(opts.look.columns, { ...WIDTH, reserve: CARD_RESERVE }), mode: opts.look.theme.mode }
77 const said = words()
78 const head = headSvg(frame, { role: 'amber', label: said.undo, meta: said.undoMeta, lead: questionOf(pending), rest: changedText(pending) })
79 const confirm = confirmOf(pending)
80
81 return (
82 <Box flexDirection="column" borderStyle="single" borderColor={boxColor(opts.look.theme, 'amber', 'line')} paddingX={2} paddingY={1} rowGap={1}>
83 {picture(ui, head, 'cp-card-head')}
84 {picture(ui, diffstatSvg(frame, pending), 'cp-card-stat')}
85 <Box flexDirection="row" columnGap={1}>
86 <Button key="cp-confirm" variant="secondary" hotkey={confirm.hotkey} label={confirm.label} onPress={opts.actions.onConfirm} />
87 <Button key="cp-cancel" variant="secondary" hotkey={said.cancel.hotkey} label={said.cancel.label} autoFocus onPress={opts.actions.onCancel} />
88 </Box>
89 </Box>
90 )
91}
92
93function rowSpecs(view: CheckpointView, opts: PaneOpts): RowSpec[] {
94 const last = view.entries.length - 1
95
96 return view.entries.map((entry, index) => ({
97 ...titleOf(entry, opts.meta), when: ago(opts.now - entry.at), meta: opts.meta[entry.ref] ?? {}, isNewest: index === 0, isFirst: index === 0, isLast: index === last,
98 }))
99}
100
101// A row moves with its checkpoint, not its place, and its age ticking on is no change to sweep.
102function rowPicture(ui: Ui, row: { frame: Frame; spec: RowSpec; isStacked: boolean; ref: string }) {
103 const { when, ...shows } = row.spec
104
105 return picture(ui, rowSvg(row.frame, { ...row.spec, isStacked: row.isStacked }), `cp-svg-${row.ref}`, { isRow: true, shows: { ...shows, isStacked: row.isStacked, frame: row.frame, isAged: when !== '' } })
106}
107
108// Every row stacks its facts under the label once one row has no room beside its title, so the rows stay alike.
109function timeline(ui: Ui, view: CheckpointView, opts: PaneOpts) {
110 const { Box, Button } = ui
111 const frame: Frame = { width: svgWidth(opts.look.columns, { ...WIDTH, reserve: ROW_RESERVE }), mode: opts.look.theme.mode }
112 const specs = rowSpecs(view, opts)
113 const isStacked = specs.some(spec => !rowFits(frame, spec))
114
115 return (
116 <Box flexDirection="column">
117 {view.entries.map((entry, index) => (
118 <Box key={`cp-row-${index}`} flexDirection="row" alignItems="flex-start" columnGap={1}>
119 {rowPicture(ui, { frame, spec: specs[index] as RowSpec, isStacked, ref: entry.ref })}
120 <Button key={`cp-undo-${index}`} plain dimColor label={words().undo} onPress={() => opts.actions.onUndo(entry)} />
121 </Box>
122 ))}
123 </Box>
124 )
125}
126
127const STATUS_LOOK: Record<CheckpointStatus['tone'], { color: string } | { dimColor: boolean }> = {
128 busy: { dimColor: true },
129 success: { color: 'success' },
130 error: { color: 'error' },
131 info: { dimColor: true },
132}
133
134export function statusRow(ui: Ui, view: CheckpointView, actions: PaneActions) {
135 const { Box, Text, Button } = ui
136 const status = view.status
137 if (status === null) return null
138 const look = STATUS_LOOK[status.tone]
139 const glyph = { busy: '◐', success: '✓', error: '✕', info: '›' }[status.tone]
140 const redo = status.redo
141
142 return (
143 <Box flexDirection="row" columnGap={1} alignItems="center">
144 <Text {...look}>{glyph}</Text>
145 <Text>{status.text}</Text>
146 {redo !== undefined && <Button key="cp-redo" plain hotkey="y" label={words().redo} onPress={() => actions.onUndo(redo)} />}
147 </Box>
148 )
149}
150
151export function paneTree(ui: Ui, view: CheckpointView, opts: PaneOpts) {
152 const { Box } = ui
153 if (!opts.look.isRich) return textPane(ui, view, opts)
154 const frame: Frame = { width: svgWidth(opts.look.columns, WIDTH), mode: opts.look.theme.mode }
155 const count = view.entries.length
156 const said = words()
157 const turns = view.entries.filter(entry => titleOf(entry, opts.meta).title !== said.beforeUndo).length
158 const lead = count === 0 ? { lead: said.emptyLead, rest: said.emptyRest, joiner: ' · ' } : { lead: said.turnsSaved(turns), rest: '' }
159 const head = headSvg(frame, { role: 'kiln', label: said.title, meta: headerMeta(view, opts.now), ...lead })
160
161 return (
162 <Box flexDirection="column" rowGap={1}>
163 {picture(ui, head, 'cp-head', { shows: { lead, turns, count, frame } })}
164 {statusRow(ui, view, opts.actions)}
165 {view.pending !== null && confirmCard(ui, view.pending, opts)}
166 {view.pending === null && count > 0 && timeline(ui, view, opts)}
167 </Box>
168 )
169}
170hooks/home.tsx 33 lines1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12 const { Box, Button } = ui
13
14 return (
15 <Box key="modz-home-row" flexDirection="row">
16 <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17 </Box>
18 )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23 if (onHome === null) return tree
24 const { Box } = ui
25
26 return (
27 <Box flexDirection="column" rowGap={1}>
28 {HomeButton(ui, onHome)}
29 {tree}
30 </Box>
31 )
32}
33hooks/pin.ts 48 lines1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18 let handle = pins.get(id)
19 if (handle === undefined) {
20 handle = FIRST_PINNED + pins.size
21 pins.set(id, handle)
22 }
23
24 return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28 if (typeof node !== 'object' || node === null) return node
29 const el = node as Node
30 const children = el.children?.map(child => pinNode(child, ctx))
31 const withKids = children === undefined ? el : { ...el, children }
32 // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33 if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34 const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35 const nth = ctx.seen.get(base) ?? 0
36 ctx.seen.set(base, nth + 1)
37
38 return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43 if (tree === null || tree === undefined) return tree
44 const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46 return pinNode(tree, ctx) as T
47}
48hooks/stage.ts 30 lines1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12 if (typeof value !== 'object' || value === null) return null
13 const pane = (value as { pane?: unknown }).pane
14
15 return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23 const staged = paneOf(value)
24
25 return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30hooks/stage-hook.ts 19 lines1import type { Register } from 'claude-code'
2
3import { HUB_PANE } from './hub'
4import { isOffStage } from './stage'
5
6type On = Parameters<Register>[0]
7
8const PANE = 'checkpoints'
9
10// Another pane took the MODZ main panel (kit/stage.ts): this one steps aside rather than wait as a tab.
11export function hookStage(on: On): void {
12 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
13 const done = await next(e)
14 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
15
16 return done
17 }).catch(($, e, next) => next(e))
18}
19hooks/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/tidemark.ts 73 lines1// Tidemark tokens (DESIGN.md §2) in one file, so the shared kit can replace it as a whole.
2// Svg reads them as CSS variables of the root's theme class; Box and Text read them through
3// `boxColor`, which falls back to the host's theme keys while the theme is not known.
4
5export type Mode = 'dark' | 'light' | 'auto'
6export type Role = 'tide' | 'kiln' | 'iris' | 'moss' | 'amber' | 'flare' | 'orchid'
7export type Neutral = 'card' | 'inset' | 'line' | 'strong' | 'track' | 'hi' | 'mid' | 'lo'
8export type Theme = { mode: Mode; isLight: boolean }
9
10type Swatch = { fg: string; tint: string; line: string }
11type Palette = Record<Neutral, string> & Record<Role, Swatch>
12
13// `MODZ_THEME=light|dark` (read by each mod with a literal $.env.get) pins the theme;
14// otherwise Svg follows the surface's color scheme.
15export const AUTO: Theme = { mode: 'auto', isLight: false }
16
17const NEUTRALS: Neutral[] = ['card', 'inset', 'line', 'strong', 'track', 'hi', 'mid', 'lo']
18const ROLES: Role[] = ['tide', 'kiln', 'iris', 'moss', 'amber', 'flare', 'orchid']
19
20const DARK: Palette = {
21 card: '#1F1F1C', inset: '#171715', line: '#2E2E2A', strong: '#45443F', track: '#34332F',
22 hi: '#F0EEE8', mid: '#C2BEB4', lo: '#8E8A80',
23 tide: { fg: '#7FB6E8', tint: '#1E2B37', line: '#2F4A63' },
24 kiln: { fg: '#E58A5F', tint: '#38251D', line: '#6A3E2D' },
25 iris: { fg: '#B19CF3', tint: '#29243D', line: '#4A3F75' },
26 moss: { fg: '#6FC593', tint: '#1B2F25', line: '#2F5A42' },
27 amber: { fg: '#E9B949', tint: '#332D19', line: '#66552A' },
28 flare: { fg: '#F2727F', tint: '#3B2023', line: '#74343C' },
29 orchid: { fg: '#E07BC0', tint: '#351F2E', line: '#6B3A5C' },
30}
31
32const LIGHT: Palette = {
33 card: '#FFFFFF', inset: '#F6F4EF', line: '#E4E0D6', strong: '#CFC9BC', track: '#ECE8DF',
34 hi: '#1C1B19', mid: '#4F4B44', lo: '#77726A',
35 tide: { fg: '#2464A0', tint: '#E7F0F9', line: '#B9D3EC' },
36 kiln: { fg: '#A94A26', tint: '#FBEDE6', line: '#EFC6B4' },
37 iris: { fg: '#6243C4', tint: '#F0ECFC', line: '#CFC3F3' },
38 moss: { fg: '#1F6E43', tint: '#E4F4EA', line: '#B5DEC4' },
39 amber: { fg: '#8A6000', tint: '#FBF2D9', line: '#EBD48F' },
40 flare: { fg: '#C0263A', tint: '#FCE8EA', line: '#F1B9C0' },
41 orchid: { fg: '#A8337F', tint: '#FBE9F4', line: '#EDBFDC' },
42}
43
44// The terminal's theme keys for each role (DESIGN.md §2, "Terminal eşlemesi").
45export const THEME_KEY: Record<Role, string> = {
46 tide: 'suggestion', kiln: 'claude', iris: 'permission', moss: 'success', amber: 'warning', flare: 'error', orchid: 'merged',
47}
48
49export function themeFrom(value: unknown): Theme {
50 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
51 if (name === 'light') return { mode: 'light', isLight: true }
52 if (name === 'dark') return { mode: 'dark', isLight: false }
53
54 return AUTO
55}
56
57const varsOf = (palette: Palette): string => [
58 ...NEUTRALS.map(name => `--${name}:${palette[name]}`),
59 ...ROLES.flatMap(role => [`--${role}:${palette[role].fg}`, `--${role}-t:${palette[role].tint}`, `--${role}-l:${palette[role].line}`]),
60].join(';')
61
62// Dark by default; a pinned light theme, or an unpinned one on a light surface, swaps the set.
63export function themeCss(): string {
64 return `svg,.t-dark{${varsOf(DARK)}}.t-light{${varsOf(LIGHT)}}@media (prefers-color-scheme: light){.t-auto{${varsOf(LIGHT)}}}`
65}
66
67// A role's color for a Box border or a Text: the hex once the theme is pinned, the theme key before.
68export function boxColor(theme: Theme, role: Role, part: keyof Swatch = 'fg'): string {
69 if (theme.mode === 'auto') return THEME_KEY[role]
70
71 return (theme.isLight ? LIGHT : DARK)[role][part]
72}
73hooks/words.ts 157 lines1// Every word the person reads from checkpoint, in English or in Turkish (MODZ_LANG=tr).
2// Read through `words()` when drawing or answering: the language is set at session.start.
3
4import { pick } from './lang'
5
6export type Words = {
7 title: string
8 ago: (ms: number) => string
9 hubDetail: (count: number, ago: string) => string
10 headerMeta: (count: number, ago: string) => string
11 emptyLead: string
12 emptyRest: string
13 turnsSaved: (turns: number) => string
14 turn: (number: number) => string
15 // A checkpoint with no turn number.
16 saved: string
17 beforeUndo: string
18 // What a safety snapshot's label starts with; written into git, so every language's prefix is read back.
19 safetyPrefix: string
20 undo: string
21 undoMeta: string
22 question: (target: string) => string
23 changed: (count: number) => string
24 confirm: (count: number) => { label: string; hotkey: string }
25 cancel: { label: string; hotkey: string }
26 redo: string
27 files: (count: number) => string
28 undone: string
29 willDelete: string
30 moreFiles: (count: number) => string
31 more: (count: number) => string
32 toRestore: (count: number) => string
33 toDelete: (count: number) => string
34 lines: string
35 summary: (restore: number, remove: number) => string
36 checking: string
37 undoing: string
38 noRepo: string
39 alreadyThere: string
40 failed: string
41 restored: (count: number, target: string) => string
42 toggled: (isOn: boolean) => string
43 toggleLine: string
44 paneLine: string
45 // A prompt with no words of its own.
46 promptFallback: string
47}
48
49const SECOND = 1000
50const MINUTE = 60 * SECOND
51const HOUR = 60 * MINUTE
52const DAY = 24 * HOUR
53
54const plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`
55
56/** `just now`, `3m ago`, `2h ago`, `1d ago`. */
57function agoEn(ms: number): string {
58 if (ms < MINUTE) return 'just now'
59 if (ms < HOUR) return `${Math.round(ms / MINUTE)}m ago`
60 if (ms < DAY) return `${Math.round(ms / HOUR)}h ago`
61
62 return `${Math.round(ms / DAY)}d ago`
63}
64
65function agoTr(ms: number): string {
66 if (ms < MINUTE) return `${Math.max(0, Math.round(ms / SECOND))} sn önce`
67 if (ms < HOUR) return `${Math.round(ms / MINUTE)} dk önce`
68 if (ms < DAY) return `${Math.round(ms / HOUR)} sa önce`
69
70 return `${Math.round(ms / DAY)} g önce`
71}
72
73const EN: Words = {
74 title: 'Checkpoints',
75 ago: agoEn,
76 hubDetail: (count, ago) => `${count} saved · ${ago}`,
77 headerMeta: (count, ago) => `${count} saved · newest ${ago}`,
78 emptyLead: 'No checkpoints yet',
79 emptyRest: 'one is taken quietly before the first file write',
80 turnsSaved: turns => `${plural(turns, 'turn', 'turns')} saved`,
81 turn: number => `Turn ${number}`,
82 saved: 'Checkpoint',
83 beforeUndo: 'Before undo',
84 safetyPrefix: 'before undo: ',
85 undo: 'Undo',
86 undoMeta: 'the current state is saved first',
87 question: target => `Go back to before ${target}?`,
88 changed: count => `${plural(count, 'file', 'files')} will change`,
89 confirm: count => ({ label: `Restore ${plural(count, 'file', 'files')}`, hotkey: 'r' }),
90 cancel: { label: 'Cancel', hotkey: 'c' },
91 redo: 'Redo',
92 files: count => `+${plural(count, 'file', 'files')}`,
93 undone: 'undone',
94 willDelete: 'will be deleted',
95 moreFiles: count => `+ ${plural(count, 'more file', 'more files')}`,
96 more: count => `+ ${count} more`,
97 toRestore: count => `${count} to restore`,
98 toDelete: count => `${count} to delete`,
99 lines: 'lines',
100 summary: (restore, remove) => [`${plural(restore, 'file', 'files')} will be restored`, remove > 0 ? `${plural(remove, 'new file', 'new files')} will be deleted` : ''].filter(Boolean).join(', ') + '. The current state is saved first.',
101 checking: 'Checking…',
102 undoing: 'Undoing…',
103 noRepo: 'There is no git repository here.',
104 alreadyThere: 'The files already match this checkpoint.',
105 failed: 'Couldn\'t undo; the details are in the debug log.',
106 restored: (count, target) => `Restored ${plural(count, 'file', 'files')} to before ${target}.`,
107 toggled: isOn => `checkpoint ${isOn ? 'on' : 'off'}`,
108 toggleLine: 'A quiet snapshot before each turn\'s first write: on/off',
109 paneLine: 'Lists the checkpoints; undoes a turn',
110 promptFallback: 'turn',
111}
112
113const TR: Words = {
114 title: 'Kontrol noktaları',
115 ago: agoTr,
116 hubDetail: (count, ago) => `${count} nokta · son ${ago}`,
117 headerMeta: (count, ago) => `${count} kayıt · en yenisi ${ago}`,
118 emptyLead: 'Henüz kontrol noktası yok',
119 emptyRest: 'ilk dosya yazımından önce sessizce alınır',
120 turnsSaved: turns => `${turns} tur kayıtlı`,
121 turn: number => `Tur ${number}`,
122 saved: 'Kayıt',
123 beforeUndo: 'Geri almadan önce',
124 safetyPrefix: 'geri almadan önce: ',
125 undo: 'Geri al',
126 undoMeta: 'şimdiki hal önce kaydedilir',
127 question: target => `${target} öncesine dönülsün mü?`,
128 changed: count => `${count} dosya değişecek`,
129 confirm: count => ({ label: `${count} dosyayı geri al`, hotkey: 'g' }),
130 cancel: { label: 'Vazgeç', hotkey: 'v' },
131 redo: 'Yinele',
132 files: count => `+${count} dosya`,
133 undone: 'geri alındı',
134 willDelete: 'silinecek',
135 moreFiles: count => `+${count} dosya daha`,
136 more: count => `+${count} daha`,
137 toRestore: count => `${count} geri yüklenecek`,
138 toDelete: count => `${count} silinecek`,
139 lines: 'satır',
140 summary: (restore, remove) => [`${restore} dosya geri yüklenecek`, remove > 0 ? `${remove} yeni dosya silinecek` : ''].filter(Boolean).join(', ') + '. Önce şimdiki hal kaydedilir.',
141 checking: 'Denetleniyor…',
142 undoing: 'Geri alınıyor…',
143 noRepo: 'Burada git deposu yok.',
144 alreadyThere: 'Dosyalar zaten bu noktadaki gibi.',
145 failed: 'Geri alınamadı; ayrıntı debug günlüğünde.',
146 restored: (count, target) => `${count} dosya ${target} öncesine döndü.`,
147 toggled: isOn => `checkpoint ${isOn ? 'açık' : 'kapalı'}`,
148 toggleLine: 'Her turun ilk yazımından önce sessiz anlık görüntü: aç/kapa',
149 paneLine: 'Kontrol noktalarını listeler; bir turu geri alır',
150 promptFallback: 'tur',
151}
152
153/** Every language's safety prefix: a label written under one language is still read under another. */
154export const SAFETY_PREFIXES = [EN.safetyPrefix, TR.safetyPrefix]
155
156export const words = (): Words => pick({ en: EN, tr: TR })
157