TL;DR for Claude Code: every turn as one row, with what to check.

Every turn, in one row. What changed, how the tests went, and what to check before you trust it, in Claude Code's own row style.


In a Claude Code terminal session, type:
/plugin install tldr --marketplace kushalBanda/claude-tldr
Answer y to add the marketplace, then pick a scope (user is the first one). The mod starts at once, with no restart. It needs Claude Code 2.1.301 or later.






Also: when a turn over 30 seconds ends, a notification carries its verdict, and the status line keeps it until your next prompt. /tldr copy copies the reply as clean text, and /tldr diff prints the net diff of the turn. On desktop and in VS Code, Claude Code draws its own rows. TL;DR is for the terminal.
| Command | Does |
|---|---|
/tldr | Help. |
/tldr show [row] | Open what's behind a line of the latest row: changed, tests, check, ran, looked, notes. With no row, the steps of the turn in order. Escape closes it. |
/tldr context | Open the context pane: each category that fills the window, with its tokens and share. |
/tldr history | Open the kept rows of this session, newest first. |
/tldr copy | Copy the latest reply as clean text: no trailing spaces, no shared indentation. |
/tldr diff | Print the net change of the latest turn per file, as a unified diff. |
Every feature is a switch, and all are on by default. Change them with /plugin configure tldr@claude-tldr in Claude Code.
| Switch | Off means |
|---|---|
card | Claude Code's own rows. Also turns off trust, keep, actions and notify. |
motion | Claude Code's own spinner, and no TL;DR line while a turn runs. |
animation | Motion keeps its colors but doesn't move. The band's clock moves only when a step lands. |
context | No context bar. |
trust | No lines to check, no amber dot. |
keep | Nothing saved, no note after /compact. |
actions | No copy or diff commands. |
notify | No notification and no status line when a long turn ends. |
pytest, unittest, jest, vitest, Playwright, go test, gotestsum, cargo test and nextest, bun test, and deno test. It also reads npm, pnpm and yarn test, make test, just test and tox: it finds the summary of the runner they start. For go test, counts need -v, and a compile error reads ✗ build failed. Runs that only list tests (--collect-only, --listTests) don't count.
notify switch turns it off.command.run only to answer its own /tldr command, and tool.call only to read each call and its result, which it passes back unchanged. It never changes or blocks a call.claude plugin validate on a clone. The hooks: and calls: lines list every event the mod handles and every API it calls.tee, sed -i, perl -i, cp, mv, touch, git checkout -- path or git restore. They show as path via Bash, without line counts. Writes from scripts (python x.py), git apply and git stash pop don't show. A path built from a variable (> $OUT) is left out.cd counts inside one command (cd app && touch x), but not when it ran in an earlier call.go test output, the row keeps the failures it sees but drops the pass count. A summary line (gotestsum, or a wrapper's own) keeps all counts.find -delete, git clean -f, xargs rm) are flagged by command, not file by file.make test that runs go test and then vitest) shows the first summary it finds, not the sum.motion off to get it back./tldr show notes brings it back.Clone the repository, and in its folder:
claude --plugin-dir . # run it; a save reloads the mod
claude plugin validate . # manifest, marketplace and hooks
claude plugin test . # the test suite (tests/*.test.ts*)
The mod is TypeScript with no dependencies. Render hooks are pure, and all parsing is static: TL;DR never runs a command to learn what a command did. See CONTRIBUTING.md to send a change.
The images in this README are mock-ups of the mod's output for the turn in tests/fixtures/login-bug.ts.
Bug reports, ideas and pull requests are welcome. Use the issue forms. A screenshot of the TL;DR row helps a lot. To report a security problem, see SECURITY.md.
MIT © Kushal Banda
hooks/register.tsx 624 lines1// TL;DR for Claude Code: each turn folds into one card.
2// This file wires hooks and holds every function that takes $; the logic lives in the modules it imports.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderInput, Timer } from 'claude-code'
5
6import type { Kept, Live, Plan, Turn } from '../types'
7
8import { addDiff, cleanCopy, diffOf, mergeNet, turnDiff } from './actions'
9import { classify, placePath } from './classify'
10import { unifiedDiff } from './netdiff'
11import { drawCard } from './draw/card'
12import { drawBand, keptNote, PLAN_BAR_KEY, PLAN_ROW_KEY, revealKeys } from './draw/band'
13import { CONTEXT_PANE, drawContextPane } from './draw/context'
14import { contextFill } from './context'
15import { doneText, jumpParts, nextJump, runLine } from './progress'
16import type { ContextFill } from './context'
17import { drawCompactLine } from './draw/compact-line'
18import { DETAIL_PANE, drawDetail, paneRows, paneTitle, sectionOf } from './draw/detail'
19import type { Section } from './draw/detail'
20import { drawHistory, HISTORY_PANE } from './draw/history'
21import { drawSpinner, SPINNER_KEY } from './draw/spinner'
22import { glyph } from './draw/theme'
23import { commandKey, duration, durationsKey, isDurations, remember, REMEMBER_MS } from './durations'
24import type { Durations } from './durations'
25import { readFeatures } from './features'
26import { encodeCells, fitsCells, planBarFrame, PULSE_MS, shimmerRowFrame, spinnerFrame } from './motion'
27import { applyTaskCall, EMPTY_PLAN, planView, TASK_TOOLS, taskLabel } from './plan'
28import type { SpinnerMode } from './motion'
29import { isTurnList, KEPT_MAX, keptKey, MAX_TURNS, turnForCalls, turnForDuration } from './state'
30import { activityWord, isMeta, logLines, runName } from './summary'
31import { verdictOf } from './steps'
32import { checkTurn } from './trust'
33import { endTurn, isInterim, isReply, recordCall, recordStep, startTurn, toKept } from './turn'
34
35const HELP = 'TL;DR is on. Click a line of the TL;DR row for its detail, or type its digit at the empty prompt, or /tldr show [changed|tests|check|ran|looked] · /tldr context · /tldr history · /tldr copy · /tldr diff'
36
37// The frame loop repaints the spinner between renders (module state: a reload restarts it).
38const FRAME_MS = 80
39let frameMs = 0
40let loop: Timer | undefined
41let spinnerSite: { requestId: string; mode: SpinnerMode } | null = null
42// The band's stepper as last drawn, for the loop to repaint its bar and running row; doneFrame is when the last task was done.
43let planSite: { requestId: string; done: number; total: number; barColumns: number; row: string | null; rowColumns: number } | null = null
44let doneFrame = -Infinity
45// The band's clock: once a second while a turn runs, so the running step's time and the ring move between calls.
46const TICK_MS = 1000
47let tick: Timer | undefined
48// A finished turn at least this long sends a notification and leaves its verdict on the status line.
49const LONG_TURN_MS = 30_000
50let statusShown = false
51// What the detail pane shows: the turn and the row pressed last (or /tldr show).
52let detailFor: { turnId: string; section: Section | 'all' } | null = null
53// Fullscreen: the row whose detail is open inside its card. One at a time; the same press closes it.
54let inlineFor: { turnId: string; section: Section | 'all' } | null = null
55// The context window by category, as the band's bar draws it: measured after each request (a local estimate).
56let contextNow: ContextFill | null = null
57let measuring = false
58// A measure asked while one runs: that one may read the transcript from before a change, so it runs again after.
59let measureAgain = false
60const COMPACT_SETTLE_MS = 250
61// Each edited file's text before the open turn's first edit of it, for the turn's net diff (null: too large).
62let openTurnId: string | null = null
63let befores = new Map<string, string | null>()
64const BEFORE_MAX = 256 * 1024
65const EDIT_TOOLS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
66
67function startLoop($: EngineInterface) {
68 if (loop !== undefined) return
69 loop = $.clock.every(FRAME_MS, () => {
70 frameMs += FRAME_MS
71 const spin = spinnerSite
72 if (spin !== null)
73 void $.ui.blit({ requestId: spin.requestId, key: SPINNER_KEY, cells: encodeCells(spinnerFrame(spin.mode, frameMs)) })
74 .then(r => { if (r.deny !== undefined && spinnerSite === spin) spinnerSite = null })
75 .catch(() => {})
76 const p = planSite
77 if (p !== null) {
78 void $.ui.blit({ requestId: p.requestId, key: PLAN_BAR_KEY, cells: encodeCells(planBarFrame(p.done, p.total, frameMs - doneFrame, frameMs, p.barColumns)) })
79 .then(r => { if (r.deny !== undefined && planSite === p) planSite = null })
80 .catch(() => {})
81 if (p.row !== null) void $.ui.blit({ requestId: p.requestId, key: PLAN_ROW_KEY, cells: encodeCells(shimmerRowFrame(p.row, frameMs, p.rowColumns)) }).catch(() => {})
82 }
83 })
84}
85
86function endLoop() {
87 loop?.cancel()
88 loop = undefined
89 spinnerSite = null
90 planSite = null
91}
92
93export const register: Register = (on, options) => {
94 const f = readFeatures(options)
95
96 on('session.start', async ($, e, next) => {
97 const r = await next(e)
98 await $.command
99 .register({ name: 'tldr', description: 'TL;DR for Claude Code: turn cards', argumentHint: '[show|context|history|copy|diff]' })
100 .catch(() => {})
101 if (f.context) await measureContext($)
102 // A resumed session: its earlier cards are on disk. They also stand in for the turns, so rows redrawn after a restart find their card.
103 if (f.keep) {
104 const list = await loadKept($).catch(() => [])
105 if (list.length > 0) {
106 await seedTurns($, list).catch(() => {})
107 await setKept($, () => ({ count: list.length, note: true })).catch(() => {})
108 }
109 }
110 return r
111 })
112
113 // A turn of the main conversation opens a card; subagents fill their own windows and are left out.
114 on('turn.start', async ($, e, next) => {
115 const r = await next(e)
116 if (e.agentId === undefined) {
117 openTurnId = e.turnId
118 befores = new Map()
119 const now = await $.clock.now()
120 await openTurn($, startTurn(e.turnId, e.text, now))
121 if (f.keep) await setKept($, k => ({ ...k, note: false })).catch(() => {})
122 // The last long turn's verdict stays on the status line until the next prompt.
123 if (statusShown) {
124 $.ui.status(undefined)
125 statusShown = false
126 }
127 if (f.motion) {
128 await setLive($, () => ({ phase: 'working', word: 'Thinking', running: null, since: now })).catch(() => {})
129 if (f.animation) {
130 startLoop($)
131 tick ??= $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
132 }
133 }
134 }
135 return r
136 })
137
138 // Observe only: the engine's result goes back unchanged.
139 on('tool.call', async ($, e, next) => {
140 // Link the call to the turn first: the engine draws its group while the call still runs.
141 const startedAt = await $.clock.now()
142 const command = e.tool === 'Bash' ? commandKey(String((e as { command?: unknown }).command ?? '')) : null
143 if (e.agentId === undefined) {
144 await updateCurrent($, t => recordCall(t, e.tool_use_id, [])).catch(() => {})
145 if (f.motion) {
146 // The band's TL;DR line names the new job, and a command it has timed before gets a ring against last time.
147 const lastMs = command !== null ? (await loadDurations($))[command] ?? null : null
148 const running = { id: e.tool_use_id, label: activityWord(e.tool, e), startedAt, isCommand: command !== null, lastMs }
149 await setLive($, l => ({ ...l, word: running.label, running })).catch(() => {})
150 $.ui.invalidate('ui.render')
151 }
152 const path = editPath(e.tool, e)
153 if (f.actions && path !== undefined && !befores.has(path)) {
154 // Missing (ENOENT) is a new file, empty before; any other failure leaves the net diff unknown.
155 const text = await $.fs.read(path).catch((err: unknown) => (/ENOENT|not found|no such file/i.test(String(err)) ? '' : null))
156 befores.set(path, typeof text === 'string' && text.length <= BEFORE_MAX ? text : null)
157 }
158 }
159 const ran = await next(e).finally(async () => {
160 if (e.agentId !== undefined || !f.motion) return
161 const endedAt = await $.clock.now()
162 await setLive($, l => (l.running?.id === e.tool_use_id ? { ...l, word: 'Thinking', running: null, since: endedAt } : l)).catch(() => {})
163 })
164 if (e.agentId !== undefined || ran.deny !== undefined) return ran
165 // How long this command took, for the ring next time it runs.
166 if (command !== null && command !== '' && f.motion) {
167 const ms = (await $.clock.now()) - startedAt
168 if (ms >= REMEMBER_MS) await saveDuration($, command, ms).catch(() => {})
169 }
170 // Claude's own task list feeds the band's stepper.
171 if (f.card && f.motion && TASK_TOOLS.includes(e.tool) && ran.isError !== true && openTurnId !== null) {
172 const before = await readPlan($)
173 const after = applyTaskCall(before, e.tool, e, ran.result, openTurnId, await $.clock.now())
174 if (after !== before) {
175 await setPlan($, () => after).catch(() => {})
176 if (after.done !== before.done) {
177 doneFrame = frameMs
178 // The done task's highlight ends on the next draw after it runs out.
179 $.clock.after(PULSE_MS + 50, () => $.ui.invalidate('ui.render'))
180 }
181 $.ui.invalidate('ui.render')
182 }
183 }
184 const root = await $.session.root().catch(() => undefined)
185 const calls = classify(e.tool, e, ran.result, ran.isError === true, root)
186 const meta = isMeta(e.tool)
187 const lines = meta ? [] : logLines(e.tool, calls, e)
188 const edit = f.actions ? diffOf(e.tool, e, ran.result, ran.isError === true) : null
189 const diff = edit === null ? null : { ...edit, path: placePath(edit.path, root).path }
190 const now = await $.clock.now()
191 await updateCurrent($, t => {
192 const called = recordCall(t, e.tool_use_id, calls, lines, meta ? undefined : runName(e.tool, e))
193 const recorded = diff === null ? called : { ...called, diffs: addDiff(called.diffs, diff) }
194 const done = f.trust ? { ...recorded, flags: checkTurn(recorded) } : recorded
195 // A test result or a new flag is the band's "last" until the next one.
196 return { ...done, jump: nextJump(t, done, now) ?? t.jump }
197 }).catch(() => {})
198 // The band's TL;DR line counts the finished call.
199 if (f.motion) $.ui.invalidate('ui.render')
200 return ran
201 }).catch((_$, e, next) => next(e)) // a failure here must never stop or change a tool call
202
203 // A finished response: text before a tool call folds into the card; text with no tool call is the reply.
204 on('turn.step', async function* ($, e, next) {
205 const r = yield* next(e)
206 if (e.agentId === undefined) {
207 await updateTurn($, r.turnId, t => recordStep(t, r.answer, r.toolUses.length > 0)).catch(() => {})
208 if (f.context) void measureContext($)
209 }
210 return r
211 })
212
213 on('turn.complete', async ($, e, next) => {
214 const r = await next(e)
215 if (e.agentId === undefined) {
216 const now = await $.clock.now()
217 const net = f.actions && openTurnId === e.turnId ? await netDiffs($) : []
218 await closeCurrent($, t => endTurn({ ...t, diffs: mergeNet(t.diffs, net) }, now)).catch(() => {})
219 openTurnId = null
220 befores = new Map()
221 if (f.keep) {
222 const t = await latestTurn($)
223 if (t !== null && (t.toolUseIds.length > 0 || t.reply !== null)) {
224 const count = await saveKept($, toKept(t)).catch(() => null)
225 if (count !== null) await setKept($, k => ({ ...k, count })).catch(() => {})
226 }
227 }
228 if (f.motion) await setLive($, () => ({ phase: 'idle', word: '', running: null, since: now })).catch(() => {})
229 endLoop()
230 tick?.cancel()
231 tick = undefined
232 if (f.notify) await tellDone($, now)
233 }
234 return r
235 })
236
237 on('command.run', { command: 'tldr' }, async ($, e) => {
238 const sub = e.args.trim().toLowerCase()
239 if (sub === 'context') {
240 if (!f.context) return { text: 'TL;DR: the context bar is off. Turn it on in /plugin, TL;DR options.' }
241 await openContext($)
242 return { text: contextNow === null ? 'TL;DR: no context figures yet.' : `TL;DR: context opened, ${contextNow.percent}% used. Escape closes it.` }
243 }
244 if (!f.card) return { text: 'TL;DR: the turn card is off. Turn it on in /plugin, TL;DR options.' }
245 // One card, one detail: `show` opens what sits behind a row. `expand` is kept as a word for it.
246 const [verb = '', arg = ''] = sub.split(/\s+/)
247 if (verb === 'show' || verb === 'expand') {
248 const section = sectionOf(arg)
249 if (section === null) return { text: `TL;DR: no row "${arg}". Rows: changed, tests, check, ran, looked.` }
250 const t = await lastTurn($, f.keep)
251 if (t === null) return { text: 'TL;DR: no turn yet.' }
252 await showDetail($, t, section)
253 const what = section === 'all' ? `the whole turn "${t.goal}"` : paneTitle(t, section).replace(/^TL;DR · /, '')
254 return { text: `TL;DR: ${what} opened. Escape closes it.` }
255 }
256 if (verb === 'collapse' || verb === 'hide') {
257 await $.ui.close({ id: DETAIL_PANE }).catch(() => {})
258 return { text: 'TL;DR: detail closed.' }
259 }
260 if (sub === 'history' && !f.keep) return { text: 'TL;DR: keeping cards is off. Turn it on in /plugin, TL;DR options.' }
261 if (sub === 'history') {
262 const n = (await loadKept($).catch(() => [])).length
263 await $.ui.open({ id: HISTORY_PANE, title: 'TL;DR history' })
264 return { text: `TL;DR: history opened, ${n} card${n === 1 ? '' : 's'} kept.` }
265 }
266 if (f.actions && (sub === 'copy' || sub === 'diff')) {
267 const t = await lastTurn($, f.keep)
268 if (t === null) return { text: 'TL;DR: no turn yet.' }
269 if (sub === 'copy') return { text: await copyReply($, t) }
270 const diff = turnDiff(t)
271 return { text: diff === '' ? `TL;DR: no edits kept for "${t.goal}".` : diff }
272 }
273 return { text: HELP }
274 })
275
276 // The card takes the place of the turn's tool rows: the turn's first group draws it, later groups draw nothing.
277 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
278 if (!f.card || e.surface !== 'terminal') return next(e)
279 const ids = e.props.calls.map(c => c.tool_use_id).filter((id): id is string => id !== undefined)
280 const found = turnForCalls(await allTurns($), ids)
281 if (found === null) return next(e)
282 // Main screen: printed rows cannot change, so each group is one line; the card follows at turn end.
283 if (e.viewport?.isFullscreen === false) return e.props.isExpanded ? next(e) : drawCompactLine($.ui.resolve(e), e.props.calls, await $.session.root().catch(() => undefined))
284 // A click on the group expands it, and its rows are ToolUse rows that would draw the card again: the card stays the one view.
285 const { Box } = $.ui.resolve(e)
286 if (!found.isFirst) return <Box key="tldr-folded" />
287 const t = found.turn
288 return drawCard($.ui.resolve(e), t, { isExpanded: e.props.isExpanded, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
289 })
290
291 // A call the transcript does not fold into a group (Bash, Edit, Write, ...) is its own row: the same rule as a group.
292 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
293 if (!f.card || e.surface !== 'terminal') return next(e)
294 const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
295 if (found === null) return next(e)
296 const t = found.turn
297 if (e.viewport?.isFullscreen === false) return drawCompactLine($.ui.resolve(e), [e.props], await $.session.root().catch(() => undefined))
298 const { Box } = $.ui.resolve(e)
299 if (!found.isFirst) return <Box key="tldr-folded" />
300 // A standalone row has no ctrl+o of its own: the card stands for it, and a press opens the detail.
301 return drawCard($.ui.resolve(e), t, { isExpanded: false, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
302 })
303
304 // The result block under a standalone row: folded with its row.
305 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
306 if (!f.card || e.surface !== 'terminal') return next(e)
307 const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
308 if (found === null) return next(e)
309 const { Box } = $.ui.resolve(e)
310 return <Box key="tldr-folded-result" />
311 })
312
313 // In-between text folds into the card in fullscreen; the main screen keeps what it printed.
314 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
315 if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen === false) return next(e)
316 // A block that is also some turn's reply is ambiguous (a later answer can repeat earlier in-between text): it stays.
317 const recent = (await allTurns($)).slice(-50)
318 if (!recent.some(t => isInterim(t, e.props.text)) || recent.some(t => isReply(t, e.props.text))) return next(e)
319 const { Box } = $.ui.resolve(e)
320 return <Box key="tldr-folded-text" />
321 })
322
323 // The mode-aware spinner, in place of the engine's spinning word.
324 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
325 if (!f.motion || e.surface !== 'terminal') return next(e)
326 const l = await readLive($)
327 const t = await latestTurn($)
328 const now = await $.clock.now()
329 const word = e.props.message ?? (l.phase !== 'idle' && l.word ? l.word : e.props.word)
330 // Any spinner moves, not only a turn's: /compact draws one with no turn open.
331 if (f.animation) {
332 spinnerSite = { requestId: e.requestId, mode: e.props.mode as SpinnerMode }
333 startLoop($)
334 }
335 return drawSpinner($.ui.resolve(e as RenderInput<'Spinner', 'terminal'>), e, word, t !== null && t.endedAt === null ? now - t.startedAt : null, frameMs)
336 })
337
338 // The band above the prompt: the kept-cards note and the row keys; what other mods draw there stays, above it.
339 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
340 const rest = await next(e)
341 if (e.surface !== 'terminal' || e.props.hasSurvey) return rest
342 const k = f.keep ? await readKept($) : { count: 0, note: false }
343 const note = k.note && k.count > 0 ? keptNote(k.count) : null
344 // On the main screen the printed card can't take a press, so its rows sit on the band as digit keys; a key opens its row's detail on the band.
345 const t = f.card && e.viewport?.isFullscreen === false && !e.props.isWorking ? await lastTurn($, f.keep) : null
346 const reveal = t !== null && t.endedAt !== null
347 ? { turnId: t.id, verdict: verdictOf(t), keys: revealKeys(t), show: (s: Section | 'all') => toggleInline($, t, s), open: inlineFor?.turnId === t.id ? { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), t, inlineFor.section) } : null }
348 : null
349 const context = f.context && contextNow !== null ? { fill: contextNow, open: () => void openContext($) } : null
350 // While a turn runs: what runs now against last time, and the last thing that happened. The tick redraws it each second.
351 const open = f.card && f.motion && e.props.isWorking ? await latestTurn($) : null
352 const running = open !== null && open.endedAt === null
353 const now = running ? await $.clock.now() : 0
354 const l = running ? await readLive($) : null
355 const run = l !== null ? runLine({ word: l.word, running: l.running, since: l.since, now, turn: open! }) : null
356 const plan = running ? await planParts($, e.requestId, open!.id, e.props.bodyColumns, f.animation) : null
357 if (plan === null && planSite?.requestId === e.requestId) planSite = null
358 if (note === null && reveal === null && context === null && run === null) return rest
359 return drawBand($.ui.resolve(e as RenderInput<'AbovePrompt', 'terminal'>), e, rest ?? null, { note, reveal, context, run, plan, last: running ? jumpParts(open!.jump, now) : [] })
360 })
361
362 // After a compaction the earlier rows are gone from the screen; the band points to the kept cards.
363 // While it runs, the spinner moves and says so: a compaction can take a minute. A precompute runs unseen and is left out.
364 on('session.compact', async ($, e, next) => {
365 const shown = f.motion && e.agentId === undefined && e.trigger !== 'precompute'
366 if (shown) {
367 await setLive($, l => ({ ...l, phase: 'compacting', word: 'Compacting' })).catch(() => {})
368 if (f.animation) startLoop($)
369 }
370 const r = await next(e).finally(async () => {
371 if (!shown) return
372 // An automatic compaction can fall inside a turn: the turn goes on working.
373 const inTurn = openTurnId !== null
374 await setLive($, l => ({ ...l, phase: inTurn ? 'working' : 'idle', word: inTurn ? 'Thinking' : '' })).catch(() => {})
375 if (!inTurn) endLoop()
376 })
377 if (f.keep && e.agentId === undefined && 'messages' in r) await setKept($, k => ({ ...k, note: k.count > 0 })).catch(() => {})
378 // The engine puts the compacted messages in place after the chain returns: measured now, the bar keeps the old figures.
379 if (f.context && e.agentId === undefined && 'messages' in r && e.trigger !== 'precompute') $.clock.after(COMPACT_SETTLE_MS, () => void measureContext($))
380 return r
381 })
382
383 on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, async ($, e) => drawContextPane($.ui.resolve(e as RenderInput<'Pane', 'terminal'>), contextNow))
384 on('ui.render', { component: 'Pane', requestId: HISTORY_PANE }, async ($, e) => drawHistory($.ui.resolve(e), await loadKept($).catch(() => [])))
385
386 on('ui.render', { component: 'Pane', requestId: DETAIL_PANE }, async ($, e) => {
387 const all = [...(await allTurns($)), ...(f.keep ? await loadKept($).catch(() => []) : [])]
388 return drawDetail($.ui.resolve(e), detailFor === null ? null : all.find(t => t.id === detailFor!.turnId) ?? null, detailFor?.section ?? 'all')
389 })
390
391 // Main screen: the full card takes the place of the turn's closing line ("Baked for 52s").
392 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
393 if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen !== false) return next(e)
394 const t = await durationTurn($, e.requestId, e.props.durationMs)
395 if (t === null) return next(e)
396 return drawCard($.ui.resolve(e), t, { isExpanded: false, durationMs: e.props.durationMs })
397 })
398
399 // Main screen: the line under the prompt says the latest card's rows open by digit, while the prompt is empty and Claude idle.
400 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
401 if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen !== false || e.props.isDraft || e.props.isWorking) return next(e)
402 const t = await lastTurn($, f.keep)
403 if (t === null || t.endedAt === null) return next(e)
404 const keys = revealKeys(t)
405 const range = keys.length === 1 ? keys[0]!.hotkey : `${keys[0]!.hotkey}–${keys.at(-1)!.hotkey}`
406 return next({ ...e, props: { ...e.props, tail: ` · type ${range} to open the last card` } })
407 })
408
409}
410
411/** The stepper's first frame, and the site the frame loop repaints; null when the open turn has no task list. */
412async function planParts($: EngineInterface, requestId: string, turnId: string, bodyColumns: number, animation: boolean) {
413 const plan = await readPlan($)
414 const view = planView(plan, turnId)
415 if (view === null) return null
416 // With animation off nothing repaints: the bar holds still and the running task is plain text.
417 const moving = animation && loop !== undefined
418 const barColumns = Math.max(10, Math.min(24, Math.floor(bodyColumns / 5)))
419 const label = view.current !== null ? taskLabel(view.current) : null
420 const row = moving && label !== null && fitsCells(label) ? label : null
421 const rowColumns = row === null ? 0 : Math.max(1, Math.min(bodyColumns, [...row].length + 4))
422 planSite = moving ? { requestId, done: view.done, total: view.total, barColumns, row, rowColumns } : null
423 const now = await $.clock.now()
424 return {
425 view,
426 barCells: encodeCells(planBarFrame(view.done, view.total, frameMs - doneFrame, frameMs, barColumns, moving)),
427 barColumns,
428 rowCells: row === null ? null : encodeCells(shimmerRowFrame(row, frameMs, rowColumns, moving)),
429 rowColumns,
430 justDone: plan.done !== null && now - plan.done.at < PULSE_MS ? plan.done.id : null,
431 }
432}
433
434/** The newest turn in this session; after a resume, the newest kept card. */
435async function lastTurn($: EngineInterface, keep: boolean): Promise<Turn | null> {
436 const t = await latestTurn($)
437 if (t !== null || !keep) return t
438 return (await loadKept($).catch(() => [])).at(-1) ?? null
439}
440
441async function copyReply($: EngineInterface, t: Turn): Promise<string> {
442 if (t.reply === null) return `TL;DR: "${t.goal}" has no reply to copy.`
443 const r = await $.ui.copy({ text: cleanCopy(t.reply) })
444 return r.isCopied ? 'TL;DR: reply copied.' : `TL;DR: could not copy the reply (${r.reason}).`
445}
446
447/** A long turn that just ended: a notification with its verdict, and the same on the status line until the next prompt. */
448async function tellDone($: EngineInterface, now: number) {
449 const t = await latestTurn($)
450 if (t === null || t.toolUseIds.length === 0 || now - t.startedAt < LONG_TURN_MS) return
451 const v = verdictOf(t)
452 const mark = v === 'pass' || v === 'fail' || v === 'check' ? ` ${glyph[v]}` : ''
453 $.ui.status(`TL;DR${mark} done ${duration(now - t.startedAt)}`)
454 statusShown = true
455 await $.ui.notify(doneText(t, now - t.startedAt), { title: 'TL;DR' }).catch(() => {})
456}
457
458/** Opens the detail pane behind a card row: small, holding the keys, closed by Escape. */
459async function showDetail($: EngineInterface, t: Turn, section: Section | 'all') {
460 detailFor = { turnId: t.id, section }
461 await $.ui.open({ id: DETAIL_PANE, title: paneTitle(t, section), focus: true, closeOnEscape: true, rows: paneRows(t, section) })
462 $.ui.invalidate('ui.render')
463}
464
465/** A press opens a row's detail where it was pressed (inside the fullscreen card, on the main screen's band), and the same press closes it. */
466function toggleInline($: EngineInterface, t: Turn, section: Section | 'all') {
467 inlineFor = inlineFor?.turnId === t.id && inlineFor.section === section ? null : { turnId: t.id, section }
468 $.ui.invalidate('ui.render')
469}
470
471function inlineOpen($: EngineInterface, e: RenderInput<'ToolGroup'> | RenderInput<'ToolUse'>, t: Turn) {
472 if (inlineFor?.turnId !== t.id) return undefined
473 return { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), t, inlineFor.section) }
474}
475
476/** The context window by category, as /context counts it but estimated locally, so it costs no request. */
477async function measureContext($: EngineInterface) {
478 if (measuring) {
479 measureAgain = true
480 return
481 }
482 measuring = true
483 measureAgain = false
484 try {
485 const u = await $.session.usage({ breakdown: 'summary' })
486 const b = u.context.breakdown
487 if (b !== undefined && b.rawMaxTokens > 0) {
488 contextNow = contextFill(b)
489 $.ui.invalidate('ui.render')
490 }
491 } catch {
492 // No figures: the bar keeps its last ones.
493 } finally {
494 measuring = false
495 }
496 if (measureAgain) await measureContext($)
497}
498
499async function openContext($: EngineInterface) {
500 await measureContext($)
501 await $.ui.open({ id: CONTEXT_PANE, title: 'TL;DR context', focus: true, closeOnEscape: true, rows: (contextNow?.slices.length ?? 1) + (contextNow?.setup.length ?? 0) + 3 })
502}
503
504function editPath(tool: string, input: unknown): string | undefined {
505 if (!EDIT_TOOLS.includes(tool)) return undefined
506 const i = input as { file_path?: unknown; notebook_path?: unknown }
507 return typeof i.file_path === 'string' ? i.file_path : typeof i.notebook_path === 'string' ? i.notebook_path : undefined
508}
509
510/** The open turn's edited files as net diffs: their text now against their text before the turn's first edit. */
511async function netDiffs($: EngineInterface): Promise<{ path: string; lines: string[] | null }[]> {
512 const root = await $.session.root().catch(() => undefined)
513 const out: { path: string; lines: string[] | null }[] = []
514 for (const [abs, before] of befores) {
515 const path = placePath(abs, root).path
516 if (before === null) {
517 out.push({ path, lines: null })
518 continue
519 }
520 const after = await $.fs.read(abs).catch(() => null)
521 out.push({ path, lines: typeof after === 'string' ? unifiedDiff(before, after) : null })
522 }
523 return out
524}
525
526// State: TL;DR's atoms, and every read and write of them and of the kept cards.
527
528const turns = atom({ plugin: 'tldr', key: 'turns' } as const, [] as Turn[])
529const IDLE: Live = { phase: 'idle', word: '', running: null, since: 0 }
530const live = atom({ plugin: 'tldr', key: 'live' } as const, IDLE)
531const kept = atom({ plugin: 'tldr', key: 'kept' } as const, { count: 0, note: false } as Kept)
532const current = atom({ plugin: 'tldr', key: 'current' } as const, null as string | null)
533const planAtom = atom({ plugin: 'tldr', key: 'plan' } as const, EMPTY_PLAN as Plan)
534
535async function readPlan($: EngineInterface): Promise<Plan> {
536 return read($, planAtom)
537}
538
539async function setPlan($: EngineInterface, fn: (p: Plan) => Plan): Promise<void> {
540 await update($, planAtom, p => fn(p ?? EMPTY_PLAN))
541}
542
543async function openTurn($: EngineInterface, t: Turn): Promise<void> {
544 // A turn that never completed (interrupted) ends where the next one starts, so its card stops saying "running".
545 await update($, turns, list => [...(list ?? []).filter(x => x.id !== t.id).map(x => (x.endedAt === null ? { ...x, endedAt: t.startedAt } : x)), t].slice(-MAX_TURNS))
546 await update($, current, () => t.id)
547}
548
549async function updateCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
550 const id = await read($, current)
551 if (id === null) return
552 await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
553}
554
555async function updateTurn($: EngineInterface, id: string, fn: (t: Turn) => Turn): Promise<void> {
556 await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
557}
558
559async function closeCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
560 await updateCurrent($, fn)
561 await update($, current, () => null)
562}
563
564// Drawing is pure, so the pins live in the module, like the spinner's site. A hot reload starts them over.
565const durationPins = new Map<string, string>()
566
567async function durationTurn($: EngineInterface, site: string, durationMs: number): Promise<Turn | null> {
568 const t = turnForDuration(await read($, turns), durationPins, site, durationMs, await $.clock.now())
569 if (t !== null) durationPins.set(site, t.id)
570 return t
571}
572
573async function latestTurn($: EngineInterface): Promise<Turn | null> {
574 const list = await read($, turns)
575 return list.at(-1) ?? null
576}
577
578async function allTurns($: EngineInterface): Promise<Turn[]> {
579 return read($, turns)
580}
581
582async function readLive($: EngineInterface): Promise<Live> {
583 return read($, live)
584}
585
586async function setLive($: EngineInterface, fn: (l: Live) => Live): Promise<void> {
587 await update($, live, l => fn(l ?? IDLE))
588}
589
590async function loadDurations($: EngineInterface): Promise<Durations> {
591 const v = await $.store.get(durationsKey(await $.session.root().catch(() => undefined))).catch(() => undefined)
592 return isDurations(v) ? v : {}
593}
594
595async function saveDuration($: EngineInterface, command: string, ms: number): Promise<void> {
596 const key = durationsKey(await $.session.root().catch(() => undefined))
597 await $.store.set(key, remember(await loadDurations($), command, ms))
598}
599
600async function loadKept($: EngineInterface): Promise<Turn[]> {
601 const v = await $.store.get(keptKey(await $.session.id()))
602 return isTurnList(v) ? v : []
603}
604
605async function saveKept($: EngineInterface, t: Turn): Promise<number> {
606 const list = (await loadKept($)).filter(x => x.id !== t.id)
607 const next = [...list, t].slice(-KEPT_MAX)
608 await $.store.set(keptKey(await $.session.id()), next)
609 return next.length
610}
611
612/** A restarted session (a resume, a /tui switch) starts with no turns in memory: the kept cards stand in, so its old rows draw cards again. */
613async function seedTurns($: EngineInterface, list: readonly Turn[]): Promise<void> {
614 await update($, turns, now => ((now ?? []).length > 0 ? now ?? [] : list.slice(-MAX_TURNS)))
615}
616
617async function readKept($: EngineInterface): Promise<Kept> {
618 return read($, kept)
619}
620
621async function setKept($: EngineInterface, fn: (k: Kept) => Kept): Promise<void> {
622 await update($, kept, k => fn(k ?? { count: 0, note: false }))
623}
624hooks/actions.ts 79 lines1// What the card's actions put on the clipboard or the screen: clean reply text, the turn's diff. Pure.
2import type { FileDiff, Turn } from '../types'
3import { patchesOf } from './classify'
4
5/** Claude's reply as plain text to paste: no CRs, no trailing spaces, no shared indentation, at most one blank line in a row. */
6export function cleanCopy(text: string): string {
7 const lines = text.replace(/\r\n?/g, '\n').split('\n').map(l => l.replace(/[ \t]+$/, ''))
8 const indents = lines.filter(l => l !== '').map(l => /^[ \t]*/.exec(l)![0].length)
9 const shared = indents.length > 0 ? Math.min(...indents) : 0
10 return lines
11 .map(l => l.slice(shared))
12 .join('\n')
13 .replace(/\n{3,}/g, '\n\n')
14 .trim()
15}
16
17function str(v: unknown): string | undefined {
18 return typeof v === 'string' ? v : undefined
19}
20
21const EDIT_TOOLS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
22
23/** One finished edit as diff lines: the engine's structured patch, or a new file's content as added lines. */
24export function diffOf(tool: string, input: unknown, result: unknown, isError: boolean): FileDiff | null {
25 if (isError || !EDIT_TOOLS.includes(tool)) return null
26 const i = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
27 const path = str(i.file_path) ?? str(i.notebook_path)
28 if (path === undefined) return null
29 const patches = patchesOf(result)
30 if (patches.length > 0) {
31 const lines: string[] = []
32 for (const p of patches) {
33 if (p.oldStart !== undefined && p.newStart !== undefined) lines.push(`@@ -${p.oldStart},${p.oldLines ?? 0} +${p.newStart},${p.newLines ?? 0} @@`)
34 lines.push(...p.lines)
35 }
36 return { path, lines }
37 }
38 const content = str(i.content)
39 if (tool !== 'Write' || content === undefined) return null
40 const added = content.split('\n')
41 if (added.at(-1) === '') added.pop()
42 return { path, lines: [`@@ -0,0 +1,${added.length} @@`, ...added.map(l => `+${l}`)] }
43}
44
45export const DIFF_FILE_MAX = 400 // lines kept per file per turn
46
47/** Adds one edit's lines to the turn's diffs, merged per file. */
48export function addDiff(diffs: readonly FileDiff[], d: FileDiff): FileDiff[] {
49 const i = diffs.findIndex(x => x.path === d.path)
50 if (i < 0) return [...diffs, { path: d.path, lines: d.lines.slice(0, DIFF_FILE_MAX) }]
51 return diffs.map((x, j) => (j === i ? { path: x.path, lines: [...x.lines, ...d.lines].slice(0, DIFF_FILE_MAX) } : x))
52}
53
54/**
55 * The turn's diffs with each file's net change in place of its edit-by-edit patches.
56 * A file with no net result (lines null: too large, or not read) keeps its patches; a file back to where it was drops out.
57 */
58export function mergeNet(diffs: readonly FileDiff[], net: readonly { path: string; lines: string[] | null }[]): FileDiff[] {
59 let out = [...diffs]
60 for (const n of net) {
61 if (n.lines === null) continue
62 out = out.filter(d => d.path !== n.path)
63 if (n.lines.length > 0) out.push({ path: n.path, lines: n.lines.slice(0, DIFF_FILE_MAX) })
64 }
65 return out
66}
67
68/** The turn's edits as one unified-style diff; empty when the turn edited nothing. */
69export function turnDiff(t: Turn): string {
70 const out: string[] = []
71 for (const d of t.diffs ?? []) {
72 // A path outside the project stays absolute, so it takes no a/ and b/ prefix.
73 const isAbsolute = d.path.startsWith('/') || d.path.startsWith('~')
74 out.push(isAbsolute ? `--- ${d.path}` : `--- a/${d.path}`, isAbsolute ? `+++ ${d.path}` : `+++ b/${d.path}`, ...d.lines)
75 if (d.lines.length >= DIFF_FILE_MAX) out.push(`… cut at ${DIFF_FILE_MAX} lines`)
76 }
77 return out.join('\n')
78}
79hooks/classify.ts 314 lines1// Turn one tool call and its result into facts for the card. Pure.
2import type { Change, TestRun } from '../types'
3import { detectRunner, parseResult } from './tests-parse'
4
5export type ClassifiedCall =
6 | { kind: 'read'; path: string }
7 | { kind: 'search'; pattern: string }
8 | { kind: 'change'; change: Change }
9 | { kind: 'test'; run: TestRun }
10 | { kind: 'other'; tool: string }
11
12export type Patch = { lines: string[]; oldStart?: number; oldLines?: number; newStart?: number; newLines?: number }
13
14const TEST_PATH = /(^|\/)(tests?|__tests__|spec)\/|(^|\/)(test_[^/]*|[^/]*_test\.[a-z]+|[^/]*\.(test|spec)\.[a-z]+|conftest\.py)$/
15
16export function isTestPath(path: string): boolean {
17 return TEST_PATH.test(path)
18}
19
20/** Lines added and removed, from the engine's structured patch. */
21export function patchDelta(patches: readonly Patch[]): { added: number; removed: number } {
22 let added = 0
23 let removed = 0
24 for (const p of patches)
25 for (const l of p.lines) {
26 if (l.startsWith('+')) added++
27 else if (l.startsWith('-')) removed++
28 }
29 return { added, removed }
30}
31
32function str(v: unknown): string | undefined {
33 return typeof v === 'string' ? v : undefined
34}
35
36function obj(v: unknown): Record<string, unknown> {
37 return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {}
38}
39
40export function patchesOf(result: unknown): Patch[] {
41 const p = obj(result).structuredPatch
42 return Array.isArray(p) ? p.filter((x): x is Patch => Array.isArray(obj(x).lines)) : []
43}
44
45// A test marked to be skipped, run alone or expected to fail, in the common frameworks.
46const SKIP = /(@pytest\.mark\.(skip|xfail)|pytest\.skip\(|\b(it|test|describe)\.(skip|only)\(|\bx(it|describe|test)\(|\bt\.Skip\(|#\[ignore\]|@Disabled\b|@Ignore\b|\bskip\()/
47
48/** Whether the patch adds a skip/only/xfail marker that the removed lines did not have. */
49export function addsSkipMarker(patches: readonly Patch[]): boolean {
50 const removed = new Set<string>()
51 const added: string[] = []
52 for (const p of patches)
53 for (const l of p.lines) {
54 if (l.startsWith('-')) removed.add(l.slice(1).trim())
55 else if (l.startsWith('+')) added.push(l.slice(1).trim())
56 }
57 return added.some(l => SKIP.test(l) && !removed.has(l))
58}
59
60/** A path as the card shows it: relative inside the project root, absolute outside it. */
61export function placePath(path: string, root: string | undefined): { path: string; outsideRoot: boolean } {
62 if (root === undefined || root === '') return { path, outsideRoot: false }
63 const base = root.endsWith('/') ? root : `${root}/`
64 if (path.startsWith(base)) return { path: path.slice(base.length), outsideRoot: false }
65 if (path === root) return { path: '.', outsideRoot: false }
66 if (!path.startsWith('/') && !path.startsWith('~')) return { path, outsideRoot: path.startsWith('..') }
67 return { path, outsideRoot: true }
68}
69
70/** "a, b +3 more": the first two names, so a list stays on one line. */
71export function few(names: readonly string[]): string {
72 const shown = names.slice(0, 2).join(', ')
73 return names.length > 2 ? `${shown} +${names.length - 2} more` : shown
74}
75
76/** A path as it fits on a card row: an absolute path outside the project keeps its last two parts. */
77export function shortPath(path: string): string {
78 if (!path.startsWith('/') && !path.startsWith('~')) return path
79 const parts = path.split('/').filter(p => p !== '')
80 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : path
81}
82
83// Heredoc bodies and quoted strings hold text, not commands. Heredocs are dropped; each quoted string
84// becomes a placeholder that `unquote` turns back into its text once a path has been picked out.
85type Words = { text: string; quoted: string[] }
86
87function shellWords(command: string): Words {
88 const quoted: string[] = []
89 const text = command
90 .replace(/<<-?\s*['"]?(\w+)['"]?([^\n]*)\n[\s\S]*?\n\s*\1\s*(?=\n|$)/g, '$2')
91 .replace(/'([^'\n]*)'|"((?:[^"\\\n]|\\.)*)"/g, (_, a: string | undefined, b: string | undefined) => `\x01${quoted.push(a ?? (b ?? '').replace(/\\(.)/g, '$1')) - 1}\x01`)
92 return { text, quoted }
93}
94
95function unquote(word: string, w: Words): string {
96 return word.replace(/\x01(\d+)\x01/g, (_, i: string) => w.quoted[Number(i)] ?? '')
97}
98
99// A path the text names: not empty, and no variable, command substitution or glob to expand.
100function named(path: string): boolean {
101 return path !== '' && !/[$`*?[\]]/.test(path)
102}
103
104/** `path` as seen from `dir`, both relative to where the command started. */
105function joinPath(dir: string, path: string): string {
106 if (dir === '' || path.startsWith('/') || path.startsWith('~')) return path
107 const out: string[] = []
108 for (const part of `${dir}/${path}`.split('/')) {
109 if (part === '' || part === '.') continue
110 if (part === '..' && out.length > 0 && out.at(-1) !== '..') out.pop()
111 else out.push(part)
112 }
113 const joined = out.join('/') || '.'
114 return dir.startsWith('/') ? `/${joined}` : path.endsWith('/') ? `${joined}/` : joined
115}
116
117function baseName(path: string): string {
118 return path.replace(/\/+$/, '').split('/').at(-1) ?? path
119}
120
121const CD_CMD = /^\s*cd(?:\s+(\S+))?\s*$/
122// `S=/tmp/x.sh` or `export OUT=dist/a.json` on its own: later parts can name the path through `$S`.
123const ASSIGN = /^\s*(?:export\s+)?([A-Za-z_]\w*)=(\S*)\s*$/
124
125type Vars = ReadonlyMap<string, string>
126
127/** `$NAME` and `${NAME}` replaced from what the same command set; a variable it did not set stays as it is. */
128function expand(path: string, vars: Vars): string {
129 return path.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (all, name: string) => vars.get(name) ?? all)
130}
131
132/**
133 * The command's parts in order (split at && ; || | and newlines, but not the `>|` redirect), each with the
134 * folder a `cd` before it moved to. `dir` is null after a `cd` the text cannot follow.
135 */
136function partsOf(w: Words): { part: string; dir: string | null; vars: Vars }[] {
137 const out: { part: string; dir: string | null; vars: Vars }[] = []
138 const vars = new Map<string, string>()
139 let dir: string | null = ''
140 for (const part of w.text.split(/\s*(?:&&|;|\|\||(?<!>)\||\n)\s*/)) {
141 const cd = CD_CMD.exec(part)
142 if (cd) {
143 const to = cd[1] === undefined ? '' : unquote(cd[1], w)
144 dir = dir === null || !named(to) || to === '-' || to === '~' ? null : joinPath(dir, to)
145 continue
146 }
147 const set = ASSIGN.exec(part)
148 if (set) {
149 const value = expand(unquote(set[2]!, w), vars)
150 if (named(value)) vars.set(set[1]!, value)
151 else vars.delete(set[1]!)
152 continue
153 }
154 out.push({ part, dir, vars: new Map(vars) })
155 }
156 return out
157}
158
159function place(raw: string, dir: string | null, vars: Vars): string | null {
160 const path = expand(raw, vars)
161 if (!named(path)) return null
162 if (dir === null) return path.startsWith('/') || path.startsWith('~') ? path : null
163 return joinPath(dir, path)
164}
165
166const RM_CMD = /^\s*(?:sudo\s+)?(?:git\s+)?rm\s+(.+)$/
167
168/** Paths a plain `rm` or `git rm` command deletes; none for anything more complex. */
169export function rmTargets(command: string): string[] {
170 const w = shellWords(command)
171 const out: string[] = []
172 for (const { part, dir, vars } of partsOf(w)) {
173 const m = RM_CMD.exec(part)
174 if (!m) continue
175 let flags = true
176 for (const word of m[1]!.split(/\s+/)) {
177 if (word === '') continue
178 if (flags && word === '--') { flags = false; continue }
179 if (flags && word.startsWith('-')) continue
180 const path = place(unquote(word, w), dir, vars)
181 if (path !== null) out.push(path)
182 }
183 }
184 return out
185}
186
187const REDIRECT = /(?:^|[^\d&>])>>?\|?\s*([^\s;&|<>()]+)/g
188const LAST_ARG_CMD = /^\s*(?:sudo\s+)?(?:sed\s+(?:-[a-zA-Z]*\s+)*-i|perl\s+-[a-z]*i|cp|mv|touch|install)\b(.*)$/
189const TEE_CMD = /^\s*(?:sudo\s+)?tee\s+(.+)$/
190// `git checkout -- <paths>` and `git restore <paths>` put files back. `restore --staged` alone touches only the index.
191const CHECKOUT_CMD = /^\s*git\s+checkout\b.*?\s--\s+(.+)$/
192const RESTORE_CMD = /^\s*git\s+restore\s+(.+)$/
193
194function restorePaths(args: string): string[] {
195 const words = args.split(/\s+/).filter(x => x !== '')
196 if (words.some(x => x === '--staged' || x === '-S') && !words.some(x => x === '--worktree' || x === '-W')) return []
197 const out: string[] = []
198 let flags = true
199 for (let k = 0; k < words.length; k++) {
200 const word = words[k]!
201 if (flags && word === '--') { flags = false; continue }
202 if (flags && (word === '-s' || word === '--source')) { k++; continue }
203 if (flags && word.startsWith('-')) continue
204 out.push(word)
205 }
206 return out
207}
208
209/** Paths a Bash command writes by plain means: a redirect, tee, sed -i, perl -i, cp, mv, touch, git restore. */
210export function bashWrites(command: string): string[] {
211 const w = shellWords(command)
212 const out = new Set<string>()
213 const add = (word: string, dir: string | null, vars: Vars) => {
214 const path = place(unquote(word, w), dir, vars)
215 if (path !== null && !path.startsWith('/dev/')) out.add(path)
216 }
217 for (const { part, dir, vars } of partsOf(w)) {
218 for (const m of part.matchAll(REDIRECT)) if (!m[1]!.startsWith('&')) add(m[1]!, dir, vars)
219 const bare = part.replace(/\s*\d?>>?.*$/, '') // the command without its redirects
220 const tee = TEE_CMD.exec(bare)
221 if (tee) for (const word of tee[1]!.split(/\s+/)) if (word !== '' && !word.startsWith('-')) add(word, dir, vars)
222 const back = CHECKOUT_CMD.exec(part)?.[1] ?? RESTORE_CMD.exec(part)?.[1]
223 if (back !== undefined) for (const word of restorePaths(back)) add(word, dir, vars)
224 const last = LAST_ARG_CMD.exec(bare)
225 if (last) {
226 const args = last[1]!.trim().split(/\s+/).filter(x => x !== '' && !x.startsWith('-')).map(x => unquote(x, w)).filter(x => x !== '')
227 const target = args.at(-1)
228 if (target === undefined) continue
229 if (!/^\s*(?:sudo\s+)?(?:cp|mv|install)\b/.test(part)) { add(target, dir, vars); continue }
230 // A source, then the target. More than one source, or a target ending in `/`, means a folder.
231 const sources = args.slice(0, -1)
232 if (sources.length === 0) continue
233 if (sources.length > 1 || target.endsWith('/')) for (const s of sources) add(joinPath(target, baseName(s)), dir, vars)
234 else add(target, dir, vars)
235 }
236 }
237 return [...out]
238}
239
240// `sed -n` takes its script first (`sed -n 28,100p file`), so the path is the word after it.
241const READ_CMD = /^\s*(cat|head|tail|less|bat|sed\s+-n\s+(?:\x01\d+\x01|\S+))\s+(?:-\S+\s+)*([^\s|;&>-][^\s|;&>]*)/
242
243/** Paths the command's parts read with cat, head, tail, less, bat or sed -n. */
244export function bashReads(command: string): string[] {
245 const w = shellWords(command)
246 const out: string[] = []
247 for (const { part, dir, vars } of partsOf(w)) {
248 const m = READ_CMD.exec(part)
249 const path = m ? place(unquote(m[2]!, w), dir, vars) : null
250 if (path !== null && !out.includes(path)) out.push(path)
251 }
252 return out
253}
254// A runner's output piped into a filter: the lines a parser counts are not all there.
255const FILTERED = /\|\s*(grep|egrep|rg|head|tail|awk|sed|cut)\b/
256const SEARCH_CMD = /^\s*(rg|grep|egrep|ag|find|fd|ls)\b(.*)$/
257
258const PATTERN_MAX = 80
259
260// A search as the card names it: the first sub-command only, on one line.
261function searchPattern(command: string): string {
262 const first = command.trim().split(/\s*(?:&&|;|\|\||\||\n)\s*/)[0] ?? ''
263 return first.length > PATTERN_MAX ? `${first.slice(0, PATTERN_MAX - 1)}…` : first
264}
265
266export function classify(tool: string, input: unknown, result: unknown, isError: boolean, root?: string): ClassifiedCall[] {
267 const i = obj(input)
268 const rawPath = str(i.file_path) ?? str(i.notebook_path) ?? str(i.path)
269 const placed = rawPath === undefined ? undefined : placePath(rawPath, root)
270 const path = placed?.path
271
272 if (tool === 'Read' && path) return [{ kind: 'read', path }]
273 if (tool === 'Grep' || tool === 'Glob') return [{ kind: 'search', pattern: str(i.pattern) ?? '' }]
274
275 if ((tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit') && path) {
276 if (isError) return [{ kind: 'other', tool }]
277 let patches = patchesOf(result)
278 if (patches.length === 0 && tool === 'Write') patches = [{ lines: (str(i.content) ?? '').split('\n').filter((l, k, all) => k < all.length - 1 || l !== '').map(l => `+${l}`) }]
279 const delta = patchDelta(patches)
280 const change: Change = { path, ...delta, viaBash: false, isTest: isTestPath(path), isDelete: false, outsideRoot: placed!.outsideRoot, addsSkip: addsSkipMarker(patches) }
281 return [{ kind: 'change', change }]
282 }
283
284 // One command can do several jobs (`rm x && go test ./...`, `go test; cat a_test.go`): the card counts each one.
285 if (tool === 'Bash') {
286 const command = str(i.command) ?? ''
287 const out: ClassifiedCall[] = []
288 const runner = detectRunner(command)
289 if (runner !== null) {
290 const r = obj(result)
291 const output = `${str(r.stdout) ?? ''}\n${str(r.stderr) ?? ''}`
292 const { byLine, ...counts } = parseResult(runner, output)
293 // Counted result lines piped through grep or tail are only some of them: keep the failures seen, drop the rest.
294 if (byLine === true && FILTERED.test(command)) Object.assign(counts, { passed: null, failed: counts.failed !== null && counts.failed > 0 ? counts.failed : null })
295 // A pipe hides the runner's exit code (`go test | tail` exits with tail's), so a failure in the output is an error.
296 out.push({ kind: 'test', run: { command, runner, ...counts, isError: isError || (counts.failed ?? 0) > 0 } })
297 }
298 // A failed command may not have run its rm or its write. With a test run, the error is the test's, so they count.
299 const ran = !isError || runner !== null
300 const bashChange = (p: string, isDelete: boolean): ClassifiedCall => {
301 const at = placePath(p, root)
302 return { kind: 'change', change: { path: at.path, added: 0, removed: 0, viaBash: true, isTest: isTestPath(at.path), isDelete, outsideRoot: at.outsideRoot, addsSkip: false } }
303 }
304 const gone = ran ? rmTargets(command) : []
305 const written = ran ? bashWrites(command).filter(p => !gone.includes(p)) : []
306 out.push(...written.map(p => bashChange(p, false)), ...gone.map(p => bashChange(p, true)))
307 if (runner === null && written.length === 0 && SEARCH_CMD.test(command)) out.push({ kind: 'search', pattern: searchPattern(command) })
308 for (const p of bashReads(command)) if (!written.includes(p) && !gone.includes(p)) out.push({ kind: 'read', path: placePath(p, root).path })
309 if (out.length > 0) return out
310 }
311
312 return [{ kind: 'other', tool }]
313}
314hooks/netdiff.ts 82 lines1// A file's net change over a turn: unified diff hunks between its text before the turn's first edit and after the turn. Pure.
2
3const CONTEXT = 3
4const MAX_CELLS = 4_000_000 // the line table's size limit; past it there is no net diff
5
6type Op = { kind: ' ' | '-' | '+'; text: string; a: number; b: number } // a, b: 1-based line numbers in before/after
7
8function splitLines(text: string): string[] {
9 if (text === '') return []
10 const lines = text.split('\n')
11 if (lines.at(-1) === '') lines.pop()
12 return lines
13}
14
15/** The edit script between two line lists: common prefix and suffix, then a longest-common-subsequence table for the middle. */
16function script(a: string[], b: string[]): Op[] | null {
17 let start = 0
18 while (start < a.length && start < b.length && a[start] === b[start]) start++
19 let endA = a.length
20 let endB = b.length
21 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
22 endA--
23 endB--
24 }
25 const n = endA - start
26 const m = endB - start
27 if ((n + 1) * (m + 1) > MAX_CELLS) return null
28 // lcs[i][j]: the common run length of a[start+i..endA) and b[start+j..endB).
29 const lcs: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
30 for (let i = n - 1; i >= 0; i--)
31 for (let j = m - 1; j >= 0; j--)
32 lcs[i]![j] = a[start + i] === b[start + j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
33 const ops: Op[] = []
34 for (let k = 0; k < start; k++) ops.push({ kind: ' ', text: a[k]!, a: k + 1, b: k + 1 })
35 let i = 0
36 let j = 0
37 while (i < n || j < m) {
38 if (i < n && j < m && a[start + i] === b[start + j]) {
39 ops.push({ kind: ' ', text: a[start + i]!, a: start + i + 1, b: start + j + 1 })
40 i++
41 j++
42 } else if (i < n && (j === m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) {
43 // On a tie the removed line goes first, as a unified diff writes a changed line.
44 ops.push({ kind: '-', text: a[start + i]!, a: start + i + 1, b: start + j + 1 })
45 i++
46 } else {
47 ops.push({ kind: '+', text: b[start + j]!, a: start + i + 1, b: start + j + 1 })
48 j++
49 }
50 }
51 for (let k = 0; k < a.length - endA; k++) ops.push({ kind: ' ', text: a[endA + k]!, a: endA + k + 1, b: endB + k + 1 })
52 return ops
53}
54
55/** Unified diff hunks (`@@ -a,n +b,m @@` then lines) from `before` to `after`; [] when equal, null when too large to compare. */
56export function unifiedDiff(before: string, after: string): string[] | null {
57 const ops = script(splitLines(before), splitLines(after))
58 if (ops === null) return null
59 const changed = ops.map((o, k) => (o.kind !== ' ' ? k : -1)).filter(k => k >= 0)
60 if (changed.length === 0) return []
61 // Group changes whose context windows touch into one hunk.
62 const groups: [number, number][] = []
63 for (const k of changed) {
64 const last = groups.at(-1)
65 if (last !== undefined && k - last[1] <= CONTEXT * 2) last[1] = k
66 else groups.push([k, k])
67 }
68 const out: string[] = []
69 for (const [first, last] of groups) {
70 const from = Math.max(0, first - CONTEXT)
71 const to = Math.min(ops.length - 1, last + CONTEXT)
72 const slice = ops.slice(from, to + 1)
73 const oldLines = slice.filter(o => o.kind !== '+').length
74 const newLines = slice.filter(o => o.kind !== '-').length
75 const head = slice[0]!
76 const oldStart = oldLines === 0 ? head.a - 1 : head.a
77 const newStart = newLines === 0 ? head.b - 1 : head.b
78 out.push(`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`, ...slice.map(o => `${o.kind}${o.text}`))
79 }
80 return out
81}
82hooks/draw/card.tsx 129 lines1// The turn card, as one of Claude Code's own rows: "⏺ TL;DR <goal> · 52s", then under ⎿ what changed and how the
2// tests went, one line per thing to check. The dot's color is the verdict. No box, no labels.
3import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
4
5import type { Change, Turn } from '../../types'
6import { shortPath } from '../classify'
7import { duration } from '../durations'
8import { byFolder, changeTotals, verdictOf } from '../steps'
9import { ranLabel, testLabel } from '../summary'
10import { sectionTitle } from './detail'
11import type { Section } from './detail'
12import { color, glyph, verdictColor } from './theme'
13
14export function lookedLabel(t: Turn): string {
15 const parts: string[] = []
16 if (t.reads.length > 0) parts.push(`${t.reads.length} file${t.reads.length === 1 ? '' : 's'}`)
17 if (t.searches.length > 0) parts.push(`${t.searches.length} search${t.searches.length === 1 ? '' : 'es'}`)
18 return parts.length > 0 ? parts.join(', ') : 'nothing'
19}
20
21export type CardOptions = {
22 /** An expanded fullscreen group (a click, ctrl+o): the card adds Claude's notes. */
23 isExpanded: boolean
24 /** The engine's own measure of the turn, where it gives one (TurnDuration). */
25 durationMs?: number
26 /** Opens or closes the detail behind a line, where presses reach the mod (fullscreen). The goal opens the steps. Absent: lines are plain text. */
27 show?: (s: Section | 'all') => void
28 /** The line whose detail is open inside the card, and that detail drawn. */
29 open?: { section: Section | 'all'; detail: RenderChildren }
30}
31
32const FOLDERS_SHOWN = 3 // more read as "+N folders"
33
34const dirName = (dir: string) => (dir === '' ? './' : dir.replace(/\/$/, ''))
35
36export function drawCard(ui: Elements[RenderSurface], t: Turn, o: CardOptions) {
37 const { Box, Button, Text } = ui
38 const ms = o.durationMs ?? (t.endedAt === null ? null : t.endedAt - t.startedAt)
39 const pressable = (s: Section | 'all', children: RenderChildren, n = 0) =>
40 o.show !== undefined ? (
41 <Button key={`tldr-show:${s}:${t.id}:${n}`} plain onPress={() => o.show!(s)}>{children}</Button>
42 ) : (
43 <Text>{children}</Text>
44 )
45 // The gutter: ⎿ on the first line under the dot, blank under it, as Claude Code draws a tool's result.
46 const line = (first: boolean, children: RenderChildren, key: string) => (
47 <Box key={key} flexDirection="row">
48 <Box width={glyph.elbow.length} flexShrink={0}>
49 <Text color={color.dim}>{first ? glyph.elbow : ''}</Text>
50 </Box>
51 <Box flexDirection="row" flexWrap="wrap" flexGrow={1}>{children}</Box>
52 </Box>
53 )
54 const sep = <Text color={color.dim}>{' · '}</Text>
55 const counts = (added: number, removed: number) => (
56 <Text><Text color={color.added}>{`+${added}`}</Text>{' '}<Text color={color.removed}>{`${glyph.minus}${removed}`}</Text></Text>
57 )
58
59 // One file reads as itself; more read as their size and folders, so a 60-file refactor is as short as a 2-file fix. Key 2 has each file.
60 const one = (c: Change) =>
61 c.isDelete ? <Text color={color.check}>{`${shortPath(c.path)} deleted`}</Text>
62 : c.viaBash ? <Text>{shortPath(c.path)}<Text color={color.dim}> via Bash</Text></Text>
63 : <Text>{`${shortPath(c.path)} `}{counts(c.added, c.removed)}</Text>
64 const changed = (): RenderChildren => {
65 if (t.changes.length === 1) return one(t.changes[0]!)
66 const total = changeTotals(t.changes)
67 const size = <Text>{`${total.files} files `}{total.counted ? counts(total.added, total.removed) : <Text color={color.dim}>via Bash</Text>}</Text>
68 const folders = byFolder(t.changes)
69 const more = folders.length - FOLDERS_SHOWN
70 return (
71 <Text>
72 {size}
73 <Text color={color.dim}>{' in '}</Text>
74 {folders.slice(0, FOLDERS_SHOWN).map(f => dirName(f.dir)).join(', ')}
75 {more > 0 ? <Text color={color.dim}>{` +${more} folder${more === 1 ? '' : 's'}`}</Text> : ''}
76 </Text>
77 )
78 }
79
80 // The first run and the last: where the turn's tests started and where they ended. One runner names itself once.
81 const tests = (): RenderChildren => {
82 const first = t.testRuns[0]!
83 const last = t.testRuns.at(-1)!
84 const a = testLabel(first)
85 if (t.testRuns.length === 1) return <Text color={a.ok ? color.pass : color.fail}>{a.text}</Text>
86 const b = testLabel(last)
87 return (
88 <Text>
89 <Text color={a.ok ? color.pass : color.fail}>{a.text}</Text>
90 <Text color={color.dim}>{` ${glyph.arrow} `}</Text>
91 <Text color={b.ok ? color.pass : color.fail}>{first.runner === last.runner ? b.result : b.text}</Text>
92 </Text>
93 )
94 }
95
96 // What the turn did, in one line: its changes and its tests. A turn with neither says what it read or ran.
97 const facts: RenderChildren[] = []
98 if (t.changes.length > 0) facts.push(pressable('changed', changed()))
99 if (t.testRuns.length > 0) facts.push(pressable('tests', tests()))
100 else if (t.changes.length > 0) facts.push(pressable('tests', <Text color={color.dim}>tests not run</Text>))
101 if (facts.length === 0 && (t.ran ?? []).length > 0) facts.push(pressable('ran', `ran ${ranLabel(t.ran ?? [])}`))
102 if (facts.length === 0) facts.push(pressable('looked', `read ${lookedLabel(t)}`))
103
104 const lines = [
105 facts.flatMap((f, i) => (i > 0 ? [sep, f] : [f])),
106 ...t.flags.map((fl, i) => pressable('check', <Text color={color.check}>{`${glyph.check} ${fl.detail}`}</Text>, i)),
107 ...(o.isExpanded ? t.interim.map(n => <Text color={color.dim}>{n}</Text>) : []),
108 ]
109
110 const goal = <Text wrap="truncate-end">{t.goal}</Text>
111 return (
112 <Box key="tldr-card" flexDirection="column">
113 <Box flexDirection="row">
114 <Text color={verdictColor[verdictOf(t)]}>{`${glyph.dot} `}</Text>
115 <Text bold>{'TL;DR '}</Text>
116 {o.show !== undefined ? <Button key={`tldr-show:all:${t.id}`} plain onPress={() => o.show!('all')}>{goal}</Button> : goal}
117 <Text color={color.dim}>{` · ${ms === null ? 'running' : duration(ms)}`}</Text>
118 </Box>
119 {lines.map((l, i) => line(i === 0, l, `tldr-line:${i}`))}
120 {o.open !== undefined ? (
121 <Box key={`tldr-inline:${t.id}`} flexDirection="column" marginTop={1} paddingLeft={glyph.elbow.length}>
122 {o.open.section !== 'all' ? <Text bold>{sectionTitle(o.open.section)}</Text> : null}
123 {o.open.detail}
124 </Box>
125 ) : null}
126 </Box>
127 )
128}
129hooks/draw/band.tsx 140 lines1// The band above the prompt: the TL;DR line while a turn runs, the context bar, the kept-cards note after /compact, and on the main screen the rows of the latest card as keys, with the open row's detail under them.
2import type { Elements, RenderElement, RenderInput } from 'claude-code'
3
4import type { Turn } from '../../types'
5import { contextCells, contextLevel, hex, sliceTokens, tokens } from '../context'
6import type { ContextFill } from '../context'
7import { conversationRgb, SETUP_RGB } from '../context'
8import { encodeCells } from '../motion'
9import { taskLabel } from '../plan'
10import type { PlanView } from '../plan'
11import type { RunPart } from '../progress'
12import type { Verdict } from '../steps'
13import { sectionTitle } from './detail'
14import type { Section } from './detail'
15import { color, glyph, toneColor, verdictColor } from './theme'
16
17export function keptNote(count: number): string {
18 return `${count} earlier turn${count === 1 ? '' : 's'} kept · /tldr history`
19}
20
21export type Reveal = { section: Section | 'all'; label: string; hotkey: string }
22
23/**
24 * The rows of the latest card as keys on the band, for the main screen: a printed card can't take a press,
25 * but the band can. Each row keeps one digit, so the same key always opens the same row, and pressed again closes it.
26 */
27export function revealKeys(t: Turn): Reveal[] {
28 const out: Reveal[] = [{ section: 'all', label: 'steps', hotkey: '1' }]
29 if (t.changes.length > 0) out.push({ section: 'changed', label: 'changed', hotkey: '2' })
30 if (t.testRuns.length > 0) out.push({ section: 'tests', label: 'tests', hotkey: '3' })
31 if (t.flags.length > 0) out.push({ section: 'check', label: 'check', hotkey: '4' })
32 if ((t.ranLog?.length ?? 0) > 0) out.push({ section: 'ran', label: 'ran', hotkey: '5' })
33 if (t.reads.length + t.searches.length > 0) out.push({ section: 'looked', label: 'looked at', hotkey: '6' })
34 return out
35}
36
37export type BandReveal = { turnId: string; verdict: Verdict; keys: Reveal[]; show: (s: Section | 'all') => void; open: { section: Section | 'all'; detail: RenderElement } | null }
38export type BandContext = { fill: ContextFill; open: () => void }
39/** The stepper: the bar and the running task's row are Rasters the frame loop repaints; `rowCells` null draws that row as Text. */
40export type BandPlan = { view: PlanView; barCells: string; barColumns: number; rowCells: string | null; rowColumns: number; justDone: string | null }
41export type BandParts = { note: string | null; reveal: BandReveal | null; context: BandContext | null; run: RunPart[] | null; plan: BandPlan | null; last: RunPart[] }
42
43export const CONTEXT_KEY = 'tldr-context-bar'
44export const PLAN_BAR_KEY = 'tldr-plan-bar'
45export const PLAN_ROW_KEY = 'tldr-plan-now'
46
47export function drawBand(ui: Elements['terminal'], e: RenderInput<'AbovePrompt'>, rest: RenderElement | null, parts: BandParts) {
48 const { Box, Text, Button, Raster } = ui
49 const reveal = parts.reveal
50 const ctx = parts.context
51 // The label names each color in that color, so the bar reads without a legend: "62% full · conversation 94k · setup 30k".
52 const level = ctx !== null ? contextLevel(ctx.fill.percent) : 'ok'
53 const head = ctx !== null ? `${ctx.fill.percent}% full${level === 'full' ? ' · /compact' : ''}` : ''
54 const conv = ctx !== null ? `conversation ${tokens(sliceTokens(ctx.fill, 'conversation'))}` : ''
55 const setup = ctx !== null ? `setup ${tokens(sliceTokens(ctx.fill, 'setup'))}` : ''
56 const wide = e.props.bodyColumns >= 72
57 const labelLength = head.length + (wide ? conv.length + setup.length + 6 : 0)
58 // The label takes its own cells, one more for the gap; the bar takes the rest.
59 const barColumns = Math.max(4, e.props.bodyColumns - labelLength - 1)
60 const mark = <Text color={color.accent} bold>{'TL;DR '}</Text>
61 const verdict = reveal !== null && (reveal.verdict === 'pass' || reveal.verdict === 'fail' || reveal.verdict === 'check') ? reveal.verdict : null
62 return (
63 <Box key="tldr-bands" flexDirection="column">
64 {rest}
65 {parts.note !== null ? <Text key="tldr-kept-note" color={color.dim} wrap="truncate-end">{parts.note}</Text> : null}
66 {parts.plan ? drawPlan(ui, parts.plan, parts.last) : parts.run !== null ? (
67 <Text key="tldr-run" wrap="truncate-end">
68 {mark}
69 {runText(ui, parts.run)}
70 </Text>
71 ) : null}
72 {reveal !== null ? (
73 <Box key="tldr-reveal" flexDirection="row" columnGap={2}>
74 <Text>
75 <Text color={color.accent} bold>TL;DR</Text>
76 {verdict !== null ? <Text color={verdictColor[verdict]} bold>{` ${glyph[verdict]}`}</Text> : ''}
77 </Text>
78 {reveal.keys.map(k => (
79 <Button key={`tldr-reveal:${k.section}:${reveal.turnId}`} label={k.label} hotkey={k.hotkey} plain dimColor={reveal.open?.section !== k.section} onPress={() => reveal.show(k.section)} />
80 ))}
81 </Box>
82 ) : null}
83 {reveal?.open ? (
84 <Box key={`tldr-inline:${reveal.turnId}`} flexDirection="column" marginBottom={1}>
85 {reveal.open.section !== 'all' ? <Text bold>{sectionTitle(reveal.open.section)}</Text> : null}
86 {reveal.open.detail}
87 </Box>
88 ) : null}
89 {ctx !== null ? (
90 <Box key="tldr-context-row" flexDirection="row" columnGap={1}>
91 <Raster key={CONTEXT_KEY} columns={barColumns} rows={1} cells={encodeCells(contextCells(ctx.fill, barColumns))} />
92 <Button key="tldr-context-open" plain onPress={ctx.open}>
93 <Text color={level === 'full' ? color.fail : level === 'warn' ? color.check : color.text}>{head}</Text>
94 {wide ? <Text color={color.dim}>{' · '}</Text> : null}
95 {wide ? <Text color={hex(conversationRgb(ctx.fill.percent))}>{conv}</Text> : null}
96 {wide ? <Text color={color.dim}>{' · '}</Text> : null}
97 {wide ? <Text color={hex(SETUP_RGB)}>{setup}</Text> : null}
98 </Button>
99 </Box>
100 ) : null}
101 </Box>
102 )
103}
104
105function runText(ui: Elements['terminal'], parts: readonly RunPart[]) {
106 const { Text } = ui
107 return parts.map(p => <Text color={toneColor[p.tone]}>{p.text}</Text>)
108}
109
110/** Claude's task list while the turn runs: the bar and count with the last thing that happened, then a window of tasks, the running one in motion. */
111function drawPlan(ui: Elements['terminal'], plan: BandPlan, last: readonly RunPart[]) {
112 const { Box, Text, Raster } = ui
113 const v = plan.view
114 return (
115 <Box key="tldr-plan" flexDirection="column">
116 <Box key="tldr-plan-head" flexDirection="row" columnGap={1}>
117 <Text color={color.accent} bold>TL;DR </Text>
118 <Raster key={PLAN_BAR_KEY} columns={plan.barColumns} rows={1} cells={plan.barCells} />
119 <Text wrap="truncate-end">
120 <Text>{`${v.done}/${v.total} tasks`}</Text>
121 {runText(ui, last)}
122 </Text>
123 </Box>
124 {v.before > 0 ? <Text key="tldr-plan-before" color={color.dim}>{` ${glyph.pass} ${v.before} earlier`}</Text> : null}
125 {v.rows.map(t =>
126 t.status === 'in_progress' && plan.rowCells !== null ? <Raster key={PLAN_ROW_KEY} columns={plan.rowColumns} rows={1} cells={plan.rowCells} />
127 : t.status === 'in_progress' ? <Text key={`tldr-plan:${t.id}`} color={color.accent} wrap="truncate-end">{` ◐ ${taskLabel(t)}`}</Text>
128 : t.status === 'completed' ? (
129 <Text key={`tldr-plan:${t.id}`} wrap="truncate-end">
130 <Text color={color.pass}>{` ${glyph.pass} `}</Text>
131 <Text color={plan.justDone === t.id ? color.pass : color.dim}>{t.subject}</Text>
132 </Text>
133 )
134 : <Text key={`tldr-plan:${t.id}`} color={color.dim} wrap="truncate-end">{` ○ ${t.subject}`}</Text>,
135 )}
136 {v.after > 0 ? <Text key="tldr-plan-after" color={color.dim}>{` +${v.after} more`}</Text> : null}
137 </Box>
138 )
139}
140hooks/draw/context.tsx 56 lines1// The context pane: the bar's three parts with their tokens and share, what each one means, and the setup rows.
2import type { Elements } from 'claude-code'
3
4import { contextLevel, tokens } from '../context'
5import type { ContextFill, SliceKind } from '../context'
6import { encodeCells } from '../motion'
7import { color } from './theme'
8
9export const CONTEXT_PANE = 'tldr-context'
10const SWATCH = 3
11const NAME_WIDTH = 26
12
13const GLYPH: Record<SliceKind, number> = { conversation: 0x2588, setup: 0x2588, free: 0x2500, buffer: 0x254c }
14const MEANS: Record<SliceKind, string> = {
15 conversation: '/compact frees this',
16 setup: 'loaded on every request',
17 free: 'room left',
18 buffer: 'kept back so auto-compact can run',
19}
20
21const swatch = (rgb: number, glyph: number) => encodeCells(Uint32Array.from({ length: SWATCH * 3 }, (_, i) => (i % 3 === 0 ? glyph : i % 3 === 1 ? rgb : 0x01000000)))
22
23export function drawContextPane(ui: Elements['terminal'], fill: ContextFill | null) {
24 const { Box, Text, Raster } = ui
25 if (fill === null) return <Box key="tldr-context-pane"><Text color={color.dim}>No context figures yet. They come after the first reply.</Text></Box>
26 const total = fill.slices.reduce((n, s) => n + s.tokens, 0) || 1
27 const level = contextLevel(fill.percent)
28 const share = (n: number) => `${Math.round((n / total) * 100)}%`.padStart(4)
29 return (
30 <Box key="tldr-context-pane" flexDirection="column">
31 <Text color={level === 'full' ? color.fail : level === 'warn' ? color.check : color.text}>{`${fill.percent}% full · ${tokens(fill.used)} of ${tokens(fill.window)}`}</Text>
32 {fill.slices.map(s => (
33 <Box flexDirection="column">
34 <Box flexDirection="row" columnGap={1}>
35 <Raster key={`tldr-swatch:${s.kind}`} columns={SWATCH} rows={1} cells={swatch(s.rgb, GLYPH[s.kind])} />
36 <Box width={NAME_WIDTH} flexShrink={0}><Text wrap="truncate-end" color={s.kind === 'free' || s.kind === 'buffer' ? color.dim : color.text}>{s.name}</Text></Box>
37 <Box width={5} flexShrink={0}><Text color={color.dim}>{tokens(s.tokens).padStart(5)}</Text></Box>
38 <Box width={5} flexShrink={0}><Text color={color.dim}>{share(s.tokens)}</Text></Box>
39 <Text color={color.dim} wrap="truncate-end">{MEANS[s.kind]}</Text>
40 </Box>
41 {s.kind === 'setup'
42 ? fill.setup.map(r => (
43 <Box flexDirection="row" columnGap={1}>
44 <Box width={SWATCH} flexShrink={0} />
45 <Box width={NAME_WIDTH} flexShrink={0}><Text color={color.dim} wrap="truncate-end">{` ${r.name}`}</Text></Box>
46 <Text color={color.dim}>{tokens(r.tokens).padStart(5)}</Text>
47 </Box>
48 ))
49 : null}
50 </Box>
51 ))}
52 {level !== 'ok' ? <Text color={color.dim}>/compact shrinks the conversation to a summary. TL;DR keeps the cards.</Text> : null}
53 </Box>
54 )
55}
56hooks/context.ts 109 lines1// The context window as one line of cells, in two colors you can read without a legend:
2// the conversation, which /compact frees, and the setup, which every request loads. Then the free space. Pure.
3import type { SessionContextBreakdown } from 'claude-code'
4
5const DEFAULT = 0x01000000
6const FULL = 0x2588 // █
7const FREE = 0x2500 // ─
8const BUFFER = 0x254c // ╌
9const DIM = 0x5a5650
10const BUFFER_TONE = 0x7a756d
11
12export const CONVERSATION_RGB = 0x636efa // Plotly blue: the part /compact frees
13export const WARN_RGB = 0xf5a623 // amber: the conversation from WARN_AT
14export const FULL_RGB = 0xef553b // red: the conversation from FULL_AT
15export const SETUP_RGB = 0x9aa0a6 // neutral gray: loaded on every request
16export const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
17
18export type SliceKind = 'conversation' | 'setup' | 'free' | 'buffer'
19export type Slice = { name: string; tokens: number; rgb: number; kind: SliceKind }
20export type Row = { name: string; tokens: number }
21// slices: the bar, left to right. setup: the rows the setup slice stands for, for the pane.
22export type ContextFill = { slices: Slice[]; setup: Row[]; used: number; window: number; percent: number }
23
24// The one row /compact shrinks. Every other used row is setup.
25const CONVERSATION_ROW = 'Messages'
26
27/** The breakdown as conversation, setup, free space and the compaction buffer. Deferred tool schemas sit outside the window. */
28export function contextFill(b: Pick<SessionContextBreakdown, 'categories' | 'totalTokens' | 'rawMaxTokens' | 'percentage'>): ContextFill {
29 const used = b.categories.filter(c => c.kind === 'used' && c.tokens > 0)
30 const setup = used.filter(c => c.name !== CONVERSATION_ROW).map(c => ({ name: c.name, tokens: c.tokens }))
31 const sum = (kind: string, pick = (_: string) => true) => b.categories.filter(c => c.kind === kind && pick(c.name)).reduce((n, c) => n + c.tokens, 0)
32 const percent = Math.round(b.percentage)
33 const slices: Slice[] = [
34 { name: 'conversation', tokens: sum('used', n => n === CONVERSATION_ROW), rgb: conversationRgb(percent), kind: 'conversation' },
35 { name: 'setup', tokens: sum('used', n => n !== CONVERSATION_ROW), rgb: SETUP_RGB, kind: 'setup' },
36 { name: 'free', tokens: sum('free'), rgb: DIM, kind: 'free' },
37 { name: 'reserved for auto-compact', tokens: sum('buffer'), rgb: BUFFER_TONE, kind: 'buffer' },
38 ]
39 return { slices: slices.filter(s => s.tokens > 0), setup, used: b.totalTokens, window: b.rawMaxTokens, percent }
40}
41
42/**
43 * Cells per slice, summing to `columns`: largest remainder, and a used slice with tokens never drops to zero
44 * while there is room, so a small category stays visible.
45 */
46export function sliceWidths(slices: readonly Slice[], columns: number): number[] {
47 const total = slices.reduce((n, s) => n + s.tokens, 0)
48 if (total <= 0 || columns <= 0) return slices.map(() => 0)
49 const exact = slices.map(s => (s.tokens / total) * columns)
50 const out = exact.map(Math.floor)
51 slices.forEach((s, i) => { if ((s.kind === 'conversation' || s.kind === 'setup') && out[i] === 0 && columns >= slices.length) out[i] = 1 })
52 let left = columns - out.reduce((n, w) => n + w, 0)
53 const order = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
54 for (let k = 0; left > 0 && order.length > 0; k = (k + 1) % order.length, left--) out[order[k]!.i]!++
55 // Taking a cell back from the widest slice when the minimums overshot.
56 while (left < 0) {
57 const widest = out.indexOf(Math.max(...out))
58 out[widest]!--
59 left++
60 }
61 return out
62}
63
64/** The bar as Raster cells: full blocks for what is used, a thin line for free space, a dashed one for the buffer. */
65export function contextCells(fill: ContextFill, columns: number): Uint32Array {
66 const cols = Math.max(1, columns)
67 const cells = new Uint32Array(cols * 3)
68 const widths = sliceWidths(fill.slices, cols)
69 let at = 0
70 fill.slices.forEach((s, i) => {
71 for (let k = 0; k < widths[i]! && at < cols; k++, at++) {
72 cells[at * 3] = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : FULL
73 cells[at * 3 + 1] = s.rgb
74 cells[at * 3 + 2] = DEFAULT
75 }
76 })
77 for (; at < cols; at++) {
78 cells[at * 3] = FREE
79 cells[at * 3 + 1] = DIM
80 cells[at * 3 + 2] = DEFAULT
81 }
82 return cells
83}
84
85export function tokens(n: number): string {
86 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
87 if (n >= 1000) return `${Math.round(n / 1000)}k`
88 return String(n)
89}
90
91export type Level = 'ok' | 'warn' | 'full'
92export const WARN_AT = 60
93export const FULL_AT = 80
94
95export function contextLevel(percent: number): Level {
96 return percent >= FULL_AT ? 'full' : percent >= WARN_AT ? 'warn' : 'ok'
97}
98
99/** The conversation's color: blue, then amber from WARN_AT, then red from FULL_AT. */
100export function conversationRgb(percent: number): number {
101 const level = contextLevel(percent)
102 return level === 'full' ? FULL_RGB : level === 'warn' ? WARN_RGB : CONVERSATION_RGB
103}
104
105/** The tokens of one slice kind, 0 when absent. */
106export function sliceTokens(fill: ContextFill, kind: SliceKind): number {
107 return fill.slices.find(s => s.kind === kind)?.tokens ?? 0
108}
109hooks/progress.ts 89 lines1// The band's line while a turn runs: what runs now and how far it is against last time, then the last thing that happened.
2// Also the one line a finished long turn sends as a notification. Pure.
3import type { Jump, Running, Tone, Turn } from '../types'
4import { glyph } from './draw/theme'
5import { duration, ring } from './durations'
6import { changeTotals, verdictOf } from './steps'
7import { testLabel } from './summary'
8
9export type RunPart = { text: string; tone: Tone | 'accent' | 'dim' | 'text' }
10
11const CLOCK_FROM_MS = 2000 // under this a clock says nothing worth reading
12const MINUS = '−'
13
14/** "pytest ✗ 3 failed" and "⚠ tests/conftest.py changed …" from one call: the runs and flags `after` has that `before` did not. */
15export function nextJump(before: Pick<Turn, 'testRuns' | 'flags'>, after: Pick<Turn, 'testRuns' | 'flags'>, now: number): Jump | null {
16 const parts: Jump['parts'] = []
17 const run = after.testRuns.length > before.testRuns.length ? after.testRuns.at(-1) : undefined
18 if (run !== undefined) {
19 const l = testLabel(run)
20 parts.push({ text: l.text, tone: l.ok ? 'pass' : 'fail' })
21 }
22 for (const f of after.flags) {
23 if (!before.flags.some(g => g.kind === f.kind && g.detail === f.detail)) parts.push({ text: `${glyph.check} ${f.detail}`, tone: 'check' })
24 }
25 return parts.length > 0 ? { parts, at: now } : null
26}
27
28/** " · last: ✗ pytest 3 failed 1m 30s ago": the jump and its age, as the band and the stepper's head end. */
29export function jumpParts(jump: Jump | null, now: number): RunPart[] {
30 if (jump === null) return []
31 const age = now - jump.at
32 return [
33 { text: ' · last: ', tone: 'dim' },
34 ...jump.parts.flatMap((p, i): RunPart[] => (i > 0 ? [{ text: ' · ', tone: 'dim' }, p] : [p])),
35 { text: age < CLOCK_FROM_MS ? ' just now' : ` ${duration(age)} ago`, tone: 'dim' },
36 ]
37}
38
39function filesText(t: Pick<Turn, 'changes'>): string | null {
40 if (t.changes.length === 0) return null
41 const s = changeTotals(t.changes)
42 const files = `${s.files} file${s.files === 1 ? '' : 's'}`
43 return s.counted ? `${files} +${s.added} ${MINUS}${s.removed}` : files
44}
45
46export type RunInput = { word: string; running: Running | null; since: number; now: number; turn: Pick<Turn, 'jump' | 'changes'> }
47
48/**
49 * The line: "◑ Running pytest -q 38s of ~1m 12s · last: ✗ pytest 3 failed 1m 30s ago".
50 * A command gets a ring against last time; anything else, and Claude thinking between calls, gets a plain clock.
51 * With nothing happened yet, the files changed so far stand in for the jump.
52 */
53export function runLine(o: RunInput): RunPart[] {
54 const out: RunPart[] = []
55 const r = o.running
56 if (r !== null) {
57 const ms = o.now - r.startedAt
58 if (r.isCommand) out.push({ text: `${ring(ms, r.lastMs)} `, tone: 'accent' })
59 out.push({ text: r.label, tone: 'text' })
60 if (ms >= CLOCK_FROM_MS) {
61 out.push({ text: ` ${duration(ms)}`, tone: 'text' })
62 if (r.isCommand) out.push({ text: r.lastMs === null ? ' · first run' : ms > r.lastMs ? ` · past ~${duration(r.lastMs)}` : ` of ~${duration(r.lastMs)}`, tone: 'dim' })
63 }
64 } else {
65 const ms = o.now - o.since
66 out.push({ text: o.word, tone: 'text' })
67 if (ms >= CLOCK_FROM_MS) out.push({ text: ` ${duration(ms)}`, tone: 'text' })
68 }
69 const jump = o.turn.jump ?? null
70 if (jump !== null) return [...out, ...jumpParts(jump, o.now)]
71 const files = filesText(o.turn)
72 return files === null ? out : [...out, { text: ` · ${files}`, tone: 'dim' }]
73}
74
75/** The finished turn in one line, for a notification: "⚠ fix the login bug · ✓ 12 passed · 1 to check · 3 files +18 −6 · 4m 12s". */
76export function doneText(t: Turn, ms: number): string {
77 const v = verdictOf(t)
78 const mark = v === 'pass' || v === 'fail' || v === 'check' ? `${glyph[v]} ` : ''
79 const last = t.testRuns.at(-1)
80 const parts = [
81 `${mark}${t.goal}`,
82 last !== undefined ? testLabel(last).result : null,
83 t.flags.length > 0 ? `${t.flags.length} to check` : null,
84 filesText(t),
85 duration(ms),
86 ]
87 return parts.filter((p): p is string => p !== null).join(' · ')
88}
89hooks/draw/compact-line.tsx 33 lines1// The main screen's one line per tool group: rows printed there cannot change later,
2// so each group says what it did in one line and the card follows at the end of the turn.
3// A line that ran tests shows the result in green or red, its dot too, so a red run stands out in scrollback.
4// Calls a line cannot name (git, gh, an MCP tool) draw nothing: a row of them would read "1 other call" again and again,
5// and the card stacks them into one line.
6import type { Elements, RenderSurface } from 'claude-code'
7
8import { groupSummary, isQuiet } from '../summary'
9import type { GroupCall } from '../summary'
10import { color, glyph } from './theme'
11import { countParts } from './tint'
12
13export function drawCompactLine(ui: Elements[RenderSurface], calls: readonly GroupCall[], root?: string) {
14 const { Box, Text } = ui
15 if (calls.every(c => isQuiet(c, root))) return <Box key="tldr-quiet" />
16 const parts = groupSummary(calls, root)
17 const runs = parts.filter(p => p.ok !== undefined)
18 const dot = runs.length === 0 ? color.accent : runs.every(p => p.ok) ? color.pass : color.fail
19 return (
20 <Text key="tldr-line" wrap="truncate-end">
21 <Text color={dot}>{`${glyph.dot} `}</Text>
22 <Text color={color.dim}>
23 {parts.length === 0 ? 'tool calls' : parts.map((p, i) => [
24 i > 0 ? ' · ' : '',
25 p.ok !== undefined
26 ? <Text color={p.ok ? color.pass : color.fail}>{p.text}</Text>
27 : countParts(p.text).map(c => (c.tone === undefined ? c.text : <Text color={c.tone === 'added' ? color.added : color.removed}>{c.text}</Text>)),
28 ])}
29 </Text>
30 </Text>
31 )
32}
33hooks/draw/detail.tsx 223 lines1// The detail: what sits behind one card row (what Claude read, changed, tested, ran or should be checked).
2// Fullscreen draws it inside the card, a press on the row toggling it; the main screen and /tldr show open it as a pane.
3// The card stays the TL;DR; this is the "read it anyway".
4import type { Elements, RenderSurface } from 'claude-code'
5
6import type { Turn } from '../../types'
7import { shortPath } from '../classify'
8import { byFolder, changeTotals, stepsOf, stepVerb } from '../steps'
9import type { Step } from '../steps'
10import { testLabel } from '../summary'
11import { runPart } from '../tests-parse'
12import { countParts } from './tint'
13import { color, glyph } from './theme'
14
15export const DETAIL_PANE = 'tldr-detail'
16
17export type Section = 'looked' | 'changed' | 'tests' | 'ran' | 'check' | 'notes'
18
19const TITLE: Record<Section, string> = { looked: 'Looked at', changed: 'Changed', tests: 'Tests', ran: 'Ran', check: 'Check', notes: 'Notes' }
20const DIFF_PEEK = 6 // diff lines shown per file; /tldr diff has the rest
21const PEEK_FILES = 4 // more files than this: the pane lists them without diff lines, so it fits
22const FOLDERS_ONLY = 12 // more files than this: one line per folder, so a big refactor still fits the pane
23const STEPS_SHOWN = 16 // the newest steps; older ones fold into one line
24const VERB_WIDTH = 8
25
26export function sectionOf(word: string): Section | 'all' | null {
27 const w = word.trim().toLowerCase()
28 if (w === '' || w === 'all' || w === 'steps' || w === 'turn') return 'all'
29 if (w === 'looked' || w === 'read' || w === 'reads' || w === 'search' || w === 'searches') return 'looked'
30 if (w === 'changed' || w === 'changes' || w === 'files' || w === 'wrote') return 'changed'
31 if (w === 'tests' || w === 'test') return 'tests'
32 if (w === 'ran' || w === 'commands') return 'ran'
33 if (w === 'check' || w === 'flags') return 'check'
34 if (w === 'notes' || w === 'text') return 'notes'
35 return null
36}
37
38export function sectionTitle(s: Section | 'all'): string {
39 return s === 'all' ? 'Steps' : TITLE[s]
40}
41
42export function paneTitle(t: Turn, s: Section | 'all'): string {
43 return s === 'all' ? `TL;DR · ${t.goal}` : `TL;DR · ${TITLE[s]}`
44}
45
46/** The commands behind key 5 (ran): log lines of calls that were not a read, search, change or test. */
47export function ranCommands(t: Turn): string[] {
48 return (t.ranLog ?? []).map(l => l.replace(/^Bash /, ''))
49}
50
51/** Rows the pane wants for a section, so a short list opens a short pane. */
52export function paneRows(t: Turn, s: Section | 'all'): number {
53 const count = (x: Section): number => {
54 if (x === 'looked') return t.reads.length + t.searches.length + 2
55 if (x === 'changed') {
56 const folders = byFolder(t.changes).length
57 if (t.changes.length > FOLDERS_ONLY) return 2 + folders
58 return 1 + t.changes.length + folders + (t.changes.length > PEEK_FILES ? 1 : (t.diffs ?? []).reduce((n, d) => n + Math.min(d.lines.length, DIFF_PEEK), 0))
59 }
60 if (x === 'tests') return t.testRuns.length
61 if (x === 'ran') return ranCommands(t).length
62 if (x === 'notes') return t.interim.reduce((n, s) => n + s.split('\n').length, 0)
63 return t.flags.reduce((n, f) => n + 1 + (f.items ?? []).length, 0)
64 }
65 const n = s === 'all' ? Math.min(stepsOf(t).length, STEPS_SHOWN + 1) + (t.flags.length > 0 ? count('check') + 2 : 0) : count(s)
66 return Math.max(3, Math.min(18, n + 1))
67}
68
69function diffColor(l: string): string | undefined {
70 if (l.startsWith('+')) return color.added
71 if (l.startsWith('-')) return color.removed
72 return color.dim
73}
74
75export function drawDetail(ui: Elements[RenderSurface], t: Turn | null, s: Section | 'all') {
76 const { Box, Text } = ui
77 if (t === null) return <Box key="tldr-detail"><Text color={color.dim}>No turn yet.</Text></Box>
78
79 const empty = (what: string) => <Text color={color.dim}>{what}</Text>
80 // A text with its line counts in green and red; the rest keeps the given color.
81 const tinted = (text: string, base?: string) => (
82 <Text color={base}>{countParts(text).map(p => (p.tone === undefined ? p.text : <Text color={p.tone === 'added' ? color.added : color.removed}>{p.text}</Text>))}</Text>
83 )
84 // One list row: the name on the left, its fact dim on the right.
85 const line = (name: string, fact?: string, factColor: string = color.dim) => (
86 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
87 <Text wrap="truncate-end">{name}</Text>
88 {fact !== undefined ? tinted(fact, factColor) : null}
89 </Box>
90 )
91 const heading = (text: string) => tinted(text, color.dim)
92
93 const looked = () => (
94 <Box flexDirection="column">
95 {t.reads.length === 0 && t.searches.length === 0 ? empty('Read and searched nothing.') : null}
96 {t.reads.length > 0 ? heading(`Read · ${t.reads.length}`) : null}
97 {t.reads.map(p => line(` ${shortPath(p)}`))}
98 {t.searches.length > 0 ? heading(`Searched · ${t.searches.length}`) : null}
99 {t.searches.map(p => line(` ${p}`))}
100 </Box>
101 )
102
103 // Files by folder, so ten files read as two or three places. One folder, or one file, stays a flat list.
104 const changed = () => {
105 if (t.changes.length === 0) return empty('No files changed that the card could see.')
106 const diffs = new Map(t.changes.length > PEEK_FILES ? [] : (t.diffs ?? []).map(d => [d.path, d.lines]))
107 const total = changeTotals(t.changes)
108 const folders = byFolder(t.changes)
109 const grouped = folders.length > 1
110 const counts = (added: number, removed: number) => `+${added} ${glyph.minus}${removed}`
111 const file = (c: Turn['changes'][number], indent: string) => {
112 const fact = c.isDelete ? 'deleted' : c.viaBash ? 'via Bash' : counts(c.added, c.removed)
113 const name = grouped ? shortPath(c.path).slice(dirLength(c.path)) : shortPath(c.path)
114 const lines = (diffs.get(c.path) ?? []).filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l))
115 return (
116 <Box flexDirection="column">
117 {line(`${indent}${name}`, fact, c.isDelete ? color.check : color.dim)}
118 {lines.slice(0, DIFF_PEEK).map(l => <Text color={diffColor(l)} wrap="truncate-end">{`${indent} ${l}`}</Text>)}
119 {lines.length > DIFF_PEEK ? <Text color={color.dim}>{`${indent} +${lines.length - DIFF_PEEK} more lines · /tldr diff`}</Text> : null}
120 </Box>
121 )
122 }
123 return (
124 <Box flexDirection="column">
125 {heading(`${total.files} file${total.files === 1 ? '' : 's'}${total.counted ? ` · ${counts(total.added, total.removed)}` : ''}`)}
126 {t.changes.length > FOLDERS_ONLY
127 ? folders.map(f => line(`${f.dir || './'} ${f.changes.length} file${f.changes.length === 1 ? '' : 's'}`, f.added + f.removed > 0 ? counts(f.added, f.removed) : undefined))
128 : grouped
129 ? folders.map(f => (
130 <Box flexDirection="column">
131 {f.dir !== '' ? line(f.dir, f.added + f.removed > 0 ? counts(f.added, f.removed) : undefined) : null}
132 {f.changes.map(c => file(c, f.dir !== '' ? ' ' : ''))}
133 </Box>
134 ))
135 : t.changes.map(c => file(c, ''))}
136 {t.changes.length > FOLDERS_ONLY ? <Text color={color.dim}>{'Each file and its lines: /tldr diff'}</Text>
137 : t.changes.length > PEEK_FILES && (t.diffs ?? []).length > 0 ? <Text color={color.dim}>{'Diff lines: /tldr diff'}</Text> : null}
138 </Box>
139 )
140 }
141
142 const tests = () =>
143 t.testRuns.length === 0 ? empty('No tests ran.') : (
144 <Box flexDirection="column">
145 {t.testRuns.map(r => {
146 const l = testLabel(r)
147 return line(runPart(r.command).split('\n')[0]!, l.result, l.ok ? color.pass : color.fail)
148 })}
149 </Box>
150 )
151
152 const ran = () => {
153 const cmds = ranCommands(t)
154 return cmds.length === 0 ? empty('No other commands ran.') : <Box flexDirection="column">{cmds.map(c => line(c))}</Box>
155 }
156
157 const check = () =>
158 t.flags.length === 0 ? empty('Nothing to check.') : (
159 <Box flexDirection="column">
160 {t.flags.map(f => (
161 <Box flexDirection="column">
162 <Text color={color.check} wrap="truncate-end">{`⚠ ${f.detail}`}</Text>
163 {(f.items ?? []).map(i => <Text color={color.dim} wrap="truncate-end">{` ${i}`}</Text>)}
164 </Box>
165 ))}
166 </Box>
167 )
168
169 // What Claude wrote between tool calls: folded off the transcript, kept here.
170 const notes = () =>
171 t.interim.length === 0 ? empty('No notes between tool calls.') : (
172 <Box flexDirection="column" rowGap={1}>{t.interim.map(n => <Text color={color.dim} wrap="wrap">{n}</Text>)}</Box>
173 )
174
175 // The turn in order: how it got from the prompt to the result. A failing test is red, an edited test file amber.
176 const steps = () => {
177 const all = stepsOf(t)
178 if (all.length === 0) return empty('No tool calls in this turn.')
179 const shown = all.slice(-STEPS_SHOWN)
180 const first = all.length - shown.length
181 const flagged = t.flags.some(f => f.kind === 'tests-edited')
182 const tone = (s: Step) =>
183 s.kind === 'test' ? (s.ok ? color.pass : color.fail) : s.kind === 'delete' || (s.isTest && flagged) ? color.check : undefined
184 return (
185 <Box flexDirection="column">
186 {first > 0 ? <Text color={color.dim}>{`… ${first} earlier step${first === 1 ? '' : 's'}`}</Text> : null}
187 {shown.map((s, i) => (
188 <Box flexDirection="row" columnGap={1}>
189 <Box width={3} flexShrink={0}><Text color={color.dim}>{String(first + i + 1).padStart(2)}</Text></Box>
190 <Box width={VERB_WIDTH} flexShrink={0}><Text color={color.dim}>{stepVerb(s.kind)}</Text></Box>
191 {tone(s) === undefined ? <Text wrap="truncate-end">{tinted(s.text)}</Text> : <Text color={tone(s)} wrap="truncate-end">{s.text}</Text>}
192 {s.count > 1 ? <Text color={color.dim}>{`×${s.count}`}</Text> : null}
193 {s.isTest ? <Text color={flagged ? color.check : color.dim}>test file</Text> : null}
194 </Box>
195 ))}
196 </Box>
197 )
198 }
199
200 const body: Record<Section, () => unknown> = { looked, changed, tests, ran, check, notes }
201 if (s !== 'all') return <Box key="tldr-detail" flexDirection="column">{body[s]() as never}</Box>
202 return (
203 <Box key="tldr-detail" flexDirection="column" rowGap={1}>
204 <Box flexDirection="column">
205 <Text bold>Steps</Text>
206 {steps() as never}
207 </Box>
208 {t.flags.length > 0 ? (
209 <Box flexDirection="column">
210 <Text bold color={color.check}>Check</Text>
211 {check() as never}
212 </Box>
213 ) : null}
214 </Box>
215 )
216}
217
218/** Characters of the folder part of a path as the pane shows it, so a grouped file shows its name only. */
219function dirLength(path: string): number {
220 const p = shortPath(path)
221 return p.lastIndexOf('/') + 1
222}
223hooks/draw/history.tsx 34 lines1// The /tldr history pane: every kept card of this session, newest first, each with Claude's reply under it.
2import type { Elements, RenderSurface } from 'claude-code'
3
4import type { Turn } from '../../types'
5import { drawCard } from './card'
6import { color, glyph } from './theme'
7
8export const HISTORY_PANE = 'tldr-history'
9
10export function drawHistory(ui: Elements[RenderSurface], list: readonly Turn[]) {
11 const { Box, Text } = ui
12 if (list.length === 0) {
13 return (
14 <Box key="tldr-history" flexDirection="column">
15 <Text color={color.dim}>No cards kept yet. Each finished turn is kept here, also after /compact.</Text>
16 </Box>
17 )
18 }
19 return (
20 <Box key="tldr-history" flexDirection="column" rowGap={1}>
21 {[...list].reverse().map(t => (
22 <Box key={`tldr-kept:${t.id}`} flexDirection="column">
23 {drawCard(ui, t, { isExpanded: false })}
24 {t.reply !== null ? (
25 <Box paddingLeft={glyph.elbow.length}>
26 <Text wrap="wrap">{t.reply}</Text>
27 </Box>
28 ) : null}
29 </Box>
30 ))}
31 </Box>
32 )
33}
34