SLOPSHOPPER

checkpoint

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

newpaneguardcommandtoastprompt
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/checkpoint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · checkpoint
│ ┃ Checkpoints ✕ › fix the failing auth test and add an audit log c╭────────────────╮ │ ┃ ● CHECKPOINTS │ checkpoint │ │ ┃ ⏺ Read(src/auth.ts) │ checkpoint off │ │ ┃ No checkpoints yet · one is taken quietly ⎿ Read 6 lines ╰────────────────╯ │ ┃ before the first file write ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /checkpoint │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Checkpoints
● CHECKPOINTS No checkpoints yet · one is taken quietly before the first file write
README

checkpoint

Takes a silent snapshot of your project before Claude's first file write of every turn, so you can undo any turn.

Install

/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.

What you see

/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.

  • Restore N files (r) goes back to before that turn.
  • Cancel (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.

Commands

CommandEffect
`/checkpoint [on\off]`Turns snapshots on or off; a toast confirms, and the choice is kept across sessions
/checkpointsOpens the checkpoint pane

Settings

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.

How it works

  • After each prompt, the first 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.
  • In a git repo, the work tree goes into a private index and is committed under refs/checkpoints/<session>/<time>. Your branch, HEAD, staging area and stash are never touched, and checkpoint refs are not pushed.
  • Outside a repo, a shadow repo at ~/.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.
  • The newest 30 checkpoints per project are kept; older refs are deleted and git's normal gc collects the objects.
  • Before an undo, a safety checkpoint of the current state is taken, so the undo can itself be undone. Changed and deleted files come back with git restore; files created after the checkpoint are removed. Ignored files and files over 5 MB are left alone.
  • If /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.

Limits

  • Bash commands that write files do not trigger a snapshot. The next turn's snapshot captures their result.
  • If Claude runs 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.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 19 files
hooks/register.tsx 302 lines
1import { 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}
302
hooks/git.ts 187 lines
1import 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}
187
hooks/hub.ts 23 lines
1import { 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}
23
hooks/meta.ts 47 lines
1import 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}
47
hooks/pane.tsx 170 lines
1import { 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}
170
hooks/home.tsx 33 lines
1// 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}
33
hooks/pin.ts 48 lines
1// 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}
48
hooks/stage.ts 30 lines
1// 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 })
30
hooks/stage-hook.ts 19 lines
1import 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}
19
hooks/lang.ts 24 lines
1// 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)
24
hooks/tidemark.ts 73 lines
1// 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}
73
hooks/words.ts 157 lines
1// 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