claude review: the session's latest reply in a calm reading pane docked beside the conversation, with its plan, question and tasks one key away

Faster than HTML. Calmer than the terminal.
A reading pane docked inside Claude Code, beside the conversation. It shows the session's latest reply, rendered for reading in a padded column, and follows the live turn as Claude writes. Its plan, the question Claude is waiting on, and the task list are one key away.

claude-review is a Claude Code mod: a plugin of function hooks that runs inside Claude Code, so there is no second terminal, no transcript path and nothing to keep in sync.
claude plugin marketplace add r3al1tym/claude-review
claude plugin install review-pane@claude-review
The next session opens the pane by itself once the terminal is 144 columns or wider. At any width, /claude-review opens it and closes it again.
From a clone, link the folder into your skills directory, where Claude Code loads it in every session as review-pane@skills-dir and reloads it when you save a file:
git clone https://github.com/r3al1tym/claude-review ~/src/claude-review
ln -s ~/src/claude-review ~/.claude/skills/review-pane
For one session only: claude --plugin-dir ~/src/claude-review.
The pane follows the live turn. Give it the keyboard with ctrl+x then Tab (until you do, its key row says ctrl+x ⇥ focus), and Esc hands the keys back.
| Keys | What they do |
|---|---|
h l | earlier / later turn; an earlier turn leads with the prompt it answered |
↑ ↓ j k, wheel, PgUp PgDn, Home End, g | scroll |
f | freeze the view while Claude keeps working; again to unfreeze |
r | back to the live turn |
t | next surface: plan, question, response, tasks, whichever this turn has |
y | copy the surface shown |
m | the guide: every key, plus the session's diagnostics |
q | close the pane |
A plan waiting for your approval and a pending question lead while Claude waits on them. While the view is frozen or on an earlier turn, new reply lights up in the key row when a reply lands on the live turn.
To change the default, set Open on start in /config (on: the pane opens by itself on a wide terminal; off: only /claude-review opens it). claude plugin disable review-pane@claude-review turns the mod off everywhere, and enable turns it back on.
The mod reads the conversation through the hooks API ($.session.messages()) and refreshes as rows land (session.append), so the pane updates while Claude writes, not only when a turn ends. It writes nothing to the session, makes no network calls and touches no files; y puts text on your clipboard through Claude Code.
In the terminal it lays out its own rows (hooks/markdown.ts, hooks/screen.tsx): a monochrome markdown theme where emphasis comes from weight and only code keeps its syntax colours, a 4-cell gutter, and chrome pinned at the edges. A rule with the claude review label sits at the top, and a rule and one key row sit at the bottom. The rules carry ▲ and ▼ N% when there is more above or below. The desktop app and VS Code draw the same reply with their own Markdown and buttons.
claude plugin validate . and claude plugin test ..m and surfaces are t, and the pane needs the keyboard (ctrl+x then Tab) before they work.claude-review began as a Python TUI you ran in a second terminal pane. The mod replaces it. Its last release, v0.5.3, still installs with pipx install git+https://github.com/r3al1tym/claude-review@v0.5.3, and its source lives at that tag.
See CONTRIBUTING.md for the development loop. Security posture and how to report a vulnerability are in SECURITY.md; the project follows a Code of Conduct.
MIT © r3al1tym.
hooks/register.tsx 284 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
3
4import type { ReviewSnapshot, ReviewTurn, ReviewView } from '../types'
5import { screen, surfacesFor } from './screen'
6import type { Facts, Laid } from './screen'
7import { buildSnapshot, hasReply, replySig } from './transcript'
8
9const PANE = 'claude-review'
10const EMPTY_TURN: ReviewTurn = { question: null, text: null, plan: null, planWaiting: false, ask: null }
11const FIRST_VIEW: ReviewView = { index: null, frozen: false, held: null, surface: 0, scroll: 0, seen: '', help: false, flash: null }
12
13const snapshot = atom({ plugin: 'review-pane', key: 'snapshot' } as const, { turns: [], tasks: [] })
14const view = atom({ plugin: 'review-pane', key: 'view' } as const, FIRST_VIEW)
15// the ids of the turns running now: the main one and any agent's
16const working = atom({ plugin: 'review-pane', key: 'working' } as const, [])
17
18// The last layout drawn, so a scroll or a key clamps to the content it moves
19// over. A cache only: a reload starts it over and the next draw refills it.
20let laid: Laid = { maxScroll: Number.MAX_SAFE_INTEGER, bodyRows: 10, surfaces: [], active: 0 }
21
22const latest = (snap: ReviewSnapshot): number => Math.max(0, snap.turns.length - 1)
23
24// The turn on screen, as the CLI's turn_at: a pin holds only on a turn that
25// exists, and a frozen view keeps the turn it froze.
26function onScreen(snap: ReviewSnapshot, v: ReviewView): { turn: ReviewTurn; cursor: number; historical: boolean } {
27 const last = snap.turns.length - 1
28 const cursor = v.index !== null && v.index >= 0 && v.index <= last ? v.index : last
29 const turn = (v.frozen && v.held) || snap.turns[cursor] || EMPTY_TURN
30 return { turn, cursor, historical: cursor >= 0 && cursor < last }
31}
32
33const isFollowing = (snap: ReviewSnapshot, v: ReviewView): boolean =>
34 !v.frozen && (v.index === null || v.index >= snap.turns.length - 1)
35
36// Show the live turn and mark its reply seen; back to the top only when the
37// reply changed since the view last showed it.
38const catchUp = (snap: ReviewSnapshot, v: ReviewView): ReviewView => {
39 const sig = replySig(snap)
40 if (sig !== v.seen) return { ...v, index: null, frozen: false, held: null, seen: sig, surface: 0, scroll: 0 }
41 return v.index === null && !v.frozen && v.held === null ? v : { ...v, index: null, frozen: false, held: null }
42}
43
44async function refresh($: EngineInterface): Promise<void> {
45 const snap = buildSnapshot(await $.session.messages())
46 await update($, snapshot, () => snap)
47 await update($, view, v => (isFollowing(snap, v) ? catchUp(snap, v) : v))
48}
49
50// Rows land in bursts (a response's blocks, a tool's result), so a refresh
51// waits for the burst to settle rather than reading the session per row.
52let pending: { cancel: () => void } | null = null
53function soon($: EngineInterface): void {
54 pending?.cancel()
55 pending = $.clock.after(120, () => {
56 pending = null
57 void isOpen($).then(isShown => (isShown ? refresh($) : undefined))
58 })
59}
60
61async function isOpen($: EngineInterface): Promise<boolean> {
62 return (await $.ui.panes()).some(pane => pane.id === PANE)
63}
64
65async function open($: EngineInterface): Promise<boolean> {
66 await refresh($)
67 return (await $.ui.open({ id: PANE, title: 'Review' })).isPlaced
68}
69
70// ---------------------------------------------------------------- keys
71
72const clampScroll = (n: number): number => Math.max(0, Math.min(n, laid.maxScroll))
73
74// Every key closes a flash; with the guide up, any key but a scroll closes it.
75async function key($: EngineInterface, act: (snap: ReviewSnapshot, v: ReviewView) => ReviewView, scrolls = false): Promise<void> {
76 const snap = await read($, snapshot)
77 await update($, view, v => {
78 const base = { ...v, flash: null }
79 if (base.help && !scrolls) return { ...base, help: false, scroll: 0 }
80 return act(snap, base)
81 })
82}
83
84// Point the view at turn `index`; a frozen view holds what it now shows.
85const show = (snap: ReviewSnapshot, v: ReviewView, index: number): ReviewView => {
86 const at = Math.max(0, Math.min(index, latest(snap)))
87 return { ...v, index: at, held: v.frozen ? (snap.turns[at] ?? null) : null, surface: 0, scroll: 0 }
88}
89
90const earlier = (snap: ReviewSnapshot, v: ReviewView): ReviewView => {
91 const { cursor } = onScreen(snap, v)
92 return cursor > 0 ? show(snap, v, cursor - 1) : v
93}
94
95const later = (snap: ReviewSnapshot, v: ReviewView): ReviewView => {
96 const { cursor } = onScreen(snap, v)
97 if (cursor >= latest(snap)) return v
98 return cursor + 1 === latest(snap) && !v.frozen ? catchUp(snap, v) : show(snap, v, cursor + 1)
99}
100
101const freeze = (snap: ReviewSnapshot, v: ReviewView): ReviewView => {
102 if (!v.frozen) return { ...v, frozen: true, held: onScreen(snap, v).turn }
103 const thawed = { ...v, frozen: false, held: null }
104 // unfreezing on the live turn resumes it
105 return onScreen(snap, thawed).cursor >= latest(snap) ? catchUp(snap, thawed) : thawed
106}
107
108const scrollBy = (n: number) => (_: ReviewSnapshot, v: ReviewView): ReviewView => ({ ...v, scroll: clampScroll(v.scroll + n) })
109
110const nextSurface = (_: ReviewSnapshot, v: ReviewView): ReviewView => {
111 const count = Math.max(1, laid.surfaces.length)
112 return { ...v, surface: (Math.min(v.surface, count - 1) + 1) % count, scroll: 0 }
113}
114
115async function copySurface($: EngineInterface, press: UiPressArgument): Promise<void> {
116 if ((await read($, view)).help) return key($, (_, v) => v)
117 const surface = laid.surfaces[laid.active]
118 const text = surface?.raw ?? ''
119 const copied = text === '' ? null : await $.ui.copy({ text, surface: press.surface })
120 const flash = copied === null ? 'nothing to copy' : copied.isCopied ? `copied ${surface!.label}` : `copy failed: ${copied.reason}`
121 await key($, (_, v) => ({ ...v, flash }))
122}
123
124const HOTKEYS = ['h', 'l', 'j', 'k', 'g', 'f', 'r', 't', 'y', 'm', 'q'] as const
125
126async function pressKey($: EngineInterface, k: string, press: UiPressArgument): Promise<void> {
127 switch (k) {
128 case 'h': return key($, earlier)
129 case 'l': return key($, later)
130 case 'j': return key($, scrollBy(1), true)
131 case 'k': return key($, scrollBy(-1), true)
132 case 'g': return key($, (_, v) => ({ ...v, scroll: 0 }), true)
133 case 'f': return key($, freeze)
134 case 'r': return key($, catchUp)
135 case 't': return key($, nextSurface)
136 case 'y': return copySurface($, press)
137 case 'm': return key($, (_, v) => (v.help ? v : { ...v, help: true, scroll: 0 }))
138 case 'q': return $.ui.close({ id: PANE }).then(() => undefined)
139 }
140}
141const hotkey = (k: string): string => `key-${k}`
142
143// ---------------------------------------------------------------- hooks
144
145export const register: Register = (on, options) => {
146 on('session.start', async ($, e, next) => {
147 await $.command.register({
148 name: 'claude-review',
149 description: 'Toggle the review pane: the latest response, plan and tasks of this session',
150 })
151 // a headless run has no surface to draw on, so it never opens unasked
152 if (options.openOnStart && (await $.session.surfaces()).length > 0) void open($)
153
154 return next(e)
155 })
156
157 on('command.run', { command: 'claude-review' }, async $ => {
158 if (await isOpen($)) {
159 await $.ui.close({ id: PANE })
160 return { text: 'Review pane closed.' }
161 }
162
163 return { text: (await open($)) ? 'Review pane opened.' : 'Review pane is waiting for a wider terminal.' }
164 })
165
166 on('turn.start', async ($, e, next) => {
167 await update($, working, ids => [...ids.filter(id => id !== e.turnId), e.turnId])
168 return next(e)
169 })
170
171 on('turn.complete', async ($, e, next) => {
172 const result = await next(e)
173 await update($, working, ids => ids.filter(id => id !== e.turnId))
174 if (e.agentId === undefined && (await isOpen($))) await refresh($)
175
176 return result
177 })
178
179 // the pane follows the conversation as its rows land, as the CLI follows
180 // the transcript file; a closed pane reads nothing
181 on('session.append', async ($, e, next) => {
182 const stored = await next(e)
183 if (e.agentId === undefined && (e.door === 'prompt' || e.door === 'response' || e.door === 'tool-result')) soon($)
184
185 return stored
186 })
187
188 // The terminal pane draws exactly its body's rows plus one, so the engine
189 // has a row to scroll and passes every scroll key here; the pane moves its
190 // own window under the pinned chrome and the engine's window never moves.
191 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
192 if (e.contentRows !== e.bodyRows + 1) return next(e)
193 const step = Math.abs(e.by) >= e.contentRows
194 ? Math.sign(e.by) * Number.MAX_SAFE_INTEGER
195 : Math.abs(e.by) === e.bodyRows ? Math.sign(e.by) * laid.bodyRows
196 : e.pointer ? e.by * 3 : e.by
197 await key($, (_, v) => ({ ...v, scroll: clampScroll(v.scroll + step) }), true)
198
199 // the engine's window stays at the top, whatever moved it before
200 return next({ ...e, offset: 0 })
201 })
202
203 // the hotkeys ride on Buttons drawn hidden; the focus ring never lands on one
204 on('ui.focus', { requestId: PANE }, async ($, e, next) =>
205 (e.element?.startsWith('key-') ? { deny: 'a hotkey, not a control' } : next(e)))
206
207 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
208 const E = $.ui.resolve(e)
209 const snap = await read($, snapshot)
210 const v = await read($, view)
211 const busy = (await read($, working)).length > 0
212 const { turn, cursor, historical } = onScreen(snap, v)
213 const isLatest = !historical
214 const behind = !isFollowing(snap, v) && hasReply(snap) && replySig(snap) !== v.seen
215
216 if (e.surface === 'terminal') {
217 const T = E as typeof E & Parameters<typeof screen>[0]
218 // the guide's diagnostics never cost the drawing: a fact that fails reads '?'
219 const facts: Facts | null = v.help
220 ? {
221 session: await $.session.id().catch(() => '?'),
222 model: (await $.session.model().catch(() => '?')).replace(/^.*?(?=claude-)/, '').replace(/^claude-/, ''),
223 project: (await $.session.cwd().catch(() => '?')).replace(/^\/home\/[^/]+/, '~'),
224 }
225 : null
226 const drawn = screen(T, {
227 columns: e.props.bodyColumns,
228 rows: e.props.scroll.bodyRows,
229 isFocused: e.props.isFocused,
230 placement: e.props.placement,
231 turn,
232 tasks: isLatest ? snap.tasks : [],
233 historical,
234 working: busy,
235 behind,
236 cursor,
237 turnCount: snap.turns.length,
238 view: v,
239 facts,
240 })
241 laid = drawn.laid
242 const { Box, Button, Text } = T
243
244 return (
245 <Box flexDirection="column">
246 {drawn.tree}
247 <Text> </Text>
248 <Box display="none">
249 {HOTKEYS.map(k => <Button key={hotkey(k)} label={k} hotkey={k} onPress={press => pressKey($, k, press)} />)}
250 </Box>
251 </Box>
252 )
253 }
254
255 // Other surfaces draw with their own type and scroll the tree themselves:
256 // the same column, drawn by the surface's Markdown.
257 const { Box, Button, Markdown, Text } = E
258 const surfaces = surfacesFor(turn, isLatest ? snap.tasks : [], historical, busy)
259 const active = Math.min(v.surface, surfaces.length - 1)
260 laid = { maxScroll: 0, bodyRows: 10, surfaces, active }
261 const shown = surfaces[active]!
262 const body = shown.raw !== '' ? shown.raw : '_No response yet._'
263 const position = snap.turns.length === 0 ? 'no turns yet'
264 : `turn ${cursor + 1} of ${snap.turns.length}${v.frozen ? ' · frozen' : isFollowing(snap, v) ? ' · live' : ''}`
265
266 return (
267 <Box flexDirection="column" paddingX={2} paddingY={1} gap={1}>
268 <Text dimColor>{position}</Text>
269 {historical && turn.question ? <Text dimColor>{`› ${turn.question.replace(/\s+/g, ' ').slice(0, 200)}`}</Text> : null}
270 <Markdown key="body" text={body.length <= 9800 ? body : `${body.slice(0, 9800)}\n\n_Cut at 9800 characters. Copy for the full text._`} />
271 <Box flexDirection="row" gap={2}>
272 {surfaces.length > 1 ? surfaces.map((sf, i) => (
273 <Button key={`surface-${sf.label}`} label={sf.label} plain dimColor={i !== active} onPress={() => key($, (_, w) => ({ ...w, surface: i, scroll: 0 }))} />
274 )) : null}
275 <Button key="earlier" label="earlier" plain dimColor onPress={press => pressKey($, 'h', press)} />
276 <Button key="later" label="later" plain dimColor onPress={press => pressKey($, 'l', press)} />
277 <Button key="freeze" label={v.frozen ? 'unfreeze' : 'freeze'} plain dimColor onPress={press => pressKey($, 'f', press)} />
278 <Button key="copy" label="copy" plain dimColor onPress={press => pressKey($, 'y', press)} />
279 </Box>
280 </Box>
281 )
282 })
283}
284hooks/screen.tsx 340 lines1// The terminal pane, laid out as the CLI's render_screen: no header, the
2// response owns the column. Chrome is three rows: a top rule carrying the
3// wordmark, a bottom rule carrying the overflow cue, and one key row with
4// the state on the left and the cues on the right. Every frame element is
5// greyscale; the one colour marks the state you set (frozen, new reply).
6
7import type { ElementTable } from 'claude-code'
8
9import type { ReviewTask, ReviewTurn, ReviewView } from '../types'
10import { INK, layout } from './markdown'
11import type { Line } from './markdown'
12import { cells, clip, oneline, spanCells, wrap } from './text'
13import type { Span, Style } from './text'
14
15export type Surface = { label: string; lines: (width: number) => Line[]; raw: string }
16
17export type Facts = { session: string; model: string; project: string }
18
19export type ScreenInput = {
20 columns: number
21 rows: number
22 isFocused: boolean
23 placement: 'dock' | 'inline'
24 turn: ReviewTurn
25 tasks: ReviewTask[]
26 historical: boolean
27 working: boolean
28 behind: boolean
29 cursor: number
30 turnCount: number
31 view: ReviewView
32 facts: Facts | null
33}
34
35const textLine = (spans: Span[]): Line => ({ kind: 'text', spans })
36const blank = (): Line => textLine([])
37
38// ---------------------------------------------------------------- surfaces
39
40function taskLines(tasks: readonly ReviewTask[], width: number): Line[] {
41 // status by brightness, not hue: done recedes, active is brightest, pending mid
42 const look: Record<string, [string, Style]> = {
43 completed: ['✓', { color: INK.meta }],
44 in_progress: ['▸', { color: INK.brightest, bold: true }],
45 pending: ['○', { color: INK.question }],
46 }
47 return tasks.flatMap(t => {
48 const [sym, style] = look[t.status] ?? look.pending!
49 return wrap([{ text: oneline(t.content), style }], width, [{ text: ` ${sym} `, style }], [{ text: ' ' }]).map(textLine)
50 })
51}
52
53const note = (text: string): Surface => ({
54 label: 'waiting',
55 lines: width => wrap([{ text, style: { color: INK.link, italic: true } }], width, [{ text: ' ' }]).map(textLine),
56 raw: '',
57})
58
59// Ordered surfaces for whatever this turn produced, as the CLI builds them:
60// what Claude is blocked on leads (a plan awaiting approval, a question),
61// then the response, a plan already approved, the tasks.
62export function surfacesFor(turn: ReviewTurn, tasks: readonly ReviewTask[], historical: boolean, working: boolean): Surface[] {
63 const md = (label: string, src: string): Surface => ({ label, lines: width => layout(src, width), raw: src })
64 const out: Surface[] = []
65 if (turn.plan && turn.planWaiting) out.push(md('plan', turn.plan))
66 if (turn.ask) out.push(md('question', turn.ask))
67 if (turn.text) out.push(md('response', turn.text))
68 if (turn.plan && !turn.planWaiting) out.push(md('plan', turn.plan))
69 if (tasks.length > 0) {
70 out.push({ label: 'tasks', lines: width => taskLines(tasks, width), raw: tasks.map(t => `[${t.status}] ${t.content}`).join('\n') })
71 }
72 if (out.length > 0) return out
73 if (historical) return [note('(this turn produced no response text)')]
74 return [note(working ? '(no response yet, Claude is working)' : '(no response yet)')]
75}
76
77// ---------------------------------------------------------------- the guide
78
79const HELP_INTRO = ' shows this session\'s latest reply, rendered for reading, beside the conversation. '
80 + 'It follows the live turn until you freeze it or step back into an earlier turn. '
81 + 'It only reads the conversation; nothing here writes to the session.'
82
83type HelpRow = [readonly string[], string]
84
85const HELP_GROUPS: readonly [string, readonly HelpRow[]][] = [
86 ['MOVE', [[['h', 'l'], 'earlier / later turn'], [['↑ ↓', 'j k'], 'scroll a line'], [['pgup', 'pgdn'], 'scroll a page'], [['g'], 'top']]],
87 ['HOLD', [[['f'], 'freeze this view'], [['r'], 'back to the live turn']]],
88 ['SURFACES', [[['t'], 'next surface'], [['y'], 'copy this surface']]],
89 ['PANE', [[['ctrl+x ⇥'], 'give the pane the keys'], [['esc'], 'hand them back'], [['q'], 'close the pane'], [['m'], 'close this guide']]],
90]
91
92const HELP_TWO_COL_MIN = 84
93
94function helpSection(title: string, rows: readonly HelpRow[], chips: boolean): Span[][] {
95 const ncol = Math.max(...rows.map(([keys]) => keys.length))
96 const widths = Array.from({ length: ncol }, (_, i) => Math.max(1, ...rows.map(([keys]) => cells(keys[i] ?? ''))))
97 const keyW = widths.reduce((a, w) => a + w + (chips ? 2 : 0), 0) + (ncol - 1)
98 const out: Span[][] = [[{ text: title, style: { color: INK.question, bold: true } }]]
99 rows.forEach(([keys, desc], n) => {
100 if (n > 0 && chips) out.push([]) // air between chip rows so they read as keys
101 const line: Span[] = []
102 keys.forEach((k, i) => {
103 if (i > 0) line.push({ text: ' ' })
104 const w = widths[i]!
105 if (chips) {
106 const left = Math.floor((w - cells(k)) / 2)
107 line.push({ text: ` ${' '.repeat(left)}${k}${' '.repeat(w - cells(k) - left)} `, style: { color: INK.badge, bg: INK.rule } })
108 } else {
109 line.push({ text: k + ' '.repeat(w - cells(k)), style: { color: INK.bright, bold: true } })
110 }
111 })
112 line.push({ text: ' '.repeat(Math.max(0, keyW - spanCells(line) + 2)) })
113 line.push({ text: desc, style: { color: INK.question } })
114 out.push(line)
115 })
116 return out
117}
118
119function helpLines(s: ScreenInput, width: number, state: [string, string, Style]): Line[] {
120 const wide = width >= HELP_TWO_COL_MIN
121 const rule = textLine([{ text: '─'.repeat(Math.max(1, width)), style: { color: INK.rule } }])
122 const out: Line[] = []
123
124 out.push(...wrap([{ text: 'claude review', style: { color: INK.bright, bold: true } }, { text: HELP_INTRO, style: { color: INK.question } }], width).map(textLine))
125 out.push(blank(), rule, blank())
126
127 const sections = HELP_GROUPS.map(([title, rows]) => helpSection(title, rows, wide))
128 if (wide) {
129 // MOVE and PANE on the left, HOLD and SURFACES on the right
130 const left = [...sections[0]!, [], ...sections[3]!]
131 const right = [...sections[1]!, [], ...sections[2]!]
132 const leftW = Math.max(...left.map(spanCells))
133 for (let i = 0; i < Math.max(left.length, right.length); i++) {
134 const l = left[i] ?? []
135 out.push(textLine([...l, { text: ' '.repeat(leftW - spanCells(l) + 6) }, ...(right[i] ?? [])]))
136 }
137 } else {
138 sections.forEach((sec, i) => {
139 if (i > 0) out.push(blank())
140 out.push(...sec.map(textLine))
141 })
142 }
143
144 out.push(blank(), rule, blank())
145 const [dot, word, style] = state
146 const value: Style = { color: INK.meta }
147 const facts: [string, string, Style][] = [
148 ['state', `${dot} ${word}`, word === 'frozen' ? style : value],
149 ['session', s.facts?.session ?? '?', value],
150 ['model', s.facts?.model ?? '?', value],
151 ['project', s.facts?.project ?? '?', value],
152 ['turns', s.turnCount > 0 ? `${s.cursor + 1} of ${s.turnCount} on screen` : '0', value],
153 ['pane', `${s.placement}, ${s.columns} × ${s.rows}`, value],
154 ]
155 const labW = Math.max(...facts.map(([k]) => k.length)) + 2
156 out.push(textLine([{ text: 'DIAGNOSTICS', style: { color: INK.rule } }]))
157 for (const [k, v, st] of facts) {
158 out.push(textLine([{ text: k.padEnd(labW), style: { color: INK.rule } }, { text: clip(oneline(v), Math.max(1, width - labW)), style: st }]))
159 }
160
161 return out
162}
163
164// ---------------------------------------------------------------- chrome
165
166// A hairline rule with text inset into it, left (the wordmark) and right (an
167// overflow cue), so labels sit exactly where the content edge is.
168function insetRule(width: number, gutter: number, left: string | null, right: string | null): Span[] {
169 const rule: Style = { color: INK.rule }
170 const spans: Span[] = [{ text: ' '.repeat(gutter) }, { text: '──', style: rule }]
171 let used = gutter + 2
172 const rt = right ? ` ${right} ` : null
173 if (left) {
174 const budget = width - used - gutter - 2 - (rt ? cells(rt) + 2 : 0)
175 const lt = clip(` ${left} `, Math.max(1, budget))
176 spans.push({ text: lt, style: { color: INK.badge, bg: INK.rule } })
177 used += cells(lt)
178 }
179 if (rt) {
180 spans.push({ text: '─'.repeat(Math.max(0, width - used - cells(rt) - 2 - gutter)), style: rule })
181 spans.push({ text: rt, style: { color: INK.meta } }, { text: '──', style: rule })
182 } else {
183 spans.push({ text: '─'.repeat(Math.max(0, width - used - gutter)), style: rule })
184 }
185 return spans
186}
187
188function keyRow(s: ScreenInput, gutter: number, surfaces: readonly Surface[], active: number, state: [string, string, Style]): Span[] {
189 const meta: Style = { color: INK.meta }
190 const [dot, word, style] = state
191 // the state word is padded so a change of state never shifts what follows
192 const left: Span[] = [{ text: ' '.repeat(gutter) }, { text: `${dot} ${word.padEnd(7)}`, style }]
193 if (s.behind) left.push({ text: ' ' }, { text: 'new reply', style: { color: INK.accent } })
194
195 const right: Span[] = (() => {
196 if (s.view.help) return [{ text: '↑↓ scroll · m close', style: meta }]
197 if (s.view.flash) return [{ text: `✓ ${s.view.flash}`, style: { color: INK.accent } }]
198 if (!s.isFocused) return [{ text: 'ctrl+x ⇥ focus', style: meta }]
199 const cues = [s.view.frozen ? 'f unfreeze' : 'f freeze', ...(s.turnCount > 1 ? ['h l turns'] : []), 'm more']
200 return [{ text: cues.join(' · '), style: meta }]
201 })()
202
203 // surface tabs, only when there are several; they give way before the cues
204 const tabs = (mode: 'full' | 'active' | 'none'): Span[] => {
205 if (surfaces.length < 2 || mode === 'none' || s.view.help) return []
206 const on: Style = { color: INK.bright, underline: true }
207 if (mode === 'active') return [{ text: 't ', style: meta }, { text: surfaces[active]!.label, style: on }, { text: ' ' }]
208 return [
209 { text: 't ', style: meta },
210 ...surfaces.flatMap((sf, i) => [{ text: sf.label, style: i === active ? on : meta }, { text: i < surfaces.length - 1 ? ' · ' : ' ', style: meta }]),
211 ]
212 }
213
214 let tab: Span[] = []
215 for (const mode of ['full', 'active', 'none'] as const) {
216 tab = tabs(mode)
217 if (spanCells(left) + spanCells(tab) + spanCells(right) + gutter <= s.columns) break
218 }
219 const pad = Math.max(1, s.columns - spanCells(left) - spanCells(tab) - spanCells(right) - gutter)
220
221 return [...left, { text: ' '.repeat(pad) }, ...tab, ...right]
222}
223
224// ---------------------------------------------------------------- the tree
225
226const isLink = (href: string): boolean => {
227 try {
228 return /^https:\/\/[\x21-\x7e]+$/.test(href) && !href.includes('@') && new URL(href).href === href
229 } catch {
230 return false
231 }
232}
233
234export type Laid = { maxScroll: number; bodyRows: number; surfaces: Surface[]; active: number }
235
236export function screen(E: ElementTable<'terminal'>, s: ScreenInput): { tree: JSX.Element; laid: Laid } {
237 const { Box, Code, Link, Text } = E
238 const W = s.columns
239 const gutter = W >= 60 ? 4 : 2
240 const inner = Math.max(8, W - 2 * gutter)
241 const bodyH = Math.max(1, s.rows - 3)
242
243 const state: [string, string, Style] = s.view.frozen
244 ? ['■', 'frozen', { color: INK.accent }]
245 : s.working ? ['●', 'working', { color: INK.meta }] : ['○', 'idle', { color: INK.meta }]
246
247 const surfaces = surfacesFor(s.turn, s.tasks, s.historical, s.working)
248 const active = Math.min(s.view.surface, surfaces.length - 1)
249
250 // vertical air and the gutter make the content its own column; an earlier
251 // turn leads with the prompt it answered, so it is never read out of context
252 const content = s.view.help ? helpLines(s, inner, state) : surfaces[active]!.lines(inner)
253 const lead = !s.view.help && s.historical && s.turn.question
254 ? [textLine([{ text: clip(`› ${oneline(s.turn.question)}`, inner), style: { color: INK.question } }]), blank()]
255 : []
256 const lines = [blank(), ...lead, ...content, blank()]
257
258 const maxScroll = Math.max(0, lines.length - bodyH)
259 const scroll = Math.max(0, Math.min(s.view.scroll, maxScroll))
260 const window = lines.slice(scroll, scroll + bodyH)
261
262 const styled = (sp: Span): JSX.Element => {
263 const st = sp.style ?? {}
264 const props: Record<string, unknown> = {}
265 if (st.color !== undefined) props.color = `ansi256(${st.color})`
266 if (st.bg !== undefined) props.backgroundColor = `ansi256(${st.bg})`
267 if (st.bold) props.bold = true
268 if (st.italic) props.italic = true
269 if (st.underline) props.underline = true
270 if (st.strike) props.strikethrough = true
271 const t = <Text {...props}>{sp.text}</Text>
272 return st.href && isLink(st.href) ? <Link href={st.href}>{t}</Link> : t
273 }
274 const row = (spans: readonly Span[], indent: number): JSX.Element => (
275 <Text wrap="truncate-end">{' '.repeat(indent)}{spans.map(styled)}</Text>
276 )
277
278 // consecutive rows of one code block draw as one panel, its lines one Code
279 const body: JSX.Element[] = []
280 let i = 0
281 for (; i < window.length;) {
282 const l = window[i]!
283 if (l.kind === 'text') {
284 body.push(row(l.spans, gutter))
285 i += 1
286 continue
287 }
288 const group: Extract<Line, { kind: 'code' }>[] = []
289 while (i < window.length) {
290 const g = window[i]!
291 if (g.kind !== 'code' || g.block !== l.block) break
292 group.push(g)
293 i += 1
294 }
295 const preW = spanCells(l.prefix)
296 const parts: JSX.Element[] = []
297 for (let k = 0; k < group.length;) {
298 if (group[k]!.pad) {
299 parts.push(<Text> </Text>)
300 k += 1
301 continue
302 }
303 const run: string[] = []
304 while (k < group.length && !group[k]!.pad) {
305 run.push(clip(group[k]!.text, inner + 8))
306 k += 1
307 }
308 const language = /^[\w+#.-]{1,24}$/.test(l.language) ? l.language : undefined
309 parts.push(run.some(r => r.trim() !== '')
310 ? <Code source={run.join('\n')} wrap="truncate-end" {...(language ? { language } : {})} />
311 : <Box flexDirection="column">{run.map(() => <Text> </Text>)}</Box>)
312 }
313 body.push(
314 <Box flexDirection="row">
315 {row(l.prefix, gutter)}
316 <Box flexDirection="column" width={Math.max(4, inner - preW)} paddingX={1} backgroundColor={`ansi256(${INK.codeBg})`}>
317 {parts}
318 </Box>
319 </Box>,
320 )
321 }
322 // pad by rows, not elements: a code panel is one element over several rows
323 for (; i < bodyH; i++) body.push(<Text> </Text>)
324
325 const more = maxScroll > 0 && scroll < maxScroll
326 const top = insetRule(W, gutter, 'claude review', scroll > 0 ? '▲' : null)
327 const bottom = insetRule(W, gutter, null, more ? `▼ ${Math.round((100 * scroll) / maxScroll)}%` : null)
328
329 const tree = (
330 <Box flexDirection="column">
331 {row(top, 0)}
332 {body}
333 {row(bottom, 0)}
334 {row(keyRow(s, gutter, surfaces, active, state), 0)}
335 </Box>
336 )
337
338 return { tree, laid: { maxScroll, bodyRows: bodyH, surfaces, active } }
339}
340hooks/transcript.ts 108 lines1import type { SessionMessage } from 'claude-code'
2
3import type { ReviewSnapshot, ReviewTask, ReviewTurn } from '../types'
4
5const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() !== '' ? v.trim() : null)
6
7// A prompt is a user row the person wrote: no tool results, and not a
8// harness record (`<command-name>`, `<local-command-stdout>`), which the CLI
9// skips by its leading `<`.
10const promptText = (m: SessionMessage): string | null =>
11 m.role === 'user' && !(m.toolResults?.length) && !m.text.trimStart().startsWith('<') ? str(m.text) : null
12
13export function renderQuestion(input: Record<string, unknown>): string | null {
14 const questions = Array.isArray(input.questions) ? input.questions : []
15 const blocks = questions.flatMap(q => {
16 if (typeof q !== 'object' || q === null) return []
17 const { header, question, options, multiSelect } = q as Record<string, unknown>
18 if (!str(header) && !str(question)) return []
19 const lines: string[] = []
20 if (str(header)) lines.push(`### ${str(header)}`)
21 if (str(question)) lines.push(str(question)!)
22 for (const op of Array.isArray(options) ? options : []) {
23 const { label, description } = (typeof op === 'object' && op !== null ? op : { label: op }) as Record<string, unknown>
24 if (str(label) && str(description)) lines.push(`- **${str(label)}**: ${str(description)}`)
25 else if (str(label)) lines.push(`- **${str(label)}**`)
26 }
27 if (multiSelect === true) lines.push('_(select all that apply)_')
28 return [lines.join('\n\n')]
29 })
30
31 return blocks.length > 0 ? blocks.join('\n\n---\n\n') : null
32}
33
34// Replays TaskCreate / TaskUpdate over the whole session (ids count from the
35// first create, as Claude Code numbers them); TodoWrite's list stands in when
36// the session used that instead.
37function replayTasks(messages: readonly SessionMessage[]): ReviewTask[] {
38 const tasks = new Map<number, ReviewTask>()
39 let seq = 0
40 let todos: ReviewTask[] | null = null
41
42 for (const use of messages.flatMap(m => (m.role === 'assistant' ? m.toolUses : []))) {
43 const input = use.input
44 if (use.tool === 'TodoWrite' && Array.isArray(input.todos)) {
45 todos = input.todos.flatMap(t => {
46 const { content, status } = (t ?? {}) as Record<string, unknown>
47 return str(content) ? [{ content: str(content)!, status: str(status) ?? 'pending' }] : []
48 })
49 } else if (use.tool === 'TaskCreate') {
50 if ('subagent_type' in input) continue
51 const subject = str(input.subject) ?? str(input.content)
52 if (subject) tasks.set(++seq, { content: subject, status: 'pending' })
53 } else if (use.tool === 'TaskUpdate') {
54 const id = Number(input.taskId)
55 const task = tasks.get(id)
56 if (!task) continue
57 if (input.status === 'deleted') tasks.delete(id)
58 else tasks.set(id, { content: str(input.subject) ?? task.content, status: str(input.status) ?? task.status })
59 }
60 }
61
62 return tasks.size > 0 ? [...tasks.values()] : (todos ?? [])
63}
64
65// A turn is a prompt and everything the assistant produced after it: its
66// latest text, the plan it presented, and a question it still waits on.
67export function buildSnapshot(messages: readonly SessionMessage[]): ReviewSnapshot {
68 const turns: ReviewTurn[] = []
69 let cur: ReviewTurn | null = null
70
71 for (const m of messages) {
72 const prompt = promptText(m)
73 if (prompt !== null) {
74 cur = { question: prompt, text: null, plan: null, planWaiting: false, ask: null }
75 turns.push(cur)
76 continue
77 }
78 if (m.role !== 'assistant') continue
79 if (cur === null) {
80 cur = { question: null, text: null, plan: null, planWaiting: false, ask: null }
81 turns.push(cur)
82 }
83 if (m.text.trim() !== '') cur.text = m.text
84 for (const use of m.toolUses) {
85 if (use.tool === 'ExitPlanMode') {
86 cur.plan = str(use.input.plan) ?? cur.plan
87 cur.planWaiting = use.text === undefined
88 }
89 // shown while Claude is blocked on it; once answered the reply carries on
90 if (use.tool === 'AskUserQuestion') cur.ask = use.text === undefined ? renderQuestion(use.input) : null
91 }
92 }
93
94 return { turns, tasks: replayTasks(messages) }
95}
96
97// What the latest turn shows as a reply. It changes when a reply lands or
98// grows, never when a tool runs, so "new reply" means one.
99export function replySig(snap: ReviewSnapshot): string {
100 const t = snap.turns[snap.turns.length - 1]
101 return t ? JSON.stringify([snap.turns.length, t.text, t.plan, t.ask]) : ''
102}
103
104export const hasReply = (snap: ReviewSnapshot): boolean => {
105 const t = snap.turns[snap.turns.length - 1]
106 return Boolean(t && (t.text || t.plan || t.ask))
107}
108hooks/markdown.ts 375 lines1// Markdown to rows, in the CLI's monochrome reading theme: emphasis comes from
2// weight (bold, italic, underline), never hue, so the only bright thing in the
3// column is the text itself. Code keeps its syntax colours, as rich does.
4
5import { cells, clean, wrap } from './text'
6import type { Span, Style } from './text'
7
8export const INK = {
9 meta: 59, // grey37: the quietest line
10 question: 247, // grey62: frames the content
11 rule: 238, // grey27: hairlines
12 bright: 253, // grey85
13 brightest: 255, // grey93
14 link: 244, // grey50
15 code: 253, // inline code text
16 codeBg: 237, // a step above the dock's grey15, which rich's code bg matches
17 badge: 249, // grey70, on the rule's grey27
18 accent: 6, // cyan: the one colour, for the state you set
19} as const
20
21export type Line =
22 | { kind: 'text'; spans: Span[] }
23 | { kind: 'code'; prefix: Span[]; text: string; language: string; block: number; pad: boolean }
24
25type Block =
26 | { kind: 'heading'; level: number; text: string }
27 | { kind: 'para'; text: string }
28 | { kind: 'code'; language: string; lines: string[] }
29 | { kind: 'hr' }
30 | { kind: 'quote'; blocks: Block[] }
31 | { kind: 'list'; ordered: boolean; start: number; items: Block[][] }
32 | { kind: 'table'; header: string[]; align: ('left' | 'right' | 'center')[]; rows: string[][] }
33
34// ---------------------------------------------------------------- inline
35
36const merge = (a: Style | undefined, b: Style): Style => ({ ...a, ...b })
37
38function closing(src: string, from: number, delim: string, guardWord: boolean): number {
39 let i = from
40 while (i < src.length) {
41 const at = src.indexOf(delim, i)
42 if (at < 0) return -1
43 const before = src[at - 1] ?? ''
44 const after = src[at + delim.length] ?? ''
45 if (at > from && before !== ' ' && (!guardWord || !/[\p{L}\p{N}]/u.test(after))) return at
46 i = at + 1
47 }
48 return -1
49}
50
51export function inline(src: string, base?: Style): Span[] {
52 const out: Span[] = []
53 let text = ''
54 const flush = (): void => {
55 if (text !== '') out.push({ text, style: base })
56 text = ''
57 }
58
59 let i = 0
60 while (i < src.length) {
61 const c = src[i]!
62 const rest = src.slice(i)
63
64 if (c === '\\' && /[!-/:-@[-`{-~]/.test(src[i + 1] ?? '')) {
65 text += src[i + 1]
66 i += 2
67 continue
68 }
69
70 if (c === '`') {
71 const run = /^`+/.exec(rest)![0]
72 const end = src.indexOf(run, i + run.length)
73 if (end > 0) {
74 flush()
75 let code = src.slice(i + run.length, end).replace(/\n/g, ' ')
76 if (code.startsWith(' ') && code.endsWith(' ') && code.trim() !== '') code = code.slice(1, -1)
77 out.push({ text: code, style: merge(base, { color: INK.code, bg: INK.codeBg }) })
78 i = end + run.length
79 continue
80 }
81 }
82
83 const link = /^!?\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
84 if (link) {
85 flush()
86 const [whole, label, href] = link
87 const style = merge(base, { color: INK.link, ...(/^https?:\/\//.test(href!) ? { href } : {}) })
88 out.push(...inline(label || href!, style))
89 i += whole.length
90 continue
91 }
92
93 const auto = /^<(https?:\/\/[^\s>]+)>/.exec(rest)
94 if (auto) {
95 flush()
96 out.push({ text: auto[1]!, style: merge(base, { color: INK.link, href: auto[1] }) })
97 i += auto[0].length
98 continue
99 }
100
101 const strong = /^(\*\*\*|\*\*|__|~~|\*|_)/.exec(rest)
102 if (strong) {
103 const delim = strong[1]!
104 const wordy = delim.startsWith('_')
105 const opensWord = !wordy || !/[\p{L}\p{N}]/u.test(src[i - 1] ?? '')
106 const next = src[i + delim.length] ?? ''
107 const end = opensWord && next !== ' ' && next !== '' ? closing(src, i + delim.length, delim, wordy) : -1
108 if (end > 0) {
109 flush()
110 const style: Style =
111 delim === '***' ? { bold: true, italic: true }
112 : delim === '**' || delim === '__' ? { bold: true }
113 : delim === '~~' ? { strike: true }
114 : { italic: true }
115 out.push(...inline(src.slice(i + delim.length, end), merge(base, style)))
116 i = end + delim.length
117 continue
118 }
119 }
120
121 text += c
122 i += 1
123 }
124 flush()
125
126 return out
127}
128
129// ---------------------------------------------------------------- blocks
130
131const FENCE = /^(\s*)(`{3,}|~{3,})\s*([^`\s]*)/
132const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
133const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
134const QUOTE = /^\s{0,3}>\s?/
135const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(.*)$/
136const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
137
138const indentOf = (s: string): number => /^ */.exec(s)![0].length
139const isBlank = (s: string): boolean => s.trim() === ''
140const cellsOf = (row: string): string[] =>
141 row.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'))
142
143function startsBlock(line: string, next: string | undefined): boolean {
144 return FENCE.test(line) || HEADING.test(line) || HR.test(line) || QUOTE.test(line) || ITEM.test(line)
145 || (line.includes('|') && next !== undefined && TABLE_RULE.test(next) && next.includes('-'))
146}
147
148export function parse(src: string): Block[] {
149 return blocks(clean(src).split('\n'))
150}
151
152function blocks(lines: readonly string[]): Block[] {
153 const out: Block[] = []
154 let i = 0
155
156 while (i < lines.length) {
157 const line = lines[i]!
158 if (isBlank(line)) {
159 i += 1
160 continue
161 }
162
163 const fence = FENCE.exec(line)
164 if (fence) {
165 const [, pad, marks, language] = fence
166 const body: string[] = []
167 i += 1
168 while (i < lines.length && !new RegExp(`^\\s*${marks![0]}{${marks!.length},}\\s*$`).test(lines[i]!)) {
169 body.push(lines[i]!.slice(Math.min(pad!.length, indentOf(lines[i]!))))
170 i += 1
171 }
172 i += 1
173 out.push({ kind: 'code', language: language ?? '', lines: body })
174 continue
175 }
176
177 const heading = HEADING.exec(line)
178 if (heading) {
179 out.push({ kind: 'heading', level: heading[1]!.length, text: heading[2]! })
180 i += 1
181 continue
182 }
183
184 if (HR.test(line)) {
185 out.push({ kind: 'hr' })
186 i += 1
187 continue
188 }
189
190 if (QUOTE.test(line)) {
191 const body: string[] = []
192 while (i < lines.length && !isBlank(lines[i]!) && (QUOTE.test(lines[i]!) || !startsBlock(lines[i]!, lines[i + 1]))) {
193 body.push(lines[i]!.replace(QUOTE, ''))
194 i += 1
195 }
196 out.push({ kind: 'quote', blocks: blocks(body) })
197 continue
198 }
199
200 if (line.includes('|') && lines[i + 1] !== undefined && TABLE_RULE.test(lines[i + 1]!) && lines[i + 1]!.includes('-')) {
201 const header = cellsOf(line)
202 const align = cellsOf(lines[i + 1]!).map(c => (c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left') as 'left' | 'right' | 'center')
203 const rows: string[][] = []
204 i += 2
205 while (i < lines.length && !isBlank(lines[i]!) && lines[i]!.includes('|')) {
206 rows.push(cellsOf(lines[i]!))
207 i += 1
208 }
209 out.push({ kind: 'table', header, align, rows })
210 continue
211 }
212
213 const item = ITEM.exec(line)
214 if (item) {
215 const ordered = /\d/.test(item[2]!)
216 const base = item[1]!.length
217 const items: string[][] = []
218 let start = ordered ? Number.parseInt(item[2]!, 10) : 1
219 let contentIndent = 0
220 while (i < lines.length) {
221 const cur = lines[i]!
222 const m = ITEM.exec(cur)
223 if (m && m[1]!.length <= base + 1 && /\d/.test(m[2]!) === ordered) {
224 if (items.length === 0) start = ordered ? Number.parseInt(m[2]!, 10) : 1
225 contentIndent = m[1]!.length + m[2]!.length + Math.min(m[3]!.length, 4)
226 items.push([m[4]!])
227 i += 1
228 continue
229 }
230 if (isBlank(cur)) {
231 const next = lines[i + 1]
232 if (next === undefined || (indentOf(next) < contentIndent && !(ITEM.test(next) && indentOf(next) <= base + 1))) break
233 items[items.length - 1]!.push('')
234 i += 1
235 continue
236 }
237 if (indentOf(cur) >= contentIndent) {
238 items[items.length - 1]!.push(cur.slice(contentIndent))
239 i += 1
240 continue
241 }
242 // a lazy continuation of the item's paragraph
243 const prev = items[items.length - 1]!
244 if (!isBlank(prev[prev.length - 1] ?? '') && !startsBlock(cur, lines[i + 1])) {
245 prev.push(cur.trim())
246 i += 1
247 continue
248 }
249 break
250 }
251 out.push({ kind: 'list', ordered, start, items: items.map(it => blocks(it)) })
252 continue
253 }
254
255 const para: string[] = []
256 while (i < lines.length && !isBlank(lines[i]!) && (para.length === 0 || !startsBlock(lines[i]!, lines[i + 1]))) {
257 const l = lines[i]!
258 // a hard break: two trailing spaces or a backslash
259 para.push(/( {2,}|\\)$/.test(l) ? `${l.replace(/( {2,}|\\)$/, '')}\n` : l.trim())
260 i += 1
261 }
262 out.push({ kind: 'para', text: para.join(' ').replace(/\n /g, '\n') })
263 }
264
265 return out
266}
267
268// ---------------------------------------------------------------- layout
269
270const text = (spans: Span[]): Line => ({ kind: 'text', spans })
271const blank = (): Line => text([])
272
273function prefixed(lines: Line[], first: Span[], rest: Span[]): Line[] {
274 return lines.map((l, n) => {
275 const pre = n === 0 ? first : rest
276 return l.kind === 'text' ? text([...pre, ...l.spans]) : { ...l, prefix: [...pre, ...l.prefix] }
277 })
278}
279
280let blockSeq = 0
281
282export function layout(src: string, width: number): Line[] {
283 return stack(parse(src), Math.max(8, width), undefined, false)
284}
285
286// Blocks sit a blank row apart, except a list right under its line inside a
287// list item, which rich keeps tight.
288function stack(list: readonly Block[], width: number, base: Style | undefined, inItem: boolean): Line[] {
289 const out: Line[] = []
290 list.forEach((b, n) => {
291 if (n > 0 && !(inItem && b.kind === 'list' && list[n - 1]!.kind === 'para')) out.push(blank())
292 out.push(...block(b, width, base))
293 })
294 return out
295}
296
297function block(b: Block, width: number, base: Style | undefined): Line[] {
298 switch (b.kind) {
299 case 'heading': {
300 const style: Style = b.level === 1 ? { bold: true, underline: true } : b.level <= 3 ? { bold: true } : { bold: true, italic: true }
301 const rows = wrap(inline(b.text, merge(base, style)), width)
302 if (b.level !== 1) return rows.map(text)
303 return rows.map(r => {
304 const pad = Math.max(0, Math.floor((width - r.reduce((w, s) => w + cells(s.text), 0)) / 2))
305 return text([{ text: ' '.repeat(pad) }, ...r])
306 })
307 }
308 case 'para':
309 return wrap(inline(b.text, base), width).map(text)
310 case 'hr':
311 return [text([{ text: '─'.repeat(width), style: { color: INK.rule } }])]
312 case 'code': {
313 const id = ++blockSeq
314 const row = (t: string, pad: boolean): Line => ({ kind: 'code', prefix: [], text: t, language: b.language, block: id, pad })
315 return [row('', true), ...b.lines.map(l => row(l, false)), row('', true)]
316 }
317 case 'quote': {
318 const quoteStyle = merge(base, { color: INK.question, italic: true })
319 const bar: Span = { text: '▌ ', style: { color: INK.question, italic: true } }
320 return prefixed(stack(b.blocks, width - 2, quoteStyle, false), [bar], [bar])
321 }
322 case 'list': {
323 const last = b.start + b.items.length - 1
324 const markW = b.ordered ? String(last).length + 2 : 3
325 const out: Line[] = []
326 b.items.forEach((item, n) => {
327 const mark = b.ordered ? ` ${String(b.start + n).padStart(markW - 2)} ` : ' • '
328 const body = stack(item, width - markW, base, true)
329 out.push(...prefixed(body.length > 0 ? body : [blank()], [{ text: mark, style: { color: INK.question } }], [{ text: ' '.repeat(markW) }]))
330 })
331 return out
332 }
333 case 'table':
334 return table(b, width, base)
335 }
336}
337
338function table(b: Extract<Block, { kind: 'table' }>, width: number, base: Style | undefined): Line[] {
339 const ncol = Math.max(b.header.length, ...b.rows.map(r => r.length))
340 const grid = [b.header, ...b.rows].map(r => Array.from({ length: ncol }, (_, c) => inline(r[c] ?? '', base)))
341 const natural = Array.from({ length: ncol }, (_, c) => Math.max(1, ...grid.map(r => r[c]!.reduce((w, s) => w + cells(s.text), 0))))
342 const gap = 2
343 const room = width - 1 - gap * (ncol - 1)
344 const widths = [...natural]
345 // shrink the widest column a cell at a time until the table fits
346 while (widths.reduce((a, w) => a + w, 0) > room) {
347 const widest = widths.indexOf(Math.max(...widths))
348 if (widths[widest]! <= 4) break
349 widths[widest]! -= 1
350 }
351
352 const out: Line[] = []
353 grid.forEach((row, r) => {
354 const wrapped = row.map((cell, c) => wrap(r === 0 ? cell.map(s => ({ ...s, style: merge(s.style, { bold: true }) })) : cell, widths[c]!))
355 const height = Math.max(...wrapped.map(w => w.length))
356 for (let k = 0; k < height; k++) {
357 const spans: Span[] = [{ text: ' ' }]
358 wrapped.forEach((w, c) => {
359 const content = w[k] ?? []
360 const used = content.reduce((a, s) => a + cells(s.text), 0)
361 const free = Math.max(0, widths[c]! - used)
362 const align = b.align[c] ?? 'left'
363 const left = align === 'right' ? free : align === 'center' ? Math.floor(free / 2) : 0
364 if (left > 0) spans.push({ text: ' '.repeat(left) })
365 spans.push(...content)
366 if (c < ncol - 1) spans.push({ text: ' '.repeat(free - left + gap) })
367 })
368 out.push(text(spans))
369 }
370 if (r === 0) out.push(text([{ text: ' ' }, { text: '─'.repeat(widths.reduce((a, w) => a + w, 0) + gap * (ncol - 1)), style: { color: INK.rule } }]))
371 })
372
373 return out
374}
375hooks/text.ts 132 lines1// Cells and wrapping for the terminal pane. The pane lays out its own rows
2// (as the CLI does with rich's render_lines), so the chrome stays pinned and
3// the overflow cues know exactly how much is above and below.
4
5export type Style = {
6 color?: number
7 bg?: number
8 bold?: boolean
9 italic?: boolean
10 underline?: boolean
11 strike?: boolean
12 href?: string
13}
14
15export type Span = { text: string; style?: Style }
16
17const WIDE: readonly [number, number][] = [
18 [0x1100, 0x115f], [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf],
19 [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
20 [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
21 [0x1f680, 0x1f6ff], [0x1f900, 0x1faff], [0x20000, 0x3fffd],
22]
23
24const ZERO: readonly [number, number][] = [
25 [0x0300, 0x036f], [0x200b, 0x200f], [0x20d0, 0x20ff], [0xfe00, 0xfe0f], [0x1f3fb, 0x1f3ff],
26]
27
28const within = (cp: number, ranges: readonly [number, number][]): boolean =>
29 ranges.some(([lo, hi]) => cp >= lo && cp <= hi)
30
31export function cells(s: string): number {
32 let n = 0
33 for (const ch of s) {
34 const cp = ch.codePointAt(0) ?? 0
35 if (within(cp, ZERO)) continue
36 n += within(cp, WIDE) ? 2 : 1
37 }
38 return n
39}
40
41// Control bytes a transcript may carry (pasted ANSI, OSC 52) never reach the
42// screen: C0 and C1 go, newline stays, tab becomes spaces.
43export const clean = (s: string): string =>
44 s.replace(/\t/g, ' ').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '')
45
46export const oneline = (s: string): string => clean(s).replace(/\s+/g, ' ').trim()
47
48export const spanCells = (spans: readonly Span[]): number => spans.reduce((n, s) => n + cells(s.text), 0)
49
50// Cut to at most `width` cells, with an ellipsis when anything was dropped.
51export function clip(s: string, width: number): string {
52 if (cells(s) <= width) return s
53 let out = ''
54 for (const ch of s) {
55 if (cells(out + ch) > width - 1) break
56 out += ch
57 }
58 return `${out}…`
59}
60
61type Piece = { text: string; style?: Style; space: boolean }
62
63// Greedy word wrap over styled spans. `first` and `rest` prefix the first and
64// following rows (a list marker, then its hanging indent). A `\n` inside a
65// span is a hard break; a word wider than the row is cut by cells.
66export function wrap(spans: readonly Span[], width: number, first: readonly Span[] = [], rest: readonly Span[] = first): Span[][] {
67 const pieces: Piece[] = []
68 for (const span of spans) {
69 for (const part of span.text.split(/(\n| +)/)) {
70 if (part === '') continue
71 pieces.push({ text: part, style: span.style, space: part !== '\n' && part.trim() === '' })
72 }
73 }
74
75 const rows: Span[][] = []
76 let row: Span[] = [...first]
77 let used = spanCells(first)
78 let empty = true
79
80 const push = (): void => {
81 rows.push(row)
82 row = [...rest]
83 used = spanCells(rest)
84 empty = true
85 }
86
87 for (const p of pieces) {
88 if (p.text === '\n') {
89 push()
90 continue
91 }
92 if (p.space) {
93 if (!empty) {
94 row.push({ text: ' ', style: p.style })
95 used += 1
96 }
97 continue
98 }
99 let word = p.text
100 if (!empty && used + cells(word) > width) {
101 while (row.length > 0 && row[row.length - 1]!.text === ' ') {
102 row.pop()
103 used -= 1
104 }
105 push()
106 }
107 // a word wider than the row: cut it by cells
108 while (cells(word) > width - used) {
109 let head = ''
110 for (const ch of word) {
111 if (cells(head + ch) > width - used) break
112 head += ch
113 }
114 if (head === '') head = [...word][0] ?? ''
115 row.push({ text: head, style: p.style })
116 word = word.slice(head.length)
117 push()
118 }
119 if (word !== '') {
120 row.push({ text: word, style: p.style })
121 used += cells(word)
122 empty = false
123 }
124 }
125 if (!empty || rows.length === 0 || row.length > rest.length) rows.push(row)
126
127 return rows.map(r => {
128 while (r.length > 0 && r[r.length - 1]!.text === ' ') r.pop()
129 return r
130 })
131}
132types/index.d.ts 40 lines1export type ReviewTask = { content: string; status: string }
2
3export type ReviewTurn = {
4 question: string | null
5 text: string | null
6 plan: string | null
7 // the plan still waits on the person's approval
8 planWaiting: boolean
9 ask: string | null
10}
11
12export type ReviewSnapshot = {
13 turns: ReviewTurn[]
14 tasks: ReviewTask[]
15}
16
17// What the pane shows, as the CLI's review loop keeps it: the turn on screen
18// (null follows the live one), whether it is frozen and what it froze, the
19// surface and scroll, the reply it last caught up to, the guide, a flash.
20export type ReviewView = {
21 index: number | null
22 frozen: boolean
23 held: ReviewTurn | null
24 surface: number
25 scroll: number
26 seen: string
27 help: boolean
28 flash: string | null
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'review-pane': {
34 snapshot: ReviewSnapshot
35 view: ReviewView
36 working: string[]
37 }
38 }
39}
40