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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +16 -6
- data/README.md +83 -13
- data/lib/hammer/builder.rb +6 -1
- data/lib/hammer/input.rb +121 -0
- data/lib/hammer/option.rb +23 -9
- data/lib/hammer/parser.rb +12 -14
- data/lib/lux-hammer.rb +98 -16
- data/recipes/lib/deploy/commands.rb +1 -1
- data/recipes/lib/deploy/hammer.rb +1 -1
- data/recipes/lib/llm/usage.rb +844 -0
- data/recipes/lib/llm/wrap.rb +691 -0
- data/recipes/llm.rb +130 -4
- metadata +5 -2
|
@@ -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
|