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 +1 -1
- package/README.md +34 -27
- package/bin/create-pathfinder.mjs +22 -0
- package/package.json +2 -2
- package/src/cells.mjs +324 -0
- package/src/cli.mjs +816 -89
- package/src/install.mjs +30 -2
- package/src/kit.mjs +16 -0
- package/src/progress.mjs +144 -0
- package/src/prompt.mjs +116 -11
- package/src/select.mjs +426 -0
- package/src/theme.mjs +590 -0
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 —
|
|
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://
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
108
|
-
- **Several found** — a
|
|
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.
|
|
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.
|
|
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
|
-
- [
|
|
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.
|
|
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://
|
|
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
|
+
}
|