SLOPSHOPPER

handily-reply-view

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…

newrowscommandtoast
v0.5.1MITupdated 2026-10-09niksavis/handily/mods/handily-reply-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handily-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 ⎿ handily-reply-view: off for this session. Replies and prompts 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 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.

Install

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

A long reply

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 line counts as the rows that it takes on the screen. A long paragraph counts as several rows. When the 30 rows end inside a line, that line is cut at a word and ends with ….
  • A reply of 30 lines or fewer draws as Claude Code draws it, unless it holds a table or a fenced block.
  • While a reply streams, Claude Code draws it itself, and the view draws the reply only when it is complete, so the look of the reply changes once at the end.

A table

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:

  1. The header line, after the headings.
  2. The last line of the last row, after its text.
  3. A line of their own, under the table.

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.
  • A table with no data rows has no copy as text button.
  • A cell shows its text without markdown marks: no , no _, no backticks, no backslash escapes, 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.

A prompt that you type

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
  • A mod cannot set the grey band itself, because no theme key names it. So the mod keeps the row of Claude Code and frames it.
  • The time comes from the moment the session kept the prompt. A prompt that you type while Claude works waits in the queue, and its time is the moment the session takes it from the queue. The mod keeps the time for this session only. After a resume, a prompt from before the resume shows you without a time, because the mod knows no time for it. The mod never guesses a time.
  • While a prompt waits in the queue, its rule shows you without a time.
  • The prompt text, its wrap and its band are those of Claude Code. The mod never changes them.
  • The next row of the transcript draws the blank line under the prompt. A row that draws no blank line above itself, such as a tool row, stands directly under the prompt.
  • These rows draw as Claude Code draws them: a task notification, a message from another agent, teammate, session or channel, a prompt that a plugin sent, a slash command that you type and its output.
  • The ctrl+o view and --verbose draw each prompt in full as Claude Code draws it, without the rule, so a copy of that text holds no rule characters.
  • The desktop app draws each prompt as it draws it.

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 and every prompt unchanged.

When the reply stays as Claude Code draws it

  • /replies turned the view off for this session.
  • The reply is 30 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 each column sets the layout of the table: aligned, wrapped or in blocks.
  • 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.
  • Each prompt that you type keeps its time in $.state for this session.
Source 7 files
hooks/register.ts 156 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'
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}
156
hooks/draw.ts 547 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  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}
547
hooks/markdown.ts 261 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 = /!?\[([^\]]*)\]\(\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}
261
hooks/prompt.ts 58 lines
1import 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}
58
hooks/table.ts 230 lines
1import { 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}
230
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 15 lines
1export 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