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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2312e8eb343bf518ce61de53a97608a42ece28af9777501910d864e9e2f79f1f
4
- data.tar.gz: 05063b25a45e82fbcb7051f93f6af1715e0c2e25f7cfeefdbf343b3d5f4877ed
3
+ metadata.gz: 5b619dd3e2a5448b9ed2f8c22bb286bec9fcb568f3f9d09db1a139774c226928
4
+ data.tar.gz: e06a2ee565a2cdf528f4c4c3f86257c55c8099e972d7ca4c239e8ae73aee605f
5
5
  SHA512:
6
- metadata.gz: 8eb2f34c4c3e7dcb851976b4f7ad502edfb57784661dd06ed0792eaef9f1344ef65dee5a1b05f63c51859aa84d9412f23b5714cde1d65a08360dac549245e98f
7
- data.tar.gz: 23d4f50ec5dab93b6d792009103f78a56df3fa7019bd1266866ff582a55db1e5831f72376ef2949c3bb4cf029a68c1faf1b3f68ea60cb4ea627507924ece146d
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 do |task|
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 ? "#{DIM}[#{var}]#{RESET}" : "[#{var}]"
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 ? "#{DIM}[#{var}]#{RESET} " : "[#{var}] "
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)
@@ -127,6 +127,7 @@ module Tempest
127
127
  private
128
128
 
129
129
  def read_line
130
+ @output.prepare_prompt if @output.respond_to?(:prepare_prompt)
130
131
  @input.readline(PROMPT)
131
132
  rescue Interrupt
132
133
  nil
@@ -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 >= 4
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 - 1}r" # scrolling region: rows 1..rows-1
34
- @io.print "\e[#{rows};1H" # park cursor on the final row (prompt)
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 - 1}r"
146
- @io.print "\e[#{@rows};1H"
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 - 1
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.include?("\e_G")
212
- return [line] if Reline::Unicode.calculate_width(line, true) <= @cols
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
- chunks, _ = Reline::Unicode.split_by_width(line, @cols)
215
- chunks.compact.reject(&:empty?)
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
@@ -1,3 +1,3 @@
1
1
  module Tempest
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tempest-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuya Fujiwara