lux-hammer 0.3.17 → 0.3.21

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.
@@ -0,0 +1,882 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'io/console'
5
+ require 'pty'
6
+
7
+ # Run a command in a PTY with the last prompts you typed pinned to the bottom
8
+ # rows of the screen, under a "prompt history" rule.
9
+ #
10
+ # The trick is to lie about the terminal size: the child gets a PTY that is
11
+ # a few rows shorter than the real terminal, so it renders inside that and never
12
+ # draws over the bar - even a full-screen TUI. A scroll region (DECSTBM) keeps
13
+ # normal-buffer scrolling above the bar as well.
14
+ #
15
+ # A "prompt" is whatever you type between Enters. This is keystroke sniffing,
16
+ # so text the program inserts for you (history recall, autocomplete, menu
17
+ # picks) never shows up - that is the price of working with any program at all.
18
+ #
19
+ # Wrapping a program hides it: the child lives on our PTY, in its own session,
20
+ # so anything outside looking at the terminal sees this process and not the
21
+ # program. Terminals that watch what is running in a pane - Herdr naming a tab,
22
+ # or deciding whether an agent is thinking, waiting, or idle - go blind, and the
23
+ # fix is to stop hiding rather than to relay anything: we borrow the child's
24
+ # name (Session#adopt_child_name) and keep the bar from imitating the child's
25
+ # own screen furniture (KeyBuffer::RULE).
26
+ module LlmWrap
27
+ # Prompts pinned, oldest first, so the newest sits on the very last line -
28
+ # closest to where you are typing. A labelled rule sits above them, so the
29
+ # bar is one row taller than the number of prompts.
30
+ DEFAULT_KEEP ||= 3
31
+ MAX_KEEP ||= 20
32
+
33
+ # Poll gap used to notice that the child's output has settled, and the
34
+ # longest we let a stale bar sit while output keeps streaming.
35
+ IDLE_TICK ||= 0.02
36
+ MAX_DEFER ||= 0.15
37
+ CHUNK ||= 65_536
38
+
39
+ # A pty master hands back at most a kilobyte per read whatever CHUNK says, so
40
+ # one redraw arrives as dozens of pieces. DRAIN_MAX caps how much of it we
41
+ # take in a single go, so a child that never stops talking cannot starve the
42
+ # keyboard. MAX_BLOCK is how long a paint will wait for a safe moment before
43
+ # giving up and taking one - see Session#paint.
44
+ DRAIN_MAX ||= 262_144
45
+ MAX_BLOCK ||= 1.5
46
+
47
+ # Introducers of the escape sequences that carry a string payload:
48
+ # OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
49
+ # final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
50
+ # on the way out - have to know them to find the end of one.
51
+ STRING_INTRO ||= [0x5d, 0x50, 0x58, 0x5e, 0x5f].freeze
52
+
53
+ # `origin` is how this wrapper was asked for, in the words that would ask for
54
+ # it again - only the caller knows that, since taking the child's name throws
55
+ # our own command line away. It is what Handoff writes down; the child's argv
56
+ # stands in when nobody says.
57
+ def self.run(argv, keep: DEFAULT_KEEP, origin: argv)
58
+ keep = keep.to_i.clamp(1, MAX_KEEP)
59
+ return passthrough(argv) unless $stdin.tty? && $stdout.tty?
60
+
61
+ rows, = $stdout.winsize
62
+ # Leave the child a usable screen, or skip the bar entirely.
63
+ return passthrough(argv) if rows.to_i < keep + 4
64
+
65
+ Session.new(argv, keep, origin).run
66
+ end
67
+
68
+ # No terminal to draw on (piped, scripted, inside another wrapper), so get
69
+ # out of the way completely rather than half-working.
70
+ def self.passthrough(argv)
71
+ exec(*argv)
72
+ rescue Errno::ENOENT
73
+ warn "llm wrap: command not found: #{argv.first}"
74
+ 127
75
+ end
76
+
77
+ # Reads the child's line discipline through the PTY master. Used only to tell
78
+ # a password prompt (echo off, still canonical) apart from a raw-mode TUI
79
+ # (echo off, canonical off) - see Session#capture?.
80
+ module Termios
81
+ # struct termios: c_iflag, c_oflag, c_cflag, c_lflag, ... - we want c_lflag.
82
+ # Darwin/BSD fields are unsigned long (8b), Linux uses uint32 (4b), and the
83
+ # ICANON bit differs between them.
84
+ GETA, LFLAG_AT, LFLAG_PACK, ICANON, ECHO =
85
+ if RUBY_PLATFORM.match?(/darwin|bsd/)
86
+ [0x4048_7413, 24, 'Q', 0x100, 0x8] # TIOCGETA
87
+ else
88
+ [0x5401, 12, 'L', 0x2, 0x8] # TCGETS
89
+ end
90
+
91
+ # [echo?, canonical?], or nil when the ioctl is not understood here.
92
+ def self.state(io)
93
+ buf = String.new("\0" * 128)
94
+ io.ioctl(GETA, buf)
95
+ lflag = buf.unpack1("@#{LFLAG_AT}#{LFLAG_PACK}")
96
+ [(lflag & ECHO) != 0, (lflag & ICANON) != 0]
97
+ rescue SystemCallError, IOError, ArgumentError
98
+ nil
99
+ end
100
+ end
101
+
102
+ # Taking the child's name costs us our own command line, and that is the only
103
+ # record of how the pane was started - Herdr's clone-tab reads it back off the
104
+ # foreground process to reopen the same wrapper. So leave it in a file named
105
+ # after our pid: whoever can see the process can find the command. One
106
+ # argument per line, and the file goes away when we do.
107
+ module Handoff
108
+ # Read at call time rather than frozen into a constant at load: this is a
109
+ # long-lived process, and the tests need somewhere else to write.
110
+ def self.dir
111
+ state = ENV['XDG_STATE_HOME'].to_s
112
+ state = File.join(Dir.home, '.local/state') if state.empty?
113
+ File.join(state, 'llm-wrap')
114
+ end
115
+
116
+ def self.path(pid = Process.pid)
117
+ File.join(dir, pid.to_s)
118
+ end
119
+
120
+ def self.write(argv)
121
+ FileUtils.mkdir_p(dir)
122
+ sweep
123
+ File.write(path, argv.join("\n"))
124
+ rescue SystemCallError, IOError
125
+ nil
126
+ end
127
+
128
+ def self.clear
129
+ File.unlink(path)
130
+ rescue SystemCallError
131
+ nil
132
+ end
133
+
134
+ # A crash leaves its file behind, and pids come round again - a stale entry
135
+ # would hand clone-tab someone else's command line. Drop the ones whose
136
+ # process is gone. Signal 0 only asks; EPERM means it is alive and not ours.
137
+ def self.sweep
138
+ Dir.children(dir).each do |name|
139
+ pid = Integer(name, exception: false) or next
140
+
141
+ begin
142
+ Process.kill(0, pid)
143
+ rescue Errno::ESRCH
144
+ File.unlink(File.join(dir, name))
145
+ rescue SystemCallError
146
+ nil
147
+ end
148
+ end
149
+ rescue SystemCallError, IOError
150
+ nil
151
+ end
152
+ end
153
+
154
+ class Session
155
+ def initialize(argv, keep, origin = argv)
156
+ @argv = argv
157
+ @origin = origin
158
+ @bar_rows = keep + 1 # the prompts, plus the rule above them
159
+ @keys = KeyBuffer.new(keep)
160
+ @out = OutScan.new
161
+ @winch = false
162
+ @dirty = true
163
+ @drawn_at = 0.0
164
+ @blocked = nil
165
+ @skipped = nil
166
+ @started = now
167
+ @trace = (File.open(ENV['LLM_WRAP_DEBUG'], 'a') if ENV['LLM_WRAP_DEBUG'].to_s != '')
168
+ rescue SystemCallError
169
+ @trace = nil
170
+ end
171
+
172
+ def run
173
+ @rows, @cols = $stdout.winsize
174
+ $stdout.sync = true
175
+
176
+ @pty_out, @pty_in, @pid = PTY.spawn(*@argv)
177
+ resize_child
178
+ adopt_child_name
179
+ trace('RUN', "#{@argv.inspect} term=#{ENV['TERM'].inspect} #{@rows}x#{@cols} " \
180
+ "child=#{child_rows}x#{@cols}")
181
+ Signal.trap('WINCH') { @winch = true }
182
+
183
+ begin
184
+ enter_screen
185
+ # raw leaves ISIG off, so Ctrl-C reaches the child as a plain 0x03 byte
186
+ # and its own line discipline signals it. We must not trap SIGINT here.
187
+ $stdin.raw { pump }
188
+ ensure
189
+ leave_screen
190
+ Handoff.clear
191
+ end
192
+
193
+ reap
194
+ rescue Errno::ENOENT
195
+ warn "llm wrap: command not found: #{@argv.first}"
196
+ 127
197
+ end
198
+
199
+ private
200
+
201
+ # Answer to the child's name, so a pane running `llm wrap claude` reads from
202
+ # the outside as a pane running `claude`.
203
+ #
204
+ # Everything that watches a terminal pane identifies what is in it from the
205
+ # foreground process, and our child is not it: it has its own session on our
206
+ # PTY and is invisible from out there. Herdr in particular matches argv[0]
207
+ # against the agent it knows - see `herdr pane process-info` - and with
208
+ # `ruby` sitting in that slot the pane has no agent, so none of its
209
+ # detection runs and the tab loses the working spinner and the blocked
210
+ # marker. The name is all it needs; state it reads off the screen and the
211
+ # OSC title, both of which already come straight through us.
212
+ #
213
+ # This throws our own command line away, which is why Handoff wrote it down
214
+ # first.
215
+ def adopt_child_name
216
+ Handoff.write(@origin)
217
+ Process.setproctitle(File.basename(@argv.first.to_s))
218
+ rescue StandardError
219
+ nil
220
+ end
221
+
222
+ def child_rows
223
+ [@rows - @bar_rows, 1].max
224
+ end
225
+
226
+ def now
227
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
228
+ end
229
+
230
+ # -- screen ------------------------------------------------------------
231
+
232
+ def enter_screen
233
+ emit "\e[2J\e[H"
234
+ set_region
235
+ draw_bar
236
+ end
237
+
238
+ def leave_screen
239
+ emit "\e[r" # full screen back
240
+ emit "\e[#{child_rows + 1};1H\e[J" # erase the bar, park the cursor
241
+ emit "\e[?25h" # the child may have hidden it
242
+ end
243
+
244
+ def set_region
245
+ emit "\e[1;#{child_rows}r"
246
+ end
247
+
248
+ def resize_child
249
+ @pty_out.winsize = [child_rows, @cols]
250
+ rescue SystemCallError
251
+ nil
252
+ end
253
+
254
+ # Painted in one write so the cursor never visibly detours through the bar.
255
+ #
256
+ # The region is reset before addressing the bar rows and re-asserted after:
257
+ # that keeps them reachable if the child turned on origin mode, and undoes
258
+ # any scroll region the child set for itself (a full-screen `ESC[r` from the
259
+ # child would otherwise let its scrolling eat the bar).
260
+ #
261
+ # DECSC/DECRC (ESC7/ESC8) is the only way to get the cursor back without
262
+ # emulating the child's screen. The terminal has a single save slot, so a
263
+ # child holding a saved cursor across this point would lose it - drawing
264
+ # only once output has settled makes that vanishingly rare in practice.
265
+ def draw_bar
266
+ out = +"\e7\e[r"
267
+ @keys.lines(@cols).each_with_index do |line, i|
268
+ out << "\e[#{child_rows + 1 + i};1H\e[K" << line
269
+ end
270
+ out << "\e[1;#{child_rows}r\e8"
271
+ emit out
272
+
273
+ @dirty = false
274
+ @drawn_at = now
275
+ end
276
+
277
+ def emit(str)
278
+ $stdout.write(str)
279
+ end
280
+
281
+ def apply_winch
282
+ @winch = false
283
+ rows, cols = $stdout.winsize
284
+ return if rows.to_i.zero? || cols.to_i.zero?
285
+
286
+ @rows, @cols = rows, cols
287
+ resize_child # the kernel SIGWINCHes the child off the back of this
288
+ set_region
289
+ draw_bar
290
+ end
291
+
292
+ # -- pump --------------------------------------------------------------
293
+
294
+ def pump
295
+ loop do
296
+ apply_winch if @winch
297
+
298
+ ready = IO.select([$stdin, @pty_out], nil, nil, IDLE_TICK)
299
+
300
+ unless ready
301
+ paint if @dirty # output settled
302
+ next
303
+ end
304
+
305
+ ready[0].each do |io|
306
+ if io.equal?($stdin)
307
+ data = slurp($stdin) or return
308
+ @pty_in.write(data)
309
+ taking = capture?
310
+ @dirty = true if taking && @keys.feed(data)
311
+ trace_in(data, taking)
312
+ else
313
+ data = drain(@pty_out) or return
314
+ $stdout.write(data)
315
+ @out.feed(data)
316
+ trace_out(data)
317
+ # The child cannot address the bar rows, but it can switch to the
318
+ # alt screen (which starts blank) or scroll a region it set itself.
319
+ # Repainting after every burst heals both.
320
+ @dirty = true
321
+ end
322
+ end
323
+
324
+ # Keep the bar honest while output streams without ever settling.
325
+ paint if @dirty && now - @drawn_at > MAX_DEFER
326
+ end
327
+ rescue Errno::EIO, Errno::EPIPE, EOFError
328
+ nil
329
+ end
330
+
331
+ def slurp(io)
332
+ io.readpartial(CHUNK)
333
+ rescue EOFError, Errno::EIO, IOError
334
+ nil
335
+ end
336
+
337
+ # Take everything the child has queued, not just the kilobyte a pty master
338
+ # gives back per read. One redraw then lands in a single write and the paint
339
+ # that follows it falls on a frame boundary instead of inside the frame.
340
+ def drain(io)
341
+ data = slurp(io) or return nil
342
+
343
+ while data.bytesize < DRAIN_MAX && IO.select([io], nil, nil, 0)
344
+ more = slurp(io) or break
345
+ data << more
346
+ end
347
+
348
+ data
349
+ end
350
+
351
+ # Paint only where the terminal's parser is at rest - see OutScan for what
352
+ # goes wrong otherwise. The bar is cosmetic, so waiting is nearly always
353
+ # right; the exception is a child that leaves a sequence open for good (an
354
+ # unterminated OSC, a DECSC it never restores), where a bar frozen on the
355
+ # wrong prompts is worse than one glitched frame.
356
+ def paint
357
+ return released if @out.safe?
358
+
359
+ @blocked ||= now
360
+
361
+ if now - @blocked < MAX_BLOCK
362
+ # Once per state rather than once per tick, or the log is nothing else.
363
+ reason = @out.to_s
364
+ trace('SKIP', reason) if reason != @skipped
365
+ @skipped = reason
366
+ return
367
+ end
368
+
369
+ trace('FORCE', @out.to_s)
370
+ @out.reset!
371
+ released
372
+ end
373
+
374
+ def released
375
+ @blocked = nil
376
+ @skipped = nil
377
+ draw_bar
378
+ end
379
+
380
+ # Terminals disagree wildly about how they report keys, and the encoding
381
+ # depends on modes the child turns on and off as it runs. LLM_WRAP_DEBUG=<file>
382
+ # records both sides raw so the two can be lined up. Off unless asked for -
383
+ # it necessarily writes down every keystroke.
384
+ #
385
+ # IN <t> <bytes> take=.. echo=.. canon=.. buf=<line so far>
386
+ # OUT <t> <mode sequences the child just set/reset>
387
+ def trace(kind, text)
388
+ return unless @trace
389
+
390
+ @trace.write(format("%9.3f %-3s %s\n", now - @started, kind, text))
391
+ @trace.flush
392
+ rescue IOError, SystemCallError
393
+ @trace = nil
394
+ end
395
+
396
+ def trace_in(data, taking)
397
+ return unless @trace
398
+
399
+ echo, canon = Termios.state(@pty_out)
400
+ trace('IN', "#{data.inspect} take=#{taking} echo=#{echo} canon=#{canon} " \
401
+ "buf=#{@keys.pending.inspect}")
402
+ end
403
+
404
+ # Only the mode-setting sequences, not the whole output stream - the render
405
+ # traffic would bury the log, and it is the modes that decide how the
406
+ # terminal encodes keys in the first place.
407
+ def trace_out(data)
408
+ return unless @trace
409
+
410
+ seqs = data.scan(/\e\[\?[\d;]+[hl]|\e\[[<>][\d;]*[a-zA-Z]/)
411
+ trace('OUT', seqs.uniq.map(&:inspect).join(' ')) unless seqs.empty?
412
+ end
413
+
414
+ # Don't pin what the user cannot see themselves. Echo off while the child
415
+ # is still canonical is a password prompt (sudo, ssh); echo off with
416
+ # canonical also off is just a TUI in raw mode, which is the normal case.
417
+ def capture?
418
+ echo, canonical = Termios.state(@pty_out)
419
+ return true if echo.nil?
420
+
421
+ echo || !canonical
422
+ end
423
+
424
+ def reap
425
+ _, status = Process.waitpid2(@pid)
426
+ status.exitstatus || (128 + status.termsig.to_i)
427
+ rescue Errno::ECHILD
428
+ 0
429
+ end
430
+ end
431
+
432
+ # Follows the child's output on its way to the screen and answers one
433
+ # question: is the terminal's parser at rest right now?
434
+ #
435
+ # Painting the bar means writing our own escapes into that stream, and a pty
436
+ # master hands back at most a kilobyte per read - so a full redraw arrives as
437
+ # dozens of pieces split at arbitrary bytes, and a paint dropped into one of
438
+ # the seams breaks whatever it landed in the middle of:
439
+ #
440
+ # * a sequence, leaving the introducer stranded and its tail printed as
441
+ # text - the literal "[22m" on screen
442
+ # * a UTF-8 character, which comes out as a replacement glyph
443
+ # * a DECSC the child opened and has not closed yet. The terminal has one
444
+ # save slot; we take it, and the child's own ESC8 then puts its cursor on
445
+ # our bar and it draws the rest of the frame over the top. Claude Code
446
+ # wraps every repaint in ESC7 ... ESC8, so this one is not theoretical.
447
+ #
448
+ # This is only ever asked about the *end* of what we have written so far, so
449
+ # there is no need to understand the sequences - just to know where they stop.
450
+ class OutScan
451
+ ESC = 0x1b
452
+ BEL = 0x07
453
+
454
+ # Escapes whose second byte is followed by exactly one more: SS3 (ESC O),
455
+ # the charset designators (ESC ( ) * +), and ESC # / ESC %.
456
+ ONE_MORE ||= [0x4f, 0x28, 0x29, 0x2a, 0x2b, 0x23, 0x25].freeze
457
+
458
+ # Runaway guards. A CSI this long, or a string payload this long, is a
459
+ # stream we have lost the thread of rather than a sequence still coming.
460
+ MAX_CSI ||= 128
461
+ MAX_STR ||= 8192
462
+
463
+ # Nothing outside these bytes can start a sequence or a multi-byte
464
+ # character, so a plain-text burst needs no walking at all.
465
+ INTERESTING ||= /[\e\x80-\xff]/n
466
+
467
+ def initialize
468
+ reset!
469
+ end
470
+
471
+ def reset!
472
+ @state = :text
473
+ @need = 0 # UTF-8 continuation bytes still owed
474
+ @saved = 0 # ESC7 seen without its ESC8
475
+ @len = 0
476
+ end
477
+
478
+ # True when the last byte written ended a sequence and a character, and the
479
+ # child is not holding a saved cursor.
480
+ def safe?
481
+ @state == :text && @need.zero? && @saved.zero?
482
+ end
483
+
484
+ def feed(bytes)
485
+ return if @state == :text && @need.zero? && !bytes.match?(INTERESTING)
486
+
487
+ bytes.each_byte { |b| step(b) }
488
+ end
489
+
490
+ # What is holding a paint back, for the debug trace.
491
+ def to_s
492
+ "#{@state} utf8=#{@need} saved=#{@saved}"
493
+ end
494
+
495
+ private
496
+
497
+ def step(byte)
498
+ case @state
499
+ when :text then text(byte)
500
+ when :esc then escape(byte)
501
+ when :csi then csi(byte)
502
+ when :one then @state = :text
503
+ when :str then string(byte)
504
+ when :st then terminator(byte)
505
+ end
506
+ end
507
+
508
+ def text(byte)
509
+ return @state = :esc if byte == ESC
510
+ return @need = 0 if byte < 0x80
511
+
512
+ # A continuation byte only counts while one is owed; anything else is a
513
+ # lead byte, and a stray one just resets the count.
514
+ return @need -= 1 if @need.positive? && (byte & 0xc0) == 0x80
515
+
516
+ @need = case byte
517
+ when 0xc0..0xdf then 1
518
+ when 0xe0..0xef then 2
519
+ when 0xf0..0xf7 then 3
520
+ else 0
521
+ end
522
+ end
523
+
524
+ # The second byte says how the rest of the sequence ends. ESC7/ESC8 are the
525
+ # pair we care about beyond that - see the class comment.
526
+ def escape(byte)
527
+ @len = 0
528
+
529
+ case byte
530
+ when 0x5b then @state = :csi
531
+ when *STRING_INTRO then @state = :str
532
+ when *ONE_MORE then @state = :one
533
+ when ESC then nil # ESC ESC: still :esc
534
+ else
535
+ # ESC7 saves the cursor and ESC8 restores it. Everything else that gets
536
+ # here is a two-byte escape, and is over.
537
+ @saved += 1 if byte == 0x37
538
+ @saved -= 1 if byte == 0x38 && @saved.positive?
539
+ @state = :text
540
+ end
541
+ end
542
+
543
+ def csi(byte)
544
+ @len += 1
545
+ return @state = :esc if byte == ESC # aborted, a new one starting
546
+ return @state = :text if @len > MAX_CSI
547
+
548
+ @state = :text if byte >= 0x40 && byte <= 0x7e
549
+ end
550
+
551
+ # String payloads run to BEL or ST (ESC \).
552
+ def string(byte)
553
+ @len += 1
554
+ return @state = :st if byte == ESC
555
+ return @state = :text if byte == BEL || @len > MAX_STR
556
+ end
557
+
558
+ def terminator(byte)
559
+ return if byte == ESC
560
+
561
+ @state = byte == 0x5c ? :text : :str
562
+ end
563
+ end
564
+
565
+ # Rebuilds the line you are typing from the raw byte stream on its way to the
566
+ # child, and keeps the last `keep` submitted lines.
567
+ #
568
+ # In raw mode keystrokes usually arrive one at a time, but never rely on it:
569
+ # reads can split a UTF-8 character or an escape sequence, so bytes are
570
+ # accumulated in a binary buffer and only decoded when rendered.
571
+ class KeyBuffer
572
+ MAX_LEN ||= 4096
573
+ NO_PROMPTS ||= '(nothing typed yet)'
574
+ LABEL ||= 'prompt history'
575
+
576
+ CR = 0x0d
577
+ LF = 0x0a
578
+ ESC = 0x1b
579
+ TAB = 0x09
580
+ BS = 0x08
581
+ BEL = 0x07
582
+ DEL = 0x7f
583
+
584
+ CTRL_C = 0x03
585
+ CTRL_U = 0x15
586
+ CTRL_W = 0x17
587
+
588
+ # Modifier bits, as reported by both keyboard protocols (1-based, so the
589
+ # wire value is this + 1).
590
+ SHIFT ||= 1
591
+ ALT ||= 2
592
+ CTRL ||= 4
593
+ SUPER ||= 8
594
+
595
+ # CSI <code>[:<alternates>][;<mods>[:<event>]][;<text codepoints>] u
596
+ KITTY_KEY ||= /\A\e\[(\d+)(?::\d+(?::\d+)?)?(?:;(\d+)(?::(\d+))?)?(?:;([\d:]+))?u\z/
597
+ # CSI 27 ; <mods> ; <code> ~ (xterm modifyOtherKeys)
598
+ XTERM_KEY ||= /\A\e\[27;(\d+);(\d+)~\z/
599
+
600
+ # Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
601
+ # from overflowing the bar without pulling in a character-width gem.
602
+ WIDE ||= [
603
+ 0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
604
+ 0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
605
+ 0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
606
+ 0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
607
+ ].freeze
608
+ ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
609
+
610
+ def initialize(keep)
611
+ @keep = keep
612
+ @prompts = []
613
+ @buf = binary
614
+ @esc = nil
615
+ @paste = false
616
+ end
617
+
618
+ # The line being typed right now, for the debug trace.
619
+ def pending
620
+ decode(@buf)
621
+ end
622
+
623
+ # Returns true when the pinned prompts changed.
624
+ def feed(bytes)
625
+ changed = false
626
+ bytes.each_byte { |b| changed = true if @esc ? escape(b) : plain(b) }
627
+ changed
628
+ end
629
+
630
+ # The whole bar, top to bottom: the labelled rule, then the pinned prompts
631
+ # oldest first so the newest lands on the last line.
632
+ def lines(cols)
633
+ rows = Array.new(@keep) do |slot|
634
+ age = @keep - 1 - slot # 0 is the newest, and it renders last
635
+ text = @prompts[age]
636
+
637
+ if text.nil?
638
+ age.zero? ? "#{DIM}#{clip(NO_PROMPTS, cols)}#{RESET}" : ''
639
+ elsif age.zero?
640
+ "#{CYAN}❯#{RESET} #{clip(text, cols - 2)}"
641
+ else
642
+ "#{DIM}❯ #{clip(text, cols - 2)}#{RESET}"
643
+ end
644
+ end
645
+
646
+ [rule(cols)] + rows
647
+ end
648
+
649
+ private
650
+
651
+ CYAN = "\e[36m"
652
+ DIM = "\e[2m"
653
+ GRAY = "\e[38;5;245m"
654
+ RESET = "\e[0m"
655
+
656
+ # Dashed, and deliberately not the solid U+2500 "─" it wants to be.
657
+ #
658
+ # A pane watcher works out where a program's own furniture is by looking for
659
+ # the last solid rule on the screen: Herdr hangs its "the prompt box is
660
+ # here" and "the permission dialog is here" regions off it. Our bar is below
661
+ # everything, so a solid rule down here becomes the last one and those
662
+ # regions land on the prompt history instead - detection then sees no prompt
663
+ # and no dialog, and the pane reads as neither idle nor blocked. Any glyph
664
+ # that is not box-drawing keeps the bar out of that reckoning; a dashed rule
665
+ # is also a fair signal that this row is not part of the program above.
666
+ RULE ||= '┄'
667
+
668
+ # A full-width rule with the label centred in it, marking off the bar from
669
+ # whatever the wrapped program is drawing above it.
670
+ def rule(cols)
671
+ return '' if cols <= 0
672
+
673
+ label = " #{LABEL} "
674
+ pad = cols - width(label)
675
+ return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
676
+
677
+ left = pad / 2
678
+ "#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
679
+ end
680
+
681
+ def binary
682
+ String.new(encoding: Encoding::BINARY)
683
+ end
684
+
685
+ def plain(byte)
686
+ case byte
687
+ when ESC then @esc = binary << byte; false
688
+ when CR, LF then @paste ? (append("\n"); false) : submit
689
+ when DEL, BS then chop_char; false
690
+ when CTRL_U, CTRL_C then @buf = binary; false
691
+ when CTRL_W then drop_word; false
692
+ when TAB then false # completion text is inserted by the app
693
+ else
694
+ append_byte(byte) if byte >= 0x20
695
+ false
696
+ end
697
+ end
698
+
699
+ # Collect an escape sequence, then decide. Everything is discarded except
700
+ # the handful of forms that mean "newline, but do not submit" and the
701
+ # bracketed-paste markers.
702
+ def escape(byte)
703
+ @esc << byte
704
+
705
+ if @esc.bytesize == 2
706
+ case byte
707
+ when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
708
+ when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
709
+ when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
710
+ else @esc = nil; return false
711
+ end
712
+ end
713
+
714
+ intro = @esc.getbyte(1)
715
+
716
+ # String sequences run to BEL or ST (ESC \) rather than a final byte, and
717
+ # carry a payload of printable text. They are the terminal answering a
718
+ # question the program asked - Codex queries the fg/bg colour on startup
719
+ # and gets back "\e]10;rgb:cdcd/d6d6/f4f4\e\\" - so the whole thing is
720
+ # dropped. Treating them as CSI would spill that payload into the prompt.
721
+ if STRING_INTRO.include?(intro)
722
+ @esc = nil if byte == BEL || (byte == 0x5c && @esc.getbyte(-2) == ESC)
723
+ @esc = nil if @esc && @esc.bytesize > 1024 # unterminated: cut losses
724
+ return false
725
+ end
726
+
727
+ if intro == 0x4f # SS3 is always three bytes
728
+ @esc = nil
729
+ return false
730
+ end
731
+
732
+ if byte >= 0x40 && byte <= 0x7e # CSI final byte
733
+ seq = @esc
734
+ @esc = nil
735
+ return csi(seq)
736
+ end
737
+
738
+ @esc = nil if @esc.bytesize > 32 # runaway guard
739
+ false
740
+ end
741
+
742
+ # Keys do not always arrive as plain bytes. Claude Code turns on both the
743
+ # kitty keyboard protocol (CSI > 1 u) and xterm's modifyOtherKeys level 2
744
+ # (CSI > 4 ; 2 m), under which the terminal reports keys as escape
745
+ # sequences instead - so "hello" can arrive as five CSI sequences and a
746
+ # parser that only reads raw bytes sees nothing at all.
747
+ #
748
+ # Everything else (arrows, function keys, focus in/out, and the SGR mouse
749
+ # reports that Claude's motion tracking produces by the hundred) is
750
+ # discarded: it is not text the user typed.
751
+ def csi(seq)
752
+ case seq
753
+ when "\e[200~" then @paste = true
754
+ when "\e[201~" then @paste = false
755
+ when KITTY_KEY # CSI code[:alt][;mods[:event]][;text] u
756
+ m = Regexp.last_match
757
+ return key(m[1].to_i, m[2].to_i, event: m[3].to_i, text: m[4])
758
+ when XTERM_KEY # CSI 27 ; mods ; code ~
759
+ m = Regexp.last_match
760
+ return key(m[2].to_i, m[1].to_i)
761
+ end
762
+ false
763
+ end
764
+
765
+ # One decoded keypress. `mods` is the usual 1-based bitfield (1 = none,
766
+ # +1 shift, +2 alt, +4 ctrl, +8 super); caps/num lock are masked off so a
767
+ # stuck caps lock does not turn every Enter into a continuation.
768
+ def key(code, mods, event: 0, text: nil)
769
+ return false if event == 3 # key release, not a press
770
+
771
+ bits = [mods - 1, 0].max & (SHIFT | ALT | CTRL | SUPER)
772
+
773
+ case code
774
+ when 13, 10 then return bits.zero? ? submit : (append("\n"); false)
775
+ when 127, 8 then chop_char; return false
776
+ when 9, 27 then return false # Tab, Esc
777
+ end
778
+
779
+ # Ctrl-<key> is a command, never text. Ctrl-V in particular pastes an
780
+ # image in Claude Code, which puts no typed text on the wire at all.
781
+ if bits & CTRL != 0
782
+ case code
783
+ when 117, 99 then @buf = binary # ctrl-u, ctrl-c
784
+ when 119 then drop_word # ctrl-w
785
+ end
786
+ return false
787
+ end
788
+ return false if bits & (ALT | SUPER) != 0
789
+ return false if code < 0x20
790
+
791
+ # The kitty "associated text" field is the literal text of the keypress
792
+ # and wins when present. Without it, shift lives in the modifiers while
793
+ # `code` stays the unshifted key, so apply it by hand.
794
+ if text
795
+ text.split(':').each { |cp| append(cp.to_i.chr(Encoding::UTF_8)) }
796
+ else
797
+ ch = code.chr(Encoding::UTF_8)
798
+ ch = ch.upcase if bits & SHIFT != 0
799
+ append(ch)
800
+ end
801
+ false
802
+ rescue RangeError
803
+ false
804
+ end
805
+
806
+ def append_byte(byte)
807
+ @buf << byte if @buf.bytesize < MAX_LEN
808
+ end
809
+
810
+ def append(str)
811
+ @buf << str.b if @buf.bytesize < MAX_LEN
812
+ end
813
+
814
+ # Drop one whole character: back over any UTF-8 continuation bytes first.
815
+ def chop_char
816
+ return if @buf.empty?
817
+
818
+ i = @buf.bytesize - 1
819
+ i -= 1 while i.positive? && (@buf.getbyte(i) & 0xc0) == 0x80
820
+ @buf.slice!(i..)
821
+ end
822
+
823
+ # Readline's unix-word-rubout: eat trailing whitespace, then the word, and
824
+ # leave the separator before it alone ("one two" -> "one ").
825
+ def drop_word
826
+ @buf.sub!(/\s+\z/, '')
827
+ @buf.sub!(/\S+\z/, '')
828
+ end
829
+
830
+ def submit
831
+ text = decode(@buf)
832
+ @buf = binary
833
+
834
+ return false if text.empty? || @prompts.first == text
835
+
836
+ @prompts.unshift(text)
837
+ @prompts.pop while @prompts.size > @keep
838
+ true
839
+ end
840
+
841
+ # A bar row is one line, so a multi-line prompt - a continuation with
842
+ # Shift/Option+Enter, or a pasted block - is flattened onto it: line breaks
843
+ # show as a backslash + space, and clip() caps the result to the terminal
844
+ # width. Nothing is dropped that would still fit.
845
+ def decode(bytes)
846
+ text = bytes.dup.force_encoding(Encoding::UTF_8).scrub('').strip
847
+ return '' if text.empty?
848
+
849
+ text.gsub(/[^\S\n]+/, ' ') # runs of spaces/tabs, newlines kept
850
+ .gsub(/ ?\n+ ?/) { '\ ' } # block form: no backslash escaping here
851
+ end
852
+
853
+ def clip(text, cells)
854
+ return '' if cells <= 0
855
+ return text if width(text) <= cells
856
+
857
+ out = +''
858
+ used = 0
859
+ text.each_char do |ch|
860
+ w = char_width(ch)
861
+ break if used + w > cells - 1
862
+
863
+ out << ch
864
+ used += w
865
+ end
866
+ out << '…'
867
+ end
868
+
869
+ def width(text)
870
+ text.each_char.sum { |ch| char_width(ch) }
871
+ end
872
+
873
+ def char_width(char)
874
+ cp = char.ord
875
+ return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
876
+
877
+ WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
878
+ rescue RangeError
879
+ 1
880
+ end
881
+ end
882
+ end