SLOPSHOPPER

review-pane

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

newpanecommandtimer
★ 2v1.0.0MITupdated 2026-10-02r3al1tym/claude-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · review-pane
│ ┃ claude-review ✕ › fix the failing auth test and add an audit log call │ ┃ ── claude review ────────────────────────… │ ┃ ⏺ Read(src/auth.ts) │ ┃ I updated src/auth.ts to reject expired c… ⎿ Read 6 lines │ ┃ added an audit call. ⏺ 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 │ ┃ │ ┃ › /claude-review │ ┃ ⎿ review-pane: Review pane closed. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claude-review
── claude review ─────────────────────────────────── I updated src/auth.ts to reject expired claims and added an audit call. ──────────────────────────────────────────────────── ○ idle ctrl+x ⇥ focus
README

claude-review

License: MIT Claude Code mod

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 Code with the review pane docked on the right: the transcript on the left, the latest reply rendered in a calm reading column on the right

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.

Install

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.

Use

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.

KeysWhat they do
h learlier / later turn; an earlier turn leads with the prompt it answered
↑ ↓ j k, wheel, PgUp PgDn, Home End, gscroll
ffreeze the view while Claude keeps working; again to unfreeze
rback to the live turn
tnext surface: plan, question, response, tasks, whichever this turn has
ycopy the surface shown
mthe guide: every key, plus the session's diagnostics
qclose 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.

How it works

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.

Limitations

  • Early access. Function hooks are an early-access Claude Code surface that may change between releases. This version is checked against Claude Code 2.1.287 with claude plugin validate . and claude plugin test ..
  • Letter keys. A pane's keys are letters and digits, so the guide is m and surfaces are t, and the pane needs the keyboard (ctrl+x then Tab) before they work.
  • No horizontal scroll. Wide tables shrink their widest columns and wrap.

The standalone CLI

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.

Contributing

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.

License

MIT © r3al1tym.

Source 6 files
hooks/register.tsx 284 lines
1import { 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}
284
hooks/screen.tsx 340 lines
1// 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}
340
hooks/transcript.ts 108 lines
1import 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}
108
hooks/markdown.ts 375 lines
1// 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}
375
hooks/text.ts 132 lines
1// 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}
132
types/index.d.ts 40 lines
1export 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