SLOPSHOPPER

TL;DR for Claude Code

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

panebandspinnerrowsguardcommand
★ 2v0.1.6MITupdated 2026-10-25kushalBanda/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 line of the TL;DR row for its detail, │ │ ▅▃▂▂▃▄▅▆▇ 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.
Spinner
▅▃▂▂▃▄▅▆▇ Thinking
README

TL;DR for Claude Code

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

License: MIT Version Claude Code No network calls

Listed on claudecodemods.com claudemods.dev claudemods.si

Left: a Claude Code turn as 33 lines of reads, edits and test logs. Right: the same turn as one line per tool group, then a three-line TL;DR row with an amber dot that flags the changed test file, the row keys and the prompt hint.

33 lines become 3. The dot turns amber and each edit that can fake a green run gets its own line. 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.

Features

A TL;DR row with an amber dot and four lines to check: a write outside the project, a deleted file, an added skip marker, and skipped hooks with a force push.

In fullscreen, the changes line is open under the row: files grouped by folder, with the first diff lines of auth.py.

A spinner reads "Running pytest". The TL;DR line above the prompt shows a half-filled ring, 38s of about 1m 12s last time, and the last result: pytest 3 failed 1m 30s ago.

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 TL;DR, the last turn's verdict and its rows as digit keys. Key 3 opens the test runs under it.

The /tldr history pane lists two kept rows with their replies, newest first.

Also: when a turn over 30 seconds ends, a notification carries its verdict, and the status line keeps it until your next prompt. /tldr copy copies the reply as clean text, and /tldr diff prints the net diff of the turn. On desktop and in VS Code, Claude Code draws its own rows. TL;DR is for the terminal.

Reference

CommandDoes
/tldrHelp.
/tldr show [row]Open what's behind a line of the latest row: changed, tests, check, ran, looked, notes. With no row, the steps of the turn in order. Escape closes it.
/tldr contextOpen the context pane: each category that fills the window, with its tokens and share.
/tldr historyOpen the kept rows 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, actions and notify.
motionClaude Code's own spinner, and no TL;DR line while a turn runs.
animationMotion keeps its colors but doesn't move. The band's clock moves only when a step lands.
contextNo context bar.
trustNo lines to check, no amber dot.
keepNothing saved, no note after /compact.
actionsNo copy or diff commands.
notifyNo notification and no status line when a long turn ends.

pytest, unittest, jest, vitest, Playwright, go test, gotestsum, cargo test and nextest, bun test, and deno test. It also reads npm, pnpm and yarn test, make test, just test and tox: it finds the summary of the runner they start. For go test, counts need -v, and a compile error reads ✗ build failed. Runs that only list tests (--collect-only, --listTests) don't count.

  • 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 rows stay in Claude Code's local plugin store: one list per session, at most 50 rows, without diff bodies. The same store keeps how long each Bash command took, at most 100 per project, for the band's ring.
  • Notifications. A turn over 30 seconds ends with a notification through your own Claude Code notification setting. The notify switch turns it off.
  • 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 row 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 for the turn in tests/fixtures/login-bug.ts.

Contributing

Bug reports, ideas and pull requests are welcome. Use the issue forms. A screenshot of the TL;DR row helps a lot. To report a security problem, see SECURITY.md.

License

MIT © Kushal Banda

Source 26 files
hooks/register.tsx 624 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, Timer } from 'claude-code'
5
6import type { Kept, Live, Plan, Turn } from '../types'
7
8import { addDiff, cleanCopy, diffOf, mergeNet, turnDiff } from './actions'
9import { classify, placePath } from './classify'
10import { unifiedDiff } from './netdiff'
11import { drawCard } from './draw/card'
12import { drawBand, keptNote, PLAN_BAR_KEY, PLAN_ROW_KEY, revealKeys } from './draw/band'
13import { CONTEXT_PANE, drawContextPane } from './draw/context'
14import { contextFill } from './context'
15import { doneText, jumpParts, nextJump, runLine } from './progress'
16import type { ContextFill } from './context'
17import { drawCompactLine } from './draw/compact-line'
18import { DETAIL_PANE, drawDetail, paneRows, paneTitle, sectionOf } from './draw/detail'
19import type { Section } from './draw/detail'
20import { drawHistory, HISTORY_PANE } from './draw/history'
21import { drawSpinner, SPINNER_KEY } from './draw/spinner'
22import { glyph } from './draw/theme'
23import { commandKey, duration, durationsKey, isDurations, remember, REMEMBER_MS } from './durations'
24import type { Durations } from './durations'
25import { readFeatures } from './features'
26import { encodeCells, fitsCells, planBarFrame, PULSE_MS, shimmerRowFrame, spinnerFrame } from './motion'
27import { applyTaskCall, EMPTY_PLAN, planView, TASK_TOOLS, taskLabel } from './plan'
28import type { SpinnerMode } from './motion'
29import { isTurnList, KEPT_MAX, keptKey, MAX_TURNS, turnForCalls, turnForDuration } from './state'
30import { activityWord, isMeta, logLines, runName } from './summary'
31import { verdictOf } from './steps'
32import { checkTurn } from './trust'
33import { endTurn, isInterim, isReply, recordCall, recordStep, startTurn, toKept } from './turn'
34
35const HELP = 'TL;DR is on. Click a line of the TL;DR row for its detail, or type its digit at the empty prompt, or /tldr show [changed|tests|check|ran|looked] · /tldr context · /tldr history · /tldr copy · /tldr diff'
36
37// The frame loop repaints the spinner between renders (module state: a reload restarts it).
38const FRAME_MS = 80
39let frameMs = 0
40let loop: Timer | undefined
41let spinnerSite: { requestId: string; mode: SpinnerMode } | null = null
42// The band's stepper as last drawn, for the loop to repaint its bar and running row; doneFrame is when the last task was done.
43let planSite: { requestId: string; done: number; total: number; barColumns: number; row: string | null; rowColumns: number } | null = null
44let doneFrame = -Infinity
45// The band's clock: once a second while a turn runs, so the running step's time and the ring move between calls.
46const TICK_MS = 1000
47let tick: Timer | undefined
48// A finished turn at least this long sends a notification and leaves its verdict on the status line.
49const LONG_TURN_MS = 30_000
50let statusShown = false
51// What the detail pane shows: the turn and the row pressed last (or /tldr show).
52let detailFor: { turnId: string; section: Section | 'all' } | null = null
53// Fullscreen: the row whose detail is open inside its card. One at a time; the same press closes it.
54let inlineFor: { turnId: string; section: Section | 'all' } | null = null
55// The context window by category, as the band's bar draws it: measured after each request (a local estimate).
56let contextNow: ContextFill | null = null
57let measuring = false
58// A measure asked while one runs: that one may read the transcript from before a change, so it runs again after.
59let measureAgain = false
60const COMPACT_SETTLE_MS = 250
61// Each edited file's text before the open turn's first edit of it, for the turn's net diff (null: too large).
62let openTurnId: string | null = null
63let befores = new Map<string, string | null>()
64const BEFORE_MAX = 256 * 1024
65const EDIT_TOOLS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
66
67function startLoop($: EngineInterface) {
68  if (loop !== undefined) return
69  loop = $.clock.every(FRAME_MS, () => {
70    frameMs += FRAME_MS
71    const spin = spinnerSite
72    if (spin !== null)
73      void $.ui.blit({ requestId: spin.requestId, key: SPINNER_KEY, cells: encodeCells(spinnerFrame(spin.mode, frameMs)) })
74        .then(r => { if (r.deny !== undefined && spinnerSite === spin) spinnerSite = null })
75        .catch(() => {})
76    const p = planSite
77    if (p !== null) {
78      void $.ui.blit({ requestId: p.requestId, key: PLAN_BAR_KEY, cells: encodeCells(planBarFrame(p.done, p.total, frameMs - doneFrame, frameMs, p.barColumns)) })
79        .then(r => { if (r.deny !== undefined && planSite === p) planSite = null })
80        .catch(() => {})
81      if (p.row !== null) void $.ui.blit({ requestId: p.requestId, key: PLAN_ROW_KEY, cells: encodeCells(shimmerRowFrame(p.row, frameMs, p.rowColumns)) }).catch(() => {})
82    }
83  })
84}
85
86function endLoop() {
87  loop?.cancel()
88  loop = undefined
89  spinnerSite = null
90  planSite = null
91}
92
93export const register: Register = (on, options) => {
94  const f = readFeatures(options)
95
96  on('session.start', async ($, e, next) => {
97    const r = await next(e)
98    await $.command
99      .register({ name: 'tldr', description: 'TL;DR for Claude Code: turn cards', argumentHint: '[show|context|history|copy|diff]' })
100      .catch(() => {})
101    if (f.context) await measureContext($)
102    // A resumed session: its earlier cards are on disk. They also stand in for the turns, so rows redrawn after a restart find their card.
103    if (f.keep) {
104      const list = await loadKept($).catch(() => [])
105      if (list.length > 0) {
106        await seedTurns($, list).catch(() => {})
107        await setKept($, () => ({ count: list.length, note: true })).catch(() => {})
108      }
109    }
110    return r
111  })
112
113  // A turn of the main conversation opens a card; subagents fill their own windows and are left out.
114  on('turn.start', async ($, e, next) => {
115    const r = await next(e)
116    if (e.agentId === undefined) {
117      openTurnId = e.turnId
118      befores = new Map()
119      const now = await $.clock.now()
120      await openTurn($, startTurn(e.turnId, e.text, now))
121      if (f.keep) await setKept($, k => ({ ...k, note: false })).catch(() => {})
122      // The last long turn's verdict stays on the status line until the next prompt.
123      if (statusShown) {
124        $.ui.status(undefined)
125        statusShown = false
126      }
127      if (f.motion) {
128        await setLive($, () => ({ phase: 'working', word: 'Thinking', running: null, since: now })).catch(() => {})
129        if (f.animation) {
130          startLoop($)
131          tick ??= $.clock.every(TICK_MS, () => $.ui.invalidate('ui.render'))
132        }
133      }
134    }
135    return r
136  })
137
138  // Observe only: the engine's result goes back unchanged.
139  on('tool.call', async ($, e, next) => {
140    // Link the call to the turn first: the engine draws its group while the call still runs.
141    const startedAt = await $.clock.now()
142    const command = e.tool === 'Bash' ? commandKey(String((e as { command?: unknown }).command ?? '')) : null
143    if (e.agentId === undefined) {
144      await updateCurrent($, t => recordCall(t, e.tool_use_id, [])).catch(() => {})
145      if (f.motion) {
146        // The band's TL;DR line names the new job, and a command it has timed before gets a ring against last time.
147        const lastMs = command !== null ? (await loadDurations($))[command] ?? null : null
148        const running = { id: e.tool_use_id, label: activityWord(e.tool, e), startedAt, isCommand: command !== null, lastMs }
149        await setLive($, l => ({ ...l, word: running.label, running })).catch(() => {})
150        $.ui.invalidate('ui.render')
151      }
152      const path = editPath(e.tool, e)
153      if (f.actions && path !== undefined && !befores.has(path)) {
154        // Missing (ENOENT) is a new file, empty before; any other failure leaves the net diff unknown.
155        const text = await $.fs.read(path).catch((err: unknown) => (/ENOENT|not found|no such file/i.test(String(err)) ? '' : null))
156        befores.set(path, typeof text === 'string' && text.length <= BEFORE_MAX ? text : null)
157      }
158    }
159    const ran = await next(e).finally(async () => {
160      if (e.agentId !== undefined || !f.motion) return
161      const endedAt = await $.clock.now()
162      await setLive($, l => (l.running?.id === e.tool_use_id ? { ...l, word: 'Thinking', running: null, since: endedAt } : l)).catch(() => {})
163    })
164    if (e.agentId !== undefined || ran.deny !== undefined) return ran
165    // How long this command took, for the ring next time it runs.
166    if (command !== null && command !== '' && f.motion) {
167      const ms = (await $.clock.now()) - startedAt
168      if (ms >= REMEMBER_MS) await saveDuration($, command, ms).catch(() => {})
169    }
170    // Claude's own task list feeds the band's stepper.
171    if (f.card && f.motion && TASK_TOOLS.includes(e.tool) && ran.isError !== true && openTurnId !== null) {
172      const before = await readPlan($)
173      const after = applyTaskCall(before, e.tool, e, ran.result, openTurnId, await $.clock.now())
174      if (after !== before) {
175        await setPlan($, () => after).catch(() => {})
176        if (after.done !== before.done) {
177          doneFrame = frameMs
178          // The done task's highlight ends on the next draw after it runs out.
179          $.clock.after(PULSE_MS + 50, () => $.ui.invalidate('ui.render'))
180        }
181        $.ui.invalidate('ui.render')
182      }
183    }
184    const root = await $.session.root().catch(() => undefined)
185    const calls = classify(e.tool, e, ran.result, ran.isError === true, root)
186    const meta = isMeta(e.tool)
187    const lines = meta ? [] : logLines(e.tool, calls, e)
188    const edit = f.actions ? diffOf(e.tool, e, ran.result, ran.isError === true) : null
189    const diff = edit === null ? null : { ...edit, path: placePath(edit.path, root).path }
190    const now = await $.clock.now()
191    await updateCurrent($, t => {
192      const called = recordCall(t, e.tool_use_id, calls, lines, meta ? undefined : runName(e.tool, e))
193      const recorded = diff === null ? called : { ...called, diffs: addDiff(called.diffs, diff) }
194      const done = f.trust ? { ...recorded, flags: checkTurn(recorded) } : recorded
195      // A test result or a new flag is the band's "last" until the next one.
196      return { ...done, jump: nextJump(t, done, now) ?? t.jump }
197    }).catch(() => {})
198    // The band's TL;DR line counts the finished call.
199    if (f.motion) $.ui.invalidate('ui.render')
200    return ran
201  }).catch((_$, e, next) => next(e)) // a failure here must never stop or change a tool call
202
203  // A finished response: text before a tool call folds into the card; text with no tool call is the reply.
204  on('turn.step', async function* ($, e, next) {
205    const r = yield* next(e)
206    if (e.agentId === undefined) {
207      await updateTurn($, r.turnId, t => recordStep(t, r.answer, r.toolUses.length > 0)).catch(() => {})
208      if (f.context) void measureContext($)
209    }
210    return r
211  })
212
213  on('turn.complete', async ($, e, next) => {
214    const r = await next(e)
215    if (e.agentId === undefined) {
216      const now = await $.clock.now()
217      const net = f.actions && openTurnId === e.turnId ? await netDiffs($) : []
218      await closeCurrent($, t => endTurn({ ...t, diffs: mergeNet(t.diffs, net) }, now)).catch(() => {})
219      openTurnId = null
220      befores = new Map()
221      if (f.keep) {
222        const t = await latestTurn($)
223        if (t !== null && (t.toolUseIds.length > 0 || t.reply !== null)) {
224          const count = await saveKept($, toKept(t)).catch(() => null)
225          if (count !== null) await setKept($, k => ({ ...k, count })).catch(() => {})
226        }
227      }
228      if (f.motion) await setLive($, () => ({ phase: 'idle', word: '', running: null, since: now })).catch(() => {})
229      endLoop()
230      tick?.cancel()
231      tick = undefined
232      if (f.notify) await tellDone($, now)
233    }
234    return r
235  })
236
237  on('command.run', { command: 'tldr' }, async ($, e) => {
238    const sub = e.args.trim().toLowerCase()
239    if (sub === 'context') {
240      if (!f.context) return { text: 'TL;DR: the context bar is off. Turn it on in /plugin, TL;DR options.' }
241      await openContext($)
242      return { text: contextNow === null ? 'TL;DR: no context figures yet.' : `TL;DR: context opened, ${contextNow.percent}% used. Escape closes it.` }
243    }
244    if (!f.card) return { text: 'TL;DR: the turn card is off. Turn it on in /plugin, TL;DR options.' }
245    // One card, one detail: `show` opens what sits behind a row. `expand` is kept as a word for it.
246    const [verb = '', arg = ''] = sub.split(/\s+/)
247    if (verb === 'show' || verb === 'expand') {
248      const section = sectionOf(arg)
249      if (section === null) return { text: `TL;DR: no row "${arg}". Rows: changed, tests, check, ran, looked.` }
250      const t = await lastTurn($, f.keep)
251      if (t === null) return { text: 'TL;DR: no turn yet.' }
252      await showDetail($, t, section)
253      const what = section === 'all' ? `the whole turn "${t.goal}"` : paneTitle(t, section).replace(/^TL;DR · /, '')
254      return { text: `TL;DR: ${what} opened. Escape closes it.` }
255    }
256    if (verb === 'collapse' || verb === 'hide') {
257      await $.ui.close({ id: DETAIL_PANE }).catch(() => {})
258      return { text: 'TL;DR: detail closed.' }
259    }
260    if (sub === 'history' && !f.keep) return { text: 'TL;DR: keeping cards is off. Turn it on in /plugin, TL;DR options.' }
261    if (sub === 'history') {
262      const n = (await loadKept($).catch(() => [])).length
263      await $.ui.open({ id: HISTORY_PANE, title: 'TL;DR history' })
264      return { text: `TL;DR: history opened, ${n} card${n === 1 ? '' : 's'} kept.` }
265    }
266    if (f.actions && (sub === 'copy' || sub === 'diff')) {
267      const t = await lastTurn($, f.keep)
268      if (t === null) return { text: 'TL;DR: no turn yet.' }
269      if (sub === 'copy') return { text: await copyReply($, t) }
270      const diff = turnDiff(t)
271      return { text: diff === '' ? `TL;DR: no edits kept for "${t.goal}".` : diff }
272    }
273    return { text: HELP }
274  })
275
276  // The card takes the place of the turn's tool rows: the turn's first group draws it, later groups draw nothing.
277  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
278    if (!f.card || e.surface !== 'terminal') return next(e)
279    const ids = e.props.calls.map(c => c.tool_use_id).filter((id): id is string => id !== undefined)
280    const found = turnForCalls(await allTurns($), ids)
281    if (found === null) return next(e)
282    // Main screen: printed rows cannot change, so each group is one line; the card follows at turn end.
283    if (e.viewport?.isFullscreen === false) return e.props.isExpanded ? next(e) : drawCompactLine($.ui.resolve(e), e.props.calls, await $.session.root().catch(() => undefined))
284    // A click on the group expands it, and its rows are ToolUse rows that would draw the card again: the card stays the one view.
285    const { Box } = $.ui.resolve(e)
286    if (!found.isFirst) return <Box key="tldr-folded" />
287    const t = found.turn
288    return drawCard($.ui.resolve(e), t, { isExpanded: e.props.isExpanded, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
289  })
290
291  // A call the transcript does not fold into a group (Bash, Edit, Write, ...) is its own row: the same rule as a group.
292  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
293    if (!f.card || e.surface !== 'terminal') return next(e)
294    const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
295    if (found === null) return next(e)
296    const t = found.turn
297    if (e.viewport?.isFullscreen === false) return drawCompactLine($.ui.resolve(e), [e.props], await $.session.root().catch(() => undefined))
298    const { Box } = $.ui.resolve(e)
299    if (!found.isFirst) return <Box key="tldr-folded" />
300    // A standalone row has no ctrl+o of its own: the card stands for it, and a press opens the detail.
301    return drawCard($.ui.resolve(e), t, { isExpanded: false, show: sec => toggleInline($, t, sec), open: inlineOpen($, e, t) })
302  })
303
304  // The result block under a standalone row: folded with its row.
305  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
306    if (!f.card || e.surface !== 'terminal') return next(e)
307    const found = turnForCalls(await allTurns($), [e.props.tool_use_id])
308    if (found === null) return next(e)
309    const { Box } = $.ui.resolve(e)
310    return <Box key="tldr-folded-result" />
311  })
312
313  // In-between text folds into the card in fullscreen; the main screen keeps what it printed.
314  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
315    if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen === false) return next(e)
316    // A block that is also some turn's reply is ambiguous (a later answer can repeat earlier in-between text): it stays.
317    const recent = (await allTurns($)).slice(-50)
318    if (!recent.some(t => isInterim(t, e.props.text)) || recent.some(t => isReply(t, e.props.text))) return next(e)
319    const { Box } = $.ui.resolve(e)
320    return <Box key="tldr-folded-text" />
321  })
322
323  // The mode-aware spinner, in place of the engine's spinning word.
324  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
325    if (!f.motion || e.surface !== 'terminal') return next(e)
326    const l = await readLive($)
327    const t = await latestTurn($)
328    const now = await $.clock.now()
329    const word = e.props.message ?? (l.phase !== 'idle' && l.word ? l.word : e.props.word)
330    // Any spinner moves, not only a turn's: /compact draws one with no turn open.
331    if (f.animation) {
332      spinnerSite = { requestId: e.requestId, mode: e.props.mode as SpinnerMode }
333      startLoop($)
334    }
335    return drawSpinner($.ui.resolve(e as RenderInput<'Spinner', 'terminal'>), e, word, t !== null && t.endedAt === null ? now - t.startedAt : null, frameMs)
336  })
337
338  // The band above the prompt: the kept-cards note and the row keys; what other mods draw there stays, above it.
339  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
340    const rest = await next(e)
341    if (e.surface !== 'terminal' || e.props.hasSurvey) return rest
342    const k = f.keep ? await readKept($) : { count: 0, note: false }
343    const note = k.note && k.count > 0 ? keptNote(k.count) : null
344    // On the main screen the printed card can't take a press, so its rows sit on the band as digit keys; a key opens its row's detail on the band.
345    const t = f.card && e.viewport?.isFullscreen === false && !e.props.isWorking ? await lastTurn($, f.keep) : null
346    const reveal = t !== null && t.endedAt !== null
347      ? { turnId: t.id, verdict: verdictOf(t), keys: revealKeys(t), show: (s: Section | 'all') => toggleInline($, t, s), open: inlineFor?.turnId === t.id ? { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), t, inlineFor.section) } : null }
348      : null
349    const context = f.context && contextNow !== null ? { fill: contextNow, open: () => void openContext($) } : null
350    // While a turn runs: what runs now against last time, and the last thing that happened. The tick redraws it each second.
351    const open = f.card && f.motion && e.props.isWorking ? await latestTurn($) : null
352    const running = open !== null && open.endedAt === null
353    const now = running ? await $.clock.now() : 0
354    const l = running ? await readLive($) : null
355    const run = l !== null ? runLine({ word: l.word, running: l.running, since: l.since, now, turn: open! }) : null
356    const plan = running ? await planParts($, e.requestId, open!.id, e.props.bodyColumns, f.animation) : null
357    if (plan === null && planSite?.requestId === e.requestId) planSite = null
358    if (note === null && reveal === null && context === null && run === null) return rest
359    return drawBand($.ui.resolve(e as RenderInput<'AbovePrompt', 'terminal'>), e, rest ?? null, { note, reveal, context, run, plan, last: running ? jumpParts(open!.jump, now) : [] })
360  })
361
362  // After a compaction the earlier rows are gone from the screen; the band points to the kept cards.
363  // While it runs, the spinner moves and says so: a compaction can take a minute. A precompute runs unseen and is left out.
364  on('session.compact', async ($, e, next) => {
365    const shown = f.motion && e.agentId === undefined && e.trigger !== 'precompute'
366    if (shown) {
367      await setLive($, l => ({ ...l, phase: 'compacting', word: 'Compacting' })).catch(() => {})
368      if (f.animation) startLoop($)
369    }
370    const r = await next(e).finally(async () => {
371      if (!shown) return
372      // An automatic compaction can fall inside a turn: the turn goes on working.
373      const inTurn = openTurnId !== null
374      await setLive($, l => ({ ...l, phase: inTurn ? 'working' : 'idle', word: inTurn ? 'Thinking' : '' })).catch(() => {})
375      if (!inTurn) endLoop()
376    })
377    if (f.keep && e.agentId === undefined && 'messages' in r) await setKept($, k => ({ ...k, note: k.count > 0 })).catch(() => {})
378    // The engine puts the compacted messages in place after the chain returns: measured now, the bar keeps the old figures.
379    if (f.context && e.agentId === undefined && 'messages' in r && e.trigger !== 'precompute') $.clock.after(COMPACT_SETTLE_MS, () => void measureContext($))
380    return r
381  })
382
383  on('ui.render', { component: 'Pane', requestId: CONTEXT_PANE }, async ($, e) => drawContextPane($.ui.resolve(e as RenderInput<'Pane', 'terminal'>), contextNow))
384  on('ui.render', { component: 'Pane', requestId: HISTORY_PANE }, async ($, e) => drawHistory($.ui.resolve(e), await loadKept($).catch(() => [])))
385
386  on('ui.render', { component: 'Pane', requestId: DETAIL_PANE }, async ($, e) => {
387    const all = [...(await allTurns($)), ...(f.keep ? await loadKept($).catch(() => []) : [])]
388    return drawDetail($.ui.resolve(e), detailFor === null ? null : all.find(t => t.id === detailFor!.turnId) ?? null, detailFor?.section ?? 'all')
389  })
390
391  // Main screen: the full card takes the place of the turn's closing line ("Baked for 52s").
392  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
393    if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen !== false) return next(e)
394    const t = await durationTurn($, e.requestId, e.props.durationMs)
395    if (t === null) return next(e)
396    return drawCard($.ui.resolve(e), t, { isExpanded: false, durationMs: e.props.durationMs })
397  })
398
399  // Main screen: the line under the prompt says the latest card's rows open by digit, while the prompt is empty and Claude idle.
400  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
401    if (!f.card || e.surface !== 'terminal' || e.viewport?.isFullscreen !== false || e.props.isDraft || e.props.isWorking) return next(e)
402    const t = await lastTurn($, f.keep)
403    if (t === null || t.endedAt === null) return next(e)
404    const keys = revealKeys(t)
405    const range = keys.length === 1 ? keys[0]!.hotkey : `${keys[0]!.hotkey}–${keys.at(-1)!.hotkey}`
406    return next({ ...e, props: { ...e.props, tail: ` · type ${range} to open the last card` } })
407  })
408
409}
410
411/** The stepper's first frame, and the site the frame loop repaints; null when the open turn has no task list. */
412async function planParts($: EngineInterface, requestId: string, turnId: string, bodyColumns: number, animation: boolean) {
413  const plan = await readPlan($)
414  const view = planView(plan, turnId)
415  if (view === null) return null
416  // With animation off nothing repaints: the bar holds still and the running task is plain text.
417  const moving = animation && loop !== undefined
418  const barColumns = Math.max(10, Math.min(24, Math.floor(bodyColumns / 5)))
419  const label = view.current !== null ? taskLabel(view.current) : null
420  const row = moving && label !== null && fitsCells(label) ? label : null
421  const rowColumns = row === null ? 0 : Math.max(1, Math.min(bodyColumns, [...row].length + 4))
422  planSite = moving ? { requestId, done: view.done, total: view.total, barColumns, row, rowColumns } : null
423  const now = await $.clock.now()
424  return {
425    view,
426    barCells: encodeCells(planBarFrame(view.done, view.total, frameMs - doneFrame, frameMs, barColumns, moving)),
427    barColumns,
428    rowCells: row === null ? null : encodeCells(shimmerRowFrame(row, frameMs, rowColumns, moving)),
429    rowColumns,
430    justDone: plan.done !== null && now - plan.done.at < PULSE_MS ? plan.done.id : null,
431  }
432}
433
434/** The newest turn in this session; after a resume, the newest kept card. */
435async function lastTurn($: EngineInterface, keep: boolean): Promise<Turn | null> {
436  const t = await latestTurn($)
437  if (t !== null || !keep) return t
438  return (await loadKept($).catch(() => [])).at(-1) ?? null
439}
440
441async function copyReply($: EngineInterface, t: Turn): Promise<string> {
442  if (t.reply === null) return `TL;DR: "${t.goal}" has no reply to copy.`
443  const r = await $.ui.copy({ text: cleanCopy(t.reply) })
444  return r.isCopied ? 'TL;DR: reply copied.' : `TL;DR: could not copy the reply (${r.reason}).`
445}
446
447/** A long turn that just ended: a notification with its verdict, and the same on the status line until the next prompt. */
448async function tellDone($: EngineInterface, now: number) {
449  const t = await latestTurn($)
450  if (t === null || t.toolUseIds.length === 0 || now - t.startedAt < LONG_TURN_MS) return
451  const v = verdictOf(t)
452  const mark = v === 'pass' || v === 'fail' || v === 'check' ? ` ${glyph[v]}` : ''
453  $.ui.status(`TL;DR${mark} done ${duration(now - t.startedAt)}`)
454  statusShown = true
455  await $.ui.notify(doneText(t, now - t.startedAt), { title: 'TL;DR' }).catch(() => {})
456}
457
458/** Opens the detail pane behind a card row: small, holding the keys, closed by Escape. */
459async function showDetail($: EngineInterface, t: Turn, section: Section | 'all') {
460  detailFor = { turnId: t.id, section }
461  await $.ui.open({ id: DETAIL_PANE, title: paneTitle(t, section), focus: true, closeOnEscape: true, rows: paneRows(t, section) })
462  $.ui.invalidate('ui.render')
463}
464
465/** A press opens a row's detail where it was pressed (inside the fullscreen card, on the main screen's band), and the same press closes it. */
466function toggleInline($: EngineInterface, t: Turn, section: Section | 'all') {
467  inlineFor = inlineFor?.turnId === t.id && inlineFor.section === section ? null : { turnId: t.id, section }
468  $.ui.invalidate('ui.render')
469}
470
471function inlineOpen($: EngineInterface, e: RenderInput<'ToolGroup'> | RenderInput<'ToolUse'>, t: Turn) {
472  if (inlineFor?.turnId !== t.id) return undefined
473  return { section: inlineFor.section, detail: drawDetail($.ui.resolve(e), t, inlineFor.section) }
474}
475
476/** The context window by category, as /context counts it but estimated locally, so it costs no request. */
477async function measureContext($: EngineInterface) {
478  if (measuring) {
479    measureAgain = true
480    return
481  }
482  measuring = true
483  measureAgain = false
484  try {
485    const u = await $.session.usage({ breakdown: 'summary' })
486    const b = u.context.breakdown
487    if (b !== undefined && b.rawMaxTokens > 0) {
488      contextNow = contextFill(b)
489      $.ui.invalidate('ui.render')
490    }
491  } catch {
492    // No figures: the bar keeps its last ones.
493  } finally {
494    measuring = false
495  }
496  if (measureAgain) await measureContext($)
497}
498
499async function openContext($: EngineInterface) {
500  await measureContext($)
501  await $.ui.open({ id: CONTEXT_PANE, title: 'TL;DR context', focus: true, closeOnEscape: true, rows: (contextNow?.slices.length ?? 1) + (contextNow?.setup.length ?? 0) + 3 })
502}
503
504function editPath(tool: string, input: unknown): string | undefined {
505  if (!EDIT_TOOLS.includes(tool)) return undefined
506  const i = input as { file_path?: unknown; notebook_path?: unknown }
507  return typeof i.file_path === 'string' ? i.file_path : typeof i.notebook_path === 'string' ? i.notebook_path : undefined
508}
509
510/** The open turn's edited files as net diffs: their text now against their text before the turn's first edit. */
511async function netDiffs($: EngineInterface): Promise<{ path: string; lines: string[] | null }[]> {
512  const root = await $.session.root().catch(() => undefined)
513  const out: { path: string; lines: string[] | null }[] = []
514  for (const [abs, before] of befores) {
515    const path = placePath(abs, root).path
516    if (before === null) {
517      out.push({ path, lines: null })
518      continue
519    }
520    const after = await $.fs.read(abs).catch(() => null)
521    out.push({ path, lines: typeof after === 'string' ? unifiedDiff(before, after) : null })
522  }
523  return out
524}
525
526// State: TL;DR's atoms, and every read and write of them and of the kept cards.
527
528const turns = atom({ plugin: 'tldr', key: 'turns' } as const, [] as Turn[])
529const IDLE: Live = { phase: 'idle', word: '', running: null, since: 0 }
530const live = atom({ plugin: 'tldr', key: 'live' } as const, IDLE)
531const kept = atom({ plugin: 'tldr', key: 'kept' } as const, { count: 0, note: false } as Kept)
532const current = atom({ plugin: 'tldr', key: 'current' } as const, null as string | null)
533const planAtom = atom({ plugin: 'tldr', key: 'plan' } as const, EMPTY_PLAN as Plan)
534
535async function readPlan($: EngineInterface): Promise<Plan> {
536  return read($, planAtom)
537}
538
539async function setPlan($: EngineInterface, fn: (p: Plan) => Plan): Promise<void> {
540  await update($, planAtom, p => fn(p ?? EMPTY_PLAN))
541}
542
543async function openTurn($: EngineInterface, t: Turn): Promise<void> {
544  // A turn that never completed (interrupted) ends where the next one starts, so its card stops saying "running".
545  await update($, turns, list => [...(list ?? []).filter(x => x.id !== t.id).map(x => (x.endedAt === null ? { ...x, endedAt: t.startedAt } : x)), t].slice(-MAX_TURNS))
546  await update($, current, () => t.id)
547}
548
549async function updateCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
550  const id = await read($, current)
551  if (id === null) return
552  await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
553}
554
555async function updateTurn($: EngineInterface, id: string, fn: (t: Turn) => Turn): Promise<void> {
556  await update($, turns, list => (list ?? []).map(t => (t.id === id ? fn(t) : t)))
557}
558
559async function closeCurrent($: EngineInterface, fn: (t: Turn) => Turn): Promise<void> {
560  await updateCurrent($, fn)
561  await update($, current, () => null)
562}
563
564// Drawing is pure, so the pins live in the module, like the spinner's site. A hot reload starts them over.
565const durationPins = new Map<string, string>()
566
567async function durationTurn($: EngineInterface, site: string, durationMs: number): Promise<Turn | null> {
568  const t = turnForDuration(await read($, turns), durationPins, site, durationMs, await $.clock.now())
569  if (t !== null) durationPins.set(site, t.id)
570  return t
571}
572
573async function latestTurn($: EngineInterface): Promise<Turn | null> {
574  const list = await read($, turns)
575  return list.at(-1) ?? null
576}
577
578async function allTurns($: EngineInterface): Promise<Turn[]> {
579  return read($, turns)
580}
581
582async function readLive($: EngineInterface): Promise<Live> {
583  return read($, live)
584}
585
586async function setLive($: EngineInterface, fn: (l: Live) => Live): Promise<void> {
587  await update($, live, l => fn(l ?? IDLE))
588}
589
590async function loadDurations($: EngineInterface): Promise<Durations> {
591  const v = await $.store.get(durationsKey(await $.session.root().catch(() => undefined))).catch(() => undefined)
592  return isDurations(v) ? v : {}
593}
594
595async function saveDuration($: EngineInterface, command: string, ms: number): Promise<void> {
596  const key = durationsKey(await $.session.root().catch(() => undefined))
597  await $.store.set(key, remember(await loadDurations($), command, ms))
598}
599
600async function loadKept($: EngineInterface): Promise<Turn[]> {
601  const v = await $.store.get(keptKey(await $.session.id()))
602  return isTurnList(v) ? v : []
603}
604
605async function saveKept($: EngineInterface, t: Turn): Promise<number> {
606  const list = (await loadKept($)).filter(x => x.id !== t.id)
607  const next = [...list, t].slice(-KEPT_MAX)
608  await $.store.set(keptKey(await $.session.id()), next)
609  return next.length
610}
611
612/** A restarted session (a resume, a /tui switch) starts with no turns in memory: the kept cards stand in, so its old rows draw cards again. */
613async function seedTurns($: EngineInterface, list: readonly Turn[]): Promise<void> {
614  await update($, turns, now => ((now ?? []).length > 0 ? now ?? [] : list.slice(-MAX_TURNS)))
615}
616
617async function readKept($: EngineInterface): Promise<Kept> {
618  return read($, kept)
619}
620
621async function setKept($: EngineInterface, fn: (k: Kept) => Kept): Promise<void> {
622  await update($, kept, k => fn(k ?? { count: 0, note: false }))
623}
624
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 129 lines
1// The turn card, as one of Claude Code's own rows: "⏺ TL;DR <goal> · 52s", then under ⎿ what changed and how the
2// tests went, one line per thing to check. The dot's color is the verdict. No box, no labels.
3import type { Elements, RenderChildren, RenderSurface } from 'claude-code'
4
5import type { Change, Turn } from '../../types'
6import { shortPath } from '../classify'
7import { duration } from '../durations'
8import { byFolder, changeTotals, verdictOf } from '../steps'
9import { ranLabel, testLabel } from '../summary'
10import { sectionTitle } from './detail'
11import type { Section } from './detail'
12import { color, glyph, verdictColor } from './theme'
13
14export function lookedLabel(t: Turn): string {
15  const parts: string[] = []
16  if (t.reads.length > 0) parts.push(`${t.reads.length} file${t.reads.length === 1 ? '' : 's'}`)
17  if (t.searches.length > 0) parts.push(`${t.searches.length} search${t.searches.length === 1 ? '' : 'es'}`)
18  return parts.length > 0 ? parts.join(', ') : 'nothing'
19}
20
21export type CardOptions = {
22  /** An expanded fullscreen group (a click, ctrl+o): the card adds Claude's notes. */
23  isExpanded: boolean
24  /** The engine's own measure of the turn, where it gives one (TurnDuration). */
25  durationMs?: number
26  /** Opens or closes the detail behind a line, where presses reach the mod (fullscreen). The goal opens the steps. Absent: lines are plain text. */
27  show?: (s: Section | 'all') => void
28  /** The line whose detail is open inside the card, and that detail drawn. */
29  open?: { section: Section | 'all'; detail: RenderChildren }
30}
31
32const FOLDERS_SHOWN = 3 // more read as "+N folders"
33
34const dirName = (dir: string) => (dir === '' ? './' : dir.replace(/\/$/, ''))
35
36export function drawCard(ui: Elements[RenderSurface], t: Turn, o: CardOptions) {
37  const { Box, Button, Text } = ui
38  const ms = o.durationMs ?? (t.endedAt === null ? null : t.endedAt - t.startedAt)
39  const pressable = (s: Section | 'all', children: RenderChildren, n = 0) =>
40    o.show !== undefined ? (
41      <Button key={`tldr-show:${s}:${t.id}:${n}`} plain onPress={() => o.show!(s)}>{children}</Button>
42    ) : (
43      <Text>{children}</Text>
44    )
45  // The gutter: ⎿ on the first line under the dot, blank under it, as Claude Code draws a tool's result.
46  const line = (first: boolean, children: RenderChildren, key: string) => (
47    <Box key={key} flexDirection="row">
48      <Box width={glyph.elbow.length} flexShrink={0}>
49        <Text color={color.dim}>{first ? glyph.elbow : ''}</Text>
50      </Box>
51      <Box flexDirection="row" flexWrap="wrap" flexGrow={1}>{children}</Box>
52    </Box>
53  )
54  const sep = <Text color={color.dim}>{' · '}</Text>
55  const counts = (added: number, removed: number) => (
56    <Text><Text color={color.added}>{`+${added}`}</Text>{' '}<Text color={color.removed}>{`${glyph.minus}${removed}`}</Text></Text>
57  )
58
59  // One file reads as itself; more read as their size and folders, so a 60-file refactor is as short as a 2-file fix. Key 2 has each file.
60  const one = (c: Change) =>
61    c.isDelete ? <Text color={color.check}>{`${shortPath(c.path)} deleted`}</Text>
62    : c.viaBash ? <Text>{shortPath(c.path)}<Text color={color.dim}> via Bash</Text></Text>
63    : <Text>{`${shortPath(c.path)} `}{counts(c.added, c.removed)}</Text>
64  const changed = (): RenderChildren => {
65    if (t.changes.length === 1) return one(t.changes[0]!)
66    const total = changeTotals(t.changes)
67    const size = <Text>{`${total.files} files `}{total.counted ? counts(total.added, total.removed) : <Text color={color.dim}>via Bash</Text>}</Text>
68    const folders = byFolder(t.changes)
69    const more = folders.length - FOLDERS_SHOWN
70    return (
71      <Text>
72        {size}
73        <Text color={color.dim}>{' in '}</Text>
74        {folders.slice(0, FOLDERS_SHOWN).map(f => dirName(f.dir)).join(', ')}
75        {more > 0 ? <Text color={color.dim}>{` +${more} folder${more === 1 ? '' : 's'}`}</Text> : ''}
76      </Text>
77    )
78  }
79
80  // The first run and the last: where the turn's tests started and where they ended. One runner names itself once.
81  const tests = (): RenderChildren => {
82    const first = t.testRuns[0]!
83    const last = t.testRuns.at(-1)!
84    const a = testLabel(first)
85    if (t.testRuns.length === 1) return <Text color={a.ok ? color.pass : color.fail}>{a.text}</Text>
86    const b = testLabel(last)
87    return (
88      <Text>
89        <Text color={a.ok ? color.pass : color.fail}>{a.text}</Text>
90        <Text color={color.dim}>{` ${glyph.arrow} `}</Text>
91        <Text color={b.ok ? color.pass : color.fail}>{first.runner === last.runner ? b.result : b.text}</Text>
92      </Text>
93    )
94  }
95
96  // What the turn did, in one line: its changes and its tests. A turn with neither says what it read or ran.
97  const facts: RenderChildren[] = []
98  if (t.changes.length > 0) facts.push(pressable('changed', changed()))
99  if (t.testRuns.length > 0) facts.push(pressable('tests', tests()))
100  else if (t.changes.length > 0) facts.push(pressable('tests', <Text color={color.dim}>tests not run</Text>))
101  if (facts.length === 0 && (t.ran ?? []).length > 0) facts.push(pressable('ran', `ran ${ranLabel(t.ran ?? [])}`))
102  if (facts.length === 0) facts.push(pressable('looked', `read ${lookedLabel(t)}`))
103
104  const lines = [
105    facts.flatMap((f, i) => (i > 0 ? [sep, f] : [f])),
106    ...t.flags.map((fl, i) => pressable('check', <Text color={color.check}>{`${glyph.check} ${fl.detail}`}</Text>, i)),
107    ...(o.isExpanded ? t.interim.map(n => <Text color={color.dim}>{n}</Text>) : []),
108  ]
109
110  const goal = <Text wrap="truncate-end">{t.goal}</Text>
111  return (
112    <Box key="tldr-card" flexDirection="column">
113      <Box flexDirection="row">
114        <Text color={verdictColor[verdictOf(t)]}>{`${glyph.dot} `}</Text>
115        <Text bold>{'TL;DR '}</Text>
116        {o.show !== undefined ? <Button key={`tldr-show:all:${t.id}`} plain onPress={() => o.show!('all')}>{goal}</Button> : goal}
117        <Text color={color.dim}>{` · ${ms === null ? 'running' : duration(ms)}`}</Text>
118      </Box>
119      {lines.map((l, i) => line(i === 0, l, `tldr-line:${i}`))}
120      {o.open !== undefined ? (
121        <Box key={`tldr-inline:${t.id}`} flexDirection="column" marginTop={1} paddingLeft={glyph.elbow.length}>
122          {o.open.section !== 'all' ? <Text bold>{sectionTitle(o.open.section)}</Text> : null}
123          {o.open.detail}
124        </Box>
125      ) : null}
126    </Box>
127  )
128}
129
hooks/draw/band.tsx 140 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 { RunPart } from '../progress'
12import type { Verdict } from '../steps'
13import { sectionTitle } from './detail'
14import type { Section } from './detail'
15import { color, glyph, toneColor, verdictColor } from './theme'
16
17export function keptNote(count: number): string {
18  return `${count} earlier turn${count === 1 ? '' : 's'} kept · /tldr history`
19}
20
21export type Reveal = { section: Section | 'all'; label: string; hotkey: string }
22
23/**
24 * The rows of the latest card as keys on the band, for the main screen: a printed card can't take a press,
25 * but the band can. Each row keeps one digit, so the same key always opens the same row, and pressed again closes it.
26 */
27export function revealKeys(t: Turn): Reveal[] {
28  const out: Reveal[] = [{ section: 'all', label: 'steps', hotkey: '1' }]
29  if (t.changes.length > 0) out.push({ section: 'changed', label: 'changed', hotkey: '2' })
30  if (t.testRuns.length > 0) out.push({ section: 'tests', label: 'tests', hotkey: '3' })
31  if (t.flags.length > 0) out.push({ section: 'check', label: 'check', hotkey: '4' })
32  if ((t.ranLog?.length ?? 0) > 0) out.push({ section: 'ran', label: 'ran', hotkey: '5' })
33  if (t.reads.length + t.searches.length > 0) out.push({ section: 'looked', label: 'looked at', hotkey: '6' })
34  return out
35}
36
37export type BandReveal = { turnId: string; verdict: Verdict; keys: Reveal[]; show: (s: Section | 'all') => void; open: { section: Section | 'all'; detail: RenderElement } | null }
38export type BandContext = { fill: ContextFill; open: () => void }
39/** The stepper: the bar and the running task's row are Rasters the frame loop repaints; `rowCells` null draws that row as Text. */
40export type BandPlan = { view: PlanView; barCells: string; barColumns: number; rowCells: string | null; rowColumns: number; justDone: string | null }
41export type BandParts = { note: string | null; reveal: BandReveal | null; context: BandContext | null; run: RunPart[] | null; plan: BandPlan | null; last: RunPart[] }
42
43export const CONTEXT_KEY = 'tldr-context-bar'
44export const PLAN_BAR_KEY = 'tldr-plan-bar'
45export const PLAN_ROW_KEY = 'tldr-plan-now'
46
47export function drawBand(ui: Elements['terminal'], e: RenderInput<'AbovePrompt'>, rest: RenderElement | null, parts: BandParts) {
48  const { Box, Text, Button, Raster } = ui
49  const reveal = parts.reveal
50  const ctx = parts.context
51  // The label names each color in that color, so the bar reads without a legend: "62% full · conversation 94k · setup 30k".
52  const level = ctx !== null ? contextLevel(ctx.fill.percent) : 'ok'
53  const head = ctx !== null ? `${ctx.fill.percent}% full${level === 'full' ? ' · /compact' : ''}` : ''
54  const conv = ctx !== null ? `conversation ${tokens(sliceTokens(ctx.fill, 'conversation'))}` : ''
55  const setup = ctx !== null ? `setup ${tokens(sliceTokens(ctx.fill, 'setup'))}` : ''
56  const wide = e.props.bodyColumns >= 72
57  const labelLength = head.length + (wide ? conv.length + setup.length + 6 : 0)
58  // The label takes its own cells, one more for the gap; the bar takes the rest.
59  const barColumns = Math.max(4, e.props.bodyColumns - labelLength - 1)
60  const mark = <Text color={color.accent} bold>{'TL;DR  '}</Text>
61  const verdict = reveal !== null && (reveal.verdict === 'pass' || reveal.verdict === 'fail' || reveal.verdict === 'check') ? reveal.verdict : null
62  return (
63    <Box key="tldr-bands" flexDirection="column">
64      {rest}
65      {parts.note !== null ? <Text key="tldr-kept-note" color={color.dim} wrap="truncate-end">{parts.note}</Text> : null}
66      {parts.plan ? drawPlan(ui, parts.plan, parts.last) : parts.run !== null ? (
67        <Text key="tldr-run" wrap="truncate-end">
68          {mark}
69          {runText(ui, parts.run)}
70        </Text>
71      ) : null}
72      {reveal !== null ? (
73        <Box key="tldr-reveal" flexDirection="row" columnGap={2}>
74          <Text>
75            <Text color={color.accent} bold>TL;DR</Text>
76            {verdict !== null ? <Text color={verdictColor[verdict]} bold>{` ${glyph[verdict]}`}</Text> : ''}
77          </Text>
78          {reveal.keys.map(k => (
79            <Button key={`tldr-reveal:${k.section}:${reveal.turnId}`} label={k.label} hotkey={k.hotkey} plain dimColor={reveal.open?.section !== k.section} onPress={() => reveal.show(k.section)} />
80          ))}
81        </Box>
82      ) : null}
83      {reveal?.open ? (
84        <Box key={`tldr-inline:${reveal.turnId}`} flexDirection="column" marginBottom={1}>
85          {reveal.open.section !== 'all' ? <Text bold>{sectionTitle(reveal.open.section)}</Text> : null}
86          {reveal.open.detail}
87        </Box>
88      ) : null}
89      {ctx !== null ? (
90        <Box key="tldr-context-row" flexDirection="row" columnGap={1}>
91          <Raster key={CONTEXT_KEY} columns={barColumns} rows={1} cells={encodeCells(contextCells(ctx.fill, barColumns))} />
92          <Button key="tldr-context-open" plain onPress={ctx.open}>
93            <Text color={level === 'full' ? color.fail : level === 'warn' ? color.check : color.text}>{head}</Text>
94            {wide ? <Text color={color.dim}>{' · '}</Text> : null}
95            {wide ? <Text color={hex(conversationRgb(ctx.fill.percent))}>{conv}</Text> : null}
96            {wide ? <Text color={color.dim}>{' · '}</Text> : null}
97            {wide ? <Text color={hex(SETUP_RGB)}>{setup}</Text> : null}
98          </Button>
99        </Box>
100      ) : null}
101    </Box>
102  )
103}
104
105function runText(ui: Elements['terminal'], parts: readonly RunPart[]) {
106  const { Text } = ui
107  return parts.map(p => <Text color={toneColor[p.tone]}>{p.text}</Text>)
108}
109
110/** Claude's task list while the turn runs: the bar and count with the last thing that happened, then a window of tasks, the running one in motion. */
111function drawPlan(ui: Elements['terminal'], plan: BandPlan, last: readonly RunPart[]) {
112  const { Box, Text, Raster } = ui
113  const v = plan.view
114  return (
115    <Box key="tldr-plan" flexDirection="column">
116      <Box key="tldr-plan-head" flexDirection="row" columnGap={1}>
117        <Text color={color.accent} bold>TL;DR </Text>
118        <Raster key={PLAN_BAR_KEY} columns={plan.barColumns} rows={1} cells={plan.barCells} />
119        <Text wrap="truncate-end">
120          <Text>{`${v.done}/${v.total} tasks`}</Text>
121          {runText(ui, last)}
122        </Text>
123      </Box>
124      {v.before > 0 ? <Text key="tldr-plan-before" color={color.dim}>{`  ${glyph.pass} ${v.before} earlier`}</Text> : null}
125      {v.rows.map(t =>
126        t.status === 'in_progress' && plan.rowCells !== null ? <Raster key={PLAN_ROW_KEY} columns={plan.rowColumns} rows={1} cells={plan.rowCells} />
127          : t.status === 'in_progress' ? <Text key={`tldr-plan:${t.id}`} color={color.accent} wrap="truncate-end">{`  ◐ ${taskLabel(t)}`}</Text>
128          : t.status === 'completed' ? (
129            <Text key={`tldr-plan:${t.id}`} wrap="truncate-end">
130              <Text color={color.pass}>{`  ${glyph.pass} `}</Text>
131              <Text color={plan.justDone === t.id ? color.pass : color.dim}>{t.subject}</Text>
132            </Text>
133          )
134          : <Text key={`tldr-plan:${t.id}`} color={color.dim} wrap="truncate-end">{`  ○ ${t.subject}`}</Text>,
135      )}
136      {v.after > 0 ? <Text key="tldr-plan-after" color={color.dim}>{`  +${v.after} more`}</Text> : null}
137    </Box>
138  )
139}
140
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 } from 'claude-code'
3
4import { contextLevel, tokens } from '../context'
5import type { ContextFill, SliceKind } from '../context'
6import { encodeCells } from '../motion'
7import { color } from './theme'
8
9export const CONTEXT_PANE = 'tldr-context'
10const SWATCH = 3
11const NAME_WIDTH = 26
12
13const GLYPH: Record<SliceKind, number> = { conversation: 0x2588, setup: 0x2588, free: 0x2500, buffer: 0x254c }
14const MEANS: Record<SliceKind, string> = {
15  conversation: '/compact frees this',
16  setup: 'loaded on every request',
17  free: 'room left',
18  buffer: 'kept back so auto-compact can run',
19}
20
21const swatch = (rgb: number, glyph: number) => encodeCells(Uint32Array.from({ length: SWATCH * 3 }, (_, i) => (i % 3 === 0 ? glyph : i % 3 === 1 ? rgb : 0x01000000)))
22
23export function drawContextPane(ui: Elements['terminal'], fill: ContextFill | null) {
24  const { Box, Text, Raster } = ui
25  if (fill === null) return <Box key="tldr-context-pane"><Text color={color.dim}>No context figures yet. They come after the first reply.</Text></Box>
26  const total = fill.slices.reduce((n, s) => n + s.tokens, 0) || 1
27  const level = contextLevel(fill.percent)
28  const share = (n: number) => `${Math.round((n / total) * 100)}%`.padStart(4)
29  return (
30    <Box key="tldr-context-pane" flexDirection="column">
31      <Text color={level === 'full' ? color.fail : level === 'warn' ? color.check : color.text}>{`${fill.percent}% full · ${tokens(fill.used)} of ${tokens(fill.window)}`}</Text>
32      {fill.slices.map(s => (
33        <Box flexDirection="column">
34          <Box flexDirection="row" columnGap={1}>
35            <Raster key={`tldr-swatch:${s.kind}`} columns={SWATCH} rows={1} cells={swatch(s.rgb, GLYPH[s.kind])} />
36            <Box width={NAME_WIDTH} flexShrink={0}><Text wrap="truncate-end" color={s.kind === 'free' || s.kind === 'buffer' ? color.dim : color.text}>{s.name}</Text></Box>
37            <Box width={5} flexShrink={0}><Text color={color.dim}>{tokens(s.tokens).padStart(5)}</Text></Box>
38            <Box width={5} flexShrink={0}><Text color={color.dim}>{share(s.tokens)}</Text></Box>
39            <Text color={color.dim} wrap="truncate-end">{MEANS[s.kind]}</Text>
40          </Box>
41          {s.kind === 'setup'
42            ? fill.setup.map(r => (
43                <Box flexDirection="row" columnGap={1}>
44                  <Box width={SWATCH} flexShrink={0} />
45                  <Box width={NAME_WIDTH} flexShrink={0}><Text color={color.dim} wrap="truncate-end">{`  ${r.name}`}</Text></Box>
46                  <Text color={color.dim}>{tokens(r.tokens).padStart(5)}</Text>
47                </Box>
48              ))
49            : null}
50        </Box>
51      ))}
52      {level !== 'ok' ? <Text color={color.dim}>/compact shrinks the conversation to a summary. TL;DR keeps the cards.</Text> : null}
53    </Box>
54  )
55}
56
hooks/context.ts 109 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 tokens of one slice kind, 0 when absent. */
106export function sliceTokens(fill: ContextFill, kind: SliceKind): number {
107  return fill.slices.find(s => s.kind === kind)?.tokens ?? 0
108}
109
hooks/progress.ts 89 lines
1// The band's line while a turn runs: what runs now and how far it is against last time, then the last thing that happened.
2// Also the one line a finished long turn sends as a notification. Pure.
3import type { Jump, Running, Tone, Turn } from '../types'
4import { glyph } from './draw/theme'
5import { duration, ring } from './durations'
6import { changeTotals, verdictOf } from './steps'
7import { testLabel } from './summary'
8
9export type RunPart = { text: string; tone: Tone | 'accent' | 'dim' | 'text' }
10
11const CLOCK_FROM_MS = 2000 // under this a clock says nothing worth reading
12const MINUS = '−'
13
14/** "pytest ✗ 3 failed" and "⚠ tests/conftest.py changed …" from one call: the runs and flags `after` has that `before` did not. */
15export function nextJump(before: Pick<Turn, 'testRuns' | 'flags'>, after: Pick<Turn, 'testRuns' | 'flags'>, now: number): Jump | null {
16  const parts: Jump['parts'] = []
17  const run = after.testRuns.length > before.testRuns.length ? after.testRuns.at(-1) : undefined
18  if (run !== undefined) {
19    const l = testLabel(run)
20    parts.push({ text: l.text, tone: l.ok ? 'pass' : 'fail' })
21  }
22  for (const f of after.flags) {
23    if (!before.flags.some(g => g.kind === f.kind && g.detail === f.detail)) parts.push({ text: `${glyph.check} ${f.detail}`, tone: 'check' })
24  }
25  return parts.length > 0 ? { parts, at: now } : null
26}
27
28/** " · last: ✗ pytest 3 failed 1m 30s ago": the jump and its age, as the band and the stepper's head end. */
29export function jumpParts(jump: Jump | null, now: number): RunPart[] {
30  if (jump === null) return []
31  const age = now - jump.at
32  return [
33    { text: ' · last: ', tone: 'dim' },
34    ...jump.parts.flatMap((p, i): RunPart[] => (i > 0 ? [{ text: ' · ', tone: 'dim' }, p] : [p])),
35    { text: age < CLOCK_FROM_MS ? ' just now' : ` ${duration(age)} ago`, tone: 'dim' },
36  ]
37}
38
39function filesText(t: Pick<Turn, 'changes'>): string | null {
40  if (t.changes.length === 0) return null
41  const s = changeTotals(t.changes)
42  const files = `${s.files} file${s.files === 1 ? '' : 's'}`
43  return s.counted ? `${files} +${s.added} ${MINUS}${s.removed}` : files
44}
45
46export type RunInput = { word: string; running: Running | null; since: number; now: number; turn: Pick<Turn, 'jump' | 'changes'> }
47
48/**
49 * The line: "◑ Running pytest -q 38s of ~1m 12s · last: ✗ pytest 3 failed 1m 30s ago".
50 * A command gets a ring against last time; anything else, and Claude thinking between calls, gets a plain clock.
51 * With nothing happened yet, the files changed so far stand in for the jump.
52 */
53export function runLine(o: RunInput): RunPart[] {
54  const out: RunPart[] = []
55  const r = o.running
56  if (r !== null) {
57    const ms = o.now - r.startedAt
58    if (r.isCommand) out.push({ text: `${ring(ms, r.lastMs)} `, tone: 'accent' })
59    out.push({ text: r.label, tone: 'text' })
60    if (ms >= CLOCK_FROM_MS) {
61      out.push({ text: ` ${duration(ms)}`, tone: 'text' })
62      if (r.isCommand) out.push({ text: r.lastMs === null ? ' · first run' : ms > r.lastMs ? ` · past ~${duration(r.lastMs)}` : ` of ~${duration(r.lastMs)}`, tone: 'dim' })
63    }
64  } else {
65    const ms = o.now - o.since
66    out.push({ text: o.word, tone: 'text' })
67    if (ms >= CLOCK_FROM_MS) out.push({ text: ` ${duration(ms)}`, tone: 'text' })
68  }
69  const jump = o.turn.jump ?? null
70  if (jump !== null) return [...out, ...jumpParts(jump, o.now)]
71  const files = filesText(o.turn)
72  return files === null ? out : [...out, { text: ` · ${files}`, tone: 'dim' }]
73}
74
75/** The finished turn in one line, for a notification: "⚠ fix the login bug · ✓ 12 passed · 1 to check · 3 files +18 −6 · 4m 12s". */
76export function doneText(t: Turn, ms: number): string {
77  const v = verdictOf(t)
78  const mark = v === 'pass' || v === 'fail' || v === 'check' ? `${glyph[v]} ` : ''
79  const last = t.testRuns.at(-1)
80  const parts = [
81    `${mark}${t.goal}`,
82    last !== undefined ? testLabel(last).result : null,
83    t.flags.length > 0 ? `${t.flags.length} to check` : null,
84    filesText(t),
85    duration(ms),
86  ]
87  return parts.filter((p): p is string => p !== null).join(' · ')
88}
89
hooks/draw/compact-line.tsx 33 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 card follows at the end of the turn.
3// A line that ran tests shows the result in green or red, its dot too, so a red run stands out in scrollback.
4// Calls a line cannot name (git, gh, an MCP tool) draw nothing: a row of them would read "1 other call" again and again,
5// and the card stacks them into one line.
6import type { Elements, RenderSurface } from 'claude-code'
7
8import { groupSummary, isQuiet } from '../summary'
9import type { GroupCall } from '../summary'
10import { color, glyph } from './theme'
11import { countParts } from './tint'
12
13export function drawCompactLine(ui: Elements[RenderSurface], calls: readonly GroupCall[], root?: string) {
14  const { Box, Text } = ui
15  if (calls.every(c => isQuiet(c, root))) return <Box key="tldr-quiet" />
16  const parts = groupSummary(calls, root)
17  const runs = parts.filter(p => p.ok !== undefined)
18  const dot = runs.length === 0 ? color.accent : runs.every(p => p.ok) ? color.pass : color.fail
19  return (
20    <Text key="tldr-line" wrap="truncate-end">
21      <Text color={dot}>{`${glyph.dot} `}</Text>
22      <Text color={color.dim}>
23        {parts.length === 0 ? 'tool calls' : parts.map((p, i) => [
24          i > 0 ? ' · ' : '',
25          p.ok !== undefined
26            ? <Text color={p.ok ? color.pass : color.fail}>{p.text}</Text>
27            : countParts(p.text).map(c => (c.tone === undefined ? c.text : <Text color={c.tone === 'added' ? color.added : color.removed}>{c.text}</Text>)),
28        ])}
29      </Text>
30    </Text>
31  )
32}
33
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, RenderSurface } from 'claude-code'
5
6import type { Turn } from '../../types'
7import { shortPath } from '../classify'
8import { byFolder, changeTotals, stepsOf, stepVerb } from '../steps'
9import type { Step } from '../steps'
10import { testLabel } from '../summary'
11import { runPart } from '../tests-parse'
12import { countParts } from './tint'
13import { color, glyph } from './theme'
14
15export const DETAIL_PANE = 'tldr-detail'
16
17export type Section = 'looked' | 'changed' | 'tests' | 'ran' | 'check' | 'notes'
18
19const TITLE: Record<Section, string> = { looked: 'Looked at', changed: 'Changed', tests: 'Tests', ran: 'Ran', check: 'Check', notes: 'Notes' }
20const DIFF_PEEK = 6 // diff lines shown per file; /tldr diff has the rest
21const PEEK_FILES = 4 // more files than this: the pane lists them without diff lines, so it fits
22const FOLDERS_ONLY = 12 // more files than this: one line per folder, so a big refactor still fits the pane
23const STEPS_SHOWN = 16 // the newest steps; older ones fold into one line
24const VERB_WIDTH = 8
25
26export function sectionOf(word: string): Section | 'all' | null {
27  const w = word.trim().toLowerCase()
28  if (w === '' || w === 'all' || w === 'steps' || w === 'turn') return 'all'
29  if (w === 'looked' || w === 'read' || w === 'reads' || w === 'search' || w === 'searches') return 'looked'
30  if (w === 'changed' || w === 'changes' || w === 'files' || w === 'wrote') return 'changed'
31  if (w === 'tests' || w === 'test') return 'tests'
32  if (w === 'ran' || w === 'commands') return 'ran'
33  if (w === 'check' || w === 'flags') return 'check'
34  if (w === 'notes' || w === 'text') return 'notes'
35  return null
36}
37
38export function sectionTitle(s: Section | 'all'): string {
39  return s === 'all' ? 'Steps' : TITLE[s]
40}
41
42export function paneTitle(t: Turn, s: Section | 'all'): string {
43  return s === 'all' ? `TL;DR · ${t.goal}` : `TL;DR · ${TITLE[s]}`
44}
45
46/** The commands behind key 5 (ran): log lines of calls that were not a read, search, change or test. */
47export function ranCommands(t: Turn): string[] {
48  return (t.ranLog ?? []).map(l => l.replace(/^Bash /, ''))
49}
50
51/** Rows the pane wants for a section, so a short list opens a short pane. */
52export function paneRows(t: Turn, s: Section | 'all'): number {
53  const count = (x: Section): number => {
54    if (x === 'looked') return t.reads.length + t.searches.length + 2
55    if (x === 'changed') {
56      const folders = byFolder(t.changes).length
57      if (t.changes.length > FOLDERS_ONLY) return 2 + folders
58      return 1 + t.changes.length + folders + (t.changes.length > PEEK_FILES ? 1 : (t.diffs ?? []).reduce((n, d) => n + Math.min(d.lines.length, DIFF_PEEK), 0))
59    }
60    if (x === 'tests') return t.testRuns.length
61    if (x === 'ran') return ranCommands(t).length
62    if (x === 'notes') return t.interim.reduce((n, s) => n + s.split('\n').length, 0)
63    return t.flags.reduce((n, f) => n + 1 + (f.items ?? []).length, 0)
64  }
65  const n = s === 'all' ? Math.min(stepsOf(t).length, STEPS_SHOWN + 1) + (t.flags.length > 0 ? count('check') + 2 : 0) : count(s)
66  return Math.max(3, Math.min(18, n + 1))
67}
68
69function diffColor(l: string): string | undefined {
70  if (l.startsWith('+')) return color.added
71  if (l.startsWith('-')) return color.removed
72  return color.dim
73}
74
75export function drawDetail(ui: Elements[RenderSurface], t: Turn | null, s: Section | 'all') {
76  const { Box, Text } = ui
77  if (t === null) return <Box key="tldr-detail"><Text color={color.dim}>No turn yet.</Text></Box>
78
79  const empty = (what: string) => <Text color={color.dim}>{what}</Text>
80  // A text with its line counts in green and red; the rest keeps the given color.
81  const tinted = (text: string, base?: string) => (
82    <Text color={base}>{countParts(text).map(p => (p.tone === undefined ? p.text : <Text color={p.tone === 'added' ? color.added : color.removed}>{p.text}</Text>))}</Text>
83  )
84  // One list row: the name on the left, its fact dim on the right.
85  const line = (name: string, fact?: string, factColor: string = color.dim) => (
86    <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
87      <Text wrap="truncate-end">{name}</Text>
88      {fact !== undefined ? tinted(fact, factColor) : null}
89    </Box>
90  )
91  const heading = (text: string) => tinted(text, color.dim)
92
93  const looked = () => (
94    <Box flexDirection="column">
95      {t.reads.length === 0 && t.searches.length === 0 ? empty('Read and searched nothing.') : null}
96      {t.reads.length > 0 ? heading(`Read · ${t.reads.length}`) : null}
97      {t.reads.map(p => line(`  ${shortPath(p)}`))}
98      {t.searches.length > 0 ? heading(`Searched · ${t.searches.length}`) : null}
99      {t.searches.map(p => line(`  ${p}`))}
100    </Box>
101  )
102
103  // Files by folder, so ten files read as two or three places. One folder, or one file, stays a flat list.
104  const changed = () => {
105    if (t.changes.length === 0) return empty('No files changed that the card could see.')
106    const diffs = new Map(t.changes.length > PEEK_FILES ? [] : (t.diffs ?? []).map(d => [d.path, d.lines]))
107    const total = changeTotals(t.changes)
108    const folders = byFolder(t.changes)
109    const grouped = folders.length > 1
110    const counts = (added: number, removed: number) => `+${added} ${glyph.minus}${removed}`
111    const file = (c: Turn['changes'][number], indent: string) => {
112      const fact = c.isDelete ? 'deleted' : c.viaBash ? 'via Bash' : counts(c.added, c.removed)
113      const name = grouped ? shortPath(c.path).slice(dirLength(c.path)) : shortPath(c.path)
114      const lines = (diffs.get(c.path) ?? []).filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l))
115      return (
116        <Box flexDirection="column">
117          {line(`${indent}${name}`, fact, c.isDelete ? color.check : color.dim)}
118          {lines.slice(0, DIFF_PEEK).map(l => <Text color={diffColor(l)} wrap="truncate-end">{`${indent}  ${l}`}</Text>)}
119          {lines.length > DIFF_PEEK ? <Text color={color.dim}>{`${indent}  +${lines.length - DIFF_PEEK} more lines · /tldr diff`}</Text> : null}
120        </Box>
121      )
122    }
123    return (
124      <Box flexDirection="column">
125        {heading(`${total.files} file${total.files === 1 ? '' : 's'}${total.counted ? ` · ${counts(total.added, total.removed)}` : ''}`)}
126        {t.changes.length > FOLDERS_ONLY
127          ? folders.map(f => line(`${f.dir || './'}  ${f.changes.length} file${f.changes.length === 1 ? '' : 's'}`, f.added + f.removed > 0 ? counts(f.added, f.removed) : undefined))
128          : grouped
129          ? folders.map(f => (
130              <Box flexDirection="column">
131                {f.dir !== '' ? line(f.dir, f.added + f.removed > 0 ? counts(f.added, f.removed) : undefined) : null}
132                {f.changes.map(c => file(c, f.dir !== '' ? '  ' : ''))}
133              </Box>
134            ))
135          : t.changes.map(c => file(c, ''))}
136        {t.changes.length > FOLDERS_ONLY ? <Text color={color.dim}>{'Each file and its lines: /tldr diff'}</Text>
137          : t.changes.length > PEEK_FILES && (t.diffs ?? []).length > 0 ? <Text color={color.dim}>{'Diff lines: /tldr diff'}</Text> : null}
138      </Box>
139    )
140  }
141
142  const tests = () =>
143    t.testRuns.length === 0 ? empty('No tests ran.') : (
144      <Box flexDirection="column">
145        {t.testRuns.map(r => {
146          const l = testLabel(r)
147          return line(runPart(r.command).split('\n')[0]!, l.result, l.ok ? color.pass : color.fail)
148        })}
149      </Box>
150    )
151
152  const ran = () => {
153    const cmds = ranCommands(t)
154    return cmds.length === 0 ? empty('No other commands ran.') : <Box flexDirection="column">{cmds.map(c => line(c))}</Box>
155  }
156
157  const check = () =>
158    t.flags.length === 0 ? empty('Nothing to check.') : (
159      <Box flexDirection="column">
160        {t.flags.map(f => (
161          <Box flexDirection="column">
162            <Text color={color.check} wrap="truncate-end">{`⚠ ${f.detail}`}</Text>
163            {(f.items ?? []).map(i => <Text color={color.dim} wrap="truncate-end">{`  ${i}`}</Text>)}
164          </Box>
165        ))}
166      </Box>
167    )
168
169  // What Claude wrote between tool calls: folded off the transcript, kept here.
170  const notes = () =>
171    t.interim.length === 0 ? empty('No notes between tool calls.') : (
172      <Box flexDirection="column" rowGap={1}>{t.interim.map(n => <Text color={color.dim} wrap="wrap">{n}</Text>)}</Box>
173    )
174
175  // The turn in order: how it got from the prompt to the result. A failing test is red, an edited test file amber.
176  const steps = () => {
177    const all = stepsOf(t)
178    if (all.length === 0) return empty('No tool calls in this turn.')
179    const shown = all.slice(-STEPS_SHOWN)
180    const first = all.length - shown.length
181    const flagged = t.flags.some(f => f.kind === 'tests-edited')
182    const tone = (s: Step) =>
183      s.kind === 'test' ? (s.ok ? color.pass : color.fail) : s.kind === 'delete' || (s.isTest && flagged) ? color.check : undefined
184    return (
185      <Box flexDirection="column">
186        {first > 0 ? <Text color={color.dim}>{`… ${first} earlier step${first === 1 ? '' : 's'}`}</Text> : null}
187        {shown.map((s, i) => (
188          <Box flexDirection="row" columnGap={1}>
189            <Box width={3} flexShrink={0}><Text color={color.dim}>{String(first + i + 1).padStart(2)}</Text></Box>
190            <Box width={VERB_WIDTH} flexShrink={0}><Text color={color.dim}>{stepVerb(s.kind)}</Text></Box>
191            {tone(s) === undefined ? <Text wrap="truncate-end">{tinted(s.text)}</Text> : <Text color={tone(s)} wrap="truncate-end">{s.text}</Text>}
192            {s.count > 1 ? <Text color={color.dim}>{`×${s.count}`}</Text> : null}
193            {s.isTest ? <Text color={flagged ? color.check : color.dim}>test file</Text> : null}
194          </Box>
195        ))}
196      </Box>
197    )
198  }
199
200  const body: Record<Section, () => unknown> = { looked, changed, tests, ran, check, notes }
201  if (s !== 'all') return <Box key="tldr-detail" flexDirection="column">{body[s]() as never}</Box>
202  return (
203    <Box key="tldr-detail" flexDirection="column" rowGap={1}>
204      <Box flexDirection="column">
205        <Text bold>Steps</Text>
206        {steps() as never}
207      </Box>
208      {t.flags.length > 0 ? (
209        <Box flexDirection="column">
210          <Text bold color={color.check}>Check</Text>
211          {check() as never}
212        </Box>
213      ) : null}
214    </Box>
215  )
216}
217
218/** Characters of the folder part of a path as the pane shows it, so a grouped file shows its name only. */
219function dirLength(path: string): number {
220  const p = shortPath(path)
221  return p.lastIndexOf('/') + 1
222}
223
hooks/draw/history.tsx 34 lines
1// The /tldr history pane: every kept card of this session, newest first, each with Claude's reply under it.
2import type { Elements, RenderSurface } from 'claude-code'
3
4import type { Turn } from '../../types'
5import { drawCard } from './card'
6import { color, glyph } from './theme'
7
8export const HISTORY_PANE = 'tldr-history'
9
10export function drawHistory(ui: Elements[RenderSurface], list: readonly Turn[]) {
11  const { Box, Text } = ui
12  if (list.length === 0) {
13    return (
14      <Box key="tldr-history" flexDirection="column">
15        <Text color={color.dim}>No cards kept yet. Each finished turn is kept here, also after /compact.</Text>
16      </Box>
17    )
18  }
19  return (
20    <Box key="tldr-history" flexDirection="column" rowGap={1}>
21      {[...list].reverse().map(t => (
22        <Box key={`tldr-kept:${t.id}`} flexDirection="column">
23          {drawCard(ui, t, { isExpanded: false })}
24          {t.reply !== null ? (
25            <Box paddingLeft={glyph.elbow.length}>
26              <Text wrap="wrap">{t.reply}</Text>
27            </Box>
28          ) : null}
29        </Box>
30      ))}
31    </Box>
32  )
33}
34