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 +21 -0
- package/README.md +417 -0
- package/dist/claude-highlight.js +2282 -0
- package/dist/pty-read-worker.js +19 -0
- package/dist/pty-write-worker.js +18 -0
- package/man/claude-highlight.1 +145 -0
- package/package.json +63 -0
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 |
|