SLOPSHOPPER

reply-view

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

newrowscommandtoast
v0.1.0MITupdated 2026-10-09niksavis/handily/mods/reply-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-view
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /replies ⎿ reply-view: off for this session. Replies draw as Claude Code draws them. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

reply-view

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.

Install

/plugin install reply-view --marketplace niksavis/handily

A long reply

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 line counts as the rows that it takes on the screen. A long paragraph counts as several rows. When the 12 rows end inside a line, that line is cut at a word and ends with ….
  • A reply of 12 lines or fewer draws as Claude Code draws it, unless it holds a table or a fenced block.
  • While a reply streams, the view draws it again with each new part. A reply that passes 12 rows changes from the view of Claude Code to the folded view. After that, the first rows of prose stay in place, and only the count of hidden lines grows.

A table

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.
  • A cell shows its text without markdown marks: no , no backticks, a link as its text. The text between two backticks keeps its marks, such as /*.ts.
  • A table without data rows that does not fit the width shows its headings, one on each line.
  • An empty cell draws no line in a block, and copy as text leaves it out.

A fenced block

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

CommandEffect
/repliesTurns 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.

When the reply stays as Claude Code draws it

  • /replies turned the view off for this session.
  • The reply is 12 lines or fewer and holds no table and no fenced block.
  • The block is a summary of the text that Claude wrote between two tool calls.
  • The reply is longer than 60000 characters.
  • Drawing the reply failed. The mod writes the reason to the debug log.

Known limits

  • The count of lines is an estimate from the width of the screen. The renderer of Claude Code wraps the text, so the real count can differ by a few lines.
  • A hidden character in a reply, such as a zero-width space, shows as an escape such as \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.
  • An emoji counts as two columns. A terminal that draws an emoji in another width moves the columns of a table after it.
  • The widest cell of a table sets its layout, in columns or in blocks. While a table streams, a wider row can change it from columns to blocks.
  • While a table streams, its first line draws as text until the line under it arrives.
  • A press on more or less keeps one small value for that reply in $.state, so the reply stays open or folded when it draws again.
Source 6 files
hooks/register.ts 106 lines
1import 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}
106
hooks/draw.ts 398 lines
1import 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}
398
hooks/markdown.ts 216 lines
1import { 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}
216
hooks/table.ts 78 lines
1import { 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}
78
hooks/width.ts 49 lines
1const 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}
49
types/index.d.ts 14 lines
1export 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