claude-highlight 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noah Peterson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,417 @@
1
+ # claude-highlight
2
+
3
+ Colors epistemic markers in Claude Code's output, live, in the terminal.
4
+
5
+ claude-highlight # instead of: claude
6
+ claude-highlight --resume # any claude args pass straight through
7
+
8
+ ## Install
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.
12
+
13
+ Globally, the usual way:
14
+
15
+ npm install -g claude-highlight
16
+
17
+ That installs `claude-highlight` on your PATH and its man page alongside, so
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`:
46
+
47
+ alias claude='claude-highlight'
48
+
49
+ Interactive shells only, so scripts, hooks and anything else invoking `claude`
50
+ directly are unaffected.
51
+
52
+ ## Why a PTY wrapper
53
+
54
+ Neither of the two obvious layers can do this:
55
+
56
+ * **Claude Code hooks** never see rendered output. The event set in 2.1.236 is
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!"
63
+
64
+ So the seam is the pseudo-terminal between them.
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 |
93
+ |---|---|---|
94
+ | user message | `48;2;55;55;55` | white |
95
+ | plain prose | default | default |
96
+ | **bold** prose | default | default |
97
+ | list item | default | default |
98
+ | inline code | default | `38;2;177;185;249` |
99
+ | fenced code | default | `32` (syntax color) |
100
+
101
+ "Foreground and background both default" is the `prose_only` setting, on by
102
+ default. It also makes restoring trivial: since a highlight only ever paints
103
+ over a default foreground, `ESC[39m` puts things back exactly, without
104
+ disturbing bold or any background.
105
+
106
+ **Nor does it protect what you are typing.** The input box is drawn at the
107
+ default foreground, word by word, exactly like prose -- measured from the
108
+ capture, a keystroke echo is `ESC[H ESC[52C ESC[50B a` with no SGR at all. The
109
+ stream cannot tell your draft from the model's prose, so that one is settled on
110
+ the screen instead: see "What you type is yours" below.
111
+
112
+ **It does not fully protect code blocks.** Only *syntax-coloured* tokens carry
113
+ a foreground; anything the highlighter leaves alone is indistinguishable from
114
+ prose in the byte stream:
115
+
116
+ | token inside a fence | foreground | skipped? |
117
+ |---|---|---|
118
+ | `# a comment` | 32 | yes |
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
+ }
310
+ }
311
+
312
+ Words are matched **literally**, so punctuation and spaces need no escaping and
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
359
+
360
+ **Linux: written for, not yet re-verified.** The Python original passed in a
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.
367
+
368
+ The one genuinely platform-shaped call is `ioctl`, which is variadic: Apple's
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.
374
+
375
+ The other constant that genuinely differs is the pty write ceiling in raw
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.
380
+
381
+ **Windows: needs a new plumbing layer.** `openpty`, `termios` and the winsize
382
+ ioctls are Unix-only, and `SIGWINCH` does not exist. A port means ConPTY plus
383
+ a different resize path. The filter and the shadow screen are pure TypeScript
384
+ and portable as-is -- it is the process plumbing that is Unix-shaped, roughly
385
+ the whole of `sysffi.ts` and `pty.ts`.
386
+
387
+ **Terminal capability is separate from OS.** The panel uses truecolor and
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.
392
+
393
+ Config honours `XDG_CONFIG_HOME`.
394
+
395
+ ## Files
396
+
397
+ | file | what |
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 |