legion-tty 0.5.0 → 0.5.2

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: 20065c242e16ca4262309393cdad733b473f139c1efd288512ffe9d704fb5b69
4
- data.tar.gz: 1159cc15d4545b6dbda4d9a69087a9f9a929c6579fd477c1ae0a27d12dbf95c5
3
+ metadata.gz: 7e484905e47a82bedca53b308ff66880c8b64bcb320d9af6a1130fe4ad50a056
4
+ data.tar.gz: f5c49491e1227c5984e99f161662eef56a5d5924903666aa2ea9dcaecccf098f
5
5
  SHA512:
6
- metadata.gz: 77d12618eb36e18a62e3c0fbfa973f645155a4611771982d596b03803a1c98e3e4b592f3b40699842cd23f977ecb86518c1cdd3a26a4e58db936f0f45ba6137c
7
- data.tar.gz: 4b8f2551be051f7140c851f9cac8a632430a102eda8f0ca78a5b1750fb0f2c266fc903fafd6e01606c5a2222986ab1cab3539003d41120bfac8a2762957de329
6
+ metadata.gz: f2eb92406a0f61f109213eec5858122038e270052cdca8c9c4f3bfcfba7d64d0ead54a1f40b53dd356aa08dc172f920a09f47c46dd108c15a708e2a680ffe474
7
+ data.tar.gz: e107d25bbb2ce75ce886329f8ef02f2fb5991278c5a4d140cc809d940adf34aba9f14a9c4269309be847ae78c78d48ee4d33540d03bfc71712d4b4261ae7a080
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-04-20
4
+
5
+ ### Fixed
6
+ - **Bracketed paste support** — `App#run_loop` now enables bracketed paste mode (`\e[?2004h`) on startup and disables (`\e[?2004l`) on shutdown; `read_csi_sequence` detects `\e[200~` / `\e[201~` paste markers and buffers the full pasted text into a `{ paste: text }` event (closes #22)
7
+ - **InputBar paste handling** — `handle_key` recognizes paste Hash events and inserts the full pasted text at the cursor position, replacing newlines with spaces, without triggering submit (closes #22)
8
+ - **Non-symbol key guard** — `dispatch_key` checks `key.is_a?(Symbol)` before the scroll-event include check, preventing `NoMethodError` on Hash paste events (closes #22)
9
+ - **`normalize_key` paste passthrough** — Hash events (paste) pass through without KEY_MAP lookup (closes #22)
10
+
11
+ ## [0.5.1] - 2026-04-18
12
+
13
+ ### Fixed
14
+ - **Alternate screen buffer** — `App#run_loop` now enables `\e[?1049h` on startup and `\e[?1049l` on shutdown so the TUI no longer pollutes shell scrollback and the prior shell session is restored on exit (closes #20)
15
+ - **Mouse wheel scroll** — enabled SGR mouse tracking (`\e[?1000h` + `\e[?1006h`) and routed wheel-up/wheel-down events to `MessageStream#scroll_up` / `#scroll_down` with 3-line increments (closes #20)
16
+ - **Vim-style scroll bindings** — added `Ctrl+B` (half-page up) and `Ctrl+F` (half-page down) on the Chat screen; wired `Home` / `End` to jump-to-top / jump-to-bottom when the input bar is empty (closes #20)
17
+ - **Scroll hint in status bar** — `scroll_segment` now shows directional arrows (`↑↓ scroll` or `↑ scroll`) alongside the position counter when content overflows the viewport (closes #20)
18
+ - **Help text updated** — added scroll binding reference line to the help overlay
19
+
3
20
  ## [0.5.0] - 2026-04-17
4
21
 
5
22
  ### Added
@@ -30,11 +30,20 @@ module Legion
30
30
  "\e[1~" => :home, "\e[4~" => :end,
31
31
  "\x7f" => :backspace, "\b" => :backspace, "\t" => :tab,
32
32
  "\x03" => :ctrl_c, "\x04" => :ctrl_d,
33
- "\x01" => :ctrl_a, "\x05" => :ctrl_e,
33
+ "\x01" => :ctrl_a, "\x02" => :ctrl_b,
34
+ "\x05" => :ctrl_e, "\x06" => :ctrl_f,
34
35
  "\x0B" => :ctrl_k, "\x0C" => :ctrl_l, "\x13" => :ctrl_s,
35
36
  "\x15" => :ctrl_u
36
37
  }.freeze
37
38
 
39
+ ENABLE_ALT_SCREEN = "\e[?1049h"
40
+ DISABLE_ALT_SCREEN = "\e[?1049l"
41
+ ENABLE_MOUSE = "\e[?1000h\e[?1006h"
42
+ DISABLE_MOUSE = "\e[?1000h\e[?1006l"
43
+ ENABLE_BRACKETED_PASTE = "\e[?2004h"
44
+ DISABLE_BRACKETED_PASTE = "\e[?2004l"
45
+ SGR_MOUSE_RE = /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
46
+
38
47
  attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat, :input_bar
39
48
 
40
49
  def self.run(argv = [])
@@ -151,12 +160,15 @@ module Legion
151
160
 
152
161
  # --- Event Loop ---
153
162
 
154
- # rubocop:disable Metrics/AbcSize
163
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
155
164
  def run_loop
156
165
  require 'io/console'
157
166
 
158
167
  @running = true
159
168
  @raw_mode = true
169
+ $stdout.print ENABLE_ALT_SCREEN
170
+ $stdout.print ENABLE_MOUSE
171
+ $stdout.print ENABLE_BRACKETED_PASTE
160
172
  $stdout.print cursor.hide
161
173
  $stdout.print cursor.clear_screen
162
174
 
@@ -169,30 +181,47 @@ module Legion
169
181
 
170
182
  key = normalize_key(raw_key)
171
183
  dispatch_key(key)
184
+ drain_burst_keys(raw_in)
172
185
  end
173
186
  end
174
187
  rescue Interrupt
175
188
  nil
176
189
  ensure
177
190
  @raw_mode = false
191
+ $stdout.print DISABLE_BRACKETED_PASTE
192
+ $stdout.print DISABLE_MOUSE
178
193
  $stdout.print cursor.show
179
- $stdout.print cursor.move_to(0, terminal_height - 1)
180
- $stdout.puts
194
+ $stdout.print DISABLE_ALT_SCREEN
181
195
  shutdown
182
196
  end
183
- # rubocop:enable Metrics/AbcSize
197
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
184
198
 
185
199
  def needs_refresh?
186
200
  active = @screen_manager.active_screen
187
201
  active.respond_to?(:streaming?) && active.streaming?
188
202
  end
189
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
+
190
213
  def normalize_key(raw)
214
+ return raw if raw.is_a?(Hash)
215
+
216
+ mouse = parse_sgr_mouse(raw)
217
+ return mouse if mouse
218
+
191
219
  KEY_MAP[raw] || raw
192
220
  end
193
221
 
194
222
  # --- Key Dispatch ---
195
223
 
224
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
196
225
  def dispatch_key(key)
197
226
  if key == :ctrl_c
198
227
  @running = false
@@ -213,12 +242,18 @@ module Legion
213
242
  active = @screen_manager.active_screen
214
243
  return unless active
215
244
 
245
+ if key.is_a?(Symbol) && %i[scroll_up scroll_down].include?(key)
246
+ dispatch_to_screen(active, key)
247
+ return
248
+ end
249
+
216
250
  if active.respond_to?(:needs_input_bar?) && active.needs_input_bar? && @input_bar
217
251
  dispatch_to_input_screen(active, key)
218
252
  else
219
253
  dispatch_to_screen(active, key)
220
254
  end
221
255
  end
256
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
222
257
 
223
258
  def dispatch_to_input_screen(screen, key)
224
259
  result = @input_bar.handle_key(key)
@@ -294,9 +329,39 @@ module Legion
294
329
  seq << c
295
330
  break if c.ord.between?(0x40, 0x7E)
296
331
  end
332
+
333
+ return read_paste_content(io) if seq == "\e[200~"
334
+
297
335
  seq
298
336
  end
299
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
+
354
+ def parse_sgr_mouse(raw)
355
+ match = SGR_MOUSE_RE.match(raw)
356
+ return nil unless match
357
+
358
+ button = match[1].to_i
359
+ return :scroll_up if button == 64
360
+ return :scroll_down if button == 65
361
+
362
+ nil
363
+ end
364
+
300
365
  # --- Rendering ---
301
366
 
302
367
  # rubocop:disable Metrics/AbcSize
@@ -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
@@ -49,10 +51,20 @@ module Legion
49
51
  when :left
50
52
  @cursor_pos = [@cursor_pos - 1, 0].max
51
53
  :handled
52
- when :home, :ctrl_a
54
+ when :home
55
+ return :pass if @buffer.empty?
56
+
57
+ @cursor_pos = 0
58
+ :handled
59
+ when :ctrl_a
53
60
  @cursor_pos = 0
54
61
  :handled
55
- when :end, :ctrl_e
62
+ when :end
63
+ return :pass if @buffer.empty?
64
+
65
+ @cursor_pos = @buffer.length
66
+ :handled
67
+ when :ctrl_e
56
68
  @cursor_pos = @buffer.length
57
69
  :handled
58
70
  when :ctrl_u
@@ -192,6 +204,14 @@ module Legion
192
204
  :handled
193
205
  end
194
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
+
195
215
  def insert_char(key)
196
216
  return :pass unless key.is_a?(String) && key.length == 1 && key.ord >= 32
197
217
 
@@ -122,7 +122,8 @@ module Legion
122
122
  scroll = @state[:scroll]
123
123
  return nil unless scroll.is_a?(Hash) && scroll[:total].to_i > scroll[:visible].to_i
124
124
 
125
- Theme.c(:muted, "#{scroll[:current]}/#{scroll[:total]}")
125
+ hint = scroll[:current].to_i.positive? ? "\u2191\u2193 scroll" : "\u2191 scroll"
126
+ Theme.c(:muted, "#{scroll[:current]}/#{scroll[:total]} #{hint}")
126
127
  end
127
128
 
128
129
  def level_to_priority(level)
@@ -36,7 +36,8 @@ module Legion
36
36
  'TOOLS : /tools /export /bookmark /pin /pins /alias /snippet /history',
37
37
  'UTILS : /calc /rand',
38
38
  '',
39
- 'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
39
+ 'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back',
40
+ 'Scroll : Mouse wheel PgUp/PgDn Ctrl+B/F (half-page) Home/End (top/bottom)'
40
41
  ].freeze
41
42
 
42
43
  CALC_SAFE_PATTERN = %r{\A[\d\s+\-*/.()%]*\z}
@@ -200,20 +200,33 @@ module Legion
200
200
  end
201
201
 
202
202
  def handle_input(key)
203
+ scroll = handle_scroll_key(key)
204
+ return scroll if scroll
205
+
206
+ :pass
207
+ end
208
+
209
+ def handle_scroll_key(key)
203
210
  case key
204
- when :page_up
205
- @message_stream.scroll_up(10)
206
- :handled
207
- when :page_down
208
- @message_stream.scroll_down(10)
209
- :handled
210
- else
211
- :pass
211
+ when :page_up then @message_stream.scroll_up(10)
212
+ when :page_down then @message_stream.scroll_down(10)
213
+ when :scroll_up then @message_stream.scroll_up(3)
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
+ when :home then @message_stream.scroll_up(@message_stream.messages.size * 5)
218
+ when :end then @message_stream.scroll_down(@message_stream.scroll_offset)
219
+ else return nil
212
220
  end
221
+ :handled
213
222
  end
214
223
 
215
224
  private
216
225
 
226
+ def half_page_lines
227
+ [(terminal_height - 3) / 2, 1].max
228
+ end
229
+
217
230
  def render_focus(width, height)
218
231
  stream_lines = @message_stream.render(width: width, height: [height, 1].max)
219
232
  @status_bar.update(scroll: @message_stream.scroll_position)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.5.0'
5
+ VERSION = '0.5.2'
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.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity