SLOPSHOPPER

gitscope

KOZMOS Gitscope: a git radar sidebar. Branch, upstream and ahead/behind, staged/changed/new/conflict counts and stashes, the last 8 commits on a graph rail…

newpaneguardcommandprocesstimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/gitscope
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gitscope
│ ┃ KOZMOS · Gitscope ✕ › fix the failing auth test and add an audit log call │ ┃ ⎇ local only │ ┃ ✓ working tree clean ✦ ✧ ⋆ ⏺ Read(src/auth.ts) │ ┃ HEAD a1b2c3d fix: refresh expired tokens (2… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ── COMMITS 2 ──────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ ┬◉ a1b2c3d 56y ⏺ Bash(bun test) │ ┃ └● 9f8e7d6 56y ⎿ 3 pass, 1 fail │ ┃ │ ┃ ── CHANGES 2 files · +14 −3 ───────────────… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✎ src/auth.ts +11 −3██████████████ │ ┃ · src/auth.test.ts +3 −0 ███ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ✎ touched this session: 1 changed, 2 clean … › /gitscope │ ⎿ gitscope: Gitscope open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Gitscope
⎇ local only ✓ working tree clean ✦ ✧ ⋆ HEAD a1b2c3d fix: refresh expired tokens (2 minutes ago) 9f8… ── COMMITS 2 ────────────────────────────────────────── ┬◉ a1b2c3d 56y └● 9f8e7d6 56y ── CHANGES 2 files · +14 −3 ─────────────────────────── ✎ src/auth.ts +11 −3██████████████ · src/auth.test.ts +3 −0 ███ ✎ touched this session: 1 changed, 2 clean or outside
README

KOZMOS Gitscope

A git radar in a sidebar pane. /gitscope toggles it.

  • Header: branch (or detached HEAD), upstream, ahead/behind or "in sync", and chips for staged, changed, new, conflicted files and stashes.
  • Commits: the last 8 on a graph rail: sha, subject, age, author; newer dots glow violet, older ones fade.
  • Changes: every file from git diff --numstat HEAD plus untracked files, with a green/red +/− histogram scaled to the busiest file.
  • Touched this session: files the session edited (Edit, Write, NotebookEdit, in the main loop or any agent) get a ✎ marker and sort first.
  • Clean tree: a small celebration. Not a repo: a friendly empty state.

Git runs only while the pane is open: every 5 s, and right after a Bash, PowerShell, Edit or Write call finishes. Terminal: colored rows with eighth-block bars. Desktop: SVG cards that follow the light and dark theme.

Settings: autoOpen (default false) opens the pane when a session starts.

Türkçe

Kenar panelinde bir git radarı. /gitscope açıp kapatır.

  • Başlık: dal (veya bağımsız HEAD), upstream, ileri/geri sayısı ya da "senkron", ve hazırlanmış, değişmiş, yeni, çakışmalı dosyalarla stash sayıları.
  • Commitler: son 8 commit bir grafik rayı üzerinde: sha, başlık, yaş, yazar; yeni noktalar mor parlar, eskiler solar.
  • Değişiklikler: git diff --numstat HEAD ile gelen her dosya ve izlenmeyen dosyalar, en yoğun dosyaya göre ölçeklenen yeşil/kırmızı +/− çubuklarıyla.
  • Bu oturumda dokunulanlar: oturumun düzenlediği dosyalar (Edit, Write, NotebookEdit; ana döngü ya da herhangi bir ajan) ✎ işareti alır ve en üste sıralanır.
  • Temiz ağaç: küçük bir kutlama. Depo değilse: dostça bir boş durum.

Git yalnızca panel açıkken çalışır: 5 saniyede bir ve bir Bash, PowerShell, Edit ya da Write çağrısı bittiğinde hemen. Terminal: renkli satırlar ve sekizlik blok çubuklar. Masaüstü: açık ve koyu temaya uyan SVG kartlar.

Ayar: autoOpen (varsayılan false) oturum başlarken paneli açar.

Source 5 files
hooks/register.tsx 506 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitscopeFile, GitscopeSnap } from '../types'
5import { KZ, MONO, clip, fitText, mix, pxOf, svg, svgText, xml } from './lib/kz.ts'
6import { GIT_HEAD, GIT_NUMSTAT, GIT_STATUS, gitLogArgv, parseGitLog, parseGitStatus, parseNumstat } from './lib/probe.ts'
7import { diffBar, fmtAgo, isAbsolute, normPath, relTo, splitPath, untrackedOf } from './scope.ts'
8
9const PANE = 'kz-gitscope'
10const TITLE = 'KOZMOS · Gitscope'
11const snapAtom = atom({ plugin: 'gitscope', key: 'snap' } as const, null)
12const REFRESH_MS = 5000
13const MAX_FILES = 40
14const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
15const DIRTYING_TOOLS = new Set(['Bash', 'PowerShell', 'Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
16
17// ---------------------------------------------------------------------------
18// Module state: the files this session edited, and the refresh bookkeeping.
19
20let touched = new Set<string>() // normalized absolute paths
21let lastAt = -Infinity
22let isDirty = true
23let isBusy = false
24let lastPublished = ''
25
26async function isOpen($: EngineInterface): Promise<boolean> {
27  return (await $.ui.panes()).some(p => p.id === PANE)
28}
29
30async function toggle($: EngineInterface): Promise<boolean> {
31  if (await isOpen($)) {
32    await $.ui.close({ id: PANE })
33    return false
34  }
35  await $.ui.open({ id: PANE, title: TITLE })
36  await refresh($).catch(() => undefined)
37  return true
38}
39
40// ---------------------------------------------------------------------------
41// Probes: git through $.process.run, parsed by lib/probe.ts and scope.ts.
42
43async function sh($: EngineInterface, argv: readonly string[], timeoutMs: number, cwd?: string): Promise<string | undefined> {
44  try {
45    const r = await $.process.run(argv, cwd ? { timeoutMs, cwd } : { timeoutMs })
46    return r.exitCode === 0 ? r.stdout : undefined
47  } catch {
48    return undefined
49  }
50}
51
52const notRepo = (now: number): GitscopeSnap => ({
53  isRepo: false, root: '', branch: '', isDetached: false, ahead: 0, behind: 0, staged: 0, unstaged: 0,
54  untracked: 0, conflicts: 0, stash: 0, commits: [], files: [], touchedClean: 0, refreshedAt: now,
55})
56
57async function readScope($: EngineInterface, cwd: string, now: number): Promise<GitscopeSnap> {
58  const status = await sh($, GIT_STATUS, 5000, cwd)
59  if (status === undefined) return notRepo(now)
60  const [top, head, log, numstat] = await Promise.all([
61    sh($, ['git', 'rev-parse', '--show-toplevel'], 3000, cwd),
62    sh($, GIT_HEAD, 3000, cwd),
63    sh($, gitLogArgv(8), 4000, cwd),
64    sh($, GIT_NUMSTAT, 5000, cwd),
65  ])
66  const g = parseGitStatus(status, head ?? '')
67  const root = (top ?? cwd).trim()
68  const isWin = /^[a-zA-Z]:\//.test(normPath(root))
69  const key = (p: string) => (isWin ? p.toLowerCase() : p)
70
71  const touchedRel = new Set<string>()
72  let outside = 0
73  for (const abs of touched) {
74    const rel = relTo(root, abs)
75    if (rel === undefined || rel === '') outside++
76    else touchedRel.add(key(rel))
77  }
78
79  const files: GitscopeFile[] = parseNumstat(numstat ?? '').map(f => ({
80    path: f.path, added: f.added, removed: f.removed, kind: 'mod' as const, isTouched: touchedRel.has(key(f.path)),
81  }))
82  for (const p of untrackedOf(status)) {
83    files.push({ path: p, added: 0, removed: 0, kind: 'new', isTouched: touchedRel.has(key(p)) })
84  }
85  const shown = new Set(files.map(f => key(f.path)))
86  const touchedClean = outside + [...touchedRel].filter(p => !shown.has(p)).length
87  // Touched first, then by churn.
88  files.sort((a, b) => Number(b.isTouched) - Number(a.isTouched) || b.added + b.removed - (a.added + a.removed) || a.path.localeCompare(b.path))
89
90  return {
91    isRepo: true,
92    root,
93    branch: g.branch,
94    isDetached: g.isDetached,
95    upstream: g.upstream,
96    ahead: g.ahead,
97    behind: g.behind,
98    staged: g.staged,
99    unstaged: g.unstaged,
100    untracked: g.untracked,
101    conflicts: g.conflicts,
102    stash: g.stash,
103    head: g.head ? { sha: g.head.sha, subject: g.head.subject } : undefined,
104    commits: parseGitLog(log ?? '').map(c => ({ sha: c.sha, subject: c.subject, author: c.author, at: c.at })),
105    files: files.slice(0, MAX_FILES),
106    touchedClean,
107    refreshedAt: now,
108  }
109}
110
111async function refresh($: EngineInterface): Promise<void> {
112  if (isBusy) {
113    isDirty = true
114    return
115  }
116  isBusy = true
117  try {
118    const now = await $.clock.now()
119    isDirty = false
120    lastAt = now
121    const snap = await readScope($, await $.session.cwd(), now)
122    const key = JSON.stringify({ ...snap, refreshedAt: 0 })
123    if (key !== lastPublished) {
124      lastPublished = key
125      await update($, snapAtom, () => snap)
126    }
127  } finally {
128    isBusy = false
129  }
130}
131
132async function tick($: EngineInterface): Promise<void> {
133  if (!(await isOpen($))) return
134  const now = await $.clock.now()
135  if (isDirty || now - lastAt >= REFRESH_MS) await refresh($)
136}
137
138async function noteTool($: EngineInterface, tool: string, input: Record<string, unknown>): Promise<void> {
139  if (EDIT_TOOLS.has(tool)) {
140    const p = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
141    if (p) touched.add(normPath(isAbsolute(p) ? p : `${await $.session.cwd()}/${p}`))
142  }
143  if (DIRTYING_TOOLS.has(tool)) {
144    isDirty = true
145    if (await isOpen($)) await refresh($)
146  }
147}
148
149// ---------------------------------------------------------------------------
150
151export const register: Register = (on, options) => {
152  on('session.start', async ($, e, next) => {
153    const started = await next(e)
154    touched = new Set()
155    lastAt = -Infinity
156    isDirty = true
157    isBusy = false
158    lastPublished = ''
159    await $.command.register({ name: 'gitscope', description: 'KOZMOS: toggle the Gitscope git radar sidebar', immediate: true })
160    $.clock.every(1000, () => void tick($).catch(() => undefined))
161    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
162    return started
163  })
164
165  on('command.run', { command: 'gitscope' }, async $ => ({
166    text: (await toggle($)) ? 'Gitscope open.' : 'Gitscope closed.',
167  }))
168
169  on('tool.call', async ($, e, next) => {
170    const ran = await next(e)
171    if (ran.deny === undefined) await noteTool($, String(e.tool), e as unknown as Record<string, unknown>).catch(() => undefined)
172    return ran
173  }).catch(($, e, next) => next(e))
174
175  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176    const snap = await read($, snapAtom)
177    const now = await $.clock.now()
178    const ui = $.ui.resolve(e)
179    const { Box, Text } = ui
180    const cols = Math.max(28, e.props.bodyColumns || 40)
181
182    if ('Svg' in ui && e.surface !== 'terminal') {
183      const { Svg } = ui
184      const W = pxOf(cols, 44)
185      const { source, height } = snap === null ? loadingCard(W) : snap.isRepo ? desktopCard(snap, W, now) : emptyCard(W)
186      return (
187        <Box flexDirection="column">
188          <Svg source={source} alt={altText(snap)} width={W} height={height} />
189        </Box>
190      )
191    }
192
193    if (snap === null) return <Text dimColor>⎇ reading git…</Text>
194    if (!snap.isRepo) {
195      return (
196        <Box flexDirection="column" paddingY={1} alignItems="center">
197          <Text color={KZ.mist}>╭─────╮</Text>
198          <Text color={KZ.mist}>│  ⎇  │</Text>
199          <Text color={KZ.mist}>╰─────╯</Text>
200          <Text bold>Not a git repository</Text>
201          <Text dimColor wrap="wrap">This folder has no history yet.</Text>
202          <Text dimColor wrap="wrap">Run <Text color={KZ.cyan}>git init</Text> to start the radar.</Text>
203        </Box>
204      )
205    }
206
207    // A section rule: its label, a count, and a line to the edge.
208    const rule = (label: string, extra: string) => {
209      const text = `── ${label} ${extra} `
210      return (
211        <Box key={`rule-${label}`} marginTop={1}>
212          <Text wrap="truncate-end">
213            <Text bold color={KZ.violet}>{`── ${label}`}</Text>
214            <Text dimColor> {extra}</Text>
215            <Text color={KZ.mist} dimColor> {'─'.repeat(Math.max(0, cols - text.length - 1))}</Text>
216          </Text>
217        </Box>
218      )
219    }
220
221    const sync = snap.upstream
222      ? snap.ahead || snap.behind
223        ? [
224            snap.ahead ? <Text key="a" color={KZ.green}>↑{snap.ahead} </Text> : '',
225            snap.behind ? <Text key="b" color={KZ.amber}>↓{snap.behind}</Text> : '',
226          ]
227        : <Text color={KZ.teal}>≡ in sync</Text>
228      : <Text dimColor>local only</Text>
229
230    const chips: [string, number, string, string][] = [
231      ['●', snap.staged, 'staged', KZ.green],
232      ['✚', snap.unstaged, 'changed', KZ.amber],
233      ['?', snap.untracked, 'new', KZ.blue],
234      ['✖', snap.conflicts, 'conflict', KZ.red],
235      ['⚑', snap.stash, 'stash', KZ.violet],
236    ]
237    const isClean = snap.staged + snap.unstaged + snap.untracked + snap.conflicts === 0
238
239    const railW = 2
240    const shaW = 8
241    const commitRows = snap.commits.map((c, i) => {
242      const last = i === snap.commits.length - 1
243      const rail = snap.commits.length === 1 ? '◉' : i === 0 ? '┬' : last ? '└' : '├'
244      const age = now - c.at
245      const tone = mix(KZ.violet, KZ.mist, Math.min(1, age / (7 * 864e5)))
246      const right = `${fmtAgo(age)} ${clip(c.author.split(' ')[0]!, 10)}`
247      return (
248        <Box key={`c-${c.sha}`} flexDirection="row">
249          <Box width={railW} flexShrink={0}>
250            <Text color={tone}>{rail}{i === 0 ? '◉' : '●'}</Text>
251          </Box>
252          <Box width={shaW} flexShrink={0}>
253            <Text color={i === 0 ? KZ.yellow : KZ.amber} dimColor={i > 0}> {c.sha.slice(0, 7)}</Text>
254          </Box>
255          <Box flexGrow={1} flexShrink={1}>
256            <Text wrap="truncate-end" bold={i === 0}> {c.subject}</Text>
257          </Box>
258          <Box flexShrink={0} marginLeft={1}>
259            <Text dimColor>{right}</Text>
260          </Box>
261        </Box>
262      )
263    })
264
265    const maxChurn = Math.max(1, ...snap.files.map(f => f.added + f.removed))
266    const barW = Math.max(4, Math.min(14, Math.floor(cols / 4)))
267    const numW = Math.max(...snap.files.map(f => (f.kind === 'new' ? 3 : `+${f.added} −${f.removed}`.length)), 3)
268    const fileRows = snap.files.map(f => {
269      const { dir, name } = splitPath(f.path)
270      const b = diffBar(f.added, f.removed, maxChurn, barW)
271      return (
272        <Box key={`f-${f.path}`} flexDirection="row">
273          <Box width={2} flexShrink={0}>
274            <Text color={f.isTouched ? KZ.magenta : KZ.mist}>{f.isTouched ? '✎' : f.kind === 'new' ? '+' : '·'}</Text>
275          </Box>
276          <Box flexGrow={1} flexShrink={1}>
277            <Text wrap="truncate-start"><Text dimColor>{dir}</Text><Text bold={f.isTouched} color={f.kind === 'new' ? KZ.blue : undefined}>{name}</Text></Text>
278          </Box>
279          <Box flexShrink={0} marginLeft={1} width={numW + 1}>
280            {f.kind === 'new'
281              ? <Text color={KZ.blue}>new</Text>
282              : <Text><Text color={KZ.green}>+{f.added}</Text> <Text color={KZ.red}>−{f.removed}</Text></Text>}
283          </Box>
284          <Box flexShrink={0} width={barW}>
285            <Text><Text color={KZ.green}>{b.add}</Text><Text color={KZ.red}>{b.rem}</Text></Text>
286          </Box>
287        </Box>
288      )
289    })
290    const totals = snap.files.reduce((t, f) => ({ a: t.a + f.added, r: t.r + f.removed }), { a: 0, r: 0 })
291    const touchedCount = snap.files.filter(f => f.isTouched).length
292
293    return (
294      <Box flexDirection="column">
295        <Box flexDirection="row" justifyContent="space-between">
296          <Text wrap="truncate-end">
297            <Text bold color={KZ.cyan}>⎇ {snap.isDetached ? `(detached) ${snap.branch}` : snap.branch}</Text>
298            {snap.upstream ? <Text dimColor> → {snap.upstream}</Text> : ''}
299          </Text>
300          <Box flexShrink={0} marginLeft={1}><Text>{sync}</Text></Box>
301        </Box>
302        {isClean
303          ? (
304              <Box flexDirection="row">
305                <Text bold color={KZ.green}>✓ working tree clean </Text>
306                <Text color={KZ.yellow}>✦</Text><Text color={KZ.magenta}> ✧</Text><Text color={KZ.cyan}> ⋆</Text>
307                {snap.stash ? <Text color={KZ.violet}>  ⚑{snap.stash} stash</Text> : ''}
308              </Box>
309            )
310          : (
311              <Text wrap="truncate-end">
312                {chips.filter(c => c[1] > 0).map(([g, n, label, c]) => (
313                  <Text key={label}><Text bold color={c}>{g}{n}</Text><Text dimColor> {label}  </Text></Text>
314                ))}
315              </Text>
316            )}
317        {snap.head ? <Text wrap="truncate-end" dimColor>HEAD <Text color={KZ.yellow}>{snap.head.sha}</Text> {snap.head.subject}</Text> : <Text dimColor>no commits yet</Text>}
318        {snap.commits.length > 0 && rule('COMMITS', `${snap.commits.length}`)}
319        {commitRows}
320        {snap.files.length > 0 && rule('CHANGES', `${snap.files.length} files · +${totals.a} −${totals.r}`)}
321        {fileRows}
322        {(touchedCount > 0 || snap.touchedClean > 0) && (
323          <Box marginTop={1}>
324            <Text wrap="truncate-end">
325              <Text color={KZ.magenta}>✎</Text>
326              <Text dimColor> touched this session: {touchedCount} changed{snap.touchedClean ? `, ${snap.touchedClean} clean or outside` : ''}</Text>
327            </Text>
328          </Box>
329        )}
330      </Box>
331    )
332  })
333}
334
335// ---------------------------------------------------------------------------
336// Desktop: one SVG card stack.
337
338function altText(s: GitscopeSnap | null): string {
339  if (s === null) return 'Reading git'
340  if (!s.isRepo) return 'Not a git repository'
341  const dirty = s.staged + s.unstaged + s.untracked + s.conflicts
342  return `Branch ${s.branch}${s.upstream ? ` tracking ${s.upstream}, ${s.ahead} ahead, ${s.behind} behind` : ''}; ${dirty ? `${s.staged} staged, ${s.unstaged} changed, ${s.untracked} new, ${s.conflicts} conflicts` : 'working tree clean'}; ${s.commits.length} recent commits; ${s.files.length} changed files`
343}
344
345const CSS = `
346.draw{stroke-dasharray:40;stroke-dashoffset:40;animation:kzdraw .9s ease-out .1s forwards}@keyframes kzdraw{to{stroke-dashoffset:0}}
347.tw{transform-box:fill-box;transform-origin:center;animation:kztw 2.4s ease-in-out infinite}@keyframes kztw{0%,100%{opacity:.25;transform:scale(.6)}50%{opacity:1;transform:scale(1)}}
348.ring{transform-box:fill-box;transform-origin:center;animation:kzring 2.2s ease-out infinite}@keyframes kzring{0%{opacity:.7;transform:scale(1)}100%{opacity:0;transform:scale(2.4)}}
349.rowin{animation:kzin .5s ease-out both}@keyframes kzin{from{opacity:0;transform:translateX(-4px)}}
350`
351
352function loadingCard(W: number): { source: string; height: number } {
353  const body = `<rect class="p" x="0" y="0" width="${W}" height="44" rx="12"/>` +
354    `<circle cx="22" cy="22" r="6" fill="${KZ.cyan}" class="pulse"/>` + svgText(36, 26, 'Reading git…', { cls: 's', size: 12 })
355  return { source: svg(W, 44, body, CSS), height: 44 }
356}
357
358function emptyCard(W: number): { source: string; height: number } {
359  const H = 150
360  const cx = W / 2
361  const body = `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="14"/>` +
362    `<circle cx="${cx}" cy="46" r="24" fill="none" stroke="${KZ.mist}" stroke-width="1.5" stroke-dasharray="4 5" class="spin" style="animation-duration:14s"/>` +
363    `<path d="M${cx - 5} 34v24M${cx - 5} 46c10 0 12-6 12-10" stroke="${KZ.mist}" stroke-width="2" fill="none" stroke-linecap="round"/><circle cx="${cx + 7}" cy="34" r="3" fill="${KZ.mist}"/>` +
364    svgText(cx, 96, 'Not a git repository', { size: 14, weight: 650, anchor: 'middle' }) +
365    svgText(cx, 116, 'This folder has no history yet.', { cls: 's', size: 11.5, anchor: 'middle' }) +
366    svgText(cx, 134, 'Run git init to start the radar.', { cls: 'm', size: 11, anchor: 'middle', mono: true })
367  return { source: svg(W, H, body, CSS), height: H }
368}
369
370function desktopCard(s: GitscopeSnap, W: number, now: number): { source: string; height: number } {
371  const parts: string[] = []
372  const pad = 14
373  const inner = W - pad * 2
374  let y = 0
375  const isClean = s.staged + s.unstaged + s.untracked + s.conflicts === 0
376
377  // Header: branch, upstream, ahead/behind, and the tree's chips.
378  const hh = 82
379  parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="${hh}" rx="14"/>`)
380  parts.push(`<path d="M${pad + 4} ${y + 14}v22M${pad + 4} ${y + 25}c9 0 11-6 11-10" stroke="${KZ.cyan}" stroke-width="2.2" fill="none" stroke-linecap="round"/><circle cx="${pad + 15}" cy="${y + 14}" r="3.4" fill="${KZ.cyan}"/><circle cx="${pad + 4}" cy="${y + 36}" r="3.4" fill="${KZ.cyan}"/>`)
381  parts.push(svgText(pad + 28, y + 24, fitText(s.isDetached ? `detached @ ${s.branch}` : s.branch, 15, inner - 130), { size: 15, weight: 700 }))
382  parts.push(svgText(pad + 28, y + 41, fitText(s.upstream ? `→ ${s.upstream}` : 'no upstream · local only', 11, inner - 130), { cls: 'm', size: 11 }))
383  // Ahead/behind pills on the right.
384  let rx = W - pad
385  const pill = (txt: string, c: string) => {
386    const pw = txt.length * 7 + 16
387    rx -= pw
388    parts.push(`<rect x="${rx}" y="${y + 12}" width="${pw}" height="20" rx="10" fill="${c}" opacity=".16"/>`)
389    parts.push(svgText(rx + pw / 2, y + 26, txt, { size: 11, weight: 700, anchor: 'middle', fill: c }))
390    rx -= 6
391  }
392  if (s.upstream) {
393    if (s.behind) pill(`↓${s.behind}`, KZ.amber)
394    if (s.ahead) pill(`↑${s.ahead}`, KZ.green)
395    if (!s.ahead && !s.behind) pill('≡ in sync', KZ.teal)
396  }
397  // Chips row.
398  const chips: [string, number, string][] = [
399    ['staged', s.staged, KZ.green], ['changed', s.unstaged, KZ.amber], ['new', s.untracked, KZ.blue],
400    ['conflict', s.conflicts, KZ.red], ['stash', s.stash, KZ.violet],
401  ]
402  let cx = pad
403  for (const [label, n, c] of chips) {
404    if (!n) continue
405    const txt = `${n} ${label}`
406    const cw = txt.length * 6.4 + 18
407    if (cx + cw > W - pad) break
408    parts.push(`<rect x="${cx}" y="${y + 52}" width="${cw}" height="20" rx="10" fill="${c}" opacity=".16"/>`)
409    parts.push(`<circle cx="${cx + 9}" cy="${y + 62}" r="3" fill="${c}"/>`)
410    parts.push(svgText(cx + 15, y + 66, txt, { size: 10.5, weight: 650, fill: c }))
411    cx += cw + 6
412  }
413  if (isClean && !s.stash) parts.push(svgText(pad, y + 66, s.head ? `HEAD ${s.head.sha} · ${s.head.subject}` : 'no commits yet', { cls: 'm', size: 10.5, mono: true }))
414  y += hh + 8
415
416  // Clean: a celebration.
417  if (isClean) {
418    const ch = 70
419    parts.push(`<defs><linearGradient id="gsClean" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${KZ.green}" stop-opacity=".22"/><stop offset="1" stop-color="${KZ.teal}" stop-opacity=".06"/></linearGradient></defs>`)
420    parts.push(`<rect x="0" y="${y}" width="${W}" height="${ch}" rx="14" fill="url(#gsClean)"/>`)
421    parts.push(`<circle cx="${pad + 22}" cy="${y + ch / 2}" r="18" fill="${KZ.green}" opacity=".2"/>`)
422    parts.push(`<path class="draw" d="M${pad + 13} ${y + ch / 2}l6 7 13-15" stroke="${KZ.green}" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
423    parts.push(svgText(pad + 52, y + ch / 2 - 2, 'Working tree clean', { size: 14.5, weight: 700, fill: KZ.green }))
424    parts.push(svgText(pad + 52, y + ch / 2 + 15, s.upstream && !s.ahead && !s.behind ? 'Everything committed and pushed.' : 'Everything is committed.', { cls: 's', size: 11 }))
425    const stars: [number, number, string, number][] = [[W - 46, 18, KZ.yellow, 0], [W - 26, 34, KZ.magenta, 0.6], [W - 58, 48, KZ.cyan, 1.2], [W - 34, 56, KZ.green, 1.8]]
426    for (const [sx, sy, c, d] of stars) {
427      parts.push(`<path class="tw" style="animation-delay:${d}s" d="M${sx} ${y + sy - 5}L${sx + 1.4} ${y + sy - 1.4}L${sx + 5} ${y + sy}L${sx + 1.4} ${y + sy + 1.4}L${sx} ${y + sy + 5}L${sx - 1.4} ${y + sy + 1.4}L${sx - 5} ${y + sy}L${sx - 1.4} ${y + sy - 1.4}Z" fill="${c}"/>`)
428    }
429    y += ch + 8
430  }
431
432  // Commits: a rail of dots.
433  if (s.commits.length) {
434    const rowH = 34
435    const ch = 30 + s.commits.length * rowH
436    parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="${ch}" rx="14"/>`)
437    parts.push(svgText(pad, y + 20, 'COMMITS', { cls: 's', size: 10.5, weight: 700 }))
438    const railX = pad + 7
439    const y0 = y + 30 + rowH / 2 - 4
440    const y1 = y0 + (s.commits.length - 1) * rowH
441    parts.push(`<defs><linearGradient id="gsRail" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${KZ.violet}"/><stop offset="1" stop-color="${KZ.violet}" stop-opacity=".15"/></linearGradient></defs>`)
442    if (s.commits.length > 1) parts.push(`<rect x="${railX - 1}" y="${y0}" width="2" height="${y1 - y0}" rx="1" fill="url(#gsRail)"/>`)
443    s.commits.forEach((c, i) => {
444      const cy = y0 + i * rowH
445      const age = now - c.at
446      const tone = mix(KZ.violet, KZ.mist, Math.min(1, age / (7 * 864e5)))
447      if (i === 0) parts.push(`<circle class="ring" cx="${railX}" cy="${cy}" r="6" fill="none" stroke="${KZ.violet}" stroke-width="1.5"/>`)
448      parts.push(`<circle cx="${railX}" cy="${cy}" r="${i === 0 ? 5.5 : 4}" fill="${tone}"/>`)
449      const tx = railX + 16
450      const right = fmtAgo(age)
451      parts.push(`<g class="rowin" style="animation-delay:${i * 40}ms">`)
452      parts.push(svgText(tx, cy - 2, fitText(c.subject, 12, inner - 70), { size: 12, weight: i === 0 ? 650 : 450 }))
453      parts.push(svgText(tx, cy + 12, `${c.sha.slice(0, 7)} · ${fitText(c.author, 10, inner / 2)}`, { cls: 'm', size: 10, mono: true }))
454      parts.push(svgText(W - pad, cy + 3, right, { cls: 's', size: 10.5, weight: 600, anchor: 'end' }))
455      parts.push('</g>')
456    })
457    y += ch + 8
458  }
459
460  // Changes: a +/− histogram per file.
461  if (s.files.length) {
462    const rowH = 24
463    const ch = 34 + s.files.length * rowH + 6
464    const totals = s.files.reduce((t, f) => ({ a: t.a + f.added, r: t.r + f.removed }), { a: 0, r: 0 })
465    parts.push(`<rect class="p" x="0" y="${y}" width="${W}" height="${ch}" rx="14"/>`)
466    parts.push(svgText(pad, y + 20, 'CHANGES', { cls: 's', size: 10.5, weight: 700 }))
467    parts.push(svgText(pad + 62, y + 20, `${s.files.length} files`, { cls: 'm', size: 10.5 }))
468    parts.push(`<text x="${W - pad}" y="${y + 20}" font-size="11" font-weight="700" text-anchor="end" font-family="${MONO}"><tspan fill="${KZ.green}">+${totals.a}</tspan><tspan fill="${KZ.red}"> −${totals.r}</tspan></text>`)
469    const maxChurn = Math.max(1, ...s.files.map(f => f.added + f.removed))
470    const barW = Math.max(40, Math.min(120, inner * 0.28))
471    const numW = 64
472    const nameW = inner - 14 - barW - numW - 8
473    s.files.forEach((f, i) => {
474      const ry = y + 34 + i * rowH
475      const mid = ry + rowH / 2
476      if (f.isTouched) parts.push(`<circle class="pulse" cx="${pad + 4}" cy="${mid}" r="3.5" fill="${KZ.magenta}"/>`)
477      else parts.push(`<circle cx="${pad + 4}" cy="${mid}" r="2" fill="${f.kind === 'new' ? KZ.blue : KZ.mist}" opacity=".7"/>`)
478      const { dir, name } = splitPath(f.path)
479      const nameFit = fitText(name, 11.5, nameW)
480      const nameWpx = Math.min(nameW, nameFit.length * 6.6)
481      const dirFit = dir && nameWpx < nameW - 30 ? fitText(dir, 10.5, nameW - nameWpx - 4) : ''
482      parts.push(`<text x="${pad + 14}" y="${mid + 4}" font-size="11.5" font-family="${MONO}"><tspan class="m" font-size="10.5">${xml(dirFit)}</tspan><tspan ${f.kind === 'new' ? `fill="${KZ.blue}"` : 'class="t"'} font-weight="${f.isTouched ? 700 : 500}">${xml(nameFit)}</tspan></text>`)
483      const nx = W - pad - barW - 8
484      if (f.kind === 'new') parts.push(svgText(nx, mid + 4, 'new', { size: 10.5, weight: 650, anchor: 'end', fill: KZ.blue }))
485      else parts.push(`<text x="${nx}" y="${mid + 4}" font-size="10.5" font-weight="650" text-anchor="end" font-family="${MONO}"><tspan fill="${KZ.green}">+${f.added}</tspan><tspan fill="${KZ.red}"> −${f.removed}</tspan></text>`)
486      const bx = W - pad - barW
487      parts.push(`<rect class="k" x="${bx}" y="${mid - 4}" width="${barW}" height="8" rx="4"/>`)
488      const aw = f.added ? Math.max(2, (f.added / maxChurn) * barW) : 0
489      const rw = f.removed ? Math.max(2, (f.removed / maxChurn) * barW) : 0
490      if (aw) parts.push(`<rect x="${bx}" y="${mid - 4}" width="${aw}" height="8" rx="2" fill="${KZ.green}"/>`)
491      if (rw) parts.push(`<rect x="${bx + aw}" y="${mid - 4}" width="${rw}" height="8" rx="2" fill="${KZ.red}"/>`)
492      if (f.kind === 'new') parts.push(`<rect x="${bx}" y="${mid - 4}" width="${barW}" height="8" rx="4" fill="${KZ.blue}" opacity=".35"/>`)
493    })
494    y += ch + 8
495  }
496
497  const touchedCount = s.files.filter(f => f.isTouched).length
498  if (touchedCount || s.touchedClean) {
499    parts.push(`<circle cx="${pad + 4}" cy="${y + 8}" r="3.5" fill="${KZ.magenta}"/>`)
500    parts.push(svgText(pad + 14, y + 12, `touched this session · ${touchedCount} changed${s.touchedClean ? ` · ${s.touchedClean} clean or outside` : ''}`, { cls: 'm', size: 10.5 }))
501    y += 20
502  }
503
504  return { source: svg(W, y, parts.join(''), CSS), height: y }
505}
506
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/lib/probe.ts 203 lines
1// GENERATED by scripts/sync-shared.mjs from shared/probe.ts. Do not edit here.
2// KOZMOS shared probes: git and the machine, as argv lists and pure parsers.
3// Source of truth: shared/probe.ts; synced into each mod as hooks/lib/probe.ts.
4//
5// The engine only lets `$` flow into functions declared in the hooks file
6// itself, never across an import. So this file never touches `$`: a mod runs
7// the argv with `$.process.run` (or reads the path with `$.fs.read`) in a
8// top-level function of its own and hands the text to a parser here:
9//
10//   async function readGit($: EngineInterface, cwd: string): Promise<GitSnap> {
11//     const st = await $.process.run(GIT_STATUS, { cwd, timeoutMs: 5000 }).catch(() => undefined)
12//     if (!st || st.exitCode !== 0) return NO_GIT
13//     const head = await $.process.run(GIT_HEAD, { cwd, timeoutMs: 3000 }).catch(() => undefined)
14//     return parseGitStatus(st.stdout, head?.exitCode === 0 ? head.stdout : '')
15//   }
16
17export type Platform = 'win' | 'mac' | 'linux'
18
19/** From `$.env.get('OS')` and, off Windows, `uname -s`. */
20export function platformFrom(osEnv: string | undefined, uname: string | undefined): Platform {
21  if (/windows/i.test(osEnv ?? '')) return 'win'
22  return /darwin/i.test(uname ?? '') ? 'mac' : 'linux'
23}
24
25// ---------------------------------------------------------------------------
26// Git.
27
28export const GIT_STATUS = ['git', 'status', '--porcelain=v2', '--branch', '--show-stash'] as const
29export const GIT_HEAD = ['git', 'log', '-1', '--format=%h%x09%s%x09%cr'] as const
30export const gitLogArgv = (n: number): string[] => ['git', 'log', `-${n}`, '--format=%h%x09%s%x09%cr%x09%an%x09%ct']
31export const GIT_NUMSTAT = ['git', 'diff', '--numstat', 'HEAD'] as const
32
33export type GitSnap = {
34  isRepo: boolean
35  branch: string
36  isDetached: boolean
37  upstream?: string
38  ahead: number
39  behind: number
40  staged: number
41  unstaged: number
42  untracked: number
43  conflicts: number
44  head?: { sha: string; subject: string; when: string }
45  stash: number
46}
47
48export const NO_GIT: GitSnap = { isRepo: false, branch: '', isDetached: false, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0, conflicts: 0, stash: 0 }
49
50/** Parses `GIT_STATUS` output, and `GIT_HEAD` output when given. */
51export function parseGitStatus(status: string, headLine = ''): GitSnap {
52  const snap: GitSnap = { ...NO_GIT, isRepo: true }
53  for (const line of status.split('\n')) {
54    if (line.startsWith('# branch.head ')) {
55      const b = line.slice(14).trim()
56      snap.isDetached = b === '(detached)'
57      snap.branch = b
58    } else if (line.startsWith('# branch.upstream ')) snap.upstream = line.slice(18).trim()
59    else if (line.startsWith('# branch.ab ')) {
60      const m = /\+(\d+) -(\d+)/.exec(line)
61      snap.ahead = Number(m?.[1] ?? 0)
62      snap.behind = Number(m?.[2] ?? 0)
63    } else if (line.startsWith('# stash ')) snap.stash = Number(line.slice(8)) || 0
64    else if (line.startsWith('1 ') || line.startsWith('2 ')) {
65      const xy = line.slice(2, 4)
66      if (xy[0] !== '.') snap.staged++
67      if (xy[1] !== '.') snap.unstaged++
68    } else if (line.startsWith('u ')) snap.conflicts++
69    else if (line.startsWith('? ')) snap.untracked++
70  }
71  const h = headLine.trim()
72  if (h) {
73    const [sha, subject = '', when = ''] = h.split('\t') as [string, ...string[]]
74    snap.head = { sha, subject, when }
75  }
76  if (snap.isDetached && snap.head) snap.branch = snap.head.sha
77  return snap
78}
79
80export type GitCommit = { sha: string; subject: string; when: string; author: string; at: number }
81
82/** Parses `gitLogArgv(n)` output. `at` is the commit time in ms. */
83export function parseGitLog(out: string): GitCommit[] {
84  return out
85    .split('\n')
86    .filter(Boolean)
87    .map(l => {
88      const [sha, subject = '', when = '', author = '', ct = '0'] = l.split('\t') as [string, ...string[]]
89      return { sha, subject, when, author, at: Number(ct) * 1000 }
90    })
91}
92
93export type GitFileStat = { path: string; added: number; removed: number }
94
95/** Parses `GIT_NUMSTAT` output (binary files count as 0/0). */
96export function parseNumstat(out: string): GitFileStat[] {
97  return out
98    .split('\n')
99    .filter(Boolean)
100    .map(l => {
101      const [a, d = '0', ...rest] = l.split('\t') as [string, ...string[]]
102      return { path: rest.join('\t'), added: Number(a) || 0, removed: Number(d) || 0 }
103    })
104}
105
106// ---------------------------------------------------------------------------
107// The machine.
108
109export type SysSnap = {
110  cpu?: number // percent, all cores
111  memUsed?: number // bytes
112  memTotal?: number // bytes
113  diskUsed?: number
114  diskTotal?: number
115  procs?: number
116  gpu?: GpuSnap
117}
118
119export type GpuSnap = { util: number; memUsed: number; memTotal: number; temp: number; name: string }
120
121/** Windows: one PowerShell call answering CPU, memory, C: and process count as JSON. */
122export const WIN_SYS = [
123  'powershell', '-NoProfile', '-NonInteractive', '-Command',
124  '$o=Get-CimInstance Win32_OperatingSystem;' +
125    '$c=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average;' +
126    "$d=Get-CimInstance Win32_LogicalDisk -Filter \"DeviceID='C:'\";" +
127    '$p=(Get-Process).Count;' +
128    '[pscustomobject]@{cpu=$c;free=$o.FreePhysicalMemory;total=$o.TotalVisibleMemorySize;dfree=$d.FreeSpace;dsize=$d.Size;procs=$p} | ConvertTo-Json -Compress',
129] as const
130
131export function parseWinSys(out: string): SysSnap {
132  const snap: SysSnap = {}
133  try {
134    const j = JSON.parse(out || '{}') as Record<string, number | null>
135    if (typeof j.cpu === 'number') snap.cpu = j.cpu
136    if (typeof j.total === 'number' && typeof j.free === 'number') {
137      snap.memTotal = j.total * 1024
138      snap.memUsed = (j.total - j.free) * 1024
139    }
140    if (typeof j.dsize === 'number' && typeof j.dfree === 'number') {
141      snap.diskTotal = j.dsize
142      snap.diskUsed = j.dsize - j.dfree
143    }
144    if (typeof j.procs === 'number') snap.procs = j.procs
145  } catch {
146    // Leave the fields out.
147  }
148  return snap
149}
150
151export type CpuTicks = { idle: number; total: number }
152
153/** Linux: `/proc/stat`'s first line, against the previous reading. */
154export function parseProcStat(stat: string, prev: CpuTicks | undefined): { cpu?: number; ticks: CpuTicks } {
155  const nums = stat.split('\n')[0]!.trim().split(/\s+/).slice(1).map(Number)
156  const idle = (nums[3] ?? 0) + (nums[4] ?? 0)
157  const total = nums.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
158  const ticks = { idle, total }
159  if (!prev || total <= prev.total) return { ticks }
160  return { cpu: 100 * (1 - (idle - prev.idle) / (total - prev.total)), ticks }
161}
162
163/** Linux: `/proc/meminfo`. */
164export function parseMeminfo(mem: string): Pick<SysSnap, 'memUsed' | 'memTotal'> {
165  const kb = (k: string) => Number(new RegExp(`${k}:\\s+(\\d+)`).exec(mem)?.[1] ?? 0) * 1024
166  const memTotal = kb('MemTotal')
167  return memTotal ? { memTotal, memUsed: memTotal - kb('MemAvailable') } : {}
168}
169
170/** macOS: `top -l 1 -n 0` and `sysctl -n hw.memsize`. */
171export const MAC_TOP = ['top', '-l', '1', '-n', '0'] as const
172export const MAC_MEMSIZE = ['sysctl', '-n', 'hw.memsize'] as const
173
174export function parseMacTop(top: string, memsize: string): SysSnap {
175  const snap: SysSnap = {}
176  const m = /CPU usage: ([\d.]+)% user, ([\d.]+)% sys/.exec(top)
177  if (m) snap.cpu = Number(m[1]) + Number(m[2])
178  const pm = /PhysMem: ([\d.]+)([GM]) used/.exec(top)
179  if (pm) snap.memUsed = Number(pm[1]) * (pm[2] === 'G' ? 1024 ** 3 : 1024 ** 2)
180  const total = Number(memsize.trim())
181  if (total) snap.memTotal = total
182  return snap
183}
184
185/** NVIDIA GPUs, any platform. */
186export const NVIDIA_SMI = ['nvidia-smi', '--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name', '--format=csv,noheader,nounits'] as const
187
188export function parseNvidia(out: string): GpuSnap | undefined {
189  const line = out.split('\n')[0]!
190  if (!line.trim()) return undefined
191  const [util, used = '0', total = '0', temp = '0', name = 'GPU'] = line.split(',').map(s => s.trim()) as [string, ...string[]]
192  return { util: Number(util), memUsed: Number(used) * 1024 ** 2, memTotal: Number(total) * 1024 ** 2, temp: Number(temp), name }
193}
194
195export function fmtBytes(n: number | undefined): string {
196  if (n === undefined || !Number.isFinite(n)) return '—'
197  // Each unit starts where the one below would round up to 1024 of itself.
198  if (n >= 1023.95 * 1024 ** 3) return `${(n / 1024 ** 4).toFixed(1)}T`
199  if (n >= 1023.5 * 1024 ** 2) return `${(n / 1024 ** 3).toFixed(1)}G`
200  if (n >= 1023.5 * 1024) return `${Math.round(n / 1024 ** 2)}M`
201  return `${Math.round(n / 1024)}K`
202}
203
hooks/scope.ts 78 lines
1// gitscope's own pure helpers: paths, untracked files, ages, diff bars.
2// No `$` here; register.tsx runs git and hands the text over.
3
4/** Forward slashes, msys `/d/x` → `d:/x`, no trailing slash; lowercased for Windows drive paths. */
5export function normPath(p: string): string {
6  let s = p.trim().replace(/\\/g, '/')
7  const msys = /^\/([a-zA-Z])\//.exec(s)
8  if (msys) s = `${msys[1]}:/${s.slice(3)}`
9  s = s.replace(/\/+$/, '')
10  if (/^[a-zA-Z]:\//.test(s)) s = s.toLowerCase()
11  return s
12}
13
14export function isAbsolute(p: string): boolean {
15  return /^([a-zA-Z]:)?[\\/]/.test(p)
16}
17
18/** `abs` relative to `root` (both normalized), or undefined when outside it. */
19export function relTo(root: string, abs: string): string | undefined {
20  const r = normPath(root)
21  const a = normPath(abs)
22  if (!r) return undefined
23  if (a === r) return ''
24  return a.startsWith(r + '/') ? a.slice(r.length + 1) : undefined
25}
26
27/** Untracked paths from `git status --porcelain=v2` output (`? path`). */
28export function untrackedOf(status: string): string[] {
29  return status
30    .split('\n')
31    .filter(l => l.startsWith('? '))
32    .map(l => l.slice(2).trim().replace(/^"(.*)"$/, '$1'))
33    .filter(Boolean)
34}
35
36/** `now`, `4m`, `3h`, `2d`, `5w`, `7mo`, `2y`: a commit's age. */
37export function fmtAgo(ms: number): string {
38  const s = Math.max(0, Math.round(ms / 1000))
39  if (s < 45) return 'now'
40  const m = Math.round(s / 60)
41  if (m < 60) return `${m}m`
42  const h = Math.round(m / 60)
43  if (h < 36) return `${h}h`
44  const d = Math.round(h / 24)
45  if (d < 14) return `${d}d`
46  if (d < 60) return `${Math.round(d / 7)}w`
47  if (d < 365) return `${Math.round(d / 30)}mo`
48  return `${Math.round(d / 365)}y`
49}
50
51const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
52
53/** A run of `cells` (fractional) width in eighth blocks, no padding. */
54export function blocks(cells: number): string {
55  const c = Math.max(0, cells)
56  const full = Math.floor(c)
57  const part = EIGHTHS[Math.round((c - full) * 8) % 8]! // an index of 0..7
58  return '█'.repeat(full) + part
59}
60
61/**
62 * The +/− histogram of one file: green cells for added lines, red for
63 * removed, scaled against the largest file; a non-zero side shows at least
64 * an eighth.
65 */
66export function diffBar(added: number, removed: number, max: number, width: number): { add: string; rem: string } {
67  const top = Math.max(1, max)
68  const a = added > 0 ? Math.max(0.125, (added / top) * width) : 0
69  const r = removed > 0 ? Math.max(0.125, (removed / top) * width) : 0
70  return { add: blocks(a), rem: blocks(r) }
71}
72
73/** Splits a repo path into its folder (with slash) and file name. */
74export function splitPath(p: string): { dir: string; name: string } {
75  const i = p.lastIndexOf('/')
76  return i < 0 ? { dir: '', name: p } : { dir: p.slice(0, i + 1), name: p.slice(i + 1) }
77}
78
types/index.d.ts 40 lines
1export type GitscopeCommit = { sha: string; subject: string; author: string; at: number }
2
3export type GitscopeFile = {
4  /** Repo-relative, forward slashes. */
5  path: string
6  added: number
7  removed: number
8  /** `new` for an untracked file (no numstat), else `mod`. */
9  kind: 'mod' | 'new'
10  /** The session edited it (Edit / Write / NotebookEdit, any agent). */
11  isTouched: boolean
12}
13
14export type GitscopeSnap = {
15  isRepo: boolean
16  root: string
17  branch: string
18  isDetached: boolean
19  upstream?: string
20  ahead: number
21  behind: number
22  staged: number
23  unstaged: number
24  untracked: number
25  conflicts: number
26  stash: number
27  head?: { sha: string; subject: string }
28  commits: GitscopeCommit[]
29  files: GitscopeFile[]
30  /** Touched files of this session that git shows no change for (or outside the repo). */
31  touchedClean: number
32  refreshedAt: number
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    gitscope: { snap: GitscopeSnap | null }
38  }
39}
40