Long replies fold to their first lines, tables draw without box lines, and each table and code block gets a copy button.

reply-view makes long replies of Claude short and structured on the screen. A reply longer than 12 lines folds to its first lines. A markdown table draws without box lines. Each table and each fenced code block gets a copy button. /replies switches the view off and on for the session. The model still reads the full reply. Only the screen changes.
The approved mock is in docs/mocks.md, section 7.2, with the decisions in section 7.3.
/plugin install reply-view --marketplace niksavis/handily
A reply longer than 12 lines draws its first 12 lines, a dim count of the hidden lines, and two buttons at the right edge:
● The release is done. task-pane 0.3.0 and agent-board 0.2.0 are published, and every gate passed.
- Step 1 of the release ran without a warning.
…
- Step 9 of the release ran without a warning.
… 33 more lines [ more ] [ copy ]
more shows the whole reply, with less and copy under its last line. less folds it again.copy copies the whole reply as markdown, as Claude wrote it. A toast says what it copied, or why the copy failed.….A table loses its box lines. The columns stay aligned, the header is bold, and a column that the table aligns to the right stays right-aligned. Two buttons stand under the table, at its right edge:
Mod State Next
task-pane released follows the work
simple-view released click to expand
[ copy ] [ copy as text ]
When the columns do not fit the width of the screen, each row draws as a block. The first cell is the bold title of the block, and each other cell is a label: value line:
task-pane
State: released
Next: follows the work
copy copies the table as markdown, as Claude wrote it.copy as text copies label: value lines, one block for each row, for an email or a chat. A table with no data rows has no copy as text button., no backticks, a link as its text. The text between two backticks keeps its marks, such as /*.ts.copy as text leaves it out.A fenced block gets a dim title line from its tag, and a copy button at the right edge. The content draws with the highlighting of Claude Code for that language:
── plan ────────────────────────────────────────────────────────── [ copy ]
1. Write the mocks
2. Ask the person
copy copies the content of the block only, without the fences. A block without a tag draws a rule with no title.
| Command | Effect |
|---|---|
/replies | Turns the reply view off or on for this session, and replies with the new mode. It starts on |
While the view is off, Claude Code draws every reply unchanged.
/replies turned the view off for this session.\u200b. The joiners inside an emoji, such as the family emoji, stay hidden. copy and copy as text keep the characters as Claude wrote them.more or less keeps one small value for that reply in $.state, so the reply stays open or folded when it draws again.hooks/register.ts 106 lines1import type { EngineInterface, Register, RenderElement, RenderSurface } from 'claude-code'
2import type { ReplyViewFold, ReplyViewMode } from '../types'
3import { DEFAULT_COLUMNS, replyTree, type ReplyActions } from './draw'
4import { blocksOf } from './markdown'
5
6const COMMAND = 'replies'
7const DEFAULT_MODE: ReplyViewMode = 'on'
8const MODE = { plugin: 'reply-view', key: 'mode' } as const
9const DRAWN_CHARACTERS_AT_MOST = 60_000
10
11const ON_TEXT =
12 'on for this session. A reply longer than 12 lines folds to its first lines. Tables draw without box lines, and tables and code blocks get copy buttons.'
13const OFF_TEXT = 'off for this session. Replies draw as Claude Code draws them.'
14const USAGE_TEXT = '/replies takes no argument. It turns the reply view off or on for this session.'
15
16const COPY_REFUSALS: Readonly<Record<'no-surface' | 'no-clipboard' | 'refused', string>> = {
17 'no-surface': 'no screen is attached to this session',
18 'no-clipboard': 'the clipboard took nothing. The text may be too long for this terminal',
19 refused: 'another plugin refused the copy',
20}
21
22function logFailure($: EngineInterface, what: string, error: unknown): void {
23 $.ui.log(`reply-view: ${what}: ${String(error)}`)
24}
25
26async function toggle($: EngineInterface): Promise<string> {
27 const { value: current = DEFAULT_MODE } = await $.state.get(MODE)
28 const mode: ReplyViewMode = current === 'on' ? 'off' : 'on'
29 await $.state.set(MODE, mode)
30 return mode === 'on' ? ON_TEXT : OFF_TEXT
31}
32
33async function copyText(
34 $: EngineInterface,
35 text: string,
36 what: string,
37 surface: RenderSurface,
38): Promise<void> {
39 try {
40 const result = await $.ui.copy({ text, surface })
41 $.ui.toast(result.isCopied ? `Copied ${what}.` : `Not copied: ${COPY_REFUSALS[result.reason]}.`)
42 } catch (error) {
43 logFailure($, `${what} was not copied`, error)
44 $.ui.toast('Not copied. The debug log says why.')
45 }
46}
47
48function replyActions($: EngineInterface, id: string): ReplyActions {
49 return {
50 copy: (text, what, surface) => {
51 void copyText($, text, what, surface)
52 },
53 fold: (next) => {
54 void $.state.set({ plugin: 'reply-view', key: 'folds', id }, next).catch((error: unknown) => {
55 logFailure($, `the reply did not ${next === 'folded' ? 'fold' : 'open'}`, error)
56 })
57 },
58 }
59}
60
61async function foldOf($: EngineInterface, id: string): Promise<ReplyViewFold> {
62 const { value = 'folded' } = await $.state.get({ plugin: 'reply-view', key: 'folds', id })
63 return value
64}
65
66export const register: Register = (on) => {
67 on('session.start', async ($, e, next) => {
68 await $.command.register({
69 name: COMMAND,
70 description: 'Turn the folded view of long replies, tables and code blocks off or on',
71 })
72 await $.state.set({ plugin: 'reply-view', key: 'ready' }, { root: $.plugin.root })
73 return next(e)
74 })
75
76 on('command.run', { command: COMMAND }, async ($, e) => {
77 if (e.args.trim() !== '') return { text: USAGE_TEXT }
78 return { text: await toggle($) }
79 })
80
81 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
82 if (e.props.isSummary === true || e.props.text.length > DRAWN_CHARACTERS_AT_MOST) return next(e)
83 let drawn: RenderElement | null = null
84 try {
85 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
86 if (mode === 'on') {
87 drawn = replyTree(
88 $.ui.resolve(e),
89 e.props.text,
90 blocksOf(e.props.text),
91 {
92 columns: e.viewport?.columns ?? DEFAULT_COLUMNS,
93 hasBullet: e.surface === 'terminal',
94 isFirstOfReply: e.props.isFirstOfReply,
95 },
96 await foldOf($, e.requestId),
97 replyActions($, e.requestId),
98 )
99 }
100 } catch (error) {
101 logFailure($, `the engine draws reply ${e.requestId}`, error)
102 }
103 return drawn ?? next(e)
104 })
105}
106hooks/draw.ts 398 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2import type { ReplyViewFold } from '../types'
3import { shownText, type Block, type Fence, type Prose, type Table } from './markdown'
4import {
5 fieldsOf,
6 labelWidth,
7 lineOf,
8 rowTitle,
9 tableLook,
10 tableWidth,
11 textCopy,
12 type TableLook,
13} from './table'
14import { cutToWidth, displayWidth } from './width'
15
16export const FOLD_AFTER_ROWS = 12
17export const DEFAULT_COLUMNS = 80
18const BULLET_COLUMNS = 2
19const FIELD_INDENT = 2
20const COPY_BUTTON_COLUMNS = '[ copy ]'.length
21const TABLE_BUTTONS_COLUMNS = '[ copy ] [ copy as text ]'.length
22const RULE = '─'
23const LANGUAGE_TAG = /^[\w+#.-]{1,32}$/
24const TAG_COLUMNS_AT_MOST = 24
25
26export type ReplyElements = Pick<
27 Elements[RenderSurface],
28 'Box' | 'Text' | 'Button' | 'Markdown' | 'Code'
29>
30
31export type ReplyActions = {
32 copy: (text: string, what: string, surface: RenderSurface) => void
33 fold: (next: ReplyViewFold) => void
34}
35
36export type ReplyLayout = { columns: number; hasBullet: boolean; isFirstOfReply: boolean }
37
38type Drawing = { element: RenderElement; rows: number }
39
40type Piece = { rows: number; whole: () => RenderElement; cut: (rows: number) => Drawing | null }
41
42type Context = { elements: ReplyElements; width: number; actions: ReplyActions }
43
44function rowsOf(line: string, width: number): number {
45 return Math.max(1, Math.ceil(displayWidth(line) / Math.max(width, 1)))
46}
47
48function sum(numbers: readonly number[]): number {
49 return numbers.reduce((total, value) => total + value, 0)
50}
51
52function plural(count: number, word: string): string {
53 return `${String(count)} ${word}${count === 1 ? '' : 's'}`
54}
55
56function copyButton(context: Context, key: string, label: string, text: string, what: string) {
57 return context.elements.Button({
58 key,
59 label,
60 dimColor: true,
61 onPress: (press) => {
62 context.actions.copy(text, what, press.surface)
63 },
64 })
65}
66
67function cutLine(line: string, columns: number): string {
68 const kept = cutToWidth(line, Math.max(columns - 1, 1))
69 const space = kept.lastIndexOf(' ')
70 const word = space > kept.length / 2 ? kept.slice(0, space) : kept
71 return `${word.trimEnd()}…`
72}
73
74function prosePiece(context: Context, prose: Prose): Piece {
75 const lines = prose.lines.map(shownText)
76 const rows = lines.map((line, index) => {
77 if (line.trim() !== '') return rowsOf(line, context.width)
78 return (lines[index - 1] ?? '').trim() === '' ? 0 : 1
79 })
80 const draw = (shown: readonly string[]) => context.elements.Markdown({ text: shown.join('\n') })
81 return {
82 rows: sum(rows),
83 whole: () => draw(lines),
84 cut: (budget) => {
85 const shown: string[] = []
86 let used = 0
87 for (const [index, line] of lines.entries()) {
88 const needed = rows[index] ?? 1
89 if (used + needed <= budget) {
90 shown.push(line)
91 used += needed
92 continue
93 }
94 if (budget > used) {
95 shown.push(cutLine(line, (budget - used) * context.width))
96 used = budget
97 }
98 break
99 }
100 return shown.length === 0 ? null : { element: draw(shown), rows: used }
101 },
102 }
103}
104
105function tableButtons(context: Context, table: Table, key: string, width: number) {
106 const { Box } = context.elements
107 const text = textCopy(table)
108 return Box({
109 key: `${key}-buttons`,
110 width: Math.max(width, TABLE_BUTTONS_COLUMNS),
111 justifyContent: 'flex-end',
112 children: Box({
113 flexDirection: 'row',
114 gap: 1,
115 children: [
116 copyButton(context, `${key}-copy`, 'copy', table.source, 'the table as markdown'),
117 ...(text === ''
118 ? []
119 : [copyButton(context, `${key}-text`, 'copy as text', text, 'the table as text')]),
120 ],
121 }),
122 })
123}
124
125function wideLines(context: Context, look: TableLook, rows: readonly string[][]) {
126 const { Box, Text } = context.elements
127 const line = (text: string, key: string, isHeader: boolean) =>
128 Box({ key, children: Text({ bold: isHeader, wrap: 'truncate-end', children: text }) })
129 return [
130 line(lineOf(look, look.header), 'header', true),
131 ...rows.map((row, index) => line(lineOf(look, row), `row-${String(index)}`, false)),
132 ]
133}
134
135function rowBlock(
136 context: Context,
137 look: TableLook,
138 key: string,
139 row: readonly string[],
140 index: number,
141) {
142 const { Box, Text } = context.elements
143 const labels = labelWidth(look)
144 return Box({
145 key: `${key}-row-${String(index)}`,
146 flexDirection: 'column',
147 children: [
148 Text({ bold: true, wrap: 'truncate-end', children: rowTitle(row, index) }),
149 ...fieldsOf(look, row).map((field, fieldIndex) =>
150 Box({
151 key: `field-${String(fieldIndex)}`,
152 flexDirection: 'row',
153 paddingLeft: FIELD_INDENT,
154 children: [
155 Box({ width: labels, flexShrink: 0, children: Text({ children: `${field.label}:` }) }),
156 Box({ flexShrink: 1, marginLeft: 1, children: Text({ children: field.value }) }),
157 ],
158 }),
159 ),
160 ],
161 })
162}
163
164function headingsBlock(context: Context, look: TableLook, key: string) {
165 const { Box, Text } = context.elements
166 return Box({
167 key: `${key}-headings`,
168 flexDirection: 'column',
169 children: look.header.map((heading, index) =>
170 Box({ key: `heading-${String(index)}`, children: Text({ bold: true, children: heading }) }),
171 ),
172 })
173}
174
175function blockRows(context: Context, look: TableLook, row: readonly string[]): number {
176 const valueWidth = context.width - FIELD_INDENT - labelWidth(look) - 1
177 return 1 + sum(fieldsOf(look, row).map((field) => rowsOf(field.value, valueWidth)))
178}
179
180function tablePiece(context: Context, table: Table, key: string): Piece {
181 const { Box } = context.elements
182 const look = tableLook(table)
183 const width = tableWidth(look)
184 const column = (children: RenderElement[], gap = 0) =>
185 Box({ key, flexDirection: 'column', gap, children })
186 if (width <= context.width) {
187 return {
188 rows: 2 + look.rows.length,
189 whole: () =>
190 column([...wideLines(context, look, look.rows), tableButtons(context, table, key, width)]),
191 cut: (budget) => {
192 if (budget < 2) return null
193 const rows = look.rows.slice(0, budget - 1)
194 return { element: column(wideLines(context, look, rows)), rows: rows.length + 1 }
195 },
196 }
197 }
198 const hasRows = look.rows.length > 0
199 const heights = hasRows
200 ? look.rows.map((row) => blockRows(context, look, row))
201 : [sum(look.header.map((heading) => rowsOf(heading, context.width)))]
202 const blocks = hasRows
203 ? look.rows.map((row, index) => rowBlock(context, look, key, row, index))
204 : [headingsBlock(context, look, key)]
205 return {
206 rows: sum(heights) + Math.max(blocks.length - 1, 0) + 1,
207 whole: () =>
208 Box({
209 key,
210 flexDirection: 'column',
211 children: [column(blocks, 1), tableButtons(context, table, key, context.width)],
212 }),
213 cut: (budget) => {
214 let used = 0
215 let count = 0
216 for (const height of heights) {
217 const needed = height + (count > 0 ? 1 : 0)
218 if (used + needed > budget) break
219 used += needed
220 count += 1
221 }
222 return count === 0 ? null : { element: column(blocks.slice(0, count), 1), rows: used }
223 },
224 }
225}
226
227function fenceTitle(context: Context, fence: Fence, key: string): RenderElement {
228 const { Box, Text } = context.elements
229 const room = Math.max(context.width - COPY_BUTTON_COLUMNS - 2, 4)
230 const tag = cutToWidth(shownText(fence.tag), TAG_COLUMNS_AT_MOST)
231 const head = tag === '' ? '' : `${RULE.repeat(2)} ${tag} `
232 const rule = head + RULE.repeat(Math.max(room - displayWidth(head), 2))
233 const what = tag === '' ? 'the block' : `the ${tag} block`
234 return Box({
235 key: `${key}-title`,
236 flexDirection: 'row',
237 children: [
238 Box({
239 flexShrink: 1,
240 children: Text({ dimColor: true, wrap: 'truncate-end', children: rule }),
241 }),
242 Box({
243 flexShrink: 0,
244 marginLeft: 2,
245 children: copyButton(context, `${key}-copy`, 'copy', fence.content, what),
246 }),
247 ],
248 })
249}
250
251function fencePiece(context: Context, fence: Fence, key: string): Piece {
252 const { Box, Code } = context.elements
253 const lines = fence.content === '' ? [] : shownText(fence.content).split('\n')
254 const rows = lines.map((line) => rowsOf(line, context.width))
255 const language = LANGUAGE_TAG.test(fence.tag) ? fence.tag : undefined
256 const draw = (shown: readonly string[]) =>
257 Box({
258 key,
259 flexDirection: 'column',
260 children: [
261 fenceTitle(context, fence, key),
262 shown.length === 0 ? null : Code({ source: shown.join('\n'), language }),
263 ],
264 })
265 return {
266 rows: 1 + sum(rows),
267 whole: () => draw(lines),
268 cut: (budget) => {
269 if (budget < 1) return null
270 let used = 1
271 let count = 0
272 for (const height of rows) {
273 if (used + height > budget) break
274 used += height
275 count += 1
276 }
277 return { element: draw(lines.slice(0, count)), rows: used }
278 },
279 }
280}
281
282function pieceOf(context: Context, block: Block, index: number): Piece {
283 const key = `block-${String(index)}`
284 switch (block.kind) {
285 case 'prose':
286 return prosePiece(context, block)
287 case 'table':
288 return tablePiece(context, block, key)
289 case 'fence':
290 return fencePiece(context, block, key)
291 }
292}
293
294function foldedPieces(pieces: readonly Piece[]): Drawing[] {
295 const shown: Drawing[] = []
296 let left = FOLD_AFTER_ROWS
297 for (const piece of pieces) {
298 const gap = shown.length > 0 ? 1 : 0
299 if (piece.rows + gap <= left) {
300 shown.push({ element: piece.whole(), rows: piece.rows + gap })
301 left -= piece.rows + gap
302 continue
303 }
304 const cut = left - gap > 0 ? piece.cut(left - gap) : null
305 if (cut !== null) shown.push({ element: cut.element, rows: cut.rows + gap })
306 break
307 }
308 return shown
309}
310
311function replyButtons(context: Context, fold: ReplyViewFold, text: string): RenderElement {
312 const { Box, Button } = context.elements
313 return Box({
314 flexDirection: 'row',
315 flexShrink: 0,
316 gap: 1,
317 children: [
318 Button({
319 key: 'fold',
320 label: fold === 'folded' ? 'more' : 'less',
321 dimColor: true,
322 onPress: () => {
323 context.actions.fold(fold === 'folded' ? 'open' : 'folded')
324 },
325 }),
326 copyButton(context, 'copy-reply', 'copy', text, 'the reply as markdown'),
327 ],
328 })
329}
330
331function foldRow(context: Context, fold: ReplyViewFold, text: string, hidden: number) {
332 const { Box, Text } = context.elements
333 return Box({
334 key: 'fold-row',
335 flexDirection: 'row',
336 children: [
337 fold === 'folded'
338 ? Box({
339 flexShrink: 1,
340 children: Text({ dimColor: true, children: `… ${plural(hidden, 'more line')}` }),
341 })
342 : null,
343 Box({ flexGrow: 1, minWidth: 2 }),
344 replyButtons(context, fold, text),
345 ],
346 })
347}
348
349function withBullet(elements: ReplyElements, layout: ReplyLayout, body: RenderElement) {
350 if (!layout.hasBullet) return body
351 const { Box, Text } = elements
352 return Box({
353 flexDirection: 'row',
354 children: [
355 Box({
356 width: BULLET_COLUMNS,
357 flexShrink: 0,
358 children: layout.isFirstOfReply ? Text({ children: '●' }) : null,
359 }),
360 Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: body }),
361 ],
362 })
363}
364
365export function replyTree(
366 elements: ReplyElements,
367 text: string,
368 blocks: readonly Block[],
369 layout: ReplyLayout,
370 fold: ReplyViewFold,
371 actions: ReplyActions,
372): RenderElement | null {
373 const width = layout.columns - (layout.hasBullet ? BULLET_COLUMNS : 0)
374 const context: Context = { elements, width, actions }
375 const pieces = blocks.map((block, index) => pieceOf(context, block, index))
376 const total = sum(pieces.map((piece) => piece.rows)) + Math.max(pieces.length - 1, 0)
377 const isLong = total > FOLD_AFTER_ROWS
378 if (!isLong && blocks.every((block) => block.kind === 'prose')) return null
379 const { Box } = elements
380 const shown =
381 isLong && fold === 'folded'
382 ? foldedPieces(pieces)
383 : pieces.map((piece) => ({ element: piece.whole(), rows: piece.rows }))
384 const hidden = total - sum(shown.map((drawing) => drawing.rows))
385 const body = Box({
386 flexDirection: 'column',
387 children: [
388 Box({
389 flexDirection: 'column',
390 gap: 1,
391 children: shown.map((drawing) => drawing.element),
392 }),
393 isLong ? foldRow(context, fold, text, hidden) : null,
394 ],
395 })
396 return withBullet(elements, layout, body)
397}
398hooks/markdown.ts 216 lines1import { graphemesOf, isEmoji } from './width'
2
3export type Alignment = 'left' | 'right' | 'center'
4
5export type Prose = { kind: 'prose'; lines: string[] }
6
7export type Table = {
8 kind: 'table'
9 source: string
10 header: string[]
11 rows: string[][]
12 alignments: Alignment[]
13}
14
15export type Fence = { kind: 'fence'; tag: string; content: string }
16
17export type Block = Prose | Table | Fence
18
19const FENCE_OPENING = /^( {0,3})(`{3,}|~{3,})(.*)$/
20const FENCE_CLOSING = /^ {0,3}(`{3,}|~{3,})\s*$/
21const DELIMITER_CELL = /^:?-+:?$/
22const HIDDEN_CHARACTER = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu
23const KEPT_CONTROLS: ReadonlySet<string> = new Set(['\t', '\n'])
24const EMOJI_JOINER = /^[\u200D\u{E0020}-\u{E007F}]$/u
25const LINK = /!?\[([^\]]*)\]\([^)]*\)/g
26const PAIRED_MARK = /(\*\*|__|~~)(.+?)\1/g
27const ESCAPE_OR_CODE_SPAN = /\\([\\`*_~|[\]])|`([^`]*)`/g
28const PRIVATE_USE_START = 0xe000
29const STAR_EMPHASIS = /\*(\S(?:[^*]*\S)?)\*/g
30const LINE_BREAK_TAG = /<br\s*\/?>/gi
31const INDENTED_CODE = /^(?: {4}|\t)/
32const LEADING_SPACES = /^ */
33
34function escapedUnits(text: string): string {
35 return Array.from(
36 { length: text.length },
37 (_, index) => `\\u${text.charCodeAt(index).toString(16).padStart(4, '0')}`,
38 ).join('')
39}
40
41function shownGrapheme(grapheme: string): string {
42 const keepsJoiners = isEmoji(grapheme)
43 return grapheme.replace(HIDDEN_CHARACTER, (found) =>
44 KEPT_CONTROLS.has(found) || (keepsJoiners && EMOJI_JOINER.test(found))
45 ? found
46 : escapedUnits(found),
47 )
48}
49
50export function shownText(text: string): string {
51 return graphemesOf(text).map(shownGrapheme).join('')
52}
53
54function unusedCharacter(text: string): string {
55 let code = PRIVATE_USE_START
56 while (text.includes(String.fromCodePoint(code))) code += 1
57 return String.fromCodePoint(code)
58}
59
60export function plainCell(cell: string): string {
61 const mark = unusedCharacter(cell)
62 const literals: string[] = []
63 const held = cell.replace(ESCAPE_OR_CODE_SPAN, (_found, escaped?: string, code?: string) => {
64 literals.push(escaped ?? code ?? '')
65 return `${mark}${String(literals.length - 1)}${mark}`
66 })
67 return held
68 .replace(LINE_BREAK_TAG, ' ')
69 .replace(LINK, '$1')
70 .replace(PAIRED_MARK, '$2')
71 .replace(STAR_EMPHASIS, '$1')
72 .replace(
73 new RegExp(`${mark}(\\d+)${mark}`, 'gu'),
74 (_found, index: string) => literals[Number(index)] ?? '',
75 )
76 .replace(/\t/g, ' ')
77}
78
79export function cellsOf(line: string): string[] {
80 let text = line.trim()
81 if (text.startsWith('|')) text = text.slice(1)
82 if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
83 const cells: string[] = []
84 let cell = ''
85 for (let index = 0; index < text.length; index += 1) {
86 const character = text.charAt(index)
87 if (character === '\\' && text.charAt(index + 1) === '|') {
88 cell += '\\|'
89 index += 1
90 } else if (character === '|') {
91 cells.push(cell.trim())
92 cell = ''
93 } else {
94 cell += character
95 }
96 }
97 cells.push(cell.trim())
98 return cells
99}
100
101function alignmentOf(cell: string): Alignment {
102 if (cell.startsWith(':') && cell.endsWith(':')) return 'center'
103 return cell.endsWith(':') ? 'right' : 'left'
104}
105
106function delimiterOf(line: string, columns: number): Alignment[] | null {
107 if (!line.includes('-')) return null
108 const cells = cellsOf(line)
109 if (cells.length !== columns || !cells.every((cell) => DELIMITER_CELL.test(cell))) return null
110 return cells.map(alignmentOf)
111}
112
113function isTableRow(line: string): boolean {
114 return line.trim() !== '' && line.includes('|') && !INDENTED_CODE.test(line)
115}
116
117function fitted(cells: string[], columns: number): string[] {
118 return Array.from({ length: columns }, (_, index) => cells[index] ?? '')
119}
120
121function tableAt(lines: readonly string[], start: number): { table: Table; end: number } | null {
122 const headerLine = lines[start] ?? ''
123 if (!isTableRow(headerLine)) return null
124 const header = cellsOf(headerLine)
125 const alignments = delimiterOf(lines[start + 1] ?? '', header.length)
126 if (alignments === null) return null
127 let end = start + 2
128 while (
129 end < lines.length &&
130 isTableRow(lines[end] ?? '') &&
131 openingOf(lines[end] ?? '') === null
132 ) {
133 end += 1
134 }
135 return {
136 table: {
137 kind: 'table',
138 source: lines.slice(start, end).join('\n'),
139 header,
140 rows: lines.slice(start + 2, end).map((line) => fitted(cellsOf(line), header.length)),
141 alignments,
142 },
143 end,
144 }
145}
146
147function isClosing(line: string, fence: string): boolean {
148 const marks = FENCE_CLOSING.exec(line)?.[1]
149 return marks !== undefined && marks[0] === fence[0] && marks.length >= fence.length
150}
151
152type Opening = { indent: number; marks: string; info: string }
153
154function openingOf(line: string): Opening | null {
155 const found = FENCE_OPENING.exec(line)
156 const indent = found?.[1]
157 const marks = found?.[2]
158 const info = found?.[3] ?? ''
159 if (indent === undefined || marks === undefined) return null
160 if (marks.startsWith('`') && info.includes('`')) return null
161 return { indent: indent.length, marks, info }
162}
163
164function withoutIndent(line: string, indent: number): string {
165 const spaces = LEADING_SPACES.exec(line)?.[0].length ?? 0
166 return line.slice(Math.min(spaces, indent))
167}
168
169function fenceAt(lines: readonly string[], start: number): { fence: Fence; end: number } | null {
170 const opening = openingOf(lines[start] ?? '')
171 if (opening === null) return null
172 let end = start + 1
173 while (end < lines.length && !isClosing(lines[end] ?? '', opening.marks)) end += 1
174 const tag = opening.info.trim().split(/\s+/)[0] ?? ''
175 const content = lines.slice(start + 1, end).map((line) => withoutIndent(line, opening.indent))
176 return {
177 fence: { kind: 'fence', tag, content: content.join('\n') },
178 end: Math.min(end + 1, lines.length),
179 }
180}
181
182function trimmedProse(lines: readonly string[]): Prose | null {
183 let first = 0
184 let last = lines.length
185 while (first < last && (lines[first] ?? '').trim() === '') first += 1
186 while (last > first && (lines[last - 1] ?? '').trim() === '') last -= 1
187 return first === last ? null : { kind: 'prose', lines: lines.slice(first, last) }
188}
189
190export function blocksOf(markdown: string): Block[] {
191 const lines = markdown.split(/\r?\n/)
192 const blocks: Block[] = []
193 let prose: string[] = []
194 const endProse = () => {
195 const kept = trimmedProse(prose)
196 if (kept !== null) blocks.push(kept)
197 prose = []
198 }
199 let index = 0
200 while (index < lines.length) {
201 const fence = fenceAt(lines, index)
202 const table = fence === null ? tableAt(lines, index) : null
203 const found = fence?.fence ?? table?.table
204 if (found === undefined) {
205 prose.push(lines[index] ?? '')
206 index += 1
207 continue
208 }
209 endProse()
210 blocks.push(found)
211 index = fence?.end ?? table?.end ?? index + 1
212 }
213 endProse()
214 return blocks
215}
216hooks/table.ts 78 lines1import { plainCell, shownText, type Alignment, type Table } from './markdown'
2import { displayWidth, padToWidth } from './width'
3
4export const COLUMN_GAP = 3
5
6export type TableLook = {
7 header: string[]
8 rows: string[][]
9 alignments: Alignment[]
10 widths: number[]
11}
12
13function plainHeader(table: Table): string[] {
14 return table.header.map((cell, index) => plainCell(cell) || `Column ${String(index + 1)}`)
15}
16
17function plainRows(table: Table): string[][] {
18 return table.rows.map((row) => row.map(plainCell))
19}
20
21export function tableLook(table: Table): TableLook {
22 const header = plainHeader(table).map(shownText)
23 const rows = plainRows(table).map((row) => row.map(shownText))
24 const widths = header.map((cell, index) =>
25 Math.max(displayWidth(cell), ...rows.map((row) => displayWidth(row[index] ?? ''))),
26 )
27 return { header, rows, alignments: table.alignments, widths }
28}
29
30export function tableWidth(look: TableLook): number {
31 return look.widths.reduce((sum, width) => sum + width, 0) + COLUMN_GAP * (look.widths.length - 1)
32}
33
34function aligned(text: string, width: number, alignment: Alignment): string {
35 const room = Math.max(width - displayWidth(text), 0)
36 if (alignment === 'right') return ' '.repeat(room) + text
37 if (alignment === 'center') return padToWidth(' '.repeat(Math.floor(room / 2)) + text, width)
38 return padToWidth(text, width)
39}
40
41export function lineOf(look: TableLook, cells: readonly string[]): string {
42 return cells
43 .map((cell, index) => aligned(cell, look.widths[index] ?? 0, look.alignments[index] ?? 'left'))
44 .join(' '.repeat(COLUMN_GAP))
45 .trimEnd()
46}
47
48export function rowTitle(row: readonly string[], index: number): string {
49 const first = row[0] ?? ''
50 return first === '' ? `Row ${String(index + 1)}` : first
51}
52
53export type Field = { label: string; value: string }
54
55export function fieldsOf(look: TableLook, row: readonly string[]): Field[] {
56 return look.header
57 .slice(1)
58 .map((label, index) => ({ label, value: row[index + 1] ?? '' }))
59 .filter((field) => field.value !== '')
60}
61
62export function labelWidth(look: TableLook): number {
63 return Math.max(0, ...look.header.slice(1).map(displayWidth)) + 1
64}
65
66export function textCopy(table: Table): string {
67 const header = plainHeader(table)
68 return plainRows(table)
69 .map((row) =>
70 header
71 .map((label, index) => ({ label, value: row[index] ?? '' }))
72 .filter((field) => field.value !== '')
73 .map((field) => `${field.label}: ${field.value}`)
74 .join('\n'),
75 )
76 .join('\n\n')
77}
78hooks/width.ts 49 lines1const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}]$/u
2
3const DOUBLE_WIDTH =
4 /^[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]$/u
5
6const EMOJI = /\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F/u
7
8const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
9
10export function graphemesOf(text: string): string[] {
11 return Array.from(GRAPHEMES.segment(text), (part) => part.segment)
12}
13
14export function isEmoji(grapheme: string): boolean {
15 return EMOJI.test(grapheme)
16}
17
18function columnsOfCharacter(character: string): number {
19 if (ZERO_WIDTH.test(character)) return 0
20 return DOUBLE_WIDTH.test(character) ? 2 : 1
21}
22
23function columnsOf(grapheme: string): number {
24 if (isEmoji(grapheme)) return 2
25 return Math.max(0, ...Array.from(grapheme, columnsOfCharacter))
26}
27
28export function displayWidth(text: string): number {
29 let width = 0
30 for (const grapheme of graphemesOf(text)) width += columnsOf(grapheme)
31 return width
32}
33
34export function cutToWidth(text: string, width: number): string {
35 let kept = ''
36 let used = 0
37 for (const grapheme of graphemesOf(text)) {
38 const columns = columnsOf(grapheme)
39 if (used + columns > width) break
40 kept += grapheme
41 used += columns
42 }
43 return kept
44}
45
46export function padToWidth(text: string, width: number): string {
47 return text + ' '.repeat(Math.max(width - displayWidth(text), 0))
48}
49types/index.d.ts 14 lines1export type ReplyViewMode = 'on' | 'off'
2
3export type ReplyViewFold = 'folded' | 'open'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'reply-view': {
8 mode: ReplyViewMode
9 folds: StateFamily<ReplyViewFold>
10 ready: { root: string }
11 }
12 }
13}
14