lux-hammer 0.3.16 → 0.3.20

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