SLOPSHOPPER

chezmoi-status

Status pane for this chezmoi repo: drift badges, AI diff summaries, a reconcile wizard, and Clawd fishing by the pond

newpanecommandmodelprocesstimer
v1.0.0no licenseupdated 2026-10-08btk5h/dotfiles/.claude/skills/chezmoi-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chezmoi-status
│ ┃ chezmoi status ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ Clawd fishing by the pond │ ┃ ⏺ Read(src/auth.ts) │ ┃ … checking r: ↻ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Reconcile ] ⎿ 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 │ │ › /chezmoi-status │ ⎿ chezmoi-status: chezmoi status pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · chezmoi status
▣ Clawd fishing by the pond … checking r: ↻ [ Reconcile ]
README

dotfiles

Personal dotfiles managed with chezmoi, shared across personal and work machines.

Setup

# Install chezmoi and apply dotfiles
chezmoi init --apply <repo-url>

Usage

chezmoi add ~/.<file>    # Track a new dotfile
chezmoi diff             # Preview pending changes
chezmoi apply            # Apply changes to home directory

Automation

Configuration management is assisted by Claude Code, which helps maintain and evolve dotfiles across machines.

Source 7 files
hooks/register.tsx 840 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DriftedFile, FileDecision, FileSummary, PackageDecision, PushState, Reconcile, Snapshot, Summary, Upstream } from '../types'
5import { FILM_ROWS, FILM_TICK_MS, filmFrame, fishKind, randomSize } from './film'
6import type { Mood, ReconcileStep, Scene } from './film'
7import { addToPackages, firstLine, note, parseBrewDiff, parseRichDiff } from './reconcile'
8import {
9  DIRECTION_LABELS,
10  FILE_OUTCOMES,
11  PACKAGE_OUTCOMES,
12  countBy,
13  describeCounts,
14  entryGlyph,
15  formatTime,
16  isEditedHere,
17  isScript,
18  moodOf,
19  parseStatus,
20  plural,
21  repoBadges,
22  statusBadges,
23} from './status'
24
25const PANE = 'chezmoi-status'
26const POLL_MS = 60_000
27const UPSTREAM_POLL_MS = 5 * 60_000
28const UPSTREAM_TIMEOUT_MS = 20_000
29const FETCHED_REF_PREFIX = 'refs/chezmoi-status/'
30const MAX_DIFF_CHARS = 100_000
31
32const snapshot = atom({ plugin: 'chezmoi-status', key: 'snapshot' } as const, null as Snapshot | null)
33const summary = atom({ plugin: 'chezmoi-status', key: 'summary' } as const, null as Summary | null)
34const upstream = atom({ plugin: 'chezmoi-status', key: 'upstream' } as const, null as Upstream | null)
35const reconcile = atom({ plugin: 'chezmoi-status', key: 'reconcile' } as const, null as Reconcile | null)
36const push = atom({ plugin: 'chezmoi-status', key: 'push' } as const, null as PushState | null)
37
38const SUMMARY_SYSTEM = [
39  'You summarize `chezmoi diff` output for the owner of a dotfiles repo.',
40  'In the diff, `-` lines are what is on this machine now and `+` lines are what `chezmoi apply` would write from the repo.',
41  'Write at most 6 terse markdown bullets, one per file or related group of files, saying in plain terms what applying would change.',
42  'When a change looks like a local edit on this machine that the repo lacks, say so.',
43  'No preamble, no headings.',
44].join(' ')
45
46
47const sha256 = async (text: string): Promise<string> => {
48  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
49  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')
50}
51
52const summarize = async ($: EngineInterface) => {
53  const diff = await $.process.run(['chezmoi', 'diff', '--no-pager'], { timeoutMs: 60_000 })
54  const diffHash = await sha256(diff.stdout)
55  const previous = await read($, summary)
56  if (previous?.diffHash === diffHash && previous.error === undefined) return
57
58  await update($, summary, () => ({ text: previous?.text ?? '', diffHash, isLoading: true }))
59  const prompt = diff.stdout.length > MAX_DIFF_CHARS
60    ? `${diff.stdout.slice(0, MAX_DIFF_CHARS)}\n\n(diff truncated)`
61    : diff.stdout
62  const reply = await $.model.complete({
63    model: 'haiku',
64    system: SUMMARY_SYSTEM,
65    prompt,
66    maxTokens: 600,
67    effort: 'low',
68    timeoutMs: 60_000,
69  })
70
71  await update($, summary, () =>
72    reply.isAnswered
73      ? { text: reply.text.trim(), diffHash, isLoading: false }
74      : { text: '', diffHash, isLoading: false, error: reply.reason },
75  )
76}
77
78let statusCheck: Promise<number> | null = null
79let isSummarizing = false
80
81const checkStatus = ($: EngineInterface): Promise<number> => {
82  statusCheck ??= (async () => {
83    const status = await $.process.run(['chezmoi', 'status'], { timeoutMs: 60_000 })
84    const checkedAt = await $.clock.now()
85    if (status.exitCode !== 0) {
86      const error = status.stderr.trim() || `chezmoi status exited ${status.exitCode}`
87      await update($, snapshot, () => ({ entries: [], error, checkedAt }))
88      return 0
89    }
90    const entries = parseStatus(status.stdout)
91    await update($, snapshot, () => ({ entries, checkedAt }))
92    return entries.length
93  })().finally(() => {
94    statusCheck = null
95  })
96  return statusCheck
97}
98
99const refresh = async ($: EngineInterface, { isOffline = true } = {}) => {
100  const pending = await checkStatus($)
101  await checkUpstream($, { isOffline })
102  if (pending === 0) {
103    await update($, summary, () => null)
104    return
105  }
106  if (isSummarizing) return
107  isSummarizing = true
108  try {
109    await summarize($)
110  } finally {
111    isSummarizing = false
112  }
113}
114
115let lastRemote: { branch: string; sha: string } | null = null
116
117const readRemote = async (git: (...args: string[]) => Promise<{ exitCode: number; stdout: string }>) => {
118  const remote = await git('ls-remote', '--symref', 'origin', 'HEAD')
119  const branch = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(remote.stdout)?.[1]
120  const sha = /^([0-9a-f]{40})\s+HEAD$/m.exec(remote.stdout)?.[1]
121  return remote.exitCode === 0 && branch !== undefined && sha !== undefined ? { branch, sha } : null
122}
123
124const countCommits = async (git: (...args: string[]) => Promise<{ exitCode: number; stdout: string }>, range: string) => {
125  const counted = await git('rev-list', '--count', range)
126  return counted.exitCode === 0 ? Number(counted.stdout.trim()) : 0
127}
128
129const checkUpstream = async ($: EngineInterface, { isOffline = false } = {}) => {
130  try {
131    const { source } = await locate($)
132    if (source === '') return
133    const git = (...args: string[]) => $.process.run(['git', '-C', source, ...args], { timeoutMs: UPSTREAM_TIMEOUT_MS })
134
135    let isReachable = true
136    if (!isOffline || lastRemote === null) {
137      const remote = await readRemote(git)
138      isReachable = remote !== null
139      lastRemote = remote ?? lastRemote
140    }
141    if (lastRemote === null) return
142    const { branch, sha } = lastRemote
143
144    const hasCommit = async () => (await git('cat-file', '-e', `${sha}^{commit}`)).exitCode === 0
145    if (!isOffline && isReachable && !(await hasCommit())) {
146      await git('fetch', '--no-tags', '--no-write-fetch-head', '--quiet', 'origin', `+refs/heads/${branch}:${FETCHED_REF_PREFIX}${branch}`)
147    }
148    const isFetched = await hasCommit()
149    const behind = isFetched ? await countCommits(git, `HEAD..${sha}`) : null
150    const ahead = await countCommits(git, `${isFetched ? sha : `refs/remotes/origin/${branch}`}..HEAD`)
151    const isDirty = (await git('--no-optional-locks', 'status', '--porcelain')).stdout.trim() !== ''
152    await update($, upstream, previous => ({ branch, ahead, behind, isDirty, isReachable: isOffline ? (previous?.isReachable ?? true) : isReachable }))
153  } catch {
154    return
155  }
156}
157
158const film = {
159  t: 0,
160  columns: 0,
161  scene: { mood: 'unknown', previous: 'unknown', start: 0, size: 1, kind: 0 } as Scene,
162  pending: 0,
163  reconcileStep: null as ReconcileStep | null,
164  lastKey: '',
165  isShowing: false,
166  isBlitting: false,
167  hasGraphics: true,
168}
169
170const castFilm = (mood: Mood, pending: number, reconcileStep: ReconcileStep | null) => {
171  const before = film.pending
172  film.pending = pending
173  film.reconcileStep = reconcileStep
174  if (mood === film.scene.mood) {
175    if (mood === 'pending' && pending < before) film.scene = { ...film.scene, nudge: film.t }
176    return
177  }
178  const from = film.scene
179  const isHooked = from.mood === 'pending' || from.mood === 'behind' || from.mood === 'drift'
180  const carried = isHooked ? { size: from.size, kind: from.kind } : { size: randomSize(film.t), kind: fishKind(film.t) }
181  film.scene = { mood, previous: from.mood, start: film.t, ...carried }
182}
183
184const advanceFilm = async ($: EngineInterface) => {
185  if (!film.isShowing || film.isBlitting) return
186  film.isBlitting = true
187  try {
188    film.t += 1
189    const frame = filmFrame(film.t, film.columns, film.scene, film.reconcileStep)
190    if (frame.key === film.lastKey) return
191    film.lastKey = frame.key
192    const blitted = await $.ui.blit({ requestId: PANE, key: 'film', source: frame.source, columns: film.columns, rows: FILM_ROWS })
193    if (blitted.deny === undefined) return
194    film.lastKey = ''
195    film.isShowing = false
196    if (blitted.deny.includes('alt')) {
197      film.hasGraphics = false
198      $.ui.invalidate('ui.render')
199    }
200  } finally {
201    film.isBlitting = false
202  }
203}
204
205const COMMAND_TIMEOUT_MS = 120_000
206const UNINSTALL_TIMEOUT_MS = 600_000
207const LABEL_WIDTH = '[MACHINE] '.length
208
209const paths = { source: null as string | null, home: null as string | null }
210
211const locate = async ($: EngineInterface) => {
212  paths.source ??= (await $.process.run(['chezmoi', 'source-path'])).stdout.trim()
213  paths.home ??= (await $.process.run(['chezmoi', 'target-path'])).stdout.trim()
214  return { source: paths.source, home: paths.home }
215}
216
217const setReconcile = ($: EngineInterface, change: (current: Reconcile) => Reconcile) =>
218  update($, reconcile, current => (current === null ? null : change(current)))
219
220const busy = async ($: EngineInterface, label: string, work: () => Promise<void>) => {
221  const current = await read($, reconcile)
222  if (current === null || current.busy !== null) return
223  await setReconcile($, r => ({ ...r, busy: label, isConfirming: false }))
224  try {
225    await work()
226  } catch (error) {
227    await setReconcile($, r => ({ ...r, note: note(error instanceof Error ? error.message : String(error), 'error') }))
228  } finally {
229    await setReconcile($, r => ({ ...r, busy: null }))
230  }
231}
232
233const loadFiles = async ($: EngineInterface) => {
234  const { source, home } = await locate($)
235  const diff = await $.process.run(['bash', `${source}/.claude/skills/reconcile/rich-diff.sh`], { timeoutMs: COMMAND_TIMEOUT_MS })
236  const found = parseRichDiff(diff.stdout)
237  const scripts = new Set((await read($, snapshot))?.entries.filter(isScript).map(entry => entry.path))
238  const files: DriftedFile[] = []
239  for (const file of found) {
240    const sourceFile = await $.process.run(['chezmoi', 'source-path', `${home}/${file.path}`])
241    files.push({ ...file, isTemplate: sourceFile.stdout.trim().endsWith('.tmpl'), isScript: scripts.has(file.path) })
242  }
243  await setReconcile($, r => ({ ...r, step: files.length > 0 ? 'files' : 'done', files, cursor: 0, note: null }))
244}
245
246const loadPackages = async ($: EngineInterface) => {
247  const { source } = await locate($)
248  const diff = await $.process.run(['bash', `${source}/.claude/skills/reconcile/brew-diff.sh`], { timeoutMs: COMMAND_TIMEOUT_MS })
249  if (diff.exitCode === 1) {
250    await setReconcile($, r => ({ ...r, note: note('Homebrew is not installed; skipping packages', 'warn') }))
251    await loadFiles($)
252    return
253  }
254  const { profile, missing, packages } = parseBrewDiff(diff.stdout)
255  await setReconcile($, r => ({ ...r, step: 'packages', profile, missing, packages, cursor: 0 }))
256  if (packages.length === 0) await loadFiles($)
257}
258
259const advance = async ($: EngineInterface, list: 'packages' | 'files') => {
260  const current = await read($, reconcile)
261  if (current === null) return
262  const next = current.cursor + 1
263  if (next < current[list].length) {
264    await setReconcile($, r => ({ ...r, cursor: next }))
265    return
266  }
267  if (list === 'packages') await loadFiles($)
268  else await setReconcile($, r => ({ ...r, step: 'done' }))
269}
270
271const startReconcile = async ($: EngineInterface) => {
272  await update($, reconcile, (): Reconcile => ({
273    step: 'pull',
274    busy: null,
275    note: null,
276    isBlocked: false,
277    profile: 'common',
278    missing: [],
279    packages: [],
280    files: [],
281    cursor: 0,
282    isConfirming: false,
283  }))
284}
285
286const closeReconcile = ($: EngineInterface) => update($, reconcile, () => null)
287
288const pullFromOrigin = ($: EngineInterface) =>
289  busy($, 'jj git fetch', async () => {
290    const { source } = await locate($)
291    const jj = (...args: string[]) => $.process.run(['jj', '-R', source, ...args], { timeoutMs: COMMAND_TIMEOUT_MS })
292    const fetched = await jj('git', 'fetch')
293    if (fetched.exitCode !== 0) {
294      await setReconcile($, r => ({ ...r, isBlocked: true, note: note(`fetch failed: ${firstLine(fetched)}`, 'error') }))
295      return
296    }
297    const bookmark = await jj('bookmark', 'list', 'main')
298    if (/conflict|\?\?/i.test(bookmark.stdout)) {
299      await setReconcile($, r => ({ ...r, isBlocked: true, note: note('main diverged from origin', 'warn') }))
300      return
301    }
302    await setReconcile($, r => ({ ...r, busy: 'jj rebase -b @ -d main' }))
303    const rebased = await jj('rebase', '-b', '@', '-d', 'main')
304    const conflicts = await jj('log', '-r', 'conflicts()', '--no-graph', '-T', 'change_id.short() ++ "\\n"')
305    if (rebased.exitCode !== 0 || conflicts.stdout.trim() !== '') {
306      const problem = rebased.exitCode !== 0 ? `rebase failed: ${firstLine(rebased)}` : 'rebase left conflicts'
307      await setReconcile($, r => ({ ...r, isBlocked: true, note: note(problem, 'error') }))
308      return
309    }
310    await setReconcile($, r => ({ ...r, note: note('up to date with origin'), busy: 'checking packages' }))
311    await loadPackages($)
312  })
313
314const skipPull = ($: EngineInterface) =>
315  busy($, 'checking packages', async () => {
316    await setReconcile($, r => ({ ...r, isBlocked: false, note: null }))
317    await loadPackages($)
318  })
319
320const askAboutPull = ($: EngineInterface) =>
321  $.prompt.submit({
322    text: "The chezmoi-status reconcile could not bring the dotfiles repo up to date with origin. Look at `jj bookmark list main`, `jj log -r '::@ ~ ::main@origin'`, `jj log -r '::main@origin ~ ::@'` and any conflicts, explain what happened, and ask me how to proceed (rebase onto main@origin, merge, or pause).",
323  })
324
325const decidePackage = ($: EngineInterface, decision: PackageDecision, scope = 'common') =>
326  busy($, decision === 'uninstalled' ? 'brew uninstall' : `${decision} package`, async () => {
327    const current = await read($, reconcile)
328    const item = current?.packages[current.cursor]
329    if (!current || !item) return
330    const { source } = await locate($)
331    if (decision === 'added') {
332      const file = `${source}/.chezmoidata/packages.yaml`
333      await $.fs.write(file, addToPackages(await $.fs.read(file), item.kind, scope, item.name))
334    }
335    if (decision === 'ignored') {
336      const file = `${source}/.brew-ignored`
337      const existing = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
338      await $.fs.write(file, `${existing}${existing === '' || existing.endsWith('\n') ? '' : '\n'}${item.name}\n`)
339    }
340    if (decision === 'uninstalled') {
341      const argv = item.kind === 'tap' ? ['brew', 'untap', item.name] : item.kind === 'cask' ? ['brew', 'uninstall', '--cask', item.name] : ['brew', 'uninstall', item.name]
342      const removed = await $.process.run(argv, { timeoutMs: UNINSTALL_TIMEOUT_MS })
343      if (removed.exitCode !== 0) {
344        await setReconcile($, r => ({ ...r, note: note(`${argv.join(' ')} failed: ${firstLine(removed)}`, 'error') }))
345        return
346      }
347    }
348    await setReconcile($, r => ({ ...r, note: null, packages: r.packages.map((one, i) => (i === r.cursor ? { ...one, decision, scope: decision === 'added' ? scope : null } : one)) }))
349    await advance($, 'packages')
350  })
351
352const ignoreRemainingPackages = ($: EngineInterface) =>
353  busy($, 'ignoring packages', async () => {
354    const current = await read($, reconcile)
355    if (!current) return
356    const { source } = await locate($)
357    const file = `${source}/.brew-ignored`
358    const remaining = current.packages.slice(current.cursor)
359    const existing = (await $.fs.exists(file)) ? await $.fs.read(file) : ''
360    const prefix = existing === '' || existing.endsWith('\n') ? existing : `${existing}\n`
361    await $.fs.write(file, `${prefix}${remaining.map(one => `${one.name}\n`).join('')}`)
362    await setReconcile($, r => ({ ...r, packages: r.packages.map((one, i) => (i >= r.cursor ? { ...one, decision: 'ignored' } : one)) }))
363    await loadFiles($)
364  })
365
366const confirmUninstall = ($: EngineInterface) => setReconcile($, r => ({ ...r, isConfirming: true }))
367
368const cancelUninstall = ($: EngineInterface) => setReconcile($, r => ({ ...r, isConfirming: false }))
369
370const decideFile = ($: EngineInterface, decision: FileDecision) =>
371  busy($, decision === 'applied' ? 'chezmoi apply' : decision === 'kept' ? 'chezmoi add' : 'next file', async () => {
372    const current = await read($, reconcile)
373    const file = current?.files[current.cursor]
374    if (!current || !file) return
375    const { home } = await locate($)
376    const target = `${home}/${file.path}`
377    const command = decision === 'applied' ? ['chezmoi', 'apply', '--force', target] : decision === 'kept' ? ['chezmoi', 'add', target] : null
378    if (command) {
379      const ran = await $.process.run(command, { timeoutMs: COMMAND_TIMEOUT_MS })
380      if (ran.exitCode !== 0) {
381        await setReconcile($, r => ({ ...r, note: note(`${command.slice(0, 2).join(' ')} failed: ${firstLine(ran)}`, 'error') }))
382        return
383      }
384    }
385    if (decision === 'merging') {
386      const templateNote = file.isTemplate ? ', and keep the template expressions intact when editing the repo file' : ''
387      await $.prompt.submit({
388        text: `Help me merge ~/${file.path} with the chezmoi repo. Run the reconcile skill's rich-diff.sh for this path to see both sides${templateNote}. Ask whether the change should apply to all profiles or only the "${current.profile}" profile before editing.`,
389      })
390    }
391    await setReconcile($, r => ({ ...r, note: null, files: r.files.map((one, i) => (i === r.cursor ? { ...one, decision } : one)) }))
392    await advance($, 'files')
393  })
394
395const installMissing = ($: EngineInterface) =>
396  busy($, 'running chezmoi scripts', async () => {
397    const ran = await $.process.run(['chezmoi', 'apply', '--force', '--include', 'scripts'], { timeoutMs: UNINSTALL_TIMEOUT_MS })
398    await setReconcile($, r => ({
399      ...r,
400      missing: ran.exitCode === 0 ? [] : r.missing,
401      note: ran.exitCode === 0 ? note('missing packages installed') : note(`install failed: ${firstLine(ran)}`, 'error'),
402    }))
403  })
404
405const FILE_SUMMARY_SYSTEM = [
406  'You explain one drifted dotfile to its owner, who must choose between applying the repo version and keeping the version on this machine.',
407  'Lines labelled [MACHINE] exist only on this machine, lines labelled [REPO] only in the repo, and unlabelled lines are shared context.',
408  'In at most two short sentences, say in plain terms what actually differs. Do not repeat the labels or the file name.',
409].join(' ')
410
411const setFileSummary = ($: EngineInterface, path: string, summary: FileSummary) =>
412  setReconcile($, r => ({ ...r, files: r.files.map(one => (one.path === path ? { ...one, summary } : one)) }))
413
414const isPackageScript = (file: DriftedFile) => file.isScript && file.path.endsWith('install-packages.sh')
415
416const summarizeCurrentFile = async ($: EngineInterface) => {
417  const current = await read($, reconcile)
418  const file = current?.step === 'files' ? current.files[current.cursor] : undefined
419  if (!file || file.summary !== null || isPackageScript(file)) return
420  await setFileSummary($, file.path, { text: '', isLoading: true })
421  try {
422    const { source, home } = await locate($)
423    const diff = await $.process.run(['bash', `${source}/.claude/skills/reconcile/rich-diff.sh`, `${home}/${file.path}`], {
424      timeoutMs: COMMAND_TIMEOUT_MS,
425    })
426    const reply = await $.model.complete({
427      model: 'haiku',
428      system: FILE_SUMMARY_SYSTEM,
429      prompt: diff.stdout.slice(0, MAX_DIFF_CHARS),
430      maxTokens: 200,
431      effort: 'low',
432      timeoutMs: 30_000,
433    })
434    await setFileSummary($, file.path, reply.isAnswered ? { text: reply.text.trim(), isLoading: false } : { text: '', isLoading: false, error: reply.reason })
435  } catch (error) {
436    await setFileSummary($, file.path, { text: '', isLoading: false, error: error instanceof Error ? error.message : String(error) })
437  }
438}
439
440const later = ($: EngineInterface, work: () => Promise<unknown>) => {
441  $.clock.after(1, () => void work())
442}
443
444const refreshSoon = ($: EngineInterface) => later($, () => refresh($, { isOffline: false }))
445
446const reconcilePull = ($: EngineInterface) =>
447  later($, async () => {
448    await pullFromOrigin($)
449    await refresh($)
450    await summarizeCurrentFile($)
451  })
452
453const reconcileSkipPull = ($: EngineInterface) =>
454  later($, async () => {
455    await skipPull($)
456    await summarizeCurrentFile($)
457  })
458
459const reconcilePackage = ($: EngineInterface, decision: PackageDecision, scope?: string) =>
460  later($, async () => {
461    await decidePackage($, decision, scope)
462    await refresh($)
463    await summarizeCurrentFile($)
464  })
465
466const reconcileIgnoreRest = ($: EngineInterface) =>
467  later($, async () => {
468    await ignoreRemainingPackages($)
469    await refresh($)
470    await summarizeCurrentFile($)
471  })
472
473const reconcileFile = ($: EngineInterface, decision: FileDecision) =>
474  later($, async () => {
475    await decideFile($, decision)
476    await refresh($)
477    await summarizeCurrentFile($)
478  })
479
480const reconcileInstall = ($: EngineInterface) =>
481  later($, async () => {
482    await installMissing($)
483    await refresh($)
484  })
485
486const pushMain = ($: EngineInterface) =>
487  later($, async () => {
488    if ((await read($, push))?.busy) return
489    await update($, push, () => ({ busy: 'jj bookmark set main -r @-', note: null }))
490    const { source } = await locate($)
491    const jj = (...args: string[]) => $.process.run(['jj', '-R', source, ...args], { timeoutMs: COMMAND_TIMEOUT_MS })
492    const moved = await jj('bookmark', 'set', 'main', '-r', '@-')
493    if (moved.exitCode !== 0) {
494      await update($, push, () => ({ busy: null, note: note(`couldn't move main: ${firstLine(moved)}`, 'error') }))
495      return
496    }
497    await update($, push, () => ({ busy: 'jj git push', note: null }))
498    const pushed = await jj('git', 'push', '--bookmark', 'main')
499    await update($, push, () => ({ busy: null, note: pushed.exitCode === 0 ? note('pushed main to origin') : note(`push failed: ${firstLine(pushed)}`, 'error') }))
500    await refresh($, { isOffline: false })
501  })
502
503const commitAndPush = ($: EngineInterface) =>
504  later($, async () => {
505    const { source } = await locate($)
506    await $.prompt.submit({
507      text: `Commit and push the unpushed work in my chezmoi repo at ${source}. Review the diff for secrets first, describe it following my commit conventions (split it if it spans more than one logical change), then move the main bookmark to the new commit and push it.`,
508    })
509  })
510
511export const register: Register = on => {
512  on('session.start', async ($, e, next) => {
513    await $.command.register({
514      name: 'chezmoi-status',
515      description: 'Show chezmoi status and an AI summary of the pending diff in a pane',
516    })
517    void $.ui.open({ id: PANE, title: 'chezmoi status' })
518    $.clock.every(POLL_MS, () => void refresh($))
519    $.clock.every(FILM_TICK_MS, () => void advanceFilm($))
520    $.clock.every(UPSTREAM_POLL_MS, () => void refresh($, { isOffline: false }))
521    $.clock.after(1, () => void refresh($, { isOffline: false }))
522
523    return next(e)
524  })
525
526  on('command.run', { command: 'chezmoi-status' }, async $ => {
527    await $.ui.open({ id: PANE, title: 'chezmoi status' })
528    refreshSoon($)
529
530    return { text: 'chezmoi status pane opened.' }
531  })
532
533  on('turn.complete', async ($, e, next) => {
534    const result = await next(e)
535    $.clock.after(1, () => void refresh($))
536
537    return result
538  })
539
540  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541    const elements = $.ui.resolve(e)
542    const { Box, Text, Button, Markdown } = elements
543    const current = await read($, snapshot)
544    const currentSummary = await read($, summary)
545    const currentUpstream = await read($, upstream)
546    const wizard = await read($, reconcile)
547    const isDocked = e.props.placement === 'dock'
548    const filmColumns = Math.min(255, e.props.bodyColumns)
549    film.isShowing = isDocked && film.hasGraphics && e.surface === 'terminal' && filmColumns > 0
550    film.columns = filmColumns
551    castFilm(moodOf(current, currentUpstream), current?.entries.length ?? 0, wizard?.step ?? null)
552    const frame = film.isShowing ? filmFrame(film.t, filmColumns, film.scene, film.reconcileStep) : null
553    film.lastKey = frame?.key ?? ''
554
555    const clawd =
556      frame && 'Image' in elements ? (
557        <elements.Image key="film" source={frame.source} columns={filmColumns} rows={FILM_ROWS} alt="Clawd fishing by the pond" />
558      ) : isDocked && 'Client' in elements ? (
559        <elements.Client key="clawd" module="./clawd.tsx" props={{ scene: film.scene, reconcileStep: film.reconcileStep }} height={FILM_ROWS} />
560      ) : null
561
562    const badges = [...statusBadges(current), ...repoBadges(currentUpstream)]
563    const header = (
564      <Box flexDirection="row" justifyContent="space-between">
565        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
566          {badges.map(badge => (
567            <Text color={badge.color} dimColor={badge.isDim} bold={badge.isBold}>
568              {badge.text}
569            </Text>
570          ))}
571        </Box>
572        <Box flexDirection="row" gap={1}>
573          {current && <Text dimColor>{formatTime(current.checkedAt)}</Text>}
574          <Button key="refresh" hotkey="r" plain onPress={() => refreshSoon($)}>
575            ↻
576          </Button>
577        </Box>
578      </Box>
579    )
580
581    const pushing = await read($, push)
582    const unpushed = currentUpstream && currentUpstream.isReachable ? currentUpstream : null
583    const pushAction = unpushed?.isDirty ? (
584      <Button key="commit-push" hotkey="p" onPress={() => commitAndPush($)}>
585        Commit & push with Claude
586      </Button>
587    ) : unpushed && unpushed.ahead > 0 ? (
588      <Button key="push" hotkey="p" onPress={() => pushMain($)}>
589        {`Push ${plural(unpushed.ahead, 'commit')}`}
590      </Button>
591    ) : null
592
593    const reconcileButton = wizard === null && (
594      <Box flexDirection="column">
595        <Box flexDirection="row" gap={1}>
596          <Button key="reconcile" hotkey="c" onPress={() => startReconcile($)}>
597            Reconcile
598          </Button>
599          {pushing?.busy ? <Text dimColor>⋯ {pushing.busy}</Text> : pushAction}
600        </Box>
601        {pushing?.note && <Text color={pushing.note.tone === 'error' ? 'red' : 'green'}>{pushing.note.text}</Text>}
602      </Box>
603    )
604
605    const statusList = current && current.entries.length > 0 && (
606      <Box flexDirection="column">
607        {current.entries.map(entry => {
608          const glyph = entryGlyph(entry)
609          return (
610            <Box flexDirection="row" gap={1}>
611              <Text color={glyph.color} bold>
612                {glyph.text}
613              </Text>
614              <Text wrap="truncate-start">{entry.path}</Text>
615              {isEditedHere(entry) && <Text color="yellow">✎ edited here</Text>}
616            </Box>
617          )
618        })}
619      </Box>
620    )
621
622    const summaryView = currentSummary && (
623      <Box flexDirection="column">
624        <Text dimColor>✦ summary</Text>
625        {currentSummary.isLoading && currentSummary.text === '' && <Text dimColor>summarizing…</Text>}
626        {currentSummary.error !== undefined && <Text color="red">summary failed: {currentSummary.error}</Text>}
627        {currentSummary.text !== '' && <Markdown text={currentSummary.text} dimColor={currentSummary.isLoading} />}
628      </Box>
629    )
630
631    const step = (label: string, index: number) => {
632      const order = ['pull', 'packages', 'files', 'done']
633      const at = wizard ? order.indexOf(wizard.step) : 0
634      return (
635        <Text bold={index === at} dimColor={index !== at} color={index < at ? 'green' : undefined}>
636          {index < at ? `✓ ${label}` : label}
637        </Text>
638      )
639    }
640
641    const choices = (items: { key: string; label: string; hotkey: string; isSuggested?: boolean; onPress: () => unknown }[]) => (
642      <Box flexDirection="row" flexWrap="wrap" gap={1}>
643        {items.map(item => (
644          <Button key={item.key} hotkey={item.hotkey} variant={item.isSuggested ? 'primary' : 'secondary'} onPress={item.onPress}>
645            {item.label}
646          </Button>
647        ))}
648      </Box>
649    )
650
651    const wizardBody = (() => {
652      if (wizard === null) return null
653      if (wizard.busy !== null) return <Text dimColor>⋯ {wizard.busy}</Text>
654
655      if (wizard.step === 'pull') {
656        return (
657          <Box flexDirection="column">
658            <Text dimColor>Move your work onto the latest main from origin.</Text>
659            {wizard.isBlocked
660              ? choices([
661                  { key: 'reconcile-ask', label: 'Ask Claude', hotkey: 'a', isSuggested: true, onPress: () => askAboutPull($) },
662                  { key: 'reconcile-skip', label: 'Skip', hotkey: 's', onPress: () => reconcileSkipPull($) },
663                ])
664              : choices([
665                  { key: 'reconcile-pull', label: 'Rebase onto main', hotkey: 'b', isSuggested: true, onPress: () => reconcilePull($) },
666                  { key: 'reconcile-skip', label: 'Skip', hotkey: 's', onPress: () => reconcileSkipPull($) },
667                ])}
668          </Box>
669        )
670      }
671
672      if (wizard.step === 'packages') {
673        const item = wizard.packages[wizard.cursor]
674        if (!item) return null
675        return (
676          <Box flexDirection="column">
677            <Box flexDirection="row" gap={1}>
678              <Text dimColor>{item.kind}</Text>
679              <Text bold>{item.name}</Text>
680              <Text dimColor>
681                installed, not in the repo · {wizard.cursor + 1}/{wizard.packages.length}
682              </Text>
683            </Box>
684            {wizard.isConfirming
685              ? choices([
686                  { key: 'reconcile-uninstall-yes', label: `Uninstall ${item.name}`, hotkey: 'y', onPress: () => reconcilePackage($, 'uninstalled') },
687                  { key: 'reconcile-uninstall-no', label: 'Cancel', hotkey: 'n', isSuggested: true, onPress: () => cancelUninstall($) },
688                ])
689              : choices([
690                  { key: 'reconcile-add-common', label: 'Add for all', hotkey: 'a', onPress: () => reconcilePackage($, 'added', 'common') },
691                  ...(wizard.profile !== 'common'
692                    ? [{ key: 'reconcile-add-profile', label: `Add for ${wizard.profile}`, hotkey: 'p', onPress: () => reconcilePackage($, 'added', wizard.profile) }]
693                    : []),
694                  { key: 'reconcile-ignore', label: 'Ignore', hotkey: 'i', onPress: () => reconcilePackage($, 'ignored') },
695                  { key: 'reconcile-uninstall', label: 'Uninstall', hotkey: 'u', onPress: () => confirmUninstall($) },
696                  { key: 'reconcile-skip', label: 'Skip', hotkey: 's', onPress: () => reconcilePackage($, 'skipped') },
697                  ...(wizard.packages.length - wizard.cursor > 1
698                    ? [{ key: 'reconcile-ignore-rest', label: 'Ignore rest', hotkey: 'g', onPress: () => reconcileIgnoreRest($) }]
699                    : []),
700                ])}
701          </Box>
702        )
703      }
704
705      if (wizard.step === 'files') {
706        const file = wizard.files[wizard.cursor]
707        if (!file) return null
708        const declared = wizard.packages.filter(item => item.decision === 'added')
709        const packageLines = [
710          wizard.missing.length > 0 ? `installs ${wizard.missing.map(ref => ref.name).join(', ')}` : '',
711          declared.length > 0 ? `newly declared, already installed: ${declared.map(item => `${item.name} (${item.scope === 'common' ? 'all' : item.scope})`).join(', ')}` : '',
712        ].filter(Boolean)
713        const summaryLine = isPackageScript(file) ? null : file.summary === null || file.summary.isLoading ? (
714          <Text dimColor>✦ summarizing…</Text>
715        ) : file.summary.error !== undefined ? (
716          <Text dimColor>✦ no summary ({file.summary.error})</Text>
717        ) : (
718          <Box flexDirection="row" gap={1}>
719            <Text color="magenta">✦</Text>
720            <Text>{file.summary.text}</Text>
721          </Box>
722        )
723        return (
724          <Box flexDirection="column" gap={1}>
725            <Box flexDirection="row" gap={1}>
726              <Text bold wrap="truncate-start">
727                {file.isScript ? file.path.replace(/^\.chezmoiscripts\//, '') : `~/${file.path}`}
728              </Text>
729              <Text dimColor>
730                {file.isScript ? 'script · runs on the next apply' : DIRECTION_LABELS[file.direction]}
731                {file.isTemplate && !file.isScript ? ' · template' : ''} · {wizard.cursor + 1}/{wizard.files.length}
732              </Text>
733            </Box>
734            {isPackageScript(file) ? (
735              <Box flexDirection="column">
736                {(packageLines.length > 0 ? packageLines : ['the package list changed; nothing new to install']).map(line => (
737                  <Text color="cyan">{`📦 ${line}`}</Text>
738                ))}
739              </Box>
740            ) : (
741              <Box flexDirection="column">
742                {file.preview.map(line => {
743                  const isMachine = line.startsWith('[MACHINE]')
744                  return (
745                    <Text color={isMachine ? 'cyan' : 'yellow'} wrap="truncate-end">
746                      {isMachine ? 'machine │' : 'repo    │'}
747                      {` ${line.slice(LABEL_WIDTH)}`}
748                    </Text>
749                  )
750                })}
751              </Box>
752            )}
753            {file.isScript
754              ? choices([
755                  { key: 'reconcile-apply', label: 'Run script', hotkey: 'a', isSuggested: true, onPress: () => reconcileFile($, 'applied') },
756                  { key: 'reconcile-skip', label: 'Skip', hotkey: 's', onPress: () => reconcileFile($, 'skipped') },
757                ])
758              : choices([
759                  { key: 'reconcile-apply', label: 'Apply repo', hotkey: 'a', isSuggested: file.direction === 'REPO_NEWER', onPress: () => reconcileFile($, 'applied') },
760                  ...(file.isTemplate
761                    ? []
762                    : [{ key: 'reconcile-keep', label: 'Keep machine', hotkey: 'k', isSuggested: file.direction === 'MACHINE_NEWER', onPress: () => reconcileFile($, 'kept') }]),
763                  {
764                    key: 'reconcile-merge',
765                    label: 'Merge with Claude',
766                    hotkey: 'm',
767                    isSuggested: file.isTemplate && file.direction === 'MACHINE_NEWER',
768                    onPress: () => reconcileFile($, 'merging'),
769                  },
770                  { key: 'reconcile-skip', label: 'Skip', hotkey: 's', onPress: () => reconcileFile($, 'skipped') },
771                ])}
772            {summaryLine}
773          </Box>
774        )
775      }
776
777      const filesDone = countBy(wizard.files.map(file => file.decision))
778      const packagesDone = countBy(wizard.packages.map(item => item.decision))
779      const isRepoChanged = (filesDone.kept ?? 0) + (packagesDone.added ?? 0) + (packagesDone.ignored ?? 0) > 0
780      return (
781        <Box flexDirection="column">
782          <Text>{wizard.files.length > 0 ? `files: ${describeCounts(filesDone, FILE_OUTCOMES)}` : 'files: nothing to reconcile'}</Text>
783          {wizard.packages.length > 0 && <Text>{`packages: ${describeCounts(packagesDone, PACKAGE_OUTCOMES)}`}</Text>}
784          {isRepoChanged && <Text color="cyan">The repo changed; commit it with jj when you're happy.</Text>}
785          {choices([
786            ...(wizard.missing.length > 0
787              ? [{ key: 'reconcile-install', label: `Install ${wizard.missing.map(ref => ref.name).join(', ')}`, hotkey: 'i', onPress: () => reconcileInstall($) }]
788              : []),
789            { key: 'reconcile-close', label: 'Done', hotkey: 'd', isSuggested: true, onPress: () => closeReconcile($) },
790          ])}
791        </Box>
792      )
793    })()
794
795    const wizardView = wizard && (
796      <Box flexDirection="column" gap={1}>
797        <Box flexDirection="row" justifyContent="space-between">
798          <Box flexDirection="row" gap={1}>
799            <Text bold>Reconcile</Text>
800            {step('rebase', 0)}
801            <Text dimColor>›</Text>
802            {step('packages', 1)}
803            <Text dimColor>›</Text>
804            {step('files', 2)}
805            <Text dimColor>›</Text>
806            {step('done', 3)}
807          </Box>
808          {wizard.step !== 'done' && wizard.busy === null && (
809            <Button key="reconcile-cancel" hotkey="x" plain onPress={() => closeReconcile($)}>
810              ✕
811            </Button>
812          )}
813        </Box>
814        {wizard.step === 'packages' && wizard.missing.length > 0 && (
815          <Text dimColor>{`${plural(wizard.missing.length, 'missing package')} will install on the next apply`}</Text>
816        )}
817        {wizardBody}
818        {wizard.note && (
819          <Text color={wizard.note.tone === 'error' ? 'red' : wizard.note.tone === 'warn' ? 'yellow' : 'green'}>{wizard.note.text}</Text>
820        )}
821      </Box>
822    )
823
824    return (
825      <Box flexDirection="column" gap={1}>
826        {clawd}
827        {header}
828        {reconcileButton}
829        {current?.error !== undefined && (
830          <Text color="red" wrap="truncate-end">
831            {current.error}
832          </Text>
833        )}
834        {wizardView ?? statusList}
835        {wizard === null && summaryView}
836      </Box>
837    )
838  })
839}
840
hooks/film.ts 845 lines
1import { encodePng } from './png'
2
3export const hash = (x: number, y = 0) => (Math.imul(x, 2654435761) ^ Math.imul(y, 2246822519)) >>> 0
4
5type Rgb = readonly [number, number, number]
6type Eyes = 'open' | 'closed' | 'left' | 'right' | 'down' | 'up' | 'happy' | 'flat'
7type Side = 'left' | 'right'
8type Point = { x: number; y: number }
9export type Size = 1 | 2 | 3
10
11type ClawdPose = { x: number; lift: number; eyes: Eyes; arms: 'down' | 'up'; hasUmbrella: boolean; isSweating: boolean }
12export type Catch = { what: 'fish'; kind: number; size: Size } | { what: 'junk' }
13type Flying = Point & Catch & { facing: Side }
14type Bottle = Point & { isUpright: boolean }
15
16export type Fishing = {
17  bob: number
18  tug: number
19  flying: Flying | null
20  held: (Catch & { wiggle: number }) | null
21  ripples: { x: number; phase: number; isFaint?: boolean }[]
22  isPropped: boolean
23}
24
25export type ReconcileStep = 'pull' | 'packages' | 'files' | 'done'
26export type Mood = 'sync' | 'pending' | 'drift' | 'behind' | 'ahead' | 'error' | 'unknown'
27
28export type Spec = {
29  width: number
30  rain: number | null
31  isDrizzle: boolean
32  shimmer: number
33  swim: number | null
34  clawd: ClawdPose
35  fishing: Fishing
36  bottle: Bottle | null
37  alert: { count: Size; step: number } | null
38  sparkle: number | null
39  bubble: { text: string; side: Side } | null
40  snore: number | null
41}
42
43export type Scene = { mood: Mood; previous: Mood; start: number; size: Size; kind: number; nudge?: number }
44
45export const randomSize = (seed: number) => ((hash(seed, 11) % 3) + 1) as Size
46export const fishKind = (seed: number) => hash(seed, 5) % 7
47
48export const FILM_ROWS = 7
49export const FILM_TICK_MS = 100
50export const ART_PER_COLUMN = 4
51export const HEIGHT = 56
52const UPSCALE = 2
53export const GROUND = 40
54const POND_DEPTH = HEIGHT - GROUND - 1
55
56const SPRITE = [
57  '000111111111111000',
58  '000110111111011000',
59  '011111111111111110',
60  '000111111111111000',
61]
62const EYE_COLUMNS = [5, 12]
63const LEG_COLUMNS = [4, 6, 11, 13]
64const QUAD_W = 2
65const QUAD_H = 4
66export const SPRITE_W = 36
67export const SPRITE_H = 20
68
69const BODY: Rgb = [215, 119, 87]
70const HIGHLIGHT: Rgb = [236, 152, 120]
71const SHADE: Rgb = [178, 92, 66]
72const EYE: Rgb = [42, 23, 18]
73const PAPER: Rgb = [246, 241, 232]
74const INK: Rgb = [42, 23, 18]
75const BLACK: Rgb = [0, 0, 0]
76const LINE: Rgb = [128, 128, 128]
77const SNORE: Rgb = [150, 150, 175]
78const WATER_TOP: Rgb = [86, 140, 196]
79const WATER_DEEP: Rgb = [38, 76, 128]
80const BANK: Rgb = [66, 104, 150]
81const SHINE: Rgb = [170, 208, 238]
82const LILY: Rgb = [82, 146, 92]
83const REED: Rgb = [96, 140, 84]
84const CATTAIL: Rgb = [122, 82, 52]
85const ROD: Rgb = [110, 78, 50]
86const BOBBER: Rgb = [220, 60, 60]
87const GLASS: Rgb = [120, 190, 150]
88const CORK: Rgb = [150, 100, 60]
89const ALERT: Rgb = [255, 214, 64]
90const SWEAT: Rgb = [150, 200, 240]
91
92export const FISH_KINDS: readonly { body: Rgb; dark: Rgb; belly: Rgb }[] = [
93  { body: [235, 140, 70], dark: [190, 96, 48], belly: [250, 200, 150] },
94  { body: [240, 196, 72], dark: [196, 150, 40], belly: [252, 230, 150] },
95  { body: [232, 128, 150], dark: [190, 90, 112], belly: [248, 190, 204] },
96  { body: [70, 180, 170], dark: [40, 130, 124], belly: [160, 226, 218] },
97  { body: [150, 110, 200], dark: [106, 76, 150], belly: [204, 180, 236] },
98  { body: [86, 140, 220], dark: [56, 96, 168], belly: [170, 200, 246] },
99  { body: [110, 160, 80], dark: [76, 116, 52], belly: [190, 214, 150] },
100]
101
102const FISH_SPRITES: Record<Size, readonly string[]> = {
103  1: [
104    '...FF...',
105    'T.BBBBB.',
106    'TBBBBBEB',
107    'T.LLLL..',
108  ],
109  2: [
110    '....FFF.....',
111    'T..BBBBBBB..',
112    'TTBBBBBBBEBB',
113    'T..BBBBBBBB.',
114    '....LLLLL...',
115  ],
116  3: [
117    '........FFFFF.........',
118    'TT...BBBBBBBBBBBBB....',
119    'TTT.BBBBBBBBBBBBBBBB..',
120    'TTTBBBBBBBBBBBBBBBEBBB',
121    'TTT.BBBBBBBBBBBBBBBBB.',
122    'TT...LLLLLLLLLLLLLL...',
123    '.........FF..FF.......',
124  ],
125}
126
127const JUNK_SPRITE = [
128  '...BBB...',
129  '...BBB...',
130  '...BBBB..',
131  '...BBBBBB',
132  '..BBBBBBB',
133  '..SSSSSSS',
134]
135
136const LINES = {
137  greeting: 'tight lines!',
138  idle: ['nothing biting', 'calm waters', 'just me and the pond', 'perfect fishing weather', 'patience is bait'],
139  bite: ["something's biting!", 'got a nibble!', 'easy… easy…', 'reel it in?'],
140  bigBite: "that's a big one!",
141  catch: ['what a catch!', 'look at this beauty', 'a real keeper', 'one for the album'],
142  nudge: 'still more on the line!',
143  reconciled: 'got it!',
144  release: 'catch and release!',
145  junk: 'just junk…',
146  back: 'back you go',
147  bottle: ['message in a bottle', 'off you go, little bottle', 'drifting out to sea'],
148  sailed: 'bon voyage!',
149  storm: ['stormy waters…', 'better wait it out', 'the fish are hiding'],
150  clearing: 'clearing up!',
151  checking: 'checking the waters…',
152  reconciling: {
153    pull: ['reeling in the line…', 'steady now…', 'almost there…'],
154    packages: ['checking the tackle box…', 'sorting the lures', 'need more bait?'],
155    files: ['sorting the catch…', 'keep or toss?', 'measuring this one'],
156    done: ['nice haul!', 'good day on the water'],
157  },
158} as const
159
160const LINE_TICKS = 30
161const cycle = (lines: readonly string[], local: number) => lines[Math.floor(local / LINE_TICKS) % lines.length]!
162
163const GLYPHS: Record<string, string> = {
164  A: '010101111101101', B: '110101110101110', C: '011100100100011', D: '110101101101110',
165  E: '111100110100111', F: '111100110100100', G: '011100101101011', H: '101101111101101',
166  I: '111010010010111', J: '001001001101010', K: '101101110101101', L: '100100100100111',
167  M: '101111111101101', N: '110101101101101', O: '010101101101010', P: '110101110100100',
168  Q: '010101101110011', R: '110101110101101', S: '011100010001110', T: '111010010010010',
169  U: '101101101101111', V: '101101101101010', W: '101101111111101', X: '101101010101101',
170  Y: '101101010010010', Z: '111001010100111', '0': '111101101101111', '1': '010110010010111',
171  '2': '110001010100111', '3': '110001010001110', '4': '101101111001001', '5': '111100110001110',
172  '6': '011100111101111', '7': '111001010010010', '8': '111101111101111', '9': '111101111001110',
173  '!': '010010010000010', '?': '110001010000010', '.': '000000000000010', ',': '000000000010100',
174  '+': '000010111010000', '/': '001001010100100', '=': '000111000111000', ':': '000010000010000', '-': '000000111000000', ' ': '000000000000000',
175}
176
177const toGlyphText = (text: string) => Array.from(text.toUpperCase().replaceAll('…', '...'))
178
179
180const CAST_TICKS = 4
181const NIBBLE_TICKS = 4
182const STRIKE_TICKS = 2
183const REEL_TICKS = 7
184const THROW_TICKS = 7
185const SPLASH_TICKS = 4
186const REACT_TICKS = 14
187const INTRO_TICKS = 12
188const NUDGE_TICKS = 14
189const IDLE_PERIOD = 50
190const IDLE_REST = 25
191const JUMP_TICKS = 9
192export const SWIM_PERIOD = 40
193export const RAIN_PERIOD = 8
194const TOSS_TICKS = 8
195const BOTTLE_DRIFT_TICKS = 20
196const SAIL_TICKS = 12
197const CLEAR_TICKS = 10
198const TUGS = [0, 2, 1, 2, 0, 0, 2, 1]
199
200export type Pond = { left: number; cx: number; rx: number; bobber: number; jumpFrom: number; jumpTo: number }
201
202export const pondOf = (width: number, clawdX: number): Pond => {
203  const left = clawdX + SPRITE_W + 16
204  const rx = Math.max(10, Math.min(64, Math.floor((width - 4 - left) / 2)))
205  const cx = left + rx
206  return { left, cx, rx, bobber: left + Math.round(rx * 0.7), jumpFrom: cx + Math.round(rx * 0.15), jumpTo: cx + Math.round(rx * 0.6) }
207}
208
209export const homeOf = (width: number) => Math.max(4, Math.round(width * 0.06))
210export const bucketOf = (clawdX: number) => clawdX + SPRITE_W + 4
211export const heldOf = (clawdX: number): Point => ({ x: clawdX + SPRITE_W / 2, y: GROUND - SPRITE_H - 6 })
212export const rodOf = (clawdX: number, fishing: Fishing) => {
213  const top = GROUND - SPRITE_H + 1
214  return {
215    butt: fishing.isPropped ? { x: clawdX + 38, y: GROUND } : { x: clawdX + 33, y: top + 9 },
216    tip: fishing.isPropped ? { x: clawdX + 58, y: top - 4 } : { x: clawdX + 52, y: top - 12 + fishing.tug },
217  }
218}
219
220const arc = (from: Point, to: Point, p: number, height: number): Point => ({
221  x: Math.round(from.x + (to.x - from.x) * p),
222  y: Math.round(from.y + (to.y - from.y) * p - Math.sin(Math.PI * p) * height),
223})
224
225const blinking = (local: number): Eyes => (local % 40 === 30 || local % 40 === 31 ? 'closed' : 'open')
226const describeScene = (t: number, columns: number, scene: Scene): Spec => {
227  const width = columns * ART_PER_COLUMN
228  const home = homeOf(width)
229  const pond = pondOf(width, home)
230  const surface = { x: pond.bobber, y: GROUND }
231  const held = heldOf(home)
232  const shore = { x: bucketOf(home) + 9, y: GROUND - 7 }
233  const landing = { x: pond.cx - Math.round(pond.rx * 0.2), y: GROUND - 1 }
234  const moored = Math.round(landing.x + pond.rx * 0.7)
235  const standing: ClawdPose = { x: home, lift: 0, eyes: 'open', arms: 'down', hasUmbrella: false, isSweating: false }
236  const watching: ClawdPose = { ...standing, eyes: 'right' }
237  const calm: Fishing = { bob: Math.floor(t / 8) % 2, tug: 0, flying: null, held: null, ripples: [], isPropped: false }
238  const base: Spec = {
239    width,
240    rain: null,
241    isDrizzle: false,
242    shimmer: Math.floor(t / 6) % 2,
243    swim: null,
244    clawd: standing,
245    fishing: calm,
246    bottle: null,
247    alert: null,
248    sparkle: null,
249    bubble: null,
250    snore: null,
251  }
252  const fish = { what: 'fish' as const, kind: scene.kind, size: scene.size }
253  const say = (text: string) => ({ text, side: 'right' as const })
254  const reel = (local: number, what: Catch): Spec => ({
255    ...base,
256    clawd: watching,
257    fishing: { ...calm, bob: 0, flying: { ...arc(surface, held, local / (REEL_TICKS - 1), 16), ...what, facing: 'left' }, ripples: local < 3 ? [{ x: pond.bobber, phase: local + 1 }] : [] },
258  })
259  const toss = (local: number, what: Catch, bubble: Spec['bubble']): Spec => ({
260    ...base,
261    bubble: local < THROW_TICKS ? null : bubble,
262    fishing:
263      local < THROW_TICKS
264        ? { ...calm, isPropped: true, flying: { ...arc(held, surface, local / (THROW_TICKS - 1), 14), ...what, facing: 'right' } }
265        : { ...calm, ripples: [{ x: pond.bobber, phase: local - THROW_TICKS }] },
266  })
267  const storm = (beat: number): Spec => {
268    const burst = Math.floor(t / 4) % 6
269    const ripples = [0, 1, 2].map(i => ({ x: pond.cx - pond.rx + 4 + (hash(burst, i) % (pond.rx * 2 - 8)), phase: t % 4 }))
270    return {
271      ...base,
272      rain: t % RAIN_PERIOD,
273      clawd: { ...standing, hasUmbrella: true, eyes: beat === 10 || beat === 11 ? 'closed' : 'open' },
274      fishing: { ...calm, isPropped: true, ripples },
275    }
276  }
277
278  const idleAt = (local: number, isGreeting: boolean): Spec => {
279    const beat = local % IDLE_PERIOD
280    const anim = Math.floor(local / IDLE_PERIOD) % 4
281    const blink = beat === 12 || beat === 13
282    const idle: Spec = {
283      ...base,
284      swim: Math.floor(t / 3) % SWIM_PERIOD,
285      bubble: isGreeting && local < 16 ? say(LINES.greeting) : null,
286      clawd: { ...standing, eyes: blink ? 'closed' : 'open' },
287    }
288    if (beat < IDLE_REST) return idle
289    const step = beat - IDLE_REST
290    if (anim === 0) {
291      const from = { x: pond.jumpFrom, y: GROUND + 2 }
292      const to = { x: pond.jumpTo, y: GROUND + 2 }
293      const jumper = { what: 'fish' as const, kind: hash(Math.floor(local / IDLE_PERIOD), 9) % FISH_KINDS.length, size: 2 as const, facing: 'right' as const }
294      if (step < JUMP_TICKS) {
295        const ripples = step < 3 ? [{ x: pond.jumpFrom, phase: step + 1 }] : []
296        return { ...idle, clawd: watching, fishing: { ...calm, flying: { ...arc(from, to, step / (JUMP_TICKS - 1), 13), ...jumper }, ripples } }
297      }
298      if (step < JUMP_TICKS + 4) {
299        return { ...idle, clawd: watching, fishing: { ...calm, ripples: [{ x: pond.jumpTo, phase: step - JUMP_TICKS }] } }
300      }
301      return idle
302    }
303    if (anim === 1) return { ...idle, bubble: say(LINES.idle[Math.floor(local / (IDLE_PERIOD * 4)) % LINES.idle.length]!) }
304    if (anim === 2) return { ...idle, clawd: { ...standing, eyes: step < 6 ? 'left' : step < 12 ? 'right' : 'open' } }
305    return { ...idle, clawd: { ...standing, eyes: 'closed' }, snore: Math.floor(step / 3) % 4 }
306  }
307
308  const exits: Partial<Record<Mood, (local: number) => Spec | number>> = {
309    pending: local => {
310      const release = say(LINES.release)
311      if (local < 3) return { ...base, clawd: { ...standing, arms: 'up', eyes: 'happy' }, fishing: { ...calm, isPropped: true, held: { ...fish, wiggle: local % 2 } }, bubble: release }
312      return local < 3 + THROW_TICKS + SPLASH_TICKS ? toss(local - 3, fish, release) : 3 + THROW_TICKS + SPLASH_TICKS
313    },
314    ahead: local => {
315      if (local >= SAIL_TICKS) return SAIL_TICKS
316      const sink = Math.max(0, local - (SAIL_TICKS - 4))
317      return { ...base, clawd: { ...standing, eyes: 'right' }, bottle: { x: moored + local * 2, y: GROUND - 1 + sink, isUpright: false }, bubble: say(LINES.sailed) }
318    },
319    error: local => {
320      if (local >= CLEAR_TICKS) return CLEAR_TICKS
321      if (local < CLEAR_TICKS / 2) return { ...storm(0), isDrizzle: true }
322      return { ...base, clawd: { ...standing, eyes: 'happy' }, fishing: { ...calm, isPropped: true }, bubble: say(LINES.clearing) }
323    },
324  }
325
326  let local = t - scene.start
327  const exit = scene.previous !== scene.mood ? exits[scene.previous] : undefined
328  if (exit) {
329    const leaving = exit(local)
330    if (typeof leaving !== 'number') return leaving
331    local -= leaving
332  }
333
334  if (scene.mood === 'pending') {
335    if (scene.previous !== 'behind' && scene.previous !== 'drift') {
336      if (local < CAST_TICKS) return { ...base, clawd: watching, fishing: { ...calm, bob: 0, ripples: [{ x: pond.bobber, phase: local }] } }
337      local -= CAST_TICKS
338      if (local < NIBBLE_TICKS) return { ...base, clawd: watching, fishing: { ...calm, bob: local % 2 } }
339      local -= NIBBLE_TICKS
340      if (local < STRIKE_TICKS) {
341        return { ...base, clawd: watching, alert: { count: scene.size, step: 4 + local }, fishing: { ...calm, bob: 2, tug: 2, ripples: [{ x: pond.bobber, phase: local, isFaint: true }] } }
342      }
343      local -= STRIKE_TICKS
344    }
345    if (local < REEL_TICKS) return reel(local, fish)
346    local -= REEL_TICKS
347    const sinceNudge = scene.nudge === undefined ? Infinity : t - scene.nudge
348    const isNudged = sinceNudge >= 0 && sinceNudge < NUDGE_TICKS
349    const isCheering = local < INTRO_TICKS || isNudged
350    return {
351      ...base,
352      clawd: { ...standing, arms: 'up', eyes: isCheering ? 'happy' : blinking(local) },
353      fishing: { ...calm, isPropped: true, held: { ...fish, wiggle: Math.floor(local / 3) % 2 } },
354      sparkle: local < 16 || isNudged || local % 40 >= 36 ? local % 4 : null,
355      bubble: say(isNudged ? LINES.nudge : cycle(LINES.catch, local)),
356    }
357  }
358
359  if (scene.mood === 'drift' || scene.mood === 'behind') {
360    const bob = TUGS[local % TUGS.length]!
361    return {
362      ...base,
363      clawd: watching,
364      alert: { count: scene.size, step: alertStep(local) },
365      fishing: { ...calm, bob, tug: bob, ripples: bob === 2 ? [{ x: pond.bobber, phase: local % 2, isFaint: true }] : [] },
366      bubble: say(scene.size === 3 && local < LINE_TICKS ? LINES.bigBite : cycle(LINES.bite, local)),
367    }
368  }
369
370  if (scene.mood === 'ahead') {
371    if (local < 6) return { ...base, clawd: watching, bottle: { ...shore, isUpright: true } }
372    local -= 6
373    if (local < TOSS_TICKS) {
374      return { ...base, clawd: watching, bottle: { ...arc(shore, landing, local / (TOSS_TICKS - 1), 16), isUpright: false } }
375    }
376    local -= TOSS_TICKS
377    const drift = Math.min(local, BOTTLE_DRIFT_TICKS) / BOTTLE_DRIFT_TICKS
378    const bottle = { x: Math.round(landing.x + (moored - landing.x) * drift), y: GROUND - 1 + (Math.floor(local / 5) % 2), isUpright: false }
379    if (local < BOTTLE_DRIFT_TICKS) {
380      return {
381        ...base,
382        clawd: { ...standing, eyes: 'right' },
383        bottle,
384        fishing: { ...calm, ripples: local < 4 ? [{ x: landing.x, phase: local }] : [] },
385        bubble: say(cycle(LINES.bottle, local)),
386      }
387    }
388    return { ...idleAt(local - BOTTLE_DRIFT_TICKS, false), bottle }
389  }
390
391  if (scene.mood === 'error') {
392    const beat = t % 60
393    return { ...storm(beat), bubble: beat >= 30 ? say(LINES.storm[Math.floor(t / 60) % LINES.storm.length]!) : null }
394  }
395
396  if (scene.mood === 'unknown') {
397    return {
398      ...base,
399      clawd: { ...standing, eyes: Math.floor(t / 6) % 2 === 0 ? 'down' : 'right' },
400      swim: Math.floor(t / 3) % SWIM_PERIOD,
401      fishing: { ...calm, bob: Math.floor(t / 4) % 2 },
402      bubble: say(LINES.checking),
403    }
404  }
405
406  if (scene.previous === 'drift') {
407    if (local < REEL_TICKS) return reel(local, fish)
408    local -= REEL_TICKS
409    if (local < REACT_TICKS) {
410      return { ...base, clawd: { ...standing, arms: 'up', eyes: 'happy' }, fishing: { ...calm, isPropped: true, held: { ...fish, wiggle: Math.floor(local / 3) % 2 } }, sparkle: local % 4, bubble: say(LINES.reconciled) }
411    }
412    local -= REACT_TICKS
413    if (local < THROW_TICKS + SPLASH_TICKS) return toss(local, fish, say(LINES.release))
414    local -= THROW_TICKS + SPLASH_TICKS
415  }
416
417  if (scene.previous === 'behind') {
418    const junk = { what: 'junk' as const }
419    if (local < REEL_TICKS) return reel(local, junk)
420    local -= REEL_TICKS
421    if (local < REACT_TICKS) {
422      return {
423        ...base,
424        clawd: { ...standing, arms: 'up', eyes: 'flat', isSweating: true },
425        fishing: { ...calm, isPropped: true, held: { ...junk, wiggle: Math.floor(local / 4) % 2 } },
426        bubble: say(LINES.junk),
427      }
428    }
429    local -= REACT_TICKS
430    if (local < THROW_TICKS + SPLASH_TICKS) return toss(local, junk, say(LINES.back))
431    local -= THROW_TICKS + SPLASH_TICKS
432  }
433
434  return idleAt(local, true)
435}
436
437export const describe = (t: number, columns: number, scene: Scene, reconcileStep: ReconcileStep | null): Spec => {
438  const spec = describeScene(t, columns, scene)
439  if (reconcileStep === null || spec.fishing.flying !== null) return spec
440  return { ...spec, bubble: { text: cycle(LINES.reconciling[reconcileStep], t), side: 'right' } }
441}
442
443class Canvas {
444  readonly pixels: Uint8ClampedArray
445
446  constructor(readonly width: number, readonly height: number) {
447    this.pixels = new Uint8ClampedArray(width * height * 4)
448  }
449
450  set(x: number, y: number, color: Rgb, alpha = 1) {
451    if (x < 0 || y < 0 || x >= this.width || y >= this.height) return
452    const i = (y * this.width + x) * 4
453    const below = (this.pixels[i + 3]! / 255) * (1 - alpha)
454    const covered = alpha + below
455    if (covered === 0) return
456    for (let c = 0; c < 3; c++) this.pixels[i + c] = (color[c]! * alpha + this.pixels[i + c]! * below) / covered
457    this.pixels[i + 3] = covered * 255
458  }
459
460  fill(x: number, y: number, w: number, h: number, color: Rgb, alpha = 1) {
461    for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) this.set(x + dx, y + dy, color, alpha)
462  }
463
464  line(x0: number, y0: number, x1: number, y1: number, color: Rgb, alpha = 1) {
465    const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
466    for (let i = 0; i <= steps; i++) this.set(Math.round(x0 + ((x1 - x0) * i) / steps), Math.round(y0 + ((y1 - y0) * i) / steps), color, alpha)
467  }
468
469  text(x: number, y: number, chars: string[], color: Rgb) {
470    chars.forEach((ch, i) => {
471      const glyph = GLYPHS[ch] ?? GLYPHS['?']!
472      for (let j = 0; j < 15; j++) if (glyph[j] === '1') this.set(x + i * 4 + (j % 3), y + Math.floor(j / 3), color)
473    })
474  }
475}
476
477const paintEyes = (canvas: Canvas, x: number, y: number, eyes: Eyes) => {
478  const cells: Record<Eyes, (dx: number, dy: number) => boolean> = {
479    open: () => true,
480    closed: (_dx, dy) => dy === QUAD_H - 1,
481    left: dx => dx === 0,
482    right: dx => dx === 1,
483    down: (_dx, dy) => dy >= 2,
484    up: (_dx, dy) => dy < 2,
485    happy: (_dx, dy) => dy === 1,
486    flat: (_dx, dy) => dy === 2,
487  }
488  for (let dy = 0; dy < QUAD_H; dy++) for (let dx = 0; dx < QUAD_W; dx++) canvas.set(x + dx, y + dy, cells[eyes](dx, dy) ? EYE : BODY)
489}
490
491const paintBubble = (canvas: Canvas, clawdX: number, bubble: { text: string; side: Side }) => {
492  const maxChars = Math.floor((canvas.width - 10) / 4)
493  const chars = toGlyphText(bubble.text)
494  const text = chars.length > maxChars ? [...chars.slice(0, maxChars - 2), '.', '.'] : chars
495  const width = text.length * 4 + 5
496  const height = 11
497  const right = clawdX + SPRITE_W + 2
498  const left = clawdX - width - 2
499  const fitsRight = right + width <= canvas.width - 1
500  const fitsLeft = left >= 1
501  const isRight = bubble.side === 'right' ? fitsRight || !fitsLeft : !fitsLeft && fitsRight
502  if (!fitsRight && !fitsLeft) return
503  const bx = isRight ? right : left
504  const by = GROUND - SPRITE_H - height - 5
505
506  canvas.fill(bx + 1, by, width - 2, height, INK)
507  canvas.fill(bx, by + 1, width, height - 2, INK)
508  canvas.fill(bx + 1, by + 1, width - 2, height - 2, PAPER)
509  const tail = isRight ? bx + 3 : bx + width - 6
510  const at = (dx: number) => (isRight ? tail + dx : tail + 2 - dx)
511  const TAIL: [number, number, number, number][] = [[-1, 3, 0, 2], [-2, 1, -1, 0], [-3, -1, -2, -2], [-3, -3, 1, 0]]
512  TAIL.forEach(([inkFrom, inkTo, paperFrom, paperTo], row) => {
513    for (let dx = inkFrom; dx <= inkTo; dx++) canvas.set(at(dx), by + height - 1 + row, INK)
514    for (let dx = paperFrom; dx <= paperTo; dx++) canvas.set(at(dx), by + height - 1 + row, PAPER)
515  })
516  canvas.text(bx + 3, by + 3, text, INK)
517}
518
519const paintSnore = (canvas: Canvas, x: number, phase: number) => {
520  const top = GROUND - SPRITE_H + 1
521  for (let k = 0; k <= Math.min(phase, 2); k++) canvas.text(x + 30 + k * 4, top - 4 - k * 5, ['Z'], SNORE)
522}
523
524const paintUmbrella = (canvas: Canvas, x: number, top: number) => {
525  const cx = x + SPRITE_W / 2
526  const rim = top - 3
527  const rx = 19
528  const ry = 10
529  const panel = 7
530  for (let dx = -rx; dx <= rx; dx++) {
531    const u = ((dx + rx) % panel) / panel
532    const hem = rim - Math.round(Math.sin(Math.PI * u) * 2)
533    const crown = rim - Math.round(ry * Math.sqrt(Math.max(0, 1 - (dx * dx) / (rx * rx))))
534    const isRib = (dx + rx) % panel === 0
535    const color: Rgb = isRib ? [120, 34, 46] : Math.floor((dx + rx) / panel) % 2 === 0 ? [200, 56, 70] : [236, 222, 206]
536    for (let y = crown; y <= hem; y++) canvas.set(cx + dx, y, y === crown ? mix(color, [255, 255, 255], 0.25) : color)
537  }
538  canvas.fill(cx, rim - ry - 2, 1, 2, [90, 62, 44])
539  canvas.fill(cx, rim + 1, 1, 2, [90, 62, 44])
540}
541
542const mix = (a: Rgb, b: Rgb, amount: number): Rgb => [
543  a[0] + (b[0] - a[0]) * amount,
544  a[1] + (b[1] - a[1]) * amount,
545  a[2] + (b[2] - a[2]) * amount,
546]
547
548const paintClawd = (canvas: Canvas, pose: ClawdPose) => {
549  const top = GROUND - SPRITE_H + 1 - pose.lift
550  const x = pose.x
551  canvas.fill(x + 2, GROUND, SPRITE_W - 4, 2, BLACK, 0.3)
552
553  SPRITE.forEach((row, qy) => {
554    for (let qx = 0; qx < row.length; qx++) {
555      const isArm = qy === 2 && (qx <= 2 || qx >= 15)
556      if (row[qx] !== '1' || (isArm && pose.arms === 'up')) continue
557      for (let dy = 0; dy < QUAD_H; dy++) {
558        const color = qy === 0 && dy === 0 ? HIGHLIGHT : qy === SPRITE.length - 1 && dy >= QUAD_H - 2 ? SHADE : BODY
559        canvas.fill(x + qx * QUAD_W, top + qy * QUAD_H + dy, QUAD_W, 1, color)
560      }
561    }
562  })
563  if (pose.arms === 'up') {
564    ;[[1, 2], [0, 1], [-1, 1], [1, 15], [0, 16], [-1, 16]].forEach(([qy, qx]) => canvas.fill(x + qx! * QUAD_W, top + qy! * QUAD_H, QUAD_W, QUAD_H, BODY))
565  }
566  EYE_COLUMNS.forEach(qx => paintEyes(canvas, x + qx * QUAD_W, top + QUAD_H, pose.eyes))
567  LEG_COLUMNS.forEach(qx => canvas.fill(x + qx * QUAD_W, top + SPRITE.length * QUAD_H, QUAD_W, QUAD_H, SHADE))
568  if (pose.hasUmbrella) paintUmbrella(canvas, x, top)
569  if (pose.isSweating) {
570    canvas.fill(x + 30, top + 1, 1, 2, SWEAT)
571    canvas.set(x + 31, top + 3, SWEAT)
572  }
573}
574
575const paintPond = (canvas: Canvas, pond: Pond, shimmer: number) => {
576  for (let dy = 0; dy <= POND_DEPTH; dy++) {
577    const half = Math.round(pond.rx * Math.sqrt(1 - (dy / (POND_DEPTH + 1)) ** 2))
578    canvas.fill(pond.cx - half, GROUND + dy, half * 2 + 1, 1, mix(WATER_TOP, WATER_DEEP, dy / POND_DEPTH))
579    canvas.set(pond.cx - half - 1, GROUND + dy, BANK)
580    canvas.set(pond.cx + half + 1, GROUND + dy, BANK)
581  }
582  for (let k = 0; k < 4; k++) {
583    const x = pond.cx - pond.rx + Math.round(pond.rx * (0.25 + k * 0.42)) + shimmer * 2
584    canvas.fill(x, GROUND + 1 + (k % 2) * 2, 4, 1, SHINE, 0.7)
585  }
586  const lily = pond.cx + Math.round(pond.rx * 0.45)
587  canvas.fill(lily - 3, GROUND, 7, 1, LILY)
588  canvas.fill(lily - 2, GROUND - 1, 2, 1, LILY)
589  canvas.fill(lily + 1, GROUND - 1, 2, 1, LILY)
590  canvas.set(lily, GROUND - 2, [236, 150, 180])
591  ;[[-3, 10], [-1, 13], [1, 9]].forEach(([dx, height]) => {
592    const x = pond.cx + pond.rx + dx!
593    canvas.fill(x, GROUND - height!, 1, height!, REED)
594    canvas.fill(x, GROUND - height! - 1, 1, 3, CATTAIL)
595  })
596}
597
598const paintSwimmers = (canvas: Canvas, pond: Pond, phase: number) => {
599  for (let i = 0; i < 2; i++) {
600    const p = ((phase + i * (SWIM_PERIOD / 2)) % SWIM_PERIOD) / SWIM_PERIOD
601    const travel = p < 0.5 ? p * 2 : 2 - p * 2
602    const x = Math.round(pond.cx - pond.rx * 0.55 + travel * pond.rx * 1.1)
603    const y = GROUND + 5 + i * 5
604    const tail = p < 0.5 ? x - 2 : x + 8
605    canvas.fill(x, y, 8, 3, [22, 50, 90], 0.75)
606    canvas.fill(tail, y - 1 + (phase % 2), 2, 3, [22, 50, 90], 0.6)
607  }
608}
609
610const paintRipple = (canvas: Canvas, x: number, phase: number, isFaint: boolean) => {
611  const rx = 2 + phase * 2
612  const alpha = (0.85 - phase * 0.18) * (isFaint ? 0.5 : 1)
613  for (let dx = -rx; dx <= rx; dx++) {
614    const dy = Math.round(Math.sqrt(Math.max(0, 1 - (dx * dx) / (rx * rx))) * (1 + Math.floor(phase / 2)))
615    canvas.set(x + dx, GROUND + 1 + dy, SHINE, alpha)
616    canvas.set(x + dx, GROUND + 1 - dy, SHINE, alpha)
617  }
618}
619
620const paintRain = (canvas: Canvas, phase: number, isDrizzle: boolean) => {
621  const step = HEIGHT / RAIN_PERIOD
622  for (let i = 0; i < canvas.width / 3; i += isDrizzle ? 4 : 1) {
623    const y = (hash(i, 8) + phase * step) % HEIGHT
624    const x = (hash(i, 7) + Math.floor(y / 2)) % canvas.width
625    for (let k = 0; k < 3; k++) canvas.set(x - k, y - k * 2, [160, 170, 200], 0.55)
626  }
627}
628
629const paintBottle = (canvas: Canvas, bottle: Bottle) => {
630  if (bottle.isUpright) {
631    canvas.fill(bottle.x, bottle.y + 2, 3, 6, GLASS, 0.9)
632    canvas.fill(bottle.x + 1, bottle.y, 1, 2, GLASS, 0.9)
633    canvas.set(bottle.x + 1, bottle.y - 1, CORK)
634    canvas.fill(bottle.x + 1, bottle.y + 3, 1, 3, PAPER)
635    return
636  }
637  canvas.fill(bottle.x - 3, bottle.y, 6, 3, GLASS, 0.9)
638  canvas.fill(bottle.x + 3, bottle.y + 1, 2, 1, GLASS, 0.9)
639  canvas.set(bottle.x + 5, bottle.y + 1, CORK)
640  canvas.fill(bottle.x - 2, bottle.y + 1, 3, 1, PAPER)
641}
642
643const paintSprite = (canvas: Canvas, rows: readonly string[], center: Point, facing: Side, colorOf: (cell: string) => Rgb | null) => {
644  const width = rows[0]!.length
645  const left = Math.round(center.x - width / 2)
646  const top = Math.round(center.y - rows.length / 2)
647  rows.forEach((row, dy) => {
648    Array.from(row).forEach((cell, i) => {
649      const color = colorOf(cell)
650      if (color) canvas.set(left + (facing === 'right' ? i : width - 1 - i), top + dy, color)
651    })
652  })
653}
654
655const paintCatch = (canvas: Canvas, what: Catch, center: Point, facing: Side) => {
656  if (what.what === 'junk') {
657    paintSprite(canvas, JUNK_SPRITE, center, facing, cell => (cell === 'B' ? [110, 84, 64] : cell === 'S' ? [60, 46, 36] : null))
658    return
659  }
660  const colors = FISH_KINDS[what.kind]!
661  paintSprite(canvas, FISH_SPRITES[what.size], center, facing, cell =>
662    cell === 'B' ? colors.body : cell === 'L' ? colors.belly : cell === 'E' ? EYE : cell === 'F' || cell === 'T' ? colors.dark : null,
663  )
664}
665
666const paintHeld = (canvas: Canvas, held: Catch & { wiggle: number }, clawdX: number) => {
667  const center = heldOf(clawdX)
668  if (held.what === 'junk') {
669    paintCatch(canvas, held, center, 'right')
670    canvas.set(center.x + 3, center.y + 5 + held.wiggle * 2, WATER_TOP)
671    return
672  }
673  paintCatch(canvas, held, { x: center.x, y: center.y - held.wiggle }, held.wiggle === 0 ? 'right' : 'left')
674}
675
676const ALERT_BIG = [
677  '.OOO.',
678  'OHYYO',
679  'OHYYO',
680  'OHYSO',
681  'OHYSO',
682  '.OYO.',
683  '.OYO.',
684  '.OSO.',
685  '..O..',
686  '.....',
687  '.OOO.',
688  'OHYSO',
689  '.OOO.',
690]
691
692const ALERT_SMALL = [
693  '.OOO.',
694  'OHYYO',
695  'OHYSO',
696  '.OYO.',
697  '.OSO.',
698  '..O..',
699  '.....',
700  '.OOO.',
701  'OHYSO',
702  '.OOO.',
703]
704
705const ALERT_LAYOUTS: Record<Size, readonly (readonly string[])[]> = {
706  1: [ALERT_BIG],
707  2: [ALERT_BIG, ALERT_SMALL],
708  3: [ALERT_SMALL, ALERT_BIG, ALERT_SMALL],
709}
710
711const ALERT_COLORS: Record<string, Rgb> = { O: INK, Y: ALERT, H: [255, 246, 200], S: [230, 160, 40] }
712
713const alertStep = (local: number) => (local < 6 ? local : 6 + (Math.floor(local / 6) % 2))
714
715const paintAlert = (canvas: Canvas, alert: { count: Size; step: number }, clawdX: number) => {
716  const marks = ALERT_LAYOUTS[alert.count]
717  const gap = 1
718  const width = marks.reduce((sum, mark) => sum + mark[0]!.length, 0) + gap * (marks.length - 1)
719  const baseline = GROUND - SPRITE_H - 2
720  let left = Math.round(clawdX + SPRITE_W / 2 - width / 2)
721  marks.forEach((mark, i) => {
722    const age = alert.step - i * 2
723    if (age >= 0) {
724      const lift = age === 0 ? -2 : age === 1 ? 1 : alert.step === 7 && i % 2 === 0 ? 1 : alert.step === 6 && i % 2 === 1 ? 1 : 0
725      const top = baseline - mark.length - lift
726      mark.forEach((row, dy) =>
727        Array.from(row).forEach((cell, dx) => {
728          const color = ALERT_COLORS[cell]
729          if (color) canvas.set(left + dx, top + dy, color)
730        }),
731      )
732    }
733    left += mark[0]!.length + gap
734  })
735}
736
737const paintSparkles = (canvas: Canvas, phase: number, clawdX: number, held: Fishing['held']) => {
738  const center = heldOf(clawdX)
739  const sprite = held?.what === 'fish' ? FISH_SPRITES[held.size] : JUNK_SPRITE
740  const spread = Math.ceil(sprite[0]!.length / 2) + 3
741  const rise = Math.ceil(sprite.length / 2) + 3
742  const spots = [
743    { x: center.x - spread, y: center.y - 2 },
744    { x: center.x + spread, y: center.y - 4 },
745    { x: center.x - 5, y: center.y - rise },
746    { x: center.x + 7, y: center.y - rise - 1 },
747  ]
748  spots.forEach((spot, i) => {
749    const reach = (phase + i) % 4
750    if (reach === 3) return
751    canvas.set(spot.x, spot.y, ALERT)
752    for (let r = 1; r <= reach; r++) {
753      canvas.set(spot.x + r, spot.y, ALERT, 0.6)
754      canvas.set(spot.x - r, spot.y, ALERT, 0.6)
755      canvas.set(spot.x, spot.y + r, ALERT, 0.6)
756      canvas.set(spot.x, spot.y - r, ALERT, 0.6)
757    }
758  })
759}
760
761const paintGear = (canvas: Canvas, fishing: Fishing, clawdX: number, pond: Pond) => {
762  const bucket = bucketOf(clawdX)
763  canvas.fill(bucket, GROUND - 6, 8, 7, [150, 150, 160])
764  canvas.fill(bucket, GROUND - 6, 8, 1, [196, 196, 206])
765  canvas.fill(bucket + 1, GROUND - 5, 6, 1, [72, 72, 82])
766
767  const { butt, tip } = rodOf(clawdX, fishing)
768  const bobber = { x: pond.bobber, y: GROUND - 2 + fishing.bob }
769  const bend = { x: Math.round((butt.x + tip.x) / 2), y: Math.round((butt.y + tip.y) / 2) + Math.ceil(fishing.tug / 2) }
770  canvas.line(butt.x, butt.y, bend.x, bend.y, ROD)
771  canvas.line(bend.x, bend.y, tip.x, tip.y, ROD)
772  canvas.line(tip.x, tip.y, bobber.x, bobber.y, [200, 200, 205], fishing.tug > 0 ? 0.9 : 0.6)
773  canvas.fill(bobber.x, bobber.y, 2, 2, BOBBER)
774  canvas.fill(bobber.x, bobber.y + 2, 2, 1, PAPER)
775}
776
777const paint = (spec: Spec): Canvas => {
778  const canvas = new Canvas(spec.width, HEIGHT)
779  const pond = pondOf(spec.width, spec.clawd.x)
780  canvas.fill(0, GROUND, spec.width, 1, LINE, 0.35)
781  paintPond(canvas, pond, spec.shimmer)
782  if (spec.swim !== null) paintSwimmers(canvas, pond, spec.swim)
783  spec.fishing.ripples.forEach(ripple => paintRipple(canvas, ripple.x, ripple.phase, ripple.isFaint === true))
784  if (spec.rain !== null) paintRain(canvas, spec.rain, spec.isDrizzle)
785  paintClawd(canvas, spec.clawd)
786  paintGear(canvas, spec.fishing, spec.clawd.x, pond)
787  if (spec.bottle) paintBottle(canvas, spec.bottle)
788  if (spec.fishing.held) paintHeld(canvas, spec.fishing.held, spec.clawd.x)
789  if (spec.sparkle !== null) paintSparkles(canvas, spec.sparkle, spec.clawd.x, spec.fishing.held)
790  if (spec.fishing.flying) paintCatch(canvas, spec.fishing.flying, spec.fishing.flying, spec.fishing.flying.facing)
791  if (spec.alert) paintAlert(canvas, spec.alert, spec.clawd.x)
792  if (spec.snore !== null) paintSnore(canvas, spec.clawd.x, spec.snore)
793  if (spec.bubble) paintBubble(canvas, spec.clawd.x, spec.bubble)
794  return canvas
795}
796
797const upscale = (canvas: Canvas): Uint8Array => {
798  const width = canvas.width * UPSCALE
799  const rgba = new Uint8Array(width * canvas.height * UPSCALE * 4)
800  for (let y = 0; y < canvas.height; y++) {
801    const row = new Uint8Array(width * 4)
802    for (let x = 0; x < canvas.width; x++) {
803      const src = (y * canvas.width + x) * 4
804      for (let k = 0; k < UPSCALE; k++) row.set(canvas.pixels.subarray(src, src + 4), (x * UPSCALE + k) * 4)
805    }
806    for (let k = 0; k < UPSCALE; k++) rgba.set(row, (y * UPSCALE + k) * width * 4)
807  }
808  return rgba
809}
810
811const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
812
813export const toBase64 = (bytes: Uint8Array): string => {
814  const out = new Uint8Array(Math.ceil(bytes.length / 3) * 4)
815  let o = 0
816  for (let i = 0; i < bytes.length; i += 3) {
817    const a = bytes[i]!
818    const b = bytes[i + 1] ?? 0
819    const c = bytes[i + 2] ?? 0
820    out[o++] = BASE64.charCodeAt(a >> 2)
821    out[o++] = BASE64.charCodeAt(((a & 3) << 4) | (b >> 4))
822    out[o++] = i + 1 < bytes.length ? BASE64.charCodeAt(((b & 15) << 2) | (c >> 6)) : 61
823    out[o++] = i + 2 < bytes.length ? BASE64.charCodeAt(c & 63) : 61
824  }
825  return new TextDecoder().decode(out)
826}
827
828const FRAME_CACHE_SIZE = 512
829const frames = new Map<string, string>()
830
831export type FilmFrame = { key: string; source: { png: string } }
832
833export const filmFrame = (t: number, columns: number, scene: Scene, reconcileStep: ReconcileStep | null): FilmFrame => {
834  const spec = describe(t, columns, scene, reconcileStep)
835  const key = JSON.stringify(spec)
836  let png = frames.get(key)
837  if (png === undefined) {
838    const canvas = paint(spec)
839    png = toBase64(encodePng(upscale(canvas), canvas.width * UPSCALE, canvas.height * UPSCALE, 4))
840    if (frames.size >= FRAME_CACHE_SIZE) frames.delete(frames.keys().next().value!)
841    frames.set(key, png)
842  }
843  return { key, source: { png } }
844}
845
hooks/reconcile.ts 53 lines
1import type { DriftedFile, ExtraPackage, FileDirection, PackageKind, PackageRef } from '../types'
2
3const PREVIEW_LINES = 6
4const SECTIONS: Record<PackageKind, string> = { tap: 'taps', formula: 'formulae', cask: 'casks' }
5
6type Run = { exitCode: number; stdout: string; stderr: string }
7
8export const firstLine = (run: Run) => (run.stderr.trim() || run.stdout.trim()).split('\n')[0] ?? ''
9
10export const note = (text: string, tone: 'ok' | 'warn' | 'error' = 'ok') => ({ text, tone })
11
12export const parseBrewDiff = (stdout: string) => {
13  const profile = /^PROFILE: (\S+)/m.exec(stdout)?.[1] ?? 'common'
14  const refs = (label: string) =>
15    [...stdout.matchAll(new RegExp(`^${label} (TAP|FORMULA|CASK): (\\S+)$`, 'gm'))].map(match => ({ kind: match[1]!.toLowerCase() as PackageKind, name: match[2]! }))
16  const missing: PackageRef[] = refs('MISSING')
17  const packages: ExtraPackage[] = refs('EXTRA').map(ref => ({ ...ref, decision: null, scope: null }))
18  return { profile, missing, packages }
19}
20
21export const parseRichDiff = (stdout: string): Omit<DriftedFile, 'isTemplate' | 'isScript'>[] => {
22  const files: Omit<DriftedFile, 'isTemplate' | 'isScript'>[] = []
23  for (const line of stdout.split('\n')) {
24    const header = /^=== (.+) \((REPO_NEWER|MACHINE_NEWER|SAME_TIME|NEW_FILE)\) ===$/.exec(line)
25    if (header) {
26      files.push({ path: header[1]!, direction: header[2] as FileDirection, preview: [], decision: null, summary: null })
27      continue
28    }
29    const current = files[files.length - 1]
30    if (current && /^\[(MACHINE|REPO)\]/.test(line) && current.preview.length < PREVIEW_LINES) current.preview.push(line)
31  }
32  return files
33}
34
35export const addToPackages = (yaml: string, kind: PackageKind, key: string, name: string): string => {
36  const lines = yaml.split('\n')
37  const section = lines.findIndex(line => line === `  ${SECTIONS[kind]}:`)
38  if (section === -1) throw new Error(`packages.yaml has no ${SECTIONS[kind]} section`)
39  let end = section + 1
40  while (end < lines.length && (lines[end]!.startsWith('    ') || lines[end]!.trim() === '')) end++
41  const list = lines.findIndex((line, i) => i > section && i < end && (line === `    ${key}:` || line === `    ${key}: []`))
42  if (list === -1) throw new Error(`packages.yaml has no ${SECTIONS[kind]}.${key} list`)
43  if (lines[list] === `    ${key}: []`) {
44    lines.splice(list, 1, `    ${key}:`, `      - ${name}`)
45    return lines.join('\n')
46  }
47  let at = list + 1
48  while (at < end && lines[at]!.startsWith('      - ') && lines[at]!.slice(8) < name) at++
49  lines.splice(at, 0, `      - ${name}`)
50  return lines.join('\n')
51}
52
53
hooks/status.ts 87 lines
1import type { FileDirection, Snapshot, StatusEntry, Upstream } from '../types'
2import type { Mood } from './film'
3
4export const parseStatus = (stdout: string): StatusEntry[] =>
5  stdout
6    .split('\n')
7    .filter(line => line.trim() !== '')
8    .map(line => ({ code: line.slice(0, 2), path: line.slice(3) }))
9
10export const isScript = (entry: StatusEntry) => entry.code.includes('R')
11export const isEditedHere = (entry: StatusEntry) => entry.code[0] !== ' '
12
13export const isBehind = (up: Upstream | null) => up !== null && up.isReachable && (up.behind === null || up.behind > 0)
14const hasUnpushed = (up: Upstream | null) => up !== null && (up.ahead > 0 || up.isDirty)
15
16export const moodOf = (current: Snapshot | null, up: Upstream | null): Mood => {
17  if (current === null) return 'unknown'
18  if (current.error !== undefined) return 'error'
19  if (current.entries.some(isEditedHere)) return 'drift'
20  if (current.entries.length > 0) return 'pending'
21  if (isBehind(up)) return 'behind'
22  return hasUnpushed(up) ? 'ahead' : 'sync'
23}
24
25export const formatTime = (epochMs: number) => {
26  const date = new Date(epochMs)
27  const pad = (n: number) => String(n).padStart(2, '0')
28  return `${pad(date.getHours())}:${pad(date.getMinutes())}`
29}
30
31export type Badge = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
32
33export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
34
35export const statusBadges = (current: Snapshot | null): Badge[] => {
36  if (current === null) return [{ text: '… checking', isDim: true }]
37  if (current.error !== undefined) return [{ text: '✗ status failed', color: 'red', isBold: true }]
38  const edited = current.entries.filter(isEditedHere).length
39  const scripts = current.entries.filter(isScript).length
40  const files = current.entries.length - scripts
41  if (current.entries.length === 0) return [{ text: '✓ in sync', color: 'green', isBold: true }]
42  const pending = [files > 0 ? plural(files, 'file') : '', scripts > 0 ? plural(scripts, 'script') : ''].filter(Boolean).join(' · ')
43  return [
44    ...(edited > 0 ? [{ text: `✎ ${edited} edited here`, color: 'yellow', isBold: true }] : []),
45    { text: `● ${pending} to apply`, color: 'yellow', isBold: edited === 0 },
46  ]
47}
48
49export const repoBadges = (up: Upstream | null): Badge[] => {
50  if (up === null) return []
51  const marks: Badge[] = [
52    ...(isBehind(up) ? [{ text: `↓${up.behind ?? '?'}`, color: 'yellow' }] : []),
53    ...(up.ahead > 0 ? [{ text: `↑${up.ahead}`, color: 'cyan' }] : []),
54    ...(up.isDirty ? [{ text: '± wip', color: 'cyan' }] : []),
55    ...(!up.isReachable ? [{ text: 'offline', isDim: true }] : []),
56  ]
57  return [{ text: `${up.branch}${marks.length === 0 ? ' ✓' : ''}`, isDim: true }, ...marks]
58}
59
60const ENTRY_GLYPHS: Record<string, Badge> = {
61  A: { text: '+', color: 'green' },
62  D: { text: '−', color: 'red' },
63  M: { text: '~', color: 'yellow' },
64  R: { text: '▶', color: 'cyan' },
65}
66
67export const entryGlyph = (entry: StatusEntry): Badge => ENTRY_GLYPHS[entry.code.trim().slice(-1)] ?? { text: '·', isDim: true }
68
69export const DIRECTION_LABELS: Record<FileDirection, string> = {
70  REPO_NEWER: 'repo is newer',
71  MACHINE_NEWER: 'machine is newer',
72  SAME_TIME: 'same age',
73  NEW_FILE: 'new file',
74}
75
76export const FILE_OUTCOMES: Record<string, string> = { applied: 'applied', kept: 'kept from machine', merging: 'sent to Claude', skipped: 'skipped' }
77export const PACKAGE_OUTCOMES: Record<string, string> = { added: 'added', ignored: 'ignored', uninstalled: 'uninstalled', skipped: 'skipped' }
78
79export const countBy = (values: (string | null)[]) =>
80  values.reduce<Record<string, number>>((counts, value) => (value === null ? counts : { ...counts, [value]: (counts[value] ?? 0) + 1 }), {})
81
82export const describeCounts = (counts: Record<string, number>, labels: Record<string, string>) =>
83  Object.entries(labels)
84    .filter(([key]) => (counts[key] ?? 0) > 0)
85    .map(([key, label]) => `${counts[key]} ${label}`)
86    .join(' · ')
87
hooks/clawd.tsx 193 lines
1import type { ClientModule } from 'claude-code'
2
3import { ART_PER_COLUMN, FILM_ROWS, FILM_TICK_MS, FISH_KINDS, GROUND, HEIGHT, RAIN_PERIOD, SPRITE_H, SPRITE_W, SWIM_PERIOD, bucketOf, describe, hash, heldOf, pondOf, rodOf } from './film'
4import type { Catch, ReconcileStep, Scene, Spec } from './film'
5
6type Props = { scene: Scene; reconcileStep: ReconcileStep | null }
7type Reel = { t: number; base: number; sceneKey: string }
8type Cell = { ch: string; color?: string; dim?: boolean; bold?: boolean }
9type Style = Omit<Cell, 'ch'>
10
11const FALLBACK_COLUMNS = 40
12const ROW = HEIGHT / FILM_ROWS
13const ORANGE = '#D77757'
14const GOLD = '#FFD640'
15const WATER = '#5690C4'
16const DEEP = '#2E5A8C'
17const SHINE = '#AAD0EE'
18const ROD = '#8A6440'
19const LINE = '#B8B8C0'
20const RED = '#DC3C3C'
21
22const HEADS = { open: ' ▐▛███▜▌ ', shut: ' ▐█████▌ ' }
23const BODY = '▝▜█████▛▘'
24const BODY_ARMS_UP = ' ▜█████▛ '
25const LEGS = '  ▘▘ ▝▝  '
26const SPRITE_COLUMNS = SPRITE_W / ART_PER_COLUMN
27
28const hex = (rgb: readonly number[]) => `#${rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`
29const cellX = (x: number) => Math.floor(x / ART_PER_COLUMN)
30const cellY = (y: number) => Math.floor(y / ROW)
31
32const paint = (grid: Cell[][], row: number, column: number, text: string, style: Style, isOpaque = false) => {
33  const line = grid[row]
34  if (line === undefined) return
35  Array.from(text).forEach((ch, i) => {
36    const at = column + i
37    if ((isOpaque || ch !== ' ') && at >= 0 && at < line.length) line[at] = { ch, ...style }
38  })
39}
40
41const catchGlyph = (what: Catch, facing: 'left' | 'right') => {
42  if (what.what === 'junk') return { text: '▙', style: { color: '#6E5440' } }
43  const glyphs = facing === 'right' ? ['><>', '><(>', '><((°>'] : ['<><', '<)><', '<°))><']
44  return { text: glyphs[what.size - 1]!, style: { color: hex(FISH_KINDS[what.kind]!.body), bold: true } }
45}
46
47const paintLine = (grid: Cell[][], from: { x: number; y: number }, to: { x: number; y: number }, style: Style) => {
48  const [x0, y0, x1, y1] = [cellX(from.x), cellY(from.y), cellX(to.x), cellY(to.y)]
49  const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1)
50  const ch = y1 === y0 ? '─' : x1 === x0 ? '│' : (x1 - x0) * (y1 - y0) < 0 ? '╱' : '╲'
51  for (let i = 0; i <= steps; i++) paint(grid, Math.round(y0 + ((y1 - y0) * i) / steps), Math.round(x0 + ((x1 - x0) * i) / steps), ch, style)
52}
53
54const paintBubble = (grid: Cell[][], x: number, columns: number, text: string) => {
55  const room = columns - x - 5
56  if (room < 4) return
57  const chars = Array.from(text)
58  const shown = chars.length > room ? `${chars.slice(0, room - 1).join('')}…` : text
59  const border = '─'.repeat(Array.from(shown).length + 2)
60  paint(grid, 0, x + 1, `╭${border}╮`, {}, true)
61  paint(grid, 1, x, `─┤ ${shown} │`, {}, true)
62  paint(grid, 2, x + 1, `╰${border}╯`, {}, true)
63}
64
65const compose = (spec: Spec, columns: number): Cell[][] => {
66  const grid: Cell[][] = Array.from({ length: FILM_ROWS }, () => Array.from({ length: columns }, () => ({ ch: ' ' })))
67  const x = cellX(spec.clawd.x)
68  const pond = pondOf(spec.width, spec.clawd.x)
69  const groundRow = cellY(GROUND)
70
71  paint(grid, groundRow, 0, '▁'.repeat(columns), { dim: true })
72  for (let column = cellX(pond.cx - pond.rx); column <= cellX(pond.cx + pond.rx); column++) {
73    paint(grid, groundRow, column, (column + spec.shimmer) % 3 === 0 ? '∽' : '~', { color: WATER })
74  }
75  for (let column = cellX(pond.cx - pond.rx * 0.8); column <= cellX(pond.cx + pond.rx * 0.8); column++) {
76    paint(grid, groundRow + 1, column, '≈', { color: DEEP })
77  }
78  if (spec.swim !== null) {
79    for (let i = 0; i < 2; i++) {
80      const p = ((spec.swim + i * (SWIM_PERIOD / 2)) % SWIM_PERIOD) / SWIM_PERIOD
81      const travel = p < 0.5 ? p * 2 : 2 - p * 2
82      paint(grid, groundRow + 1, cellX(pond.cx - pond.rx * 0.55 + travel * pond.rx * 1.1), p < 0.5 ? '>' : '<', { color: DEEP, bold: true })
83    }
84  }
85  spec.fishing.ripples.forEach(ripple => {
86    const reach = Math.ceil(ripple.phase / 2)
87    paint(grid, groundRow, cellX(ripple.x) - reach, '◦', { color: SHINE, dim: ripple.isFaint })
88    paint(grid, groundRow, cellX(ripple.x) + reach, '◦', { color: SHINE, dim: ripple.isFaint })
89  })
90  if (spec.rain !== null) {
91    for (let i = 0; i < spec.width / 3; i += spec.isDrizzle ? 4 : 1) {
92      const y = (hash(i, 8) + spec.rain * (HEIGHT / RAIN_PERIOD)) % HEIGHT
93      if (i % 3 === 0) paint(grid, cellY(y), cellX((hash(i, 7) + Math.floor(y / 2)) % spec.width), '╱', { color: SHINE, dim: true })
94    }
95  }
96
97  const clawdRow = cellY(GROUND - SPRITE_H + 1) + 1
98  const isShut = ['closed', 'happy', 'flat'].includes(spec.clawd.eyes)
99  paint(grid, clawdRow, x, isShut ? HEADS.shut : HEADS.open, { color: ORANGE })
100  paint(grid, clawdRow + 1, x, spec.clawd.arms === 'up' ? BODY_ARMS_UP : BODY, { color: ORANGE })
101  paint(grid, clawdRow + 2, x, LEGS, { color: ORANGE })
102  if (spec.clawd.arms === 'up') {
103    paint(grid, clawdRow, x, '▐', { color: ORANGE })
104    paint(grid, clawdRow, x + SPRITE_COLUMNS - 1, '▌', { color: ORANGE })
105  }
106  if (spec.clawd.isSweating) paint(grid, clawdRow, x + SPRITE_COLUMNS, '’', { color: SHINE })
107  if (spec.clawd.hasUmbrella) paint(grid, clawdRow - 1, x - 1, '▄▆████████▆▄', { color: RED })
108
109  paint(grid, clawdRow + 1, cellX(bucketOf(spec.clawd.x)), '▆▆', { color: '#9696A0' })
110  const { butt, tip } = rodOf(spec.clawd.x, spec.fishing)
111  paintLine(grid, butt, tip, { color: ROD })
112  const bobber = { x: pond.bobber, y: GROUND }
113  paintLine(grid, tip, bobber, { color: LINE, dim: spec.fishing.tug === 0 })
114  paint(grid, groundRow, cellX(pond.bobber), spec.fishing.bob === 2 ? '·' : 'o', { color: RED, bold: true })
115
116  if (spec.bottle) {
117    paint(grid, spec.bottle.isUpright ? clawdRow + 1 : groundRow, cellX(spec.bottle.x), spec.bottle.isUpright ? '▮' : '▬', { color: '#78BE96' })
118  }
119  const held = heldOf(spec.clawd.x)
120  if (spec.fishing.held) {
121    const glyph = catchGlyph(spec.fishing.held, spec.fishing.held.wiggle === 0 ? 'right' : 'left')
122    paint(grid, clawdRow - 1, cellX(held.x) - Math.floor(Array.from(glyph.text).length / 2), glyph.text, glyph.style)
123  }
124  if (spec.sparkle !== null) {
125    paint(grid, clawdRow - 1, x - 1 + (spec.sparkle % 2), '✦', { color: GOLD })
126    paint(grid, clawdRow - 2, x + SPRITE_COLUMNS - 1 - (spec.sparkle % 2), '✦', { color: GOLD })
127  }
128  if (spec.fishing.flying) {
129    const glyph = catchGlyph(spec.fishing.flying, spec.fishing.flying.facing)
130    paint(grid, cellY(spec.fishing.flying.y), cellX(spec.fishing.flying.x) - 1, glyph.text, glyph.style)
131  }
132  if (spec.alert) {
133    const marks = '!'.repeat(spec.alert.count)
134    const shown = Array.from(marks).slice(0, Math.max(1, Math.ceil((spec.alert.step + 1) / 2))).join('')
135    paint(grid, clawdRow - 1, cellX(held.x) - Math.floor(shown.length / 2), shown, { color: GOLD, bold: true })
136  }
137  if (spec.snore !== null) paint(grid, clawdRow - 1 - (spec.snore % 2), x + SPRITE_COLUMNS - 1, spec.snore < 2 ? 'z' : 'Z', { dim: true })
138  if (spec.bubble) paintBubble(grid, x + SPRITE_COLUMNS, columns, spec.bubble.text)
139
140  return grid
141}
142
143const toRuns = (line: Cell[]): Cell[] =>
144  line.reduce<Cell[]>((runs, cell) => {
145    const last = runs[runs.length - 1]
146    if (last !== undefined && last.color === cell.color && last.dim === cell.dim && last.bold === cell.bold) {
147      last.ch += cell.ch
148    } else {
149      runs.push({ ...cell })
150    }
151    return runs
152  }, [])
153
154const Clawd: ClientModule<Props | null, Reel> = (props, surface) => {
155  const { Box, Text } = surface.elements
156  if (surface.state === undefined) {
157    surface.setState({ t: 0, base: 0, sceneKey: '' })
158    surface.every(FILM_TICK_MS, () => {
159      const reel = surface.state
160      if (reel) surface.setState({ ...reel, t: reel.t + 1 })
161    })
162  }
163  const reel = surface.state ?? { t: 0, base: 0, sceneKey: '' }
164  if (props === null) return <Box />
165
166  const sceneKey = JSON.stringify([props.scene.mood, props.scene.previous, props.scene.start, props.scene.nudge])
167  const base = reel.sceneKey === sceneKey ? reel.base : reel.t
168  if (reel.sceneKey !== sceneKey) surface.setState({ ...reel, base, sceneKey })
169  const scene: Scene = {
170    ...props.scene,
171    start: base,
172    ...(props.scene.nudge === undefined ? {} : { nudge: base + (props.scene.nudge - props.scene.start) }),
173  }
174  const columns = surface.columns || FALLBACK_COLUMNS
175  const grid = compose(describe(reel.t, columns, scene, props.reconcileStep), columns)
176
177  return (
178    <Box flexDirection="column">
179      {grid.map(line => (
180        <Box flexDirection="row">
181          {toRuns(line).map(run => (
182            <Text color={run.color} dimColor={run.dim} bold={run.bold} wrap="truncate-end">
183              {run.ch}
184            </Text>
185          ))}
186        </Box>
187      ))}
188    </Box>
189  )
190}
191
192export default Clawd
193
hooks/png.ts 207 lines
1const CRC_TABLE = (() => {
2  const table = new Uint32Array(256)
3  for (let n = 0; n < 256; n++) {
4    let c = n
5    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
6    table[n] = c >>> 0
7  }
8  return table
9})()
10
11const crc32 = (bytes: Uint8Array): number => {
12  let c = 0xffffffff
13  for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
14  return (c ^ 0xffffffff) >>> 0
15}
16
17const adler32 = (bytes: Uint8Array): number => {
18  let a = 1
19  let b = 0
20  for (let i = 0; i < bytes.length; i += 4096) {
21    const end = Math.min(bytes.length, i + 4096)
22    for (let j = i; j < end; j++) {
23      a += bytes[j]!
24      b += a
25    }
26    a %= 65521
27    b %= 65521
28  }
29  return ((b << 16) | a) >>> 0
30}
31
32const reverseBits = (code: number, length: number) => {
33  let reversed = 0
34  for (let i = 0; i < length; i++) {
35    reversed = (reversed << 1) | (code & 1)
36    code >>>= 1
37  }
38  return reversed
39}
40
41const LITERAL_CODES = new Uint16Array(288)
42const LITERAL_LENGTHS = new Uint8Array(288)
43for (let symbol = 0; symbol < 288; symbol++) {
44  const [base, length, first] =
45    symbol < 144 ? [0x30, 8, 0] : symbol < 256 ? [0x190, 9, 144] : symbol < 280 ? [0, 7, 256] : [0xc0, 8, 280]
46  LITERAL_CODES[symbol] = reverseBits(base + symbol - first, length)
47  LITERAL_LENGTHS[symbol] = length
48}
49
50const LENGTH_BASES = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
51const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
52const DISTANCE_BASES = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
53const DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
54
55const LENGTH_SYMBOLS = new Uint8Array(259)
56for (let code = 0; code < LENGTH_BASES.length; code++) {
57  const end = code + 1 < LENGTH_BASES.length ? LENGTH_BASES[code + 1]! : 259
58  for (let length = LENGTH_BASES[code]!; length < end; length++) LENGTH_SYMBOLS[length] = code
59}
60LENGTH_SYMBOLS[258] = 28
61
62
63const distanceCode = (distance: number) => {
64  let code = DISTANCE_BASES.length - 1
65  while (DISTANCE_BASES[code]! > distance) code--
66  return code
67}
68
69class BitWriter {
70  bytes: Uint8Array
71  length = 0
72  private buffer = 0
73  private bits = 0
74
75  constructor(capacity: number) {
76    this.bytes = new Uint8Array(capacity)
77  }
78
79  write(value: number, count: number) {
80    this.buffer |= value << this.bits
81    this.bits += count
82    while (this.bits >= 8) {
83      this.push(this.buffer & 0xff)
84      this.buffer >>>= 8
85      this.bits -= 8
86    }
87  }
88
89  flush() {
90    if (this.bits > 0) this.push(this.buffer & 0xff)
91    this.buffer = 0
92    this.bits = 0
93  }
94
95  private push(byte: number) {
96    if (this.length === this.bytes.length) {
97      const grown = new Uint8Array(this.bytes.length * 2)
98      grown.set(this.bytes)
99      this.bytes = grown
100    }
101    this.bytes[this.length++] = byte
102  }
103}
104
105const HASH_BITS = 15
106const MAX_CHAIN = 16
107const WINDOW = 32768
108
109const deflate = (data: Uint8Array): Uint8Array => {
110  const out = new BitWriter(Math.max(1024, data.length >> 3))
111  const head = new Int32Array(1 << HASH_BITS).fill(-1)
112  const previous = new Int32Array(data.length)
113  const hashAt = (p: number) => ((data[p]! << 10) ^ (data[p + 1]! << 5) ^ data[p + 2]!) & ((1 << HASH_BITS) - 1)
114  const insert = (p: number) => {
115    if (p + 2 >= data.length) return
116    const h = hashAt(p)
117    previous[p] = head[h]!
118    head[h] = p
119  }
120  const literal = (symbol: number) => out.write(LITERAL_CODES[symbol]!, LITERAL_LENGTHS[symbol]!)
121
122  out.write(1, 1)
123  out.write(1, 2)
124  let i = 0
125  while (i < data.length) {
126    let bestLength = 0
127    let bestDistance = 0
128    if (i + 2 < data.length) {
129      const limit = Math.min(258, data.length - i)
130      let candidate = head[hashAt(i)]!
131      for (let chain = 0; candidate >= 0 && i - candidate <= WINDOW && chain < MAX_CHAIN; chain++) {
132        if (data[candidate + bestLength] === data[i + bestLength]) {
133          let length = 0
134          while (length < limit && data[candidate + length] === data[i + length]) length++
135          if (length > bestLength) {
136            bestLength = length
137            bestDistance = i - candidate
138            if (length === limit) break
139          }
140        }
141        candidate = previous[candidate]!
142      }
143    }
144
145    if (bestLength >= 3) {
146      const lengthCode = LENGTH_SYMBOLS[bestLength]!
147      literal(257 + lengthCode)
148      out.write(bestLength - LENGTH_BASES[lengthCode]!, LENGTH_EXTRA[lengthCode]!)
149      const code = distanceCode(bestDistance)
150      out.write(reverseBits(code, 5), 5)
151      out.write(bestDistance - DISTANCE_BASES[code]!, DISTANCE_EXTRA[code]!)
152      for (let k = 0; k < bestLength; k++) insert(i + k)
153      i += bestLength
154    } else {
155      literal(data[i]!)
156      insert(i)
157      i++
158    }
159  }
160  literal(256)
161  out.flush()
162  return out.bytes.subarray(0, out.length)
163}
164
165const chunk = (type: string, data: Uint8Array): Uint8Array => {
166  const bytes = new Uint8Array(12 + data.length)
167  const view = new DataView(bytes.buffer)
168  view.setUint32(0, data.length)
169  for (let i = 0; i < 4; i++) bytes[4 + i] = type.charCodeAt(i)
170  bytes.set(data, 8)
171  view.setUint32(8 + data.length, crc32(bytes.subarray(4, 8 + data.length)))
172  return bytes
173}
174
175export const encodePng = (pixels: Uint8Array, width: number, height: number, channels: 3 | 4): Uint8Array => {
176  const stride = width * channels
177  const raw = new Uint8Array((stride + 1) * height)
178  for (let y = 0; y < height; y++) raw.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1)
179
180  const compressed = deflate(raw)
181  const zlib = new Uint8Array(compressed.length + 6)
182  zlib[0] = 0x78
183  zlib[1] = 0x01
184  zlib.set(compressed, 2)
185  new DataView(zlib.buffer).setUint32(compressed.length + 2, adler32(raw))
186
187  const header = new Uint8Array(13)
188  const headerView = new DataView(header.buffer)
189  headerView.setUint32(0, width)
190  headerView.setUint32(4, height)
191  header.set([8, channels === 4 ? 6 : 2, 0, 0, 0], 8)
192
193  const parts = [
194    new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
195    chunk('IHDR', header),
196    chunk('IDAT', zlib),
197    chunk('IEND', new Uint8Array(0)),
198  ]
199  const png = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
200  let offset = 0
201  for (const part of parts) {
202    png.set(part, offset)
203    offset += part.length
204  }
205  return png
206}
207
types/index.d.ts 62 lines
1export type StatusEntry = { code: string; path: string }
2
3export type Snapshot = {
4  entries: StatusEntry[]
5  error?: string
6  checkedAt: number
7}
8
9export type Summary = {
10  text: string
11  diffHash: string
12  isLoading: boolean
13  error?: string
14}
15
16export type Upstream = { branch: string; ahead: number; behind: number | null; isDirty: boolean; isReachable: boolean }
17
18export type PackageKind = 'tap' | 'formula' | 'cask'
19export type PackageDecision = 'added' | 'ignored' | 'uninstalled' | 'skipped'
20export type PackageRef = { kind: PackageKind; name: string }
21export type ExtraPackage = PackageRef & { decision: PackageDecision | null; scope: string | null }
22
23export type FileDirection = 'REPO_NEWER' | 'MACHINE_NEWER' | 'SAME_TIME' | 'NEW_FILE'
24export type FileDecision = 'applied' | 'kept' | 'merging' | 'skipped'
25export type FileSummary = { text: string; isLoading: boolean; error?: string }
26export type DriftedFile = {
27  path: string
28  direction: FileDirection
29  preview: string[]
30  isTemplate: boolean
31  isScript: boolean
32  decision: FileDecision | null
33  summary: FileSummary | null
34}
35
36export type PushState = { busy: string | null; note: { text: string; tone: 'ok' | 'warn' | 'error' } | null }
37
38export type Reconcile = {
39  step: 'pull' | 'packages' | 'files' | 'done'
40  busy: string | null
41  note: { text: string; tone: 'ok' | 'warn' | 'error' } | null
42  isBlocked: boolean
43  profile: string
44  missing: PackageRef[]
45  packages: ExtraPackage[]
46  files: DriftedFile[]
47  cursor: number
48  isConfirming: boolean
49}
50
51declare module 'claude-code' {
52  interface PluginState {
53    'chezmoi-status': {
54      snapshot: Snapshot | null
55      summary: Summary | null
56      upstream: Upstream | null
57      reconcile: Reconcile | null
58      push: PushState | null
59    }
60  }
61}
62