claude-highlight 1.0.0 → 1.0.1
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/README.md +54 -392
- package/dist/claude-highlight-egzaxzbg.js +72 -0
- package/dist/claude-highlight.js +106 -293
- package/dist/pty-bun-2y4wmvve.js +159 -0
- package/dist/pty-node-6j2dj9ta.js +63 -0
- package/dist/pty-read-worker.js +2 -1
- package/dist/pty-write-worker.js +2 -1
- package/image.png +0 -0
- package/man/claude-highlight.1 +6 -3
- package/package.json +17 -12
package/README.md
CHANGED
|
@@ -1,417 +1,79 @@
|
|
|
1
1
|
# claude-highlight
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
claude-highlight --resume # any claude args pass straight through
|
|
5
|
+
Colors the hedges in Claude Code's output, live, as it streams.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Needs [Bun](https://bun.sh) 1.4 or newer at runtime -- the pty is built on
|
|
11
|
-
`openpty(3)` through `bun:ffi`, so Node cannot run this. Unix only.
|
|
7
|
+
Words like *likely*, *seems*, *assuming*, *untested* and *obviously* get a color so you can see at a glance where the model is guessing, admitting a gap, or overclaiming. Nothing else about the session changes.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
## Install
|
|
14
10
|
|
|
15
11
|
npm install -g claude-highlight
|
|
12
|
+
bun install -g claude-highlight
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
`man claude-highlight` works. The published package is just `dist/`, the man
|
|
19
|
-
page and this README: 30 KB, no dependencies. From a clone instead, it is
|
|
20
|
-
`bun run build && npm install -g .`
|
|
21
|
-
|
|
22
|
-
Or from a checkout, with no install step at all -- Bun runs the TypeScript
|
|
23
|
-
directly, so an edit takes effect on the next run:
|
|
24
|
-
|
|
25
|
-
ln -sf "$PWD/src/claude-highlight.ts" ~/.local/bin/claude-highlight
|
|
26
|
-
|
|
27
|
-
Either way the name on your PATH is what the resume hint uses: the wrapper
|
|
28
|
-
looks for the PATH entry that resolves back to the file it is running as, and
|
|
29
|
-
rewrites Claude Code's `claude --resume` into that name, so the line it prints
|
|
30
|
-
on exit is one you can paste.
|
|
31
|
-
|
|
32
|
-
### Build
|
|
33
|
-
|
|
34
|
-
bun run build # dist/, what `bin` points at
|
|
35
|
-
bun run test:all # every suite against src/
|
|
36
|
-
bun run test:dist # rebuild, then every suite, driving dist/ where it matters
|
|
37
|
-
|
|
38
|
-
The build emits three files: the bundled entry point and the two pty worker
|
|
39
|
-
threads, which stay separate because they are loaded as workers by URL rather
|
|
40
|
-
than imported.
|
|
41
|
-
|
|
42
|
-
Config lives at `~/.config/claude-highlight/config.json` (or `XDG_CONFIG_HOME`),
|
|
43
|
-
so category toggles are shared across every workspace.
|
|
44
|
-
|
|
45
|
-
To make it the default without typing the longer name, add to `~/.zshrc`:
|
|
14
|
+
## Use
|
|
46
15
|
|
|
47
|
-
|
|
16
|
+
claude-highlight # instead of: claude
|
|
17
|
+
claude-highlight --resume # any claude arguments pass straight through
|
|
48
18
|
|
|
49
|
-
|
|
50
|
-
directly are unaffected.
|
|
19
|
+
Press **F9** during a session to toggle categories. Changes apply immediately and are saved.
|
|
51
20
|
|
|
52
|
-
|
|
21
|
+
Scripts and hooks still get the real `claude`.
|
|
53
22
|
|
|
54
|
-
|
|
23
|
+
claude-highlight --hl-selftest # prints sample text; check that color shows up
|
|
24
|
+
claude-highlight --hl-help # the wrapper's own flags
|
|
55
25
|
|
|
56
|
-
|
|
57
|
-
PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, Notification,
|
|
58
|
-
PreCompact, SessionStart, SessionEnd, PermissionRequest — nothing between the
|
|
59
|
-
model's text and the screen.
|
|
60
|
-
* **Ghostty** has no text-match highlighter among its 634 config keys. The
|
|
61
|
-
`link` regex option that would come closest is documented "TODO: This can't
|
|
62
|
-
currently be set!"
|
|
26
|
+
## What gets colored
|
|
63
27
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
## Why it doesn't corrupt the TUI
|
|
67
|
-
|
|
68
|
-
SGR (color) sequences are zero-width. Claude Code lays out its screen by
|
|
69
|
-
counting printable cells, so `ESC[38;5;203m` injected mid-stream changes how a
|
|
70
|
-
cell looks without moving any cell. The child redraws over the wrapper freely
|
|
71
|
-
and never knows it's there.
|
|
72
|
-
|
|
73
|
-
The two hard parts, both handled in `highlight_filter.ts`:
|
|
74
|
-
|
|
75
|
-
* **Chunk boundaries.** Streaming means `likely` often arrives as `lik` +
|
|
76
|
-
`ely`. Trailing word characters are held back until the next chunk resolves
|
|
77
|
-
them, or a 20 ms idle timeout flushes them.
|
|
78
|
-
* **Escape sequences.** A state machine keeps the regex on ground-state text
|
|
79
|
-
only, passes CSI/OSC/DCS through untouched, and tracks the app's current SGR
|
|
80
|
-
so a highlight can restore it afterward.
|
|
81
|
-
|
|
82
|
-
`test/test_filter.ts` asserts the invariant that matters — printable cell count
|
|
83
|
-
identical before and after — including a fuzz pass over random ANSI traffic cut
|
|
84
|
-
at random byte offsets.
|
|
85
|
-
|
|
86
|
-
## Telling prose from everything else
|
|
87
|
-
|
|
88
|
-
The wrapper sees bytes, not roles — so left alone it would happily color your
|
|
89
|
-
own typing, code blocks, and tool output. It doesn't, because Claude Code
|
|
90
|
-
styles those differently. Measured from a real render:
|
|
91
|
-
|
|
92
|
-
| region | background | foreground |
|
|
28
|
+
| category | examples | default |
|
|
93
29
|
|---|---|---|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
| `'a string'` | 31 | yes |
|
|
120
|
-
| a bare identifier | default | **no** |
|
|
121
|
-
| any line in a fence with no language | default | **no** |
|
|
122
|
-
|
|
123
|
-
So a hedge word in an unlabelled code block still gets painted. A fence with no
|
|
124
|
-
language is rendered exactly like prose -- same default colour, same word-by-word
|
|
125
|
-
layout -- so there is nothing left to key on without parsing the markdown that
|
|
126
|
-
the renderer already consumed.
|
|
127
|
-
|
|
128
|
-
If you switch to a theme that gives prose an explicit foreground, highlighting
|
|
129
|
-
will stop appearing — set `"prose_only": false` in the config to fall back to
|
|
130
|
-
painting everything.
|
|
131
|
-
|
|
132
|
-
## Pastes
|
|
133
|
-
|
|
134
|
-
A pty in raw mode -- which is what Claude Code puts its tty in -- accepts about
|
|
135
|
-
**1 KB per write**. A single `os.write` of a large paste returns after 1,022
|
|
136
|
-
bytes and the rest is gone, so keystrokes bound for the child are queued and
|
|
137
|
-
drained whenever the pty reports writable. A blocking write-until-done loop
|
|
138
|
-
would be worse than the bug: the wrapper would stop reading the child's output
|
|
139
|
-
while the child stopped reading its input.
|
|
140
|
-
|
|
141
|
-
Terminal writes go through `write_all`, which loops, for the same reason.
|
|
142
|
-
|
|
143
|
-
Two related details:
|
|
144
|
-
|
|
145
|
-
* Raw mode is set with `TCSANOW`. `tty.setraw`'s default of `TCSAFLUSH`
|
|
146
|
-
discards input already buffered, so anything pasted before raw mode took
|
|
147
|
-
effect would vanish.
|
|
148
|
-
* Hotkeys are ignored between `ESC[200~` and `ESC[201~`. Inside a bracketed
|
|
149
|
-
paste every byte is content -- a pasted F9 sequence would otherwise be
|
|
150
|
-
stripped from the text and pop the panel open mid-paste.
|
|
151
|
-
|
|
152
|
-
`test/test_paste.ts` covers a newline-heavy 256 KB paste, a paste containing an
|
|
153
|
-
embedded F9, and a 1 MB paste, checking length and md5 end to end.
|
|
154
|
-
|
|
155
|
-
## Length: add, never remove
|
|
156
|
-
|
|
157
|
-
Inside the alternate screen the wrapper can **add** bytes but never **remove**
|
|
158
|
-
them. Claude Code already computed its wrapping with every character present,
|
|
159
|
-
so changing the count would shift every cell after it. That rules out the
|
|
160
|
-
"hide the annotation" half of a `[text](low certainty)` syntax — the wrapper
|
|
161
|
-
colors the visible text and leaves the annotation occupying its cells.
|
|
162
|
-
|
|
163
|
-
The one exception is the normal screen. On exit Claude Code prints
|
|
164
|
-
`claude --resume <id>`, which would drop you out of the wrapper; that line is
|
|
165
|
-
emitted after the alt screen closes, where nothing redraws, so it is rewritten
|
|
166
|
-
to name the wrapper instead. Rewrites are hard-gated on alt-screen state.
|
|
167
|
-
|
|
168
|
-
## Colour the child doesn't know about
|
|
169
|
-
|
|
170
|
-
Claude Code repaints with a **cell-level diff**: it rewrites the characters
|
|
171
|
-
that changed and jumps over the ones that didn't. Straight from a capture, one
|
|
172
|
-
line being updated in place:
|
|
173
|
-
|
|
174
|
-
ESC[2C ESC[44B Prob ESC[11G ru ESC[14G es against Hover ESC[31G variants
|
|
175
|
-
|
|
176
|
-
Cells it skips keep their attributes -- including a colour this wrapper
|
|
177
|
-
injected in an earlier frame. The child's damage model tracks characters and
|
|
178
|
-
knows nothing about our SGR, so the colour stays behind after the word that
|
|
179
|
-
earned it is gone, which shows up as a single amber letter inside a word that
|
|
180
|
-
was never a marker.
|
|
181
|
-
|
|
182
|
-
Measured by replaying a 1.8 MB recorded session and reconstructing the screen,
|
|
183
|
-
checked at every frame end:
|
|
184
|
-
|
|
185
|
-
| | stale cell-frames | longest-lived residue |
|
|
186
|
-
|---|---|---|
|
|
187
|
-
| filter alone | 7,865 | 5,049 frames |
|
|
188
|
-
| filter + `screen_model.ts` | 0 | 0 |
|
|
189
|
-
|
|
190
|
-
This is the colour half of "add, never remove". Length is safe because the
|
|
191
|
-
child computed its wrapping with every character present; colour isn't,
|
|
192
|
-
because the child never revisits a cell it believes is already correct.
|
|
193
|
-
|
|
194
|
-
So `screen_model.ts` mirrors the screen from the bytes we hand the terminal,
|
|
195
|
-
and at the end of each frame rewrites the cells **we** painted whose match no
|
|
196
|
-
longer holds -- same character, same column, different attribute, cursor put
|
|
197
|
-
back with DECSC/DECRC. The screen's cell count is untouched, which is the rule
|
|
198
|
-
that governs everything else here. Corrections go inside the frame's
|
|
199
|
-
synchronised-output block (`ESC[?2026h/l`, which Claude Code uses on every
|
|
200
|
-
frame), so the terminal presents the frame and its correction together.
|
|
201
|
-
|
|
202
|
-
It only ever rewrites a cell it painted itself. A cell the child wrote, or one
|
|
203
|
-
the model has never seen, is left alone whatever the text around it says -- so
|
|
204
|
-
a model that drifts loses corrections rather than corrupting the screen.
|
|
205
|
-
Anything that bypasses the model invalidates it: the plugin panel draws
|
|
206
|
-
straight to stdout, so opening or closing it drops every cell back to unknown
|
|
207
|
-
and the repaint on close refills them.
|
|
208
|
-
|
|
209
|
-
Mirroring the screen costs about **0.04 ms a frame** (measured over 4,339
|
|
210
|
-
frames of a real capture), which is well under the child's own frame budget.
|
|
211
|
-
|
|
212
|
-
One parser bug worth naming, since both parsers had it: `ESC ( B` (select
|
|
213
|
-
ASCII) is a *three*-byte escape -- ESC, an intermediate, then the final byte.
|
|
214
|
-
Reading it as two bytes leaves the `B` to be printed, which planted a stray
|
|
215
|
-
character on screen 25 times a session and drifted every column after it.
|
|
216
|
-
|
|
217
|
-
The known edges: column tracking assumes `east_asian_width` for wide
|
|
218
|
-
characters, corrections are capped at 4 KB per frame (a row that doesn't fit
|
|
219
|
-
is finished on the next one), and nothing is corrected outside the alternate
|
|
220
|
-
screen. If a terminal lacks synchronized output the corrections are still
|
|
221
|
-
right, but the cursor may visibly jitter while they're applied.
|
|
222
|
-
|
|
223
|
-
`"idle_repaint": true` in the config is the blunt fallback -- a full repaint
|
|
224
|
-
via the resize nudge once the session goes quiet, which clears residue by
|
|
225
|
-
redrawing everything. It's off by default: the model fixes residue in the
|
|
226
|
-
frame that creates it, and this costs a resize per burst.
|
|
227
|
-
|
|
228
|
-
## What you type is yours
|
|
229
|
-
|
|
230
|
-
Nothing here paints inside the input box. The box is found geometrically --
|
|
231
|
-
the band between the last two full-width rules at the bottom of the screen,
|
|
232
|
-
plus the status chrome below them -- and every cell in it is off limits, so a
|
|
233
|
-
`usually` in a message you are still drafting stays the colour you typed it.
|
|
234
|
-
|
|
235
|
-
The filter still paints it on the way past, because a byte-stream filter has no
|
|
236
|
-
idea which row it is writing to; the shadow screen takes it back at the end of
|
|
237
|
-
the same frame, inside the synchronised-output block, so the painted version is
|
|
238
|
-
never presented. Costs a few bytes a frame while you type a marker word.
|
|
239
|
-
|
|
240
|
-
Detection degrades in the safe direction. No rules found, or the lower rule not
|
|
241
|
-
within 10 rows of the bottom, means no box and no suppression -- highlighting
|
|
242
|
-
carries on as before. The band is capped at 24 rows so a stray rule higher up
|
|
243
|
-
the transcript cannot swallow the screen.
|
|
244
|
-
|
|
245
|
-
## Checking it works
|
|
246
|
-
|
|
247
|
-
claude-highlight --hl-selftest
|
|
248
|
-
|
|
249
|
-
Pushes sample text through the real filter and prints it: the prose lines
|
|
250
|
-
should show color, the inline-code / fenced-code / user-message lines should
|
|
251
|
-
not. If a session looks unhighlighted, run this first — usually the message
|
|
252
|
-
simply had no markers in it, which is not the same as the filter being broken.
|
|
253
|
-
|
|
254
|
-
The suites are standalone programs, not a test framework -- each prints its
|
|
255
|
-
own PASS lines and exits nonzero on any failure:
|
|
256
|
-
|
|
257
|
-
bun run test/run_all.ts # all of them, one at a time
|
|
258
|
-
bun run test/test_filter.ts # or any one on its own
|
|
259
|
-
bunx tsc -p tsconfig.json # types: strict, and every check that applies to src/
|
|
260
|
-
|
|
261
|
-
## Plugin menu
|
|
262
|
-
|
|
263
|
-
Press **F9** to open it. Arrows move, **space or enter** toggles the selected
|
|
264
|
-
category, and **q / esc / F9** closes. Toggles apply immediately and persist to
|
|
265
|
-
the config. In Ghostty, map cmd+/ to open it:
|
|
266
|
-
|
|
267
|
-
keybind = cmd+slash=text:\x1b[20~
|
|
268
|
-
|
|
269
|
-
The menu draws over the bottom rows, then forces a full repaint on close by
|
|
270
|
-
resizing the child's window away and back.
|
|
271
|
-
|
|
272
|
-
That nudge needs a real pause in the middle. Measured against 2.1.236, two
|
|
273
|
-
TIOCSWINSZ calls back to back emit **0 bytes** of redraw — the child coalesces
|
|
274
|
-
the signals, reads the final size, finds it unchanged, and skips rendering.
|
|
275
|
-
Holding the intermediate size for 80 ms produces a full ~4.9 KB repaint. Without
|
|
276
|
-
the pause the menu state closes but the overlay is never erased, which looks
|
|
277
|
-
exactly like a menu that refuses to close.
|
|
278
|
-
|
|
279
|
-
Preview the panel without starting a session:
|
|
280
|
-
|
|
281
|
-
claude-highlight --hl-menu
|
|
282
|
-
|
|
283
|
-
The panel is drawn as a bordered card with its own background and a title bar,
|
|
284
|
-
so it reads as an overlay rather than as more session output. Rows are clamped
|
|
285
|
-
and padded to a uniform width, verified from 50 to 200 columns at every
|
|
286
|
-
selection index.
|
|
287
|
-
|
|
288
|
-
The terminal cursor is hidden while the panel is up. Claude Code leaves it
|
|
289
|
-
wherever it last drew, which can land on a panel row -- and a block cursor
|
|
290
|
-
reads as the selection far more strongly than a marker does, so the two
|
|
291
|
-
compete. The selected row is marked instead by a thick amber bar on its left
|
|
292
|
-
edge plus a brighter background.
|
|
293
|
-
|
|
294
|
-
Config: `~/.config/claude-highlight/config.json`, re-read on change, so you can
|
|
295
|
-
edit it in another window and see it apply without restarting.
|
|
296
|
-
|
|
297
|
-
## Adding your own words
|
|
298
|
-
|
|
299
|
-
Every category takes an `add` list, and `custom` defines whole new categories
|
|
300
|
-
with their own colour and toggle:
|
|
301
|
-
|
|
302
|
-
{
|
|
303
|
-
"categories": {
|
|
304
|
-
"inference": { "add": ["gut feel", "ballpark", "re:hand-?waves?"] }
|
|
305
|
-
},
|
|
306
|
-
"custom": {
|
|
307
|
-
"deadline": { "color": "38;5;99", "on": true, "desc": "schedule risk",
|
|
308
|
-
"terms": ["slipping", "at risk", "behind schedule"] }
|
|
309
|
-
}
|
|
30
|
+
| inference | likely, probably | on |
|
|
31
|
+
| unknown | untested, can't verify | on |
|
|
32
|
+
| assumption | assuming, in theory | on |
|
|
33
|
+
| appearance | seems, looks like | on |
|
|
34
|
+
| overclaim | obviously, clearly | on |
|
|
35
|
+
| modal | might, could be | off |
|
|
36
|
+
| vagueness | roughly, several | off |
|
|
37
|
+
| softener | a bit, fairly | off |
|
|
38
|
+
|
|
39
|
+
Only Claude's prose is colored. Your own messages, inline code, and fenced code with a language tag are left alone. A fenced block with no language looks like prose in the byte stream, so hedge words inside one are still colored.
|
|
40
|
+
|
|
41
|
+
## Config
|
|
42
|
+
|
|
43
|
+
`~/.config/claude-highlight/config.json` (honors `XDG_CONFIG_HOME`), re-read
|
|
44
|
+
whenever it changes. Add words to a category, or define your own:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"categories": {
|
|
49
|
+
"inference": { "add": ["gut feel", "ballpark"] }
|
|
50
|
+
},
|
|
51
|
+
"custom": {
|
|
52
|
+
"deadline": {
|
|
53
|
+
"color": "38;5;99", "on": true, "desc": "schedule risk",
|
|
54
|
+
"terms": ["slipping", "at risk", "behind schedule"]
|
|
310
55
|
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
311
59
|
|
|
312
|
-
|
|
313
|
-
a typo can never be a broken regex that takes the session down. Prefix with
|
|
314
|
-
`re:` to opt into a raw pattern. Anything that will not compile is dropped and
|
|
315
|
-
listed by `--hl-selftest` rather than failing at launch.
|
|
316
|
-
|
|
317
|
-
Multi-word additions work across chunk boundaries like the built-ins do: the
|
|
318
|
-
growable-prefix set is rebuilt from your terms too, so "behind schedule" still
|
|
319
|
-
highlights when streaming splits it. Verified at every split point.
|
|
320
|
-
|
|
321
|
-
`hedge_scan.ts` reads the same file, so a word you add is highlighted live *and*
|
|
322
|
-
counted in scans -- the two cannot drift apart.
|
|
323
|
-
|
|
324
|
-
### Where the lexicon's judgement calls came from
|
|
325
|
-
|
|
326
|
-
The categories and weights are not guesses; they were checked against a real
|
|
327
|
-
corpus. `hedge_results.json` is the miner's own dump, from:
|
|
328
|
-
|
|
329
|
-
bun run src/hedge_scan.ts --json hedge_results.json
|
|
330
|
-
|
|
331
|
-
which walks the local Claude Code, OpenCode and Kimi transcript stores. The run
|
|
332
|
-
behind the current lexicon covered **3,213,505 words over 48,557 assistant
|
|
333
|
-
messages in 1,440 sessions** (44,991 Claude Code, 1,915 OpenCode, 1,651 Kimi),
|
|
334
|
-
and 904 of those sessions scored above zero.
|
|
335
|
-
|
|
336
|
-
What it is good for is the per-term counts, which is what settles an argument
|
|
337
|
-
about a word. Across the corpus, 129 of the lexicon's patterns hit at all, and
|
|
338
|
-
the head is steep: `likely` 1,204, `should be` 809, `some` 706, `might` 560,
|
|
339
|
-
`a few` 556. By category: vagueness 3,416, modal 2,382, inference 1,719,
|
|
340
|
-
assumption 1,279, appearance 1,066, overclaim 885, unknown 546, softener 416.
|
|
341
|
-
The two loudest categories are exactly the two that ship toggled off and
|
|
342
|
-
weighted lowest -- modal 0.5 and vagueness 0.3, against inference 3.0 and
|
|
343
|
-
unknown 2.5 -- which is the corpus agreeing with the reason already written
|
|
344
|
-
next to them: both fire constantly on ordinary option-listing.
|
|
345
|
-
|
|
346
|
-
Two of the fiddlier calls in `hedge_lexicon.ts` are downstream of this. The
|
|
347
|
-
assumption pattern is `should (?:work|be|already|still)` rather than a bare
|
|
348
|
-
`should` precisely because the corpus is full of "I should have checked", which
|
|
349
|
-
is a self-correction rather than an assumption -- so "should be" (809 hits) is
|
|
350
|
-
counted and "should have" is not. And `mostly` (378 hits) sits in assumption
|
|
351
|
-
rather than vagueness, because in transcripts it is almost always a claim about
|
|
352
|
-
coverage nobody measured ("the tests mostly pass"), not an imprecise quantity.
|
|
353
|
-
|
|
354
|
-
The file itself is gitignored: it is a scan of one machine's private
|
|
355
|
-
transcripts, down to session UUIDs and project paths. Regenerate it with the
|
|
356
|
-
command above -- yours will differ, and that is the point.
|
|
357
|
-
|
|
358
|
-
## Portability
|
|
60
|
+
Terms are matched literally; prefix with `re:` for a regex. Terms that fail to compile are skipped and listed by `--hl-selftest`.
|
|
359
61
|
|
|
360
|
-
|
|
361
|
-
container on Linux 6.12 aarch64 and on linux/amd64, including the 1 MB paste
|
|
362
|
-
test and highlighting driven through a real Linux pty. The TypeScript port
|
|
363
|
-
carries the two Linux-shaped branches it needs -- `libc.so.6` plus a
|
|
364
|
-
`libutil` fallback for `openpty`, and the Linux `TIOCSWINSZ`/`TIOCGWINSZ`
|
|
365
|
-
numbers -- but has only been run on macOS arm64, so treat Linux as untested
|
|
366
|
-
until someone runs `bun run test/run_all.ts` there.
|
|
62
|
+
## How it works
|
|
367
63
|
|
|
368
|
-
|
|
369
|
-
arm64 ABI passes variadic arguments on the stack, so the winsize pointer has
|
|
370
|
-
to be handed over as the ninth integer argument to land where `va_arg` looks.
|
|
371
|
-
Everywhere else it stays in a register and the plain three-argument form is
|
|
372
|
-
correct. Both shapes are in `sysffi.ts`; getting this wrong is silent, and
|
|
373
|
-
was: the call returned 0 and set nothing.
|
|
64
|
+
It runs `claude` on a pseudo-terminal and wraps matched words in ANSI color codes on the way to your screen. Color codes are zero-width, so Claude Code's layout is unaffected. A filter keeps matches out of escape sequences and handles words split across stream chunks; a shadow screen removes color that would otherwise linger after Claude redraws a line, and keeps color out of the input box while you type.
|
|
374
65
|
|
|
375
|
-
|
|
376
|
-
mode: **1,022 bytes on macOS, 11,776 on Linux**. Same bug class, absorbed by
|
|
377
|
-
the write worker -- which blocks on a thread of its own until every byte is
|
|
378
|
-
gone -- rather than by a per-OS branch. The BSDs are untested but use the same
|
|
379
|
-
POSIX surface.
|
|
66
|
+
## Development
|
|
380
67
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
the whole of `sysffi.ts` and `pty.ts`.
|
|
68
|
+
git clone https://github.com/NoahBPeterson/claude-highlight
|
|
69
|
+
npm run test:all # every suite, under node
|
|
70
|
+
npm run test:all:bun # same suites, under bun
|
|
71
|
+
bun run build # dist/, what the published bin points at
|
|
386
72
|
|
|
387
|
-
|
|
388
|
-
box-drawing glyphs, which a Linux console or a non-UTF-8 locale lacks. Both
|
|
389
|
-
degrade automatically: `COLORTERM` decides truecolor vs 256-color, and the
|
|
390
|
-
locale decides box-drawing vs ASCII. Row widths are identical in
|
|
391
|
-
either mode, verified at 50/80/120/200 columns for every selection index.
|
|
73
|
+
Building needs Bun, but running does not. The pty comes from `node-pty` under Node and from `openpty` via `bun:ffi` under Bun. Everything else is shared.
|
|
392
74
|
|
|
393
|
-
|
|
75
|
+
The repo also contains `src/hedge_scan.ts`, which scores your local Claude Code transcripts with the same lexicon, and `src/hedge_hook.ts`, a Stop hook that flags hedge-heavy turns. Neither is part of the npm package.
|
|
394
76
|
|
|
395
|
-
##
|
|
77
|
+
## License
|
|
396
78
|
|
|
397
|
-
|
|
398
|
-
|---|---|
|
|
399
|
-
| `src/claude-highlight.ts` | the wrapper: entry point, hotkey, menu, config |
|
|
400
|
-
| `src/pty.ts` | the pty: openpty + a spawned child, with the blocking reads and writes on worker threads |
|
|
401
|
-
| `src/sysffi.ts` | the libc surface, via `bun:ffi` -- openpty, ioctl, raw mode |
|
|
402
|
-
| `src/highlight_filter.ts` | the ANSI-safe stream filter |
|
|
403
|
-
| `src/screen_model.ts` | shadow screen; takes back a highlight the child stranded |
|
|
404
|
-
| `src/hedge_lexicon.ts` | 8 weighted marker categories, shared with the miner |
|
|
405
|
-
| `src/hedge_scan.ts` | offline miner over Claude Code / OpenCode / Kimi transcripts |
|
|
406
|
-
| `src/hedge_hook.ts` | Stop hook, flags markers after a turn via `systemMessage` |
|
|
407
|
-
| `src/json.ts` | the type of parsed JSON, and Python's `bool()` over it |
|
|
408
|
-
| `src/rules.ts` | the rule and rewrite shapes the pipeline passes around |
|
|
409
|
-
| `test/run_all.ts` | every suite, one at a time |
|
|
410
|
-
| `test/test_filter.ts` | invariant + fuzz tests |
|
|
411
|
-
| `test/test_screen.ts` | residue, cell-neutrality, and a recorded-session replay |
|
|
412
|
-
| `test/test_integration.ts` | whole-pipeline tests: config-driven rules, screen ops, hostile ANSI, adversarial config |
|
|
413
|
-
| `test/test_wrapper.ts` | black-box pty tests: real wrapper vs stand-in child (paste, menu, hotkeys, record pairs) |
|
|
414
|
-
| `test/test_paste.ts` | paste-path tests through a real pty (224 KB–1 MB, embedded F9) |
|
|
415
|
-
| `test/test_miners.ts` | `hedge_scan` / `hedge_hook` against fixture transcripts, counts hand-computed |
|
|
416
|
-
| `test/pty-smoke.ts` | the pty layer alone: byte fidelity, exit codes, winsize, 1 MB round trip |
|
|
417
|
-
| `*.py`, `claude-highlight` | the Python original this was ported from, kept as the reference the suites are diffed against |
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
':' //; exec "$(command -v node || command -v bun)" "$0" "$@"
|
|
2
|
+
|
|
3
|
+
// src/util.ts
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
function configFile(app) {
|
|
7
|
+
const base = process.env["XDG_CONFIG_HOME"] || join(process.env["HOME"] || homedir(), ".config");
|
|
8
|
+
return join(base, app, "config.json");
|
|
9
|
+
}
|
|
10
|
+
function decodeIgnore(buf) {
|
|
11
|
+
let out = "";
|
|
12
|
+
const n = buf.length;
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < n) {
|
|
15
|
+
const b = buf[i] ?? 0;
|
|
16
|
+
if (b < 128) {
|
|
17
|
+
out += String.fromCharCode(b);
|
|
18
|
+
i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
let len, cp;
|
|
22
|
+
if (b >= 194 && b <= 223) {
|
|
23
|
+
len = 2;
|
|
24
|
+
cp = b & 31;
|
|
25
|
+
} else if (b >= 224 && b <= 239) {
|
|
26
|
+
len = 3;
|
|
27
|
+
cp = b & 15;
|
|
28
|
+
} else if (b >= 240 && b <= 244) {
|
|
29
|
+
len = 4;
|
|
30
|
+
cp = b & 7;
|
|
31
|
+
} else {
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (i + len > n)
|
|
36
|
+
break;
|
|
37
|
+
let ok = true;
|
|
38
|
+
for (let k = 1;k < len; k++) {
|
|
39
|
+
const c = buf[i + k] ?? 128;
|
|
40
|
+
if ((c & 192) !== 128) {
|
|
41
|
+
ok = false;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
cp = cp << 6 | c & 63;
|
|
45
|
+
}
|
|
46
|
+
if (!ok) {
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (len === 3 && (cp < 2048 || cp >= 55296 && cp <= 57343))
|
|
51
|
+
ok = false;
|
|
52
|
+
if (len === 4 && (cp < 65536 || cp > 1114111))
|
|
53
|
+
ok = false;
|
|
54
|
+
if (!ok) {
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out += String.fromCodePoint(cp);
|
|
59
|
+
i += len;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function environ() {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
66
|
+
if (value !== undefined)
|
|
67
|
+
out[key] = value;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { configFile, decodeIgnore, environ };
|