tempest-rb 0.4.0 → 0.5.0
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/lib/tempest/jetstream/client.rb +7 -1
- data/lib/tempest/repl/formatter.rb +41 -5
- data/lib/tempest/repl/runner.rb +1 -0
- data/lib/tempest/repl/screen.rb +115 -10
- data/lib/tempest/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5b619dd3e2a5448b9ed2f8c22bb286bec9fcb568f3f9d09db1a139774c226928
|
|
4
|
+
data.tar.gz: e06a2ee565a2cdf528f4c4c3f86257c55c8099e972d7ca4c239e8ae73aee605f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e282a10f7bd163df16b8ee0167bf2ac1fb8796a8d9e7bc797efaf2c63bc504f78c3356582e1b4534c3f179a2e5e4dfedebc44bbbb0c5f174727125ec9a1506ea
|
|
7
|
+
data.tar.gz: e6b13a102de8bc229b4c98559a7af2766370311a811aef1c32d31a5c64e606f4bd42eccf7a70a798997ae9c87337f3176d0644a33724ba5fd59ce62d714f73a1
|
|
@@ -55,7 +55,13 @@ module Tempest
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def each_message(url)
|
|
58
|
-
Async
|
|
58
|
+
# `finished: false` makes Async::Task call `@promise.suppress_warnings!`
|
|
59
|
+
# so that connect failures (DNS errors after offline wake-from-sleep,
|
|
60
|
+
# TCP resets, TLS handshake errors) don't trigger Console.logger.warn
|
|
61
|
+
# with "Task may have ended with unhandled exception." plus a full
|
|
62
|
+
# backtrace. The exception still propagates via `.wait`; the
|
|
63
|
+
# StreamManager's reconnect loop already handles it cleanly.
|
|
64
|
+
Async(finished: false) do
|
|
59
65
|
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
60
66
|
Async::WebSocket::Client.connect(endpoint) do |connection|
|
|
61
67
|
while (message = connection.read)
|
|
@@ -21,6 +21,22 @@ module Tempest
|
|
|
21
21
|
DIM = "\e[2m".freeze
|
|
22
22
|
HASHTAG_BLUE = "\e[38;5;110m".freeze
|
|
23
23
|
|
|
24
|
+
# Palette of ANSI 256-color codes used to colorize id vars ($AA, $LA …).
|
|
25
|
+
# Hand-picked across the hue wheel for readability on dark backgrounds,
|
|
26
|
+
# avoiding the cyan (time), green (handle), and blue-110 (hashtag)
|
|
27
|
+
# already used elsewhere. Entries stay within the 6x6x6 cube's middle
|
|
28
|
+
# bands (rgb components mostly in 3..4) so the colors read as muted
|
|
29
|
+
# rather than saturated neon — id vars sit beside the post text and
|
|
30
|
+
# shouldn't fight it for attention. Order is the hue-sorted set rotated
|
|
31
|
+
# by step 13 (coprime with 24), so consecutive palette indices land on
|
|
32
|
+
# near-opposite hues; combined with var_index this keeps adjacent vars
|
|
33
|
+
# ($AY/$AZ/$BA …) visibly distinct.
|
|
34
|
+
VAR_PALETTE = [
|
|
35
|
+
167, 115, 138, 109, 173, 67, 137, 97,
|
|
36
|
+
186, 140, 150, 139, 108, 174, 116, 175,
|
|
37
|
+
117, 180, 103, 179, 146, 143, 176, 114,
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
24
40
|
HASHTAG_PATTERN = /#[[:alnum:]_]+/.freeze
|
|
25
41
|
URL_PATTERN = %r{https?://[^\s]+}.freeze
|
|
26
42
|
DECORATE_PATTERN = Regexp.union(URL_PATTERN, HASHTAG_PATTERN).freeze
|
|
@@ -114,7 +130,7 @@ module Tempest
|
|
|
114
130
|
var = registry&.var_for_uri(subject_uri)
|
|
115
131
|
return label unless var
|
|
116
132
|
|
|
117
|
-
bracket = Formatter.color ? "
|
|
133
|
+
bracket = Formatter.color ? "[#{colorize_var(var)}]" : "[#{var}]"
|
|
118
134
|
"#{label} #{bracket}"
|
|
119
135
|
end
|
|
120
136
|
|
|
@@ -133,7 +149,7 @@ module Tempest
|
|
|
133
149
|
return body unless registry
|
|
134
150
|
|
|
135
151
|
parent_var = registry.var_for_uri(reply_parent_uri)
|
|
136
|
-
marker = parent_var ? "↪#{parent_var} " : "↪ "
|
|
152
|
+
marker = parent_var ? "↪#{colorize_var(parent_var)} " : "↪ "
|
|
137
153
|
"#{marker}#{body}"
|
|
138
154
|
end
|
|
139
155
|
|
|
@@ -158,7 +174,7 @@ module Tempest
|
|
|
158
174
|
urls = URI.extract(text, ["http", "https"]).uniq
|
|
159
175
|
urls.each do |url|
|
|
160
176
|
var = registry.assign_url(url)
|
|
161
|
-
text = text.sub(url, "#{url} (#{var})")
|
|
177
|
+
text = text.sub(url, "#{url} (#{colorize_var(var)})")
|
|
162
178
|
end
|
|
163
179
|
text
|
|
164
180
|
end
|
|
@@ -175,7 +191,7 @@ module Tempest
|
|
|
175
191
|
replacements = valid.map do |facet|
|
|
176
192
|
var = registry.assign_url(facet.uri)
|
|
177
193
|
domain = host_of(facet.uri) || facet.uri
|
|
178
|
-
[facet, "[#{domain} #{var}]"]
|
|
194
|
+
[facet, "[#{domain} #{colorize_var(var)}]"]
|
|
179
195
|
end
|
|
180
196
|
|
|
181
197
|
# Apply substitutions in reverse byte order so earlier ranges remain valid.
|
|
@@ -239,7 +255,27 @@ module Tempest
|
|
|
239
255
|
end
|
|
240
256
|
|
|
241
257
|
def id_label(var)
|
|
242
|
-
Formatter.color ? "
|
|
258
|
+
Formatter.color ? "[#{colorize_var(var)}] " : "[#{var}] "
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Returns a deterministic ANSI 256-color escape for the given var.
|
|
262
|
+
# Indexing by the two-letter id (var_index) avoids the byte-sum
|
|
263
|
+
# collisions that made $AZ and $BA share the same palette slot.
|
|
264
|
+
def colorize_var(var)
|
|
265
|
+
return var unless Formatter.color
|
|
266
|
+
code = VAR_PALETTE[var_index(var) % VAR_PALETTE.size]
|
|
267
|
+
"\e[38;5;#{code}m#{var}#{RESET}"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Converts the two-letter portion of "$XY" into a base-26 index so
|
|
271
|
+
# consecutive vars always map to consecutive palette slots. Falls back
|
|
272
|
+
# to 0 for malformed vars; the palette rotation handles the rest.
|
|
273
|
+
def var_index(var)
|
|
274
|
+
pair = var.to_s.sub(/\A\$/, "")
|
|
275
|
+
return 0 if pair.length < 2
|
|
276
|
+
high = pair[0].ord - "A".ord
|
|
277
|
+
low = pair[1].ord - "A".ord
|
|
278
|
+
high * 26 + low
|
|
243
279
|
end
|
|
244
280
|
|
|
245
281
|
def handle_label(handle)
|
data/lib/tempest/repl/runner.rb
CHANGED
data/lib/tempest/repl/screen.rb
CHANGED
|
@@ -14,11 +14,19 @@ module Tempest
|
|
|
14
14
|
# CSI row;col H move cursor
|
|
15
15
|
# ESC 7 / ESC 8 save/restore cursor (DECSC/DECRC)
|
|
16
16
|
class Screen
|
|
17
|
+
# Number of bottom rows reserved for the prompt. Reline may wrap a long
|
|
18
|
+
# input across multiple display rows; without reserving those rows below
|
|
19
|
+
# the DECSTBM region the wrap would emit \n past the bottom margin and
|
|
20
|
+
# scroll the timeline off-screen. Two rows is the common case for a
|
|
21
|
+
# single-wrap post.
|
|
22
|
+
PROMPT_ROWS = 2
|
|
23
|
+
|
|
17
24
|
def initialize(io:, rows: nil, cols: nil)
|
|
18
25
|
@io = io
|
|
19
26
|
@rows = rows
|
|
20
27
|
@cols = cols
|
|
21
28
|
@enabled = false
|
|
29
|
+
@suspended = false
|
|
22
30
|
@mutex = Mutex.new
|
|
23
31
|
@pending_resize = nil
|
|
24
32
|
end
|
|
@@ -26,12 +34,12 @@ module Tempest
|
|
|
26
34
|
def enable
|
|
27
35
|
return unless @io.respond_to?(:tty?) && @io.tty?
|
|
28
36
|
rows = @rows || detect_rows
|
|
29
|
-
return unless rows && rows >=
|
|
37
|
+
return unless rows && rows >= PROMPT_ROWS + 3
|
|
30
38
|
|
|
31
39
|
@rows = rows
|
|
32
40
|
@cols ||= detect_cols
|
|
33
|
-
@io.print "\e[1;#{rows -
|
|
34
|
-
@io.print "\e[#{
|
|
41
|
+
@io.print "\e[1;#{rows - PROMPT_ROWS}r" # scrolling region: rows 1..rows-PROMPT_ROWS
|
|
42
|
+
@io.print "\e[#{prompt_row};1H" # park cursor on the first prompt row
|
|
35
43
|
@io.flush if @io.respond_to?(:flush)
|
|
36
44
|
@enabled = true
|
|
37
45
|
install_resize_trap
|
|
@@ -59,10 +67,12 @@ module Tempest
|
|
|
59
67
|
@io.print "\e[r"
|
|
60
68
|
@io.flush if @io.respond_to?(:flush)
|
|
61
69
|
@enabled = false
|
|
70
|
+
@suspended = true
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
def resume
|
|
65
74
|
return if @enabled
|
|
75
|
+
@suspended = false
|
|
66
76
|
enable
|
|
67
77
|
end
|
|
68
78
|
|
|
@@ -70,6 +80,22 @@ module Tempest
|
|
|
70
80
|
@enabled
|
|
71
81
|
end
|
|
72
82
|
|
|
83
|
+
# Clear the rows reserved for the prompt and re-park the cursor on the
|
|
84
|
+
# first prompt row. Called by the REPL right before each readline so a
|
|
85
|
+
# previous wrapped input doesn't leave residue on the lower prompt rows.
|
|
86
|
+
def prepare_prompt
|
|
87
|
+
return unless @enabled
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
apply_pending_resize
|
|
90
|
+
PROMPT_ROWS.times do |i|
|
|
91
|
+
@io.print "\e[#{prompt_row + i};1H"
|
|
92
|
+
@io.print "\r\e[2K"
|
|
93
|
+
end
|
|
94
|
+
@io.print "\e[#{prompt_row};1H"
|
|
95
|
+
@io.flush if @io.respond_to?(:flush)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
73
99
|
# SIGWINCH hook. Trap handlers in Ruby are restricted (can't reliably
|
|
74
100
|
# acquire mutexes or drive Reline), so we only stash the new dimensions
|
|
75
101
|
# here and apply them on the next mutex-protected write. If rows/cols
|
|
@@ -80,6 +106,11 @@ module Tempest
|
|
|
80
106
|
end
|
|
81
107
|
|
|
82
108
|
def puts(*lines)
|
|
109
|
+
# While `:compose` hands the terminal off to $EDITOR the Jetstream
|
|
110
|
+
# thread keeps emitting events. Swallow them rather than print over
|
|
111
|
+
# the editor's screen; the cursor is persisted, so reconnect-after-
|
|
112
|
+
# exit would replay anyway if anything important was missed.
|
|
113
|
+
return if @suspended
|
|
83
114
|
@mutex.synchronize do
|
|
84
115
|
apply_pending_resize
|
|
85
116
|
if @enabled
|
|
@@ -125,6 +156,12 @@ module Tempest
|
|
|
125
156
|
|
|
126
157
|
private
|
|
127
158
|
|
|
159
|
+
# The first (top) row of the prompt area. Reline draws the prompt here
|
|
160
|
+
# and may wrap downward into subsequent reserved rows.
|
|
161
|
+
def prompt_row
|
|
162
|
+
@rows - PROMPT_ROWS + 1
|
|
163
|
+
end
|
|
164
|
+
|
|
128
165
|
# Caller must hold @mutex. Re-issues DECSTBM and re-parks the cursor on
|
|
129
166
|
# the new prompt row when winsize actually changed; cheap no-op when it
|
|
130
167
|
# didn't (some terminals send spurious WINCHes on focus changes).
|
|
@@ -142,8 +179,8 @@ module Tempest
|
|
|
142
179
|
@cols = new_cols
|
|
143
180
|
return unless @enabled
|
|
144
181
|
|
|
145
|
-
@io.print "\e[1;#{@rows -
|
|
146
|
-
@io.print "\e[#{
|
|
182
|
+
@io.print "\e[1;#{@rows - PROMPT_ROWS}r"
|
|
183
|
+
@io.print "\e[#{prompt_row};1H"
|
|
147
184
|
@io.flush if @io.respond_to?(:flush)
|
|
148
185
|
end
|
|
149
186
|
|
|
@@ -195,7 +232,7 @@ module Tempest
|
|
|
195
232
|
# cleanly.
|
|
196
233
|
def insert_above_prompt(line)
|
|
197
234
|
chunks = wrap_to_cols(line)
|
|
198
|
-
bottom_of_region = @rows -
|
|
235
|
+
bottom_of_region = @rows - PROMPT_ROWS
|
|
199
236
|
@io.print "\e7" # save cursor
|
|
200
237
|
chunks.each do |chunk|
|
|
201
238
|
@io.print "\e[#{bottom_of_region};1H" # move to last row of scrolling region
|
|
@@ -206,13 +243,81 @@ module Tempest
|
|
|
206
243
|
@io.flush if @io.respond_to?(:flush)
|
|
207
244
|
end
|
|
208
245
|
|
|
246
|
+
# Kitty graphics escape: `\e_G<controls>;<data>\e\\`. A single avatar
|
|
247
|
+
# may be transmitted as multiple consecutive `\e_G..\e\\` chunks (each
|
|
248
|
+
# capped at CHUNK_BYTES) when the PNG is large, but each chunk is still
|
|
249
|
+
# an atomic terminal command that must not be broken by a newline.
|
|
250
|
+
KITTY_ESCAPE = /\e_G[^\e]*?\e\\/m.freeze
|
|
251
|
+
private_constant :KITTY_ESCAPE
|
|
252
|
+
|
|
209
253
|
def wrap_to_cols(line)
|
|
210
254
|
return [line] unless @cols && @cols.positive?
|
|
211
|
-
return [line] if line
|
|
212
|
-
|
|
255
|
+
return [line] if visible_width(line) <= @cols
|
|
256
|
+
|
|
257
|
+
# Tokenize the line into kitty-escape blocks and plain text. Plain
|
|
258
|
+
# text is split by display width; escape blocks travel intact and
|
|
259
|
+
# contribute 0 cells to the running width (their visual footprint —
|
|
260
|
+
# 2 cells per avatar in our usage — is reserved by literal spaces in
|
|
261
|
+
# the surrounding text, see Formatter#compose).
|
|
262
|
+
parts = line.split(/(#{KITTY_ESCAPE})/)
|
|
263
|
+
chunks = []
|
|
264
|
+
current = String.new
|
|
265
|
+
current_width = 0
|
|
266
|
+
|
|
267
|
+
parts.each do |part|
|
|
268
|
+
next if part.empty?
|
|
269
|
+
if part.start_with?("\e_G")
|
|
270
|
+
current << part
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
remaining = part
|
|
275
|
+
until remaining.empty?
|
|
276
|
+
available = @cols - current_width
|
|
277
|
+
if available <= 0
|
|
278
|
+
chunks << current
|
|
279
|
+
current = String.new
|
|
280
|
+
current_width = 0
|
|
281
|
+
available = @cols
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
head, tail = take_by_display_width(remaining, available)
|
|
285
|
+
if head.empty?
|
|
286
|
+
# Next grapheme is wider than the remaining cells — flush and retry.
|
|
287
|
+
chunks << current unless current.empty?
|
|
288
|
+
current = String.new
|
|
289
|
+
current_width = 0
|
|
290
|
+
next
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
current << head
|
|
294
|
+
current_width += Reline::Unicode.calculate_width(head, true)
|
|
295
|
+
remaining = tail
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
chunks << current unless current.empty?
|
|
300
|
+
chunks
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Returns [head, tail] such that head's display width is <= max_width and
|
|
304
|
+
# head + tail == str. Walks graphemes so we don't split a CJK character
|
|
305
|
+
# across chunks.
|
|
306
|
+
def take_by_display_width(str, max_width)
|
|
307
|
+
head = String.new
|
|
308
|
+
width = 0
|
|
309
|
+
str.each_grapheme_cluster do |g|
|
|
310
|
+
w = Reline::Unicode.calculate_width(g, true)
|
|
311
|
+
break if width + w > max_width
|
|
312
|
+
head << g
|
|
313
|
+
width += w
|
|
314
|
+
end
|
|
315
|
+
[head, str.byteslice(head.bytesize, str.bytesize - head.bytesize) || ""]
|
|
316
|
+
end
|
|
213
317
|
|
|
214
|
-
|
|
215
|
-
|
|
318
|
+
def visible_width(line)
|
|
319
|
+
stripped = line.gsub(KITTY_ESCAPE, "")
|
|
320
|
+
Reline::Unicode.calculate_width(stripped, true)
|
|
216
321
|
end
|
|
217
322
|
|
|
218
323
|
def rerender_prompt
|
data/lib/tempest/version.rb
CHANGED