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…

██████╗ ██╗ ██╗████████╗ ██████╗██╗ ██╗██████╗ ███████╗ ██████╗██╗ ██╗
██╔════╝ ██║ ██║╚══██╔══╝██╔════╝██║ ██║██╔══██╗██╔════╝██╔════╝██║ ██╔╝
██║ ███╗██║ ██║ ██║ ██║ ███████║██║ ██║█████╗ ██║ █████╔╝
██║ ██║██║ ██║ ██║ ██║ ██╔══██║██║ ██║██╔══╝ ██║ ██╔═██╗
╚██████╔╝███████╗██║ ██║ ╚██████╗██║ ██║██████╔╝███████╗╚██████╗██║ ██╗
╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱
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.
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:
| Kind | Comes from | Ends when |
|---|---|---|
| agent | the Agent tool, foreground or background | its turn completes, its notification arrives, or the agent list says so |
| workflow | the Workflow tool; a few of its agents nest under it while it runs | its task notification, or the next stop that no longer lists it |
| bash / pwsh | a shell command run in the background, by the session or an agent | its 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 |
| monitor | the Monitor tool | its 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.
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.
| Action | How |
|---|---|
| Open or close the pane | /glitchdeck (also /glitchdeck open, /glitchdeck close) |
| Open a process | click any line of it |
| Back to the list | click ‹ name at the top of the timeline |
| Every process, one line each | click +N more; ▴ / ▾ page it, ‹ fewer returns |
| Page through prompts | click ▴ / ▾ |
| Jump to a prompt | click 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.
One option, set in /config or under pluginConfigs in your settings: openOnStart (default true) opens the pane when a session starts.
--:--) and jump by matching the prompt's text. When neither can be read, a toast says so.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.
.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
MIT
hooks/register.tsx 681 lines1import { 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}
681hooks/core/effects.ts 62 lines1import 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}
62hooks/core/flow.ts 96 lines1import 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}
96hooks/core/fmt.ts 32 lines1import { 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}
32hooks/core/glitch.ts 31 lines1import { 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}
31hooks/core/history.ts 121 lines1import 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}
121hooks/core/graph.ts 80 lines1import { 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}
80hooks/core/layout.ts 109 lines1// 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}
109hooks/core/main.ts 168 lines1import 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}
168hooks/core/procs.ts 250 lines1import 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)
250hooks/palette.ts 30 lines1import { 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))
30hooks/views/graph.tsx 86 lines1import 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