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

Personal dotfiles managed with chezmoi, shared across personal and work machines.
# Install chezmoi and apply dotfiles
chezmoi init --apply <repo-url>
chezmoi add ~/.<file> # Track a new dotfile
chezmoi diff # Preview pending changes
chezmoi apply # Apply changes to home directory
Configuration management is assisted by Claude Code, which helps maintain and evolve dotfiles across machines.
hooks/register.tsx 840 lines1import { 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}
840hooks/film.ts 845 lines1import { 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}
845hooks/reconcile.ts 53 lines1import 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
53hooks/status.ts 87 lines1import 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(' · ')
87hooks/clawd.tsx 193 lines1import 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
193hooks/png.ts 207 lines1const 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}
207types/index.d.ts 62 lines1export 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