SLOPSHOPPER

glitchdeck

A mod that docks a live pane beside your conversation: what Claude is doing right now, every agent, workflow and background task it started as a process you…

newpanerowsguardcommandtoast
v0.1.0MITupdated 2026-10-140hte/glitchdeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glitchdeck
│ ┃ glitchdeck ✕ › fix the failing auth test and add an audit log call │ ┃ main ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ✓ last turn 42s · 9 tools · 1 error ⎿ Read 6 lines │ ┃ ✓ Bash cat .env ⏺ Update(src/auth.ts) │ ┃ ✓ Bash rm -rf build && git push --force o ⎿ Added 2 lines, removed 1 line │ ┃ ✓ Bash git status --porcelain ⏺ Bash(bun test) │ ┃ ✗ Bash bun test ⎿ 3 pass, 1 fail │ ┃ ✓ Write src/cache.ts │ ┃ ✓ Write src/audit.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Edit src/auth.ts │ ┃ ✓ Grep refresh\( ✻ Worked for 42s · done 4:20 PM │ ┃ ✓ Read src/auth.ts │ ┃ › /glitchdeck │ ┃ files 1 read · 3 edited cache.ts audit. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · glitchdeck
main ╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱ opus 5.5 ✓ last turn 42s · 9 tools · 1 error ✓ Bash cat .env 0.0s ✓ Bash rm -rf build && git push --force origin… 0.0s ✓ Bash git status --porcelain 0.0s ✗ Bash bun test 0.0s ✓ Write src/cache.ts 0.0s ✓ Write src/audit.ts 0.0s ✓ Edit src/auth.ts 0.0s ✓ Grep refresh\( 0.0s ✓ Read src/auth.ts 0.0s files 1 read · 3 edited cache.ts audit.ts auth.ts
README
 ██████╗ ██╗     ██╗████████╗ ██████╗██╗  ██╗██████╗ ███████╗ ██████╗██╗  ██╗
██╔════╝ ██║     ██║╚══██╔══╝██╔════╝██║  ██║██╔══██╗██╔════╝██╔════╝██║ ██╔╝
██║  ███╗██║     ██║   ██║   ██║     ███████║██║  ██║█████╗  ██║     █████╔╝
██║   ██║██║     ██║   ██║   ██║     ██╔══██║██║  ██║██╔══╝  ██║     ██╔═██╗
╚██████╔╝███████╗██║   ██║   ╚██████╗██║  ██║██████╔╝███████╗╚██████╗██║  ██╗
 ╚═════╝ ╚══════╝╚═╝   ╚═╝    ╚═════╝╚═╝  ╚═╝╚═════╝ ╚══════╝ ╚═════╝╚═╝  ╚═╝
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱

Claude Code 2.1.296+ mod version license: MIT

A Claude Code mod that docks a live pane beside your conversation. It shows what Claude is doing right now, every agent, workflow and background task it started as a process you can open, how fast it is writing, and every prompt you sent, one click away from its place in the transcript.

<img src="https://github.com/user-attachments/assets/00f5eb84-54b2-4c07-9949-4de87a242418" width="521" alt="The glitchdeck pane: a test runs in the main loop while the throughput graph scrolls; a background job fails and its row turns red; an Explore agent is opened to show its live tool calls, then closed.">

No panels, no fills. Everything finished fades to gray, and only what is alive carries the one ice-blue accent, with a bar beside it that breathes. Failures keep a red bar.

What it shows

main follows the main loop live: thinking, writing, preparing or running a tool (and which one, on what), with a ticking turn clock. Above it, the last minute of output speed, every agent's included, measured while responses stream; it scrolls while the turn or any agent runs, and freezes, gray, once nothing does. Under it: the turn's finished tool calls, newest first, with their durations, the todo list's progress, and the files the main loop read and edited (an agent's own calls are in its timeline). When the turn ends it keeps a one-line receipt.

agents lists what the session started:

KindComes fromEnds when
agentthe Agent tool, foreground or backgroundits turn completes, its notification arrives, or the agent list says so
workflowthe Workflow tool; a few of its agents nest under it while it runsits task notification, or the next stop that no longer lists it
bash / pwsha shell command run in the background, by the session or an agentits task notification with the exit code, its agent's final answer when the command ends with it, or the next stop that no longer lists it
monitorthe Monitor toolits task notification, TaskStop, or the next stop that no longer lists it

Live work comes first, with its last tool call under it. Finished work drops to one gray line; failed work keeps a red bar and says why (exit 1, killed). A row glitches for a moment when its state changes. Open one to swap the list for that process's live timeline: an agent's tool calls, tokens and last words, or a workflow's agents.

prompts numbers your prompts, the ones typed while Claude works included. Click one and the transcript scrolls back to it.

Before anything has run, main is its header with the model's name; agents and prompts appear once they have something to show.

Install

Built and tested on Claude Code 2.1.296 (mods are early access; the API moves between releases).

glitchdeck needs the fullscreen layout. Turn it on in ~/.claude/settings.json:

{ "tui": "fullscreen" }

Without it, the pane sits above the prompt instead of beside the transcript, and jumping to a prompt does not work.

Then, at the prompt of a terminal session:

/plugin install glitchdeck --marketplace 0hte/glitchdeck

Answer y to add the marketplace, then pick a scope.

Use

ActionHow
Open or close the pane/glitchdeck (also /glitchdeck open, /glitchdeck close)
Open a processclick any line of it
Back to the listclick ‹ name at the top of the timeline
Every process, one line eachclick +N more; ▴ / ▾ page it, ‹ fewer returns
Page through promptsclick ▴ / ▾
Jump to a promptclick its row

Every control also takes the keyboard: focus the pane with ctrl+x tab, move with tab, press with enter.

When you open the pane yourself, it opens at any terminal width. When it opens on its own at session start, it waits until the terminal is 144 columns wide, or 110 if you opened it yourself before and haven't closed it since. Closing it keeps it closed across reloads of the mod until you open it again.

Options

One option, set in /config or under pluginConfigs in your settings: openOnStart (default true) opens the pane when a session starts.

Limits

  • No event reports when you answer a permission prompt, so glitchdeck shows no "waiting for you" state.
  • Jumping to a prompt works in the terminal's fullscreen layout, from the main conversation. The desktop app does not let a plugin scroll the transcript.
  • A resumed session rebuilds its history from the transcript file. Past 4 MiB, which a plugin cannot read, it falls back to the session's newest 4096 messages: those carry no times (rows show --:--) and jump by matching the prompt's text. When neither can be read, a toast says so.
  • Workflow agents are not in Claude Code's agent list, so their timelines start when glitchdeck sees them and are not read back after a reload.
  • The colors are tuned for dark terminals.
  • While a response streams, its speed is estimated from what it streams; the true token count corrects it when the response ends, and output it never streamed, such as hidden thinking, is spread over the time it took. Outside the terminal the graph is drawn in braille and moves only when the pane redraws.

Privacy

Prompts, commands, targets, tasks and agents' words go through a credential filter, and escape codes and control characters are stripped, before anything is kept or drawn; files are kept as their last two path segments. The filter mostly protects your screen: any plugin can read another's state, and can hook the same events glitchdeck does.

Develop

.claude-plugin/types/ is written by Claude Code each time it loads the mod and is gitignored, so on a fresh clone run claude --plugin-dir . once before tsc.

claude --plugin-dir .          # load from this folder; saving a file reloads it
claude plugin validate .       # what the engine sees and would refuse
claude plugin test .           # tests/ against the engine's test kit
npx -p typescript tsc -p .     # types

License

MIT

Source 22 files
hooks/register.tsx 681 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, TurnStepChunk } from 'claude-code'
3
4import type { ActivityKind, FileTouch, Fx, HistoryEntry, Main, Proc, Timeline, Todos, ToolState, View } from '../types'
5import { effectsOf } from './core/effects'
6import type { Effect } from './core/effects'
7import { closeColumn, credit, flowCols, newFlow, newStream, settle } from './core/flow'
8import type { Stream } from './core/flow'
9import { fmtClock } from './core/fmt'
10import { FRAME_MS, GLITCH_MS } from './core/glitch'
11import { allText, attachUuid, keepRow, promptText, promptsFromMessages, promptsFromTranscript, pushPrompt, rowKey, textKey } from './core/history'
12import { columnMs } from './core/graph'
13import { GRAPH_ROWS, layout } from './core/layout'
14import {
15  DEFAULT_MAIN,
16  DEFAULT_TODOS,
17  endTool,
18  endTurn,
19  kindOfChunk,
20  runningCalls,
21  setActivity,
22  startTool,
23  startTurn,
24  targetOf,
25  todoCreated,
26  todoUpdated,
27  touchFile,
28} from './core/main'
29import {
30  addProc,
31  applyList,
32  applyStep,
33  callsFromMessages,
34  childrenOf,
35  detailItems,
36  endCall,
37  endChildren,
38  endMissing,
39  endingOf,
40  isLive,
41  itemHeight,
42  newProc,
43  setState,
44  shortType,
45  shownKids,
46  startCall,
47  topLevel,
48  turnEnding,
49  withBackfill,
50} from './core/procs'
51import { BREATH, BREATH_MS, C } from './palette'
52import { GRAPH_KEY, GRAPH_MIN_W, graphCells, graphCols, graphView } from './views/graph'
53import { HIST_PREFIX, historySection } from './views/history'
54import { mainSection, nowRows } from './views/main'
55import { PROC_PREFIX, detailSection, itemsSection, listSection, maxProcOffset, procIdOf, secondLine } from './views/procs'
56import type { Bar, Clock } from './views/section'
57
58const PANE = 'glitchdeck'
59const COLUMNS = 60
60const POLL_MS = 3000
61
62const DEFAULT_VIEW: View = { histOffset: 0, agent: '', allProcs: false, procOffset: 0, isClosed: false }
63
64// Each value keeps its own shape tag: bump only the one whose shape changed, and only it reads as absent.
65const history = atom({ plugin: 'glitchdeck', key: 'history' } as const, [] as readonly HistoryEntry[], { shape: 'v6' })
66const view = atom({ plugin: 'glitchdeck', key: 'view' } as const, DEFAULT_VIEW, { shape: 'v6' })
67const main = atom({ plugin: 'glitchdeck', key: 'main' } as const, DEFAULT_MAIN as Main, { shape: 'm9' })
68const todos = atom({ plugin: 'glitchdeck', key: 'todos' } as const, DEFAULT_TODOS as Todos, { shape: 'v6' })
69const files = atom({ plugin: 'glitchdeck', key: 'files' } as const, [] as FileTouch[], { shape: 'f7' })
70const procs = atom({ plugin: 'glitchdeck', key: 'procs' } as const, [] as readonly Proc[], { shape: 'p7' })
71const fx = atom({ plugin: 'glitchdeck', key: 'fx' } as const, { frame: 0 } as Fx, { shape: 'v6' })
72// A family: one value per process, written alone when that process calls a tool.
73const timeline = { plugin: 'glitchdeck', key: 'timeline' } as const
74
75const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'glitchdeck', columns: COLUMNS })
76
77const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
78
79// `update` always writes and redraws its readers; these skip it when the reducer changes nothing.
80// One per atom: the engine's scan needs each state call to name its atom directly.
81async function patchMain($: EngineInterface, change: (m: Main) => Main) {
82  const m = await read($, main)
83  if (change(m) !== m) await update($, main, change)
84}
85async function patchHistory($: EngineInterface, change: (h: readonly HistoryEntry[]) => readonly HistoryEntry[]) {
86  const h = await read($, history)
87  if (change(h) !== h) await update($, history, change)
88}
89async function patchView($: EngineInterface, change: (v: View) => View) {
90  const v = await read($, view)
91  if (change(v) !== v) await update($, view, change)
92}
93/** Resolves whether a process changed state, so the caller can start the glitch, and which ones the cap let go. */
94async function patchProcs($: EngineInterface, change: (p: readonly Proc[]) => readonly Proc[]) {
95  const before = await read($, procs)
96  if (change(before) === before) return { isChanged: false, gone: [] as string[] }
97  const after = await update($, procs, change)
98  const kept = new Set(after.map(p => p.id))
99  return {
100    isChanged: after.some(p => before.find(b => b.id === p.id)?.changedAt !== p.changedAt),
101    gone: before.filter(b => !kept.has(b.id)).map(b => b.id),
102  }
103}
104
105async function isProc($: EngineInterface, id: string) {
106  return (await read($, procs)).some(p => p.id === id)
107}
108
109// Timers die with the module on a reload; session.start, which fires again, restarts what is needed.
110let fxTimer: Timer | undefined
111let fxBeat = 0
112let glitchUntil = 0
113let pollTimer: Timer | undefined
114let pollBeat = 0
115let graphTimer: Timer | undefined
116let graphEvery = 0
117let graphBeat = 0
118// The graph's columns at the pane's last render, kept while it is away: they share the minute between them.
119let graphWidth = graphCols(COLUMNS)
120// Whether the pane last drew the graph as a Raster, which the timer scrolls between redraws.
121let isGraphDrawn = false
122// What every response of the session streamed, a column per tick, and the responses streaming now.
123let flow = newFlow()
124const streams = new Set<Stream>()
125// False only while the pane's last render had no flow to draw: the first column then redraws it, once.
126let isFlowDrawn = true
127
128/** A refused period ends an interval without a word: one that has not beaten lately is started again. */
129const isStale = (beat: number, now: number, ms: number) => now - beat > ms * 3
130
131const stopFx = () => {
132  fxTimer?.cancel()
133  fxTimer = undefined
134}
135const stopPoll = () => {
136  pollTimer?.cancel()
137  pollTimer = undefined
138}
139const stopGraph = () => {
140  graphTimer?.cancel()
141  graphTimer = undefined
142}
143
144/** Redraws the rows every frame while a state change is still glitching, then stops. */
145async function kickFx($: EngineInterface) {
146  const now = await $.clock.now()
147  glitchUntil = now + GLITCH_MS
148  if (fxTimer && !isStale(fxBeat, now, FRAME_MS)) return
149  stopFx()
150  fxBeat = now
151  fxTimer = $.clock.every(FRAME_MS, () => void fxFrame($).catch(stopFx))
152}
153
154async function fxFrame($: EngineInterface) {
155  const now = await $.clock.now()
156  fxBeat = now
157  if (now > glitchUntil) stopFx()
158  await update($, fx, f => ({ frame: f.frame + 1 }))
159}
160
161async function changeProcs($: EngineInterface, change: (p: readonly Proc[]) => readonly Proc[]) {
162  const { isChanged, gone } = await patchProcs($, change)
163  // State has no delete: a process the cap let go keeps an empty timeline.
164  for (const id of gone) await $.state.set({ ...timeline, id }, { calls: [], total: 0 })
165  if (isChanged) await kickFx($)
166}
167
168/** Agents end without a notification when they run in the foreground: the list says so. */
169async function pollAgents($: EngineInterface) {
170  const [list, agents, now] = await Promise.all([read($, procs), $.agent.list(), $.clock.now()])
171  pollBeat = now
172  await changeProcs($, ps => applyList(ps, agents, now))
173  if (!list.some(p => p.kind === 'agent' && isLive(p)) && !agents.some(a => a.status === 'running')) stopPoll()
174}
175
176function ensurePoll($: EngineInterface, now: number) {
177  if (pollTimer && !isStale(pollBeat, now, POLL_MS)) return
178  stopPoll()
179  pollBeat = now
180  pollTimer = $.clock.every(POLL_MS, () => void pollAgents($).catch(stopPoll))
181}
182
183/** Agents stream tokens; shells and monitors do not. */
184const isStreamer = (p: Proc) => isLive(p) && p.kind !== 'shell' && p.kind !== 'monitor'
185
186/** Whether anything can still stream: the graph keeps scrolling while it can, and freezes gray after. */
187const isFlowing = (m: Main, ps: readonly Proc[]) => m.isRunning || streams.size > 0 || ps.some(isStreamer)
188
189/** While anything runs, the graph closes a column per tick, drawn or not, so it always shows the last minute. */
190async function startGraph($: EngineInterface) {
191  const every = columnMs(graphWidth)
192  const now = await $.clock.now()
193  if (graphTimer && graphEvery === every && !isStale(graphBeat, now, every)) return
194  stopGraph()
195  graphEvery = every
196  graphBeat = now
197  graphTimer = $.clock.every(every, () => void tickGraph($).catch(() => undefined))
198}
199
200async function tickGraph($: EngineInterface) {
201  for (const s of streams) credit(flow, s)
202  closeColumn(flow, graphEvery)
203  const [m, ps, now] = await Promise.all([read($, main), read($, procs), $.clock.now()])
204  graphBeat = now
205  const isLiveNow = isFlowing(m, ps)
206  if (isGraphDrawn) await $.ui.blit({ requestId: PANE, key: GRAPH_KEY, cells: graphCells(C, { flow, isLive: isLiveNow }, graphWidth) })
207  else if (!isFlowDrawn && flow.cols.length > 0) {
208    isFlowDrawn = true
209    await patchMain($, x => ({ ...x, flow: flowCols(flow) }))
210  }
211  if (!isLiveNow) {
212    stopGraph()
213    // Kept for a reload, which starts the module, and so the flow, over.
214    await patchMain($, x => ({ ...x, flow: flowCols(flow) }))
215  } else if (columnMs(graphWidth) !== graphEvery) await startGraph($)
216}
217
218/** A response starts streaming: its text counts toward the graph from its first piece. */
219const openStream = ($: EngineInterface) => {
220  const s = newStream(flow)
221  streams.add(s)
222  void startGraph($).catch(() => undefined)
223  return s
224}
225
226const countChunk = (s: Stream, c: TurnStepChunk) => {
227  if (c.kind === 'text' || c.kind === 'thinking') s.chars += c.text.length
228  else if (c.kind === 'input') s.chars += c.json.length
229}
230
231async function applyEffect($: EngineInterface, x: Effect, now: number) {
232  if (x.kind === 'file') await update($, files, list => touchFile(list, x, x.isEdit, now))
233  else if (x.kind === 'proc') await changeProcs($, ps => addProc(ps, x.proc))
234  else if (x.kind === 'stop') await changeProcs($, ps => setState(ps, x.id, 'Z', 'killed', now))
235  else if (x.kind === 'todos') await update($, todos, () => x.todos)
236  else if (x.kind === 'todo-created') await update($, todos, t => todoCreated(t, x))
237  else await update($, todos, t => todoUpdated(t, x))
238}
239
240/** Everything this session showed, but the model's name and whether the person closed the pane. */
241async function resetSession($: EngineInterface) {
242  stopGraph()
243  flow = newFlow()
244  streams.clear()
245  await update($, history, () => [])
246  await update($, view, v => ({ ...DEFAULT_VIEW, isClosed: v.isClosed }))
247  await update($, main, m => ({ ...DEFAULT_MAIN, model: m.model }))
248  await update($, todos, () => DEFAULT_TODOS)
249  await update($, files, () => [])
250  for (const p of await read($, procs)) await $.state.set({ ...timeline, id: p.id }, { calls: [], total: 0 })
251  await update($, procs, () => [])
252}
253
254export const register: Register = (on, options) => {
255  // Transcript row ids as drawn, by rowKey. Rebuilt as rows redraw after a reload.
256  const drawn = new Map<string, string>()
257  // The same rows by their text, for prompts read back without an id.
258  const drawnByText = new Map<string, string>()
259  // Agents whose conversation was read back since this load, so a press reads it once.
260  const backfilled = new Set<string>()
261
262  on('session.start', async ($, e, next) => {
263    const started = await next(e)
264    await $.command.register({
265      name: 'glitchdeck',
266      description: 'Toggle the glitchdeck pane: what Claude is doing, its agents and your prompts',
267      argumentHint: '[open|close]',
268      immediate: true,
269    })
270    const [model, m, now] = await Promise.all([$.session.model().catch(() => ''), read($, main), $.clock.now()])
271    if (model) await patchMain($, x => (x.model === model ? x : { ...x, model }))
272    if (flow.cols.length === 0) flow = newFlow(m.flow)
273    if (m.isRunning) await startGraph($)
274    // Picks up agents already running when the mod (re)loads.
275    await pollAgents($).catch(() => undefined)
276    if ((await read($, procs)).some(isLive)) ensurePoll($, now)
277    const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
278    if (options.openOnStart !== false && !isOpen && !(await read($, view)).isClosed) void openPane($).catch(() => undefined)
279    return started
280  })
281
282  on('ui.close', async ($, e, next) => {
283    const closed = await next(e)
284    if (e.id === PANE) {
285      // Nothing is drawn while the pane is away: the glitch waits for it; the graph keeps counting.
286      stopFx()
287      isGraphDrawn = false
288      isFlowDrawn = true
289      if (e.origin.kind === 'person') await patchView($, v => (v.isClosed ? v : { ...v, isClosed: true }))
290    }
291    return closed
292  }).catch(($, e, next) => next(e))
293
294  on('session.end', async ($, e, next) => {
295    if (e.reason === 'clear') {
296      await resetSession($)
297      drawn.clear()
298      drawnByText.clear()
299      backfilled.clear()
300    }
301    return next(e)
302  })
303
304  // A resumed or forked session loads its rows without appending them, so its prompts come from the file.
305  on('classic.SessionStart', async ($, e, next) => {
306    const done = await next(e)
307    if (e.source === 'resume' || e.source === 'fork') {
308      await resetSession($)
309      drawn.clear()
310      drawnByText.clear()
311      backfilled.clear()
312      // The file has ids and times; past 4 MiB it cannot be read, and the session's newest messages stand in.
313      const text = await $.fs.read(e.transcript_path).catch(() => null)
314      const rows = typeof text === 'string' ? null : await $.session.messages().catch(() => null)
315      const seeded = typeof text === 'string' ? promptsFromTranscript(text) : Array.isArray(rows) ? promptsFromMessages(rows) : null
316      if (seeded) await update($, history, () => seeded)
317      else $.ui.toast("glitchdeck couldn't read this session's earlier prompts")
318    }
319    return done
320  }).catch(($, e, next) => next(e))
321
322  // At a stop the engine lists the background work still in flight; whatever ran here and is gone
323  // from that list has ended, even if its notification reached no hook.
324  on('classic.Stop', async ($, e, next) => {
325    const done = await next(e)
326    const listed = e.background_tasks
327    // An absent list says nothing about what still runs.
328    if (listed) {
329      const inFlight = new Set(listed.map(t => t.id))
330      const now = await $.clock.now()
331      await changeProcs($, ps => endMissing(ps, inFlight, now))
332    }
333    return done
334  }).catch(($, e, next) => next(e))
335
336  on('command.run', { command: 'glitchdeck' }, async ($, e) => {
337    const verb = e.args.trim().split(/\s+/)[0] ?? ''
338    const mine = (await $.ui.panes()).find(p => p.id === PANE)
339    if (verb === 'close' || (verb !== 'open' && mine?.isShown)) {
340      await $.ui.close({ id: PANE })
341      stopFx()
342      isGraphDrawn = false
343      isFlowDrawn = true
344      await patchView($, v => (v.isClosed ? v : { ...v, isClosed: true }))
345      return {}
346    }
347    await patchView($, v => (v.isClosed ? { ...v, isClosed: false } : v))
348    if ((await read($, main)).isRunning) await startGraph($)
349    const opened = await openPane($)
350    return opened.isPlaced ? {} : { text: `glitchdeck is waiting for room: ${opened.reason}` }
351  })
352
353  // ---------------------------------------------------------------- loops
354
355  on('turn.start', async ($, e, next) => {
356    const now = await $.clock.now()
357    await update($, main, m => startTurn(m, now))
358    await startGraph($)
359    return next(e)
360  })
361
362  on('turn.step', async function* ($, e, next) {
363    const agentId = e.agentId
364    const stream = openStream($)
365    try {
366      const s = next(e)
367      if (agentId) {
368        for await (const c of s) {
369          yield c
370          countChunk(stream, c)
371        }
372        const result = await s.result
373        settle(flow, stream, result.usage?.output_tokens)
374        const now = await $.clock.now()
375        await changeProcs($, ps => applyStep(ps, agentId, { usage: result.usage, answer: result.answer, now }))
376        return result
377      }
378      const model = e.model
379      await patchMain($, m => (m.model === model ? m : { ...m, model }))
380      let shown: ActivityKind | undefined
381      for await (const c of s) {
382        yield c
383        // The bookkeeping follows each piece, so no piece waits on it: characters are only counted, and
384        // the clock is read on a change of kind, not per token chunk.
385        countChunk(stream, c)
386        const kind = kindOfChunk(c.kind)
387        if (kind !== undefined && kind !== shown) {
388          shown = kind
389          const now = await $.clock.now()
390          const tool = c.kind === 'tool' ? c.name : ''
391          await patchMain($, m => setActivity(m, { kind, tool, target: '' }, now))
392        }
393      }
394      const result = await s.result
395      settle(flow, stream, result.usage?.output_tokens)
396      return result
397    } finally {
398      streams.delete(stream)
399    }
400  })
401
402  on('tool.call', async ($, e, next) => {
403    const id = e.tool_use_id
404    const tool = String(e.tool)
405    const agentId = e.agentId
406    // The call runs while its row is written: nothing waits on the bookkeeping.
407    const running = next(e)
408    running.catch(() => undefined)
409    const target = targetOf(tool, e)
410    const [startedAt, isTracked] = await Promise.all([$.clock.now(), agentId === undefined ? false : isProc($, agentId)])
411    const row = { id, tool, target, startedAt }
412    if (agentId === undefined) await update($, main, m => startTool(m, row))
413    else if (isTracked) await update($, { ...timeline, id: agentId }, t => startCall(t, row))
414    const settle = async (state: ToolState) => {
415      const now = await $.clock.now()
416      if (agentId === undefined) await update($, main, m => endTool(m, id, state, now))
417      else if (isTracked) await update($, { ...timeline, id: agentId }, t => endCall(t, id, state, now))
418      return now
419    }
420    let ran: Awaited<typeof running>
421    try {
422      ran = await running
423    } catch (err) {
424      await settle('err')
425      throw err
426    }
427    const state: ToolState = ran.deny !== undefined ? 'deny' : ran.isError ? 'err' : 'ok'
428    const now = await settle(state)
429    if (state !== 'ok') return ran
430    for (const x of effectsOf({ tool, input: e, result: ran.result, agentId, now })) await applyEffect($, x, now)
431    return ran
432  }).catch(($, e, next) => next(e))
433
434  on('agent.spawn', async ($, e, next) => {
435    const started = await next(e)
436    if (started.deny !== undefined || !started.agentId) return started
437    const now = await $.clock.now()
438    const runId = e.workflow?.runId ?? ''
439    const agent = newProc({
440      id: started.agentId,
441      kind: runId ? 'wfagent' : 'agent',
442      type: shortType(e.subagentType),
443      task: e.description,
444      now,
445      parent: runId || e.parentAgentId || '',
446    })
447    await changeProcs($, ps => {
448      // A workflow's agents can start before its Workflow call has answered: hold a place for the run.
449      const withRun = runId && !ps.some(p => p.id === runId) ? addProc(ps, newProc({ id: runId, kind: 'workflow', type: 'workflow', task: '', now })) : ps
450      return addProc(withRun, agent)
451    })
452    if (!runId) ensurePoll($, now)
453    return started
454  }).catch(($, e, next) => next(e))
455
456  on('turn.complete', async ($, e, next) => {
457    const done = await next(e)
458    const now = await $.clock.now()
459    const agentId = e.agentId
460    if (agentId === undefined) {
461      const t = { durationMs: e.durationMs, reason: e.reason }
462      await update($, main, m => endTurn(m, now, t))
463    } else {
464      const end = turnEnding(e.reason)
465      await changeProcs($, ps => endChildren(setState(ps, agentId, end.state, end.why, now), agentId, now))
466    }
467    return done
468  })
469
470  // ---------------------------------------------------------------- rows: prompts and notifications
471
472  on('session.append', async ($, e, next) => {
473    const stored = await next(e)
474    const kind = e.origin.kind
475    const words = allText(e.message.content)
476    if (e.agentId === undefined && e.door === 'prompt' && (kind === 'composer' || kind === 'bridge')) {
477      const text = promptText(e.message.content)
478      const at = await $.clock.now()
479      await patchHistory($, list => {
480        const named = attachUuid(list, text, e.uuid, true)
481        return named !== list ? named : pushPrompt(list, { uuid: e.uuid, text, at })
482      })
483      await patchView($, v => (v.histOffset === 0 ? v : { ...v, histOffset: 0 }))
484    } else if (e.agentId === undefined && e.door === 'attachment' && e.message.name === 'queued_command' && !words.includes('<task-notification>')) {
485      // A prompt typed mid-turn reaches the model inside the engine's framing of it.
486      await patchHistory($, list => attachUuid(list, words, e.uuid, false))
487    }
488    if (e.door === 'prompt' || e.door === 'attachment' || e.door === 'delivery') {
489      // A notification is its own prompt row when the session is idle, and an attachment folded into
490      // the running turn (origin `engine`) otherwise; it reaches a subagent's loop when that agent started the task.
491      const ending = endingOf(words)
492      if (ending) {
493        const now = await $.clock.now()
494        await changeProcs($, ps => setState(ps, ending.taskId, ending.state, ending.why, now))
495      }
496    }
497    return stored
498  }).catch(($, e, next) => next(e))
499
500  // A prompt typed at idle is kept from its row, which session.append sees before this resolves; one
501  // typed over a running turn reaches the model inside that turn, so it is taken here. Slash commands do not count.
502  on('prompt.submit', async ($, e, next) => {
503    const entered = await next(e)
504    const kind = e.origin.kind
505    const text = entered.drop === undefined ? entered.text : ''
506    if (e.turnId !== undefined && (kind === 'composer' || kind === 'bridge') && text.trim() !== '' && !text.trim().startsWith('/')) {
507      const at = await $.clock.now()
508      await patchHistory($, list => pushPrompt(list, { uuid: '', text, at }))
509      await patchView($, v => (v.histOffset === 0 ? v : { ...v, histOffset: 0 }))
510    }
511    return entered
512  }).catch(($, e, next) => next(e))
513
514  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
515    const kind = e.props.origin.kind
516    if (kind === 'composer' || kind === 'bridge') {
517      keepRow(drawn, e.requestId)
518      const key = textKey(e.props.text)
519      if (!drawnByText.has(key)) drawnByText.set(key, e.requestId)
520    }
521    return next(e)
522  })
523
524  // The transcript scrolls only while answering the person's own press, so the jump runs here,
525  // inside the press dispatch, not in the Button's onPress closure. Opening an agent reads its
526  // conversation back once, for the calls it made before the mod loaded.
527  on('ui.press', { plugin: 'glitchdeck' }, async ($, e, next) => {
528    if (e.element.startsWith(HIST_PREFIX)) {
529      const n = Number(e.element.slice(HIST_PREFIX.length))
530      const entry = (await read($, history)).find(p => p.n === n)
531      if (entry) await jumpTo($, entry, drawn, drawnByText)
532    } else if (e.element.startsWith(PROC_PREFIX)) {
533      const id = procIdOf(e.element)
534      const p = (await read($, procs)).find(x => x.id === id)
535      if (p?.kind === 'agent' && !backfilled.has(id)) {
536        backfilled.add(id)
537        const rows = await $.session.messages({ agentId: id }).catch(() => null)
538        if (Array.isArray(rows)) {
539          const back = callsFromMessages(rows, isLive(p), targetOf)
540          if (back.length > 0) await update($, { ...timeline, id }, t => withBackfill(t, back))
541        }
542      }
543    }
544    return next(e)
545  })
546
547  // ---------------------------------------------------------------- drawing
548
549  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
550    const els = $.ui.resolve(e)
551    const [m, t, f, hist, v, ps, , now] = await Promise.all([
552      read($, main),
553      read($, todos),
554      read($, files),
555      read($, history),
556      read($, view),
557      read($, procs),
558      // Read so a glitch frame redraws the pane; the frame itself is computed from the clock.
559      read($, fx),
560      $.clock.now(),
561    ])
562    const Client = 'Client' in els ? els.Client : undefined
563    // Other surfaces list a Raster but draw it as nothing, so they get the braille graph.
564    const Raster = e.surface === 'terminal' && 'Raster' in els ? els.Raster : undefined
565    const clock: Clock = (key, since, endAt, color) =>
566      since <= 0 ? (
567        <els.Text color={color}>—</els.Text>
568      ) : Client ? (
569        <Client key={key} module="./clients/elapsed.tsx" props={{ since, now, endAt, color }} />
570      ) : (
571        <els.Text color={color}>{fmtClock((endAt ?? now) - since)}</els.Text>
572      )
573    const bar: Bar = (key, tone, rows = 1) => {
574      if (tone === 'live' && Client) return <Client key={key} module="./clients/breathe.tsx" props={{ colors: BREATH, glyph: '▎', rows, ms: BREATH_MS, now }} />
575      return (
576        <els.Box flexDirection="column" flexShrink={0}>
577          {Array.from({ length: rows }, () =>
578            tone === 'none' ? <els.Text> </els.Text> : <els.Text color={tone === 'err' ? C.err : C.accent}>▎</els.Text>,
579          )}
580        </els.Box>
581      )
582    }
583
584    // The body's own width: never wider, or the pane would draw past its frame.
585    const W = Math.max(1, e.props.bodyColumns)
586    const opened = v.agent ? ps.find(p => p.id === v.agent) : undefined
587    const top = topLevel(ps)
588
589    // Reading a timeline while drawing subscribes the pane to it: only the opened process's, its live
590    // agents', and those of the live processes the short list can show are read.
591    const wanted = new Set<string>()
592    if (opened) {
593      wanted.add(opened.id)
594      for (const k of childrenOf(ps, opened.id)) if (isLive(k)) wanted.add(k.id)
595    } else if (!v.allProcs)
596      for (const p of top.filter(isLive)) {
597        wanted.add(p.id)
598        if (p.kind === 'workflow') for (const k of shownKids(ps, p.id).shown) if (isLive(k)) wanted.add(k.id)
599      }
600    const read2 = await Promise.all([...wanted].map(async id => [id, await read($, { ...timeline, id })] as const))
601    const calls = new Map<string, Timeline>()
602    for (const [id, tl] of read2) if (tl) calls.set(id, tl)
603    const own = opened ? calls.get(opened.id) : undefined
604
605    const L = layout({
606      rows: e.props.scroll.bodyRows,
607      main: {
608        now: nowRows(m),
609        past: m.tools.length - runningCalls(m).length,
610        hasTodo: t.items.length > 0,
611        hasActive: t.active !== '',
612        hasFiles: f.length > 0,
613        hasGraph: flow.cols.length > 0 && W >= GRAPH_MIN_W,
614      },
615      procs:
616        ps.length === 0
617          ? null
618          : {
619              items: top.map(p => (v.allProcs ? 1 : itemHeight(p, ps, secondLine(p, calls.get(p.id)) !== ''))),
620              detail: opened ? detailItems(opened, ps, own?.calls ?? []) : null,
621              isList: v.allProcs,
622            },
623      history: hist.length,
624    })
625
626    const raster = Raster ? (cols: number, cells: string) => <Raster key={GRAPH_KEY} columns={cols} rows={GRAPH_ROWS} cells={cells} /> : undefined
627    if (W >= GRAPH_MIN_W) graphWidth = graphCols(W)
628    isGraphDrawn = L.showGraph && raster !== undefined
629    isFlowDrawn = flow.cols.length > 0
630    const graph = L.showGraph ? graphView(els, C, W, { g: { flow, isLive: isFlowing(m, ps) }, raster }) : null
631
632    const maxHist = Math.max(0, hist.length - L.histRows)
633    const offset = Math.min(v.histOffset, maxHist)
634    const page = (delta: number) => () =>
635      void patchView($, x => {
636        const o = clamp(x.histOffset + delta, 0, maxHist)
637        return o === x.histOffset ? x : { ...x, histOffset: o }
638      })
639    const maxProcs = maxProcOffset(ps, L.listRows)
640    const pageProcs = (delta: number) => () =>
641      void patchView($, x => {
642        const o = clamp(x.procOffset + delta, 0, maxProcs)
643        return o === x.procOffset ? x : { ...x, procOffset: o }
644      })
645    const openProc = (id: string) => () => void update($, view, x => ({ ...x, agent: x.agent === id ? '' : id }))
646    const back = () => void update($, view, x => ({ ...x, agent: '' }))
647    const toggleAll = () => void update($, view, x => ({ ...x, allProcs: !x.allProcs, procOffset: 0 }))
648    const shared = { list: ps, calls, now, clock, bar, onOpen: openProc, onAll: toggleAll }
649
650    return (
651      <els.Box flexDirection="column" width={W}>
652        {mainSection(els, C, W, { m, todos: t, files: f, clock, bar, now, graph, pastRows: L.pastRows, showTodo: L.showTodo, showFiles: L.showFiles })}
653        {L.procMode === 'detail' && opened
654          ? detailSection(els, C, W, { ...shared, proc: opened, own, rows: L.detailRows, onBack: back })
655          : L.procMode === 'list'
656            ? listSection(els, C, W, { ...shared, rows: L.listRows, offset: Math.min(v.procOffset, maxProcs), onPage: pageProcs })
657            : L.procMode === 'items'
658              ? itemsSection(els, C, W, { ...shared, items: L.procItems })
659              : null}
660        {hist.length > 0
661          ? historySection(els, C, W, { list: hist, offset, rows: L.histRows, onNewer: page(-L.histRows), onOlder: page(L.histRows) })
662          : null}
663      </els.Box>
664    )
665  })
666}
667
668async function jumpTo($: EngineInterface, entry: HistoryEntry, drawn: Map<string, string>, drawnByText: Map<string, string>) {
669  const byId = entry.uuid ? [drawn.get(rowKey(entry.uuid)), entry.uuid] : []
670  const targets = [...new Set([...byId, drawnByText.get(entry.text)].filter((t): t is string => t !== undefined && t !== ''))]
671  let why = 'not drawn yet'
672  for (const requestId of targets) {
673    const moved = await $.ui
674      .scroll({ to: { requestId }, block: 'start' })
675      .catch((err: unknown) => ({ deny: err instanceof Error ? err.message : 'scroll failed' }))
676    if (moved.deny === undefined) return
677    why = moved.deny
678  }
679  $.ui.toast(`can't jump to prompt ${entry.n}: ${why}`)
680}
681
hooks/core/effects.ts 62 lines
1import type { Proc, Todos } from '../../types'
2import { EDIT_TOOLS, READ_TOOLS, fileOf, todosFromWrite } from './main'
3import { newProc } from './procs'
4
5export type Effect =
6  | { kind: 'file'; key: string; path: string; isEdit: boolean }
7  | { kind: 'proc'; proc: Proc }
8  | { kind: 'stop'; id: string }
9  | { kind: 'todos'; todos: Todos }
10  | { kind: 'todo-created'; id: string; subject: string }
11  | { kind: 'todo-updated'; taskId: string; status: string; subject: string; activeForm: string }
12
13const obj = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {})
14const str = (v: unknown) => (typeof v === 'string' ? v : '')
15const basename = (p: string) => p.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') ?? ''
16
17/**
18 * What a call that went through means for the pane, read from its input and result: a file read or
19 * edited, a background process started or stopped, the todo list changed. Files and todos count for
20 * the main loop only; an agent's own calls are in its timeline.
21 */
22export const effectsOf = (c: { tool: string; input: unknown; result: unknown; agentId?: string; now: number }): Effect[] => {
23  const { tool, now } = c
24  const input = obj(c.input)
25  const result = obj(c.result)
26  const isMain = c.agentId === undefined
27  const parent = c.agentId ?? ''
28  const out: Effect[] = []
29
30  const file = str(input.file_path) || str(input.notebook_path)
31  if (isMain && file && (READ_TOOLS.has(tool) || EDIT_TOOLS.has(tool))) out.push({ kind: 'file', ...fileOf(file), isEdit: EDIT_TOOLS.has(tool) })
32
33  if ((tool === 'Bash' || tool === 'PowerShell') && str(result.backgroundTaskId)) {
34    const type = tool === 'Bash' ? 'bash' : 'pwsh'
35    const endsWithParent = result.backgroundEndsWithFinalResponse === true
36    out.push({ kind: 'proc', proc: newProc({ id: str(result.backgroundTaskId), kind: 'shell', type, task: str(input.command), now, parent, endsWithParent }) })
37  }
38  if (tool === 'Monitor' && str(result.taskId)) {
39    const task = str(input.description) || str(input.command)
40    out.push({ kind: 'proc', proc: newProc({ id: str(result.taskId), kind: 'monitor', type: 'monitor', task, now, parent }) })
41  }
42  if (tool === 'Workflow' && str(result.taskId)) {
43    // The tool ignores the call's description and title: a run is named by its script.
44    const name = str(result.workflowName) || str(input.name) || basename(str(input.scriptPath)) || 'workflow'
45    const id = str(result.runId) || str(result.taskId)
46    out.push({ kind: 'proc', proc: newProc({ id, taskId: str(result.taskId), kind: 'workflow', type: 'workflow', task: name, now, parent }) })
47  }
48  if (tool === 'TaskStop') {
49    const id = str(input.task_id) || str(input.shell_id)
50    if (id) out.push({ kind: 'stop', id })
51  }
52
53  if (isMain && tool === 'TodoWrite') out.push({ kind: 'todos', todos: todosFromWrite(input.todos) })
54  if (isMain && tool === 'TaskCreate') {
55    const task = obj(result.task)
56    if (str(task.id)) out.push({ kind: 'todo-created', id: str(task.id), subject: str(task.subject) || str(input.subject) })
57  }
58  if (isMain && tool === 'TaskUpdate' && str(input.taskId))
59    out.push({ kind: 'todo-updated', taskId: str(input.taskId), status: str(input.status), subject: str(input.subject), activeForm: str(input.activeForm) })
60  return out
61}
62
hooks/core/flow.ts 96 lines
1import type { FlowCols } from '../../types'
2
3/** Columns kept: a Raster is at most 512 columns wide, so no graph draws more. */
4export const FLOW_CAP = 512
5
6/** Characters per token before any response has ended to learn from. */
7const CPT_START = 3.6
8/** The fewest characters a token is taken to cover, so streamed text never explains more tokens than this allows. */
9const CPT_MIN = 1.5
10const CPT_MAX = 8
11
12/** Below this the eased rate counts as nothing, so a pause ends flat instead of on a sliver. */
13const FLOOR_TPS = 0.5
14
15/**
16 * Output tokens per graph column, every response of the session counted as it streams. `cols` are the
17 * closed columns, oldest first, each lasting its `ms`; `pending` fills the open one, number `seq`.
18 */
19export type Flow = FlowCols & { seq: number; pending: number; cpt: number }
20
21/** One response as it streams: the characters of its text, thinking and tool input, how many are credited, and to which columns. */
22export type Stream = { chars: number; counted: number; startSeq: number; credits: Map<number, number> }
23
24export const newFlow = ({ cols, ms }: FlowCols = { cols: [], ms: [] }): Flow => {
25  const n = Math.min(cols.length, ms.length, FLOW_CAP)
26  return { cols: cols.slice(cols.length - n), ms: ms.slice(ms.length - n), seq: n, pending: 0, cpt: CPT_START }
27}
28
29export const flowCols = (f: Flow): FlowCols => ({ cols: f.cols.slice(), ms: f.ms.slice() })
30
31export const newStream = (f: Flow): Stream => ({ chars: 0, counted: 0, startSeq: f.seq, credits: new Map() })
32
33const add = (f: Flow, seq: number, tokens: number) => {
34  if (seq === f.seq) f.pending = Math.max(0, f.pending + tokens)
35  else {
36    const i = f.cols.length - (f.seq - seq)
37    if (i >= 0) f.cols[i] = Math.max(0, (f.cols[i] ?? 0) + tokens)
38  }
39}
40
41/** Moves a stream's characters since the last call into the open column, as estimated tokens. */
42export const credit = (f: Flow, s: Stream) => {
43  const tokens = (s.chars - s.counted) / f.cpt
44  if (tokens <= 0) return
45  s.counted = s.chars
46  f.pending += tokens
47  s.credits.set(f.seq, (s.credits.get(f.seq) ?? 0) + tokens)
48}
49
50export const closeColumn = (f: Flow, ms: number) => {
51  f.cols.push(f.pending)
52  f.ms.push(ms)
53  f.pending = 0
54  f.seq += 1
55  if (f.cols.length > FLOW_CAP) {
56    f.cols.splice(0, f.cols.length - FLOW_CAP)
57    f.ms.splice(0, f.ms.length - FLOW_CAP)
58  }
59}
60
61/**
62 * A response ended with its true count. What its streamed characters were credited is scaled to the share
63 * they can explain, column by column, and the characters-per-token guess learns when they explain it all.
64 * The rest, output the engine never streamed such as hidden thinking, spreads evenly over the columns the
65 * response lasted.
66 */
67export const settle = (f: Flow, s: Stream, outputTokens: number | undefined) => {
68  credit(f, s)
69  if (!outputTokens || outputTokens <= 0) return
70  const estimated = [...s.credits.values()].reduce((a, b) => a + b, 0)
71  const shown = estimated > 0 ? Math.min(outputTokens, s.chars / CPT_MIN) : 0
72  if (shown > 0) {
73    for (const [seq, tokens] of s.credits) add(f, seq, tokens * (shown / estimated - 1))
74    if (shown === outputTokens) f.cpt = Math.min(CPT_MAX, Math.max(CPT_MIN, f.cpt * 0.8 + (s.chars / outputTokens) * 0.2))
75  }
76  const rest = outputTokens - shown
77  if (rest <= 0) return
78  const first = Math.max(s.startSeq, f.seq - f.cols.length)
79  const span = f.seq - first + 1
80  for (let q = first; q <= f.seq; q++) add(f, q, rest / span)
81}
82
83/**
84 * Tokens per second for the last `n` columns, each over its own length, oldest first, eased so a pause
85 * slopes down over a few columns instead of dropping out; null where the flow has no column yet.
86 */
87export const flowRates = (f: FlowCols, n: number): (number | null)[] => {
88  let eased = 0
89  const rates = f.cols.map((t, i) => {
90    eased = eased * 0.65 + ((t * 1000) / Math.max(1, f.ms[i] ?? 1)) * 0.35
91    return eased < FLOOR_TPS ? 0 : eased
92  })
93  const shown = rates.slice(-n)
94  return [...Array.from({ length: Math.max(0, n - shown.length) }, () => null), ...shown]
95}
96
hooks/core/fmt.ts 32 lines
1import { shorten } from './text'
2
3/** `0.4s`, `12s`, `3:07`, `1h05`. */
4export const fmtMs = (ms: number) => {
5  if (ms < 10_000) return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
6  const s = Math.floor(ms / 1000)
7  if (s < 60) return `${s}s`
8  const m = Math.floor(s / 60)
9  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
10}
11
12/** A running clock as the elapsed client draws it: `0:42`, `12:05`, `1h05`. */
13export const fmtClock = (ms: number) => {
14  const s = Math.max(0, Math.floor(ms / 1000))
15  const m = Math.floor(s / 60)
16  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
17}
18
19/** Wall-clock `HH:MM` of an epoch time, in the environment's zone. */
20export const fmtWall = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 5) : '--:--')
21
22export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
23
24/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
25export const prettyModel = (id: string) => {
26  if (!id) return 'Claude'
27  const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
28  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?!\d)/i.exec(id)
29  if (m?.[1] && m[2]) return `${cap(m[1].toLowerCase())} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
30  return shorten(id, 18)
31}
32
hooks/core/glitch.ts 31 lines
1import { cellWidth, hash } from './text'
2
3/** How long a row glitches after it changes state, and how often the effect redraws. */
4export const GLITCH_MS = 1500
5export const FRAME_MS = 120
6
7const NOISE = ['▓', '▒', '░', '/', '\\', '|', '_', '#']
8
9export const isGlitching = (changedAt: number, now: number) => changedAt > 0 && now - changedAt < GLITCH_MS
10
11/**
12 * The text a few cells corrupted, seeded by `seed` and the frame so a test sees the same thing twice;
13 * the corruption thins out over the effect and the text comes back whole when it ends. Same width always.
14 */
15export const glitchText = (text: string, seed: string, changedAt: number, now: number) => {
16  if (!isGlitching(changedAt, now)) return text
17  const cps = [...text]
18  if (cps.length === 0) return text
19  const frame = Math.floor((now - changedAt) / FRAME_MS)
20  const left = 1 - (now - changedAt) / GLITCH_MS
21  const hits = Math.max(1, Math.round(Math.min(3, cps.length / 3) * left))
22  const h = hash(`${seed}:${frame}`)
23  for (let i = 0; i < hits; i++) {
24    const at = (h >>> (i * 5)) % cps.length
25    // Only one-cell glyphs are swapped, so the row keeps its width.
26    const ch = cps[at] ?? ' '
27    if (ch !== ' ' && cellWidth(ch.codePointAt(0) ?? 0) === 1) cps[at] = NOISE[(h >>> (i * 3 + 1)) % NOISE.length] ?? '▒'
28  }
29  return cps.join('')
30}
31
hooks/core/history.ts 121 lines
1import type { HistoryEntry } from '../../types'
2import { oneLine, redact } from './text'
3
4export const HISTORY_CAP = 1000
5export const PROMPT_CHARS = 160
6
7// When the transcript splits one message over several rows it replaces the uuid's
8// last group with the row index, so rows are matched on the first four groups.
9const UUID_HEAD = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-/i
10
11export const rowKey = (id: string) => (UUID_HEAD.test(id) ? id.slice(0, 24) : id)
12
13/** Remembers the id a transcript row was drawn under, preferring the message's first row. */
14export const keepRow = (drawn: Map<string, string>, requestId: string) => {
15  const key = rowKey(requestId)
16  const had = drawn.get(key)
17  if (had === undefined || (requestId.length === 36 && had.length !== 36)) drawn.set(key, requestId)
18}
19
20type Block = { type?: unknown; text?: unknown }
21
22/** The typed words of a prompt row: its first text block (hooks' context follows as further blocks). */
23export const promptText = (content: unknown): string => {
24  if (typeof content === 'string') return content
25  if (!Array.isArray(content)) return ''
26  const blocks = content as Block[]
27  const first = blocks.find(b => b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '')
28  if (first) return first.text as string
29  return blocks.some(b => b.type === 'image') ? '[image]' : ''
30}
31
32/** Every text block of a row, for finding a prompt inside the engine's framing of it. */
33export const allText = (content: unknown): string => {
34  if (typeof content === 'string') return content
35  if (!Array.isArray(content)) return ''
36  return (content as Block[])
37    .filter(b => b.type === 'text' && typeof b.text === 'string')
38    .map(b => b.text as string)
39    .join(' ')
40}
41
42/**
43 * The same list back when nothing is added, so the caller can skip the write. A prompt is taken
44 * when it is submitted, with no id yet; `attachUuid` gives it the row's id once the row is kept.
45 */
46export const pushPrompt = (
47  list: readonly HistoryEntry[],
48  p: { uuid: string; text: string; at: number },
49): readonly HistoryEntry[] => {
50  const text = oneLine(redact(p.text))
51  if (text === '' || (p.uuid !== '' && list.some(x => x.uuid === p.uuid))) return list
52  const n = (list.at(-1)?.n ?? 0) + 1
53  return [...list, { n, uuid: p.uuid, text: [...text].slice(0, PROMPT_CHARS).join(''), at: p.at }].slice(-HISTORY_CAP)
54}
55
56/** How a prompt is matched to its drawn row when it was read back without an id. */
57export const textKey = (text: string) => [...oneLine(redact(text))].slice(0, PROMPT_CHARS).join('')
58
59/**
60 * Gives the newest prompt still waiting for an id the id of the row that carries it: the row as
61 * typed (`exact`), or, for one typed mid-turn, the engine's framing of it, which contains its text.
62 */
63export const attachUuid = (list: readonly HistoryEntry[], text: string, uuid: string, exact: boolean): readonly HistoryEntry[] => {
64  if (list.some(x => x.uuid === uuid)) return list
65  const key = exact ? textKey(text) : oneLine(redact(text))
66  for (let i = list.length - 1; i >= 0; i--) {
67    const x = list[i]
68    if (x && x.uuid === '' && x.text !== '' && (exact ? x.text === key : key.includes(x.text))) return list.map((y, j) => (j === i ? { ...y, uuid } : y))
69  }
70  return list
71}
72
73// What the engine writes as the user: its tags, an interruption, a compaction's summary.
74const ENGINE_TEXT =
75  /^\s*(?:<(?:task-notification|command-name|command-message|local-command-|system-reminder|bash-|user-memory-input)|\[Request interrupted by user|This session is being continued from a previous conversation|Caveat: The messages below were generated)/
76
77type MessageRow = { role: string; text: string; toolResults?: readonly unknown[] }
78
79/**
80 * The fallback for a transcript too large to read: the session's own messages carry no id or
81 * origin, so typed prompts are told apart by shape, and their rows are found by text when drawn.
82 */
83export const promptsFromMessages = (rows: readonly MessageRow[]): readonly HistoryEntry[] => {
84  let list: readonly HistoryEntry[] = []
85  for (const r of rows) {
86    if (r.role !== 'user' || (r.toolResults?.length ?? 0) > 0 || !r.text.trim() || ENGINE_TEXT.test(r.text)) continue
87    const n = (list.at(-1)?.n ?? 0) + 1
88    list = [...list, { n, uuid: '', text: textKey(r.text), at: 0 }].slice(-HISTORY_CAP)
89  }
90  return list
91}
92
93type TranscriptRow = {
94  type?: unknown
95  uuid?: unknown
96  isSidechain?: unknown
97  isMeta?: unknown
98  origin?: { kind?: unknown }
99  timestamp?: unknown
100  message?: { content?: unknown }
101}
102
103/** The typed prompts of a transcript file, for a resumed session: loads are not appends. */
104export const promptsFromTranscript = (jsonl: string): readonly HistoryEntry[] => {
105  let list: readonly HistoryEntry[] = []
106  for (const raw of jsonl.split('\n')) {
107    if (!raw.includes('"human"')) continue
108    let row: TranscriptRow
109    try {
110      row = JSON.parse(raw) as TranscriptRow
111    } catch {
112      continue
113    }
114    if (row.type !== 'user' || row.isSidechain === true || row.isMeta === true || row.origin?.kind !== 'human') continue
115    if (typeof row.uuid !== 'string') continue
116    const at = typeof row.timestamp === 'string' ? Date.parse(row.timestamp) : 0
117    list = pushPrompt(list, { uuid: row.uuid, text: promptText(row.message?.content), at: Number.isNaN(at) ? 0 : at })
118  }
119  return list
120}
121
hooks/core/graph.ts 80 lines
1import { rgb } from './color'
2
3/** A Raster cell's color that leaves the terminal's own. */
4const DEFAULT = 0x01000000
5/** `▁` is one eighth of a cell; `█` the whole of it. */
6const LOWER_EIGHTH = 0x2581
7const FULL = 0x2588
8
9/** The span of time the graph shows, right edge now: its columns share it, one per tick. */
10export const WINDOW_MS = 60_000
11
12/** How long one column stands for, and so how often the graph scrolls by exactly one. */
13export const columnMs = (cols: number) => Math.round(WINDOW_MS / Math.max(1, cols))
14
15/**
16 * Columns as a filled area in eighths of a cell, scaled to the largest: any value above zero shows at
17 * least one eighth, an empty column stays the terminal's own. `color(fromTop, age)` paints a cell:
18 * `fromTop` 0 at the top row, `age` 0 at the newest column.
19 */
20export const areaCells = (values: readonly (number | null)[], rows: number, color: (fromTop: number, age: number) => string) => {
21  const cols = values.length
22  const out = new Uint32Array(cols * rows * 3)
23  const max = Math.max(1e-9, ...values.map(v => v ?? 0))
24  const eighths = rows * 8
25  for (let c = 0; c < cols; c++) {
26    const v = values[c] ?? 0
27    const h = v > 0 ? Math.max(1, Math.round((v / max) * eighths)) : 0
28    const age = 1 - c / Math.max(1, cols - 1)
29    for (let r = 0; r < rows; r++) {
30      const fill = Math.max(0, Math.min(8, h - (rows - 1 - r) * 8))
31      const i = (r * cols + c) * 3
32      if (fill === 0) out.set([0x20, DEFAULT, DEFAULT], i)
33      else out.set([fill === 8 ? FULL : LOWER_EIGHTH + fill - 1, rgb(color(r / Math.max(1, rows - 1), age)), DEFAULT], i)
34    }
35  }
36  return out
37}
38
39const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
40
41/** A Raster's `cells`: padded base64 of little-endian u32 triplets. */
42export const encodeCells = (cells: Uint32Array) => {
43  const bytes = new Uint8Array(cells.length * 4)
44  const view = new DataView(bytes.buffer)
45  cells.forEach((v, i) => view.setUint32(i * 4, v, true))
46  let s = ''
47  for (let i = 0; i < bytes.length; i += 3) {
48    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
49    s += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
50    s += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
51    s += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
52  }
53  return s
54}
55
56// Braille dots filled bottom-up, left column then right: levels 0-4 per column.
57const LEFT = [0, 0x40, 0x44, 0x46, 0x47]
58const RIGHT = [0, 0x80, 0xa0, 0xb0, 0xb8]
59
60/** The same area in braille, two values per cell, top row first: for surfaces without a Raster. */
61export const brailleRows = (values: readonly (number | null)[], rows: number) => {
62  const cols = Math.floor(values.length / 2)
63  const max = Math.max(1e-9, ...values.map(v => v ?? 0))
64  const level = (i: number) => {
65    const v = values[i] ?? 0
66    return v > 0 ? Math.max(1, Math.round((v / max) * rows * 4)) : 0
67  }
68  const out: string[] = []
69  for (let r = rows - 1; r >= 0; r--) {
70    let s = ''
71    for (let c = 0; c < cols; c++) {
72      const l = Math.max(0, Math.min(4, level(2 * c) - r * 4))
73      const rr = Math.max(0, Math.min(4, level(2 * c + 1) - r * 4))
74      s += String.fromCharCode(0x2800 + (LEFT[l] ?? 0) + (RIGHT[rr] ?? 0))
75    }
76    out.push(s)
77  }
78  return out
79}
80
hooks/core/layout.ts 109 lines
1// Every row count the pane draws is decided here, so the sections always add up to the room the pane
2// has. These constants describe the shapes the views draw; a test holds a drawn pane to `height`.
3
4/** A section's header and the blank row under it. */
5export const HEAD = 2
6/** The blank row between sections. */
7export const GAP = 1
8/** The throughput graph's rows. */
9export const GRAPH_ROWS = 3
10/** The throughput graph and the blank row under it. */
11export const GRAPH_H = GRAPH_ROWS + 1
12/** The opened process without its body: task, numbers, a blank, two rows of its last words. A body adds a blank above it. */
13export const DETAIL_BASE = 5
14
15const MAX_PAST_ROWS = 12
16const MAX_ITEMS = 12
17const MAX_LIST_ROWS = 40
18const MAX_DETAIL_ROWS = 15
19const MAX_HIST_ROWS = 12
20
21export type LayoutInput = {
22  rows: number
23  /** `now`: rows of what runs right now, or the last turn's receipt; `past`: finished calls of the turn. */
24  main: { now: number; past: number; hasTodo: boolean; hasActive: boolean; hasFiles: boolean; hasGraph: boolean }
25  /** Each top-level process's height, in drawing order; the opened one's body rows; whether all are listed. */
26  procs: { items: readonly number[]; detail: number | null; isList: boolean } | null
27  history: number
28}
29
30export type Layout = {
31  pastRows: number
32  showGraph: boolean
33  showTodo: boolean
34  showFiles: boolean
35  procMode: 'none' | 'items' | 'list' | 'detail'
36  /** Top-level processes the short list shows. */
37  procItems: number
38  listRows: number
39  detailRows: number
40  histRows: number
41  /** What the pane takes with these counts; more than `rows` only when even the minimums don't fit. */
42  height: number
43}
44
45const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0)
46
47/**
48 * Minimums first (what runs now, two processes, two prompts), then the room left goes to what reads
49 * best, in order: todo and files, a few finished calls, the graph, more history, the opened process,
50 * more processes, more calls, then everything else up to its cap.
51 */
52export const layout = (input: LayoutInput): Layout => {
53  const { main, procs, history } = input
54  const items = procs?.items ?? []
55  const l: Layout = {
56    pastRows: 0,
57    showGraph: false,
58    showTodo: false,
59    showFiles: false,
60    procMode: procs === null ? 'none' : procs.detail !== null ? 'detail' : procs.isList ? 'list' : 'items',
61    procItems: 0,
62    listRows: 0,
63    detailRows: procs?.detail != null ? Math.min(procs.detail, 1) : 0,
64    histRows: Math.min(history, 1),
65    height: 0,
66  }
67  // With nothing to show yet, main is its header alone.
68  const mainH = () => {
69    const extras = l.showTodo || l.showFiles ? 1 + (l.showTodo ? 1 + Number(main.hasActive) : 0) + Number(l.showFiles) : 0
70    const body = main.now + l.pastRows + (l.showGraph ? GRAPH_H : 0) + extras
71    return body === 0 ? 1 : HEAD + body
72  }
73  const procsH = () => {
74    if (l.procMode === 'none') return 0
75    if (l.procMode === 'detail') return GAP + HEAD + DETAIL_BASE + (l.detailRows > 0 ? 1 + l.detailRows : 0)
76    // The full list ends on its way back; the short one on `+N more` when it hides some.
77    if (l.procMode === 'list') return GAP + HEAD + l.listRows + 1
78    return GAP + HEAD + sum(items.slice(0, l.procItems)) + (l.procItems < items.length ? 1 : 0)
79  }
80  const histH = () => (history > 0 ? GAP + HEAD + l.histRows : 0)
81  const used = () => mainH() + procsH() + histH()
82  const fits = (change: () => void) => {
83    const before = { ...l }
84    change()
85    if (used() > input.rows) Object.assign(l, before)
86  }
87  const grow = (key: 'pastRows' | 'procItems' | 'listRows' | 'detailRows' | 'histRows', upTo: number) => {
88    for (let i = l[key]; i < upTo; i++) fits(() => (l[key] += 1))
89  }
90
91  if (l.procMode === 'items') grow('procItems', Math.min(items.length, 2))
92  if (l.procMode === 'list') grow('listRows', Math.min(items.length, 3))
93  grow('detailRows', Math.min(procs?.detail ?? 0, 3))
94  grow('histRows', Math.min(history, 2))
95  if (main.hasTodo) fits(() => (l.showTodo = true))
96  if (main.hasFiles) fits(() => (l.showFiles = true))
97  grow('pastRows', Math.min(main.past, 3))
98  if (main.hasGraph) fits(() => (l.showGraph = true))
99  grow('histRows', Math.min(history, 5))
100  grow('detailRows', Math.min(procs?.detail ?? 0, MAX_DETAIL_ROWS))
101  if (l.procMode === 'items') grow('procItems', Math.min(items.length, MAX_ITEMS))
102  if (l.procMode === 'list') grow('listRows', Math.min(items.length, MAX_LIST_ROWS))
103  grow('pastRows', Math.min(main.past, 6))
104  grow('pastRows', Math.min(main.past, MAX_PAST_ROWS))
105  grow('histRows', Math.min(history, MAX_HIST_ROWS))
106  l.height = used()
107  return l
108}
109
hooks/core/main.ts 168 lines
1import type { Activity, ActivityKind, FileTouch, LastTurn, Main, Todo, Todos, ToolRow, ToolState } from '../../types'
2import { hash, oneLine, redact, shorten } from './text'
3
4export const TOOLS_CAP = 50
5export const FILES_CAP = 300
6export const TODOS_CAP = 60
7
8export const IDLE: Activity = { kind: 'idle', tool: '', target: '', since: 0 }
9
10export const DEFAULT_MAIN: Main = {
11  model: '',
12  isRunning: false,
13  turnStartedAt: 0,
14  activity: IDLE,
15  tools: [],
16  flow: { cols: [], ms: [] },
17  lastTurn: null,
18}
19
20export const DEFAULT_TODOS: Todos = { items: [], active: '', activeId: '' }
21
22// ---------------------------------------------------------------- activity
23
24export const startTurn = (m: Main, now: number): Main => ({
25  ...m,
26  isRunning: true,
27  turnStartedAt: now,
28  tools: [],
29  activity: { ...IDLE, kind: 'requesting', since: now },
30})
31
32export const endTurn = (m: Main, now: number, t: { durationMs: number; reason: string }): Main => {
33  const lastTurn: LastTurn = {
34    durationMs: t.durationMs,
35    tools: m.tools.length,
36    errors: m.tools.filter(r => r.state === 'err').length,
37    reason: t.reason,
38    endedAt: now,
39  }
40  return { ...m, isRunning: false, activity: { ...IDLE, since: now }, lastTurn }
41}
42
43/** A step's chunk kind as the activity it shows; undefined keeps the current one. */
44export const kindOfChunk = (kind: string): ActivityKind | undefined =>
45  kind === 'thinking' ? 'thinking' : kind === 'text' ? 'responding' : kind === 'tool' ? 'tool-input' : undefined
46
47export const setActivity = (m: Main, a: Omit<Activity, 'since'>, now: number): Main =>
48  m.activity.kind === a.kind && m.activity.tool === a.tool && m.activity.target === a.target
49    ? m
50    : { ...m, activity: { ...a, since: now } }
51
52// ---------------------------------------------------------------- tools
53
54const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
55
56/** A path as its last two segments. */
57export const shortPath = (p: string) => oneLine(p).split(/[\\/]/).filter(Boolean).slice(-2).join('/')
58
59/** What a call is about, in one redacted line: its command, path, pattern, url or description. */
60export const targetOf = (tool: string, input: unknown): string => {
61  const i = isObject(input) ? input : {}
62  const str = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : '')
63  const path = str('file_path') || str('notebook_path') || str('path')
64  const raw =
65    tool === 'Bash' || tool === 'PowerShell'
66      ? str('command')
67      : tool === 'Grep' || tool === 'Glob'
68        ? str('pattern')
69        : tool === 'WebFetch'
70          ? str('url')
71          : tool === 'WebSearch' || tool === 'ToolSearch'
72            ? str('query')
73            : path
74              ? shortPath(path)
75              : str('description') || str('subject') || str('prompt') || str('skill') || str('query')
76  return shorten(redact(oneLine(raw)), 120)
77}
78
79/** The calls MAIN draws above the finished ones: those still running, while the turn is. */
80export const runningCalls = (m: Main) => (m.isRunning ? m.tools.filter(r => r.state === 'run') : [])
81
82export const startTool = (m: Main, row: Omit<ToolRow, 'state' | 'ms'>): Main => {
83  const tool = shorten(row.tool, 40)
84  return {
85    ...m,
86    tools: [...m.tools, { ...row, tool, state: 'run' as const, ms: null }].slice(-TOOLS_CAP),
87    activity: { kind: 'tool-use', tool, target: row.target, since: row.startedAt },
88  }
89}
90
91/** Calls run in parallel: the header follows the newest call still running, then waits for the model. */
92export const endTool = (m: Main, id: string, state: ToolState, now: number): Main => {
93  const tools = m.tools.map(r => (r.id === id ? { ...r, state, ms: now - r.startedAt } : r))
94  if (m.activity.kind !== 'tool-use') return { ...m, tools }
95  const running = tools.filter(r => r.state === 'run').at(-1)
96  const activity = running
97    ? { kind: 'tool-use' as const, tool: running.tool, target: running.target, since: running.startedAt }
98    : { ...IDLE, kind: 'requesting' as const, since: now }
99  return { ...m, tools, activity }
100}
101
102// ---------------------------------------------------------------- todos
103
104type TodoInput = { content?: unknown; status?: unknown; activeForm?: unknown }
105
106const asStatus = (s: unknown): Todo['status'] =>
107  s === 'in_progress' || s === 'completed' ? s : 'pending'
108
109/** TodoWrite replaces the whole list. */
110export const todosFromWrite = (todos: unknown): Todos => {
111  const list = Array.isArray(todos) ? (todos as TodoInput[]) : []
112  const items = list.slice(0, TODOS_CAP).map((t, i) => ({
113    id: String(i),
114    text: shorten(redact(String(t.content ?? '')), 120),
115    status: asStatus(t.status),
116  }))
117  const at = list.findIndex(t => t.status === 'in_progress')
118  const current = list[at]
119  const words = (v: unknown) => (typeof v === 'string' ? v : '')
120  return {
121    items,
122    active: current ? shorten(redact(words(current.activeForm) || words(current.content)), 120) : '',
123    activeId: current ? String(at) : '',
124  }
125}
126
127export const todoCreated = (t: Todos, task: { id: string; subject: string }): Todos =>
128  t.items.some(x => x.id === task.id)
129    ? t
130    : { ...t, items: [...t.items, { id: task.id, text: shorten(redact(task.subject), 120), status: 'pending' as const }].slice(-TODOS_CAP) }
131
132export const todoUpdated = (t: Todos, u: { taskId: string; status?: string; subject?: string; activeForm?: string }): Todos => {
133  if (u.status === 'deleted')
134    return t.activeId === u.taskId ? { items: t.items.filter(x => x.id !== u.taskId), active: '', activeId: '' } : { ...t, items: t.items.filter(x => x.id !== u.taskId) }
135  const items = t.items.map(x =>
136    x.id === u.taskId
137      ? { ...x, status: u.status ? asStatus(u.status) : x.status, text: u.subject ? shorten(redact(u.subject), 120) : x.text }
138      : x,
139  )
140  const item = items.find(x => x.id === u.taskId)
141  if (u.status === 'in_progress') return { items, active: shorten(redact(u.activeForm || item?.text || ''), 120), activeId: u.taskId }
142  if (u.status === 'completed' && t.activeId === u.taskId) return { items, active: '', activeId: '' }
143  return { ...t, items }
144}
145
146// ---------------------------------------------------------------- files
147
148export const READ_TOOLS = new Set(['Read'])
149export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
150
151/** A file as its key and its drawn name: two files of one name in different folders stay two. */
152export const fileOf = (full: string) => {
153  const p = oneLine(full).replace(/\\/g, '/')
154  return { key: hash(p).toString(36), path: redact(shortPath(p)) }
155}
156
157export const touchFile = (list: readonly FileTouch[], file: { key: string; path: string }, isEdit: boolean, now: number): FileTouch[] => {
158  const had = list.find(f => f.key === file.key)
159  const next: FileTouch = {
160    key: file.key,
161    path: file.path,
162    reads: (had?.reads ?? 0) + (isEdit ? 0 : 1),
163    edits: (had?.edits ?? 0) + (isEdit ? 1 : 0),
164    at: now,
165  }
166  return [...list.filter(f => f.key !== file.key), next].slice(-FILES_CAP)
167}
168
hooks/core/procs.ts 250 lines
1import type { Proc, ProcKind, ProcState, Timeline, ToolRow, ToolState } from '../../types'
2import { oneLine, redact, shorten } from './text'
3
4export const PROCS_CAP = 80
5export const CALLS_CAP = 40
6export const TEXT_CAP = 300
7/** A live workflow's agents the short list shows under it; the rest fold into one line. */
8export const KIDS_SHOWN = 3
9
10export const isLive = (p: Proc) => p.state === 'R' || p.state === 'S'
11
12/** `voltagent-qa-sec:code-reviewer` → `code-reviewer`. */
13export const shortType = (t: string) => t.split(':').pop() || t
14
15export const newProc = (p: {
16  id: string
17  kind: ProcKind
18  type: string
19  task: string
20  now: number
21  taskId?: string
22  parent?: string
23  endsWithParent?: boolean
24}): Proc => ({
25  id: p.id,
26  taskId: p.taskId ?? p.id,
27  kind: p.kind,
28  parent: p.parent ?? '',
29  type: shorten(p.type, 40),
30  task: shorten(redact(oneLine(p.task)), 160),
31  state: 'R',
32  why: '',
33  startedAt: p.now,
34  endedAt: null,
35  changedAt: p.now,
36  tokOut: 0,
37  ctx: 0,
38  steps: 0,
39  lastText: '',
40  endsWithParent: p.endsWithParent ?? false,
41})
42
43/** Adds or refreshes a process; past the cap the oldest finished ones go first, then the oldest live ones. */
44export const addProc = (list: readonly Proc[], p: Proc): readonly Proc[] => {
45  const had = list.find(x => x.id === p.id)
46  const merged = had ? list.map(x => (x.id === p.id ? { ...p, startedAt: x.startedAt } : x)) : [...list, p]
47  const over = merged.length - PROCS_CAP
48  if (over <= 0) return merged
49  const oldest = merged
50    .filter(x => x.id !== p.id)
51    .sort((a, b) => Number(isLive(a)) - Number(isLive(b)) || (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt))
52  const gone = new Set(oldest.slice(0, over).map(x => x.id))
53  return merged.filter(x => !gone.has(x.id))
54}
55
56const edit = (list: readonly Proc[], id: string, change: (p: Proc) => Proc): readonly Proc[] => {
57  const p = list.find(x => x.id === id || x.taskId === id)
58  if (!p) return list
59  const next = change(p)
60  return next === p ? list : list.map(x => (x === p ? next : x))
61}
62
63export const setState = (list: readonly Proc[], id: string, state: ProcState, why: string, now: number): readonly Proc[] =>
64  edit(list, id, p =>
65    p.state === state && p.why === why
66      ? p
67      : { ...p, state, why, changedAt: now, endedAt: state === 'X' || state === 'Z' ? (p.endedAt ?? now) : null },
68  )
69
70type StepUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
71
72/** One model request of an agent: its tokens, its words, and it is running again. */
73export const applyStep = (
74  list: readonly Proc[],
75  id: string,
76  s: { usage: StepUsage | null; answer: string; now: number },
77): readonly Proc[] =>
78  edit(list, id, p => ({
79    ...p,
80    state: 'R',
81    why: '',
82    endedAt: null,
83    changedAt: p.state === 'R' ? p.changedAt : s.now,
84    steps: p.steps + 1,
85    tokOut: p.tokOut + (s.usage?.output_tokens ?? 0),
86    ctx: s.usage ? s.usage.input_tokens + s.usage.cache_read_input_tokens + s.usage.cache_creation_input_tokens : p.ctx,
87    lastText: s.answer.trim() ? shorten(redact(oneLine(s.answer)), TEXT_CAP) : p.lastText,
88  }))
89
90/** The background commands an agent's final answer ends: no notification follows them. */
91export const endChildren = (list: readonly Proc[], parent: string, now: number): readonly Proc[] =>
92  list.filter(p => p.parent === parent && p.endsWithParent && isLive(p)).reduce((acc, p) => setState(acc, p.id, 'X', '', now), list)
93
94// ---------------------------------------------------------------- timelines
95
96/** Calls made in all; a value kept before the count existed has only its calls. */
97export const totalOf = (t: Timeline | undefined) => t?.total ?? t?.calls.length ?? 0
98
99export const startCall = (t: Timeline | undefined, row: Omit<ToolRow, 'state' | 'ms'>): Timeline => ({
100  calls: [...(t?.calls ?? []), { ...row, tool: shorten(row.tool, 40), state: 'run' as const, ms: null }].slice(-CALLS_CAP),
101  total: totalOf(t) + 1,
102})
103
104export const endCall = (t: Timeline | undefined, callId: string, state: ToolState, now: number): Timeline => ({
105  calls: (t?.calls ?? []).map(c => (c.id === callId ? { ...c, state, ms: now - c.startedAt } : c)),
106  total: totalOf(t),
107})
108
109type Summary = { toolUses: readonly { tool_use_id: string; tool: string; input: unknown }[]; toolResults?: readonly { tool_use_id: string; isError: boolean }[] }
110
111/**
112 * Calls read back from an agent's conversation, for one that ran before the mod loaded: each one
113 * settled by its result, still running while the agent is, cut off when it ended without one.
114 */
115export const callsFromMessages = (rows: readonly Summary[], isAgentLive: boolean, targetOf: (tool: string, input: unknown) => string): ToolRow[] => {
116  const results = new Map<string, boolean>()
117  for (const r of rows) for (const x of r.toolResults ?? []) results.set(x.tool_use_id, x.isError)
118  return rows
119    .flatMap(r => r.toolUses)
120    .map(u => {
121      const isError = results.get(u.tool_use_id)
122      const state: ToolState = isError === undefined ? (isAgentLive ? 'run' : 'err') : isError ? 'err' : 'ok'
123      return { id: u.tool_use_id, tool: shorten(u.tool, 40), target: targetOf(u.tool, u.input), state, startedAt: 0, ms: null }
124    })
125}
126
127/** The calls read back, under the ones seen live (which know their times), and the count of both. */
128export const withBackfill = (t: Timeline | undefined, back: readonly ToolRow[]): Timeline => {
129  const seen = t?.calls ?? []
130  const seenIds = new Set(seen.map(c => c.id))
131  const backIds = new Set(back.map(c => c.id))
132  const total = back.length + seen.filter(c => !backIds.has(c.id)).length
133  return { calls: [...back.filter(c => !seenIds.has(c.id)), ...seen].slice(-CALLS_CAP), total: Math.max(total, totalOf(t)) }
134}
135
136// ---------------------------------------------------------------- endings
137
138export type Ending = { taskId: string; state: ProcState; why: string }
139
140const tag = (text: string, name: string) => new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)?.[1]?.trim()
141
142/**
143 * A background task's notification, as the transcript holds it:
144 * `<task-id>…</task-id> … <status>completed|failed|killed</status> <summary>… (exit code N)</summary>`.
145 * A Monitor's output lines arrive the same way with no status: those end nothing.
146 */
147export const endingOf = (text: string): Ending | null => {
148  if (!text.includes('<task-notification>')) return null
149  const taskId = tag(text, 'task-id')
150  const status = tag(text, 'status')
151  if (!taskId || !status) return null
152  const code = /exit code (\d+)/.exec(tag(text, 'summary') ?? '')?.[1]
153  if (status === 'completed') return code && code !== '0' ? { taskId, state: 'Z', why: `exit ${code}` } : { taskId, state: 'X', why: '' }
154  if (status === 'killed' || status === 'stopped') return { taskId, state: 'Z', why: 'killed' }
155  if (status === 'failed') return { taskId, state: 'Z', why: code ? `exit ${code}` : 'failed' }
156  return null
157}
158
159/** `$.agent.list()`'s status as a process state; undefined leaves the process as it is. */
160export const stateOfStatus = (status: string): ProcState | undefined =>
161  status === 'running'
162    ? 'R'
163    : status === 'pending' || status === 'waiting' || status === 'idle'
164      ? 'S'
165      : status === 'completed'
166        ? 'X'
167        : status === 'failed' || status === 'killed'
168          ? 'Z'
169          : undefined
170
171type Listed = { id: string; status: string; type: string; description: string; parentId?: string }
172
173/**
174 * `$.agent.list()` folded in: an agent not seen yet is added while it runs, a known one follows its
175 * status. The list is read before it is applied, so it never brings back an agent that ended meanwhile;
176 * one that works again says so with its next step.
177 */
178export const applyList = (list: readonly Proc[], agents: readonly Listed[], now: number): readonly Proc[] => {
179  let acc = list
180  for (const a of agents) {
181    const state = stateOfStatus(a.status)
182    if (!state) continue
183    const known = acc.find(p => p.id === a.id)
184    if (known) {
185      const isRevival = !isLive(known) && (state === 'R' || state === 'S')
186      if (known.kind === 'agent' && known.state !== state && !isRevival) acc = setState(acc, a.id, state, state === 'Z' ? a.status : known.why, now)
187    } else if (state === 'R' || state === 'S')
188      acc = addProc(acc, { ...newProc({ id: a.id, kind: 'agent', type: shortType(a.type), task: a.description, now, parent: a.parentId }), state })
189  }
190  return acc
191}
192
193/**
194 * At a turn's stop the engine lists the background work still in flight, its agents' commands
195 * included: live work missing there has ended, even when its notification reached no hook. A nested
196 * agent is left alone, since a foreground one is never listed; a workflow's agents end with their run.
197 */
198export const endMissing = (list: readonly Proc[], inFlight: ReadonlySet<string>, now: number): readonly Proc[] => {
199  const isListed = (p: Proc) => p.kind === 'shell' || p.kind === 'monitor' || p.kind === 'workflow' || (p.kind === 'agent' && p.parent === '')
200  const gone = list.filter(p => isLive(p) && isListed(p) && !inFlight.has(p.id) && !inFlight.has(p.taskId))
201  return gone.reduce((acc, p) => setState(acc, p.id, 'X', '', now), list)
202}
203
204export const turnEnding = (reason: string): { state: ProcState; why: string } =>
205  reason === 'answer' ? { state: 'X', why: '' } : reason === 'aborted' ? { state: 'Z', why: 'stopped' } : { state: 'Z', why: reason }
206
207// ---------------------------------------------------------------- display
208
209/** Live ones by start (so rows don't move), then the newest endings. */
210const ordered = (list: readonly Proc[]): Proc[] => {
211  const live = list.filter(isLive).sort((a, b) => a.startedAt - b.startedAt)
212  const done = list.filter(p => !isLive(p)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
213  return [...live, ...done]
214}
215
216/** Top-level processes in drawing order; a workflow's agents stay inside their workflow. */
217export const topLevel = (list: readonly Proc[]): Proc[] => ordered(list.filter(p => p.kind !== 'wfagent'))
218
219export const childrenOf = (list: readonly Proc[], id: string) => ordered(list.filter(p => p.parent === id))
220
221/** The agents a live workflow shows under it in the short list, and how many fold into `+N more`. */
222export const shownKids = (list: readonly Proc[], id: string) => {
223  const kids = childrenOf(list, id)
224  return { shown: kids.slice(0, KIDS_SHOWN), hidden: Math.max(0, kids.length - KIDS_SHOWN) }
225}
226
227/** Rows a top-level process takes: a live agent adds a line when it has something to say, a live workflow its agents. */
228export const itemHeight = (p: Proc, list: readonly Proc[], hasSecondLine: boolean) => {
229  if (!isLive(p)) return 1
230  if (p.kind === 'workflow') {
231    const k = shownKids(list, p.id)
232    return 1 + k.shown.length + (k.hidden > 0 ? 1 : 0)
233  }
234  return p.kind === 'agent' && hasSecondLine ? 2 : 1
235}
236
237/** How many rows the opened process's body has: its calls, or a workflow's agents. */
238export const detailItems = (p: Proc, list: readonly Proc[], calls: readonly ToolRow[]) =>
239  p.kind === 'workflow' ? childrenOf(list, p.id).length : calls.length
240
241/** `840`, `8.1k`, `31k`, `1.2M`. */
242export const kTokens = (n: number) =>
243  n >= 1_000_000
244    ? `${(n / 1_000_000).toFixed(1)}M`
245    : n >= 10_000
246      ? `${Math.round(n / 1000)}k`
247      : n >= 1000
248        ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
249        : String(n)
250
hooks/palette.ts 30 lines
1import { mix } from './core/color'
2
3/** Neutrals for everything finished, one ice accent for what is alive, red for what failed. */
4export const C = {
5  hi: '#ECEAF4',
6  body: '#C6C3D3',
7  muted: '#9C99AD',
8  dim: '#7C798E',
9  faint: '#34333F',
10  accent: '#5CE1FF',
11  err: '#FF5C7A',
12  // The terminal's own background cannot be read: this sits just above the usual dark ones.
13  hover: '#2E2D3A',
14}
15
16export type Palette = typeof C
17
18const BREATH_STEPS = 16
19export const BREATH_MS = 150
20
21/** One breath of the live bar, full accent down toward the rule color and back. */
22export const BREATH = Array.from({ length: BREATH_STEPS }, (_, i) => mix(C.accent, C.faint, 0.3 * (1 - Math.cos((i / BREATH_STEPS) * 2 * Math.PI))))
23
24/**
25 * A graph pixel: brightest on the top edge and at the newest column, never past the rule color, so the
26 * oldest pixels never read as a dark patch on a lighter background. Gray once nothing runs.
27 */
28export const graphColor = (isLive: boolean) => (fromTop: number, age: number) =>
29  mix(isLive ? C.accent : C.dim, C.faint, Math.min(0.85, 0.08 + fromTop * 0.6 + 0.45 * age ** 1.5))
30
hooks/views/graph.tsx 86 lines
1import type { RenderNode } from 'claude-code'
2
3import type { FlowCols } from '../../types'
4import { rgb } from '../core/color'
5import { flowRates } from '../core/flow'
6import { areaCells, brailleRows, encodeCells } from '../core/graph'
7import { GRAPH_ROWS } from '../core/layout'
8import type { Palette } from '../palette'
9import { graphColor } from '../palette'
10import type { Els } from './section'
11
12export const GRAPH_KEY = 'graph'
13
14/** The narrowest body the graph draws in. */
15export const GRAPH_MIN_W = 16
16
17/** Columns the graph takes in a body `W` wide: a space each side, and the number beside it. */
18export const graphCols = (W: number) => W - 8
19
20const LABEL_W = 7
21const DEFAULT = 0x01000000
22
23/** What the graph draws: the flow's closed columns, and whether anything still runs. */
24export type GraphData = { flow: FlowCols; isLive: boolean }
25
26const ratesOf = (g: GraphData, cols: number) => flowRates(g.flow, cols)
27
28const label = (C: Palette, g: GraphData, tps: string): [string, string][] => [
29  [` ${tps.padStart(5)} `, g.isLive ? C.hi : C.muted],
30  [' tok/s ', C.muted],
31  ['   60s ', C.dim],
32]
33
34/** The Raster's cells: the area, then the speed of the newest column beside it, so one blit moves both. */
35export const graphCells = (C: Palette, g: GraphData, cols: number) => {
36  const rates = ratesOf(g, cols)
37  const area = areaCells(rates, GRAPH_ROWS, graphColor(g.isLive))
38  const lines = label(C, g, String(Math.round(rates.at(-1) ?? 0)))
39  const width = cols + LABEL_W
40  const out = new Uint32Array(width * GRAPH_ROWS * 3)
41  for (let r = 0; r < GRAPH_ROWS; r++) {
42    out.set(area.subarray(r * cols * 3, (r + 1) * cols * 3), r * width * 3)
43    const [text, color] = lines[r] ?? ['', C.dim]
44    const glyphs = [...text.padEnd(LABEL_W)].slice(0, LABEL_W)
45    glyphs.forEach((ch, i) => out.set([ch.codePointAt(0) ?? 0x20, rgb(color), DEFAULT], (r * width + cols + i) * 3))
46  }
47  return encodeCells(out)
48}
49
50/** Output speed as a scope: eighths of a cell where the surface has a Raster, braille elsewhere. */
51export const graphView = (
52  els: Els,
53  C: Palette,
54  W: number,
55  d: { g: GraphData; raster: ((cols: number, cells: string) => RenderNode) | undefined },
56): RenderNode => {
57  const { Box, Text } = els
58  const cols = graphCols(W)
59  if (d.raster)
60    return (
61      <Box width={W}>
62        <Text> </Text>
63        {d.raster(cols + LABEL_W, graphCells(C, d.g, cols))}
64      </Box>
65    )
66  const rates = ratesOf(d.g, cols)
67  return (
68    <Box width={W}>
69      <Text> </Text>
70      <Box flexDirection="column">
71        {brailleRows(
72          rates.flatMap(v => [v, v]),
73          GRAPH_ROWS,
74        ).map(row => (
75          <Text color={d.g.isLive ? C.accent : C.dim}>{row}</Text>
76        ))}
77      </Box>
78      <Box flexDirection="column">
79        {label(C, d.g, String(Math.round(rates.at(-1) ?? 0))).map(([text, color]) => (
80          <Text color={color}>{text}</Text>
81        ))}
82      </Box>
83    </Box>
84  )
85}
86