create-pathfinder 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -8,4 +8,4 @@ Use the smallest relevant context for the active task. Keep each delivery chunk
8
8
 
9
9
  Canonical skills live under `skills/` and are the only behavior contract; anything under `.claude/skills/` or `.agents/skills/` is a generated pointer to one, so edit the canonical file and regenerate the adapter.
10
10
 
11
- If this tool has no native skill discovery, invoke a skill by reading its canonical file directly: `Use skills/<name>/SKILL.md and follow it exactly.` That is the whole fallback — there is no launcher file, and no second copy of a skill to find.
11
+ If this tool has no native skill discovery, invoke a skill by reading its canonical file directly: `Use skills/<name>/SKILL.md and follow it exactly.` That is the whole fallback — one line, and no second copy of a skill anywhere to find.
package/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  **An AI-assisted, human-in-the-loop workflow for building software — without giving up the decisions.**
4
4
 
5
- This package installs [Pathfinder](https://github.com/rikilamadrid/pathfinder) into a Git repository — one you already have, or one it offers to create for you.
5
+ This package installs [Pathfinder](https://pathfinder-kit.vercel.app) into a Git repository — one you already have, or one it offers to create for you.
6
6
 
7
7
  ```bash
8
8
  npx create-pathfinder
9
9
  ```
10
10
 
11
+ That is the whole invocation. This is a scaffolding CLI you run once, not a dependency: `npm i create-pathfinder` would add it to your project's `package.json` and `node_modules` without installing anything into your repository. If you would rather not use `npx`, `npm exec create-pathfinder` is equivalent, and a global install (`npm i -g create-pathfinder`) gives you a `create-pathfinder` command that behaves identically.
12
+
11
13
  Then give your agent this prompt:
12
14
 
13
15
  ```text
@@ -15,9 +17,11 @@ Use skills/kickstart-pathfinder/SKILL.md. Help me initialize this project.
15
17
  Do not install packages or write product code yet.
16
18
  ```
17
19
 
20
+ Everything below is what the command does. **What Pathfinder is, how the workflow runs, and what each skill does are documented at [pathfinder-kit.vercel.app](https://pathfinder-kit.vercel.app).**
21
+
18
22
  ## What it installs
19
23
 
20
- Pathfinder is a kit of context files and skills — not a framework. There is no runtime, no dependency, and nothing to build. The installer copies five things into your repository and nothing else:
24
+ The installer copies five things into your repository and nothing else:
21
25
 
22
26
  | Path | What it is |
23
27
  | --- | --- |
@@ -59,25 +63,14 @@ npx create-pathfinder --agents claude-code,codex
59
63
  | `claude-code` | `.claude/skills/<name>/SKILL.md` | `/reflect` |
60
64
  | `codex` | `.agents/skills/<name>/SKILL.md` | `/skills`, or `$reflect` |
61
65
 
62
- Each adapter is a few lines long: it carries the skill's name and description, and tells the tool to read the canonical file. Both harnesses get the same bytes at a different path — the behavior lives in one place, and the adapter never restates it.
66
+ Each adapter carries the skill's name and description and tells the tool to read the canonical file — the behavior lives in one place, and the adapter never restates it.
63
67
 
64
68
  Pick one, both, or neither. Choosing one never generates, removes, or claims anything under the other's directory, and Pathfinder never writes to a personal skills directory such as `$HOME/.agents/skills`.
65
69
 
66
- In a terminal you are asked instead of passing the flag — a numbered list, comma-separated, `Enter` for the tools found on your machine, `0` for none. Each option shows the directory it writes to before you choose it.
67
-
68
- The list has a third entry, **Something else…**, and it generates nothing. Name your tool and the summary says so plainly, because a `.mdc` file Cursor half-reads or a `SKILL.md` in a directory nothing scans would be a file your tool ignores under a summary claiming success. Two things do work for any tool: the kit installs `AGENTS.md` at the repository root, which Codex, Cursor, and several others read, and any agent can be given the line the adapters delegate to anyway — `Use skills/<name>/SKILL.md and follow it exactly.`
70
+ In a terminal you are asked instead of passing the flag — a numbered list, comma-separated, `Enter` for the tools found on your machine, `0` for none. Each option shows the directory it writes to before you choose it. A third entry, **Something else…**, generates nothing and says so: the kit still installs `AGENTS.md` at the repository root, which Codex, Cursor, and several others read, and any agent can be given the line the adapters delegate to — `Use skills/<name>/SKILL.md and follow it exactly.`
69
71
 
70
72
  Nothing is configured unless you choose it. Detection only sets the default, a piped or scripted run configures nothing at all unless `--agents` says so, and `--agents` accepts only the ids in the table above — an unknown one exits 2 rather than quietly installing nothing.
71
73
 
72
- **What the installer owns, and what it will not touch:**
73
-
74
- - **It owns a file at `<tool>/skills/<name>/SKILL.md` only if that name is a Pathfinder skill *and* the file carries the `pathfinder:adapter` marker it wrote.** Those it regenerates freely, with no flag — that is how an older install gains adapters by re-running.
75
- - **A file you wrote at one of those paths is left alone** and listed by name in the summary. `--force` replaces it; nothing else does.
76
- - **Everything else under that directory is never read and never written** — your `settings.json`, `settings.local.json`, agents, commands, hooks, and any skill of your own.
77
- - **Nothing is ever deleted.** An adapter for a skill a newer version no longer ships is reported and left in place.
78
-
79
- Re-running is idempotent: the second run writes the same bytes and reports the adapters as already up to date.
80
-
81
74
  ## The Kickstart prompt, and your clipboard
82
75
 
83
76
  Every install ends by printing the one prompt that starts a session, and the prompt follows the tool you chose:
@@ -88,32 +81,36 @@ Every install ends by printing the one prompt that starts a session, and the pro
88
81
  | `codex` | `$kickstart-pathfinder` |
89
82
  | both, or neither | `Use skills/kickstart-pathfinder/SKILL.md. Help me initialize this project. Do not install packages or write product code yet.` |
90
83
 
91
- Two harnesses fall back to the neutral form because one clipboard cannot hold two syntaxes, and picking a favorite would quietly decide which of your tools is the real one.
92
-
93
- In a terminal you are then asked whether to copy it. The question says what it replaces, because your clipboard is yours:
84
+ In a terminal you are then asked whether to copy it, in a question that says what it replaces:
94
85
 
95
86
  ```text
96
- ? Copy that prompt to your clipboard? This replaces what is on it now. [Y/n]
87
+ ? Copy that prompt to your clipboard? This replaces what is on it now.
88
+
89
+ ❯ Yes
90
+ No
91
+
92
+ ↑↓ move enter confirm
97
93
  ```
98
94
 
95
+ `y` and `n` still answer it in one keystroke, and under
96
+ `PATHFINDER_PROMPT=classic` it asks as `[Y/n]` on one line.
97
+
99
98
  - **Nothing is copied without an explicit yes.** Declining, an unanswered question, `--no-clipboard`, `--yes`, `--dry-run`, and any run without a terminal on both ends all leave your clipboard exactly as it was.
100
- - **The prompt is printed either way.** Copying is a convenience, never the only way to get it, which is what lets every failure be a non-event.
99
+ - **The prompt is printed either way.** Copying is a convenience, never the only way to get it.
101
100
  - **No dependency, and no clipboard is ever read.** The copy uses whatever your system already has — `pbcopy`, `clip.exe` including under WSL, or `wl-copy`, `xclip`, or `xsel` — chosen by what is actually installed rather than by your platform's name. If none of them is there, or one of them fails, the installer says so in one line and still exits 0.
102
101
 
103
102
  ## Opening the project
104
103
 
105
104
  The last question is whether to open the project, and it is only ever about an editor you already have. The installer looks for `code` (VS Code) and `cursor` (Cursor) on your `PATH`:
106
105
 
107
- - **One found** — a yes/no naming it: `? Open this project in VS Code? [Y/n]`
108
- - **Several found** — a numbered list, alphabetical, ending in `Don't open`
106
+ - **One found** — a Yes/No naming it: `? Open this project in VS Code?`
107
+ - **Several found** — a list, alphabetical, ending in `Don't open`
109
108
  - **None found** — no question at all
110
109
 
111
- Neither editor is a Pathfinder requirement, and the alphabetical order is not a recommendation. Nothing else can be launched: there is no way to name an editor or pass a path, because an editor the installer did not find is one it cannot honestly offer.
112
-
113
- The launch is detached — the installer hands over the project directory and exits immediately, so it never waits for your editor and never prints your editor's output as its own.
110
+ Neither editor is a Pathfinder requirement, and the alphabetical order is not a recommendation. There is no way to name an editor or pass a path. The launch is detached: the installer hands over the project directory and exits immediately.
114
111
 
115
112
  - **Nothing is launched without an explicit yes.** Declining, an unanswered question, `Don't open`, `--no-open`, `--yes`, `--dry-run`, and any run without a terminal on both ends all leave your screen alone.
116
- - **A failed launch is not a failed install.** If the binary is there but cannot be run, the installer says so in one line, tells you the directory to open yourself, and still exits 0. The install already succeeded; opening it was a convenience.
113
+ - **A failed launch is not a failed install.** If the binary is there but cannot be run, the installer says so in one line, tells you the directory to open yourself, and still exits 0.
117
114
 
118
115
  ## Options
119
116
 
@@ -129,6 +126,15 @@ The launch is detached — the installer hands over the project directory and ex
129
126
  | `--yes`, `--no-input` | Take the defaults and ask nothing. It does not authorize `git init`, configure any tool, touch your clipboard, or open an editor — pass `--git-init` and `--agents` for the first two |
130
127
  | `-h`, `--help` | Show usage |
131
128
 
129
+ ### Environment
130
+
131
+ | Variable | Effect |
132
+ | --- | --- |
133
+ | `PATHFINDER_PROMPT=classic` | Ask every question as a numbered list and `y`/`n` rather than an arrow-key selector |
134
+ | `NO_COLOR` | Print no colour. It does not disable the selector |
135
+
136
+ **Both prompt styles are supported.** By default a terminal answers questions with `↑`/`↓`, `Space`, and `Enter`. `PATHFINDER_PROMPT=classic` asks for typed numbers and `y`/`n` instead — the right choice for a screen reader, for a script driving the installer's stdin, and for anyone who simply prefers it. A terminal narrower than 49 columns and `TERM=dumb` select it on their own, and `y`/`n` keep working at a Yes/No question either way.
137
+
132
138
  Questions are asked only when stdin and stdout are both terminals. Piped, redirected, or in CI, nothing is asked and nothing is prompted for — so a directory that is not a repository needs `--git-init`, or the install is refused, no tool is configured without `--agents`, the clipboard is never touched at all, and no editor is ever launched.
133
139
 
134
140
  ## Requirements
@@ -141,7 +147,8 @@ No dependencies — this package installs nothing into your project's `node_modu
141
147
 
142
148
  ## Links
143
149
 
144
- - [Repository and full documentation](https://github.com/rikilamadrid/pathfinder)
150
+ - [Documentation](https://pathfinder-kit.vercel.app)
151
+ - [Repository](https://github.com/rikilamadrid/pathfinder)
145
152
  - [Why this is not a framework](https://github.com/rikilamadrid/pathfinder/blob/main/NOT_A_FRAMEWORK.md)
146
153
  - [Changelog](https://github.com/rikilamadrid/pathfinder/blob/main/CHANGELOG.md)
147
154
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../src/cli.mjs";
3
3
  import { createPrompter } from "../src/prompt.mjs";
4
+ import { createTheme } from "../src/theme.mjs";
4
5
 
5
6
  // Everything the CLI learns about the outside world arrives through this call.
6
7
  // `run` reads no globals of its own, so a test can hand it a synthesized
@@ -13,10 +14,30 @@ import { createPrompter } from "../src/prompt.mjs";
13
14
  // is a run whose stdout is a pipe.
14
15
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
15
16
 
17
+ // One theme for the whole run, built here because this is the only file allowed
18
+ // to read the process, and threaded into both consumers rather than built twice.
19
+ // Two themes over the same terminal would be two opinions about it, and they
20
+ // would differ in exactly the value that decides how a question is asked: `run`
21
+ // is handed no stdin, so a theme it built for itself would answer no to
22
+ // `selection` while the prompter's answered yes.
23
+ //
24
+ // Note what the capability is told about stdin and what it is not. Whether
25
+ // `setRawMode` exists is asked of the stream; it is never called, here or
26
+ // anywhere in this package — readline owns raw mode for the whole run.
27
+ const theme = createTheme({
28
+ env: process.env,
29
+ platform: process.platform,
30
+ isTTY: Boolean(process.stdout.isTTY),
31
+ inputIsTTY: Boolean(process.stdin.isTTY),
32
+ setRawMode: typeof process.stdin.setRawMode === "function",
33
+ columns: process.stdout.columns,
34
+ });
35
+
16
36
  const prompter = createPrompter({
17
37
  input: process.stdin,
18
38
  output: process.stdout,
19
39
  interactive,
40
+ theme,
20
41
  });
21
42
 
22
43
  try {
@@ -27,6 +48,7 @@ try {
27
48
  env: process.env,
28
49
  platform: process.platform,
29
50
  stdoutIsTTY: Boolean(process.stdout.isTTY),
51
+ theme,
30
52
  prompter,
31
53
  });
32
54
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pathfinder",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Install the Pathfinder AI-assisted, human-in-the-loop workflow kit into a Git repository.",
5
5
  "keywords": [
6
6
  "pathfinder",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "license": "MIT",
15
15
  "author": "Ricardo Lamadrid (Lamadrid Labs)",
16
- "homepage": "https://github.com/rikilamadrid/pathfinder#readme",
16
+ "homepage": "https://pathfinder-kit.vercel.app",
17
17
  "bugs": "https://github.com/rikilamadrid/pathfinder/issues",
18
18
  "repository": {
19
19
  "type": "git",
package/src/cells.mjs ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * How much room a string takes up on a terminal, and how to make it take less.
3
+ *
4
+ * Two functions, and the reason they exist is one measurement being wrong:
5
+ * `theme.ok("✓ Git repository detected")` has a `.length` of 34 and occupies 25
6
+ * cells. The nine-character difference is the SGR pair, which `.length` counts
7
+ * as text because it is text — the terminal simply does not draw it.
8
+ *
9
+ * A renderer that repaints has to know the difference. If a line it believes is
10
+ * one row wraps to two, every cursor-up it issues afterwards is off by one, and
11
+ * the block it is redrawing walks down the screen leaving copies of itself
12
+ * behind. That is not hypothetical: it was reproduced at 24 columns during the
13
+ * prototype, five copies of the question for four keypresses.
14
+ *
15
+ * **Deliberately not a text-processing library.** No wrapping, no padding, no
16
+ * alignment, no layout. Wrapping in particular is the tempting one and is
17
+ * exactly what must not appear here: a function that breaks a line into several
18
+ * is the beginning of a layout engine, and this module exists so that a caller
19
+ * can guarantee *one* line stays one line.
20
+ *
21
+ * **Pure, and stricter than the theme about it.** `createTheme` at least reads
22
+ * an environment it was handed. This reads nothing — no `process`, no glyph
23
+ * table, no capability. The same string always measures the same, which is what
24
+ * lets the tests state cell counts as constants.
25
+ *
26
+ * ## The character model, and what it is honest about
27
+ *
28
+ * Correct for everything Pathfinder actually prints, and not claimed to be
29
+ * correct for everything. What it handles:
30
+ *
31
+ * - **ANSI escapes occupy nothing.** They are drawn by no terminal.
32
+ * - **Wide characters occupy two cells** — CJK, Hangul, fullwidth forms, and
33
+ * the emoji planes the glyph table draws from.
34
+ * - **Combining marks, zero-width joiners, and variation selectors occupy
35
+ * nothing**, because they modify the character before them rather than
36
+ * adding one.
37
+ * - **Everything else occupies one cell.**
38
+ *
39
+ * What it gets wrong, on purpose, because neither can occur in this CLI:
40
+ *
41
+ * - **Emoji joined by ZWJ** — `👨‍👩‍👧` is one glyph of two cells, and this counts
42
+ * three of two, giving 6.
43
+ * - **Skin-tone modifiers** — `👍🏽` is one glyph of two cells, counted as 4.
44
+ *
45
+ * Both **overcount**, and the direction is the whole reason they are tolerable.
46
+ * Overcounting clips a line early, which is cosmetic. Undercounting lets a line
47
+ * wrap, which is the corruption this module was written to prevent. Every
48
+ * string that reaches a measured surface comes from a frozen registry or a
49
+ * literal in this package, so neither case arises; if user text ever reaches
50
+ * one, it will clip a little early rather than break the display.
51
+ *
52
+ * ## East Asian Ambiguous width is a policy, and the policy is "narrow"
53
+ *
54
+ * **Seven of the fourteen glyphs in `theme.mjs`** — `·`, `▲`, `—`, `…`, `━`,
55
+ * `│`, `█` — are classified `Ambiguous` by Unicode, meaning they are one cell
56
+ * beside Latin text and two in a legacy CJK context. **Feature 23's selector
57
+ * adds five more glyphs, of which three are also Ambiguous** — `○`, `↑`, `↓`.
58
+ * Ten of nineteen, once that lands.
59
+ *
60
+ * There is no correct answer available to a process that cannot ask the
61
+ * terminal, so this is a decision rather than a lookup: **Ambiguous is
62
+ * narrow**, which is what every modern terminal outside a CJK locale renders
63
+ * and what the alternative implementations default to. It is asserted in the
64
+ * tests so that changing it is a decision someone makes rather than a
65
+ * regression someone ships.
66
+ *
67
+ * Note which glyphs are *not* on that list, because the grouping is not
68
+ * intuitive: `✓`, `✗`, `░`, and `◉` are unambiguously narrow, while `○` beside
69
+ * them is not. Ambiguity is a property of a character's history in legacy East
70
+ * Asian encodings, not of how it looks.
71
+ */
72
+
73
+ /**
74
+ * A CSI sequence: ESC `[`, parameter bytes, one final letter.
75
+ *
76
+ * Sticky rather than global, so it can be anchored at a position instead of
77
+ * searched for — the scanner below needs "is there an escape *here*", never
78
+ * "is there an escape somewhere after here".
79
+ *
80
+ * Only CSI is recognised, because only CSI is emitted: `theme.mjs` produces SGR
81
+ * colour codes and the two line primitives, all of which are CSI. An OSC
82
+ * sequence would be measured as its literal characters, which would be wrong —
83
+ * and is acceptable only because nothing in this package writes one. A stray
84
+ * `ESC` that begins no valid sequence falls through to the character path and
85
+ * measures zero, since it is a C0 control.
86
+ */
87
+ const CSI = /\u001B\[[0-9;?]*[A-Za-z]/y;
88
+
89
+ /** An SGR sequence — a CSI whose final byte is `m`. The only kind that paints. */
90
+ const SGR_FINAL = "m";
91
+
92
+ /** `ESC[0m` and its abbreviation `ESC[m`. Both end every span the theme opens. */
93
+ const RESET = "\u001B[0m";
94
+ const isReset = (sequence) => sequence === RESET || sequence === "\u001B[m";
95
+
96
+ /**
97
+ * Ranges rendered two cells wide.
98
+ *
99
+ * The standard Wide and Fullwidth set. Written as a sorted table of pairs and
100
+ * searched linearly: it has twenty-one entries and is consulted once per
101
+ * character of a handful of short lines, so a binary search would trade
102
+ * legibility for time nobody is waiting on.
103
+ *
104
+ * **Sorted ascending, and `inRanges` depends on it** — that function stops as
105
+ * soon as a range starts above the code point, so an entry inserted out of
106
+ * order would not merely slow the search, it would silently stop matching.
107
+ *
108
+ * Four of these ranges cover no character this CLI prints today: `1F004`,
109
+ * `1F200–1F2FF`, `1F7E0–1F7EB`, and `1FA70–1FAFF`. They are here anyway, and
110
+ * the reason is the module's one safety property rather than coverage. Every
111
+ * documented inaccuracy in this file *over*counts, which clips a line early and
112
+ * costs a character. A missing Wide range does the opposite: it undercounts, so
113
+ * a line that measured as fitting wraps, and a repainting caller's cursor-up
114
+ * count is then wrong for every frame after it. Overcounting is cosmetic;
115
+ * undercounting corrupts the display. A glyph chosen later from any of these
116
+ * blocks — a coloured circle for a status dot is an entirely plausible future —
117
+ * must not be able to introduce that quietly.
118
+ */
119
+ const WIDE = Object.freeze([
120
+ [0x1100, 0x115f], // Hangul Jamo initial consonants
121
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols
122
+ [0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, CJK compatibility
123
+ [0x3400, 0x4dbf], // CJK Extension A
124
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
125
+ [0xa000, 0xa4cf], // Yi
126
+ [0xa960, 0xa97f], // Hangul Jamo Extended-A
127
+ [0xac00, 0xd7a3], // Hangul syllables
128
+ [0xf900, 0xfaff], // CJK compatibility ideographs
129
+ [0xfe10, 0xfe19], // vertical forms
130
+ [0xfe30, 0xfe6f], // CJK compatibility forms, small form variants
131
+ [0xff00, 0xff60], // fullwidth forms
132
+ [0xffe0, 0xffe6], // fullwidth signs
133
+ [0x1f004, 0x1f004], // mahjong tile red dragon
134
+ [0x1f200, 0x1f2ff], // enclosed ideographic supplement
135
+ [0x1f300, 0x1f64f], // symbols, pictographs, emoticons — `🔍` `📦` `📋` `🎉`
136
+ [0x1f680, 0x1f6ff], // transport and map symbols
137
+ [0x1f7e0, 0x1f7eb], // geometric shapes extended — the coloured circles
138
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
139
+ [0x1fa70, 0x1faff], // symbols and pictographs extended-A
140
+ [0x20000, 0x3fffd], // CJK Extension B and beyond
141
+ ]);
142
+
143
+ /**
144
+ * Ranges that add nothing to a line's width.
145
+ *
146
+ * Combining marks attach to the character before them, and the joiners and
147
+ * selectors steer how a sequence is drawn without being drawn themselves.
148
+ * Counting any of them would overstate every accented word.
149
+ */
150
+ const ZERO_WIDTH = Object.freeze([
151
+ [0x0300, 0x036f], // combining diacritical marks
152
+ [0x1ab0, 0x1aff], // combining diacriticals extended
153
+ [0x1dc0, 0x1dff], // combining diacriticals supplement
154
+ [0x200b, 0x200f], // zero-width space, ZWNJ, ZWJ, directional marks
155
+ [0x20d0, 0x20f0], // combining marks for symbols
156
+ [0xfe00, 0xfe0f], // variation selectors
157
+ [0xfe20, 0xfe2f], // combining half marks
158
+ [0xe0100, 0xe01ef], // variation selectors supplement
159
+ ]);
160
+
161
+ /** Is `code` inside any of these `[low, high]` pairs? */
162
+ function inRanges(code, ranges) {
163
+ for (const [low, high] of ranges) {
164
+ if (code < low) return false; // sorted, so nothing later can match
165
+ if (code <= high) return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Cells occupied by one code point.
172
+ *
173
+ * C0 and C1 controls measure zero. They are not printable, and a terminal that
174
+ * receives one either acts on it or discards it — either way it draws nothing,
175
+ * so counting it would make every string containing an escape too wide.
176
+ */
177
+ function codePointWidth(code) {
178
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
179
+ if (inRanges(code, ZERO_WIDTH)) return 0;
180
+ if (inRanges(code, WIDE)) return 2;
181
+ return 1;
182
+ }
183
+
184
+ /**
185
+ * Walk `text` as a sequence of escapes and code points.
186
+ *
187
+ * One scanner, shared by both public functions, so there is exactly one place
188
+ * that decides where an escape begins and ends. Two implementations of that
189
+ * question would eventually disagree, and the disagreement would show up as a
190
+ * clipped line that measures correctly and renders wrong.
191
+ *
192
+ * @param {string} text
193
+ * @yields {{escape: string|null, character: string|null, width: number}}
194
+ */
195
+ function* scan(text) {
196
+ let index = 0;
197
+
198
+ while (index < text.length) {
199
+ CSI.lastIndex = index;
200
+ const match = CSI.exec(text);
201
+
202
+ if (match !== null) {
203
+ yield { escape: match[0], character: null, width: 0 };
204
+ index = CSI.lastIndex;
205
+ continue;
206
+ }
207
+
208
+ // `codePointAt` reads a surrogate pair as one value, and `fromCodePoint`
209
+ // rebuilds both halves, so a character is never split down the middle.
210
+ const code = text.codePointAt(index);
211
+ const character = String.fromCodePoint(code);
212
+
213
+ yield { escape: null, character, width: codePointWidth(code) };
214
+ index += character.length;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * How many terminal cells `text` occupies when printed.
220
+ *
221
+ * @param {string} text
222
+ * @returns {number} cells, never negative
223
+ */
224
+ export function width(text) {
225
+ let total = 0;
226
+ for (const token of scan(String(text))) total += token.width;
227
+ return total;
228
+ }
229
+
230
+ /**
231
+ * Shorten `text` to at most `cells` columns, leaving the terminal consistent.
232
+ *
233
+ * Three things it will not do, each of which is a way a naive slice breaks a
234
+ * terminal rather than merely a line:
235
+ *
236
+ * - **Never cuts an escape sequence in half.** The tail of a severed `ESC[32m`
237
+ * is printed as literal text — `[32m` appears in the output — and the
238
+ * sequence never takes effect.
239
+ * - **Never splits a character.** Half a surrogate pair is not a character;
240
+ * it renders as a replacement glyph.
241
+ * - **Never leaves a colour open.** A cut inside a painted span drops that
242
+ * span's reset, and the colour then bleeds into everything printed
243
+ * afterwards, including output this package does not own. When that happens
244
+ * a reset is appended — the one byte sequence this function adds, and it adds
245
+ * it precisely so the damage stops at the clip.
246
+ *
247
+ * Escapes cost nothing against the budget, and every escape preceding text that
248
+ * survives the cut is kept — so a clipped line keeps its colour rather than
249
+ * losing its meaning. Severity in this CLI is carried by a glyph and a word as
250
+ * well, but a line that silently changed colour when the window narrowed would
251
+ * look like a different kind of message.
252
+ *
253
+ * What is *not* kept is an escape no visible character earned. See `pending`
254
+ * below: a colour that paints nothing is not decoration, it is noise.
255
+ *
256
+ * @param {string} text
257
+ * @param {number} cells - budget in columns. A budget of zero yields the empty
258
+ * string for text whose leading run is escapes and visible characters, which
259
+ * is every line this CLI renders. Two exceptions, both harmless and both
260
+ * stated rather than tidied away: a text made only of escapes occupies zero
261
+ * cells and is therefore already within any budget, so identity applies; and
262
+ * a *zero-width character* such as a leading `\r` costs nothing against the
263
+ * budget and is retained, so a string beginning with one clips to that
264
+ * character rather than to `""`. Nothing measured by this module begins with
265
+ * one — the progress bar does, and the progress bar is never clipped. A
266
+ * budget at or above the text's width yields the text unchanged.
267
+ * @returns {string}
268
+ */
269
+ export function clip(text, cells) {
270
+ const source = String(text);
271
+
272
+ // Identity when it already fits. Worth stating as its own case: the common
273
+ // call passes a line that fits, and it should come back the same object's
274
+ // value with nothing appended — including no reset.
275
+ if (width(source) <= cells) return source;
276
+
277
+ let kept = "";
278
+ let used = 0;
279
+ let painted = false;
280
+
281
+ // Escapes seen but not yet earned.
282
+ //
283
+ // An escape costs no cells, so the tempting thing is to emit it the moment it
284
+ // is seen. That produces two artefacts, both of which are output describing a
285
+ // colour that paints nothing: a zero budget returns `ESC[32mESC[0m` instead of
286
+ // the empty string, and a cut landing just after a paint leaves that paint
287
+ // and its reset bracketing no text at all.
288
+ //
289
+ // Holding them until a visible character is actually kept fixes both, and
290
+ // costs nothing else: escapes still never count against the budget, and every
291
+ // escape preceding retained text is still emitted, in order.
292
+ //
293
+ // Held as tokens rather than a concatenated string on purpose — flushing has
294
+ // to know which of them were SGR, and re-parsing a string to find out would
295
+ // be the second escape-parsing path this module exists to avoid.
296
+ let pending = [];
297
+
298
+ for (const token of scan(source)) {
299
+ if (token.escape !== null) {
300
+ pending.push(token.escape);
301
+ continue;
302
+ }
303
+
304
+ if (used + token.width > cells) break;
305
+
306
+ for (const escape of pending) {
307
+ kept += escape;
308
+ // Only SGR opens or closes a paint. A cursor movement is not a colour,
309
+ // and treating one as though it were would append resets to lines that
310
+ // never had a span to close.
311
+ if (escape.endsWith(SGR_FINAL)) painted = !isReset(escape);
312
+ }
313
+ pending = [];
314
+
315
+ kept += token.character;
316
+ used += token.width;
317
+ }
318
+
319
+ // Whatever is still pending trailed the last character we kept, so it is
320
+ // discarded — including, possibly, the reset that would have closed a span.
321
+ // That is exactly why `painted` is consulted here rather than trusted to the
322
+ // input: the span is closed by the reset appended below.
323
+ return painted ? kept + RESET : kept;
324
+ }