SLOPSHOPPER

sieve-pane

A pane that shows the why and the callers of the code the agent reads and edits.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sieve-pane
│ ┃ Sieve ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ │ sieve-pane │ │ ┃ ▄▀ SIEVE ⏺ Read(src/auth.ts) │ Usage: /sieve-mascot │ │ ┃ ▀▀▀▀▀▀▀▀▀▀ No file yet. The pane fills w ⎿ Read 6 lines │ <sieve|soya|cloud|sifty|hoot|grit|none>. │ │ ┃ ▀▀▀▀▀▀ ▄ agent reads or edits a file. ⏺ Update(src/auth.ts) │ Now: sieve. │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ 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 │ │ › /sieve-pane │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sieve
▄▀ SIEVE ▀▀▀▀▀▀▀▀▀▀ No file yet. The pane fills when the ▀▀▀▀▀▀ ▄ agent reads or edits a file.
README

sieve-pane

A Claude Code mod. It shows a Tokyo Night dashboard for the code the agent reads and edits.

The pane (/sieve-pane) has these parts, top to bottom:

  • Header: a pixel mascot, the symbol name in bold, and file:line · kind · edited 9:58pm in dim.
  • Tiles: SAVED TODAY (green), THIS WEEK (blue) and IMPACT (green, orange or red).
  • WHO DEPENDS ON THIS: the callers to depth 2 as a tree, 4 rows, then "+N more".
  • IMPACT: a bar and "N callers · H hops". Green is 1 to 5, orange 6 to 20, red above 20.
  • WHY IT EXISTS: the recorded decisions. A superseded one is red with ⚠.
  • SAVED · LAST 7 DAYS: a bar chart, oldest left, today green.

A non-code file shows the resting mascot, the savings tiles and "not code · Sieve indexes code files only".

The mascot is a Raster in the terminal and an Svg on desktop and vscode. The phone has none. It alternates 2 frames every 600 ms while the pane is open, and only blits a change. It wags (frame 1) after a new saving. A red impact or a SUPERSEDED decision dims it and keeps frame 0. The default mascot is sieve. The six mascots are sieve, soya, cloud, sifty, hoot and grit. The value none shows no mascot. /sieve-mascot <name> saves the choice in .sieve/config.json, as "mascot": "<name>". You can also set that key by hand. assets/mascots.json is the source. Run node scripts/gen-mascots.mjs to make hooks/mascots.ts. The pane reacts to the Read, Edit and Write tools, and to Sieve queries made through Bash and MCP. The band above the prompt shows, after an Edit, MultiEdit or Write: ✎ checkApiKeyRateLimit and 2 more changed → 6 callers affected in 2 routes, in the level color. The saved numbers come from sieve stats --json, at most every 30 seconds. Try it: run sieve build, put sieve on PATH, then claude --plugin-dir <absolute path to this folder>. Run claude plugin test <this folder> for the tests. The mod never changes a tool call and makes no model call.

Source 3 files
hooks/register.tsx 832 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Band, Query, Row, SievePaneView, Stats, Why } from '../types'
5import { DEFAULT_MASCOT, MASCOTS, PALETTE } from './mascots'
6
7const PANE = 'sieve'
8const view = atom({ plugin: 'sieve-pane', key: 'view' } as const, null)
9const band = atom({ plugin: 'sieve-pane', key: 'band' } as const, null)
10const stats = atom({ plugin: 'sieve-pane', key: 'stats' } as const, null)
11
12type Json = any // ponytail: the sieve JSON is not typed; the code reads only the fields it needs.
13type Job = { file: string; path: string; tool: string; input: Json; at?: number; query?: Query; isFile?: boolean }
14type Put = (v: SievePaneView) => Promise<void>
15
16/** The 1-based line where the edit starts, or undefined. The tool has run, so try new_string, then old_string. A text counts only when it occurs once. */
17export function findLine(tool: string, input: Json, text: string): number | undefined {
18  if (tool === 'Read') return Math.max(1, Number(input.offset) || 1)
19  if (tool === 'Write') return 1
20  const edit = tool === 'MultiEdit' ? input.edits?.[0] : input
21  for (const s of [edit?.new_string, edit?.old_string]) {
22    const at = s ? text.indexOf(s) : -1
23    if (at >= 0 && text.indexOf(s, at + 1) < 0) return text.slice(0, at).split('\n').length
24  }
25  return undefined
26}
27
28/** Resolves . and .. in an absolute path. */
29export function normalize(p: string): string {
30  const out: string[] = []
31  for (const part of p.split('/')) {
32    if (part === '..') out.pop()
33    else if (part && part !== '.') out.push(part)
34  }
35  return `/${out.join('/')}`
36}
37
38const EDITS = ['Edit', 'MultiEdit', 'Write']
39const rowsOf = (hits: Json[]) => hits.filter(h => h.depth === 2)
40
41/** The route of a hit, as "POST  ai/chat", or undefined when the hit is not a route handler. */
42export function routeOf(h: Json): string | undefined {
43  const m = /(?:^|\/)routes\/(?:(.+)\/)?\+server\.\w+$/.exec(String(h.path))
44  return m ? `${h.name}  ${(m[1] ?? '').replace(/^api\/v\d+\/?/, '').replace(/\/?\[[^\]]+\]/g, '') || '/'}` : undefined
45}
46
47const lineOf = (span: unknown) => String(span).replace(/^L(\d+).*/, '$1')
48const base = (p: string) => p.split('/').pop() ?? p
49
50/** One why card: the heading, the date and the SUPERSEDED flag with its successor. */
51export function whyCards(why: Json): Why[] {
52  return ((why?.symbols?.[0]?.decisions ?? []) as Json[]).slice(0, 2).map(d => ({
53    date: d.date ?? '',
54    heading: String(d.heading ?? ''),
55    superseded: !!d.superseded,
56    by: d.supersededBy ?? undefined,
57  }))
58}
59
60/** Builds the tree rows (depth 1 and depth 2) from the callers hits. `kids` maps a depth-1 id to its depth-2 hits. */
61export function makeTree(d1: Json[], kids: Map<string, Json[]>): Row[] {
62  const rows: Row[] = []
63  d1.forEach((h, i) => {
64    rows.push({ d: 1, p: i, label: h.name, tail: `${base(String(h.path))}:${lineOf(h.span)}` })
65    for (const c of kids.get(h.id) ?? []) {
66      const r = routeOf(c)
67      rows.push({ d: 2, p: i, label: r ?? c.name, tail: r ? '' : `${base(String(c.path))}:${lineOf(c.span)}` })
68    }
69  })
70  return rows
71}
72
73/** Builds the view from `sieve why --json` and `sieve callers --depth 2 --json`. `kids` is optional. */
74export function makeView(file: string, tool: string, why: Json, callers?: Json, kids?: Map<string, Json[]>): SievePaneView {
75  const sym = why?.symbols?.[0]
76  if (!sym) return { file, tool, why: [], tree: [], note: 'no symbol at this line' }
77  const m = callers?.matches?.[0]
78  const hits: Json[] = (callers?.matches ?? []).flatMap((x: Json) => x.hits ?? [])
79  const d1 = hits.filter(h => h.depth === 1)
80  // One depth-1 caller owns every depth-2 hit. With more, the `kids` map says who owns which.
81  const map = kids ?? new Map(d1.length === 1 ? [[d1[0].id, rowsOf(hits)]] : [])
82  return {
83    file,
84    tool,
85    symbol: sym.name,
86    kind: m?.symbol?.kind,
87    line: m?.symbol?.span ? Number(lineOf(m.symbol.span)) : sym.line,
88    why: whyCards(why),
89    tree: makeTree(d1, map),
90    n: callers ? hits.length : undefined,
91    hops: callers ? Math.max(0, ...hits.map(h => Number(h.depth) || 0)) : undefined,
92  }
93}
94
95const firstLine = (s: string) => s.split('\n').find(l => l.trim()) ?? ''
96
97const LIMIT_MS = 20000
98const TICK_MS = 100
99const TICK = Symbol('tick')
100
101/**
102 * Runs `sieve <argv>`. It never throws. It uses `$.process.spawn`, because leaving the `for await` loop kills the child.
103 * The loop ends early when `stale()` says a newer job waits, or after 20 s. The result is then `{ cancelled: true }`.
104 * A piece is `{ stream, text }`; the last piece is `{ code, signal }`. A signal with no code counts as a failure.
105 */
106async function sieve($: Pick<Engine, 'process'>, argv: string[], stale: () => boolean = () => !!queue.job): Promise<{ json?: Json; note?: string; cancelled?: boolean }> {
107  let out = ''
108  let err = ''
109  let code: number | undefined
110  const started = Date.now()
111  let timer: ReturnType<typeof setTimeout> | undefined
112  try {
113    const it = ($.process as Json).spawn({ argv: ['sieve', ...argv] })[Symbol.asyncIterator]()
114    let next = it.next()
115    for (;;) {
116      const r: any = await Promise.race([next, new Promise(res => (timer = setTimeout(() => res(TICK), TICK_MS)))])
117      clearTimeout(timer)
118      if (stale() || Date.now() - started > LIMIT_MS) {
119        await it.return?.() // ends the loop: the engine kills the child
120        return { cancelled: true, note: 'sieve was cancelled' }
121      }
122      if (r === TICK) continue
123      if (r.done) break
124      next = it.next()
125      const x = r.value
126      if ('code' in x || 'signal' in x) code = typeof x.code === 'number' ? x.code : 1
127      else if (x.stream === 'stderr') err += String(x.text ?? '')
128      else out += String(x.text ?? '')
129    }
130  } catch {
131    return { note: 'sieve not found on PATH' }
132  }
133  if (code === 127) return { note: 'sieve not found on PATH' }
134  if (code !== undefined && code !== 0) {
135    const line = firstLine(err || out)
136    // The CLI error line is `sieve: <what> — <what to run>`.
137    return { note: /no (graph|index)/.test(line) ? 'no Sieve graph in this project; run sieve build' : line.replace(/^sieve: /, '') }
138  }
139  try {
140    return { json: JSON.parse(out) }
141  } catch {
142    return { note: 'sieve gave output that is not JSON' }
143  }
144}
145
146// `--` ends the flags, so a name or a path that starts with `-` stays an argument.
147async function query($: Pick<Engine, 'process'>, file: string, tool: string, line: number, deep = true): Promise<SievePaneView | undefined> {
148  const why = await sieve($, ['why', '--json', '--', `${file}:${line}`])
149  if (!why.json) return { file, tool, why: [], tree: [], note: why.note }
150  if (queue.job) return undefined // a newer job waits: stop, the view is stale
151  const name: string | undefined = why.json.symbols?.[0]?.name
152  const callers = name ? (await sieve($, ['callers', '--in', file, '--depth', '2', '--json', '--', name])).json : undefined
153  const hits: Json[] = (callers?.matches ?? []).flatMap((x: Json) => x.hits ?? [])
154  const d1 = hits.filter(h => h.depth === 1)
155  const kids = new Map<string, Json[]>()
156  if (d1.length === 1) kids.set(d1[0].id, rowsOf(hits))
157  else if (deep) {
158    // Several depth-1 callers: ask each shown one for its own callers, to find who owns which depth-2 hit.
159    const d2 = new Set(rowsOf(hits).map(h => h.id))
160    for (const h of d1.slice(0, 4)) {
161      if (queue.job) return undefined // a newer job waits: stop, the view is stale
162      const c = await sieve($, ['callers', '--in', String(h.path), '--json', '--', h.name])
163      kids.set(h.id, ((c.json?.matches ?? []) as Json[]).flatMap(x => x.hits ?? []).filter((x: Json) => d2.has(x.id)))
164    }
165  }
166  if (queue.job) return undefined // the last call may have ended after a newer job came in
167  return makeView(file, tool, why.json, callers, kids)
168}
169
170/**
171 * The file view: its 3 most called symbols, then the symbol view of the most called one. It runs at most 3 queries:
172 * `map` (the hubs of the file, with their caller counts), `why` and `callers`. A file with no hub runs `map` and `why` only.
173 * ponytail: `map` lists the top hubs of each directory, so a symbol that is not a hub is not counted. Upgrade: a per-file counts query in the CLI.
174 */
175export async function fileView($: Pick<Engine, 'process'>, file: string): Promise<SievePaneView | undefined> {
176  const map = await sieve($, ['map', '--json'])
177  if (!map.json) return { file, tool: 'Read', why: [], tree: [], note: map.note }
178  if (queue.job) return undefined // a newer job waits: stop, the view is stale
179  const hubs: Json[] = ((map.json.dirs ?? []) as Json[]).flatMap(d => d.hubs ?? []).filter(h => h.path === file)
180  const symbols = hubs
181    .map(h => ({ name: String(h.name), line: Number(lineOf(h.span)), n: Number(h.inDegree) || 0 }))
182    .sort((x, y) => y.n - x.n)
183    .slice(0, 3)
184  if (!symbols.length) {
185    const why = await sieve($, ['why', '--json', '--', file])
186    return { file, tool: 'Read', why: why.json ? whyCards(why.json) : [], tree: [], symbols }
187  }
188  // Below the short list: the full symbol view of the most called symbol.
189  const top = await query($, file, 'Read', symbols[0]!.line, false)
190  return top && { ...top, symbols }
191}
192
193/** The symbol view of a name, from `sieve callers`. The first match gives the file and the line, as the edit view finds them with `sieve why`. */
194export async function symbolView($: Pick<Engine, 'process'>, name: string, tool: string): Promise<SievePaneView | undefined> {
195  if (!name) return { file: name, tool, why: [], tree: [] }
196  const found = await sieve($, ['callers', '--json', '--', name])
197  const sym = found.json?.matches?.[0]?.symbol
198  if (!sym?.path) return { file: name, tool, why: [], tree: [], note: found.note ?? `no symbol named ${name}` }
199  if (queue.job) return undefined // a newer job waits: stop, the view is stale
200  return query($, String(sym.path), tool, Number(lineOf(sym.span)) || 1, false)
201}
202
203/** The symbol view of the top hit of `sieve ask`. A top hit that is not a symbol gives a note. */
204export async function askView($: Pick<Engine, 'process'>, text: string, tool: string): Promise<SievePaneView | undefined> {
205  if (!text) return { file: text, tool, why: [], tree: [] }
206  const found = await sieve($, ['ask', '--json', '--', text])
207  const top = found.json?.hits?.[0]
208  const at = top?.kind === 'symbol' ? /^(.+):L(\d+)/.exec(String(top.pointer)) : null
209  if (!at) return { file: text, tool, why: [], tree: [], note: found.note ?? 'the top hit is not a symbol' }
210  if (queue.job) return undefined // a newer job waits: stop, the view is stale
211  return query($, at[1]!, tool, Number(at[2]), false)
212}
213
214const SUBS = new Set(['ask', 'grep', 'callers', 'blast', 'map', 'skeleton', 'why'])
215const VALUE_FLAGS = new Set(['--depth', '--in', '--format', '--limit', '--max', '--budget'])
216const MCP_SUBS: Record<string, string> = { find_code: 'ask', find_all: 'grep', trace_calls: 'callers', file_api: 'skeleton', repo_map: 'map', check_freshness: 'check', why: 'why' }
217
218/** Splits a shell command into simple commands of words. Quotes keep a word whole. `;`, `&`, `|` and a newline end a command. `>` and `<` end the words of a command. */
219function shellWords(command: string): string[][] {
220  const cmds: string[][] = []
221  let words: string[] = []
222  let word: string | undefined
223  let quote = ''
224  let cut = false // a redirect: the rest of this command is not an argument
225  const endWord = () => {
226    if (word !== undefined && !cut) words.push(word)
227    word = undefined
228  }
229  const endCmd = () => {
230    endWord()
231    if (words.length) cmds.push(words)
232    words = []
233    cut = false
234  }
235  for (const ch of command) {
236    if (quote) {
237      if (ch === quote) quote = ''
238      else word = (word ?? '') + ch
239    } else if (ch === '"' || ch === "'") {
240      quote = ch
241      word = word ?? ''
242    } else if (/\s/.test(ch) && ch !== '\n') endWord()
243    else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n') endCmd()
244    else if (ch === '>' || ch === '<') {
245      if (word !== undefined && /^\d+$/.test(word)) word = undefined // the 2 of 2>
246      endWord()
247      cut = true
248    } else word = (word ?? '') + ch
249  }
250  endCmd()
251  return cmds
252}
253
254/** The first `sieve <sub>` of a Bash command, also in a joined command, after an env prefix, as `npx sieve` or as a path that ends in `/sieve`. `arg` is the main argument. */
255export function sieveCommand(command: string): { sub: string; arg: string } | undefined {
256  for (const cmd of shellWords(command)) {
257    let i = 0
258    while (/^[A-Za-z_]\w*=/.test(cmd[i] ?? '')) i++
259    if (cmd[i] === 'npx') i++
260    const bin = cmd[i] ?? ''
261    const sub = cmd[i + 1] ?? ''
262    if (!(bin === 'sieve' || bin.endsWith('/sieve')) || !SUBS.has(sub)) continue
263    const words: string[] = []
264    const rest = cmd.slice(i + 2)
265    for (let j = 0; j < rest.length; j++) {
266      const w = rest[j]!
267      if (w === '--') continue
268      if (!w.startsWith('-')) words.push(w)
269      else if (!w.includes('=') && (VALUE_FLAGS.has(w) || /^\d+$/.test(rest[j + 1] ?? ''))) j++ // the value of `--flag N` or `-x N`
270    }
271    return { sub, arg: sub === 'ask' ? words.join(' ') : (words[0] ?? '') }
272  }
273  return undefined
274}
275
276/** The sum of every `[sieve] saved ≈ N tokens` line. N may have commas. */
277export function savedTokens(text: string): number {
278  let sum = 0
279  for (const m of text.matchAll(/\[sieve\] saved ≈ (\d{1,3}(?:,\d{3})*|\d+) tokens/g)) sum += Number(m[1]!.replace(/,/g, ''))
280  return sum
281}
282
283/** The query of a Sieve Bash call or a Sieve MCP call, or undefined for any other call. */
284function sieveQuery(e: Json, ran: Json): Query | undefined {
285  const tool = String(e.tool)
286  let found: { sub: string; arg: string } | undefined
287  if (tool === 'Bash') found = sieveCommand(String(e.command ?? ''))
288  else if (tool.startsWith('mcp__sieve__')) {
289    // The MCP input fields sit flat on `e`, as `command` does for Bash (claude-code.d.ts, McpToolCallInputFallback).
290    const arg = [e.pattern, e.symbol, e.query, e.file, e.path, e.name, e.question].find(v => typeof v === 'string')
291    const name = tool.slice('mcp__sieve__'.length).replace(/^sieve_/, '') // the tool is `sieve_trace_calls`, so the full name is mcp__sieve__sieve_trace_calls
292    found = { sub: MCP_SUBS[name] ?? name, arg: arg ?? '' }
293  }
294  return found && { ...found, saved: savedTokens(String(ran?.text ?? '')) }
295}
296
297/** The newest job waits here. The tool.call hook writes it. The worker takes it. */
298export const queue: { job?: Job; busy: boolean } = { busy: false }
299
300/** Queues a job. A job that is already running is stale now: its `sieve` loop sees `queue.job` and ends. */
301export function setJob(job: Job) {
302  queue.job = job
303}
304
305/**
306 * The tool.call hook. It awaits next(e) first and returns the result unchanged.
307 * Then it records the job. It makes no query: the docs do not say that `$` stays valid after a hook returns.
308 */
309export async function onToolCall($: Pick<Engine, 'session'>, e: Json, next: (e: Json) => Promise<any>) {
310  const ran = await next(e)
311  try {
312    const tool = String(e.tool)
313    const cwd = normalize(await $.session.cwd())
314    const q = sieveQuery(e, ran)
315    if (q) {
316      const abs = normalize(q.arg.startsWith('/') ? q.arg : `${cwd}/${q.arg}`)
317      const isFile = q.sub === 'skeleton' && !!q.arg && isCode(q.arg) && abs.startsWith(`${cwd}/`)
318      setJob({ file: isFile ? abs.slice(cwd.length + 1) : q.arg, path: abs, tool: q.sub, input: e, at: Date.now(), query: q, isFile })
319      return ran
320    }
321    const path = normalize(String(e.file_path ?? ''))
322    if (['Read', ...EDITS].includes(tool) && path.startsWith(`${cwd}/`)) {
323      setJob({ file: path.slice(cwd.length + 1), path, tool, input: e, at: Date.now() })
324    }
325    if (EDITS.includes(tool) && path.startsWith(`${cwd}/`)) blast.dirty = true
326  } catch {} // A failure never reaches the tool result.
327  return ran
328}
329
330/** One worker step: take the newest job, run at most one query, drop the result when a newer job came in. */
331export async function step($: Pick<Engine, 'process' | 'fs'>, put: Put): Promise<void> {
332  const job = queue.job
333  if (queue.busy || !job) return
334  queue.busy = true
335  queue.job = undefined
336  try {
337    const text = job.tool === 'Edit' || job.tool === 'MultiEdit' ? String(await $.fs.read(job.path).catch(() => '')) : ''
338    const whole = job.tool === 'Read' && !job.input.offset && !job.input.limit // a whole-file Read: line 1 is an import, not a symbol
339    const line = whole || job.query ? 1 : findLine(job.tool, job.input, text)
340    const v: SievePaneView | undefined = job.query
341      ? job.isFile
342        ? await fileView($, job.file)
343        : job.query.sub === 'callers'
344          ? await symbolView($, job.query.arg, job.tool)
345          : job.query.sub === 'ask'
346            ? await askView($, job.query.arg, job.tool)
347            : { file: job.file, tool: job.tool, why: [], tree: [] }
348      : whole
349      ? await fileView($, job.file)
350      : line === undefined
351        ? { file: job.file, tool: job.tool, why: [], tree: [], note: 'edit line not found in the file' }
352        : await query($, job.file, job.tool, line)
353    if (v) Object.assign(v, { at: job.at, query: job.query })
354    if (v && !queue.job) await put(v)
355  } catch (err) {
356    await put({ file: job.file, tool: job.tool, why: [], tree: [], note: `sieve-pane error: ${String(err)}` }).catch(() => {})
357  } finally {
358    queue.busy = false
359  }
360}
361
362/** The band data from `sieve blast --format json`, or null when nothing changed. */
363export function bandInfo(b: Json): Band | null {
364  const seeds: Json[] = b?.seeds ?? []
365  if (!seeds.length) return null
366  const hit: Json[] = b.impacted ?? []
367  const first = seeds.find(x => !x.wholeFile) ?? seeds[0]
368  const more = seeds.length - 1
369  return {
370    sym: String(first.name ?? base(String(first.path))),
371    more,
372    n: hit.length,
373    hops: Math.max(0, ...hit.map(h => Number(h.depth) || 0)),
374    routes: hit.filter(h => routeOf(h)).length, // one per handler row, as the tree counts
375  }
376}
377
378/** The Tokyo Night colors. `text` (fg_dark) is the dim text color. `dim` is for borders, bar tracks and the dimmed mascot only: it gives 2.8:1 on the pane background. */
379export const C = { fg: '#c0caf5', dim: '#565f89', text: '#9aa5ce', blue: '#7aa2f7', cyan: '#7dcfff', purple: '#bb9af7', green: '#9ece6a', orange: '#ff9e64', red: '#f7768e', yellow: '#e0af68', border: '#3b4261' }
380
381/** The level of a caller count: green for 1 to 5, orange for 6 to 20, red above 20. */
382export const level = (n: number) => (n > 20 ? 'red' : n > 5 ? 'orange' : 'green')
383export const levelColor = (n: number) => C[level(n)]
384
385/** The band text, in plain words. `more` counts the other changed symbols. */
386export const bandLine = (b: Band) =>
387  `✎ ${b.sym}${b.more ? ` and ${b.more} more changed` : ''} → ${b.n} ${b.n === 1 ? 'caller' : 'callers'} affected${b.routes ? ` in ${b.routes} ${b.routes === 1 ? 'route' : 'routes'}` : ''}`
388
389/** A new edit sets dirty. The worker clears it. */
390export const blast: { dirty: boolean; busy: boolean; last: number } = { dirty: false, busy: false, last: -Infinity }
391const MIN_GAP_MS = 2000
392
393/** One blast step: run at most once per 2 seconds, and only after a new edit. A failure clears the band. */
394export async function blastStep($: Pick<Engine, 'process'>, now: number, putBand: (b: Band | null) => Promise<void>): Promise<void> {
395  if (blast.busy || !blast.dirty || now - blast.last < MIN_GAP_MS) return
396  blast.busy = true
397  blast.dirty = false
398  blast.last = now
399  try {
400    const out = await sieve($, ['blast', '--format', 'json', '--no-owners'], () => false)
401    if (!blast.dirty) await putBand(out.json ? bandInfo(out.json) : null) // a newer edit wins
402  } catch {} // A failing putBand is dropped. A blast failure puts null: the band clears.
403  finally {
404    blast.busy = false
405  }
406}
407
408/** The AbovePrompt hook: no band or a survey on screen, no band. */
409export function bandRender($: Pick<Engine, 'ui'>, e: Json, next: (e: Json) => any, b: Band | null) {
410  if (!b || e.props?.hasSurvey) return next(e)
411  const { Box, Text } = $.ui.resolve(e)
412  return (
413    <Box>
414      <Text color={levelColor(b.n)}>{bandLine(b)}</Text>
415    </Box>
416  )
417}
418
419// --- the savings numbers ---
420const STATS_GAP_MS = 30000
421export const statsRun: { last: number; busy: boolean } = { last: -Infinity, busy: false }
422
423/** The stats from `sieve stats --json`, or null when the shape is wrong. `spark` holds the 7 days, oldest first. */
424export function parseStats(j: Json): Stats | null {
425  const t = j?.today?.tokens
426  if (typeof t !== 'number') return null
427  const days: number[] = ((j.last7days ?? []) as Json[]).map(d => Number(d.tokens) || 0).reverse()
428  return { tokens: t, dollars: typeof j.today.dollars === 'number' ? j.today.dollars : null, spark: days }
429}
430
431export const kfmt = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
432export const sparkline = (days: number[]) => {
433  const top = Math.max(0, ...days)
434  return days.map(v => '▁▂▃▄▅▆▇█'[top ? Math.round((v / top) * 7) : 0]).join('')
435}
436
437/** One stats step: at most once per 30 s. A failure hides the section. */
438export async function statsStep($: Pick<Engine, 'process'>, now: number, putStats: (s: Stats | null) => Promise<void>): Promise<void> {
439  if (statsRun.busy || now - statsRun.last < STATS_GAP_MS) return
440  statsRun.busy = true
441  statsRun.last = now
442  try {
443    const out = await sieve($, ['stats', '--json'], () => false)
444    const s = out.json ? parseStats(out.json) : null
445    await putStats(s)
446  } catch {} // A failing putStats is dropped.
447  finally {
448    statsRun.busy = false
449  }
450}
451
452// --- the mascot ---
453const rgb = (h: string) => parseInt(h.slice(1), 16)
454const TERM_DEFAULT = 0x01000000 // the terminal's own color, as RasterProps says
455const PANE_BG = [0x1a, 0x1b, 0x26]
456/** Mixes a color 45% toward the pane background: the dimmed mascot. */
457const dimmed = (c: number) => PANE_BG.reduce((out, bg, i) => out | (Math.round(bg * 0.45 + ((c >> (16 - 8 * i)) & 255) * 0.55) << (16 - 8 * i)), 0)
458export const MASCOT_NAMES = [...Object.keys(MASCOTS), 'none']
459
460const pixel = (name: string, frame: number) => {
461  const m = MASCOTS[name]
462  return m ? m.small[frame % m.small.length]! : undefined // the small frames: 6 pixel rows, 3 cell rows
463}
464
465/**
466 * The Raster cells of one mascot frame: [codePoint, fg, bg] per cell, row-major.
467 * Two pixel rows make one cell row: ▀ has the upper pixel as fg and the lower as bg. Transparent shows the terminal color.
468 */
469export function mascotWords(name: string, frame: number, dim = false): { columns: number; rows: number; words: Uint32Array } | undefined {
470  const px = pixel(name, frame)
471  if (!px) return undefined
472  const columns = Math.max(...px.map(r => r.length))
473  const rows = Math.ceil(px.length / 2)
474  const at = (r: number, c: number) => {
475    const h = PALETTE[px[r]?.[c] ?? '.']
476    return h ? (dim ? dimmed(rgb(h)) : rgb(h)) : undefined
477  }
478  const words = new Uint32Array(columns * rows * 3)
479  for (let r = 0; r < rows; r++)
480    for (let c = 0; c < columns; c++) {
481      const top = at(2 * r, c)
482      const low = at(2 * r + 1, c)
483      const cell = top === undefined && low === undefined ? [0x20, TERM_DEFAULT, TERM_DEFAULT] : low === undefined ? [0x2580, top!, TERM_DEFAULT] : top === undefined ? [0x2584, low, TERM_DEFAULT] : [0x2580, top, low]
484      words.set(cell, (r * columns + c) * 3)
485    }
486  return { columns, rows, words }
487}
488
489/** Standard padded base64 of the cell words, little-endian (arm64 and x64 are). */
490export function packCells(words: Uint32Array): string {
491  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength)
492  let s = ''
493  for (let i = 0; i < bytes.length; i += 8192) s += String.fromCharCode(...bytes.subarray(i, i + 8192))
494  return btoa(s)
495}
496
497/** The same frame as SVG markup, for the desktop and the editor. */
498export function mascotSvg(name: string, frame: number, dim = false): string | undefined {
499  const px = pixel(name, frame)
500  if (!px) return undefined
501  const rects = px.flatMap((row, y) => [...row].flatMap((ch, x) => (PALETTE[ch] ? [`<rect x="${x}" y="${y}" width="1" height="1" fill="${PALETTE[ch]}"/>`] : [])))
502  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${Math.max(...px.map(r => r.length))} ${px.length}" shape-rendering="crispEdges" opacity="${dim ? 0.5 : 1}">${rects.join('')}</svg>`
503}
504
505// --- the Radar frame ---
506const BAR = 14
507export const cut = (s: string, n: number) => (s.length <= n ? s : n <= 1 ? s.slice(0, Math.max(n, 0)) : `${s.slice(0, n - 1)}…`)
508const clock = (ms: number) => {
509  const d = new Date(ms)
510  const h = d.getHours()
511  return `${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')}${h < 12 ? 'am' : 'pm'}`
512}
513const MAX_ROWS = 4
514const CODE = /\.(rs|ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|c|h|cc|cpp|hpp|cs|rb|php|swift|svelte|vue|scala|lua|sh)$/
515export const isCode = (file: string) => CODE.test(file)
516const NOT_CODE = 'not code · Sieve indexes code files only'
517
518/**
519 * The visible tree lines. The last visible row ends with └─, whatever was cut after it.
520 * `pre` is the connector part of `text`, so the frame can color it apart from the name.
521 * `more` counts the callers not shown: the true total minus the visible rows.
522 * ponytail: a glyph counts 1 cell, as in a monospace terminal. `radar` keeps a 1-cell margin.
523 */
524export function treeLines(tree: Row[], total?: number): { rows: { d: 1 | 2; text: string; pre: number }[]; more: number; indent: string } {
525  const seen = tree.slice(0, MAX_ROWS)
526  const lastP = seen.length ? seen[seen.length - 1]!.p : -1
527  const rows = seen.map((r, i) => {
528    const endsGroup = !seen.slice(i + 1).some(x => x.p === r.p)
529    const tail = r.tail ? `   ${r.tail}` : ''
530    if (r.d === 1) return { d: 1 as const, text: `${r.p === lastP ? '└' : '├'}─● ${r.label}${tail}`, pre: 2 }
531    return { d: 2 as const, text: `${r.p === lastP ? '   ' : '│  '}${endsGroup ? '└' : '├'}─○ ${r.label}${tail}`, pre: 5 }
532  })
533  const depth = seen.length ? seen[seen.length - 1]!.d : 1
534  return { rows, more: Math.max(total ?? 0, tree.length) - seen.length, indent: '   '.repeat(depth) }
535}
536
537/** The impact of a view: an edit takes the blast of the whole diff from the band, a read keeps the callers count. */
538const impactOf = (v: SievePaneView, b: Band | null) => (EDITS.includes(v.tool) && b ? { n: b.n, hops: b.hops } : { n: v.n, hops: v.hops })
539
540/**
541 * The mascot mood. `dim` is a red impact level or a SUPERSEDED decision: a calm, visible signal.
542 * `rest` keeps frame 0 only when dimmed. Every other view, idle or not, animates.
543 */
544export function mood(v: SievePaneView | null, b: Band | null): { rest: boolean; dim: boolean } {
545  const dim = !!v && ((impactOf(v, b).n ?? 0) > 20 || v.why.some(d => d.superseded))
546  return { dim, rest: dim }
547}
548
549export type Art = { name: string; frame: number }
550const GLYPHS = '▁▂▃▄▅▆▇█'
551
552/** The bar chart of the last days: one glyph pair per day, oldest left, today (the last) green. */
553export const chart = (days: number[]) => {
554  const top = Math.max(0, ...days)
555  return days.map(v => GLYPHS[top ? Math.round((v / top) * 7) : 0]!)
556}
557
558/** The Radar tree. `width` is the pane width in cells. Pure: it makes no query. */
559export function radar(ui: { Box: any; Text: any; Raster?: any; Svg?: any }, width: number, v: SievePaneView | null, b: Band | null, s: Stats | null, art: Art = { name: DEFAULT_MASCOT, frame: 0 }) {
560  const { Box, Text, Raster, Svg } = ui
561  const w = Math.max(width - 2, 24) - 1 // the padding takes 2 cells (the engine's pane draws the frame), the body has 24 at least, 1 cell stays free
562  const T = (props: Json, text: string, n = w) => <Text {...props}>{cut(text, n)}</Text>
563  const title = (t: string) => T({ bold: true, color: C.purple }, t)
564  const blank = () => <Text> </Text> // 1 blank row before a section
565  const out: any[] = []
566  const edit = !!v && EDITS.includes(v.tool)
567  const m = mood(v, b)
568  const noncode = !!v && !v.query && !v.symbol && !v.symbols && !isCode(v.file)
569
570  // 1. the header: the mascot, then the name and the meta
571  const shape = art.name !== 'none' && (Raster || Svg) ? mascotWords(art.name, m.rest ? 0 : art.frame, m.dim) : undefined
572  const showArt = !!shape && w >= shape.columns + 2 + 17 // the text column needs 17 cells, 2 cells stay between the mascot and the text
573  const tw = showArt ? w - shape!.columns - 2 : w
574  const head: any[] = []
575  head.push(T({ bold: true, color: C.fg }, v ? (v.symbols ? base(v.file) : (v.symbol ?? base(v.file))) : 'SIEVE', tw)) // a file view names the file, the symbol below it is the most called one
576  if (!v) head.push(<Text color={C.text} wrap="wrap">No file yet. The pane fills when the agent reads or edits a file.</Text>)
577  else if (noncode) head.push(<Text color={C.text} wrap="wrap">{NOT_CODE}</Text>)
578  else {
579    const kind = v.symbols ? undefined : v.kind === 'function' ? 'fn' : v.kind
580    const when = v.query ? v.query.sub : edit ? (v.at ? `edited ${clock(v.at)}` : 'edited') : 'read'
581    // The title is the file name or the symbol. Never repeat the file name: a file view shows the folder path.
582    const folder = v.file.includes('/') ? v.file.slice(0, v.file.lastIndexOf('/')) : ''
583    const loc = v.symbols || !v.symbol ? folder : `${base(v.file)}${v.line ? `:${v.line}` : ''}`
584    const one = [loc, kind, when].filter(Boolean).join(' · ')
585    if (one.length <= tw) head.push(T({ color: C.text }, one, tw))
586    else head.push(T({ color: C.text }, [loc, kind].filter(Boolean).join(' · '), tw), T({ color: C.text }, when, tw))
587    if (v.note) head.push(<Text color={C.text} wrap="wrap">{v.note.replace(/^[✗✓] /, '')}</Text>)
588  }
589  const art2 = !showArt ? null : Raster ? <Raster key="mascot" columns={shape!.columns} rows={shape!.rows} cells={packCells(shape!.words)} /> : (
590    <Svg source={mascotSvg(art.name, m.rest ? 0 : art.frame, m.dim)!} alt={`${MASCOTS[art.name]!.name}`} width={shape!.columns * 4} height={shape!.rows * 8} />
591  )
592  out.push(
593    <Box gap={2} alignItems="flex-start">
594      {art2}
595      <Box flexDirection="column" width={tw}>
596        {head}
597      </Box>
598    </Box>,
599  )
600
601  if (v?.query) {
602    out.push(T({ color: C.cyan }, `sieve ${v.query.sub} ${cut(v.query.arg, 40)}`))
603    if (v.query.saved) out.push(T({ bold: true, color: C.green }, `+${v.query.saved.toLocaleString('en-US')} tokens saved`))
604  }
605
606  // 2. the stat tiles
607  const imp = v && !noncode ? impactOf(v, b) : { n: undefined, hops: undefined }
608  const tiles: [string, string, string][] = []
609  if (s) tiles.push(['SAVED TODAY', kfmt(s.tokens), C.green], ['THIS WEEK', kfmt(s.spark.reduce((a, x) => a + x, 0)), C.blue])
610  if (imp.n !== undefined) tiles.push(['IMPACT', String(imp.n), levelColor(imp.n)])
611  if (tiles.length) {
612    // Each tile fits its text, 4 cells wider (border and padding). When the row is too wide, the tiles share it in equal parts.
613    const fit = tiles.map(([k, val]) => Math.max(k.length, val.length) + 4)
614    const share = Math.floor((w - (tiles.length - 1)) / tiles.length)
615    const widths = fit.reduce((a, x) => a + x, 0) + tiles.length - 1 <= w ? fit : fit.map(() => share)
616    out.push(
617      <Box gap={1}>
618        {tiles.map(([k, val, color], i) => {
619          const tile = widths[i]!
620          return (
621            <Box flexDirection="column" borderStyle="round" borderColor={C.border} paddingX={1} width={tile}>
622              {T({ color: C.text }, k, tile - 4)}
623              {T({ bold: true, color }, val, tile - 4)}
624            </Box>
625          )
626        })}
627      </Box>,
628    )
629  }
630
631  if (v && !noncode) {
632    if (v.symbols) {
633      out.push(blank(), title('MOST CALLED SYMBOLS'))
634      out.push(...(v.symbols.length ? v.symbols.map(c => T({ color: C.cyan }, `${c.name}:${c.line}   ${c.n} ${c.n === 1 ? 'caller' : 'callers'}`)) : [T({ color: C.text }, 'none')]))
635    }
636    if (v.symbol) {
637      // 3. who depends on this
638      out.push(blank(), title('WHO DEPENDS ON THIS'))
639      out.push(T({ bold: true, color: C.fg }, `◉ ${v.symbol}`))
640      const tl = treeLines(v.tree, v.n)
641      out.push(
642        ...tl.rows.map(r => (
643          <Box>
644            {T({ color: C.dim }, r.text.slice(0, r.pre), w)}
645            {T({ color: r.d === 1 ? C.cyan : C.blue }, r.text.slice(r.pre), w - r.pre)}
646          </Box>
647        )),
648      )
649      if (tl.more > 0) out.push(T({ color: C.text }, `${tl.indent}+${tl.more} more`))
650      if (!v.tree.length) out.push(T({ color: C.text }, 'no callers'))
651      // 4. the impact bar
652      if (imp.n !== undefined) {
653        const on = Math.min(BAR, Math.round(imp.n * 1.4))
654        out.push(blank(), title('IMPACT'))
655        out.push(T({ color: levelColor(imp.n) }, `${'█'.repeat(on)}${'░'.repeat(BAR - on)}  ${imp.n} ${imp.n === 1 ? 'caller' : 'callers'} · ${imp.hops} ${imp.hops === 1 ? 'hop' : 'hops'}`))
656      }
657    }
658    // 5. why it exists
659    if (v.symbol || v.symbols) {
660      out.push(blank(), title('WHY IT EXISTS'))
661      if (!v.why.length) out.push(T({ color: C.text }, 'no decision recorded'))
662      for (const d of v.why) {
663        const mark = d.superseded ? C.red : C.yellow
664        out.push(
665          <Box>
666            {T({ color: mark }, '▍', 1)}
667            {T({ color: d.superseded ? C.red : C.fg }, `${d.date ? `${d.date}  ` : ''}${d.heading}`, w - 1)}
668          </Box>,
669        )
670        if (d.superseded) out.push(<Box>{T({ bold: true, color: C.red }, `▍⚠ SUPERSEDED${d.by ? ` by ${d.by}` : ''}`)}</Box>)
671      }
672    }
673    // 6. the 7-day chart
674    if (s && s.spark.length) {
675      out.push(blank(), title('SAVED · LAST 7 DAYS'))
676      out.push(
677        <Box gap={1}>
678          {chart(s.spark).map((g, i, all) => (
679            <Text color={i === all.length - 1 ? C.green : C.blue}>{g + g}</Text>
680          ))}
681        </Box>,
682      )
683    }
684  }
685  return (
686    <Box flexDirection="column" paddingX={1} paddingTop={1}>
687      {out}
688    </Box>
689  )
690}
691
692// --- the animation ---
693/** What the worker needs to animate. The Pane render hook fills `view`, `band`, `stats`, `open` and `drawn`. */
694export const anim: {
695  name: string
696  open: boolean
697  isRaster: boolean
698  ticks: number
699  frame: 0 | 1
700  wag: number
701  seen?: number
702  drawn: string
703  view: SievePaneView | null
704  band: Band | null
705  stats: Stats | null
706} = { name: DEFAULT_MASCOT, open: false, isRaster: false, ticks: 0, frame: 0, wag: 0, drawn: '', view: null, band: null, stats: null }
707
708const artKey = (name: string, frame: number, dim: boolean) => `${name}:${frame}:${dim}`
709
710/**
711 * One animation step, run from the 300 ms worker tick. It works only while the pane is open, on every 2nd tick (600 ms).
712 * A new saving shows frame 1 for 2 steps. Else the 2 frames alternate. A red impact or a SUPERSEDED decision rests on frame 0, dimmed.
713 * It blits (Raster) or invalidates (Svg) only when the frame, the dim flag or the mascot changed.
714 * Once per step it asks `$.ui.panes()`. If the pane is not listed, the pane closed: the animation stops until the next draw.
715 */
716export async function animStep($: Pick<Engine, 'ui'>, a = anim): Promise<void> {
717  if (!a.open || a.name === 'none' || ++a.ticks % 2) return
718  const listed = await $.ui.panes().then(ps => ps.some(p => p.id === PANE), () => false)
719  if (!listed) return void Object.assign(a, { open: false, drawn: '' })
720  const m = mood(a.view, a.band)
721  const t = a.stats?.tokens
722  if (t !== undefined && a.seen !== undefined && t > a.seen) a.wag = 2
723  if (t !== undefined) a.seen = t
724  if (m.rest) a.wag = 0
725  a.frame = m.rest ? 0 : a.wag > 0 ? (a.wag--, 1) : a.frame ? 0 : 1
726  const key = artKey(a.name, a.frame, m.dim)
727  if (key === a.drawn) return
728  a.drawn = key
729  try {
730    if (!a.isRaster) return void $.ui.invalidate('ui.render')
731    const shape = mascotWords(a.name, a.frame, m.dim)
732    if (!shape) return
733    const r = await $.ui.blit({ requestId: PANE, key: 'mascot', cells: packCells(shape.words) })
734    if (r?.deny) Object.assign(a, { open: false, drawn: '' })
735  } catch {
736    Object.assign(a, { open: false, drawn: '' }) // a blit that throws stops the animation until the next draw
737  }
738}
739
740/** The mascot name from the project's `.sieve/config.json`, or undefined. Never throws. */
741export async function readMascot($: Pick<Engine, 'session' | 'fs'>): Promise<string | undefined> {
742  try {
743    const cwd = normalize(await $.session.cwd())
744    const name = JSON.parse(String(await $.fs.read(`${cwd}/.sieve/config.json`))).mascot
745    return MASCOT_NAMES.includes(name) ? name : undefined
746  } catch {
747    return undefined
748  }
749}
750
751/** The one worker tick. The flag resets when the module reloads, as the timer ends then. */
752export const worker = { started: false }
753
754/** Starts the one 300 ms tick: the pane step, the blast step, the stats step, then the animation step. */
755export function startWorker(
756  $: Pick<Engine, 'clock' | 'process' | 'fs' | 'state' | 'ui'>,
757  put: Put,
758  putBand: (b: Band | null) => Promise<void>,
759  putStats: (s: Stats | null) => Promise<void> = async () => {},
760) {
761  if (worker.started) return
762  worker.started = true
763  $.clock.every(300, async () => {
764    await step($, put).catch(() => {})
765    const now = await $.clock.now()
766    await blastStep($, now, putBand).catch(() => {})
767    await statsStep($, now, putStats).catch(() => {})
768    await animStep($).catch(() => {})
769  })
770}
771
772export const register: Register = on => {
773  on('session.start', async ($, e, next) => {
774    await $.command.register({ name: 'sieve-pane', description: 'Open the Sieve pane: the callers, the impact and the why of the code' })
775    await $.command.register({ name: 'sieve-mascot', description: `Set and save the pane mascot: ${MASCOT_NAMES.join(', ')}` })
776    anim.name = (await readMascot($)) ?? DEFAULT_MASCOT
777    // The worker starts here, with this hook's $. reference.md, "Work that outlives a dispatch", lines 129-133:
778    // "Work meant to outlive a dispatch belongs elsewhere: start it from a `session.start` hook, ...
779    //  and keep it going with `$.clock.every` and `$.clock.after`, whose timers run until cancelled or until the module reloads."
780    const put: Put = async v => void (await update($, view, () => v))
781    const putBand = async (b: Band | null) => void (await update($, band, () => b))
782    const putStats = async (s: Stats | null) => void (await update($, stats, () => s))
783    startWorker($, put, putBand, putStats)
784    return next(e)
785  })
786
787  on('command.run', { command: 'sieve-pane' }, async $ => {
788    await $.ui.open({ id: PANE, title: 'Sieve' })
789    return {} // No text: the model reads the command output.
790  })
791
792  // The choice goes to <project>/.sieve/config.json, which the status line reads too.
793  on('command.run', { command: 'sieve-mascot' }, async ($, e) => {
794    const name = String(e.args ?? '').trim().toLowerCase()
795    if (!MASCOT_NAMES.includes(name)) return $.ui.toast(`Usage: /sieve-mascot <${MASCOT_NAMES.join('|')}>. Now: ${anim.name}.`), {}
796    const file = `${normalize(await $.session.cwd())}/.sieve/config.json`
797    let cfg: Json = {}
798    try {
799      const text = String(await $.fs.read(file))
800      try {
801        cfg = JSON.parse(text)
802        if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw 0
803      } catch {
804        return $.ui.toast(`${file} is not valid JSON. The mascot is not saved.`), {}
805      }
806    } catch {} // no file yet: start a new one
807    try {
808      await $.fs.write(file, JSON.stringify({ ...cfg, mascot: name }, null, 2) + '\n')
809    } catch {
810      return $.ui.toast(`Cannot write ${file}. The mascot is not saved.`), {}
811    }
812    Object.assign(anim, { name, drawn: '' })
813    $.ui.invalidate('ui.render')
814    $.ui.toast(`Sieve mascot: ${name} (saved to .sieve/config.json)`)
815    return {} // No text: the model reads nothing.
816  })
817
818  on('tool.call', ($, e, next) => onToolCall($, e, next))
819
820  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
821    const ui = $.ui.resolve(e) as Json
822    const [v, b, s] = [await read($, view), await read($, band), await read($, stats)]
823    // Record what this draw shows, so the worker blits only a change.
824    if (!anim.open) anim.seen = s?.tokens
825    const m = mood(v, b)
826    Object.assign(anim, { open: true, isRaster: !!ui.Raster, view: v, band: b, stats: s, drawn: artKey(anim.name, m.rest ? 0 : anim.frame, m.dim) })
827    return radar(ui as never, Number(e.props?.bodyColumns) || Number(e.viewport?.columns) || 44, v, b, s, { name: anim.name, frame: anim.frame })
828  })
829
830  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => bandRender($, e, next, await read($, band)))
831}
832
hooks/mascots.ts 336 lines
1// Generated by scripts/gen-mascots.mjs from assets/mascots.json. Do not edit.
2export const PALETTE: Record<string, string> = {"k":"#16161e","f":"#c0caf5","s":"#e8b86d","S":"#b98a4a","m":"#3b4261","w":"#ffffff","b":"#7aa2f7","c":"#7dcfff","g":"#9aa5c8","o":"#b86e3c","r":"#f7768e","p":"#bb9af7","y":"#e0af68","t":"#73daca","d":"#565f89","n":"#9c6644","N":"#6f4628","l":"#c08a5c","W":"#eef0f6","G":"#d5dbef","h":"#c8cfe8","M":"#b4bcd9","O":"#e39a5f","P":"#f4a6b0","B":"#8fb4d8","Q":"#e2e6f3","V":"#9ea3b8"}
3export const MASCOTS: Record<string, { name: string; frames: string[][]; small: string[][] }> = {
4 "sieve": {
5  "name": "Sieve — the original sieve",
6  "frames": [
7   [
8    "...............G",
9    "..............G.",
10    ".............G..",
11    "............G...",
12    "...........G....",
13    "g.GGGGGGGGGGg...",
14    ".gGddddddddGg...",
15    "..gQMmMmMmMg....",
16    "..gQmMmMmMmg....",
17    "...gMmMmMmg.....",
18    "....gWMWMg......",
19    "......W.........",
20    "...W.....W..W...",
21    ".W....W.......W."
22   ],
23   [
24    "...............G",
25    "..............G.",
26    ".............G..",
27    "............G...",
28    "...........G....",
29    "g.GGGGGGGGGGg...",
30    ".gGddddddddGg...",
31    "..gQMmMmMmMg....",
32    "..gQmMmMmMmg....",
33    "...gMmMmMmg.....",
34    "....gWMWMg......",
35    "....W....W......",
36    ".W.....W.....W..",
37    "....W.......W..."
38   ]
39  ],
40  "small": [
41   [
42    "...........G",
43    "..........G.",
44    "gGGGGGGGGg..",
45    ".gQMmMmMg...",
46    "..gMmMmg....",
47    "...W..W..W.."
48   ],
49   [
50    "...........G",
51    "..........G.",
52    "gGGGGGGGGg..",
53    ".gQMmMmMg...",
54    "..gMmMmg....",
55    "..W..W..W..."
56   ]
57  ]
58 },
59 "soya": {
60  "name": "Soya — the brown poodle",
61  "frames": [
62   [
63    "....lnlnl.......",
64    "...lnlnlnl......",
65    "...nnnnnnnn.....",
66    "..NnnnnnnnnN....",
67    ".NNkkwnnkkwNN...",
68    ".NNkkknnkkkNN...",
69    ".NNPnnkknnPNN...",
70    "..NnnnnrnnnN....",
71    "...nnnnnnnn...ll",
72    "....nnnnnn...lnl",
73    "...nlnlnlnlnnnl.",
74    "...nnnnnnnnnnn..",
75    "...nn.nn..nn.nn.",
76    "...NN.NN..NN.NN."
77   ],
78   [
79    "....lnlnl.......",
80    "...lnlnlnl......",
81    "...nnnnnnnn.....",
82    "..NnnnnnnnnN....",
83    ".NNnnnnnnnnNN...",
84    ".NNkkknnkkkNN...",
85    ".NNPnnkknnPNN...",
86    "..NnnnnrnnnN..ll",
87    "...nnnnnnnn..lnl",
88    "....nnnnnn...ll.",
89    "...nlnlnlnlnnn..",
90    "...nnnnnnnnnnn..",
91    "...nn.nn..nn.nn.",
92    "...NN.NN..NN.NN."
93   ]
94  ],
95  "small": [
96   [
97    "..lnlnl...",
98    ".NnnnnnnN.",
99    "NNkwnnkwNN",
100    "NPnnkknnPN",
101    ".NnnnrnnN.",
102    "..nn..nn.."
103   ],
104   [
105    "..lnlnl...",
106    ".NnnnnnnN.",
107    "NNkknnkkNN",
108    "NPnnkknnPN",
109    ".NnnnrnnN.",
110    "..nn..nn.."
111   ]
112  ]
113 },
114 "cloud": {
115  "name": "Cloud — the cat",
116  "frames": [
117   [
118    "................",
119    "..OO........OO..",
120    "..OPO......OPO..",
121    "..OOOOOOOOOOOO..",
122    ".OOOOOOOOOOOOOO.",
123    ".OOkkwOOOOkkwOO.",
124    ".OOkkkOWWOkkkOO.",
125    ".OPPOOWWWWOOPPO.",
126    ".OOOOWWPPWWOOOO.",
127    "..OOOWWWWWWOOO..",
128    "...OOOWWWWOOO...",
129    "..OOOWWWWWWOOO.O",
130    "..OOOWWWWWWOOOOO",
131    "..WWW.OOOO.WWW.."
132   ],
133   [
134    "................",
135    "..OO........OO..",
136    "..OPO......OPO..",
137    "..OOOOOOOOOOOO..",
138    ".OOOOOOOOOOOOOO.",
139    ".OOOOOOOOOOOOOO.",
140    ".OOkkkOWWOkkkOO.",
141    ".OPPOOWWWWOOPPO.",
142    ".OOOOWWPPWWOOOO.",
143    "..OOOWWWWWWOOO..",
144    "...OOOWWWWOOO...",
145    "..OOOWWWWWWOOOO.",
146    "..OOOWWWWWWOOOOO",
147    "..WWW.OOOO.WWW.."
148   ]
149  ],
150  "small": [
151   [
152    ".O......O.",
153    ".OPOOOOPO.",
154    "OOkwOOkwOO",
155    "OPkkWWkkPO",
156    ".OWWPPWWO.",
157    "..WWWWWW.."
158   ],
159   [
160    ".O......O.",
161    ".OPOOOOPO.",
162    "OOOOOOOOOO",
163    "OPkkWWkkPO",
164    ".OWWPPWWO.",
165    "..WWWWWW.."
166   ]
167  ]
168 },
169 "sifty": {
170  "name": "Sifty — the mesh bowl",
171  "frames": [
172   [
173    "......y..y......",
174    "...y........y...",
175    "................",
176    ".ssssssssssssss.",
177    "sSSSSSSSSSSSSSSs",
178    "sSkkSSSSSSSSkkSs",
179    "sSkwSSSSSSSSkwSs",
180    "sSSSSSSkkSSSSSSs",
181    ".sSSSSSSSSSSSSs.",
182    "..mmmmmmmmmmmm..",
183    "...m.m.m.m.m.m..",
184    "................",
185    ".....y....y.....",
186    "................"
187   ],
188   [
189    "...y........y...",
190    "................",
191    "......y..y......",
192    ".ssssssssssssss.",
193    "sSSSSSSSSSSSSSSs",
194    "sSSSSSSSSSSSSSSs",
195    "sSkkSSSSSSSSkkSs",
196    "sSSSSSSkkSSSSSSs",
197    ".sSSSSSSSSSSSSs.",
198    "..mmmmmmmmmmmm..",
199    "..m.m.m.m.m.m...",
200    ".......y........",
201    "................",
202    "....y......y...."
203   ]
204  ],
205  "small": [
206   [
207    ".ssssssss.",
208    "sSkSSSSkSs",
209    "sSSSkkSSSs",
210    ".mmmmmmmm.",
211    "..m.m.m.m.",
212    "...y..y..."
213   ],
214   [
215    ".ssssssss.",
216    "sSSSSSSSSs",
217    "sSkSkkSkSs",
218    ".mmmmmmmm.",
219    ".m.m.m.m..",
220    "....y..y.."
221   ]
222  ]
223 },
224 "hoot": {
225  "name": "Hoot — the code owl",
226  "frames": [
227   [
228    "................",
229    "...pp......pp...",
230    "...pppppppppp...",
231    "..pppppppppppp..",
232    "..pwwwppppwwwp..",
233    "..pwkwppppwkwp..",
234    "..pwwwppppwwwp..",
235    "..pppppyyppppp..",
236    "..ppfpffffpfpp..",
237    "..ppfffffffffp..",
238    "...pfpfffpfpp...",
239    "...pppppppppp...",
240    "....yy....yy....",
241    "................"
242   ],
243   [
244    "................",
245    "...pp......pp...",
246    "...pppppppppp...",
247    "..pppppppppppp..",
248    "..pppppppppppp..",
249    "..pkkkppppkkkp..",
250    "..pppppppppppp..",
251    "..pppppyyppppp..",
252    "..ppfpffffpfpp..",
253    "..ppfffffffffp..",
254    "...pfpfffpfpp...",
255    "...pppppppppp...",
256    "....yy....yy....",
257    "................"
258   ]
259  ],
260  "small": [
261   [
262    ".p......p.",
263    ".pppppppp.",
264    "pwkppppwkp",
265    "ppppyypppp",
266    ".pfpffpfp.",
267    "..yy..yy.."
268   ],
269   [
270    ".p......p.",
271    ".pppppppp.",
272    "pkkppppkkp",
273    "ppppyypppp",
274    ".pfpffpfp.",
275    "..yy..yy.."
276   ]
277  ]
278 },
279 "grit": {
280  "name": "Grit — the sieve bot",
281  "frames": [
282   [
283    ".......g........",
284    ".......d........",
285    "....tttttttt....",
286    "...tttttttttt...",
287    "...tkwttttkwt...",
288    "...tttttttttt...",
289    "...ttttddtttt...",
290    "....tttttttt....",
291    "..dmmmmmmmmmmd..",
292    "..dm.m.m.m.mmd..",
293    "..dmm.m.m.m.md..",
294    "..dmmmmmmmmmmd..",
295    "....dd....dd....",
296    "................"
297   ],
298   [
299    ".......o........",
300    ".......d........",
301    "....tttttttt....",
302    "...tttttttttt...",
303    "...tkwttttkwt...",
304    "...tttttttttt...",
305    "...tttddddttt...",
306    "....tttttttt....",
307    "..dmmmmmmmmmmd..",
308    "..dmm.m.m.m.md..",
309    "..dm.m.m.m.mmd..",
310    "..dmmmmmmmmmmd..",
311    "...dd......dd...",
312    "................"
313   ]
314  ],
315  "small": [
316   [
317    "....g.....",
318    ".tttttttt.",
319    ".tkwttkwt.",
320    ".tttddttt.",
321    "dmm.mm.mmd",
322    "..dd..dd.."
323   ],
324   [
325    "....o.....",
326    ".tttttttt.",
327    ".tkwttkwt.",
328    ".ttddddtt.",
329    "dm.mm.mm.d",
330    ".dd....dd."
331   ]
332  ]
333 }
334}
335export const DEFAULT_MASCOT = "sieve"
336
types/index.d.ts 39 lines
1// The view that the pane draws. One new tool call replaces the old view.
2/** One tree row. `d` is the depth: 1 is a direct caller, 2 is a caller of a caller. */
3export type Row = { d: 1 | 2; /** The index of the depth-1 caller that owns the row. */ p: number; label: string; tail: string }
4/** One recorded decision. */
5export type Why = { date: string; heading: string; superseded: boolean; by?: string }
6/** A Sieve query that the agent ran by Bash or MCP. `saved` is the sum of the `[sieve] saved` lines. */
7export type Query = { sub: string; arg: string; saved: number }
8export type SievePaneView = {
9  query?: Query
10  file: string
11  /** The tool that made the job: Read, Edit, MultiEdit or Write. */
12  tool: string
13  /** When the job started, in ms. */
14  at?: number
15  symbol?: string
16  kind?: string
17  line?: number
18  why: Why[]
19  /** The callers to depth 2, as rows. */
20  tree: Row[]
21  /** The caller count and the hops to depth 2. */
22  n?: number
23  hops?: number
24  /** The most called symbols of a file. Set by a whole-file Read. */
25  symbols?: { name: string; line: number; n: number }[]
26  /** One line that says why the view is empty, such as "sieve not found". */
27  note?: string
28}
29/** The blast of the working diff: the band text and the impact bar read it. */
30export type Band = { sym: string; more?: number; n: number; hops: number; routes: number }
31/** The savings numbers. `spark` holds the last 7 days of tokens, oldest first. */
32export type Stats = { tokens: number; dollars: number | null; spark: number[] }
33
34declare module 'claude-code' {
35  interface PluginState {
36    'sieve-pane': { view: SievePaneView | null; band: Band | null; stats: Stats | null }
37  }
38}
39