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

Every turn, in one card. What changed, how the tests went, and what to check before you trust it.
![]()
![]()
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 listed on claudecodemods.com, claudemods.dev and claudemods.si.
![]()
![]()
![]()
![]()
![]()
![]()
Also: /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. The card is for the terminal.
| Command | Does |
|---|---|
/tldr | Help. |
/tldr show [row] | Open what's behind a row of the latest card: 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 cards 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 and actions. |
motion | Claude Code's own spinner. |
animation | Motion keeps its colors but doesn't move. |
context | No context bar. |
trust | No Check row, no amber border. |
keep | Nothing saved, no note after /compact. |
actions | No buttons, no copy or diff commands. |
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.
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 card 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.
Bug reports, ideas and pull requests are welcome. Use the issue forms. A screenshot of the card helps a lot. To report a security problem, see SECURITY.md.
MIT © Kushal Banda
hooks/register.tsx 569 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, RenderSurface, 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, PULSE_MS } 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 { fitProgress, progressParts } 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 { DIFF_PANE, drawDiff } from './draw/diff'
21import { drawHistory, HISTORY_PANE } from './draw/history'
22import { drawSpinner, SPINNER_KEY } from './draw/spinner'
23import { readFeatures } from './features'
24import { encodeCells, fitsCells, planBarFrame, shimmerRowFrame, spinnerFrame } from './motion'
25import { applyTaskCall, EMPTY_PLAN, planView, TASK_TOOLS, taskLabel } from './plan'
26import type { SpinnerMode } from './motion'
27import { isTurnList, KEPT_MAX, keptKey, MAX_TURNS, turnForCalls, turnForDuration } from './state'
28import { activityWord, isMeta, logLines, runName } from './summary'
29import { checkTurn } from './trust'
30import { endTurn, isInterim, isReply, recordCall, recordStep, startTurn, toKept } from './turn'
31
32const HELP = 'TL;DR is on. Click a card row for its detail, or /tldr show [changed|tests|check|ran|looked] · /tldr context · /tldr history · /tldr copy · /tldr diff'
33
34// The frame loop repaints the spinner between renders (module state: a reload restarts it).
35const FRAME_MS = 80
36let frameMs = 0
37let loop: Timer | undefined
38let spinnerSite: { requestId: string; mode: SpinnerMode } | null = null
39// 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.
40let planSite: { requestId: string; done: number; total: number; barColumns: number; row: string | null; rowColumns: number } | null = null
41let doneFrame = -Infinity
42// The turn the diff pane shows: the card whose diff button was pressed last.
43let diffFor: string | null = null
44// What the detail pane shows: the turn and the row pressed last (or /tldr show).
45let detailFor: { turnId: string; section: Section | 'all' } | null = null
46// Fullscreen: the row whose detail is open inside its card. One at a time; the same press closes it.
47let inlineFor: { turnId: string; section: Section | 'all' } | null = null
48// The context window by category, as the band's bar draws it: measured after each request (a local estimate).
49let contextNow: ContextFill | null = null
50let measuring = false
51// Each edited file's text before the open turn's first edit of it, for the turn's net diff (null: too large).
52let openTurnId: string | null = null
53let befores = new Map<string, string | null>()
54const BEFORE_MAX = 256 * 1024
55const EDIT_TOOLS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
56
57function startLoop($: EngineInterface) {
58 if (loop !== undefined) return
59 loop = $.clock.every(FRAME_MS, () => {
60 frameMs += FRAME_MS
61 const spin = spinnerSite
62 if (spin !== null)
63 void $.ui.blit({ requestId: spin.requestId, key: SPINNER_KEY, cells: encodeCells(spinnerFrame(spin.mode, frameMs)) })
64 .then(r => { if (r.deny !== undefined && spinnerSite === spin) spinnerSite = null })
65 .catch(() => {})
66 const p = planSite
67 if (p !== null) {
68 void $.ui.blit({ requestId: p.requestId, key: PLAN_BAR_KEY, cells: encodeCells(planBarFrame(p.done, p.total, frameMs - doneFrame, frameMs, p.barColumns)) })
69 .then(r => { if (r.deny !== undefined && planSite === p) planSite = null })
70 .catch(() => {})
71 if (p.row !== null) void $.ui.blit({ requestId: p.requestId, key: PLAN_ROW_KEY, cells: encodeCells(shimmerRowFrame(p.row, frameMs, p.rowColumns)) }).catch(() => {})
72 }
73 })
74}
75
76function endLoop() {
77 loop?.cancel()
78 loop = undefined
79 spinnerSite = null
80 planSite = null
81}
82
83export const register: Register = (on, options) => {
84 const f = readFeatures(options)
85
86 on('session.start', async ($, e, next) => {
87 const r = await next(e)
88 await $.command
89 .register({ name: 'tldr', description: 'TL;DR for Claude Code: turn cards', argumentHint: '[show|context|history|copy|diff]' })
90 .catch(() => {})
91 if (f.context) await measureContext($)
92 // 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.
93 if (f.keep) {
94 const list = await loadKept($).catch(() => [])
95 if (list.length > 0) {
96 await seedTurns($, list).catch(() => {})
97 await setKept($, () => ({ count: list.length, note: true })).catch(() => {})
98 }
99 }
100 return r
101 })
102
103 // A turn of the main conversation opens a card; subagents fill their own windows and are left out.
104 on('turn.start', async ($, e, next) => {
105 const r = await next(e)
106 if (e.agentId === undefined) {
107 openTurnId = e.turnId
108 befores = new Map()
109 await openTurn($, startTurn(e.turnId, e.text, await $.clock.now()))
110 if (f.keep) await setKept($, k => ({ ...k, note: false })).catch(() => {})
111 if (f.motion) {
112 await setLive($, () => ({ phase: 'working', word: 'Thinking' })).catch(() => {})
113 if (f.animation) startLoop($)
114 }
115 }
116 return r
117 })
118
119 // Observe only: the engine's result goes back unchanged.
120 on('tool.call', async ($, e, next) => {
121 // Link the call to the turn first: the engine draws its group while the call still runs.
122 if (e.agentId === undefined) {
123 await updateCurrent($, t => recordCall(t, e.tool_use_id, [])).catch(() => {})
124 if (f.motion) await setLive($, l => ({ ...l, word: activityWord(e.tool, e) })).catch(() => {})
125 // The band's TL;DR line names the new job.
126 if (f.motion) $.ui.invalidate('ui.render')
127 const path = editPath(e.tool, e)
128 if (f.actions && path !== undefined && !befores.has(path)) {
129 // Missing (ENOENT) is a new file, empty before; any other failure leaves the net diff unknown.
130 const text = await $.fs.read(path).catch((err: unknown) => (/ENOENT|not found|no such file/i.test(String(err)) ? '' : null))
131 befores.set(path, typeof text === 'string' && text.length <= BEFORE_MAX ? text : null)
132 }
133 }
134 const ran = await next(e)
135 if (e.agentId !== undefined || ran.deny !== undefined) return ran
136 // Claude's own task list feeds the band's stepper.
137 if (f.card && f.motion && TASK_TOOLS.includes(e.tool) && ran.isError !== true && openTurnId !== null) {
138 const before = await readPlan($)
139 const after = applyTaskCall(before, e.tool, e, ran.result, openTurnId, await $.clock.now())
140 if (after !== before) {
141 await setPlan($, () => after).catch(() => {})
142 if (after.done !== before.done) {
143 doneFrame = frameMs
144 // The done task's highlight ends on the next draw after it runs out.
145 $.clock.after(PULSE_MS + 50, () => $.ui.invalidate('ui.render'))
146 }
147 $.ui.invalidate('ui.render')
148 }
149 }
150 const root = await $.session.root().catch(() => undefined)
151 const calls = classify(e.tool, e, ran.result, ran.isError === true, root)
152 const meta = isMeta(e.tool)
153 const lines = meta ? [] : logLines(e.tool, calls, e)
154 const edit = f.actions ? diffOf(e.tool, e, ran.result, ran.isError === true) : null
155 const diff = edit === null ? null : { ...edit, path: placePath(edit.path, root).path }
156 const now = await $.clock.now()
157 await updateCurrent($, t => {
158 const called = recordCall(t, e.tool_use_id, calls, lines, meta ? undefined : runName(e.tool, e))
159 const recorded = diff === null ? called : { ...called, diffs: addDiff(called.diffs, diff) }
160 const done = f.trust ? { ...recorded, flags: checkTurn(recorded) } : recorded
161 const c = calls[0]
162 if (c?.kind === 'change') return { ...done, pulse: { kind: 'change', key: c.change.path, at: now } }
163 if (c?.kind === 'test') return { ...done, pulse: { kind: 'test', key: String(done.testRuns.length - 1), at: now } }
164 return done
165 }).catch(() => {})
166 // The band's TL;DR line counts the finished call.
167 if (f.motion) $.ui.invalidate('ui.render')
168 // The flash ends on the next draw after it runs out.
169 if (f.motion && f.animation && (calls[0]?.kind === 'change' || calls.some(c => c.kind === 'test'))) $.clock.after(PULSE_MS + 50, () => $.ui.invalidate('ui.render'))
170 return ran
171 }).catch(($, e, next) => next(e)) // a failure here must never stop or change a tool call
172
173 // A finished response: text before a tool call folds into the card; text with no tool call is the reply.
174 on('turn.step', async function* ($, e, next) {
175 const r = yield* next(e)
176 if (e.agentId === undefined) {
177 await updateTurn($, r.turnId, t => recordStep(t, r.answer, r.toolUses.length > 0)).catch(() => {})
178 if (f.context) void measureContext($)
179 }
180 return r
181 })
182
183 on('turn.complete', async ($, e, next) => {
184 const r = await next(e)
185 if (e.agentId === undefined) {
186 const now = await $.clock.now()
187 const net = f.actions && openTurnId === e.turnId ? await netDiffs($) : []
188 await closeCurrent($, t => endTurn({ ...t, diffs: mergeNet(t.diffs, net) }, now)).catch(() => {})
189 openTurnId = null
190 befores = new Map()
191 if (f.keep) {
192 const t = await latestTurn($)
193 if (t !== null && (t.toolUseIds.length > 0 || t.reply !== null)) {
194 const count = await saveKept($, toKept(t)).catch(() => null)
195 if (count !== null) await setKept($, k => ({ ...k, count })).catch(() => {})
196 }
197 }
198 if (f.motion) await setLive($, l => ({ ...l, phase: 'idle', word: '' })).catch(() => {})
199 endLoop()
200 }
201 return r
202 })
203
204 on('command.run', { command: 'tldr' }, async ($, e) => {
205 const sub = e.args.trim().toLowerCase()
206 if (sub === 'context') {
207 if (!f.context) return { text: 'TL;DR: the context bar is off. Turn it on in /plugin, TL;DR options.' }
208 await openContext($)
209 return { text: contextNow === null ? 'TL;DR: no context figures yet.' : `TL;DR: context opened, ${contextNow.percent}% used. Escape closes it.` }
210 }
211 if (!f.card) return { text: 'TL;DR: the turn card is off. Turn it on in /plugin, TL;DR options.' }
212 // One card, one detail: `show` opens what sits behind a row. `expand` is kept as a word for it.
213 const [verb = '', arg = ''] = sub.split(/\s+/)
214 if (verb === 'show' || verb === 'expand') {
215 const section = sectionOf(arg)
216 if (section === null) return { text: `TL;DR: no row "${arg}". Rows: changed, tests, check, ran, looked.` }
217 const t = await lastTurn($, f.keep)
218 if (t === null) return { text: 'TL;DR: no turn yet.' }
219 await showDetail($, t, section)
220 const what = section === 'all' ? `the whole turn "${t.goal}"` : paneTitle(t, section).replace(/^TL;DR · /, '')
221 return { text: `TL;DR: ${what} opened. Escape closes it.` }
222 }
223 if (verb === 'collapse' || verb === 'hide') {
224 await $.ui.close({ id: DETAIL_PANE }).catch(() => {})
225 return { text: 'TL;DR: detail closed.' }
226 }
227 if (sub === 'history' && !f.keep) return { text: 'TL;DR: keeping cards is off. Turn it on in /plugin, TL;DR options.' }
228 if (sub === 'history') {
229 const n = (await loadKept($).catch(() => [])).length
230 await $.ui.open({ id: HISTORY_PANE, title: 'TL;DR history' })
231 return { text: `TL;DR: history opened, ${n} card${n === 1 ? '' : 's'} kept.` }
232 }
233 if (f.actions && (sub === 'copy' || sub === 'diff')) {
234 const t = await lastTurn($, f.keep)
235 if (t === null) return { text: 'TL;DR: no turn yet.' }
236 if (sub === 'copy') return { text: await copyReply($, t) }
237 const diff = turnDiff(t)
238 return { text: diff === '' ? `TL;DR: no edits kept for "${t.goal}".` : diff }
239 }
240 return { text: HELP }
241 })
242
243 // The card takes the place of the turn's tool rows: the turn's first group draws it, later groups draw nothing.
244 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
245 if (!f.card || e.surface !== 'terminal') return next(e)
246 const ids = e.props.calls.map(c => c.tool_use_id).filter((id): id is string => id !== undefined)
247 const found = turnForCalls(await allTurns($), ids)
248 if (found === null) return next(e)
249 // Main screen: printed rows cannot change, so each group is one line; the card follows at turn end.
250 if (e.viewport?.isFullscreen === false) return e.props.isExpanded ? next(e) : drawCompactLine($.ui.resolve(e), e, e.props.calls, await $.session.root().catch(() => undefined))
251 // 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.
252 const { Box } = $.ui.resolve(e)
253 if (!found.isFirst) return <Box key="tldr-folded" />
254 const t = found.turn
255 const actions = f.actions && t.endedAt !== null
256 ? { copy: (p: { surface: RenderSurface }) => void copyReply($, t, p.surface, true), diff: () => void openDiff($, t) }
257 : undefined
258 return drawCard($.ui.resolve(e), e, t, { isExpanded: e.props.isExpanded, now: await $.clock.now(), pulse: f.motion && f.animation, actions, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
259 })
260
261 // A call the transcript does not fold into a group (Bash, Edit, Write, ...) is its own row: the same rule as a group.
262 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
263 if (!f.card || e.surface !== 'terminal') return next(e)
264 const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
265 if (found === null) return next(e)
266 const t = found.turn
267 if (e.viewport?.isFullscreen === false) return drawCompactLine($.ui.resolve(e), e, [e.props], await $.session.root().catch(() => undefined))
268 const { Box } = $.ui.resolve(e)
269 if (!found.isFirst) return <Box key="tldr-folded" />
270 const actions = f.actions && t.endedAt !== null
271 ? { copy: (p: { surface: RenderSurface }) => void copyReply($, t, p.surface, true), diff: () => void openDiff($, t) }
272 : undefined
273 // A standalone row has no ctrl+o of its own: the card stands for it, and a press opens the detail.
274 return drawCard($.ui.resolve(e), e, t, { isExpanded: false, now: await $.clock.now(), pulse: f.motion && f.animation, actions, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
275 })
276
277 // The result block under a standalone row: folded with its row.
278 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
279 if (!f.card || e.surface !== 'terminal') return next(e)
280 const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
281 if (found === null) return next(e)
282 const { Box } = $.ui.resolve(e)
283 return <Box key="tldr-folded-result" />
284 })
285
286 // In-between text folds into the card in fullscreen; the main screen keeps what it printed.
287 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
288 if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen === false) return next(e)
289 // A block that is also some turn's reply is ambiguous (a later answer can repeat earlier in-between text): it stays.
290 const recent = (await allTurns($)).slice(-50)
291 if (!recent.some(t => isInterim(t, e.props.text)) || recent.some(t => isReply(t, e.props.text))) return next(e)
292 const { Box } = $.ui.resolve(e)
293 return <Box key="tldr-folded-text" />
294 })
295
296 // The mode-aware spinner, in place of the engine's spinning word.
297 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
298 if (!f.motion || e.surface !== 'terminal') return next(e)
299 const l = await readLive($)
300 const t = await latestTurn($)
301 const now = await $.clock.now()
302 const word = e.props.message ?? (l.phase !== 'idle' && l.word ? l.word : e.props.word)
303 // Any spinner moves, not only a turn's: /compact draws one with no turn open.
304 if (f.animation) {
305 spinnerSite = { requestId: e.requestId, mode: e.props.mode as SpinnerMode }
306 startLoop($)
307 }
308 return drawSpinner($.ui.resolve(e as RenderInput<'Spinner', 'terminal'>), e, word, t !== null && t.endedAt === null ? now - t.startedAt : null, frameMs)
309 })
310
311 // The band above the prompt: the kept-cards note and the row keys; what other mods draw there stays, above it.
312 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
313 const rest = await next(e)
314 if (e.surface !== 'terminal' || e.props.hasSurvey) return rest
315 const k = f.keep ? await readKept($) : { count: 0, note: false }
316 const note = k.note && k.count > 0 ? keptNote(k.count) : null
317 // 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.
318 const t = f.card && e.viewport?.isFullscreen === false && !e.props.isWorking ? await lastTurn($, f.keep) : null
319 const reveal = t !== null && t.endedAt !== null
320 ? { turnId: t.id, keys: revealKeys(t), show: (s: Section | 'all') => toggleInline($, t, s), open: inlineFor?.turnId === t.id ? { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), e, t, inlineFor.section) } : null }
321 : null
322 const context = f.context && contextNow !== null ? { fill: contextNow, open: () => void openContext($) } : null
323 // While a turn runs: how far it got, in one line, redrawn as calls land (no timer).
324 const open = f.card && f.motion && e.props.isWorking ? await latestTurn($) : null
325 const progress = open !== null && open.endedAt === null ? fitProgress(progressParts(open, (await readLive($)).word), e.props.bodyColumns) : null
326 const plan = open !== null && open.endedAt === null ? await planParts($, e.requestId, open.id, e.props.bodyColumns, f.animation) : null
327 if (plan === null && planSite?.requestId === e.requestId) planSite = null
328 if (note === null && reveal === null && context === null && plan === null && (progress === null || progress.length === 0)) return rest
329 return drawBand($.ui.resolve(e as RenderInput<'AbovePrompt', 'terminal'>), e, rest ?? null, { note, reveal, context, progress, plan })
330 })
331
332 // After a compaction the earlier rows are gone from the screen; the band points to the kept cards.
333 // While it runs, the spinner moves and says so: a compaction can take a minute. A precompute runs unseen and is left out.
334 on('session.compact', async ($, e, next) => {
335 const shown = f.motion && e.agentId === undefined && e.trigger !== 'precompute'
336 if (shown) {
337 await setLive($, l => ({ ...l, phase: 'compacting', word: 'Compacting' })).catch(() => {})
338 if (f.animation) startLoop($)
339 }
340 const r = await next(e).finally(async () => {
341 if (!shown) return
342 // An automatic compaction can fall inside a turn: the turn goes on working.
343 const inTurn = openTurnId !== null
344 await setLive($, l => ({ ...l, phase: inTurn ? 'working' : 'idle', word: inTurn ? 'Thinking' : '' })).catch(() => {})
345 if (!inTurn) endLoop()
346 })
347 if (f.keep && e.agentId === undefined && 'messages' in r) await setKept($, k => ({ ...k, note: k.count > 0 })).catch(() => {})
348 if (f.context && e.agentId === undefined) await measureContext($)
349 return r
350 })
351
352 on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, async ($, e) => drawContextPane($.ui.resolve(e as RenderInput<'Pane', 'terminal'>), e, contextNow))
353 on('ui.render', { component: 'Pane', requestId: HISTORY_PANE }, async ($, e) => drawHistory($.ui.resolve(e), e, await loadKept($).catch(() => [])))
354
355 on('ui.render', { component: 'Pane', requestId: DETAIL_PANE }, async ($, e) => {
356 const all = [...(await allTurns($)), ...(f.keep ? await loadKept($).catch(() => []) : [])]
357 return drawDetail($.ui.resolve(e), e, detailFor === null ? null : all.find(t => t.id === detailFor!.turnId) ?? null, detailFor?.section ?? 'all')
358 })
359
360 on('ui.render', { component: 'Pane', requestId: DIFF_PANE }, async ($, e) => drawDiff($.ui.resolve(e), e, (await allTurns($)).find(t => t.id === diffFor) ?? null))
361
362 // Main screen: the full card takes the place of the turn's closing line ("Baked for 52s").
363 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
364 if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen !== false) return next(e)
365 const t = await durationTurn($, e.requestId, e.props.durationMs)
366 if (t === null) return next(e)
367 return drawCard($.ui.resolve(e), e, t, { isExpanded: false, durationMs: e.props.durationMs })
368 })
369
370}
371
372/** The stepper's first frame, and the site the frame loop repaints; null when the open turn has no task list. */
373async function planParts($: EngineInterface, requestId: string, turnId: string, bodyColumns: number, animation: boolean) {
374 const plan = await readPlan($)
375 const view = planView(plan, turnId)
376 if (view === null) return null
377 // With animation off nothing repaints: the bar holds still and the running task is plain text.
378 const moving = animation && loop !== undefined
379 const barColumns = Math.max(10, Math.min(24, Math.floor(bodyColumns / 5)))
380 const label = view.current !== null ? taskLabel(view.current) : null
381 const row = moving && label !== null && fitsCells(label) ? label : null
382 const rowColumns = row === null ? 0 : Math.max(1, Math.min(bodyColumns, [...row].length + 4))
383 planSite = moving ? { requestId, done: view.done, total: view.total, barColumns, row, rowColumns } : null
384 const now = await $.clock.now()
385 return {
386 view,
387 barCells: encodeCells(planBarFrame(view.done, view.total, frameMs - doneFrame, frameMs, barColumns, moving)),
388 barColumns,
389 rowCells: row === null ? null : encodeCells(shimmerRowFrame(row, frameMs, rowColumns, moving)),
390 rowColumns,
391 justDone: plan.done !== null && now - plan.done.at < PULSE_MS ? plan.done.id : null,
392 }
393}
394
395/** The newest turn in this session; after a resume, the newest kept card. */
396async function lastTurn($: EngineInterface, keep: boolean): Promise<Turn | null> {
397 const t = await latestTurn($)
398 if (t !== null || !keep) return t
399 return (await loadKept($).catch(() => [])).at(-1) ?? null
400}
401
402async function copyText($: EngineInterface, text: string, what: string, surface: RenderSurface | undefined, toast: boolean): Promise<string> {
403 const r = await $.ui.copy(surface === undefined ? { text } : { text, surface })
404 const said = r.isCopied ? `TL;DR: ${what} copied.` : `TL;DR: could not copy the ${what} (${r.reason}).`
405 if (toast) $.ui.toast(said)
406 return said
407}
408
409async function copyReply($: EngineInterface, t: Turn, surface?: RenderSurface, toast = false): Promise<string> {
410 if (t.reply === null) return `TL;DR: "${t.goal}" has no reply to copy.`
411 return copyText($, cleanCopy(t.reply), 'reply', surface, toast)
412}
413
414/** Opens the detail pane behind a card row: small, holding the keys, closed by Escape. */
415async function showDetail($: EngineInterface, t: Turn, section: Section | 'all') {
416 detailFor = { turnId: t.id, section }
417 await $.ui.open({ id: DETAIL_PANE, title: paneTitle(t, section), focus: true, closeOnEscape: true, rows: paneRows(t, section) })
418 $.ui.invalidate('ui.render')
419}
420
421/** 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. */
422function toggleInline($: EngineInterface, t: Turn, section: Section | 'all') {
423 inlineFor = inlineFor?.turnId === t.id && inlineFor.section === section ? null : { turnId: t.id, section }
424 $.ui.invalidate('ui.render')
425}
426
427function inlineOpen($: EngineInterface, e: RenderInput<'ToolGroup'> | RenderInput<'ToolUse'>, t: Turn) {
428 if (inlineFor?.turnId !== t.id) return undefined
429 return { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), e, t, inlineFor.section) }
430}
431
432/** The context window by category, as /context counts it but estimated locally, so it costs no request. */
433async function measureContext($: EngineInterface) {
434 if (measuring) return
435 measuring = true
436 try {
437 const u = await $.session.usage({ breakdown: 'summary' })
438 const b = u.context.breakdown
439 if (b !== undefined && b.rawMaxTokens > 0) {
440 contextNow = contextFill(b)
441 $.ui.invalidate('ui.render')
442 }
443 } catch {
444 // No figures: the bar keeps its last ones.
445 } finally {
446 measuring = false
447 }
448}
449
450async function openContext($: EngineInterface) {
451 await measureContext($)
452 await $.ui.open({ id: CONTEXT_PANE, title: 'TL;DR context', focus: true, closeOnEscape: true, rows: (contextNow?.slices.length ?? 1) + (contextNow?.setup.length ?? 0) + 3 })
453}
454
455async function openDiff($: EngineInterface, t: Turn) {
456 diffFor = t.id
457 await $.ui.open({ id: DIFF_PANE, title: `TL;DR diff · ${t.goal}` })
458}
459
460function editPath(tool: string, input: unknown): string | undefined {
461 if (!EDIT_TOOLS.includes(tool)) return undefined
462 const i = input as { file_path?: unknown; notebook_path?: unknown }
463 return typeof i.file_path === 'string' ? i.file_path : typeof i.notebook_path === 'string' ? i.notebook_path : undefined
464}
465
466/** The open turn's edited files as net diffs: their text now against their text before the turn's first edit. */
467async function netDiffs($: EngineInterface): Promise<{ path: string; lines: string[] | null }[]> {
468 const root = await $.session.root().catch(() => undefined)
469 const out: { path: string; lines: string[] | null }[] = []
470 for (const [abs, before] of befores) {
471 const path = placePath(abs, root).path
472 if (before === null) {
473 out.push({ path, lines: null })
474 continue
475 }
476 const after = await $.fs.read(abs).catch(() => null)
477 out.push({ path, lines: typeof after === 'string' ? unifiedDiff(before, after) : null })
478 }
479 return out
480}
481
482// State: TL;DR's atoms, and every read and write of them and of the kept cards.
483
484const turns = atom({ plugin: 'tldr', key: 'turns' } as const, [] as Turn[])
485const live = atom({ plugin: 'tldr', key: 'live' } as const, { phase: 'idle', word: '' } as Live)
486const kept = atom({ plugin: 'tldr', key: 'kept' } as const, { count: 0, note: false } as Kept)
487const current = atom({ plugin: 'tldr', key: 'current' } as const, null as string | null)
488const planAtom = atom({ plugin: 'tldr', key: 'plan' } as const, EMPTY_PLAN as Plan)
489
490async function readPlan($: EngineInterface): Promise<Plan> {
491 return read($, planAtom)
492}
493
494async function setPlan($: EngineInterface, fn: (p: Plan) => Plan): Promise<void> {
495 await update($, planAtom, p => fn(p ?? EMPTY_PLAN))
496}
497
498async function openTurn($: EngineInterface, t: Turn): Promise<void> {
499 // A turn that never completed (interrupted) ends where the next one starts, so its card stops saying "running".
500 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))
501 await update($, current, () => t.id)
502}
503
504async function updateCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
505 const id = await read($, current)
506 if (id === null) return
507 await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
508}
509
510async function updateTurn($: EngineInterface, id: string, fn: (t: Turn) => Turn): Promise<void> {
511 await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
512}
513
514async function closeCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
515 await updateCurrent($, fn)
516 await update($, current, () => null)
517}
518
519// Drawing is pure, so the pins live in the module, like the spinner's site. A hot reload starts them over.
520const durationPins = new Map<string, string>()
521
522async function durationTurn($: EngineInterface, site: string, durationMs: number): Promise<Turn | null> {
523 const t = turnForDuration(await read($, turns), durationPins, site, durationMs, await $.clock.now())
524 if (t !== null) durationPins.set(site, t.id)
525 return t
526}
527
528async function latestTurn($: EngineInterface): Promise<Turn | null> {
529 const list = await read($, turns)
530 return list.at(-1) ?? null
531}
532
533async function allTurns($: EngineInterface): Promise<Turn[]> {
534 return read($, turns)
535}
536
537async function readLive($: EngineInterface): Promise<Live> {
538 return read($, live)
539}
540
541async function setLive($: EngineInterface, fn: (l: Live) => Live): Promise<void> {
542 await update($, live, l => fn(l ?? { phase: 'idle', word: '' }))
543}
544
545async function loadKept($: EngineInterface): Promise<Turn[]> {
546 const v = await $.store.get(keptKey(await $.session.id()))
547 return isTurnList(v) ? v : []
548}
549
550async function saveKept($: EngineInterface, t: Turn): Promise<number> {
551 const list = (await loadKept($)).filter(x => x.id !== t.id)
552 const next = [...list, t].slice(-KEPT_MAX)
553 await $.store.set(keptKey(await $.session.id()), next)
554 return next.length
555}
556
557/** 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. */
558async function seedTurns($: EngineInterface, list: readonly Turn[]): Promise<void> {
559 await update($, turns, now => ((now ?? []).length > 0 ? now ?? [] : list.slice(-MAX_TURNS)))
560}
561
562async function readKept($: EngineInterface): Promise<Kept> {
563 return read($, kept)
564}
565
566async function setKept($: EngineInterface, fn: (k: Kept) => Kept): Promise<void> {
567 await update($, kept, k => fn(k ?? { count: 0, note: false }))
568}
569hooks/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 181 lines1// The turn card: goal, what changed, tests, what Claude looked at.
2import type { Elements, RenderChildren, RenderInput, RenderSurface, UiPressArgument } from 'claude-code'
3
4import type { Change, Turn } from '../../types'
5import { shortPath } from '../classify'
6import { changeTotals } from '../steps'
7import { ranLabel, testLabel } from '../summary'
8import { sectionTitle } from './detail'
9import type { Section } from './detail'
10import { color, glyph, LABEL_WIDTH } from './theme'
11
12function clock(ms: number): string {
13 const d = new Date(ms)
14 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
15}
16
17export function duration(ms: number): string {
18 const s = Math.max(0, Math.round(ms / 1000))
19 if (s < 60) return `${s}s`
20 const m = Math.floor(s / 60)
21 return `${m}m ${s % 60}s`
22}
23
24export function lookedLabel(t: Turn): string {
25 const parts: string[] = []
26 if (t.reads.length > 0) parts.push(`${t.reads.length} file${t.reads.length === 1 ? '' : 's'}`)
27 if (t.searches.length > 0) parts.push(`${t.searches.length} search${t.searches.length === 1 ? '' : 'es'}`)
28 return parts.length > 0 ? parts.join(', ') : 'nothing'
29}
30
31export type CardOptions = {
32 /** An expanded fullscreen group (a click, ctrl+o): the card adds Claude's notes. */
33 isExpanded: boolean
34 /** The engine's own measure of the turn, where it gives one (TurnDuration). */
35 durationMs?: number
36 /** Clock time now, and whether the newest change or test result may flash. */
37 now?: number
38 pulse?: boolean
39 /** The action buttons, where presses reach the mod (fullscreen) and the turn is done. */
40 actions?: CardActions
41 /** Opens or closes the detail behind a row, where presses reach the mod (fullscreen). The goal opens the steps. Absent: rows are plain text. */
42 show?: (s: Section | 'all') => void
43 /** The row whose detail is open inside the card, and that detail drawn. */
44 open?: { section: Section | 'all'; detail: RenderChildren }
45}
46
47export type CardActions = { copy: (p: UiPressArgument) => void; diff: (p: UiPressArgument) => void }
48
49export const PULSE_MS = 900
50const NAMES_SHOWN = 3 // file names on the Changed row; the pane lists all
51
52export function drawCard(ui: Elements[RenderSurface], e: RenderInput<'ToolGroup'> | RenderInput<'ToolUse'> | RenderInput<'TurnDuration'>, t: Turn, o: CardOptions) {
53 const { Box, Button, Text } = ui
54 const ms = o.durationMs ?? (t.endedAt === null ? null : t.endedAt - t.startedAt)
55 const meta = ms === null ? `${clock(t.startedAt)} · running` : `${clock(t.startedAt)} · ${duration(ms)}`
56 const isExpanded = o.isExpanded
57 const flash = (kind: 'change' | 'test', key: string) =>
58 o.pulse === true && o.now !== undefined && t.pulse !== null && t.pulse.kind === kind && t.pulse.key === key && o.now - t.pulse.at < PULSE_MS
59 // The card is the TL;DR; the detail sits one press away: a click in fullscreen; on the main screen, a digit key on the band or /tldr show.
60 const hint = o.open !== undefined ? `click it again to close ${glyph.collapse}` : o.show !== undefined ? `click a row ${glyph.expand}` : 'open a row: type its key at the empty prompt'
61 // A row's value as one press target: the whole value opens its detail, nothing about it changes but the cursor.
62 const pressable = (s: Section, children: RenderChildren, n = 0) =>
63 o.show !== undefined ? (
64 <Button key={`tldr-show:${s}:${t.id}:${n}`} plain onPress={() => o.show!(s)}>{children}</Button>
65 ) : (
66 <Text>{children}</Text>
67 )
68
69 const row = (label: string, value: RenderChildren, labelColor: string = color.label) => (
70 <Box flexDirection="row">
71 <Box width={LABEL_WIDTH} flexShrink={0}>
72 <Text color={labelColor}>{label}</Text>
73 </Box>
74 <Box flexDirection="row" flexWrap="wrap" columnGap={3} flexGrow={1}>
75 {value}
76 </Box>
77 </Box>
78 )
79
80 // One file reads as itself. Several read as their size first, then the first names; the pane has each one.
81 const total = changeTotals(t.changes)
82 const name = (c: Change) =>
83 c.isDelete ? (
84 <Text color={color.check}>{`${shortPath(c.path)} deleted`}</Text>
85 ) : (
86 <Text bold={flash('change', c.path)} backgroundColor={flash('change', c.path) ? (c.isTest ? color.check : color.pass) : undefined}>{shortPath(c.path)}</Text>
87 )
88 const counts = (added: number, removed: number) => (
89 <Text><Text color={color.added}>{`+${added}`}</Text>{' '}<Text color={color.removed}>{`${glyph.minus}${removed}`}</Text></Text>
90 )
91 const one = (c: Change) =>
92 c.isDelete ? name(c) : <Text>{name(c)}{' '}{c.viaBash ? <Text color={color.dim}>via Bash</Text> : counts(c.added, c.removed)}</Text>
93 const changed =
94 t.changes.length === 0 ? (
95 pressable('changed', <Text color={color.dim}>no files</Text>)
96 ) : t.changes.length === 1 ? (
97 pressable('changed', one(t.changes[0]!))
98 ) : (
99 pressable('changed', [
100 <Text>{`${total.files} files `}{total.counted ? counts(total.added, total.removed) : <Text color={color.dim}>via Bash</Text>}</Text>,
101 ' ',
102 ...t.changes.slice(0, NAMES_SHOWN).flatMap((c, i) => [i > 0 ? <Text color={color.dim}>, </Text> : '', name(c)]),
103 t.changes.length > NAMES_SHOWN ? <Text color={color.dim}>{` +${t.changes.length - NAMES_SHOWN} more`}</Text> : '',
104 ])
105 )
106
107 const tests =
108 t.testRuns.length === 0 ? (
109 pressable('tests', <Text color={color.dim}>not run</Text>)
110 ) : (
111 pressable('tests',
112 t.testRuns.map((r, i) => {
113 const l = testLabel(r)
114 // One runner names itself once: "go test ✗ 1 failed → ✓ 11 passed".
115 const same = i > 0 && t.testRuns[i - 1]!.runner === r.runner
116 return (
117 <Text>
118 {i > 0 ? <Text color={color.dim}>{` ${glyph.arrow} `}</Text> : ''}
119 <Text color={l.ok ? color.pass : color.fail} bold={flash('test', String(i))}>{same ? l.result : l.text}</Text>
120 </Text>
121 )
122 }),
123 )
124 )
125
126 const looked = (
127 <Box flexDirection="row" justifyContent="space-between" flexGrow={1}>
128 {pressable('looked', lookedLabel(t))}
129 <Text color={color.dim}>{hint}</Text>
130 </Box>
131 )
132
133 return (
134 <Box key="tldr-card" flexDirection="column" borderStyle="round" borderColor={t.flags.length > 0 ? color.check : color.border} paddingX={1}>
135 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
136 {o.show !== undefined ? (
137 <Button key={`tldr-show:all:${t.id}`} plain onPress={() => o.show!('all')}><Text bold wrap="truncate-end">{t.goal}</Text></Button>
138 ) : (
139 <Text bold wrap="truncate-end">{t.goal}</Text>
140 )}
141 <Text color={color.dim}>{meta}</Text>
142 </Box>
143 {row('Changed', changed)}
144 {row('Tests', tests)}
145 {t.flags.length > 0
146 ? row(
147 'Check',
148 <Box flexDirection="column" flexGrow={1}>
149 {t.flags.map((fl, i) => pressable('check', <Text color={color.check} wrap="truncate-end">{`⚠ ${fl.detail}`}</Text>, i))}
150 </Box>,
151 color.check,
152 )
153 : null}
154 {(t.ran ?? []).length > 0 ? row('Ran', pressable('ran', ranLabel(t.ran ?? []))) : null}
155 {row('Looked at', looked)}
156 {isExpanded && t.interim.length > 0
157 ? row(
158 'Notes',
159 <Box flexDirection="column" flexGrow={1}>
160 {t.interim.map(n => (
161 <Text color={color.dim}>{n}</Text>
162 ))}
163 </Box>,
164 )
165 : null}
166 {o.open !== undefined ? (
167 <Box key={`tldr-inline:${t.id}`} flexDirection="column" marginTop={1}>
168 {o.open.section !== 'all' ? <Text bold>{sectionTitle(o.open.section)}</Text> : null}
169 {o.open.detail}
170 </Box>
171 ) : null}
172 {o.actions !== undefined && (t.reply !== null || (t.diffs ?? []).length > 0) ? (
173 <Box flexDirection="row" justifyContent="flex-end" columnGap={1}>
174 {t.reply !== null ? <Button key={`tldr-copy:${t.id}`} label="copy reply" dimColor onPress={o.actions.copy} /> : null}
175 {(t.diffs ?? []).length > 0 ? <Button key={`tldr-diff:${t.id}`} label="diff" dimColor onPress={o.actions.diff} /> : null}
176 </Box>
177 ) : null}
178 </Box>
179 )
180}
181hooks/draw/band.tsx 146 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 { ProgressPart } from '../progress'
12import { countParts } from './tint'
13import { sectionTitle } from './detail'
14import type { Section } from './detail'
15import { color, glyph } 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; 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; progress: ProgressPart[] | null; plan?: BandPlan | null }
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 return (
61 <Box key="tldr-bands" flexDirection="column">
62 {rest}
63 {parts.note !== null ? <Text key="tldr-kept-note" color={color.dim} wrap="truncate-end">{parts.note}</Text> : null}
64 {parts.plan ? drawPlan(ui, parts.plan, parts.progress ?? []) : parts.progress !== null && parts.progress.length > 0 ? (
65 <Text key="tldr-progress" wrap="truncate-end">
66 <Text bold>{'TL;DR '}</Text>
67 {progressText(ui, parts.progress)}
68 </Text>
69 ) : null}
70 {reveal !== null ? (
71 <Box key="tldr-reveal" flexDirection="row" columnGap={2}>
72 <Text color={color.dim}>TL;DR open</Text>
73 {reveal.keys.map(k => (
74 <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)} />
75 ))}
76 {reveal.open !== null ? <Text color={color.dim}>{`press it again to close ${glyph.collapse}`}</Text> : null}
77 </Box>
78 ) : null}
79 {reveal?.open ? (
80 <Box key={`tldr-inline:${reveal.turnId}`} flexDirection="column" marginBottom={1}>
81 {reveal.open.section !== 'all' ? <Text bold>{sectionTitle(reveal.open.section)}</Text> : null}
82 {reveal.open.detail}
83 </Box>
84 ) : null}
85 {ctx !== null ? (
86 <Box key="tldr-context-row" flexDirection="row" columnGap={1}>
87 <Raster key={CONTEXT_KEY} columns={barColumns} rows={1} cells={encodeCells(contextCells(ctx.fill, barColumns))} />
88 <Button key="tldr-context-open" plain onPress={ctx.open}>
89 <Text color={level === 'full' ? color.fail : level === 'warn' ? color.check : color.text}>{head}</Text>
90 {wide ? <Text color={color.dim}>{' · '}</Text> : null}
91 {wide ? <Text color={hex(conversationRgb(ctx.fill.percent))}>{conv}</Text> : null}
92 {wide ? <Text color={color.dim}>{' · '}</Text> : null}
93 {wide ? <Text color={hex(SETUP_RGB)}>{setup}</Text> : null}
94 </Button>
95 </Box>
96 ) : null}
97 </Box>
98 )
99}
100
101function progressText(ui: Elements['terminal'], parts: readonly ProgressPart[]) {
102 const { Text } = ui
103 return parts.map((p, i) => (
104 <Text color={color.dim}>
105 {i > 0 ? ' · ' : ''}
106 {p.key === 'tests' ? <Text color={p.ok ? color.pass : color.fail}>{p.text}</Text>
107 : p.key === 'files' ? countParts(p.text).map(c => (c.tone === undefined ? c.text : <Text color={c.tone === 'added' ? color.added : color.removed}>{c.text}</Text>))
108 : p.text}
109 </Text>
110 ))
111}
112
113/** Claude's task list while the turn runs: the bar and count, then a window of tasks, the running one in motion. */
114function drawPlan(ui: Elements['terminal'], plan: BandPlan, facts: readonly ProgressPart[]) {
115 const { Box, Text, Raster } = ui
116 const v = plan.view
117 // The running task's row says what Claude does now, so the facts leave out "now".
118 const rest = facts.filter(p => p.key !== 'now' && p.key !== 'calls')
119 return (
120 <Box key="tldr-plan" flexDirection="column">
121 <Box key="tldr-plan-head" flexDirection="row" columnGap={1}>
122 <Text bold>TL;DR </Text>
123 <Raster key={PLAN_BAR_KEY} columns={plan.barColumns} rows={1} cells={plan.barCells} />
124 <Text wrap="truncate-end">
125 <Text>{`${v.done}/${v.total} tasks`}</Text>
126 {rest.length > 0 ? <Text color={color.dim}>{' · '}</Text> : null}
127 {progressText(ui, rest)}
128 </Text>
129 </Box>
130 {v.before > 0 ? <Text key="tldr-plan-before" color={color.dim}>{` ${glyph.pass} ${v.before} earlier`}</Text> : null}
131 {v.rows.map(t =>
132 t.status === 'in_progress' && plan.rowCells !== null ? <Raster key={PLAN_ROW_KEY} columns={plan.rowColumns} rows={1} cells={plan.rowCells} />
133 : t.status === 'in_progress' ? <Text key={`tldr-plan:${t.id}`} color={color.accent} wrap="truncate-end">{` ◐ ${taskLabel(t)}`}</Text>
134 : t.status === 'completed' ? (
135 <Text key={`tldr-plan:${t.id}`} wrap="truncate-end">
136 <Text color={color.pass}>{` ${glyph.pass} `}</Text>
137 <Text color={plan.justDone === t.id ? color.pass : color.dim}>{t.subject}</Text>
138 </Text>
139 )
140 : <Text key={`tldr-plan:${t.id}`} color={color.dim} wrap="truncate-end">{` ○ ${t.subject}`}</Text>,
141 )}
142 {v.after > 0 ? <Text key="tldr-plan-after" color={color.dim}>{` +${v.after} more`}</Text> : null}
143 </Box>
144 )
145}
146hooks/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, RenderInput } 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'], e: RenderInput<'Pane'>, 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 115 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 short figure: "62% · 124k/200k", and a pointer to /compact once the window is nearly full. */
106export function contextLabel(fill: ContextFill): string {
107 const base = `${fill.percent}% · ${tokens(fill.used)}/${tokens(fill.window)}`
108 return contextLevel(fill.percent) === 'full' ? `${base} · /compact` : base
109}
110
111/** The tokens of one slice kind, 0 when absent. */
112export function sliceTokens(fill: ContextFill, kind: SliceKind): number {
113 return fill.slices.find(s => s.kind === kind)?.tokens ?? 0
114}
115hooks/progress.ts 42 lines1// The TL;DR line on the band while a turn runs: the card's facts in one line, so a long turn shows how far it got. Pure.
2import type { Turn } from '../types'
3import { changeTotals } from './steps'
4import { testLabel } from './summary'
5
6export type ProgressPart = { key: 'calls' | 'files' | 'tests' | 'now'; text: string; ok?: boolean }
7
8const MINUS = '−'
9
10/** The parts, most expendable first: calls, files, tests, then what Claude does now. */
11export function progressParts(t: Pick<Turn, 'toolUseIds' | 'changes' | 'testRuns'>, word: string): ProgressPart[] {
12 const out: ProgressPart[] = []
13 const calls = t.toolUseIds.length
14 if (calls > 0) out.push({ key: 'calls', text: `${calls} call${calls === 1 ? '' : 's'}` })
15 if (t.changes.length > 0) {
16 const s = changeTotals(t.changes)
17 const files = `${s.files} file${s.files === 1 ? '' : 's'}`
18 out.push({ key: 'files', text: s.counted ? `${files} +${s.added} ${MINUS}${s.removed}` : files })
19 }
20 // The last two runs, as the card's Tests row reads them: a rerun of the same runner shows its result only.
21 const runs = t.testRuns.slice(-2).map(testLabel)
22 if (runs.length > 0) {
23 const [a, b] = runs
24 const sameRunner = b !== undefined && t.testRuns.at(-1)!.runner === t.testRuns.at(-2)!.runner
25 const text = b === undefined ? a!.text : `${a!.text} → ${sameRunner ? b.result : b.text}`
26 out.push({ key: 'tests', text, ok: (b ?? a)!.ok })
27 }
28 if (word !== '') out.push({ key: 'now', text: `now: ${word.charAt(0).toLowerCase()}${word.slice(1)}` })
29 return out
30}
31
32/** The parts that fit `columns` beside the label: drop calls, then files; tests and now stay (the row truncates). */
33export function fitProgress(parts: readonly ProgressPart[], columns: number, label = 'TL;DR'): ProgressPart[] {
34 const width = (ps: readonly ProgressPart[]) => label.length + 2 + ps.reduce((n, p) => n + p.text.length + 3, 0)
35 let out = [...parts]
36 for (const drop of ['calls', 'files'] as const) {
37 if (width(out) <= columns) break
38 out = out.filter(p => p.key !== drop)
39 }
40 return out
41}
42hooks/draw/compact-line.tsx 25 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 full card follows at the end of the turn.
3// Calls a line cannot name (git, gh, an MCP tool) draw nothing: a row of them would read "1 other call" again and again,
4// and the card's Ran row stacks them into one line.
5import type { Elements, RenderInput, RenderSurface } from 'claude-code'
6
7import { groupSummary, isQuiet } from '../summary'
8import type { GroupCall } from '../summary'
9import { color } from './theme'
10import { countParts } from './tint'
11
12export function drawCompactLine(ui: Elements[RenderSurface], e: RenderInput<'ToolGroup'> | RenderInput<'ToolUse'>, calls: readonly GroupCall[], root?: string) {
13 const { Box, Text } = ui
14 if (calls.every(c => isQuiet(c, root))) return <Box key="tldr-quiet" />
15 const parts = groupSummary(calls, root)
16 return (
17 <Text key="tldr-line" wrap="truncate-end">
18 <Text color={color.accent}>{'⏺ '}</Text>
19 <Text color={color.dim}>
20 {countParts(parts.length > 0 ? parts.join(' · ') : 'tool calls').map(p => (p.tone === undefined ? p.text : <Text color={p.tone === 'added' ? color.added : color.removed}>{p.text}</Text>))}
21 </Text>
22 </Text>
23 )
24}
25hooks/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, RenderInput, 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 the Ran row: 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], e: RenderInput<'Pane'> | RenderInput<'ToolGroup'> | RenderInput<'ToolUse'> | RenderInput<'AbovePrompt'>, 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.label)
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.label}>{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/diff.tsx 39 lines1// The diff pane: one turn's edits, opened from the card's diff button.
2import type { Elements, RenderInput, RenderSurface } from 'claude-code'
3
4import type { Turn } from '../../types'
5import { turnDiff } from '../actions'
6import { color } from './theme'
7
8export const DIFF_PANE = 'tldr-diff'
9
10function lineColor(l: string): string | undefined {
11 if (l.startsWith('+++') || l.startsWith('---')) return undefined
12 if (l.startsWith('+')) return color.added
13 if (l.startsWith('-')) return color.removed
14 if (l.startsWith('@@') || l.startsWith('…')) return color.dim
15 return undefined
16}
17
18export function drawDiff(ui: Elements[RenderSurface], e: RenderInput<'Pane'>, t: Turn | null) {
19 const { Box, Text } = ui
20 const text = t === null ? '' : turnDiff(t)
21 if (text === '') {
22 return (
23 <Box key="tldr-diff" flexDirection="column">
24 <Text color={color.dim}>No edits to show for this turn.</Text>
25 </Box>
26 )
27 }
28 return (
29 <Box key="tldr-diff" flexDirection="column">
30 <Text bold>{t!.goal}</Text>
31 {text.split('\n').map(l => (
32 <Text color={lineColor(l)} bold={l.startsWith('+++') || l.startsWith('---')} wrap="truncate-end">
33 {l === '' ? ' ' : l}
34 </Text>
35 ))}
36 </Box>
37 )
38}
39