Wraps Claude's replies in the terminal at whitespace to 80 columns, leaving tables, code blocks and long paths and URLs at full width.

ctime: 2026-10-15 mtime: 2026-10-16 spdx: GPL-3.0-only title: "Line Width: README.md" description: >- Wraps Claude's replies in the terminal at whitespace to 80 columns, leaving tables, code blocks and long paths and URLs at full width. tags:
<!-- -
Wraps Claude's replies in the terminal at whitespace to 80 columns, leaving tables, code blocks and long paths and URLs at full width.
Replies drawn the full width of a wide terminal are hard to read. This draws each reply's prose at a measure of 80 columns, wrapped at whitespace, while tables, code blocks and any word too long for the measure (a file path, a URL) keep the terminal's width, so none is broken or squeezed.
Only the drawing changes: the stored reply, what Claude reads and the ctrl+o transcript are left as they were.
columns, the measure of reply text after the gutter: 80 by default, from 40 to 200. Set it in /config.
Claude Code 2.1.300 or later, for function hooks.
<!-- vim:set expandtab shiftwidth=2 filetype=markdown foldlevel=3: -->
hooks/register.tsx 80 lines1// vim:set expandtab shiftwidth=4 filetype=typescriptreact:
2// SPDX-License-Identifier: GPL-3.0-only
3
4//
5//
6// ~chewygumxx/claude-library.git
7// ::: :/plugins/line-width/hooks/register.tsx
8//
9//
10
11// Wraps the assistant's replies in the terminal at whitespace to the
12// measure, leaving tables, code fences and words too long for it at the
13// terminal's width. The engine refuses its own drawing under a Box with a
14// width, so each piece is drawn as the mod's own Markdown, as a reply is,
15// beside a bullet drawn as the engine draws its own.
16
17import type { Register, RenderElement } from "claude-code";
18import { segment } from "./segment.ts";
19
20// The columns the engine's bullet and the space after it take.
21const GUTTER = 2;
22
23// A tree draws the first 100,000 characters of its texts and drops the
24// rest; a block near that, with the escapes and quote bars the cuts add,
25// is left to the engine, which draws it whole.
26const MAX_TEXT = 90_000;
27
28export const register: Register = (on, options) => {
29 const columns = typeof options.columns === "number" ? options.columns : 80;
30
31 on("ui.render", { component: "AssistantMessage" }, ($, e, next) => {
32 const isRoomy = (e.viewport?.columns ?? 0) > columns + GUTTER;
33 const isLong = e.props.text.length > MAX_TEXT;
34 if (
35 e.surface !== "terminal" ||
36 e.props.isSummary ||
37 !isRoomy ||
38 isLong
39 ) {
40 return next(e);
41 }
42 const pieces = segment(e.props.text, columns);
43 const [first] = pieces;
44 if (
45 first === undefined ||
46 (pieces.length === 1 && first.kind === "wide")
47 ) {
48 return next(e);
49 }
50 const { Box, Text, Markdown } = $.ui.resolve(e);
51 const drawn = pieces.map((piece) => {
52 const width = piece.kind === "prose" ? { width: columns } : {};
53 return (
54 <Box
55 {...width}
56 paddingLeft={piece.indent}
57 marginTop={piece.gapBefore ? 1 : 0}
58 >
59 <Markdown text={piece.text} />
60 </Box>
61 );
62 });
63 const column = <Box flexDirection="column">{drawn}</Box>;
64 // The engine draws no bullet and no gutter on a later block.
65 const row: RenderElement[] = e.props.isFirstOfReply
66 ? [
67 <Box width={GUTTER}>
68 <Text color="text">●</Text>
69 </Box>,
70 column,
71 ]
72 : [column];
73 return (
74 <Box flexDirection="row" marginTop={1}>
75 {row}
76 </Box>
77 );
78 });
79};
80hooks/segment.ts 484 lines1// vim:set expandtab shiftwidth=4 filetype=typescript:
2// SPDX-License-Identifier: GPL-3.0-only
3
4//
5//
6// ~chewygumxx/claude-library.git
7// ::: :/plugins/line-width/hooks/segment.ts
8//
9//
10
11// Cuts a reply's markdown into the pieces the line-width hook draws: prose,
12// wrapped to the measure, and wide pieces (tables, code fences and words
13// too long for the measure) left to the terminal's width. Pure, so the
14// repository's own tests reach it without the engine.
15
16export type Piece = {
17 kind: "prose" | "wide";
18 text: string;
19 indent: number;
20 gapBefore: boolean;
21};
22
23type Unit = Omit<Piece, "gapBefore">;
24
25const FENCE = /^[ \t]*(`{3,}|~{3,})/;
26const FENCE_CLOSE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
27const DELIMITER_CELL = /^\s*:?-+:?\s*$/;
28const LIST_ITEM = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+/;
29// Three or more of one of `-`, `*` or `_`, spaces between allowed.
30const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
31const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)[ \t]*$/;
32const HEADING = /^ {0,3}#{1,6}(?:[ \t]|$)/;
33const QUOTE = /^[ \t]*(?:>[ \t]?)+/;
34// A delimiter run that may open emphasis: up to three `*` or `_`, or `~~`.
35const EMPHASIS_OPEN = /^(\*{1,3}|_{1,3}|~~)(?=[^\s/])/;
36const EMPHASIS_RUNS = ["*", "**", "***", "_", "__", "___", "~~"];
37// A link or image: its label, then a target in angle brackets or one
38// holding balanced parentheses a level deep, and an optional title.
39const LINK =
40 /!?\[([^\]\n]*)\]\(\s*(?:<[^<>\n]*>|(?:[^()\s]|\([^()\s]*\))*)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^()\n]*\)))?\s*\)/y;
41const BACKTICKS = /`+/y;
42const AUTOLINK = /<([a-z][a-z0-9+.-]*:[^<>\s]*)>/iy;
43// East Asian Wide and Fullwidth ranges, emoji drawn as emoji, and a
44// pictograph a variation selector asks to draw so: two cells each.
45const WIDE_CHARACTER =
46 /[\u{1100}-\u{115f}\u{2e80}-\u{a4cf}\u{ac00}-\u{d7a3}\u{f900}-\u{faff}\u{fe30}-\u{fe4f}\u{ff00}-\u{ff60}\u{ffe0}-\u{ffe6}\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}|\p{Extended_Pictographic}\u{fe0f}/u;
47// Combining marks, joiners and variation selectors: no cells.
48const ZERO_WIDTH = /\p{M}|[\u{200b}-\u{200d}\u{2060}]/u;
49
50type Part = { kind: Piece["kind"]; text: string };
51type Token = { start: number; end: number; drawn: string };
52// Emphasis a line carries in from the paragraph's lines above, and the runs
53// the paragraph's lines below close.
54type Emphasis = { open: string | undefined; below: ReadonlySet<string> };
55
56export function segment(text: string, columns: number): Piece[] {
57 const lines = text.split(/\r?\n/);
58 const isHeading = setextBodies(lines);
59 const below = closersBelow(lines);
60 const pieces: Piece[] = [];
61 let gap = false;
62 let inList = false;
63 let isCut = false;
64 let open: string | undefined;
65
66 const add = (unit: Unit) => {
67 const last = pieces.at(-1);
68 if (
69 last?.kind === "prose" &&
70 unit.kind === "prose" &&
71 last.indent === unit.indent
72 ) {
73 last.text += `${gap ? "\n\n" : "\n"}${unit.text}`;
74 } else {
75 pieces.push({ ...unit, gapBefore: gap && pieces.length > 0 });
76 }
77 gap = false;
78 };
79
80 let i = 0;
81 while (i < lines.length) {
82 const line = lines[i] ?? "";
83 if (line.trim() === "") {
84 gap = true;
85 open = undefined;
86 i += 1;
87 continue;
88 }
89 const fence = FENCE.exec(line);
90 if (fence && (leading(line) <= 3 || inList)) {
91 const end = fenceEnd(lines, i, fence[1] ?? "```");
92 add(block(lines.slice(i, end), "wide"));
93 open = undefined;
94 i = end;
95 continue;
96 }
97 const table = leading(line) <= 3 || inList ? tableEnd(lines, i) : i;
98 if (table > i) {
99 add(block(lines.slice(i, table), "wide"));
100 open = undefined;
101 i = table;
102 continue;
103 }
104 if (startsBlock(line)) {
105 open = undefined;
106 }
107 if (THEMATIC_BREAK.test(line)) {
108 inList = false;
109 } else if (LIST_ITEM.test(line)) {
110 inList = true;
111 } else if (leading(line) === 0) {
112 inList = false;
113 }
114 isCut &&= inList;
115 // Once a cut has left a list's text in padded pieces, its indented
116 // lines are padded too, or they would fall to the margin.
117 const shift = isCut ? leading(line) : 0;
118 // A heading draws as one, its style on every line, so none is cut.
119 const emphasis = { open, below: below[i] ?? new Set<string>() };
120 const units: Unit[] =
121 isHeading[i] || HEADING.test(line)
122 ? [{ kind: "prose", text: line, indent: 0 }]
123 : lineUnits(
124 line.slice(shift),
125 columns - shift,
126 inList,
127 emphasis,
128 );
129 open = emphasis.open;
130 for (const unit of units) {
131 add({ ...unit, indent: unit.indent + shift });
132 }
133 isCut ||= inList && (units.length > 1 || units[0]?.kind === "wide");
134 i += 1;
135 }
136 return pieces;
137}
138
139// Per line, the emphasis runs closed by the lines below it in its
140// paragraph, so an opener whose closer is on a later line counts.
141function closersBelow(lines: string[]): ReadonlySet<string>[] {
142 const result: ReadonlySet<string>[] = lines.map(() => new Set<string>());
143 let found = new Set<string>();
144 for (let i = lines.length - 1; i >= 0; i -= 1) {
145 const line = lines[i] ?? "";
146 result[i] = found;
147 if (line.trim() === "" || startsBlock(line)) {
148 found = new Set();
149 continue;
150 }
151 found = new Set(found);
152 for (const token of tokens(line)) {
153 const raw = line.slice(token.start, token.end);
154 for (const run of EMPHASIS_RUNS) {
155 if (closes(raw, run)) {
156 found.add(run);
157 }
158 }
159 }
160 }
161 return result;
162}
163
164// Whether a line opens a block of its own, ending any paragraph above.
165function startsBlock(line: string): boolean {
166 return [LIST_ITEM, HEADING, FENCE, THEMATIC_BREAK].some((pattern) =>
167 pattern.test(line),
168 );
169}
170
171// Which lines are a setext heading's text: the paragraph lines above an
172// underline of `=` or `-`.
173function setextBodies(lines: string[]): boolean[] {
174 const marked = lines.map(() => false);
175 for (const [j, line] of lines.entries()) {
176 if (!SETEXT_UNDERLINE.test(line)) {
177 continue;
178 }
179 for (let k = j - 1; k >= 0 && isParagraphLine(lines[k] ?? ""); k -= 1) {
180 marked[k] = true;
181 }
182 }
183 return marked;
184}
185
186// Whether a line can be paragraph text: not blank, and opening no other
187// block.
188function isParagraphLine(line: string): boolean {
189 return (
190 line.trim() !== "" &&
191 leading(line) <= 3 &&
192 ![LIST_ITEM, QUOTE, THEMATIC_BREAK, FENCE, HEADING].some((pattern) =>
193 pattern.test(line),
194 )
195 );
196}
197
198// The line after the fence opened at `start` closes, or the block's end.
199function fenceEnd(lines: string[], start: number, open: string): number {
200 for (let i = start + 1; i < lines.length; i += 1) {
201 const close = FENCE_CLOSE.exec(lines[i] ?? "")?.[1] ?? "";
202 if (close[0] === open[0] && close.length >= open.length) {
203 return i + 1;
204 }
205 }
206 return lines.length;
207}
208
209// The line after a table opening at `start`, or `start` when none does: a
210// header row holding a pipe, a delimiter row of as many cells, then rows
211// holding a pipe, as GitHub's markdown reads them.
212function tableEnd(lines: string[], start: number): number {
213 const header = lines[start] ?? "";
214 const delimiter = lines[start + 1] ?? "";
215 const columns = cells(delimiter);
216 const isTable =
217 header.includes("|") &&
218 delimiter.includes("|") &&
219 columns.every((cell) => DELIMITER_CELL.test(cell)) &&
220 columns.length === cells(header).length;
221 if (!isTable) {
222 return start;
223 }
224 let end = start + 2;
225 while ((lines[end] ?? "").includes("|") && (lines[end] ?? "").trim()) {
226 end += 1;
227 }
228 return end;
229}
230
231// A row's cells: split at each pipe not escaped, outer pipes dropped.
232function cells(row: string): string[] {
233 const inner = row
234 .trim()
235 .replace(/^\|/, "")
236 .replace(/(?<!\\)\|$/, "");
237 return inner.split(/(?<!\\)\|/);
238}
239
240// Lines drawn whole, their common indentation taken as padding.
241function block(lines: string[], kind: Piece["kind"]): Unit {
242 const indent = Math.min(
243 ...lines.filter((line) => line.trim() !== "").map(leading),
244 );
245 const text = lines
246 .map((line) => line.slice(Math.min(indent, leading(line))))
247 .join("\n");
248 return { kind, text, indent };
249}
250
251function leading(line: string): number {
252 return line.length - line.trimStart().length;
253}
254
255// A prose line as units: itself, or the parts a long token cuts it into,
256// each carrying the line's context: a list item's marker stays with the
257// first part and later parts sit under its text, a continuation line's
258// indent becomes padding, and every part of a quote keeps the bar.
259function lineUnits(
260 line: string,
261 columns: number,
262 inList: boolean,
263 emphasis: Emphasis,
264): Unit[] {
265 const quote = QUOTE.exec(line)?.[0];
266 const item = quote === undefined ? LIST_ITEM.exec(line)?.[0] : undefined;
267 const lead = leading(line);
268 const isPlain = quote === undefined && item === undefined;
269 if (isPlain && !inList && lead >= 4) {
270 return [{ kind: "prose", text: line, indent: 0 }];
271 }
272 const isContinuation = isPlain && inList && lead > 0;
273 // An item inside a quote: padding would move the bar too, so its later
274 // parts are indented with no-break spaces, which markdown keeps.
275 const quoted =
276 quote === undefined
277 ? undefined
278 : LIST_ITEM.exec(line.slice(quote.length))?.[0];
279 const marker =
280 quote === undefined
281 ? (item ?? (isContinuation ? line.slice(0, lead) : ""))
282 : `${quote}${quoted ?? ""}`;
283 const parts = cut(
284 line.slice(marker.length),
285 columns - marker.length,
286 emphasis,
287 );
288 if (parts.length === 1 && parts[0]?.kind === "prose") {
289 return [{ kind: "prose", text: line, indent: 0 }];
290 }
291 const bar =
292 quote === undefined ? "" : "> ".repeat(quote.split(">").length - 1);
293 return parts.map((part, index) => {
294 const text = index === 0 ? part.text : escapeLead(part.text);
295 if (quoted !== undefined && index > 0) {
296 const pad = "\u00a0".repeat(quoted.length);
297 return {
298 kind: part.kind,
299 text: `${bar}${pad}${part.text}`,
300 indent: 0,
301 };
302 }
303 if (quote !== undefined) {
304 const head = index === 0 ? marker : bar;
305 return { kind: part.kind, text: `${head}${text}`, indent: 0 };
306 }
307 if (item !== undefined) {
308 return index === 0
309 ? { kind: part.kind, text: `${item}${text}`, indent: 0 }
310 : { kind: part.kind, text, indent: item.length };
311 }
312 return { kind: part.kind, text, indent: isContinuation ? lead : 0 };
313 });
314}
315
316// Cuts content around each long token outside emphasis, in one pass;
317// `room` is the cells its line has. Emphasis opens only where a token
318// later in the line, or in the paragraph's lines below, closes it, so
319// `~/path`, `__init__.py` and `**/*.ts` open none. Emphasis open at the
320// line's end is left in `emphasis` for the next line.
321function cut(content: string, room: number, emphasis: Emphasis): Part[] {
322 const found = tokens(content);
323 const raws = found.map((token) => content.slice(token.start, token.end));
324 // Each delimiter run's last closing token, so an opener is checked once.
325 const lastClose = new Map<string, number>();
326 for (const [index, raw] of raws.entries()) {
327 for (const run of EMPHASIS_RUNS) {
328 if (closes(raw, run)) {
329 lastClose.set(run, index);
330 }
331 }
332 }
333 const parts: Part[] = [];
334 let from = 0;
335 let open = emphasis.open;
336 for (const [index, token] of found.entries()) {
337 const raw = raws[index] ?? "";
338 const before = open;
339 const run = EMPHASIS_OPEN.exec(raw)?.[1];
340 if (open === undefined && run !== undefined) {
341 const isClosed =
342 (lastClose.get(run) ?? -1) >= index || emphasis.below.has(run);
343 if (isClosed) {
344 open = run;
345 }
346 }
347 if (open !== undefined && closes(raw, open)) {
348 open = undefined;
349 }
350 if (
351 before === undefined &&
352 open === undefined &&
353 isLong(token.drawn, room)
354 ) {
355 const head = content.slice(from, token.start).trim();
356 if (head !== "") {
357 parts.push({ kind: "prose", text: head });
358 }
359 parts.push({ kind: "wide", text: raw });
360 from = token.end;
361 }
362 }
363 emphasis.open = open;
364 const rest = content.slice(from);
365 const tail = from === 0 ? rest : rest.trim();
366 if (tail !== "") {
367 parts.push({ kind: "prose", text: tail });
368 }
369 return parts;
370}
371
372// Whether a token ends with the delimiter run, trailing punctuation aside.
373function closes(raw: string, run: string): boolean {
374 return raw.replace(/[.,;:!?)]+$/, "").endsWith(run);
375}
376
377// The line's whitespace-separated tokens, each with the text it draws; a
378// code span, link or autolink is read whole, spaces and all.
379function tokens(line: string): Token[] {
380 const found: Token[] = [];
381 let i = 0;
382 while (i < line.length) {
383 if (/\s/.test(line[i] ?? "")) {
384 i += 1;
385 continue;
386 }
387 const start = i;
388 let drawn = "";
389 while (i < line.length && !/\s/.test(line[i] ?? "")) {
390 const char = line[i] ?? "";
391 const span = char === "`" ? codeSpan(line, i) : undefined;
392 const link =
393 char === "[" || char === "!" ? at(LINK, line, i) : null;
394 const autolink = char === "<" ? at(AUTOLINK, line, i) : null;
395 if (span) {
396 drawn += span.drawn;
397 i += span.length;
398 } else if (link) {
399 drawn += link[1] ?? "";
400 i += link[0].length;
401 } else if (autolink) {
402 drawn += autolink[1] ?? "";
403 i += autolink[0].length;
404 } else if (char === "`") {
405 // An unmatched run is literal, the whole run at once.
406 const run = at(BACKTICKS, line, i)?.[0] ?? char;
407 drawn += run;
408 i += run.length;
409 } else {
410 drawn += char;
411 i += 1;
412 }
413 }
414 drawn = drawn.replace(/^[*_~(]+/, "").replace(/[*_~.,;:!?)]+$/, "");
415 found.push({ start, end: i, drawn });
416 }
417 return found;
418}
419
420// A code span opening at `start`: its length and the text it draws, or
421// undefined when no run of backticks as long closes it.
422function codeSpan(
423 line: string,
424 start: number,
425): { length: number; drawn: string } | undefined {
426 const open = at(BACKTICKS, line, start)?.[0] ?? "`";
427 let from = start + open.length;
428 for (;;) {
429 const close = line.indexOf(open, from);
430 if (close === -1) {
431 return undefined;
432 }
433 const isExact =
434 line[close - 1] !== "`" && line[close + open.length] !== "`";
435 if (isExact) {
436 return {
437 length: close + open.length - start,
438 drawn: line.slice(start + open.length, close).trim(),
439 };
440 }
441 from = close + 1;
442 }
443}
444
445// A sticky pattern's match at `index`, without slicing the line.
446function at(pattern: RegExp, line: string, index: number) {
447 pattern.lastIndex = index;
448 return pattern.exec(line);
449}
450
451// Whether a token's drawn text holds a run too wide for the room; one with
452// a wide character is never long, its script wrapping without spaces.
453function isLong(drawn: string, room: number): boolean {
454 return (
455 !WIDE_CHARACTER.test(drawn) &&
456 drawn.split(/\s+/).some((run) => cellsOf(run) > room)
457 );
458}
459
460// A run's width in terminal cells, short of wide characters, which the
461// caller has ruled out: zero-width characters take none, others one.
462function cellsOf(run: string): number {
463 let count = 0;
464 for (const char of run) {
465 count += ZERO_WIDTH.test(char) ? 0 : 1;
466 }
467 return count;
468}
469
470// Keeps a part after a cut from opening a list, heading, quote or fence,
471// or from drawing a rule.
472function escapeLead(text: string): string {
473 if (THEMATIC_BREAK.test(text) || FENCE.test(text)) {
474 return `\\${text}`;
475 }
476 const ordered = /^\d{1,9}(?=[.)](?:\s|$))/.exec(text)?.[0];
477 if (ordered !== undefined) {
478 return `${ordered}\\${text.slice(ordered.length)}`;
479 }
480 return /^(?:[-+*](?:\s|$)|#{1,6}(?:\s|$)|>)/.test(text)
481 ? `\\${text}`
482 : text;
483}
484