KOZMOS Terminus: the session's shell history in a sidebar. Every Bash and PowerShell call of the main loop and its subagents with command, description, cwd…

The session's shell history in a sidebar. Every Bash and PowerShell tool call — from the main loop and from every subagent — is recorded with its command, the model's description, the working directory (an input cwd or a leading cd dir &&), exit status or error line, duration, output size, who ran it (main or the subagent's description) and the time.
Run again: <command>./terminus toggles the pane, /terminus list prints the last runs. Set autoOpen to open it on session start.
Oturumun kabuk geçmişini kenar çubuğunda gösterir. Ana döngüden ve tüm alt ajanlardan gelen her Bash ve PowerShell çağrısı; komut, modelin açıklaması, çalışma dizini (girdideki cwd ya da baştaki cd dizin &&), çıkış durumu ya da hata satırı, süre, çıktı boyutu, çalıştıran (main ya da alt ajanın açıklaması) ve saatle kaydedilir.
Run again: <komut> ile doldurur./terminus paneli açar/kapatır, /terminus list son çalıştırmaları yazar. Oturum başında açılması için autoOpen ayarını açın.
hooks/register.tsx 246 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShellRun, TerminusFilter, TerminusSnap, TerminusView } from '../types'
5import { KZ, clip, fmtTokens, pxOf, sparkline } from './lib/kz.ts'
6import {
7 MAX_COMMAND, MAX_RUNS, SLOW_MS, altOf, clockOf, cwdOf, durHeat, emptySnap, firstLine, fmtMs, fmtSize, headerSvg, isFailed,
8 isSlow, matches, outcomeOf, runSvg, statusColor, statusGlyph, statusLine,
9} from './shell.ts'
10
11const PANE = 'kz-terminus'
12const TITLE = 'KOZMOS · Terminus'
13const SHOWN = 60
14const snapAtom = atom({ plugin: 'terminus', key: 'snap' } as const, null)
15const viewAtom = atom({ plugin: 'terminus', key: 'view' } as const, { filter: 'all' })
16
17const FILTERS: readonly { id: TerminusFilter; label: string }[] = [
18 { id: 'all', label: 'All' },
19 { id: 'failed', label: 'Failed' },
20 { id: 'slow', label: 'Slow >10s' },
21]
22
23// ---------------------------------------------------------------------------
24// The collector: module state, published to $.state after each change (a run
25// started, a run ended: every publish follows one).
26
27let live: TerminusSnap = emptySnap()
28let isSeeded = false
29let seq = 0
30const agentNames = new Map<string, string>()
31
32/** After a hot reload: carry on from what the last module published. */
33async function seed($: EngineInterface): Promise<void> {
34 if (isSeeded) return
35 isSeeded = true
36 const kept = await read($, snapAtom)
37 if (kept) live = structuredClone(kept)
38}
39
40async function publish($: EngineInterface): Promise<void> {
41 const next = structuredClone(live)
42 await update($, snapAtom, () => next)
43}
44
45async function whoOf($: EngineInterface, agentId: string | undefined): Promise<string> {
46 if (agentId === undefined) return 'main'
47 const known = agentNames.get(agentId)
48 if (known) return known
49 try {
50 for (const a of await $.agent.list()) agentNames.set(a.id, a.description || a.type)
51 } catch {
52 // The roster is not readable now; fall back to the id.
53 }
54 return agentNames.get(agentId) ?? `agent ${agentId.slice(0, 6)}`
55}
56
57async function toggle($: EngineInterface): Promise<boolean> {
58 if ((await $.ui.panes()).some(p => p.id === PANE)) {
59 await $.ui.close({ id: PANE })
60 return false
61 }
62 await $.ui.open({ id: PANE, title: TITLE })
63 return true
64}
65
66function summary(s: TerminusSnap): string {
67 const lines = [`Terminus: ${s.total} shell commands, ${s.failures} failed, ${fmtMs(s.totalMs)} in the shell.`]
68 for (const r of s.runs.slice(0, 5)) lines.push(` ${statusGlyph(r.status)} ${fmtMs(r.ms).padStart(6)} ${clip(r.command, 70)}`)
69 return lines.join('\n')
70}
71
72export const register: Register = (on, options) => {
73 on('session.start', async ($, e, next) => {
74 const started = await next(e)
75 live = emptySnap()
76 isSeeded = true
77 agentNames.clear()
78 await $.command.register({ name: 'terminus', description: 'KOZMOS: toggle the Terminus shell-history sidebar (/terminus list prints the last runs)', argumentHint: '[list]', immediate: true })
79 await publish($).catch(() => undefined)
80 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
81 return started
82 })
83
84 on('command.run', { command: 'terminus' }, async ($, e) => {
85 if (e.args.trim() === 'list') {
86 await seed($)
87 return { text: summary(live) }
88 }
89 return { text: (await toggle($)) ? 'Terminus open.' : 'Terminus closed.' }
90 })
91
92 on('agent.spawn', async ($, e, next) => {
93 const r = await next(e)
94 if (r.deny === undefined && r.agentId) agentNames.set(r.agentId, e.description || e.subagentType)
95 return r
96 }).catch(($, e, next) => next(e))
97
98 on('tool.call', async ($, e, next) => {
99 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
100 await seed($)
101 const input = e as unknown as Record<string, unknown>
102 const command = typeof input.command === 'string' ? input.command : ''
103 const description = typeof input.description === 'string' && input.description.trim() ? clip(input.description, 140) : undefined
104 const run: ShellRun = {
105 id: e.tool_use_id || `r${++seq}`,
106 tool: e.tool,
107 command: command.length > MAX_COMMAND ? command.slice(0, MAX_COMMAND) + '…' : command,
108 description,
109 cwd: cwdOf(command, input),
110 at: await $.clock.now(),
111 status: 'running',
112 who: await whoOf($, e.agentId),
113 }
114 live.runs = [run, ...live.runs.filter(r => r.id !== run.id)].slice(0, MAX_RUNS)
115 live.total++
116 void publish($).catch(() => undefined)
117
118 const ran = await next(e)
119 const out = outcomeOf(ran)
120 const ms = Math.max(0, (await $.clock.now()) - run.at)
121 const done: ShellRun = { ...run, ms, status: out.status, exit: out.exit, error: out.error, outBytes: out.outBytes }
122 live.runs = live.runs.map(r => (r.id === run.id ? done : r))
123 if (isFailed(done)) live.failures++
124 if (isSlow(done)) live.slow++
125 live.totalMs += ms
126 await publish($).catch(() => undefined)
127 return ran
128 }).catch(($, e, next) => next(e))
129
130 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
131 const snap = (await read($, snapAtom)) ?? emptySnap()
132 const view: TerminusView = await read($, viewAtom)
133 const ui = $.ui.resolve(e)
134 const { Box, Text, Button } = ui
135 const shown = snap.runs.filter(r => matches(r, view.filter))
136 const counts: Record<TerminusFilter, number> = {
137 all: snap.runs.length,
138 failed: snap.runs.filter(isFailed).length,
139 slow: snap.runs.filter(isSlow).length,
140 }
141 const filterRow = (
142 <Box key="filters" flexDirection="row" flexWrap="wrap" gap={1}>
143 {FILTERS.map(f => (
144 <Button
145 key={`f-${f.id}`}
146 label={`${view.filter === f.id ? '●' : '○'} ${f.label} ${counts[f.id]}`}
147 plain
148 dimColor={view.filter !== f.id}
149 onPress={() => update($, viewAtom, () => ({ filter: f.id }))}
150 />
151 ))}
152 </Box>
153 )
154 const buttons = (r: ShellRun) => (
155 <Box key={`b-${r.id}`} flexDirection="row" gap={2} flexShrink={0}>
156 <Button key={`cp-${r.id}`} label="copy" plain dimColor onPress={p => void $.ui.copy({ text: r.command, surface: p.surface }).catch(() => undefined)} />
157 <Button key={`rp-${r.id}`} label="↩ prompt" plain dimColor onPress={() => void $.prompt.fill({ text: `Run again: ${r.command}` }).catch(() => undefined)} />
158 </Box>
159 )
160 const more = shown.length > SHOWN ? <Text key="more" dimColor>… {shown.length - SHOWN} older runs not shown</Text> : null
161 const empty = shown.length === 0
162 ? <Text key="empty" dimColor>{snap.total === 0 ? 'No shell commands yet. Bash and PowerShell runs land here.' : 'Nothing matches this filter.'}</Text>
163 : null
164
165 if ('Svg' in ui && e.surface !== 'terminal') {
166 const { Svg } = ui
167 const W = pxOf(e.props.bodyColumns, 44)
168 const head = headerSvg(snap, W)
169 return (
170 <Box flexDirection="column" gap={1}>
171 <Svg key="head" source={head.source} alt={`Terminus: ${snap.total} commands, ${snap.failures} failed, ${fmtMs(snap.totalMs)} of shell time`} width={W} height={head.height} />
172 {filterRow}
173 {shown.slice(0, SHOWN).map(r => {
174 const card = runSvg(r, W)
175 return (
176 <Box key={`row-${r.id}`} flexDirection="column">
177 <Svg key={`svg-${r.id}`} source={card.source} alt={altOf(r)} width={W} height={card.height} />
178 {buttons(r)}
179 </Box>
180 )
181 })}
182 {empty}
183 {more}
184 </Box>
185 )
186 }
187
188 const cols = Math.max(30, e.props.bodyColumns || 44)
189 const last = snap.runs.slice(0, Math.max(8, cols - 6)).reverse()
190 // One character per run, oldest left.
191 const spark = sparkline(last.map(r => Math.log10(1 + (r.ms ?? 0) / 100)), last.length)
192 return (
193 <Box flexDirection="column">
194 <Box flexDirection="row" justifyContent="space-between">
195 <Text bold color={KZ.green}>$_ TERMINUS</Text>
196 <Text color={snap.runs.some(r => r.status === 'running') ? KZ.violet : KZ.mist}>{snap.total} cmds</Text>
197 </Box>
198 <Text wrap="truncate-end">
199 <Text color={snap.failures ? KZ.red : KZ.mist}>✖ {snap.failures} failed</Text>
200 <Text dimColor> · </Text>
201 <Text color={KZ.cyan}>⏱ {fmtMs(snap.totalMs)} shell</Text>
202 <Text dimColor> · </Text>
203 <Text color={snap.slow ? KZ.amber : KZ.mist}>{snap.slow} slow</Text>
204 </Text>
205 {last.length > 0 && (
206 <Text wrap="truncate-end">
207 <Text dimColor>time </Text>
208 {last.map((r, i) => (
209 <Text key={`sp${i}`} color={isFailed(r) ? KZ.red : durHeat(r.ms)}>{spark.charAt(i)}</Text>
210 ))}
211 </Text>
212 )}
213 {filterRow}
214 {shown.slice(0, SHOWN).map(r => {
215 const c = statusColor(r.status)
216 const sub = statusLine(r)
217 return (
218 <Box key={`row-${r.id}`} flexDirection="column" marginTop={1}>
219 <Text wrap="truncate-end">
220 <Text color={c} bold>{statusGlyph(r.status)} </Text>
221 <Text color={KZ.green}>$ </Text>
222 <Text bold>{firstLine(r.command)}</Text>
223 </Text>
224 {r.description && <Text dimColor wrap="truncate-end"> {r.description}</Text>}
225 <Box flexDirection="row" justifyContent="space-between">
226 <Text wrap="truncate-end">
227 <Text dimColor> {clockOf(r.at)} </Text>
228 <Text color={durHeat(r.ms)}>{fmtMs(r.ms)}</Text>
229 <Text dimColor> · {fmtSize(r.outBytes)} · </Text>
230 <Text color={r.who === 'main' ? KZ.mist : KZ.violet}>{r.who === 'main' ? 'main' : `◈ ${r.who}`}</Text>
231 {r.cwd ? <Text dimColor> · ⌂ {r.cwd}</Text> : ''}
232 </Text>
233 {buttons(r)}
234 </Box>
235 {(r.status !== 'ok' || r.error) && sub ? <Text color={r.status === 'ok' ? KZ.mist : c} wrap="truncate-end"> └ {sub}</Text> : null}
236 </Box>
237 )
238 })}
239 {empty}
240 {more}
241 {snap.total > 0 && <Text dimColor wrap="truncate-end">slow = over {SLOW_MS / 1000}s · {fmtTokens(snap.runs.reduce((n, r) => n + (r.outBytes ?? 0), 0))} chars of output kept</Text>}
242 </Box>
243 )
244 })
245}
246hooks/lib/kz.ts 510 lines1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11 violet: '#a78bfa',
12 magenta: '#f472b6',
13 cyan: '#22d3ee',
14 teal: '#2dd4bf',
15 green: '#4ade80',
16 lime: '#a3e635',
17 yellow: '#facc15',
18 amber: '#fb923c',
19 red: '#f87171',
20 blue: '#60a5fa',
21 clay: '#d97757',
22 ink: '#1f1e1d',
23 mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28 const t = String(tool)
29 if (t === 'Bash' || t === 'PowerShell') return KZ.green
30 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31 if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32 if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33 if (t.startsWith('Web')) return KZ.cyan
34 if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35 if (t.startsWith('mcp__')) return KZ.magenta
36 return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41 const t = String(tool)
42 if (t === 'Bash' || t === 'PowerShell') return '$'
43 if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44 if (t === 'Read') return '◉'
45 if (t === 'Glob' || t === 'Grep') return '⌕'
46 if (t === 'Agent') return '◈'
47 if (t.startsWith('Web')) return '◍'
48 if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49 if (t.startsWith('mcp__')) return '⬡'
50 return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57 const h = hex.replace('#', '')
58 const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63 const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64 return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68 const [r1, g1, b1] = hexToRgb(a)
69 const [r2, g2, b2] = hexToRgb(b)
70 const k = clamp01(t)
71 return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76 const k = clamp01(t)
77 return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82 const k = ((h % 1) + 1) % 1
83 const a = s * Math.min(l, 1 - l)
84 const f = (n: number) => {
85 const x = (n + k * 12) % 12
86 return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87 }
88 return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92 const [r, g, b] = hexToRgb(hex)
93 return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102 if (!Number.isFinite(n)) return '—'
103 // Each unit starts where the one below would round up to 1000 of itself.
104 if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105 if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106 if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107 return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111 if (!Number.isFinite(usd)) return '$—'
112 if (usd < 0.01 && usd > 0) return '<$0.01'
113 return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118 const s = Math.max(0, Math.round(ms / 1000))
119 const h = Math.floor(s / 3600)
120 const m = Math.floor((s % 3600) / 60)
121 const ss = String(s % 60).padStart(2, '0')
122 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127 const s = Math.max(0, Math.round(ms / 1000))
128 if (s < 60) return `${s}s`
129 const m = Math.floor(s / 60)
130 if (m < 60) return `${m}m`
131 const h = Math.floor(m / 60)
132 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133 return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137 return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141 const one = s.replace(/\s+/g, ' ').trim()
142 return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146 return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150 return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155 if (!id) return '—'
156 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157 if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158 const fam = m[1]!
159 return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164 const parts = p.split(/[\\/]/).filter(Boolean)
165 return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170 let h = 2166136261
171 for (let i = 0; i < s.length; i++) {
172 h ^= s.charCodeAt(i)
173 h = Math.imul(h, 16777619)
174 }
175 return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180 const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181 return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186 let a = seed >>> 0
187 return () => {
188 a = (a + 0x6d2b79f5) >>> 0
189 let t = a
190 t = Math.imul(t ^ (t >>> 15), t | 1)
191 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193 }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍ `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203 const w = Math.max(1, Math.floor(width))
204 const exact = clamp01(ratio) * w
205 const full = Math.floor(exact)
206 const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207 const used = full + (part ? 1 : 0)
208 return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213 const w = Math.max(1, Math.floor(width))
214 const n = Math.round(clamp01(ratio) * w)
215 return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222 const tail = values.slice(-Math.max(1, width))
223 const top = max ?? Math.max(1e-9, ...tail)
224 const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225 return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230 const samples = values.slice(-(width * 2))
231 const top = max ?? Math.max(1e-9, ...samples)
232 const dotsTall = rows * 4
233 const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234 const offset = width * 2 - samples.length
235 // Dot bits per column, from the bottom row of a cell up.
236 const LEFT = [0x40, 0x04, 0x02, 0x01]
237 const RIGHT = [0x80, 0x20, 0x10, 0x08]
238 samples.forEach((v, i) => {
239 const x = offset + i
240 const col = Math.floor(x / 2)
241 const isRight = x % 2 === 1
242 const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243 for (let d = 0; d <= h; d++) {
244 const row = rows - 1 - Math.floor(d / 4)
245 const bits = isRight ? RIGHT : LEFT
246 const cellRow = grid[row]
247 if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248 }
249 })
250 return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259 let out = ''
260 let i = 0
261 for (; i + 2 < bytes.length; i += 3) {
262 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264 }
265 const rest = bytes.length - i
266 if (rest === 1) {
267 const n = bytes[i]! << 16
268 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269 } else if (rest === 2) {
270 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272 }
273 return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280 readonly cols: number
281 readonly rows: number
282 private readonly words: Uint32Array
283
284 constructor(cols: number, rows: number) {
285 this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286 this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287 this.words = new Uint32Array(this.cols * this.rows * 3)
288 this.clear()
289 }
290
291 clear(bg: number = DEFAULT_COLOR): void {
292 for (let i = 0; i < this.cols * this.rows; i++) {
293 this.words[i * 3] = 0x20
294 this.words[i * 3 + 1] = DEFAULT_COLOR
295 this.words[i * 3 + 2] = bg
296 }
297 }
298
299 /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300 set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301 const cx = Math.floor(x)
302 const cy = Math.floor(y)
303 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304 const i = (cy * this.cols + cx) * 3
305 const code = ch.codePointAt(0) ?? 0x20
306 this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307 if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308 if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309 }
310
311 /** Writes a string left to right, clipped to the canvas. */
312 text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313 let cx = x
314 for (const ch of s) {
315 this.set(cx, y, ch, fg, bg)
316 cx++
317 }
318 }
319
320 /** Paints a cell's background only, keeping its character. */
321 paint(x: number, y: number, bg: string | number): void {
322 const cx = Math.floor(x)
323 const cy = Math.floor(y)
324 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325 this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326 }
327
328 /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329 pixel(x: number, py: number, color: string): void {
330 const cx = Math.floor(x)
331 const cy = Math.floor(py / 2)
332 if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333 const i = (cy * this.cols + cx) * 3
334 const c = hexToInt(color)
335 const isTop = Math.floor(py) % 2 === 0
336 if (this.words[i] !== 0x2580) {
337 this.words[i] = 0x2580
338 this.words[i + 1] = DEFAULT_COLOR
339 this.words[i + 2] = DEFAULT_COLOR
340 }
341 if (isTop) this.words[i + 1] = c
342 else this.words[i + 2] = c
343 }
344
345 /** The `cells` prop of a Raster. */
346 encode(): string {
347 return toBase64(new Uint8Array(this.words.buffer))
348 }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
358
359export function xml(s: string): string {
360 return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378 let w = 0
379 for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380 return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384 if (textWidth(s, size) <= maxW) return s
385 let out = ''
386 for (const ch of s) {
387 if (textWidth(out + ch + '…', size) > maxW) break
388 out += ch
389 }
390 return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395 const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397 (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402 const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403 return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408 return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418 [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419 [/opus-5-5/i, [4, 20, 0.2, 5]],
420 [/opus/i, [5, 25, 0.5, 6.25]],
421 [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422 [/sonnet/i, [3, 15, 0.3, 3.75]],
423 [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424 [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428 input_tokens?: number
429 output_tokens?: number
430 cache_read_input_tokens?: number
431 cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435 return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439 if (!u) return 0
440 const [i, o, r, w] = priceOf(model)
441 return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445 if (!u) return 0
446 return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451 return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455 return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464 if (kind === 'five_hour') return '5h'
465 if (kind === 'seven_day') return '7d'
466 if (kind === 'spend_limit') return '$'
467 return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472 if (!l.resetsAt) return undefined
473 const t = Date.parse(l.resetsAt)
474 return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479 if (kind === 'five_hour') return 5 * 3600_000
480 if (kind === 'seven_day') return 7 * 24 * 3600_000
481 return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489 const e = (input ?? {}) as Record<string, unknown>
490 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491 const t = String(tool)
492 if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493 if (s('file_path')) return baseName(s('file_path'))
494 if (s('notebook_path')) return baseName(s('notebook_path'))
495 if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496 if (s('pattern')) return clip(s('pattern'), 60)
497 if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498 if (s('query')) return clip(s('query'), 60)
499 if (s('subject')) return clip(s('subject'), 60)
500 if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501 if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502 return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507 const t = String(tool)
508 return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510hooks/shell.ts 189 lines1// Terminus: pure helpers (no `$`): reading a shell call's outcome, and the
2// desktop drawings. register.tsx owns the hooks and the terminal tree.
3import type { ShellRun, ShellStatus, TerminusFilter, TerminusSnap } from '../types'
4import { KZ, clip, fitText, heat, svg, svgText, xml } from './lib/kz.ts'
5
6export const SLOW_MS = 10_000
7export const MAX_RUNS = 300
8export const MAX_COMMAND = 2000
9
10export const emptySnap = (): TerminusSnap => ({ runs: [], total: 0, failures: 0, slow: 0, totalMs: 0 })
11
12/** The directory a command runs in: an explicit `cwd` input, else a leading `cd <dir> &&` / `;`. */
13export function cwdOf(command: string, input: Record<string, unknown>): string | undefined {
14 for (const k of ['cwd', 'workdir', 'directory']) {
15 const v = input[k]
16 if (typeof v === 'string' && v.trim()) return v.trim()
17 }
18 const m = /^\s*(?:cd|Set-Location|pushd)\s+(?:-LiteralPath\s+|-Path\s+)?("([^"]+)"|'([^']+)'|([^\s;&|]+))\s*(?:&&|;|\|\|)/i.exec(command)
19 if (!m) return undefined
20 return m[2] ?? m[3] ?? m[4]
21}
22
23const DENIED = /doesn't want to proceed|did not want to proceed|was rejected|permission to use .* (?:was|has been) denied|denied by|not allowed|blocked by/i
24
25/** The first line of an error that says something: not "Exit code N", preferring one that names a failure. */
26export function tellingLine(text: string): string {
27 const lines = text
28 .replace(/\u001b\[[0-9;]*[A-Za-z]/g, '')
29 .split(/\r?\n/)
30 .map(l => l.trim())
31 .filter(l => l && !/^exit code:?\s*-?\d+$/i.test(l) && !/^<\/?[a-z_-]+>$/i.test(l))
32 const loud = lines.find(l => /error|fail|fatal|not found|cannot|can't|unable|denied|refused|exception|no such|invalid|timed? ?out|killed|panic/i.test(l))
33 return clip(loud ?? lines[0] ?? '', 160)
34}
35
36type Ran = { result?: unknown; isError?: boolean; deny?: string; text?: string }
37
38/** What a finished `tool.call` says about the run: its status, exit code, error and output size. */
39export function outcomeOf(ran: Ran): { status: ShellStatus; exit?: number; error?: string; outBytes: number } {
40 if (typeof ran.deny === 'string') return { status: 'denied', error: clip(ran.deny, 160), outBytes: ran.deny.length }
41 const r = (ran.result ?? {}) as Record<string, unknown>
42 if (ran.isError) {
43 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
44 const code = /exit code:?\s*(-?\d+)/i.exec(text)
45 const isDenied = DENIED.test(text)
46 return {
47 status: isDenied ? 'denied' : 'fail',
48 exit: code ? Number(code[1]) : undefined,
49 // A denial always leaves its telling line; only a bare exit code falls back.
50 error: tellingLine(text) || 'failed',
51 outBytes: text.length,
52 }
53 }
54 const out = typeof r.stdout === 'string' ? r.stdout.length : 0
55 const err = typeof r.stderr === 'string' ? r.stderr.length : 0
56 const persisted = typeof r.persistedOutputSize === 'number' ? r.persistedOutputSize : 0
57 const outBytes = persisted || out + err || (typeof ran.text === 'string' ? ran.text.length : 0)
58 if (r.interrupted === true) return { status: 'fail', error: 'interrupted', outBytes }
59 if (typeof r.backgroundTaskId === 'string') return { status: 'bg', outBytes }
60 const note = typeof r.returnCodeInterpretation === 'string' ? r.returnCodeInterpretation : undefined
61 return { status: 'ok', exit: 0, error: note ? clip(note, 160) : undefined, outBytes }
62}
63
64/** A command's first line (a heredoc or a script shows its head). */
65export const firstLine = (s: string): string => s.replace(/\n[\s\S]*/, '')
66
67export const isFailed = (r: ShellRun): boolean => r.status === 'fail' || r.status === 'denied'
68export const isSlow = (r: ShellRun): boolean => (r.ms ?? 0) > SLOW_MS
69
70export function matches(r: ShellRun, f: TerminusFilter): boolean {
71 return f === 'all' || (f === 'failed' ? isFailed(r) : isSlow(r))
72}
73
74/** 420ms, 4.2s, 1m07s. */
75export function fmtMs(ms: number | undefined): string {
76 if (ms === undefined) return '…'
77 if (ms < 1000) return `${Math.round(ms)}ms`
78 if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
79 const s = Math.round(ms / 1000)
80 return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
81}
82
83/** 812B, 4.1k, 2.3M characters of output. */
84export function fmtSize(n: number | undefined): string {
85 if (n === undefined) return '—'
86 if (n < 1000) return `${n}B`
87 if (n < 1e6) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
88 return `${(n / 1e6).toFixed(1)}M`
89}
90
91/** 14:03:22 local time. */
92export function clockOf(at: number): string {
93 const d = new Date(at)
94 const p = (n: number) => String(n).padStart(2, '0')
95 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
96}
97
98export function statusColor(s: ShellStatus): string {
99 return s === 'ok' ? KZ.green : s === 'fail' ? KZ.red : s === 'denied' ? KZ.amber : s === 'bg' ? KZ.blue : KZ.violet
100}
101
102export function statusGlyph(s: ShellStatus): string {
103 return s === 'ok' ? '✓' : s === 'fail' ? '✖' : s === 'denied' ? '⊘' : s === 'bg' ? '◔' : '●'
104}
105
106/** Duration heat: green under a second, red past 30 s. */
107export const durHeat = (ms: number | undefined): string => (ms === undefined ? KZ.violet : heat(Math.log10(Math.max(1, ms / 300)) / 2))
108
109/** The bottom-line of an error or a status, for a row's third line. */
110export function statusLine(r: ShellRun): string {
111 if (r.status === 'running') return 'running…'
112 if (r.status === 'bg') return 'running in the background'
113 const code = r.exit !== undefined && r.exit !== 0 ? `exit ${r.exit}` : r.status === 'denied' ? 'denied' : r.status === 'fail' ? 'failed' : ''
114 return [code, r.error ?? ''].filter(Boolean).join(' · ')
115}
116
117// ---------------------------------------------------------------------------
118// Desktop drawings: one Svg for the header, one per run.
119
120export function headerSvg(s: TerminusSnap, W: number): { source: string; height: number } {
121 const H = 132
122 const parts: string[] = []
123 parts.push(`<defs><linearGradient id="tg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${KZ.green}" stop-opacity=".16"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity=".04"/></linearGradient></defs>`)
124 parts.push(`<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#tg)"/>`)
125 parts.push(`<rect x="14" y="14" width="26" height="26" rx="7" fill="${KZ.green}" opacity=".18"/>`)
126 parts.push(svgText(27, 32, '$_', { size: 13, weight: 800, anchor: 'middle', fill: KZ.green, mono: true }))
127 parts.push(svgText(50, 26, 'TERMINUS', { size: 13, weight: 800 }))
128 parts.push(svgText(50, 40, 'shell history of this session', { cls: 'm', size: 10.5 }))
129 const running = s.runs.filter(r => r.status === 'running').length
130 if (running) parts.push(`<circle cx="${W - 20}" cy="24" r="5" fill="${KZ.violet}" class="pulse"/>` + svgText(W - 30, 28, `${running} running`, { size: 10.5, weight: 600, anchor: 'end', fill: KZ.violet }))
131
132 const tiles: [string, string, string][] = [
133 ['COMMANDS', String(s.total), KZ.green],
134 ['FAILED', String(s.failures), s.failures ? KZ.red : KZ.mist],
135 ['SHELL TIME', fmtMs(s.totalMs), KZ.cyan],
136 ]
137 const tw = (W - 28 - 12) / 3
138 tiles.forEach(([k, v, c], i) => {
139 const x = 14 + i * (tw + 6)
140 parts.push(`<rect class="k" x="${x}" y="52" width="${tw}" height="40" rx="9" opacity=".7"/>`)
141 parts.push(`<rect x="${x + 8}" y="60" width="3" height="24" rx="1.5" fill="${c}"/>`)
142 parts.push(svgText(x + 17, 69, k, { cls: 's', size: 9, weight: 600 }))
143 parts.push(svgText(x + 17, 85, v, { size: 14, weight: 750 }))
144 })
145
146 // Duration bars of the last runs, oldest left, colored by status.
147 const last = s.runs.slice(0, 48).reverse()
148 const gx = 14
149 const gw = W - 28
150 const gy = 100
151 const gh = 22
152 if (last.length) {
153 const bw = Math.min(10, gw / 48)
154 const top = Math.max(1, ...last.map(r => Math.log10(1 + (r.ms ?? 0) / 100)))
155 last.forEach((r, i) => {
156 const h = Math.max(2, (Math.log10(1 + (r.ms ?? 0) / 100) / top) * gh)
157 const x = gx + gw - (last.length - i) * bw
158 parts.push(`<rect x="${(x + 1).toFixed(1)}" y="${(gy + gh - h).toFixed(1)}" width="${Math.max(1, bw - 2).toFixed(1)}" height="${h.toFixed(1)}" rx="1.5" fill="${statusColor(r.status)}" opacity="${r.status === 'running' ? 1 : 0.8}" class="${r.status === 'running' ? 'pulse' : ''}"><title>${xml(clip(r.command, 80))}</title></rect>`)
159 })
160 } else {
161 parts.push(svgText(W / 2, gy + 15, 'no shell commands yet', { cls: 'm', size: 10.5, anchor: 'middle' }))
162 }
163 parts.push(svgText(gx, gy + gh + 9, s.slow ? `${s.slow} slower than 10 s` : 'durations, last 48', { cls: 'm', size: 8.5 }))
164 return { source: svg(W, H + 2, parts.join('')), height: H + 2 }
165}
166
167export function runSvg(r: ShellRun, W: number): { source: string; height: number } {
168 const c = statusColor(r.status)
169 const sub = statusLine(r)
170 const hasSub = r.status !== 'ok' || !!r.error
171 const H = hasSub ? 66 : 52
172 const parts: string[] = []
173 parts.push(`<rect class="p" width="${W}" height="${H}" rx="10"/>`)
174 parts.push(`<rect width="4" height="${H}" rx="2" fill="${c}" class="${r.status === 'running' ? 'pulse' : ''}"/>`)
175 parts.push(svgText(14, 19, statusGlyph(r.status), { size: 12, weight: 800, fill: c }))
176 const right = `${fmtMs(r.ms)}`
177 parts.push(svgText(W - 10, 19, right, { size: 11.5, weight: 700, anchor: 'end', fill: durHeat(r.ms) }))
178 parts.push(svgText(30, 19, fitText(`$ ${firstLine(r.command)}`, 11.5, W - 96), { size: 11.5, weight: 600, mono: true }))
179 const meta = [clockOf(r.at), fmtSize(r.outBytes), r.who, r.cwd ? `⌂ ${r.cwd}` : ''].filter(Boolean).join(' · ')
180 parts.push(svgText(30, 35, fitText(r.description ?? (r.tool === 'PowerShell' ? 'PowerShell' : 'Bash'), 10.5, W - 40), { cls: 's', size: 10.5 }))
181 parts.push(svgText(30, 49, fitText(meta, 9.5, W - 40), { cls: 'm', size: 9.5 }))
182 if (hasSub) parts.push(svgText(30, 61, fitText(sub, 10, W - 40), { size: 10, weight: 600, fill: r.status === 'ok' ? KZ.mist : c, mono: true }))
183 return { source: svg(W, H, parts.join('')), height: H }
184}
185
186export function altOf(r: ShellRun): string {
187 return `${r.status} ${r.tool}: ${clip(r.command, 120)}${r.description ? ` (${r.description})` : ''}, ${fmtMs(r.ms)}, by ${r.who}${r.error ? `; ${r.error}` : ''}`
188}
189types/index.d.ts 49 lines1/** How one shell call ended: still running, exit 0, failed, refused, or sent to the background. */
2export type ShellStatus = 'running' | 'ok' | 'fail' | 'denied' | 'bg'
3
4/** One Bash / PowerShell tool call of the session, main loop or a subagent's. */
5export type ShellRun = {
6 id: string
7 /** `Bash` or `PowerShell`. */
8 tool: string
9 /** The command as the model wrote it (clipped at 2000 characters). */
10 command: string
11 /** The model's short description of the command, when it gave one. */
12 description?: string
13 /** The working directory: an input `cwd`, or a leading `cd <dir> &&`. */
14 cwd?: string
15 /** When the call started, `$.clock.now()` milliseconds. */
16 at: number
17 /** How long it ran; absent while it runs. */
18 ms?: number
19 status: ShellStatus
20 /** The exit code when known (0 on success, parsed from the error text otherwise). */
21 exit?: number
22 /** The first telling line of the error, when it failed. */
23 error?: string
24 /** Characters of output (stdout + stderr, or the error text). */
25 outBytes?: number
26 /** `main`, or the subagent's description. */
27 who: string
28}
29
30export type TerminusSnap = {
31 /** Newest first, at most 300. */
32 runs: ShellRun[]
33 /** Counters over the whole session (not only the kept runs). */
34 total: number
35 failures: number
36 slow: number
37 totalMs: number
38}
39
40export type TerminusFilter = 'all' | 'failed' | 'slow'
41
42export type TerminusView = { filter: TerminusFilter }
43
44declare module 'claude-code' {
45 interface PluginState {
46 terminus: { snap: TerminusSnap | null; view: TerminusView }
47 }
48}
49