linegauge
Measures, wraps, truncates and slices styled terminal text, grapheme-correct over Intl.Segmenter, with the same result on a terminal, a pipe or --json. Zero dependencies. Drop-in paths for string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line.
linegauge measures, wraps, truncates and slices styled terminal text without the edge
fraying, grapheme-correct over the platform's own Intl.Segmenter. Nothing here reads
process, so a pipe, --json and an agent get the same columns a terminal does. Zero
dependencies.
Drop-in for string-width (the default export), wrap-ansi, strip-ansi and
slice-ansi: npx burgee migrate moves the imports, each graded by the incumbent's own suite.
Over the platform. Intl.Segmenter splits graphemes and util.stripVTControlCharacters
strips escapes; neither one measures a column. linegauge adds the width table (East Asian wide
characters, emoji, zero-width marks) and, on Node before 26, strips the colon form of a colour
escape (ESC[38:2::255:0:0m) that the builtin leaves half behind. The repository's absorption
probe asks every supported Node line how much of this the builtins now do alone.
A printer's line gauge is the steel rule marked in picas and points: a compositor holds it against a line of type and checks it fits the measure it was set to.
Install
npm install linegauge
pnpm add linegauge
yarn add linegauge
bun add linegaugeQuick start
import { width, wrap, truncate, slice, widest } from 'linegauge';
width('古代'); // 4 — East Asian wide, two columns each
width('👨👩👧👦'); // 2 — one cluster, not four people
wrap('a long sentence that needs folding', 12);
truncate('the quick brown fox', 10); // 'the quick…'
const styled = '\u001B[1mbold\u001B[22m'; // the word, in bold
slice(styled, 2, 4); // 'ld' still in bold — columns 2 and 3, styles intact
widest(['a', 'bbb', 'cc']); // 3One problem wearing five names
width · wrap · truncate · slice · widest
They look like five utilities. They are one: cutting styled text without letting the edge come apart — no dangling escape sequence, no half a grapheme, no severed emoji cluster. Each has to know where the ANSI is and where the cluster boundaries are, and once you know that, you may as well answer all five.
The ecosystem splits it across twelve packages — strip-ansi, string-width,
ansi-regex, wrap-ansi, emoji-regex, slice-ansi, get-east-asian-width,
eastasianwidth, string-length, wcwidth, cli-truncate and widest-line — which
between them sit under most of the terminal ecosystem.
What "without the edge fraying" means
A cluster is atomic. A cut that would land inside a grapheme drops the whole cluster rather than half of it. Half an emoji is not a narrower emoji, it is mojibake, and a flag cut down the middle is two unrelated regional-indicator letters.
A style that was open stays open — and gets closed. A cut re-emits the styles active at its start and closes them at its end, so the result is self-contained: paste it anywhere and it neither loses its colour nor leaks it into what follows.
The ellipsis is inside the budget, not on top of it. truncate(text, 10) occupies ten
columns or fewer, never eleven. That is the property a table column depends on, and getting
it wrong is how a layout gains a phantom column under one input.
widest takes lines, not a blob. It accepts any iterable of strings, so the caller says
where the boundaries are rather than having a newline convention assumed for them.
Design notes
Ambiguous-width characters count narrow by default, which is what a terminal does unless
it is rendering with a CJK font. width(text, { ambiguousIsNarrow: false }) counts them two
columns wide for that context — string-width's option, under its name and with its default.
Nothing can detect which a terminal is doing, so it is the caller's decision.
Not a terminal emulator. Semicolon-delimited SGR, colon-delimited extended colour and OSC 8 hyperlinks are understood. Every other complete CSI or OSC command is carried through as an opaque zero-width unit, and anything that only looks like an introducer stays plain text.
strip is exported, from the root and as the linegauge/strip subpath whose default
export is the strip-ansi drop-in. width measures what it leaves.
An ASCII fast path. A string of printable ASCII is measured by a scan of its code units, so the segmenter is reached only when it earns its cost.
Plugins
linegauge hosts one key, widths, and it answers one question: how many columns a code
point occupies on this terminal, when that terminal disagrees with Unicode.
import { register } from 'linegauge/plugin';
register({
name: 'nerd-font',
widths: {
icons: { ranges: [[0xe000, 0xf8ff]], columns: 2, why: 'this Nerd Font draws Private Use icons two columns wide' },
},
});The disagreements it exists for are real and local: a Nerd Font that put a two-column icon in the Private Use Area, a code point newer than the table compiled into this release, a font that draws box-drawing characters wide. Each is a fact about one terminal, so the honest shape for it is data the user supplies rather than a constant somebody argues about upstream.
- Plain data, no functions.
{ ranges, columns, why }— inclusive code-point pairs, a column count of 0, 1 or 2, and a sentence — so a plugin can arrive as JSON, be diffed, and be printed bynpx linegauge checkwithout running its author's code. whyis required, which no other key in the family asks for: a width table with no provenance cannot be audited when it turns out wrong, and for ambiguous width, wrong is the normal outcome.- Later registrations win, over earlier ones and over the built-in table — the user is the
authority on their terminal. A reversed range or a column count of 3 is refused at
register()with a code and a fix. - Nothing is registered by default, and the seam is installed only while something is, so what the incumbent suites grade is Unicode's answer, and a program with no plugin pays nothing.
The ambiguous-width policy a CJK terminal needs is not a plugin: it is ambiguousIsNarrow, an
option with tests behind it.
Migrating
One import per incumbent — each default export is the incumbent's:
- import stringWidth from 'string-width';
+ import stringWidth from 'linegauge';- import wrapAnsi from 'wrap-ansi';
+ import wrapAnsi from 'linegauge/wrap';- import stripAnsi from 'strip-ansi';
+ import stripAnsi from 'linegauge/strip';- import sliceAnsi from 'slice-ansi';
+ import sliceAnsi from 'linegauge/slice';- import cliTruncate from 'cli-truncate';
+ import cliTruncate from 'linegauge/cli-truncate';- import widestLine from 'widest-line';
+ import widestLine from 'linegauge/widest-line';The default export is string-width, byte-for-byte call-compatible, so a transitive copy
resolves without a code change too:
{ "overrides": { "string-width": "npm:linegauge@^1" } }Or let the codemod make the import change: npx burgee migrate --dry-run lists every import it
would rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes
it (Migrate).
Compatibility
The incumbent is the specification. width runs against string-width, wrap against
wrap-ansi, slice against slice-ansi, and the cli-truncate and widest-line paths
against their namesakes.
The six drop-in paths are graded by each incumbent's own suite, unedited, through
compat-oracle; the grades are generated under Benchmarks below and published on the
compatibility page. linegauge/cli-truncate
passes all 17 of cli-truncate 6.2.0's cases and linegauge/widest-line widest-line 6.0.0's one,
level with each incumbent. truncate and widest stay linegauge's own API: truncate counts
the ellipsis inside the budget and cuts by measured width, and widest takes any iterable of
lines.
Benchmarks
Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.
Graded by the incumbent's own test suite:
| suite | passing |
|---|---|
cli-truncate | 17 / 17 |
slice-ansi | 106 / 106 |
string-width | 233 / 233 |
strip-ansi | 8 / 8 |
widest-line | 1 / 1 |
wrap-ansi | 85 / 85 |
Weight, installed and tree-inclusive: 107,852 bytes against 194,329 for the incumbents it replaces — a ratio of 0.5550 (measured).
For agents
- The same columns everywhere. Nothing here reads
process, so a pipe,--jsonand an agent get the columns a terminal does — which is why burgee, caique and flagstaff measure their output with it. - Non-strings answer
0rather than throwing, so a width call reached with whatever a template produced never takes a program down. - A width plugin can be checked before it ships.
npx linegauge check ./widths.mjsvalidates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. - The docs are machine-readable at linegauge.interlace.tools/llms.txt and llms-full.txt.
API
width(text, { ambiguousIsNarrow, countAnsiEscapeCodes }) | terminal columns the text occupies |
|---|---|
wrap(text, columns, options) | fold to a width, styles preserved across rows |
truncate(text, columns, { position, ellipsis }) | cut to a budget, ellipsis counted inside it |
slice(text, start, end) | the columns [start, end), self-contained |
strip(text) | the text with its escape sequences removed (linegauge/strip) |
widest(lines) | the width of the widest line of any iterable |
lineCount(text, columns) | rows the text occupies at that width |
measure(text) | columns of plain text, no escape scan |
Non-strings answer 0 rather than throwing, because a width function is usually reached
with whatever a template produced.
Every export, with its types, is on linegauge.interlace.tools.
Where it sits
Plugins register under the widths key, against the one schema the whole family shares.
burgee, caique, controlroom, flagstaff build on it, and it builds on nothing in this family.
The family
Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each
owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority
— depend on nothing; the others depend only on packages in this table, and every dependency points
one way, down the layers.
No package declares a dependency from outside the family; react and react-reconciler are
optional peers of controlroom, which npm does not install; vitest is an optional peer of
oxmutate, which npm does not install.
package-shape-lock
holds that for every manifest, and
independence-install-lock
installs each package alone and finds nothing but the family packages it declares.
Releases are published by one workflow through npm trusted publishing: no npm token is used, and
each release carries SLSA provenance, which npm view <package> dist.attestations shows.
trusted-publishing-lock
keeps both true.
| Package | What it is | Migrates from |
|---|---|---|
| burgee | The CLI framework: one declaration, every surface | commander, yargs and meow |
| roundel | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors |
| flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
| caique | Prompts that are flags first, and never hang | @inquirer/core |
| linegauge (this package) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
| paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
| seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
| closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
| bellpull | Subprocesses, and which executable actually ran | cross-spawn and which |
| controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |
| oxmutate | Mutation testing on the oxc engine, inside your own Vitest | Stryker |
Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.
Contributing
Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.
Licence
MIT © Ofri Peretz — see LICENSE.