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