SLOPSHOPPER

Line Width

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

newrows
v0.0.1GPL-3.0-onlyupdated 2026-10-15chewygumxx/claude-library/plugins/line-width
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · line-width
› 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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

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:

  • llm
  • claude
  • claude-code
  • claude-plugin
  • claude-library
  • hooks
  • ui
  • markdown

<!-- -

  • ~chewygumxx/claude-library.git
  • ::: :/plugins/line-width/README.md - -->

Line Width

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

Why

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.

When it does nothing

  • Off the terminal: the desktop app, VS Code and mobile draw as before
  • In a terminal no wider than the measure and its two-column gutter
  • On a summary of a reply, and on a reply that is one table, one code block or one long word

Options

columns, the measure of reply text after the gutter: 80 by default, from 40 to 200. Set it in /config.

Requires

Claude Code 2.1.300 or later, for function hooks.

<!-- vim:set expandtab shiftwidth=2 filetype=markdown foldlevel=3: -->

Source 2 files
hooks/register.tsx 80 lines
1// 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};
80
hooks/segment.ts 484 lines
1// 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