Long replies fold to their first lines, tables draw without box lines, each table and code block gets a copy button, and each prompt you type opens under a…

reply-view makes long replies of Claude short and structured on the screen. A reply longer than 30 lines folds to its first lines. A markdown table draws with dim rules between its columns and under its header, and without a box around it. Each table and each fenced code block gets a copy button. Each prompt that you type opens under a coloured rule labelled you, so you find it when you scroll. /replies switches the view off and on for the session. The model still reads the full reply and the prompt as you typed it. Only the screen changes.
The approved mock is in docs/mocks.md, section 7.2, with the decisions in section 7.3.
/plugin install handily-reply-view --marketplace niksavis/handily
A reply longer than 30 lines draws its first 30 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 27 of the release ran without a warning.
… 15 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. The columns stay aligned, the header is bold, and a column that the table aligns to the right stays right-aligned. A dim │ stands between the columns on every line, and a dim ─ line with a ┼ at each column stands under the header. The rules span the width of the table, not the width of the screen. Each rule character takes one column, so the rules do not change the widths of the columns. The copy and copy as text buttons stand on the header line when they fit after the headings:
Mod │ State │ Next [ copy ] [ copy as text ]
────────────┼──────────┼─────────────────
task-pane │ released │ follows the work
simple-view │ released │ click to expand
When the table does not fit the width of the screen, a long cell wraps at the spaces inside its column, and the rows stay aligned. A column whose widest cell is 12 cells or less is short and keeps its width. Every other column is long. The long columns share the rest of the width by the width of their widest cell, and each long column gets at least 12 cells when the screen has room for that. When a cell wraps, a dim dotted ┄ line separates the rows, so you can see where each row ends. A table where no cell wraps has no row rules.
At 120 columns, a column of short phrases such as Buttons on the header row is long, so it wraps and gives its width to the other long columns:
Change │ Why │ Effect [ copy ] [ copy as text ]
──────────────┼────────────────────────────────────────────────────────┼──────────────────────────────────────────────
Buttons on │ The buttons took a line of their own under every │ A table that fits draws its buttons after the
the header │ table, so a short table took one line more than it │ headings, and the reply is one line shorter
row │ needed │
┄┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
Wrapped cells │ A wide table drew one block for each row, so the │ Long cells wrap inside their columns and the
│ reader lost the columns │ rows stay aligned on a normal screen
The view puts the buttons in the first of these places where they fit:
A heading or a cell never moves and is never cut to make room for the buttons. The buttons end at the right edge of the table. The │ rules on the last line count as its text, so the buttons fit on the last row only when that line ends early. At 80 columns, this table has no room on its header line or its last row, so the buttons go under it:
Mod │ Change │ Risk │ Owner
───────────┼─────────────────────────────────┼─────────────────────────┼──────
reply-view │ Tables wrap their long cells │ A wide table can still │ Ana
│ inside their columns and keep │ take many lines on a │
│ the rows aligned │ narrow screen │
┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┼┄┄┄┄┄┄
task-pane │ Each task tool call draws as │ Low │ Bo
│ one row │ │
[ copy ] [ copy as text ]
A screen narrower than 40 columns draws each row as a block. The view also draws blocks when a column of the table would get fewer than 6 cells. The first cell is the bold title of the block, and each other cell is a label: value line. The buttons stand on the title of the first block when they fit there, and otherwise on a line of their own under the last block:
task-pane [ copy ] [ copy as text ]
State: released
Next: follows the work
copy copies the table as markdown, as Claude wrote it.copy as text copies one block of label: value lines for each row, with an empty line between the blocks. The text has no markdown marks, no table pipes and no backslash escapes, so it pastes into an email or a chat as it is. A link copies as its text and its address in parentheses. An entity such as & copies as its character.copy as text does not copy an aligned table. Spaces line up only in a font where each character has the same width, and most emails and chats use another font.copy as text button., no _, no backticks, no backslash escapes, 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.
A prompt that you type keeps the row that Claude Code draws, with its grey background band. The mod adds a blank line and a rule above it. The rule spans the width of the transcript, names you and the time when the prompt entered the session, and has the suggestion colour of your theme:
● ok
── you · 22:47 ──────────────────────────────────────────────────────────────────
❯ make the human prompts in the terminal distinguishable from your answers, and
reply with the single word ok
● ok
you without a time, because the mod knows no time for it. The mod never guesses a time.you without a time.--verbose draw each prompt in full as Claude Code draws it, without the rule, so a copy of that text holds no rule characters.| 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 and every prompt 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.$.state for this session.hooks/register.ts 156 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'
5import { framedPrompt } from './prompt'
6
7const COMMAND = 'replies'
8const DEFAULT_MODE: ReplyViewMode = 'on'
9const MODE = { plugin: 'handily-reply-view', key: 'mode' } as const
10const DRAWN_CHARACTERS_AT_MOST = 60_000
11
12const ON_TEXT =
13 'on for this session. A reply longer than 30 lines folds to its first lines. Tables draw without box lines, and tables and code blocks get copy buttons. Each prompt that you type opens under a rule labelled you.'
14const OFF_TEXT = 'off for this session. Replies and prompts draw as Claude Code draws them.'
15const USAGE_TEXT = '/replies takes no argument. It turns the reply view off or on for this session.'
16
17const COPY_REFUSALS: Readonly<Record<'no-surface' | 'no-clipboard' | 'refused', string>> = {
18 'no-surface': 'no screen is attached to this session',
19 'no-clipboard': 'the clipboard took nothing. The text may be too long for this terminal',
20 refused: 'another plugin refused the copy',
21}
22
23function logFailure($: EngineInterface, what: string, error: unknown): void {
24 $.ui.log(`reply-view: ${what}: ${String(error)}`)
25}
26
27async function toggle($: EngineInterface): Promise<string> {
28 const { value: current = DEFAULT_MODE } = await $.state.get(MODE)
29 const mode: ReplyViewMode = current === 'on' ? 'off' : 'on'
30 await $.state.set(MODE, mode)
31 return mode === 'on' ? ON_TEXT : OFF_TEXT
32}
33
34async function copyText(
35 $: EngineInterface,
36 text: string,
37 what: string,
38 surface: RenderSurface,
39): Promise<void> {
40 try {
41 const result = await $.ui.copy({ text, surface })
42 $.ui.toast(result.isCopied ? `Copied ${what}.` : `Not copied: ${COPY_REFUSALS[result.reason]}.`)
43 } catch (error) {
44 logFailure($, `${what} was not copied`, error)
45 $.ui.toast('Not copied. The debug log says why.')
46 }
47}
48
49function replyActions($: EngineInterface, id: string): ReplyActions {
50 return {
51 copy: (text, what, surface) => {
52 void copyText($, text, what, surface)
53 },
54 fold: (next) => {
55 void $.state
56 .set({ plugin: 'handily-reply-view', key: 'folds', id }, next)
57 .catch((error: unknown) => {
58 logFailure($, `the reply did not ${next === 'folded' ? 'fold' : 'open'}`, error)
59 })
60 },
61 }
62}
63
64async function foldOf($: EngineInterface, id: string): Promise<ReplyViewFold> {
65 const { value = 'folded' } = await $.state.get({ plugin: 'handily-reply-view', key: 'folds', id })
66 return value
67}
68
69export const register: Register = (on) => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({
72 name: COMMAND,
73 description:
74 'handily · Turn the folded view of long replies, tables, code blocks and prompt rules off or on',
75 })
76 await $.state.set({ plugin: 'handily-reply-view', key: 'ready' }, { root: $.plugin.root })
77 return next(e)
78 })
79
80 on('command.run', { command: COMMAND }, async ($, e) => {
81 if (e.args.trim() !== '') return { text: USAGE_TEXT }
82 return { text: await toggle($) }
83 })
84
85 on('session.append', { door: ['prompt', 'delivery'] }, async ($, e, next) => {
86 const isTyped = e.origin.kind === 'composer' && e.agentId === undefined
87 const appendedAt = await $.clock.now()
88 const stored = await next(e)
89 if (isTyped && stored.uuid !== undefined) {
90 try {
91 await $.state.set(
92 { plugin: 'handily-reply-view', key: 'submittedAt', id: stored.uuid },
93 appendedAt,
94 )
95 } catch (error) {
96 logFailure($, `the time of prompt ${stored.uuid} was not kept`, error)
97 }
98 }
99 return stored
100 })
101
102 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
103 const isFramed =
104 e.surface === 'terminal' &&
105 e.props.origin.kind === 'composer' &&
106 e.props.from === undefined &&
107 e.props.task === undefined &&
108 !e.props.isExpanded
109 if (!isFramed) return next(e)
110 const engineRow = await next(e)
111 try {
112 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
113 if (mode === 'off') return engineRow
114 const { value: submittedAt } = await $.state.get({
115 plugin: 'handily-reply-view',
116 key: 'submittedAt',
117 id: e.requestId,
118 })
119 return framedPrompt(
120 $.ui.resolve(e),
121 engineRow,
122 e.viewport?.columns ?? DEFAULT_COLUMNS,
123 submittedAt,
124 )
125 } catch (error) {
126 logFailure($, `the engine draws prompt ${e.requestId} without its rule`, error)
127 return engineRow
128 }
129 })
130
131 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
132 if (e.props.isSummary === true || e.props.text.length > DRAWN_CHARACTERS_AT_MOST) return next(e)
133 let drawn: RenderElement | null = null
134 try {
135 const { value: mode = DEFAULT_MODE } = await $.state.get(MODE)
136 if (mode === 'on') {
137 drawn = replyTree(
138 $.ui.resolve(e),
139 e.props.text,
140 blocksOf(e.props.text),
141 {
142 columns: e.viewport?.columns ?? DEFAULT_COLUMNS,
143 hasBullet: e.surface === 'terminal',
144 isFirstOfReply: e.props.isFirstOfReply,
145 },
146 await foldOf($, e.requestId),
147 replyActions($, e.requestId),
148 )
149 }
150 } catch (error) {
151 logFailure($, `the engine draws reply ${e.requestId}`, error)
152 }
153 return drawn ?? next(e)
154 })
155}
156hooks/draw.ts 547 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 columnWidths,
6 fieldsOf,
7 HEADER_RULE,
8 labelWidth,
9 linesOf,
10 ROW_RULE,
11 ruleLine,
12 rowTitle,
13 spanLines,
14 tableLook,
15 tableWidth,
16 textCopy,
17 type Span,
18 type TableLook,
19} from './table'
20import { cutToWidth, displayWidth } from './width'
21
22export const FOLD_AFTER_ROWS = 30
23export const DEFAULT_COLUMNS = 80
24const BULLET_COLUMNS = 2
25const FIELD_INDENT = 2
26const COPY_BUTTON_COLUMNS = '[ copy ]'.length
27const TABLE_BUTTONS_COLUMNS = '[ copy ] [ copy as text ]'.length
28const BUTTONS_GAP = 2
29const BLOCKS_BELOW_COLUMNS = 40
30const RULE = '─'
31const LANGUAGE_TAG = /^[\w+#.-]{1,32}$/
32const TAG_COLUMNS_AT_MOST = 24
33
34export type ReplyElements = Pick<
35 Elements[RenderSurface],
36 'Box' | 'Text' | 'Button' | 'Markdown' | 'Code'
37>
38
39export type ReplyActions = {
40 copy: (text: string, what: string, surface: RenderSurface) => void
41 fold: (next: ReplyViewFold) => void
42}
43
44export type ReplyLayout = { columns: number; hasBullet: boolean; isFirstOfReply: boolean }
45
46type Drawing = { element: RenderElement; rows: number }
47
48type Piece = { rows: number; whole: () => RenderElement; cut: (rows: number) => Drawing | null }
49
50type Context = { elements: ReplyElements; width: number; columns: number; actions: ReplyActions }
51
52function rowsOf(line: string, width: number): number {
53 return Math.max(1, Math.ceil(displayWidth(line) / Math.max(width, 1)))
54}
55
56function sum(numbers: readonly number[]): number {
57 return numbers.reduce((total, value) => total + value, 0)
58}
59
60function plural(count: number, word: string): string {
61 return `${String(count)} ${word}${count === 1 ? '' : 's'}`
62}
63
64function copyButton(context: Context, key: string, label: string, text: string, what: string) {
65 return context.elements.Button({
66 key,
67 label,
68 dimColor: true,
69 onPress: (press) => {
70 context.actions.copy(text, what, press.surface)
71 },
72 })
73}
74
75function cutLine(line: string, columns: number): string {
76 const kept = cutToWidth(line, Math.max(columns - 1, 1))
77 const space = kept.lastIndexOf(' ')
78 const word = space > kept.length / 2 ? kept.slice(0, space) : kept
79 return `${word.trimEnd()}…`
80}
81
82function prosePiece(context: Context, prose: Prose): Piece {
83 const lines = prose.lines.map(shownText)
84 const rows = lines.map((line, index) => {
85 if (line.trim() !== '') return rowsOf(line, context.width)
86 return (lines[index - 1] ?? '').trim() === '' ? 0 : 1
87 })
88 const draw = (shown: readonly string[]) => context.elements.Markdown({ text: shown.join('\n') })
89 return {
90 rows: sum(rows),
91 whole: () => draw(lines),
92 cut: (budget) => {
93 const shown: string[] = []
94 let used = 0
95 for (const [index, line] of lines.entries()) {
96 const needed = rows[index] ?? 1
97 if (used + needed <= budget) {
98 shown.push(line)
99 used += needed
100 continue
101 }
102 if (budget > used) {
103 shown.push(cutLine(line, (budget - used) * context.width))
104 used = budget
105 }
106 break
107 }
108 return shown.length === 0 ? null : { element: draw(shown), rows: used }
109 },
110 }
111}
112
113type Buttons = { element: RenderElement; columns: number }
114
115function tableButtons(context: Context, table: Table, key: string): Buttons {
116 const { Box } = context.elements
117 const text = textCopy(table)
118 return {
119 columns: text === '' ? COPY_BUTTON_COLUMNS : TABLE_BUTTONS_COLUMNS,
120 element: Box({
121 flexDirection: 'row',
122 flexShrink: 0,
123 gap: 1,
124 children: [
125 copyButton(context, `${key}-copy`, 'copy', table.source, 'the table as markdown'),
126 ...(text === ''
127 ? []
128 : [copyButton(context, `${key}-text`, 'copy as text', text, 'the table as text')]),
129 ],
130 }),
131 }
132}
133
134function lineWithButtons(
135 context: Context,
136 text: RenderElement | null,
137 buttons: Buttons,
138 end: number,
139): RenderElement {
140 const { Box } = context.elements
141 return Box({
142 key: 'buttons-line',
143 flexDirection: 'row',
144 width: Math.max(end, buttons.columns),
145 children: [
146 text,
147 Box({ flexGrow: 1, minWidth: text === null ? 0 : BUTTONS_GAP }),
148 buttons.element,
149 ],
150 })
151}
152
153function boldLine(context: Context, text: string, key: string, wrap?: 'truncate-end') {
154 const { Box, Text } = context.elements
155 return Box({ key, flexShrink: 1, children: Text({ bold: true, wrap, children: text }) })
156}
157
158function tableLine(context: Context, spans: readonly Span[], key: string, isHeader: boolean) {
159 const { Box, Text } = context.elements
160 const children = spans.map((span) => {
161 if (span.kind === 'rule') return Text({ dimColor: true, children: span.text })
162 return isHeader && span.kind === 'cell' ? Text({ bold: true, children: span.text }) : span.text
163 })
164 return Box({ key, flexShrink: 1, children: Text({ wrap: 'truncate-end', children }) })
165}
166
167function ruleRow(context: Context, text: string, key: string) {
168 const { Box, Text } = context.elements
169 return Box({ key, children: Text({ dimColor: true, wrap: 'truncate-end', children: text }) })
170}
171
172type ButtonsPlace = 'header' | 'last-row' | 'under'
173
174function buttonsPlace(
175 context: Context,
176 room: number,
177 header: readonly string[],
178 rows: readonly string[][],
179): ButtonsPlace {
180 const fitsAfter = (line: string) => displayWidth(line) + room <= context.width
181 if (fitsAfter(header[0] ?? '')) return 'header'
182 const last = rows.at(-1)?.at(-1)
183 return last !== undefined && fitsAfter(last) ? 'last-row' : 'under'
184}
185
186function columnsPiece(
187 context: Context,
188 table: Table,
189 look: TableLook,
190 widths: readonly number[],
191 key: string,
192): Piece {
193 const { Box } = context.elements
194 const buttons = tableButtons(context, table, key)
195 const room = buttons.columns + BUTTONS_GAP
196 const width = tableWidth(widths)
197 const header = linesOf(look, widths, look.header)
198 const headerSpans = spanLines(look, widths, look.header)
199 const rowSpans = look.rows.map((row) => spanLines(look, widths, row))
200 const rows = look.rows.map((row) => linesOf(look, widths, row))
201 const isWrapped = [header, ...rows].some((lines) => lines.length > 1)
202 const linesBetweenRows = isWrapped ? 1 : 0
203 const place = buttonsPlace(context, room, header, rows)
204 const withButtons = (text: RenderElement, line: string) =>
205 lineWithButtons(
206 context,
207 text,
208 buttons,
209 Math.min(context.width, Math.max(width, displayWidth(line) + room)),
210 )
211 const heading = [
212 ...headerSpans.map((spans, index) => {
213 const text = tableLine(
214 context,
215 spans,
216 index === 0 ? 'header' : `header-${String(index)}`,
217 true,
218 )
219 return index === 0 && place === 'header' ? withButtons(text, header[0] ?? '') : text
220 }),
221 ruleRow(context, ruleLine(widths, HEADER_RULE), 'header-rule'),
222 ]
223 const lastRow = rows.length - 1
224 const drawnRows = rowSpans.map((lines, index) =>
225 Box({
226 key: `row-${String(index)}`,
227 flexDirection: 'column',
228 children: lines.map((spans, lineIndex) => {
229 const text = tableLine(context, spans, `line-${String(lineIndex)}`, false)
230 const isLast = index === lastRow && lineIndex === lines.length - 1
231 return place === 'last-row' && isLast
232 ? withButtons(text, rows[index]?.[lineIndex] ?? '')
233 : text
234 }),
235 }),
236 )
237 const rowRule = ruleLine(widths, ROW_RULE)
238 const ruled = (shown: readonly RenderElement[]) =>
239 shown.flatMap((row, index) =>
240 index > 0 && isWrapped ? [ruleRow(context, rowRule, `rule-${String(index)}`), row] : [row],
241 )
242 const under = place === 'under' ? [lineWithButtons(context, null, buttons, width)] : []
243 const rowsBox = (shown: readonly RenderElement[]) =>
244 Box({ key: 'rows', flexDirection: 'column', children: ruled(shown) })
245 const column = (children: RenderElement[]) => Box({ key, flexDirection: 'column', children })
246 const rowRules = linesBetweenRows * Math.max(rows.length - 1, 0)
247 return {
248 rows: heading.length + sum(rows.map((lines) => lines.length)) + rowRules + under.length,
249 whole: () => column([...heading, rowsBox(drawnRows), ...under]),
250 cut: (budget) => {
251 let used = heading.length
252 let count = 0
253 for (const lines of rows) {
254 const needed = lines.length + (count > 0 ? linesBetweenRows : 0)
255 if (used + needed > budget) break
256 used += needed
257 count += 1
258 }
259 if (used > budget || (count === 0 && rows.length > 0)) return null
260 return { element: column([...heading, rowsBox(drawnRows.slice(0, count))]), rows: used }
261 },
262 }
263}
264
265function titleLine(context: Context, buttons: Buttons | null, title: string): RenderElement {
266 const text = boldLine(context, title, 'title', 'truncate-end')
267 return buttons === null ? text : lineWithButtons(context, text, buttons, context.width)
268}
269
270function rowBlock(
271 context: Context,
272 look: TableLook,
273 row: readonly string[],
274 index: number,
275 buttons: Buttons | null,
276): Drawing {
277 const { Box, Text } = context.elements
278 const labels = labelWidth(look)
279 const fields = fieldsOf(look, row)
280 const valueWidth = context.width - FIELD_INDENT - labels - 1
281 return {
282 rows: 1 + sum(fields.map((field) => rowsOf(field.value, valueWidth))),
283 element: Box({
284 key: `row-${String(index)}`,
285 flexDirection: 'column',
286 children: [
287 titleLine(context, buttons, rowTitle(row, index)),
288 ...fields.map((field, fieldIndex) =>
289 Box({
290 key: `field-${String(fieldIndex)}`,
291 flexDirection: 'row',
292 paddingLeft: FIELD_INDENT,
293 children: [
294 Box({
295 width: labels,
296 flexShrink: 0,
297 children: Text({ children: `${field.label}:` }),
298 }),
299 Box({ flexShrink: 1, marginLeft: 1, children: Text({ children: field.value }) }),
300 ],
301 }),
302 ),
303 ],
304 }),
305 }
306}
307
308function headingsBlock(context: Context, look: TableLook, buttons: Buttons | null): Drawing {
309 const { Box } = context.elements
310 const [first = '', ...more] = look.header
311 return {
312 rows: 1 + sum(more.map((heading) => rowsOf(heading, context.width))),
313 element: Box({
314 key: 'headings',
315 flexDirection: 'column',
316 children: [
317 titleLine(context, buttons, first),
318 ...more.map((heading, index) => boldLine(context, heading, `heading-${String(index)}`)),
319 ],
320 }),
321 }
322}
323
324function blocksPiece(context: Context, table: Table, look: TableLook, key: string): Piece {
325 const { Box } = context.elements
326 const buttons = tableButtons(context, table, key)
327 const firstRow = look.rows[0]
328 const firstTitle = firstRow === undefined ? (look.header[0] ?? '') : rowTitle(firstRow, 0)
329 const isOnTitle = displayWidth(firstTitle) + BUTTONS_GAP + buttons.columns <= context.width
330 const titled = isOnTitle ? buttons : null
331 const blocks =
332 look.rows.length > 0
333 ? look.rows.map((row, index) =>
334 rowBlock(context, look, row, index, index === 0 ? titled : null),
335 )
336 : [headingsBlock(context, look, titled)]
337 const under = isOnTitle ? [] : [lineWithButtons(context, null, buttons, context.width)]
338 const column = (shown: readonly Drawing[]) =>
339 Box({
340 key: `${key}-blocks`,
341 flexDirection: 'column',
342 gap: 1,
343 children: shown.map((block) => block.element),
344 })
345 return {
346 rows: sum(blocks.map((block) => block.rows)) + blocks.length - 1 + under.length,
347 whole: () => Box({ key, flexDirection: 'column', children: [column(blocks), ...under] }),
348 cut: (budget) => {
349 let used = 0
350 let count = 0
351 for (const block of blocks) {
352 const needed = block.rows + (count > 0 ? 1 : 0)
353 if (used + needed > budget) break
354 used += needed
355 count += 1
356 }
357 return count === 0 ? null : { element: column(blocks.slice(0, count)), rows: used }
358 },
359 }
360}
361
362function columnsFor(context: Context, look: TableLook): number[] | null {
363 const isNarrow = context.columns < BLOCKS_BELOW_COLUMNS
364 if (isNarrow && tableWidth(look.widths) > context.width) return null
365 return columnWidths(look.widths, context.width)
366}
367
368function tablePiece(context: Context, table: Table, key: string): Piece {
369 const look = tableLook(table)
370 const widths = columnsFor(context, look)
371 return widths === null
372 ? blocksPiece(context, table, look, key)
373 : columnsPiece(context, table, look, widths, key)
374}
375
376function fenceTitle(context: Context, fence: Fence, key: string): RenderElement {
377 const { Box, Text } = context.elements
378 const room = Math.max(context.width - COPY_BUTTON_COLUMNS - 2, 4)
379 const tag = cutToWidth(shownText(fence.tag), TAG_COLUMNS_AT_MOST)
380 const head = tag === '' ? '' : `${RULE.repeat(2)} ${tag} `
381 const rule = head + RULE.repeat(Math.max(room - displayWidth(head), 2))
382 const what = tag === '' ? 'the block' : `the ${tag} block`
383 return Box({
384 key: `${key}-title`,
385 flexDirection: 'row',
386 children: [
387 Box({
388 flexShrink: 1,
389 children: Text({ dimColor: true, wrap: 'truncate-end', children: rule }),
390 }),
391 Box({
392 flexShrink: 0,
393 marginLeft: 2,
394 children: copyButton(context, `${key}-copy`, 'copy', fence.content, what),
395 }),
396 ],
397 })
398}
399
400function fencePiece(context: Context, fence: Fence, key: string): Piece {
401 const { Box, Code } = context.elements
402 const lines = fence.content === '' ? [] : shownText(fence.content).split('\n')
403 const rows = lines.map((line) => rowsOf(line, context.width))
404 const language = LANGUAGE_TAG.test(fence.tag) ? fence.tag : undefined
405 const draw = (shown: readonly string[]) =>
406 Box({
407 key,
408 flexDirection: 'column',
409 children: [
410 fenceTitle(context, fence, key),
411 shown.length === 0 ? null : Code({ source: shown.join('\n'), language }),
412 ],
413 })
414 return {
415 rows: 1 + sum(rows),
416 whole: () => draw(lines),
417 cut: (budget) => {
418 if (budget < 1) return null
419 let used = 1
420 let count = 0
421 for (const height of rows) {
422 if (used + height > budget) break
423 used += height
424 count += 1
425 }
426 return { element: draw(lines.slice(0, count)), rows: used }
427 },
428 }
429}
430
431function pieceOf(context: Context, block: Block, index: number): Piece {
432 const key = `block-${String(index)}`
433 switch (block.kind) {
434 case 'prose':
435 return prosePiece(context, block)
436 case 'table':
437 return tablePiece(context, block, key)
438 case 'fence':
439 return fencePiece(context, block, key)
440 }
441}
442
443function foldedPieces(pieces: readonly Piece[]): Drawing[] {
444 const shown: Drawing[] = []
445 let left = FOLD_AFTER_ROWS
446 for (const piece of pieces) {
447 const gap = shown.length > 0 ? 1 : 0
448 if (piece.rows + gap <= left) {
449 shown.push({ element: piece.whole(), rows: piece.rows + gap })
450 left -= piece.rows + gap
451 continue
452 }
453 const cut = left - gap > 0 ? piece.cut(left - gap) : null
454 if (cut !== null) shown.push({ element: cut.element, rows: cut.rows + gap })
455 break
456 }
457 return shown
458}
459
460function replyButtons(context: Context, fold: ReplyViewFold, text: string): RenderElement {
461 const { Box, Button } = context.elements
462 return Box({
463 flexDirection: 'row',
464 flexShrink: 0,
465 gap: 1,
466 children: [
467 Button({
468 key: 'fold',
469 label: fold === 'folded' ? 'more' : 'less',
470 dimColor: true,
471 onPress: () => {
472 context.actions.fold(fold === 'folded' ? 'open' : 'folded')
473 },
474 }),
475 copyButton(context, 'copy-reply', 'copy', text, 'the reply as markdown'),
476 ],
477 })
478}
479
480function foldRow(context: Context, fold: ReplyViewFold, text: string, hidden: number) {
481 const { Box, Text } = context.elements
482 return Box({
483 key: 'fold-row',
484 flexDirection: 'row',
485 children: [
486 fold === 'folded'
487 ? Box({
488 flexShrink: 1,
489 children: Text({ dimColor: true, children: `… ${plural(hidden, 'more line')}` }),
490 })
491 : null,
492 Box({ flexGrow: 1, minWidth: 2 }),
493 replyButtons(context, fold, text),
494 ],
495 })
496}
497
498function withBullet(elements: ReplyElements, layout: ReplyLayout, body: RenderElement) {
499 if (!layout.hasBullet) return body
500 const { Box, Text } = elements
501 return Box({
502 flexDirection: 'row',
503 children: [
504 Box({
505 width: BULLET_COLUMNS,
506 flexShrink: 0,
507 children: layout.isFirstOfReply ? Text({ children: '●' }) : null,
508 }),
509 Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: body }),
510 ],
511 })
512}
513
514export function replyTree(
515 elements: ReplyElements,
516 text: string,
517 blocks: readonly Block[],
518 layout: ReplyLayout,
519 fold: ReplyViewFold,
520 actions: ReplyActions,
521): RenderElement | null {
522 const width = layout.columns - (layout.hasBullet ? BULLET_COLUMNS : 0)
523 const context: Context = { elements, width, columns: layout.columns, actions }
524 const pieces = blocks.map((block, index) => pieceOf(context, block, index))
525 const total = sum(pieces.map((piece) => piece.rows)) + Math.max(pieces.length - 1, 0)
526 const isLong = total > FOLD_AFTER_ROWS
527 if (!isLong && blocks.every((block) => block.kind === 'prose')) return null
528 const { Box } = elements
529 const shown =
530 isLong && fold === 'folded'
531 ? foldedPieces(pieces)
532 : pieces.map((piece) => ({ element: piece.whole(), rows: piece.rows }))
533 const hidden = total - sum(shown.map((drawing) => drawing.rows))
534 const body = Box({
535 flexDirection: 'column',
536 children: [
537 Box({
538 flexDirection: 'column',
539 gap: 1,
540 children: shown.map((drawing) => drawing.element),
541 }),
542 isLong ? foldRow(context, fold, text, hidden) : null,
543 ],
544 })
545 return withBullet(elements, layout, body)
546}
547hooks/markdown.ts 261 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 = /!?\[([^\]]*)\]\(\s*<?([^)\s>]*)>?[^)]*\)/g
26const AUTOLINK = /<((?:https?:\/\/|mailto:)[^>\s]+)>/g
27const PAIRED_MARK = /(\*\*|__|~~)(.+?)\1/g
28const ESCAPE_OR_CODE_SPAN = /\\([\u0021-\u002f\u003a-\u0040\u005b-\u0060\u007b-\u007e])|`([^`]*)`/g
29const PRIVATE_USE_START = 0xe000
30const STAR_EMPHASIS = /\*(\S(?:[^*]*\S)?)\*/g
31const UNDERSCORE_EMPHASIS = /(^|[^\p{L}\p{N}_])_([^\s_](?:[^_]*[^\s_])?)_(?![\p{L}\p{N}_])/gu
32const ENTITY = /&(?:#(\d{1,7})|#[xX]([\da-fA-F]{1,6})|(amp|lt|gt|quot|apos|nbsp));/g
33const NAMED_ENTITIES: Readonly<Record<string, string>> = {
34 amp: '&',
35 lt: '<',
36 gt: '>',
37 quot: '"',
38 apos: "'",
39 nbsp: '\u00a0',
40}
41const LAST_CODE_POINT = 0x10ffff
42const FIRST_SURROGATE = 0xd800
43const LAST_SURROGATE = 0xdfff
44const LINE_BREAK_TAG = /<br\s*\/?>/gi
45const INDENTED_CODE = /^(?: {4}|\t)/
46const LEADING_SPACES = /^ */
47
48function escapedUnits(text: string): string {
49 return Array.from(
50 { length: text.length },
51 (_, index) => `\\u${text.charCodeAt(index).toString(16).padStart(4, '0')}`,
52 ).join('')
53}
54
55function shownGrapheme(grapheme: string): string {
56 const keepsJoiners = isEmoji(grapheme)
57 return grapheme.replace(HIDDEN_CHARACTER, (found) =>
58 KEPT_CONTROLS.has(found) || (keepsJoiners && EMOJI_JOINER.test(found))
59 ? found
60 : escapedUnits(found),
61 )
62}
63
64export function shownText(text: string): string {
65 return graphemesOf(text).map(shownGrapheme).join('')
66}
67
68function unusedCharacter(text: string): string {
69 let code = PRIVATE_USE_START
70 while (text.includes(String.fromCodePoint(code))) code += 1
71 return String.fromCodePoint(code)
72}
73
74function entityText(found: string, decimal?: string, hex?: string, name?: string): string {
75 if (name !== undefined) return NAMED_ENTITIES[name] ?? found
76 const code = decimal === undefined ? Number.parseInt(hex ?? '', 16) : Number(decimal)
77 const isSurrogate = code >= FIRST_SURROGATE && code <= LAST_SURROGATE
78 const isCharacter = code > 0 && code <= LAST_CODE_POINT && !isSurrogate
79 return isCharacter ? String.fromCodePoint(code) : found
80}
81
82type Hold = (literal: string) => string
83
84function linkOf(text: string, address: string, keepsAddress: boolean, hold: Hold): string {
85 if (text === '') return hold(address)
86 return !keepsAddress || address === '' || address === text ? text : `${text} (${hold(address)})`
87}
88
89function plainText(cell: string, keepsAddress: boolean): string {
90 const mark = unusedCharacter(cell)
91 const literals: string[] = []
92 const hold: Hold = (literal) => {
93 literals.push(literal)
94 return `${mark}${String(literals.length - 1)}${mark}`
95 }
96 const held = cell.replace(ESCAPE_OR_CODE_SPAN, (_found, escaped?: string, code?: string) =>
97 hold(escaped ?? code ?? ''),
98 )
99 return held
100 .replace(LINE_BREAK_TAG, ' ')
101 .replace(LINK, (_found, text: string, address: string) =>
102 linkOf(text, address, keepsAddress, hold),
103 )
104 .replace(AUTOLINK, (_found, address: string) => hold(address))
105 .replace(PAIRED_MARK, '$2')
106 .replace(STAR_EMPHASIS, '$1')
107 .replace(UNDERSCORE_EMPHASIS, '$1$2')
108 .replace(ENTITY, entityText)
109 .replace(
110 new RegExp(`${mark}(\\d+)${mark}`, 'gu'),
111 (_found, index: string) => literals[Number(index)] ?? '',
112 )
113 .replace(/\t/g, ' ')
114}
115
116export function plainCell(cell: string): string {
117 return plainText(cell, false)
118}
119
120export function copiedCell(cell: string): string {
121 return plainText(cell, true)
122}
123
124export function cellsOf(line: string): string[] {
125 let text = line.trim()
126 if (text.startsWith('|')) text = text.slice(1)
127 if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
128 const cells: string[] = []
129 let cell = ''
130 for (let index = 0; index < text.length; index += 1) {
131 const character = text.charAt(index)
132 if (character === '\\' && text.charAt(index + 1) === '|') {
133 cell += '\\|'
134 index += 1
135 } else if (character === '|') {
136 cells.push(cell.trim())
137 cell = ''
138 } else {
139 cell += character
140 }
141 }
142 cells.push(cell.trim())
143 return cells
144}
145
146function alignmentOf(cell: string): Alignment {
147 if (cell.startsWith(':') && cell.endsWith(':')) return 'center'
148 return cell.endsWith(':') ? 'right' : 'left'
149}
150
151function delimiterOf(line: string, columns: number): Alignment[] | null {
152 if (!line.includes('-')) return null
153 const cells = cellsOf(line)
154 if (cells.length !== columns || !cells.every((cell) => DELIMITER_CELL.test(cell))) return null
155 return cells.map(alignmentOf)
156}
157
158function isTableRow(line: string): boolean {
159 return line.trim() !== '' && line.includes('|') && !INDENTED_CODE.test(line)
160}
161
162function fitted(cells: string[], columns: number): string[] {
163 return Array.from({ length: columns }, (_, index) => cells[index] ?? '')
164}
165
166function tableAt(lines: readonly string[], start: number): { table: Table; end: number } | null {
167 const headerLine = lines[start] ?? ''
168 if (!isTableRow(headerLine)) return null
169 const header = cellsOf(headerLine)
170 const alignments = delimiterOf(lines[start + 1] ?? '', header.length)
171 if (alignments === null) return null
172 let end = start + 2
173 while (
174 end < lines.length &&
175 isTableRow(lines[end] ?? '') &&
176 openingOf(lines[end] ?? '') === null
177 ) {
178 end += 1
179 }
180 return {
181 table: {
182 kind: 'table',
183 source: lines.slice(start, end).join('\n'),
184 header,
185 rows: lines.slice(start + 2, end).map((line) => fitted(cellsOf(line), header.length)),
186 alignments,
187 },
188 end,
189 }
190}
191
192function isClosing(line: string, fence: string): boolean {
193 const marks = FENCE_CLOSING.exec(line)?.[1]
194 return marks !== undefined && marks[0] === fence[0] && marks.length >= fence.length
195}
196
197type Opening = { indent: number; marks: string; info: string }
198
199function openingOf(line: string): Opening | null {
200 const found = FENCE_OPENING.exec(line)
201 const indent = found?.[1]
202 const marks = found?.[2]
203 const info = found?.[3] ?? ''
204 if (indent === undefined || marks === undefined) return null
205 if (marks.startsWith('`') && info.includes('`')) return null
206 return { indent: indent.length, marks, info }
207}
208
209function withoutIndent(line: string, indent: number): string {
210 const spaces = LEADING_SPACES.exec(line)?.[0].length ?? 0
211 return line.slice(Math.min(spaces, indent))
212}
213
214function fenceAt(lines: readonly string[], start: number): { fence: Fence; end: number } | null {
215 const opening = openingOf(lines[start] ?? '')
216 if (opening === null) return null
217 let end = start + 1
218 while (end < lines.length && !isClosing(lines[end] ?? '', opening.marks)) end += 1
219 const tag = opening.info.trim().split(/\s+/)[0] ?? ''
220 const content = lines.slice(start + 1, end).map((line) => withoutIndent(line, opening.indent))
221 return {
222 fence: { kind: 'fence', tag, content: content.join('\n') },
223 end: Math.min(end + 1, lines.length),
224 }
225}
226
227function trimmedProse(lines: readonly string[]): Prose | null {
228 let first = 0
229 let last = lines.length
230 while (first < last && (lines[first] ?? '').trim() === '') first += 1
231 while (last > first && (lines[last - 1] ?? '').trim() === '') last -= 1
232 return first === last ? null : { kind: 'prose', lines: lines.slice(first, last) }
233}
234
235export function blocksOf(markdown: string): Block[] {
236 const lines = markdown.split(/\r?\n/)
237 const blocks: Block[] = []
238 let prose: string[] = []
239 const endProse = () => {
240 const kept = trimmedProse(prose)
241 if (kept !== null) blocks.push(kept)
242 prose = []
243 }
244 let index = 0
245 while (index < lines.length) {
246 const fence = fenceAt(lines, index)
247 const table = fence === null ? tableAt(lines, index) : null
248 const found = fence?.fence ?? table?.table
249 if (found === undefined) {
250 prose.push(lines[index] ?? '')
251 index += 1
252 continue
253 }
254 endProse()
255 blocks.push(found)
256 index = fence?.end ?? table?.end ?? index + 1
257 }
258 endProse()
259 return blocks
260}
261hooks/prompt.ts 58 lines1import type { Color, RenderElement } from 'claude-code'
2import type { ReplyElements } from './draw'
3
4const RULE = '─'
5const LABEL = 'you'
6const RULE_COLOR: Color = 'suggestion'
7
8export function clockTime(at: number): string {
9 const date = new Date(at)
10 const hours = String(date.getHours()).padStart(2, '0')
11 return `${hours}:${String(date.getMinutes()).padStart(2, '0')}`
12}
13
14function labelOf(submittedAt: number | undefined): string {
15 return submittedAt === undefined ? LABEL : `${LABEL} · ${clockTime(submittedAt)}`
16}
17
18function promptRule(elements: ReplyElements, columns: number, submittedAt: number | undefined) {
19 const { Box, Text } = elements
20 return Box({
21 position: 'absolute',
22 top: 0,
23 left: 0,
24 right: 0,
25 height: 1,
26 flexDirection: 'row',
27 children: [
28 Box({
29 flexShrink: 0,
30 children: Text({
31 color: RULE_COLOR,
32 children: `${RULE.repeat(2)} ${labelOf(submittedAt)} `,
33 }),
34 }),
35 Box({
36 flexGrow: 1,
37 height: 1,
38 overflow: 'hidden',
39 children: Text({ color: RULE_COLOR, children: RULE.repeat(columns) }),
40 }),
41 ],
42 })
43}
44
45export function framedPrompt(
46 elements: ReplyElements,
47 engineRow: RenderElement,
48 columns: number,
49 submittedAt: number | undefined,
50): RenderElement {
51 const { Box } = elements
52 return Box({
53 flexDirection: 'column',
54 marginTop: 1,
55 children: [promptRule(elements, columns, submittedAt), engineRow],
56 })
57}
58hooks/table.ts 230 lines1import { copiedCell, plainCell, shownText, type Alignment, type Table } from './markdown'
2import { cutToWidth, displayWidth, graphemesOf, padToWidth } from './width'
3
4const COLUMN_RULE = '│'
5export const HEADER_RULE = '─'
6export const ROW_RULE = '┄'
7const RULE_CROSSING = '┼'
8const COLUMN_GAP = displayWidth(` ${COLUMN_RULE} `)
9const LONG_COLUMN_CELLS_AT_LEAST = 12
10const COLUMN_CELLS_AT_LEAST = 6
11
12export type TableLook = {
13 header: string[]
14 rows: string[][]
15 alignments: Alignment[]
16 widths: number[]
17}
18
19type CellText = (cell: string) => string
20
21function sum(numbers: readonly number[]): number {
22 return numbers.reduce((total, value) => total + value, 0)
23}
24
25function headerOf(table: Table, text: CellText): string[] {
26 return table.header.map((cell, index) => text(cell) || `Column ${String(index + 1)}`)
27}
28
29function rowsOf(table: Table, text: CellText): string[][] {
30 return table.rows.map((row) => row.map(text))
31}
32
33export function tableLook(table: Table): TableLook {
34 const header = headerOf(table, plainCell).map(shownText)
35 const rows = rowsOf(table, plainCell).map((row) => row.map(shownText))
36 const widths = header.map((cell, index) =>
37 Math.max(displayWidth(cell), ...rows.map((row) => displayWidth(row[index] ?? ''))),
38 )
39 return { header, rows, alignments: table.alignments, widths }
40}
41
42function gapsOf(columns: number): number {
43 return COLUMN_GAP * Math.max(columns - 1, 0)
44}
45
46export function tableWidth(widths: readonly number[]): number {
47 return sum(widths) + gapsOf(widths.length)
48}
49
50function longColumns(naturals: readonly number[], free: number) {
51 let long = naturals.map((_, index) => index)
52 let left = free
53 for (;;) {
54 const share = left / long.length
55 const short = long.filter((index) => {
56 const natural = naturals[index] ?? 0
57 return natural <= share && natural <= LONG_COLUMN_CELLS_AT_LEAST
58 })
59 if (short.length === 0 || short.length === long.length) return { long, left }
60 left -= sum(short.map((index) => naturals[index] ?? 0))
61 long = long.filter((index) => !short.includes(index))
62 }
63}
64
65function sharedWidths(
66 naturals: readonly number[],
67 long: readonly number[],
68 room: number,
69 least: number,
70): Map<number, number> {
71 const widths = new Map<number, number>()
72 const natural = (index: number) => naturals[index] ?? 0
73 let open = [...long]
74 let left = room
75 for (;;) {
76 const weight = sum(open.map(natural))
77 const under = open.filter((index) => (left * natural(index)) / weight < least)
78 if (under.length === 0 || under.length === open.length) break
79 for (const index of under) widths.set(index, least)
80 left -= least * under.length
81 open = open.filter((index) => !under.includes(index))
82 }
83 const weight = sum(open.map(natural))
84 const exact = open.map((index) => ({ index, share: (left * natural(index)) / weight }))
85 let spare = left - sum(exact.map(({ share }) => Math.floor(share)))
86 const byFraction = exact.sort((a, b) => (b.share % 1) - (a.share % 1) || a.index - b.index)
87 for (const { index, share } of byFraction) {
88 widths.set(index, Math.floor(share) + (spare > 0 ? 1 : 0))
89 spare -= 1
90 }
91 return widths
92}
93
94export function columnWidths(naturals: readonly number[], room: number): number[] | null {
95 const free = room - gapsOf(naturals.length)
96 if (sum(naturals) <= free) return [...naturals]
97 const { long, left } = longColumns(naturals, free)
98 const least = Math.min(LONG_COLUMN_CELLS_AT_LEAST, Math.floor(left / long.length))
99 if (least < COLUMN_CELLS_AT_LEAST) return null
100 const shared = sharedWidths(naturals, long, left, least)
101 return naturals.map((natural, index) => shared.get(index) ?? natural)
102}
103
104function piecesOf(word: string, width: number): string[] {
105 const pieces: string[] = []
106 let rest = word
107 while (displayWidth(rest) > width) {
108 const piece = cutToWidth(rest, width) || (graphemesOf(rest)[0] ?? rest)
109 pieces.push(piece)
110 rest = rest.slice(piece.length)
111 }
112 return [...pieces, rest]
113}
114
115export function wrapCell(text: string, width: number): string[] {
116 if (displayWidth(text) <= width) return [text]
117 const lines: string[] = []
118 let line = ''
119 for (const word of text.split(' ').filter((part) => part !== '')) {
120 for (const piece of piecesOf(word, width)) {
121 const joined = line === '' ? piece : `${line} ${piece}`
122 if (line === '' || displayWidth(joined) <= width) {
123 line = joined
124 continue
125 }
126 lines.push(line)
127 line = piece
128 }
129 }
130 return [...lines, line]
131}
132
133function aligned(text: string, width: number, alignment: Alignment): string {
134 const room = Math.max(width - displayWidth(text), 0)
135 if (alignment === 'right') return ' '.repeat(room) + text
136 if (alignment === 'center') return padToWidth(' '.repeat(Math.floor(room / 2)) + text, width)
137 return padToWidth(text, width)
138}
139
140function alignedLines(
141 look: TableLook,
142 widths: readonly number[],
143 cells: readonly string[],
144): string[][] {
145 const wrapped = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
146 const height = Math.max(1, ...wrapped.map((lines) => lines.length))
147 return Array.from({ length: height }, (_, line) =>
148 wrapped.map((lines, index) =>
149 aligned(lines[line] ?? '', widths[index] ?? 0, look.alignments[index] ?? 'left'),
150 ),
151 )
152}
153
154export type Span = { text: string; kind: 'cell' | 'space' | 'rule' }
155
156function spansOf(line: readonly string[]): Span[] {
157 const spans: Span[] = []
158 const addSpace = (text: string) => {
159 const last = spans.at(-1)
160 if (text === '') return
161 if (last?.kind === 'space') last.text += text
162 else spans.push({ text, kind: 'space' })
163 }
164 for (const [index, cell] of line.entries()) {
165 if (index > 0) {
166 addSpace(' ')
167 spans.push({ text: COLUMN_RULE, kind: 'rule' })
168 addSpace(' ')
169 }
170 const lead = cell.length - cell.trimStart().length
171 const text = cell.trim()
172 addSpace(cell.slice(0, lead))
173 if (text !== '') spans.push({ text, kind: 'cell' })
174 addSpace(cell.slice(lead + text.length))
175 }
176 if (spans.at(-1)?.kind === 'space') spans.pop()
177 return spans
178}
179
180export function spanLines(
181 look: TableLook,
182 widths: readonly number[],
183 cells: readonly string[],
184): Span[][] {
185 return alignedLines(look, widths, cells).map(spansOf)
186}
187
188export function linesOf(
189 look: TableLook,
190 widths: readonly number[],
191 cells: readonly string[],
192): string[] {
193 return spanLines(look, widths, cells).map((spans) => spans.map((span) => span.text).join(''))
194}
195
196export function ruleLine(widths: readonly number[], rule: string): string {
197 return widths.map((width) => rule.repeat(width)).join(`${rule}${RULE_CROSSING}${rule}`)
198}
199
200export function rowTitle(row: readonly string[], index: number): string {
201 const first = row[0] ?? ''
202 return first === '' ? `Row ${String(index + 1)}` : first
203}
204
205export type Field = { label: string; value: string }
206
207export function fieldsOf(look: TableLook, row: readonly string[]): Field[] {
208 return look.header
209 .slice(1)
210 .map((label, index) => ({ label, value: row[index + 1] ?? '' }))
211 .filter((field) => field.value !== '')
212}
213
214export function labelWidth(look: TableLook): number {
215 return Math.max(0, ...look.header.slice(1).map(displayWidth)) + 1
216}
217
218export function textCopy(table: Table): string {
219 const header = headerOf(table, copiedCell)
220 return rowsOf(table, copiedCell)
221 .map((row) =>
222 header
223 .map((label, index) => ({ label, value: row[index] ?? '' }))
224 .filter((field) => field.value !== '')
225 .map((field) => `${field.label}: ${field.value}`)
226 .join('\n'),
227 )
228 .join('\n\n')
229}
230hooks/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 15 lines1export type ReplyViewMode = 'on' | 'off'
2
3export type ReplyViewFold = 'folded' | 'open'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'handily-reply-view': {
8 mode: ReplyViewMode
9 folds: StateFamily<ReplyViewFold>
10 submittedAt: StateFamily<number>
11 ready: { root: string }
12 }
13 }
14}
15