SLOPSHOPPER

terminus

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…

newpaneguardcommandagents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/terminus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminus
│ ┃ KOZMOS · Terminus ✕ › fix the failing auth test and add an audit log call │ ┃ $_ TERMINUS 4 cmds │ ┃ ✖ 1 failed · ⏱ 80ms shell · 0 slow ⏺ Read(src/auth.ts) │ ┃ time ████ ⎿ Read 6 lines │ ┃ ● All 4 ○ Failed 1 ○ Slow >10s 0 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ✓ $ cat .env ⏺ Bash(bun test) │ ┃ Show environment file ⎿ 3 pass, 1 fail │ ┃ 08:53:20 20ms · 111B · main copy ↩ prompt │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ $ rm -rf build && git push --force origin… │ ┃ Clean build output and push ✻ Worked for 42s · done 4:20 PM │ ┃ 08:53:20 20ms · 0B · main copy ↩ prompt │ ┃ › /terminus │ ┃ ✓ $ git status --porcelain ⎿ terminus: Terminus open. │ ┃ Show working tree status │ ┃ 08:53:20 20ms · 31B · main copy ↩ prompt │ ┃ │ ┃ ✖ $ bun test │ ┃ Run the test suite │ ┃ 08:53:20 20ms · 159B · main copy ↩ prompt │ ┃ └ failed · error: expected 401, got 200 │ ┃ slow = over 10s · 301 chars of output kept │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Terminus
$_ TERMINUS 4 cmds ✖ 1 failed · ⏱ 80ms shell · 0 slow time ████ ● All 4 ○ Failed 1 ○ Slow >10s 0 ✓ $ cat .env Show environment file 08:53:20 20ms · 111B · main copy ↩ prompt ✓ $ rm -rf build && git push --force origin main Clean build output and push 08:53:20 20ms · 0B · main copy ↩ prompt ✓ $ git status --porcelain Show working tree status 08:53:20 20ms · 31B · main copy ↩ prompt ✖ $ bun test Run the test suite 08:53:20 20ms · 159B · main copy ↩ prompt └ failed · error: expected 401, got 200 slow = over 10s · 301 chars of output kept
README

KOZMOS Terminus

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.

  • Header: total commands, failures, total shell time and a duration strip of the last runs (colored by status on the terminal, bars on the desktop).
  • Filters: All, Failed, Slow > 10 s.
  • Per row: copy puts the command on the clipboard, ↩ prompt fills the prompt with Run again: <command>.
  • Newest first, at most 300 runs kept.

/terminus toggles the pane, /terminus list prints the last runs. Set autoOpen to open it on session start.

Türkçe

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.

  • Başlık: toplam komut, başarısız sayısı, toplam kabuk süresi ve son çalıştırmaların süre şeridi.
  • Filtreler: All, Failed, Slow > 10 s.
  • Her satırda: copy komutu panoya kopyalar, ↩ prompt istem kutusunu Run again: <komut> ile doldurur.
  • En yeni en üstte, en fazla 300 kayıt tutulur.

/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.

Source 4 files
hooks/register.tsx 246 lines
1import { 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}
246
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/shell.ts 189 lines
1// 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}
189
types/index.d.ts 49 lines
1/** 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