lux-hammer 0.3.21 → 0.3.23

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.
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'io/console'
5
5
  require 'pty'
6
+ require 'shellwords'
6
7
 
7
8
  # Run a command in a PTY with the last prompts you typed pinned to the bottom
8
9
  # rows of the screen, under a "prompt history" rule.
@@ -16,6 +17,12 @@ require 'pty'
16
17
  # so text the program inserts for you (history recall, autocomplete, menu
17
18
  # picks) never shows up - that is the price of working with any program at all.
18
19
  #
20
+ # A line starting with ! is taken over: it never reaches the child at all, it
21
+ # runs in a shell here, and its output is shown in the bar (Bang, Pane). The
22
+ # round trip that saves is the point - `!git status` typed at an agent is a
23
+ # tool call, a permission check and a model turn for something the shell
24
+ # answers in milliseconds, and this way it costs no context either.
25
+ #
19
26
  # Wrapping a program hides it: the child lives on our PTY, in its own session,
20
27
  # so anything outside looking at the terminal sees this process and not the
21
28
  # program. Terminals that watch what is running in a pane - Herdr naming a tab,
@@ -44,6 +51,10 @@ module LlmWrap
44
51
  DRAIN_MAX ||= 262_144
45
52
  MAX_BLOCK ||= 1.5
46
53
 
54
+ # The bar grows to show ! output, and this is what it may never take: the
55
+ # child keeps at least this many rows whatever the output was.
56
+ MIN_CHILD ||= 5
57
+
47
58
  # Introducers of the escape sequences that carry a string payload:
48
59
  # OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
49
60
  # final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
@@ -99,6 +110,87 @@ module LlmWrap
99
110
  end
100
111
  end
101
112
 
113
+ # Drawing shared by everything that puts a row in the bar: the prompt history
114
+ # and the ! output pane.
115
+ module Text
116
+ CYAN = "\e[36m"
117
+ YELLOW = "\e[33m"
118
+ DIM = "\e[2m"
119
+ GRAY = "\e[38;5;245m"
120
+ REV = "\e[7m"
121
+ RESET = "\e[0m"
122
+
123
+ # Dashed, and deliberately not the solid U+2500 "─" it wants to be.
124
+ #
125
+ # A pane watcher works out where a program's own furniture is by looking for
126
+ # the last solid rule on the screen: Herdr hangs its "the prompt box is
127
+ # here" and "the permission dialog is here" regions off it. Our bar is below
128
+ # everything, so a solid rule down here becomes the last one and those
129
+ # regions land on the prompt history instead - detection then sees no prompt
130
+ # and no dialog, and the pane reads as neither idle nor blocked. Any glyph
131
+ # that is not box-drawing keeps the bar out of that reckoning; a dashed rule
132
+ # is also a fair signal that this row is not part of the program above.
133
+ RULE ||= '┄'
134
+
135
+ # Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
136
+ # from overflowing the bar without pulling in a character-width gem.
137
+ WIDE ||= [
138
+ 0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
139
+ 0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
140
+ 0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
141
+ 0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
142
+ ].freeze
143
+ ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
144
+
145
+ private
146
+
147
+ # A full-width rule with the label centred in it, marking off the bar from
148
+ # whatever the wrapped program is drawing above it.
149
+ def rule(cols, label)
150
+ return '' if cols <= 0
151
+
152
+ label = " #{label} "
153
+ pad = cols - width(label)
154
+ return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
155
+
156
+ left = pad / 2
157
+ "#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
158
+ end
159
+
160
+ def dim(text)
161
+ "#{DIM}#{text}#{RESET}"
162
+ end
163
+
164
+ def clip(text, cells)
165
+ return '' if cells <= 0
166
+ return text if width(text) <= cells
167
+
168
+ out = +''
169
+ used = 0
170
+ text.each_char do |ch|
171
+ w = char_width(ch)
172
+ break if used + w > cells - 1
173
+
174
+ out << ch
175
+ used += w
176
+ end
177
+ out << '…'
178
+ end
179
+
180
+ def width(text)
181
+ text.each_char.sum { |ch| char_width(ch) }
182
+ end
183
+
184
+ def char_width(char)
185
+ cp = char.ord
186
+ return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
187
+
188
+ WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
189
+ rescue RangeError
190
+ 1
191
+ end
192
+ end
193
+
102
194
  # Taking the child's name costs us our own command line, and that is the only
103
195
  # record of how the pane was started - Herdr's clone-tab reads it back off the
104
196
  # foreground process to reopen the same wrapper. So leave it in a file named
@@ -151,12 +243,73 @@ module LlmWrap
151
243
  end
152
244
  end
153
245
 
246
+ # The commands `llm wrap` offers when asked for no particular one. Plain
247
+ # text, one command per line - nothing to learn, editable with anything.
248
+ #
249
+ # A # comment is dropped, so a command can be parked without being deleted.
250
+ # A line with no letter in it is kept but not runnable: blanks and ---- rules
251
+ # stay visible in the picker to group the list, the cursor just steps over
252
+ # them. No executable name is spelled without a letter, so nothing real is
253
+ # caught by that.
254
+ module Config
255
+ DEFAULTS ||= [
256
+ 'claude --dangerously-skip-permissions --continue',
257
+ 'codex resume --last --dangerously-bypass-approvals-and-sandbox',
258
+ 'grok --always-approve --continue'
259
+ ].freeze
260
+
261
+ # A method rather than a constant for the same reason as Handoff.dir: the
262
+ # tests need somewhere else to write.
263
+ def self.path
264
+ ENV['LLM_WRAP_CONFIG'] || File.expand_path('~/.config/hammer/llm-wrap.txt')
265
+ end
266
+
267
+ def self.lines
268
+ return [] unless File.file?(path)
269
+
270
+ # Decide what to keep first, then tidy what survived - lstrip inside the
271
+ # test so an indented # is still a comment.
272
+ File.readlines(path, chomp: true)
273
+ .reject { |line| line.lstrip.start_with?('#') }
274
+ .map(&:strip)
275
+ rescue SystemCallError, IOError
276
+ []
277
+ end
278
+
279
+ # A row the picker shows but will not run. Nothing without a letter in it
280
+ # can name a program, which makes ---- and a blank line free to use as
281
+ # dividers without needing a syntax for them.
282
+ def self.runnable?(line)
283
+ line.match?(/[a-z]/i)
284
+ end
285
+
286
+ # Split rather than hand the line over whole: PTY.spawn passes a
287
+ # one-element argv to /bin/sh, and a command out of this file should reach
288
+ # the wrapper the same way `llm wrap claude --foo` already does - as argv,
289
+ # no shell in between.
290
+ def self.argv(line)
291
+ Shellwords.split(line)
292
+ end
293
+
294
+ # Writes DEFAULTS when there is nothing there yet - no file, or one that is
295
+ # only whitespace. A file holding just commented-out lines is left alone;
296
+ # those are someone's notes, not an empty file.
297
+ def self.seed
298
+ return false if File.file?(path) && !File.read(path).strip.empty?
299
+
300
+ FileUtils.mkdir_p(File.dirname(path))
301
+ File.write(path, "#{DEFAULTS.join("\n")}\n")
302
+ true
303
+ end
304
+ end
305
+
154
306
  class Session
155
307
  def initialize(argv, keep, origin = argv)
156
308
  @argv = argv
157
309
  @origin = origin
158
310
  @bar_rows = keep + 1 # the prompts, plus the rule above them
159
311
  @keys = KeyBuffer.new(keep)
312
+ @pane = nil # the last ! command's output, while one is up
160
313
  @out = OutScan.new
161
314
  @winch = false
162
315
  @dirty = true
@@ -263,8 +416,11 @@ module LlmWrap
263
416
  # child holding a saved cursor across this point would lose it - drawing
264
417
  # only once output has settled makes that vanishingly rare in practice.
265
418
  def draw_bar
419
+ lines = bar_lines
420
+ resize_bar(lines.size)
421
+
266
422
  out = +"\e7\e[r"
267
- @keys.lines(@cols).each_with_index do |line, i|
423
+ lines.first(@bar_rows).each_with_index do |line, i|
268
424
  out << "\e[#{child_rows + 1 + i};1H\e[K" << line
269
425
  end
270
426
  out << "\e[1;#{child_rows}r\e8"
@@ -274,6 +430,37 @@ module LlmWrap
274
430
  @drawn_at = now
275
431
  end
276
432
 
433
+ # The prompt history, or the last ! command's output while one is up. Both
434
+ # are painted the same way; only the number of rows differs.
435
+ def bar_lines
436
+ @pane ? @pane.lines(@cols, max_bar) : @keys.lines(@cols)
437
+ end
438
+
439
+ def max_bar
440
+ [@rows - MIN_CHILD, 2].max
441
+ end
442
+
443
+ # A taller bar moves the line the child ends on, so the scroll region and
444
+ # the child's own idea of the screen have to move with it. Rows handed back
445
+ # still have our text on them and the child has no reason to touch them
446
+ # until it repaints, so clear them on the way out.
447
+ def resize_bar(rows)
448
+ rows = rows.clamp(1, max_bar)
449
+ return if rows == @bar_rows
450
+
451
+ back = @bar_rows - rows # positive when the bar shrinks
452
+ @bar_rows = rows
453
+
454
+ if back.positive?
455
+ out = +"\e7\e[r"
456
+ (child_rows - back + 1..child_rows).each { |row| out << "\e[#{row};1H\e[K" }
457
+ emit out << "\e8"
458
+ end
459
+
460
+ set_region
461
+ resize_child # the kernel SIGWINCHes the child, which repaints itself
462
+ end
463
+
277
464
  def emit(str)
278
465
  $stdout.write(str)
279
466
  end
@@ -305,10 +492,27 @@ module LlmWrap
305
492
  ready[0].each do |io|
306
493
  if io.equal?($stdin)
307
494
  data = slurp($stdin) or return
308
- @pty_in.write(data)
309
495
  taking = capture?
310
- @dirty = true if taking && @keys.feed(data)
496
+
497
+ # KeyBuffer decides what the child is allowed to see: everything,
498
+ # unless a ! line is being typed, which is ours from the ! to the
499
+ # Enter. Nothing is captured at all while the child is asking for a
500
+ # password, and then it gets the bytes untouched.
501
+ if taking
502
+ @dirty = true if @keys.feed(data)
503
+ @pty_in.write(@keys.pass) unless @keys.pass.empty?
504
+ else
505
+ @pty_in.write(data)
506
+ end
507
+
311
508
  trace_in(data, taking)
509
+
510
+ # After the trace, so the log shows the line that ran before its
511
+ # output arrives, and after the write, so nothing waits on a shell.
512
+ if taking
513
+ @keys.bangs.each { |cmd| run_bang(cmd) }
514
+ close_pane if @keys.typing?
515
+ end
312
516
  else
313
517
  data = drain(@pty_out) or return
314
518
  $stdout.write(data)
@@ -328,6 +532,35 @@ module LlmWrap
328
532
  nil
329
533
  end
330
534
 
535
+ # A ! line never went to the child, so this is the whole of it: run it here
536
+ # and put the result in the bar. A bare ! puts the bar back to the prompt
537
+ # history, which is how you close the output without running anything.
538
+ #
539
+ # The pump is stopped while the shell runs, which is why Bang keeps a
540
+ # deadline on it. The command is painted before it runs so a slow one does
541
+ # not look like a hung terminal.
542
+ def run_bang(cmd)
543
+ return close_pane if cmd.empty?
544
+
545
+ @pane = Bang.pending(cmd)
546
+ @dirty = true
547
+ paint
548
+
549
+ @pane = Bang.run(cmd)
550
+ trace('BANG', "#{cmd.inspect} -> exit=#{@pane.status.inspect} #{@pane.note}")
551
+ @dirty = true
552
+ paint
553
+ end
554
+
555
+ # The first keystroke of the next line takes the screen back: the output was
556
+ # for the question you were about to ask, and now you are asking it.
557
+ def close_pane
558
+ return unless @pane
559
+
560
+ @pane = nil
561
+ @dirty = true
562
+ end
563
+
331
564
  def slurp(io)
332
565
  io.readpartial(CHUNK)
333
566
  rescue EOFError, Errno::EIO, IOError
@@ -562,6 +795,195 @@ module LlmWrap
562
795
  end
563
796
  end
564
797
 
798
+ # What a ! command left behind, as rows for the bar.
799
+ #
800
+ # It stays on this side of the wrapper. Nothing is typed into the child, so
801
+ # the output is not in the transcript and not in the model's context - and it
802
+ # is not painted over either, because these rows are outside the child's
803
+ # screen by construction, the same trick that keeps the prompt history safe.
804
+ class Pane
805
+ include Text
806
+
807
+ LABEL ||= 'shell'
808
+
809
+ attr_reader :cmd, :pwd, :out, :status, :note
810
+
811
+ def initialize(cmd, out: [], status: nil, note: nil)
812
+ @cmd = cmd
813
+ @pwd = Dir.pwd
814
+ @out = out
815
+ @status = status
816
+ @note = note
817
+ end
818
+
819
+ # Where it ran, what ran, then what it printed - under a rule saying whose
820
+ # rows these are. `max` is everything the bar is allowed to take.
821
+ #
822
+ # The command keeps the ! you typed rather than wearing a shell's $: it is
823
+ # the same row you were just looking at while typing it, and the bar is a
824
+ # record of what you typed.
825
+ def lines(cols, max)
826
+ head = [dim(clip(tilde(@pwd), cols)),
827
+ "#{YELLOW}!#{RESET} #{clip(@cmd, cols - 2)}"]
828
+ body = @out.empty? ? [dim('(no output)')] : @out.map { |line| clip(line, cols) }
829
+
830
+ [rule(cols, label)] + head + fit(body, max - 1 - head.size)
831
+ end
832
+
833
+ private
834
+
835
+ def label
836
+ bits = [LABEL]
837
+ bits << "exit #{@status}" if @status.to_i.positive?
838
+ bits << @note if @note
839
+ bits.join(' - ')
840
+ end
841
+
842
+ # Long output is cut from the bottom. The bar is a few rows and not a pager:
843
+ # what is worth reading here a command says first, and anything else is a
844
+ # pipe into head or a file away.
845
+ def fit(rows, room)
846
+ return rows if rows.size <= room
847
+ return [] if room < 1
848
+
849
+ rows.first(room - 1) << dim("… +#{rows.size - room + 1} more lines")
850
+ end
851
+
852
+ def tilde(path)
853
+ home = Dir.home
854
+ path.start_with?("#{home}/") || path == home ? path.sub(home, '~') : path
855
+ end
856
+ end
857
+
858
+ # Runs a ! line, here, instead of handing it to the agent.
859
+ module Bang
860
+ # A ! line is meant to be quick, and the pump is stopped while it runs: no
861
+ # keys reach the child and the screen does not repaint. Anything slower than
862
+ # this wants to be a real terminal, or the agent's own tools.
863
+ TIMEOUT ||= 10
864
+ MAX_OUT ||= 65_536
865
+ READ ||= 4096
866
+
867
+ # `cd` on its own, with nothing that would need a shell to work out. Every !
868
+ # line gets a fresh shell, so a cd inside one dies with it and the pwd row
869
+ # above the output would be a lie the moment you tried. Do it here instead.
870
+ # This moves us and nothing else: the agent has its own working directory
871
+ # and we are not reaching into it.
872
+ CD ||= /\Acd(?:\s+(?<path>[^|&;<>()`$\n]*\S))?\z/
873
+
874
+ # Yours, non-interactive - so zsh syntax works, but ~/.zshrc is not read and
875
+ # your aliases and functions are not there. Loading it per line would cost
876
+ # more than the round trip this is here to save.
877
+ def self.shell
878
+ sh = ENV['SHELL'].to_s
879
+ sh.empty? ? '/bin/sh' : sh
880
+ end
881
+
882
+ def self.pending(cmd)
883
+ Pane.new(cmd, note: 'running')
884
+ end
885
+
886
+ def self.run(cmd)
887
+ cmd = cmd.strip
888
+ (m = CD.match(cmd)) ? chdir(cmd, m[:path]) : capture(cmd)
889
+ end
890
+
891
+ def self.chdir(cmd, path)
892
+ prev = Dir.pwd
893
+ Dir.chdir(target(path))
894
+ @back = prev
895
+ Pane.new(cmd, status: 0)
896
+ rescue SystemCallError => e
897
+ Pane.new(cmd, out: [e.message], status: 1)
898
+ end
899
+
900
+ def self.target(path)
901
+ case path
902
+ when nil then Dir.home
903
+ when '-' then @back || Dir.pwd
904
+ else File.expand_path(path)
905
+ end
906
+ end
907
+
908
+ def self.capture(cmd)
909
+ out = String.new(encoding: Encoding::BINARY)
910
+ note = nil
911
+ # No keyboard: our stdin is the terminal, in raw mode, and a command that
912
+ # reads it would be taking the keystrokes meant for the agent. Anything
913
+ # wanting input is not a ! line.
914
+ io = IO.popen([shell, '-c', cmd], in: File::NULL, err: %i[child out], pgroup: true)
915
+ till = clock + TIMEOUT
916
+
917
+ loop do
918
+ # Waiting on the pipe and not on the process: a command that forks and
919
+ # leaves the pipe open (`something &`) holds this until the deadline,
920
+ # and then it is stopped like anything else that overstayed.
921
+ left = till - clock
922
+ if left <= 0 || !IO.select([io], nil, nil, left)
923
+ note = "timed out after #{TIMEOUT}s"
924
+ break
925
+ end
926
+
927
+ begin
928
+ out << io.readpartial(READ)
929
+ rescue EOFError
930
+ break
931
+ end
932
+
933
+ note = 'output cut' if out.bytesize >= MAX_OUT
934
+ break if note
935
+ end
936
+
937
+ stop(io) if note
938
+ Pane.new(cmd, out: rows(out), status: close(io), note: note)
939
+ rescue SystemCallError, IOError => e
940
+ Pane.new(cmd, out: [e.message], status: 1, note: 'did not run')
941
+ end
942
+
943
+ # Whatever it is doing, it does not get to outlive the line that started it -
944
+ # the wrapper is not pumping keys while it lives. TERM first, then insist,
945
+ # and the whole group because a shell -c is usually not the only process.
946
+ def self.stop(io)
947
+ Process.kill('TERM', -io.pid)
948
+ sleep 0.05
949
+ Process.kill('KILL', -io.pid)
950
+ rescue SystemCallError
951
+ nil
952
+ end
953
+
954
+ def self.close(io)
955
+ io.close
956
+ $?&.exitstatus
957
+ rescue SystemCallError, IOError
958
+ nil
959
+ end
960
+
961
+ def self.clock
962
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
963
+ end
964
+
965
+ # Output as bar rows: no escape sequences left to fight with our own colour
966
+ # or walk the cursor out of the pane, and tabs where a terminal would put
967
+ # them. The trailing newline every command ends with is not a blank row.
968
+ def self.rows(bytes)
969
+ text = bytes.dup.force_encoding(Encoding::UTF_8).scrub('')
970
+ out = text.gsub(/\e\[[\d;?]*[ -\/]*[@-~]|\e[\]P^_X].*?(?:\a|\e\\)|\e./m, '')
971
+ .split("\n", -1)
972
+ .map { |line| untab(line).gsub(/[\x00-\x1f\x7f]/, '') }
973
+
974
+ out.pop while !out.empty? && out.last.empty?
975
+ out
976
+ end
977
+
978
+ def self.untab(line)
979
+ return line unless line.include?("\t")
980
+
981
+ line.each_char.with_object(+'') do |ch, out|
982
+ ch == "\t" ? out << ' ' * (8 - (out.length % 8)) : out << ch
983
+ end
984
+ end
985
+ end
986
+
565
987
  # Rebuilds the line you are typing from the raw byte stream on its way to the
566
988
  # child, and keeps the last `keep` submitted lines.
567
989
  #
@@ -569,10 +991,13 @@ module LlmWrap
569
991
  # reads can split a UTF-8 character or an escape sequence, so bytes are
570
992
  # accumulated in a binary buffer and only decoded when rendered.
571
993
  class KeyBuffer
994
+ include Text
995
+
572
996
  MAX_LEN ||= 4096
573
997
  NO_PROMPTS ||= '(nothing typed yet)'
574
998
  LABEL ||= 'prompt history'
575
999
 
1000
+ BANG = 0x21
576
1001
  CR = 0x0d
577
1002
  LF = 0x0a
578
1003
  ESC = 0x1b
@@ -597,33 +1022,52 @@ module LlmWrap
597
1022
  # CSI 27 ; <mods> ; <code> ~ (xterm modifyOtherKeys)
598
1023
  XTERM_KEY ||= /\A\e\[27;(\d+);(\d+)~\z/
599
1024
 
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
1025
  def initialize(keep)
611
1026
  @keep = keep
612
1027
  @prompts = []
613
1028
  @buf = binary
614
1029
  @esc = nil
615
1030
  @paste = false
1031
+ @bang = false
1032
+ @bangs = []
1033
+ @pass = binary
1034
+ @hold = binary
616
1035
  end
617
1036
 
1037
+ # What the last feed decided, for the pump:
1038
+ #
1039
+ # pass the bytes the child is allowed to see. Everything, until a line
1040
+ # starts with !, and then nothing until that line is done with.
1041
+ # bangs ! lines submitted in it, in order. An empty one is a bare !.
1042
+ # typing? there is a line on the go, so the screen is wanted for it.
1043
+ attr_reader :pass, :bangs
1044
+
1045
+ def typing? = !@buf.empty?
1046
+ def bang? = @bang
1047
+
618
1048
  # The line being typed right now, for the debug trace.
619
1049
  def pending
620
1050
  decode(@buf)
621
1051
  end
622
1052
 
623
- # Returns true when the pinned prompts changed.
1053
+ # Returns true when what the bar shows changed.
624
1054
  def feed(bytes)
625
- changed = false
626
- bytes.each_byte { |b| changed = true if @esc ? escape(b) : plain(b) }
1055
+ @pass = binary
1056
+ @hold = binary
1057
+ @bangs = []
1058
+ changed = false
1059
+
1060
+ bytes.each_byte do |b|
1061
+ @hold << b
1062
+ was = @bang
1063
+ hit = @esc ? escape(b) : plain(b)
1064
+ # A ! line redraws on every keystroke: the bar is the only place it is
1065
+ # shown, so it has to keep up the way an input box would.
1066
+ changed = true if hit || @bang || was
1067
+ release unless @esc
1068
+ end
1069
+
1070
+ release # a sequence still arriving: the child waits no longer than today
627
1071
  changed
628
1072
  end
629
1073
 
@@ -643,39 +1087,29 @@ module LlmWrap
643
1087
  end
644
1088
  end
645
1089
 
646
- [rule(cols)] + rows
1090
+ rows[-1] = typing(cols) if @bang
1091
+ [rule(cols, LABEL)] + rows
647
1092
  end
648
1093
 
649
1094
  private
650
1095
 
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
1096
+ # A ! line is not going anywhere near the child, so nothing else on screen
1097
+ # is showing it: this row is the only place you can see what you are typing,
1098
+ # cursor included. It takes the newest prompt's row, which comes back the
1099
+ # moment the line is run or dropped.
1100
+ def typing(cols)
1101
+ text = decode(@buf).delete_prefix('!').lstrip
1102
+ "#{YELLOW}!#{RESET} #{clip(text, cols - 4)}#{REV} #{RESET}"
1103
+ end
672
1104
 
673
- label = " #{LABEL} "
674
- pad = cols - width(label)
675
- return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
1105
+ # Bytes reach the child unless a ! line is holding them back. @swallow
1106
+ # carries the Enter that ended one, which is ours and not the child's.
1107
+ def release
1108
+ return if @hold.empty?
676
1109
 
677
- left = pad / 2
678
- "#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
1110
+ @pass << @hold unless @bang || @swallow
1111
+ @swallow = false
1112
+ @hold = binary
679
1113
  end
680
1114
 
681
1115
  def binary
@@ -687,7 +1121,7 @@ module LlmWrap
687
1121
  when ESC then @esc = binary << byte; false
688
1122
  when CR, LF then @paste ? (append("\n"); false) : submit
689
1123
  when DEL, BS then chop_char; false
690
- when CTRL_U, CTRL_C then @buf = binary; false
1124
+ when CTRL_U, CTRL_C then clear_line; false
691
1125
  when CTRL_W then drop_word; false
692
1126
  when TAB then false # completion text is inserted by the app
693
1127
  else
@@ -701,16 +1135,6 @@ module LlmWrap
701
1135
  # bracketed-paste markers.
702
1136
  def escape(byte)
703
1137
  @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
1138
  intro = @esc.getbyte(1)
715
1139
 
716
1140
  # String sequences run to BEL or ST (ESC \) rather than a final byte, and
@@ -718,12 +1142,34 @@ module LlmWrap
718
1142
  # question the program asked - Codex queries the fg/bg colour on startup
719
1143
  # and gets back "\e]10;rgb:cdcd/d6d6/f4f4\e\\" - so the whole thing is
720
1144
  # dropped. Treating them as CSI would spill that payload into the prompt.
721
- if STRING_INTRO.include?(intro)
1145
+ # An ESC in here is the start of that ST, so this goes before the resync.
1146
+ if @esc.bytesize > 2 && STRING_INTRO.include?(intro)
722
1147
  @esc = nil if byte == BEL || (byte == 0x5c && @esc.getbyte(-2) == ESC)
723
1148
  @esc = nil if @esc && @esc.bytesize > 1024 # unterminated: cut losses
724
1149
  return false
725
1150
  end
726
1151
 
1152
+ # An ESC part-way through a sequence never belongs to it: the one being
1153
+ # collected was abandoned and a new one starts here. That is the ordinary
1154
+ # shape of a bare Escape keypress followed by any other key - the terminal
1155
+ # sends "\e" and then the next key's own sequence - and of a read that
1156
+ # split one sequence off the end of another. Resync rather than give up:
1157
+ # dropping the state would leave the rest of the new sequence to arrive as
1158
+ # plain bytes, and "\e[27;1:3u" would land in the prompt as "[27;1:3u".
1159
+ if byte == ESC
1160
+ @esc = binary << byte
1161
+ return false
1162
+ end
1163
+
1164
+ if @esc.bytesize == 2
1165
+ case byte
1166
+ when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
1167
+ when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
1168
+ when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
1169
+ else @esc = nil; return false
1170
+ end
1171
+ end
1172
+
727
1173
  if intro == 0x4f # SS3 is always three bytes
728
1174
  @esc = nil
729
1175
  return false
@@ -735,7 +1181,9 @@ module LlmWrap
735
1181
  return csi(seq)
736
1182
  end
737
1183
 
738
- @esc = nil if @esc.bytesize > 32 # runaway guard
1184
+ # Lost the thread of it. Keep swallowing to the final byte rather than
1185
+ # dropping the state, for the same reason as the resync above.
1186
+ @esc = @esc.byteslice(0, 2) if @esc.bytesize > 32
739
1187
  false
740
1188
  end
741
1189
 
@@ -780,7 +1228,7 @@ module LlmWrap
780
1228
  # image in Claude Code, which puts no typed text on the wire at all.
781
1229
  if bits & CTRL != 0
782
1230
  case code
783
- when 117, 99 then @buf = binary # ctrl-u, ctrl-c
1231
+ when 117, 99 then clear_line # ctrl-u, ctrl-c
784
1232
  when 119 then drop_word # ctrl-w
785
1233
  end
786
1234
  return false
@@ -804,13 +1252,48 @@ module LlmWrap
804
1252
  end
805
1253
 
806
1254
  def append_byte(byte)
1255
+ return if byte == BANG && !bang_char
1256
+
807
1257
  @buf << byte if @buf.bytesize < MAX_LEN
808
1258
  end
809
1259
 
810
1260
  def append(str)
1261
+ return if str == '!' && !bang_char
1262
+
811
1263
  @buf << str.b if @buf.bytesize < MAX_LEN
812
1264
  end
813
1265
 
1266
+ # A ! in the first column is ours: from here to Enter nothing reaches the
1267
+ # child and the line runs in a shell instead. Typed twice it is handed back,
1268
+ # so `!!ls` arrives at the agent as `!ls` and its own bash mode is still
1269
+ # there when that is what you wanted. A leading space is the other way out.
1270
+ #
1271
+ # False when the character has been dealt with and must not be appended.
1272
+ def bang_char
1273
+ if @bang && @buf == '!'.b
1274
+ @bang = false # the second !: this one is the child's, and the
1275
+ return false # buffer is already holding a ! for it
1276
+ end
1277
+
1278
+ @bang = true if @buf.empty? && !@paste
1279
+ true
1280
+ end
1281
+
1282
+ def clear_line
1283
+ end_bang
1284
+ @buf = binary
1285
+ end
1286
+
1287
+ # Ending a ! line takes the keystroke that ended it along: a backspace or a
1288
+ # ctrl-c that cancelled one was editing our line and not the child's, and a
1289
+ # ctrl-c in particular would interrupt the agent over nothing.
1290
+ def end_bang
1291
+ return unless @bang
1292
+
1293
+ @bang = false
1294
+ @swallow = true
1295
+ end
1296
+
814
1297
  # Drop one whole character: back over any UTF-8 continuation bytes first.
815
1298
  def chop_char
816
1299
  return if @buf.empty?
@@ -818,6 +1301,7 @@ module LlmWrap
818
1301
  i = @buf.bytesize - 1
819
1302
  i -= 1 while i.positive? && (@buf.getbyte(i) & 0xc0) == 0x80
820
1303
  @buf.slice!(i..)
1304
+ end_bang if @buf.empty?
821
1305
  end
822
1306
 
823
1307
  # Readline's unix-word-rubout: eat trailing whitespace, then the word, and
@@ -825,11 +1309,21 @@ module LlmWrap
825
1309
  def drop_word
826
1310
  @buf.sub!(/\s+\z/, '')
827
1311
  @buf.sub!(/\S+\z/, '')
1312
+ end_bang if @buf.empty?
828
1313
  end
829
1314
 
830
1315
  def submit
831
1316
  text = decode(@buf)
832
- @buf = binary
1317
+ bang = @bang
1318
+ raw = @buf
1319
+ clear_line
1320
+
1321
+ # A ! line is not a prompt: it was never said to the agent, so it has no
1322
+ # business in a history of what was.
1323
+ if bang
1324
+ @bangs << command(raw)
1325
+ return true
1326
+ end
833
1327
 
834
1328
  return false if text.empty? || @prompts.first == text
835
1329
 
@@ -838,6 +1332,12 @@ module LlmWrap
838
1332
  true
839
1333
  end
840
1334
 
1335
+ # The line as a shell should see it, which is not how the bar sees it:
1336
+ # newlines are separators a shell understands and must survive whole.
1337
+ def command(bytes)
1338
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub('').strip.delete_prefix('!').strip
1339
+ end
1340
+
841
1341
  # A bar row is one line, so a multi-line prompt - a continuation with
842
1342
  # Shift/Option+Enter, or a pasted block - is flattened onto it: line breaks
843
1343
  # show as a backslash + space, and clip() caps the result to the terminal
@@ -849,34 +1349,5 @@ module LlmWrap
849
1349
  text.gsub(/[^\S\n]+/, ' ') # runs of spaces/tabs, newlines kept
850
1350
  .gsub(/ ?\n+ ?/) { '\ ' } # block form: no backslash escaping here
851
1351
  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
1352
  end
882
1353
  end