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

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:
file:line · kind · edited 9:58pm in dim.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.
hooks/register.tsx 832 lines1import { 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}
832hooks/mascots.ts 336 lines1// 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"
336types/index.d.ts 39 lines1// 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