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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/lib/hammer/shell.rb +33 -12
- data/recipes/lib/llm/plan.rb +660 -0
- data/recipes/lib/llm/wrap.rb +558 -87
- data/recipes/llm.rb +445 -17
- metadata +3 -2
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
1053
|
+
# Returns true when what the bar shows changed.
|
|
624
1054
|
def feed(bytes)
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
[
|
|
1090
|
+
rows[-1] = typing(cols) if @bang
|
|
1091
|
+
[rule(cols, LABEL)] + rows
|
|
647
1092
|
end
|
|
648
1093
|
|
|
649
1094
|
private
|
|
650
1095
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|