SLOPSHOPPER

TL;DR for Claude Code

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

newpanebandspinnerrowsguard
v0.1.4MITupdated 2026-10-19pixel-candy/claude-tldr
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tldr
│ ┃ tldr-context ✕ › fix the failing auth test and add an audit log call │ ┃ No context figures yet. They come after the │ ┃ first reply. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tldr │ ⎿ tldr: TL;DR is on. Click a card row for its detail, or /tldr sho │ │ ▅▃▂▂▃▄▅▆▇ Thinking ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tldr-context
No context figures yet. They come after the first reply.
Pane · tldr-history
No cards kept yet. Each finished turn is kept here, also after /compact.
Pane · tldr-detail
No turn yet.
Pane · tldr-diff
No edits to show for this turn.
Spinner
▅▃▂▂▃▄▅▆▇ Thinking
README

TL;DR for Claude Code

Every turn, in one card. What changed, how the tests went, and what to check before you trust it.

License: MIT Version Claude Code No network calls

Left: a Claude Code turn as 33 lines of reads, edits and test logs. Right: the same turn as one TL;DR card that flags the changed test file, and the steps pane under it.

33 lines become 5 rows. A Check row flags edits that can fake a green run. No network calls.

Install

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.

Features

A card with an amber border lists four checks: a write outside the project, a deleted file, an added skip marker, and skipped hooks with a force push.

The Changed row is open inside the card: files grouped by folder, with the first diff lines of auth.py.

A spinner reads "Running pytest", and one TL;DR line above the prompt shows files, the red-to-green test runs and the current job.

Three context bars: 38% full in blue, 62% full in amber, and 86% full in red with a pointer to /compact.

On the main screen, the band above the prompt shows the rows as digit keys. Key 3 opens the test runs under it.

The /tldr history pane lists two kept cards, newest first.

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.

Reference

CommandDoes
/tldrHelp.
/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 contextOpen the context pane: each category that fills the window, with its tokens and share.
/tldr historyOpen the kept cards of this session, newest first.
/tldr copyCopy the latest reply as clean text: no trailing spaces, no shared indentation.
/tldr diffPrint 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.

SwitchOff means
cardClaude Code's own rows. Also turns off trust, keep and actions.
motionClaude Code's own spinner.
animationMotion keeps its colors but doesn't move.
contextNo context bar.
trustNo Check row, no amber border.
keepNothing saved, no note after /compact.
actionsNo 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.

  • No network. TL;DR makes no network calls and starts no processes.
  • Local only. It reads the tool calls and results of your session, and the files Claude edits, to build the net diff. The context bar uses Claude Code's local estimate of the window, so it sends no request. Kept cards stay in Claude Code's local plugin store: one list per session, at most 50 cards, without diff bodies.
  • Hooks that see other calls. It hooks 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.
  • Check it yourself. Before you install, run claude plugin validate on a clone. The hooks: and calls: lines list every event the mod handles and every API it calls.
  • Changes made through Bash count when they use a redirect, a heredoc, 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.
  • A cd counts inside one command (cd app && touch x), but not when it ran in an earlier call.
  • When grep or tail cuts 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.
  • Bulk deletes (find -delete, git clean -f, xargs rm) are flagged by command, not file by file.
  • A task runner that starts two test runners (make test that runs go test and then vitest) shows the first summary it finds, not the sum.
  • The custom spinner takes the place of Claude Code's spinner line, so the token count doesn't show. Switch motion off to get it back.
  • If Claude writes the same text twice in one turn, as a note and as the reply, it can fold away with the note. /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.

Contributing

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.

License

MIT © Kushal Banda

Source 26 files
hooks/register.tsx 569 lines
1// 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}
569
hooks/actions.ts 79 lines
1// 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}
79
hooks/classify.ts 314 lines
1// 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}
314
hooks/netdiff.ts 82 lines
1// 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}
82
hooks/draw/card.tsx 181 lines
1// 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}
181
hooks/draw/band.tsx 146 lines
1// 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}
146
hooks/draw/context.tsx 56 lines
1// 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}
56
hooks/context.ts 115 lines
1// 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}
115
hooks/progress.ts 42 lines
1// 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}
42
hooks/draw/compact-line.tsx 25 lines
1// 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}
25
hooks/draw/detail.tsx 223 lines
1// 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}
223
hooks/draw/diff.tsx 39 lines
1// 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