legion-tty 0.5.1 → 0.5.3

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: c79bfc84ae3c3bc48ae055db72296b3aec2ab24c9a86dba5d7604e5732a3503e
4
- data.tar.gz: 96cb9a58135228887222a4fcc6064209ae1e825fad949c0cbd143c40c5249ed7
3
+ metadata.gz: bdea3050f87e6b47ea8d03d9d3575acdecb2a3968c120cf3f9302068d21a88d9
4
+ data.tar.gz: d90acad8892d80dfab3ba292cb024a969037a4ddbde376535c26418d17cfb5a3
5
5
  SHA512:
6
- metadata.gz: ea7ed87c3baffa35d8e02852ce301c532e0aef8deab17a40892db373abd7f82ac49ae60f22a88e4ea66e90f3d56996db0850cef8e8e09ffd743f8e0a924ff0e3
7
- data.tar.gz: dd2e68abefc811eface7a9db9c7c2014bccf433d7b859b42068746fc952afdbcc4470ec41e761a0d86df40236bb2aebeb334afddcbe278be06fe9acb78f6715f
6
+ metadata.gz: a9bc985a693932f6cd1ec9682306dc55eb282c3d7bc6e430305afdeb05962c2bd2c64d70ad6efba4177bf895519a3636ef659508c8182ead8b63410d5bd8bf78
7
+ data.tar.gz: cecd20a8b7842d4c41671458853a030a45e4276b00be917de6578c8c982024ef8c1b6a333ca43ffbbd7f48850c72416e78f47b4217420570dccf721d0ed8d691
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.3] - 2026-04-20
4
+
5
+ ### Fixed
6
+ - **Scroll performance** — `MessageStream` now caches rendered lines per message keyed on `(object_id, content_hash, role, width, reactions, annotations, tags, pinned)`; cache invalidates automatically on content change (streaming), display option change, or width change — markdown re-parsing only runs for new/changed messages (closes #23)
7
+ - **Input coalescing** — `App#run_loop` drains all queued keys before the next `render_frame`, so rapid typing no longer triggers O(total-messages) renders per keystroke (closes #23)
8
+ - **PgUp/PgDn scroll step** — now scrolls by half-viewport (`(height-3)/2`) instead of a fixed 10 lines, matching Ctrl+B/Ctrl+F behavior (closes #23)
9
+
3
10
  ## [0.5.1] - 2026-04-18
4
11
 
5
12
  ### Fixed
@@ -40,6 +40,8 @@ module Legion
40
40
  DISABLE_ALT_SCREEN = "\e[?1049l"
41
41
  ENABLE_MOUSE = "\e[?1000h\e[?1006h"
42
42
  DISABLE_MOUSE = "\e[?1000h\e[?1006l"
43
+ ENABLE_BRACKETED_PASTE = "\e[?2004h"
44
+ DISABLE_BRACKETED_PASTE = "\e[?2004l"
43
45
  SGR_MOUSE_RE = /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
44
46
 
45
47
  attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat, :input_bar
@@ -158,7 +160,7 @@ module Legion
158
160
 
159
161
  # --- Event Loop ---
160
162
 
161
- # rubocop:disable Metrics/AbcSize
163
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
162
164
  def run_loop
163
165
  require 'io/console'
164
166
 
@@ -166,6 +168,7 @@ module Legion
166
168
  @raw_mode = true
167
169
  $stdout.print ENABLE_ALT_SCREEN
168
170
  $stdout.print ENABLE_MOUSE
171
+ $stdout.print ENABLE_BRACKETED_PASTE
169
172
  $stdout.print cursor.hide
170
173
  $stdout.print cursor.clear_screen
171
174
 
@@ -178,25 +181,38 @@ module Legion
178
181
 
179
182
  key = normalize_key(raw_key)
180
183
  dispatch_key(key)
184
+ drain_burst_keys(raw_in)
181
185
  end
182
186
  end
183
187
  rescue Interrupt
184
188
  nil
185
189
  ensure
186
190
  @raw_mode = false
191
+ $stdout.print DISABLE_BRACKETED_PASTE
187
192
  $stdout.print DISABLE_MOUSE
188
193
  $stdout.print cursor.show
189
194
  $stdout.print DISABLE_ALT_SCREEN
190
195
  shutdown
191
196
  end
192
- # rubocop:enable Metrics/AbcSize
197
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
193
198
 
194
199
  def needs_refresh?
195
200
  active = @screen_manager.active_screen
196
201
  active.respond_to?(:streaming?) && active.streaming?
197
202
  end
198
203
 
204
+ def drain_burst_keys(raw_in)
205
+ while @running && raw_in.wait_readable(0)
206
+ burst_key = read_raw_key(raw_in, timeout: 0)
207
+ break unless burst_key
208
+
209
+ dispatch_key(normalize_key(burst_key))
210
+ end
211
+ end
212
+
199
213
  def normalize_key(raw)
214
+ return raw if raw.is_a?(Hash)
215
+
200
216
  mouse = parse_sgr_mouse(raw)
201
217
  return mouse if mouse
202
218
 
@@ -205,6 +221,7 @@ module Legion
205
221
 
206
222
  # --- Key Dispatch ---
207
223
 
224
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
208
225
  def dispatch_key(key)
209
226
  if key == :ctrl_c
210
227
  @running = false
@@ -225,7 +242,7 @@ module Legion
225
242
  active = @screen_manager.active_screen
226
243
  return unless active
227
244
 
228
- if %i[scroll_up scroll_down].include?(key)
245
+ if key.is_a?(Symbol) && %i[scroll_up scroll_down].include?(key)
229
246
  dispatch_to_screen(active, key)
230
247
  return
231
248
  end
@@ -236,6 +253,7 @@ module Legion
236
253
  dispatch_to_screen(active, key)
237
254
  end
238
255
  end
256
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
239
257
 
240
258
  def dispatch_to_input_screen(screen, key)
241
259
  result = @input_bar.handle_key(key)
@@ -311,9 +329,28 @@ module Legion
311
329
  seq << c
312
330
  break if c.ord.between?(0x40, 0x7E)
313
331
  end
332
+
333
+ return read_paste_content(io) if seq == "\e[200~"
334
+
314
335
  seq
315
336
  end
316
337
 
338
+ def read_paste_content(io)
339
+ buf = +''
340
+ end_marker = "\e[201~"
341
+ loop do
342
+ break unless io.wait_readable(1)
343
+
344
+ c = io.getc
345
+ break unless c
346
+
347
+ buf << c
348
+ break if buf.end_with?(end_marker)
349
+ end
350
+ buf.delete_suffix!(end_marker)
351
+ { paste: buf }
352
+ end
353
+
317
354
  def parse_sgr_mouse(raw)
318
355
  match = SGR_MOUSE_RE.match(raw)
319
356
  return nil unless match
@@ -32,6 +32,8 @@ module Legion
32
32
 
33
33
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
34
34
  def handle_key(key)
35
+ return handle_paste(key[:paste]) if key.is_a?(Hash) && key.key?(:paste)
36
+
35
37
  case key
36
38
  when :enter
37
39
  submit_line
@@ -202,6 +204,14 @@ module Legion
202
204
  :handled
203
205
  end
204
206
 
207
+ def handle_paste(text)
208
+ sanitized = text.to_s.gsub(/\r\n?/, "\n").gsub("\n", ' ')
209
+ @buffer.insert(@cursor_pos, sanitized)
210
+ @cursor_pos += sanitized.length
211
+ @tab_matches = []
212
+ :handled
213
+ end
214
+
205
215
  def insert_char(key)
206
216
  return :pass unless key.is_a?(String) && key.length == 1 && key.ord >= 32
207
217
 
@@ -28,10 +28,13 @@ module Legion
28
28
  @show_numbers = false
29
29
  @colorize = true
30
30
  @show_timestamps = true
31
+ @line_cache = {}
32
+ @cache_options_hash = nil
31
33
  end
32
34
 
33
35
  def add_message(role:, content:)
34
36
  @messages << { role: role, content: content, tool_panels: [], timestamp: Time.now }
37
+ compact_cache if @line_cache.size > @messages.size * 2
35
38
  end
36
39
 
37
40
  def append_streaming(text)
@@ -87,15 +90,35 @@ module Legion
87
90
 
88
91
  private
89
92
 
90
- def build_all_lines(width)
93
+ def build_all_lines(width) # rubocop:disable Metrics/AbcSize
94
+ current_opts = options_hash
95
+ if current_opts != @cache_options_hash
96
+ @line_cache.clear
97
+ @cache_options_hash = current_opts
98
+ end
99
+
91
100
  filtered_messages.each_with_index.flat_map do |msg, idx|
92
101
  next [] if @mute_system && msg[:role] == :system
93
102
  next [] if @silent_mode && msg[:role] == :assistant
94
103
 
95
- render_message(msg, width, @show_numbers ? idx + 1 : nil)
104
+ cache_key = message_cache_key(msg, width)
105
+ @line_cache[cache_key] ||= render_message(msg, width, @show_numbers ? idx + 1 : nil)
96
106
  end
97
107
  end
98
108
 
109
+ def options_hash
110
+ [@wrap_width, @show_numbers, @colorize, @show_timestamps, @highlights, @truncate_limit].hash
111
+ end
112
+
113
+ def message_cache_key(msg, width)
114
+ [msg.object_id, msg[:content], msg[:role], width,
115
+ msg[:reactions], msg[:annotations], msg[:tags], msg[:pinned]].hash
116
+ end
117
+
118
+ def compact_cache
119
+ @line_cache.clear
120
+ end
121
+
99
122
  def filtered_messages
100
123
  return @messages if @filter.nil?
101
124
 
@@ -208,12 +208,10 @@ module Legion
208
208
 
209
209
  def handle_scroll_key(key)
210
210
  case key
211
- when :page_up then @message_stream.scroll_up(10)
212
- when :page_down then @message_stream.scroll_down(10)
211
+ when :page_up, :ctrl_b then @message_stream.scroll_up(half_page_lines)
212
+ when :page_down, :ctrl_f then @message_stream.scroll_down(half_page_lines)
213
213
  when :scroll_up then @message_stream.scroll_up(3)
214
214
  when :scroll_down then @message_stream.scroll_down(3)
215
- when :ctrl_b then @message_stream.scroll_up(half_page_lines)
216
- when :ctrl_f then @message_stream.scroll_down(half_page_lines)
217
215
  when :home then @message_stream.scroll_up(@message_stream.messages.size * 5)
218
216
  when :end then @message_stream.scroll_down(@message_stream.scroll_offset)
219
217
  else return nil
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.5.1'
5
+ VERSION = '0.5.3'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-tty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity