SLOPSHOPPER

questline

KOZMOS Questline: task progress above the prompt. One segmented bar for the session's todos and tasks (done green, running violet and pulsing, waiting gray)…

newbandguardcommandtimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/questline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · questline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /questline ⎿ questline: Questline hidden. /questline brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

KOZMOS Questline

Task progress above the prompt, from the main loop's TodoWrite lists and TaskCreate / TaskUpdate tasks.

  • One segmented bar, a segment per task: done green, running violet and pulsing, waiting gray.
  • 3/7 · ▶ Running the tests · 1:24: the count, the running task's activeForm, and how long it has run.
  • Hidden when there are no tasks, and two minutes after everything is done (first a ✓ Quest complete with a shine rolling across the bar).

Terminal: a Client bar whose running segment breathes with a highlight running through it and whose clock ticks locally. Desktop: one SVG with rounded segments, a glowing, shimmering active segment and check marks on the done ones.

/questline shows or hides it; ✕ hides it. The choice is kept across sessions.

Türkçe

İstemin üstünde görev ilerlemesi; ana döngünün TodoWrite listelerinden ve TaskCreate / TaskUpdate görevlerinden.

  • Görev başına bir bölmeli tek çubuk: bitenler yeşil, süren mor ve nabız gibi atan, bekleyenler gri.
  • 3/7 · ▶ Running the tests · 1:24: sayı, süren görevin activeForm'u ve ne kadar süredir sürdüğü.
  • Görev yokken ve her şey bittikten iki dakika sonra gizlenir (önce çubukta gezinen bir parıltıyla ✓ Quest complete).

Terminal: süren bölmesi nefes alan ve içinden bir ışık geçen, saati yerelde işleyen bir Client çubuğu. Masaüstü: yuvarlak bölmeli, aktif bölmesi parlayıp ışıldayan, bitenlerde onay işareti olan tek SVG.

/questline gösterir veya gizler; ✕ gizler. Seçim oturumlar arasında saklanır.

Source 6 files
hooks/register.tsx 148 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Quest, QuestSnap } from '../types'
5import type { BarProps } from './bar.tsx'
6import { coarseSpan, createTask, fromTodos, isShown, progress, updateTask } from './quests.ts'
7import type { Todo } from './quests.ts'
8import { questSvg } from './svg.ts'
9
10const snapAtom = atom({ plugin: 'questline', key: 'snap' } as const, null)
11const hiddenAtom = atom({ plugin: 'questline', key: 'isHidden' } as const, false)
12
13// The quest list: TodoWrite rows and TaskCreate/TaskUpdate tasks of the main
14// loop. Module variables start over on a reload; session.start restores them
15// from the last snapshot, which the session keeps.
16let todos: Quest[] = []
17let tasks: Quest[] = []
18let allDoneAt: number | null = null
19let publishedKey = ''
20
21const all = (): Quest[] => [...tasks, ...todos]
22
23async function publish($: EngineInterface): Promise<void> {
24  const now = await $.clock.now()
25  const items = all()
26  const { done, total, active } = progress(items)
27  if (total > 0 && done === total) allDoneAt ??= now
28  else allDoneAt = null
29  const isVisible = isShown(items, allDoneAt, now)
30  // The desktop caption shows a coarse elapsed time: publish when it changes.
31  const key = JSON.stringify({ items, isVisible, since: active?.since !== undefined ? coarseSpan(now - active.since) : '' })
32  if (key === publishedKey) return
33  publishedKey = key
34  const snap: QuestSnap = { items, done, total, allDoneAt, isVisible, now }
35  await update($, snapAtom, () => snap)
36}
37
38async function restore($: EngineInterface): Promise<void> {
39  const snap = await read($, snapAtom)
40  if (!snap) return
41  tasks = snap.items.filter(q => q.id.startsWith('task:'))
42  todos = snap.items.filter(q => q.id.startsWith('todo:'))
43  allDoneAt = snap.allDoneAt
44}
45
46async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
47  await update($, hiddenAtom, () => isHidden)
48  try {
49    await $.store.set('isHidden', isHidden)
50  } catch {
51    // Hidden for this session only.
52  }
53}
54
55async function loadHidden($: EngineInterface): Promise<void> {
56  try {
57    const v = await $.store.get('isHidden')
58    if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
59  } catch {
60    // Nothing stored.
61  }
62}
63
64const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
65
66export const register: Register = on => {
67  on('session.start', async ($, e, next) => {
68    const started = await next(e)
69    todos = []
70    tasks = []
71    allDoneAt = null
72    publishedKey = ''
73    await restore($)
74    await $.command.register({ name: 'questline', description: 'KOZMOS: show or hide the Questline task bar above the prompt', immediate: true })
75    await loadHidden($)
76    $.clock.every(1000, () => void publish($).catch(() => undefined))
77    return started
78  })
79
80  on('command.run', { command: 'questline' }, async $ => {
81    const isHidden = !(await read($, hiddenAtom))
82    await setHidden($, isHidden)
83    return { text: isHidden ? 'Questline hidden. /questline brings it back.' : 'Questline shown: it appears while the session has tasks.' }
84  })
85
86  on('tool.call', async ($, e, next) => {
87    const ran = await next(e)
88    if (e.agentId !== undefined || ran.isError === true || ran.deny !== undefined) return ran
89    const now = await $.clock.now()
90    const input = e as unknown as Record<string, unknown>
91    if (e.tool === 'TodoWrite' && Array.isArray(input.todos)) {
92      todos = fromTodos(todos, input.todos as Todo[], now)
93    } else if (e.tool === 'TaskCreate') {
94      const created = (ran.result as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task
95      const id = created?.id
96      if (typeof id === 'string' || typeof id === 'number') tasks = createTask(tasks, String(id), str(created?.subject) ?? str(input.subject) ?? `Task #${id}`, str(input.activeForm))
97    } else if (e.tool === 'TaskUpdate' && (typeof input.taskId === 'string' || typeof input.taskId === 'number')) {
98      tasks = updateTask(tasks, { taskId: String(input.taskId), status: str(input.status), subject: str(input.subject), activeForm: str(input.activeForm) }, now)
99    } else return ran
100    await publish($)
101    return ran
102  }).catch(($, e, next) => next(e))
103
104  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105    const drawn = await next(e)
106    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
107    const snap = await read($, snapAtom)
108    if (!snap || !snap.isVisible) return drawn
109    const cols = Math.max(20, (e.props.bodyColumns || 80) - 3)
110
111    if (e.surface === 'terminal') {
112      const { Box, Button, Client } = $.ui.resolve(e)
113      const { active } = progress(snap.items)
114      const now = await $.clock.now()
115      const props: BarProps = {
116        marks: snap.items.map(q => (q.status === 'completed' ? 'd' : q.status === 'in_progress' ? 'a' : 'p')).join(''),
117        done: snap.done,
118        total: snap.total,
119        activeText: active?.active ?? '',
120        activeMs: active?.since !== undefined ? Math.max(0, now - active.since) : -1,
121        allDone: snap.total > 0 && snap.done === snap.total,
122      }
123      return (
124        <Box flexDirection="column">
125          {drawn}
126          <Box key="questline" flexDirection="row">
127            <Client key="questline-bar" module="./bar.tsx" width={cols} height={1} props={props} />
128            <Button key="questline-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
129          </Box>
130        </Box>
131      )
132    }
133
134    // Every surface but the terminal draws an Svg.
135    const { Box, Button, Svg } = $.ui.resolve(e)
136    const pic = questSvg(snap, cols * 8 - 8)
137    return (
138      <Box flexDirection="column">
139        {drawn}
140        <Box key="questline" flexDirection="row">
141          <Svg source={pic.source} alt={pic.alt} width={pic.width} height={pic.height} />
142          <Button key="questline-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
143        </Box>
144      </Box>
145    )
146  })
147}
148
hooks/bar.tsx 103 lines
1// Questline's terminal bar: a Client surface module. The running segment
2// breathes and a highlight runs through it; the elapsed clock ticks locally
3// from the last published figure, so the hooks module publishes only changes.
4import type { ClientModule, RenderElement } from 'claude-code'
5
6import { mix } from './lib/kz.ts'
7import { segments } from './quests.ts'
8
9export type BarProps = {
10  /** One letter per quest: d done, a active, p pending. */
11  marks: string
12  done: number
13  total: number
14  activeText: string
15  /** How long the active quest had run when published; -1 for none. */
16  activeMs: number
17  allDone: boolean
18}
19
20type BarState = { t: number; base: number; at: number }
21
22const GREEN = '#4ade80'
23const VIOLET = '#a78bfa'
24const PALE = '#ede9fe'
25const GRAY = '#6b7280'
26
27function clock(ms: number): string {
28  const s = Math.max(0, Math.floor(ms / 1000))
29  const h = Math.floor(s / 3600)
30  const m = Math.floor((s % 3600) / 60)
31  const ss = String(s % 60).padStart(2, '0')
32  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
33}
34
35let latest: BarProps = { marks: '', done: 0, total: 0, activeText: '', activeMs: -1, allDone: false }
36
37const Bar: ClientModule<BarProps, BarState> = (props, surface) => {
38  if (surface.state === undefined) {
39    surface.setState({ t: 0, base: props.activeMs, at: 0 })
40    surface.every(40, () => {
41      const s = surface.state
42      if (!s) return
43      // A new figure from the hooks restarts the local count from it.
44      const isNew = latest.activeMs !== s.base
45      surface.setState({ t: s.t + 1, base: latest.activeMs, at: isNew ? s.t + 1 : s.at })
46    })
47  }
48  latest = props
49  const st = surface.state ?? { t: 0, base: props.activeMs, at: 0 }
50  const { Box, Text } = surface.elements
51  const cols = surface.columns || 80
52  const t = st.t
53  const budget = Math.max(6, Math.min(48, Math.floor(cols * 0.4)))
54  const { width, gap } = segments(props.marks.length, budget)
55
56  type Run = { s: string; c: string }
57  const runs: Run[] = []
58  const push = (s: string, c: string) => {
59    const last = runs[runs.length - 1]
60    if (last && last.c === c) last.s += s
61    else runs.push({ s, c })
62  }
63  const breathe = 0.5 + 0.5 * Math.sin(t * 0.16)
64  const wave = (t * 0.6) % (props.marks.length * (width + gap) + 24)
65  let x = 0
66  for (const m of props.marks) {
67    for (let i = 0; i < width; i++, x++) {
68      if (m === 'd') {
69        // Once all is done a bright wave rolls across the bar now and then.
70        const glow = props.allDone ? Math.max(0, 1 - Math.abs(x - wave) / 3) : 0
71        push('█', glow > 0 ? mix(GREEN, '#f0fdf4', glow * 0.8) : GREEN)
72      } else if (m === 'a') {
73        const sweep = Math.floor(t / 3) % Math.max(1, width + 2) - 1
74        push('█', i === sweep ? PALE : mix('#7c3aed', VIOLET, breathe))
75      } else push('░', GRAY)
76    }
77    if (gap) {
78      push(' ', GRAY)
79      x++
80    }
81  }
82  const elapsed = props.activeMs >= 0 ? props.activeMs + Math.max(0, t - st.at) * 40 : -1
83  const line: RenderElement[] = runs.map((r, i) => <Text key={`s${i}`} color={r.c}>{r.s}</Text>)
84  if (props.allDone) {
85    line.push(<Text key="done" color={GREEN} bold> ✓ {props.total}/{props.total} quest complete</Text>)
86  } else {
87    line.push(<Text key="n" bold> {props.done}/{props.total}</Text>)
88    if (props.activeText) {
89      line.push(<Text key="sep" dimColor> · </Text>)
90      line.push(<Text key="go" color={mix('#7c3aed', VIOLET, breathe)}>▶ </Text>)
91      line.push(<Text key="what">{props.activeText}</Text>)
92      if (elapsed >= 0) line.push(<Text key="el" dimColor> · {clock(elapsed)}</Text>)
93    } else line.push(<Text key="idle" dimColor> · waiting for the next task</Text>)
94  }
95  return (
96    <Box flexDirection="row">
97      <Text wrap="truncate-end">{line}</Text>
98    </Box>
99  )
100}
101
102export default Bar
103
hooks/quests.ts 79 lines
1// Questline's bookkeeping: pure functions over the quest list, no `$`.
2import type { Quest, QuestStatus } from '../types'
3
4/** After everything is done the band lingers this long, then hides. */
5export const LINGER_MS = 120_000
6
7export type Todo = { content?: unknown; status?: unknown; activeForm?: unknown }
8
9const statusOf = (v: unknown): QuestStatus => (v === 'completed' || v === 'in_progress' ? v : 'pending')
10const text = (v: unknown, fallback: string): string => (typeof v === 'string' && v.trim() ? v.trim() : fallback)
11
12/** A TodoWrite list replaces the todo quests; a row keeps its start time when it stays in progress. */
13export function fromTodos(prev: readonly Quest[], todos: readonly Todo[], now: number): Quest[] {
14  return todos.map((t, i) => {
15    const title = text(t.content, `Step ${i + 1}`)
16    const status = statusOf(t.status)
17    const before = prev.find(q => q.title === title) ?? prev[i]
18    const since = status === 'in_progress' ? (before?.status === 'in_progress' && before.title === title ? before.since ?? now : now) : undefined
19    return { id: `todo:${i}`, title, active: text(t.activeForm, title), status, ...(since !== undefined ? { since } : {}) }
20  })
21}
22
23export function createTask(list: readonly Quest[], id: string, subject: string, activeForm: string | undefined): Quest[] {
24  const key = `task:${id}`
25  if (list.some(q => q.id === key)) return [...list]
26  return [...list, { id: key, title: subject, active: activeForm?.trim() || subject, status: 'pending' }]
27}
28
29export type TaskPatch = { taskId: string; status?: string; subject?: string; activeForm?: string }
30
31export function updateTask(list: readonly Quest[], patch: TaskPatch, now: number): Quest[] {
32  const key = `task:${patch.taskId}`
33  if (patch.status === 'deleted') return list.filter(q => q.id !== key)
34  const found = list.find(q => q.id === key)
35  const base: Quest = found ?? { id: key, title: patch.subject || `Task #${patch.taskId}`, active: patch.activeForm || patch.subject || `Task #${patch.taskId}`, status: 'pending' }
36  const status = patch.status === undefined ? base.status : statusOf(patch.status)
37  const next: Quest = {
38    id: key,
39    title: patch.subject || base.title,
40    active: patch.activeForm || (patch.subject && base.active === base.title ? patch.subject : base.active),
41    status,
42  }
43  if (status === 'in_progress') next.since = base.status === 'in_progress' ? base.since ?? now : now
44  return found ? list.map(q => (q.id === key ? next : q)) : [...list, next]
45}
46
47export type Progress = { done: number; total: number; active: Quest | undefined }
48
49export function progress(list: readonly Quest[]): Progress {
50  const active = list.find(q => q.status === 'in_progress')
51  return { done: list.filter(q => q.status === 'completed').length, total: list.length, active }
52}
53
54/** Shown while there is a task, until two minutes after everything completed. */
55export function isShown(list: readonly Quest[], allDoneAt: number | null, now: number): boolean {
56  if (!list.length) return false
57  const { done, total } = progress(list)
58  if (done < total) return true
59  return allDoneAt === null || now - allDoneAt < LINGER_MS
60}
61
62/** A coarse elapsed label that changes rarely: <10s, 20s, ..., 4m, 1h12m. */
63export function coarseSpan(ms: number): string {
64  const s = Math.max(0, Math.floor(ms / 1000))
65  if (s < 10) return '<10s'
66  if (s < 60) return `${Math.floor(s / 10) * 10}s`
67  const m = Math.floor(s / 60)
68  if (m < 60) return `${m}m`
69  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
70}
71
72/** Segment widths for `n` quests in `budget` cells: equal, at most 6, with a gap when there is room. */
73export function segments(n: number, budget: number): { width: number; gap: number } {
74  if (n <= 0) return { width: 0, gap: 0 }
75  const withGap = Math.floor((budget + 1) / n) - 1
76  if (withGap >= 2) return { width: Math.min(6, withGap), gap: 1 }
77  return { width: Math.max(1, Math.floor(budget / n)), gap: 0 }
78}
79
hooks/svg.ts 75 lines
1// Questline's desktop drawing: one SVG row of segments and a caption.
2import type { QuestSnap } from '../types'
3import { KZ, fitText, svg, svgText, textWidth } from './lib/kz.ts'
4import { coarseSpan, progress } from './quests.ts'
5
6const CSS = `
7.qa{animation:qa 1.6s ease-in-out infinite}@keyframes qa{50%{opacity:.62}}
8.qs{animation:qs 1.8s cubic-bezier(.4,0,.2,1) infinite}@keyframes qs{from{transform:translateX(-30px)}to{transform:translateX(var(--w))}}
9.qw{animation:qw 3.2s ease-in-out infinite}@keyframes qw{from{transform:translateX(-60px)}60%,to{transform:translateX(var(--w))}}
10`
11
12export function questSvg(s: QuestSnap, width: number): { source: string; width: number; height: number; alt: string } {
13  const W = Math.max(260, width)
14  const H = 30
15  const { done, total, active } = progress(s.items)
16  const allDone = total > 0 && done === total
17  const n = s.items.length
18  const gap = n > 24 ? 2 : 4
19  const area = Math.min(W * 0.46, n * 38)
20  const sw = Math.max(3, (area - gap * (n - 1)) / n)
21  const y = 10
22  const h = 10
23  const p: string[] = []
24  p.push(`<defs><linearGradient id="qsh" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".75"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`)
25  p.push(`<filter id="qg" x="-40%" y="-80%" width="180%" height="260%"><feGaussianBlur stdDeviation="2.2"/></filter>`)
26  p.push(`<clipPath id="qall">`)
27  s.items.forEach((_, i) => p.push(`<rect x="${(4 + i * (sw + gap)).toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${Math.min(5, sw / 2)}"/>`))
28  p.push(`</clipPath></defs>`)
29  s.items.forEach((q, i) => {
30    const x = 4 + i * (sw + gap)
31    const rx = Math.min(5, sw / 2)
32    if (q.status === 'completed') {
33      p.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${rx}" fill="${KZ.green}"/>`)
34      if (sw >= 16) p.push(`<path d="M${(x + sw / 2 - 3).toFixed(1)} ${y + 5}l2 2 4-4" stroke="#fff" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
35    } else if (q.status === 'in_progress') {
36      p.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${rx}" fill="${KZ.violet}" filter="url(#qg)" class="qa"/>`)
37      p.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${rx}" fill="${KZ.violet}" class="qa"/>`)
38      p.push(`<clipPath id="qc${i}"><rect x="${x.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${rx}"/></clipPath>`)
39      p.push(`<g clip-path="url(#qc${i})"><rect class="qs" style="--w:${(sw + 30).toFixed(0)}px" x="${x.toFixed(1)}" y="${y}" width="24" height="${h}" fill="url(#qsh)"/></g>`)
40    } else {
41      p.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="${h}" rx="${rx}" class="k"/>`)
42    }
43  })
44  const barEnd = 4 + n * (sw + gap) - gap
45  if (allDone) p.push(`<g clip-path="url(#qall)"><rect class="qw" style="--w:${(barEnd + 60).toFixed(0)}px" x="0" y="${y}" width="50" height="${h}" fill="url(#qsh)"/></g>`)
46
47  let tx = barEnd + 12
48  const base = y + h - 1
49  const put = (s: string, o: Parameters<typeof svgText>[3] & { size: number }) => {
50    p.push(svgText(tx, base, s, o))
51    tx += textWidth(s, o.size) + 6
52  }
53  if (allDone) {
54    put('✓ Quest complete', { size: 12, weight: 700, fill: KZ.green })
55    put(`${total}/${total}`, { cls: 's', size: 11.5, weight: 600 })
56  } else {
57    put(`${done}/${total}`, { size: 12.5, weight: 750 })
58    if (active) {
59      p.push(`<path d="M${tx} ${base - 9}l7 4.5-7 4.5z" fill="${KZ.violet}" class="qa"/>`)
60      tx += 12
61      const since = active.since !== undefined ? coarseSpan(s.now - active.since) : ''
62      const room = W - tx - (since ? textWidth(since, 11) + 16 : 0) - 6
63      put(fitText(active.active, 12, room), { size: 12, weight: 550 })
64      if (since) put(`· ${since}`, { cls: 'm', size: 11 })
65    } else put('waiting for the next task', { cls: 'm', size: 11.5 })
66  }
67  return { source: svg(W, H, p.join(''), CSS), width: W, height: H, alt: altOf(s) }
68}
69
70export function altOf(s: QuestSnap): string {
71  const { done, total, active } = progress(s.items)
72  if (total > 0 && done === total) return `Questline: all ${total} tasks done`
73  return `Questline: ${done} of ${total} tasks done${active ? `; now: ${active.active}` : ''}`
74}
75
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
types/index.d.ts 30 lines
1export type QuestStatus = 'pending' | 'in_progress' | 'completed'
2
3/** One task, from TodoWrite or TaskCreate/TaskUpdate. */
4export type Quest = {
5  /** `todo:<n>` for a TodoWrite row, `task:<id>` for a task. */
6  id: string
7  title: string
8  /** The present-continuous form shown while it runs ("Running the tests"). */
9  active: string
10  status: QuestStatus
11  /** When it went in progress (ms), while it is. */
12  since?: number
13}
14
15export type QuestSnap = {
16  items: Quest[]
17  done: number
18  total: number
19  /** When the last task completed with nothing left, else null. */
20  allDoneAt: number | null
21  isVisible: boolean
22  now: number
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    questline: { snap: QuestSnap | null; isHidden: boolean }
28  }
29}
30