legion-tty 0.4.35 → 0.4.36

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: 31b8e7a6dcba6b0327573f361400407394b176b79a115a9e3547c6e38c6523eb
4
- data.tar.gz: 3f5bf51a8520ca9ab668067a03033467f344020f851b6b6545273b739889135c
3
+ metadata.gz: 2e331de1a9cb6b1a10576797303b5f1c691b639a74212c5cca6dc5d5fb161f7c
4
+ data.tar.gz: 01b5bfdefa8e991d01db3da00c1f0d7f51ff029d08c89e6a008a1abb607c8543
5
5
  SHA512:
6
- metadata.gz: 6b4606e9588d44d94142722851e0b16d2d31e39c3c3ce3054eca965eeac432cf36c79b9ffb2d50e6abf005a37076df1890548f29586a44656f4ce290faf7ef90
7
- data.tar.gz: 8ac2d9587039192b05262e4ab285b7e1791d41f95c9d58990f04cadcc98113e576019a886fe1130e5ce2ef515f6e85d541c948ac2011f5eacc91ef55e5fd8343
6
+ metadata.gz: f94def1314643beeda49eb00c7a9f70a4cacee8843c56551bf24b9edfa77dfcbf17fbca6f9c5f708574a14bdf3316e382a04d41e542e8d841eba21324cc0d248
7
+ data.tar.gz: fa6d3c970d1e33492185f3cb89e3dbc9f349c8f36431558f86f13bd1a48c96656a6b0adecee84bd2dc3713ee11c25a09282bdacc3cc7ffff6a3886e785b1839e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.36] - 2026-03-26
4
+
5
+ ### Changed
6
+ - Rebuild rendering engine with raw-mode event loop replacing synchronous readline blocking
7
+ - App#run_loop uses character-by-character input via IO.select + $stdin.raw with manual escape sequence parsing
8
+ - Differential rendering compares frame buffers line-by-line to eliminate screen flicker
9
+ - Overlay compositing renders TTY::Box frames centered over screen content, persists until Escape
10
+ - Key normalization maps raw escape sequences and control chars to symbols for dispatch
11
+ - InputBar rewritten with handle_key line buffer for non-blocking single-key processing
12
+ - Chat screen uses render/handle_input contract with page_up/page_down scroll
13
+ - Streaming flag enables 50ms refresh during LLM streaming for responsive re-rendering
14
+ - Hotkeys now use normalized symbol keys (:ctrl_d, :ctrl_l, :ctrl_k, :ctrl_s)
15
+ - Screens (Dashboard, Extensions, Config) driven by App event loop instead of standalone blocking loops
16
+
3
17
  ## [0.4.35] - 2026-03-25
4
18
 
5
19
  ### Changed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Rich terminal UI for the LegionIO async cognition engine.
4
4
 
5
- **Version**: 0.4.29
5
+ **Version**: 0.4.35
6
6
 
7
7
  Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 115 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
8
8
 
@@ -13,7 +13,21 @@ module Legion
13
13
  class App
14
14
  CONFIG_DIR = File.expand_path('~/.legionio/settings')
15
15
 
16
- attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat
16
+ # Key normalization: raw escape sequences and control chars to symbols
17
+ KEY_MAP = {
18
+ "\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
19
+ "\r" => :enter, "\n" => :enter, "\e" => :escape,
20
+ "\e[5~" => :page_up, "\e[6~" => :page_down,
21
+ "\e[H" => :home, "\eOH" => :home, "\e[F" => :end, "\eOF" => :end,
22
+ "\e[1~" => :home, "\e[4~" => :end,
23
+ "\x7f" => :backspace, "\b" => :backspace, "\t" => :tab,
24
+ "\x03" => :ctrl_c, "\x04" => :ctrl_d,
25
+ "\x01" => :ctrl_a, "\x05" => :ctrl_e,
26
+ "\x0B" => :ctrl_k, "\x0C" => :ctrl_l, "\x13" => :ctrl_s,
27
+ "\x15" => :ctrl_u
28
+ }.freeze
29
+
30
+ attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat, :input_bar
17
31
 
18
32
  def self.run(argv = [])
19
33
  opts = parse_argv(argv)
@@ -42,23 +56,59 @@ module Legion
42
56
  @screen_manager = ScreenManager.new
43
57
  @hotkeys = Hotkeys.new
44
58
  @llm_chat = nil
59
+ @input_bar = nil
60
+ @running = false
61
+ @prev_frame = []
62
+ @raw_mode = false
45
63
  end
46
64
 
47
65
  def start
48
66
  setup_hotkeys
49
- if self.class.first_run?(config_dir: @config_dir)
50
- run_onboarding
51
- else
52
- run_chat
67
+ run_onboarding if self.class.first_run?(config_dir: @config_dir)
68
+ setup_for_chat
69
+ run_loop
70
+ end
71
+
72
+ # Public: called by screens (e.g., Chat during LLM streaming) to force a re-render
73
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
74
+ def render_frame
75
+ width = terminal_width
76
+ height = terminal_height
77
+ active = @screen_manager.active_screen
78
+ return unless active
79
+
80
+ has_input = active.respond_to?(:needs_input_bar?) && active.needs_input_bar?
81
+ screen_height = has_input ? height - 1 : height
82
+
83
+ lines = active.render(width, screen_height)
84
+ lines << @input_bar.render_line(width: width) if has_input && @input_bar
85
+
86
+ lines = lines[0, height] if lines.size > height
87
+ lines += Array.new(height - lines.size, '') if lines.size < height
88
+
89
+ lines = composite_overlay(lines, width, height) if @screen_manager.overlay
90
+
91
+ write_differential(lines, width)
92
+
93
+ if has_input && @input_bar
94
+ col = [@input_bar.cursor_column, width - 1].min
95
+ $stdout.print cursor.move_to(col, height - 1)
53
96
  end
97
+
98
+ $stdout.flush
99
+ rescue StandardError => e
100
+ Legion::Logging.warn("render_frame failed: #{e.message}") if defined?(Legion::Logging)
54
101
  end
102
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
103
 
56
- def setup_hotkeys
57
- @hotkeys.register("\x04", 'Toggle dashboard (Ctrl+D)') { toggle_dashboard }
58
- @hotkeys.register("\x0C", 'Refresh screen (Ctrl+L)') { :refresh }
59
- @hotkeys.register("\x0B", 'Command palette (Ctrl+K)') { :command_palette }
60
- @hotkeys.register("\x13", 'Session picker (Ctrl+S)') { :session_picker }
61
- @hotkeys.register("\e", 'Go back (Escape)') { :escape }
104
+ # Temporarily exit raw mode for blocking prompts (TTY::Prompt, etc.)
105
+ def with_cooked_mode(&)
106
+ return yield unless @raw_mode
107
+
108
+ $stdout.print cursor.show
109
+ $stdin.cooked(&)
110
+ $stdout.print cursor.hide
111
+ @prev_frame = []
62
112
  end
63
113
 
64
114
  def toggle_dashboard
@@ -72,23 +122,273 @@ module Legion
72
122
  end
73
123
  end
74
124
 
125
+ def shutdown
126
+ @running = false
127
+ @screen_manager.teardown_all
128
+ end
129
+
130
+ private
131
+
132
+ # --- Boot ---
133
+
134
+ def setup_for_chat
135
+ rescan_environment
136
+ setup_llm
137
+ cfg = safe_config
138
+ name = cfg[:name] || 'User'
139
+ @input_bar = Components::InputBar.new(name: name, completions: Screens::Chat::SLASH_COMMANDS)
140
+ chat = Screens::Chat.new(self)
141
+ @screen_manager.push(chat)
142
+ end
143
+
144
+ # --- Event Loop ---
145
+
146
+ # rubocop:disable Metrics/AbcSize
147
+ def run_loop
148
+ require 'io/console'
149
+
150
+ @running = true
151
+ @raw_mode = true
152
+ $stdout.print cursor.hide
153
+ $stdout.print cursor.clear_screen
154
+
155
+ $stdin.raw do |raw_in|
156
+ while @running
157
+ render_frame
158
+ timeout = needs_refresh? ? 0.05 : nil
159
+ raw_key = read_raw_key(raw_in, timeout: timeout)
160
+ next unless raw_key
161
+
162
+ key = normalize_key(raw_key)
163
+ dispatch_key(key)
164
+ end
165
+ end
166
+ rescue Interrupt
167
+ nil
168
+ ensure
169
+ @raw_mode = false
170
+ $stdout.print cursor.show
171
+ $stdout.print cursor.move_to(0, terminal_height - 1)
172
+ $stdout.puts
173
+ shutdown
174
+ end
175
+ # rubocop:enable Metrics/AbcSize
176
+
177
+ def needs_refresh?
178
+ active = @screen_manager.active_screen
179
+ active.respond_to?(:streaming?) && active.streaming?
180
+ end
181
+
182
+ def normalize_key(raw)
183
+ KEY_MAP[raw] || raw
184
+ end
185
+
186
+ # --- Key Dispatch ---
187
+
188
+ def dispatch_key(key)
189
+ if key == :ctrl_c
190
+ @running = false
191
+ return
192
+ end
193
+
194
+ if key == :escape && @screen_manager.overlay
195
+ @screen_manager.dismiss_overlay
196
+ return
197
+ end
198
+
199
+ result = @hotkeys.handle(key)
200
+ if result
201
+ handle_hotkey_result(result)
202
+ return
203
+ end
204
+
205
+ active = @screen_manager.active_screen
206
+ return unless active
207
+
208
+ if active.respond_to?(:needs_input_bar?) && active.needs_input_bar? && @input_bar
209
+ dispatch_to_input_screen(active, key)
210
+ else
211
+ dispatch_to_screen(active, key)
212
+ end
213
+ end
214
+
215
+ def dispatch_to_input_screen(screen, key)
216
+ result = @input_bar.handle_key(key)
217
+ if result.is_a?(Array) && result[0] == :submit
218
+ screen_result = screen.handle_line(result[1])
219
+ handle_screen_result(screen_result)
220
+ elsif result == :pass
221
+ screen_result = screen.handle_input(key)
222
+ handle_screen_result(screen_result)
223
+ end
224
+ end
225
+
226
+ def dispatch_to_screen(screen, key)
227
+ result = screen.handle_input(key)
228
+ handle_screen_result(result)
229
+ end
230
+
231
+ def handle_screen_result(result)
232
+ case result
233
+ when :pop_screen then @screen_manager.pop
234
+ when :quit then @running = false
235
+ end
236
+ end
237
+
238
+ def handle_hotkey_result(result)
239
+ case result
240
+ when :command_palette
241
+ active = @screen_manager.active_screen
242
+ active.send(:handle_palette) if active.respond_to?(:handle_palette, true)
243
+ when :session_picker
244
+ active = @screen_manager.active_screen
245
+ active.send(:handle_sessions_picker) if active.respond_to?(:handle_sessions_picker, true)
246
+ end
247
+ end
248
+
249
+ # --- Raw Key Reading ---
250
+
251
+ def read_raw_key(io, timeout: nil)
252
+ return nil unless io.wait_readable(timeout)
253
+
254
+ c = io.getc
255
+ return nil unless c
256
+
257
+ return c unless c == "\e"
258
+
259
+ read_escape_sequence(io)
260
+ end
261
+
262
+ def read_escape_sequence(io)
263
+ return "\e" unless io.wait_readable(0.05)
264
+
265
+ c2 = io.getc
266
+ return "\e" unless c2
267
+
268
+ if c2 == '['
269
+ read_csi_sequence(io)
270
+ elsif c2 == 'O'
271
+ c3 = io.wait_readable(0.05) ? io.getc : nil
272
+ c3 ? "\eO#{c3}" : "\eO"
273
+ else
274
+ "\e#{c2}"
275
+ end
276
+ end
277
+
278
+ def read_csi_sequence(io)
279
+ seq = +"\e["
280
+ loop do
281
+ break unless io.wait_readable(0.05)
282
+
283
+ c = io.getc
284
+ break unless c
285
+
286
+ seq << c
287
+ break if c.ord.between?(0x40, 0x7E)
288
+ end
289
+ seq
290
+ end
291
+
292
+ # --- Rendering ---
293
+
294
+ # rubocop:disable Metrics/AbcSize
295
+ def composite_overlay(lines, width, height)
296
+ require 'tty-box'
297
+ text = @screen_manager.overlay.to_s
298
+ box_width = (width - 4).clamp(40, width)
299
+ box = ::TTY::Box.frame(
300
+ width: box_width,
301
+ padding: 1,
302
+ title: { top_left: ' Help ' },
303
+ border: :round
304
+ ) { text }
305
+
306
+ overlay_lines = box.split("\n")
307
+ start_row = [(height - overlay_lines.size) / 2, 0].max
308
+ left_pad = [(width - box_width) / 2, 0].max
309
+
310
+ result = lines.dup
311
+ overlay_lines.each_with_index do |ol, i|
312
+ row = start_row + i
313
+ next if row >= height
314
+
315
+ result[row] = (' ' * left_pad) + ol
316
+ end
317
+ result
318
+ rescue StandardError => e
319
+ Legion::Logging.warn("composite_overlay failed: #{e.message}") if defined?(Legion::Logging)
320
+ lines
321
+ end
322
+ # rubocop:enable Metrics/AbcSize
323
+
324
+ def write_differential(lines, width)
325
+ lines.each_with_index do |line, row|
326
+ next if @prev_frame[row] == line
327
+
328
+ $stdout.print cursor.move_to(0, row)
329
+ $stdout.print line
330
+ plain_len = strip_ansi(line).length
331
+ $stdout.print(' ' * (width - plain_len)) if plain_len < width
332
+ end
333
+ @prev_frame = lines.dup
334
+ end
335
+
336
+ def strip_ansi(str)
337
+ str.to_s.gsub(/\e\[[0-9;]*m/, '')
338
+ end
339
+
340
+ def cursor
341
+ require 'tty-cursor'
342
+ ::TTY::Cursor
343
+ end
344
+
345
+ def terminal_width
346
+ require 'tty-screen'
347
+ ::TTY::Screen.width
348
+ rescue StandardError
349
+ 80
350
+ end
351
+
352
+ def terminal_height
353
+ require 'tty-screen'
354
+ ::TTY::Screen.height
355
+ rescue StandardError
356
+ 24
357
+ end
358
+
359
+ # --- Hotkeys ---
360
+
361
+ def setup_hotkeys
362
+ @hotkeys.register(:ctrl_d, 'Toggle dashboard (Ctrl+D)') do
363
+ toggle_dashboard
364
+ :handled
365
+ end
366
+ @hotkeys.register(:ctrl_l, 'Refresh screen (Ctrl+L)') do
367
+ @prev_frame = []
368
+ :handled
369
+ end
370
+ @hotkeys.register(:ctrl_k, 'Command palette (Ctrl+K)') { :command_palette }
371
+ @hotkeys.register(:ctrl_s, 'Session picker (Ctrl+S)') { :session_picker }
372
+ end
373
+
374
+ # --- Onboarding ---
375
+
75
376
  def run_onboarding
76
377
  onboarding = Screens::Onboarding.new(self, skip_rain: @skip_rain)
77
378
  data = onboarding.activate
78
379
  save_config(data)
79
380
  @config = load_config
80
381
  @credentials = load_credentials
81
- run_chat
82
382
  end
83
383
 
84
- def run_chat
85
- rescan_environment
86
- setup_llm
87
- chat = Screens::Chat.new(self)
88
- @screen_manager.push(chat)
89
- chat.run
384
+ def save_config(data)
385
+ FileUtils.mkdir_p(@config_dir)
386
+ save_identity(data)
387
+ save_credentials(data)
90
388
  end
91
389
 
390
+ # --- LLM Setup ---
391
+
92
392
  def setup_llm
93
393
  boot_legion_subsystems
94
394
  @llm_chat = try_settings_llm
@@ -120,25 +420,10 @@ module Legion
120
420
  nil
121
421
  end
122
422
  end
123
-
124
423
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
125
424
 
126
- def save_config(data)
127
- FileUtils.mkdir_p(@config_dir)
128
- save_identity(data)
129
- save_credentials(data)
130
- end
131
-
132
- def shutdown
133
- @screen_manager.teardown_all
134
- end
135
-
136
- private
137
-
138
425
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
139
426
  def boot_legion_subsystems
140
- # Follow the same init order as Legion::Service:
141
- # 1. logging 2. settings 3. crypt 4. resolve secrets 5. LLM merge
142
427
  require 'legion/logging'
143
428
  Legion::Logging.setup(log_level: 'error', level: 'error', trace: false)
144
429
 
@@ -197,6 +482,13 @@ module Legion
197
482
  nil
198
483
  end
199
484
 
485
+ # --- Config & Credentials ---
486
+
487
+ def safe_config
488
+ cfg = @config
489
+ cfg.is_a?(Hash) ? cfg : {}
490
+ end
491
+
200
492
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
201
493
  def save_identity(data)
202
494
  identity = {
@@ -205,7 +497,6 @@ module Legion
205
497
  created_at: Time.now.iso8601
206
498
  }
207
499
 
208
- # Kerberos identity
209
500
  if data[:identity].is_a?(Hash)
210
501
  id = data[:identity]
211
502
  identity[:kerberos] = {
@@ -226,7 +517,6 @@ module Legion
226
517
  }.compact
227
518
  end
228
519
 
229
- # GitHub profile
230
520
  if data[:github].is_a?(Hash) && data[:github][:username]
231
521
  gh = data[:github]
232
522
  identity[:github] = {
@@ -237,7 +527,6 @@ module Legion
237
527
  }.compact
238
528
  end
239
529
 
240
- # Environment scan
241
530
  if data[:scan].is_a?(Hash)
242
531
  scan = data[:scan]
243
532
  services = scan[:services]&.values&.select { |s| s[:running] }&.map { |s| s[:name] } || []
@@ -5,13 +5,20 @@ require_relative '../theme'
5
5
  module Legion
6
6
  module TTY
7
7
  module Components
8
- class InputBar
9
- attr_reader :completions
8
+ class InputBar # rubocop:disable Metrics/ClassLength
9
+ attr_reader :completions, :buffer
10
10
 
11
11
  def initialize(name: 'User', reader: nil, completions: [])
12
12
  @name = name
13
13
  @completions = completions
14
- @reader = reader || build_default_reader
14
+ @buffer = +''
15
+ @cursor_pos = 0
16
+ @history_entries = []
17
+ @history_index = nil
18
+ @saved_buffer = nil
19
+ @tab_matches = []
20
+ @tab_index = 0
21
+ @legacy_reader = reader
15
22
  @thinking = false
16
23
  end
17
24
 
@@ -19,20 +26,80 @@ module Legion
19
26
  "#{Theme.c(:accent, @name)} #{Theme.c(:primary, '>')} "
20
27
  end
21
28
 
22
- def read_line
23
- @reader.read_line(prompt_string)
29
+ def prompt_plain_length
30
+ @name.length + 3
24
31
  end
25
32
 
26
- def show_thinking
27
- @thinking = true
33
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
34
+ def handle_key(key)
35
+ case key
36
+ when :enter
37
+ submit_line
38
+ when :backspace
39
+ handle_backspace
40
+ when :tab
41
+ handle_tab_key
42
+ when :up
43
+ history_prev
44
+ when :down
45
+ history_next
46
+ when :right
47
+ @cursor_pos = [@cursor_pos + 1, @buffer.length].min
48
+ :handled
49
+ when :left
50
+ @cursor_pos = [@cursor_pos - 1, 0].max
51
+ :handled
52
+ when :home, :ctrl_a
53
+ @cursor_pos = 0
54
+ :handled
55
+ when :end, :ctrl_e
56
+ @cursor_pos = @buffer.length
57
+ :handled
58
+ when :ctrl_u
59
+ @buffer = +''
60
+ @cursor_pos = 0
61
+ :handled
62
+ when String
63
+ insert_char(key)
64
+ else
65
+ :pass
66
+ end
67
+ end
68
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
69
+
70
+ def render_line(width:)
71
+ avail = [width - prompt_plain_length, 1].max
72
+ display = if @buffer.length > avail
73
+ @buffer[(@buffer.length - avail)..]
74
+ else
75
+ @buffer
76
+ end
77
+ "#{prompt_string}#{display}"
28
78
  end
29
79
 
30
- def clear_thinking
31
- @thinking = false
80
+ def cursor_column
81
+ prompt_plain_length + @cursor_pos
32
82
  end
33
83
 
34
- def thinking?
35
- @thinking
84
+ def current_line
85
+ @buffer.dup
86
+ end
87
+
88
+ def clear_buffer
89
+ @buffer = +''
90
+ @cursor_pos = 0
91
+ end
92
+
93
+ def history
94
+ @history_entries.dup
95
+ end
96
+
97
+ # Backward-compatible blocking read for onboarding/tests
98
+ def read_line
99
+ reader = @legacy_reader || build_legacy_reader
100
+ reader&.read_line(prompt_string)
101
+ rescue Interrupt
102
+ nil
36
103
  end
37
104
 
38
105
  def complete(partial)
@@ -41,47 +108,104 @@ module Legion
41
108
  @completions.select { |c| c.start_with?(partial) }.sort
42
109
  end
43
110
 
44
- def history
45
- return [] unless @reader.respond_to?(:history)
46
-
47
- @reader.history.to_a
111
+ def show_thinking
112
+ @thinking = true
48
113
  end
49
114
 
50
- private
115
+ def clear_thinking
116
+ @thinking = false
117
+ end
51
118
 
52
- def build_default_reader
53
- require 'tty-reader'
54
- reader = ::TTY::Reader.new(history_cycle: true)
55
- register_tab_completion(reader)
56
- reader
57
- rescue LoadError => e
58
- Legion::Logging.debug("tty-reader not available: #{e.message}") if defined?(Legion::Logging)
59
- nil
119
+ def thinking?
120
+ @thinking
60
121
  end
61
122
 
62
- def register_tab_completion(reader)
63
- return if @completions.empty?
123
+ private
64
124
 
125
+ def submit_line
126
+ line = @buffer.dup
127
+ @history_entries << line unless line.strip.empty? || line == @history_entries.last
128
+ @buffer = +''
129
+ @cursor_pos = 0
130
+ @history_index = nil
131
+ @saved_buffer = nil
65
132
  @tab_matches = []
66
133
  @tab_index = 0
134
+ [:submit, line]
135
+ end
67
136
 
68
- reader.on(:keypress) do |event|
69
- handle_tab(event) if event.value == "\t"
70
- end
137
+ def handle_backspace
138
+ return :handled if @cursor_pos.zero?
139
+
140
+ @buffer.slice!(@cursor_pos - 1)
141
+ @cursor_pos -= 1
142
+ @tab_matches = []
143
+ :handled
71
144
  end
72
145
 
73
- def handle_tab(event)
74
- line = event.line.text.to_s
75
- matches = complete(line)
76
- return if matches.empty?
146
+ def handle_tab_key
147
+ return :handled if @buffer.empty?
148
+
149
+ matches = complete(@buffer)
150
+ return :handled if matches.empty?
77
151
 
78
152
  if matches.size == 1
79
- event.line.replace(matches.first)
153
+ @buffer = "#{matches.first} "
154
+ @cursor_pos = @buffer.length
80
155
  else
81
156
  @tab_matches = matches unless @tab_matches == matches
82
- event.line.replace(@tab_matches[@tab_index % @tab_matches.size])
157
+ @buffer = +@tab_matches[@tab_index % @tab_matches.size].to_s
158
+ @cursor_pos = @buffer.length
83
159
  @tab_index += 1
84
160
  end
161
+ :handled
162
+ end
163
+
164
+ def history_prev
165
+ return :handled if @history_entries.empty?
166
+
167
+ if @history_index.nil?
168
+ @saved_buffer = @buffer.dup
169
+ @history_index = @history_entries.size - 1
170
+ elsif @history_index.positive?
171
+ @history_index -= 1
172
+ else
173
+ return :handled
174
+ end
175
+ @buffer = +@history_entries[@history_index].to_s
176
+ @cursor_pos = @buffer.length
177
+ :handled
178
+ end
179
+
180
+ def history_next
181
+ return :handled if @history_index.nil?
182
+
183
+ if @history_index < @history_entries.size - 1
184
+ @history_index += 1
185
+ @buffer = +@history_entries[@history_index].to_s
186
+ else
187
+ @history_index = nil
188
+ @buffer = +(@saved_buffer || '')
189
+ @saved_buffer = nil
190
+ end
191
+ @cursor_pos = @buffer.length
192
+ :handled
193
+ end
194
+
195
+ def insert_char(key)
196
+ return :pass unless key.is_a?(String) && key.length == 1 && key.ord >= 32
197
+
198
+ @buffer.insert(@cursor_pos, key)
199
+ @cursor_pos += 1
200
+ @tab_matches = []
201
+ :handled
202
+ end
203
+
204
+ def build_legacy_reader
205
+ require 'tty-reader'
206
+ ::TTY::Reader.new(history_cycle: true)
207
+ rescue LoadError
208
+ nil
85
209
  end
86
210
  end
87
211
  end
@@ -11,15 +11,12 @@ module Legion
11
11
  @bindings[key] = { description: description, action: block }
12
12
  end
13
13
 
14
- # rubocop:disable Naming/PredicateMethod
15
14
  def handle(key)
16
15
  binding_entry = @bindings[key]
17
- return false unless binding_entry
16
+ return nil unless binding_entry
18
17
 
19
18
  binding_entry[:action].call
20
- true
21
19
  end
22
- # rubocop:enable Naming/PredicateMethod
23
20
 
24
21
  def list
25
22
  @bindings.map { |key, b| { key: key, description: b[:description] } }
@@ -21,6 +21,10 @@ module Legion
21
21
  :pass
22
22
  end
23
23
 
24
+ def needs_input_bar?
25
+ false
26
+ end
27
+
24
28
  def teardown; end
25
29
  end
26
30
  end
@@ -65,7 +65,10 @@ module Legion
65
65
  current_provider: safe_config[:provider],
66
66
  current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
67
67
  )
68
- selection = picker.select_with_prompt(output: @output)
68
+ selection = nil
69
+ @app.with_cooked_mode do
70
+ selection = picker.select_with_prompt(output: @output)
71
+ end
69
72
  return unless selection
70
73
 
71
74
  switch_model(selection[:provider])
@@ -122,7 +122,10 @@ module Legion
122
122
  def handle_palette
123
123
  require_relative '../components/command_palette'
124
124
  palette = Components::CommandPalette.new(session_store: @session_store)
125
- selection = palette.select_with_prompt(output: @output)
125
+ selection = nil
126
+ @app.with_cooked_mode do
127
+ selection = palette.select_with_prompt(output: @output)
128
+ end
126
129
  return :handled unless selection
127
130
 
128
131
  if selection.start_with?('/')
@@ -176,7 +179,7 @@ module Legion
176
179
  end
177
180
 
178
181
  def handle_history
179
- entries = @input_bar.history
182
+ entries = @app.respond_to?(:input_bar) && @app.input_bar ? @app.input_bar.history : []
180
183
  if entries.empty?
181
184
  @message_stream.add_message(role: :system, content: 'No input history.')
182
185
  else
@@ -68,13 +68,11 @@ module Legion
68
68
  attr_reader :message_stream, :status_bar
69
69
 
70
70
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
71
- def initialize(app, output: $stdout, input_bar: nil)
71
+ def initialize(app, output: $stdout, input_bar: nil) # rubocop:disable Lint/UnusedMethodArgument
72
72
  super(app)
73
73
  @output = output
74
74
  @message_stream = Components::MessageStream.new
75
75
  @status_bar = Components::StatusBar.new
76
- @running = false
77
- @input_bar = input_bar || build_default_input_bar
78
76
  @llm_chat = app.respond_to?(:llm_chat) ? app.llm_chat : nil
79
77
  @token_tracker = Components::TokenTracker.new(provider: detect_provider)
80
78
  @session_store = SessionStore.new
@@ -105,12 +103,12 @@ module Legion
105
103
  @timer_thread = nil
106
104
  @message_prefix = nil
107
105
  @message_suffix = nil
106
+ @streaming = false
108
107
  end
109
108
 
110
109
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
111
110
 
112
111
  def activate
113
- @running = true
114
112
  cfg = safe_config
115
113
  @status_bar.update(model: cfg[:provider], session: 'default')
116
114
  setup_system_prompt
@@ -121,34 +119,25 @@ module Legion
121
119
  @status_bar.update(message_count: @message_stream.messages.size)
122
120
  end
123
121
 
124
- def running?
125
- @running
122
+ def needs_input_bar?
123
+ true
126
124
  end
127
125
 
128
- # rubocop:disable Metrics/AbcSize
129
- def run
130
- activate
131
- while @running
132
- render_screen
133
- input = read_input
134
- break if input.nil?
135
-
136
- if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
137
- @app.screen_manager.dismiss_overlay
138
- next
139
- end
126
+ def streaming?
127
+ @streaming
128
+ end
140
129
 
141
- result = handle_slash_command(input)
142
- if result == :quit
143
- auto_save_session
144
- @running = false
145
- break
146
- elsif result.nil?
147
- handle_user_message(input) unless input.strip.empty?
148
- end
130
+ def handle_line(line)
131
+ return :handled if line.strip.empty?
132
+
133
+ result = handle_slash_command(line)
134
+ if result == :quit
135
+ auto_save_session
136
+ return :quit
149
137
  end
138
+ handle_user_message(line) if result.nil?
139
+ :handled
150
140
  end
151
- # rubocop:enable Metrics/AbcSize
152
141
 
153
142
  def handle_slash_command(input)
154
143
  return nil unless input.start_with?('/')
@@ -179,7 +168,6 @@ module Legion
179
168
  end
180
169
  @status_bar.update(message_count: @message_stream.messages.size)
181
170
  check_autosave
182
- render_screen
183
171
  end
184
172
 
185
173
  def send_to_llm(message)
@@ -207,11 +195,11 @@ module Legion
207
195
 
208
196
  def handle_input(key)
209
197
  case key
210
- when :up
211
- @message_stream.scroll_up
198
+ when :page_up
199
+ @message_stream.scroll_up(10)
212
200
  :handled
213
- when :down
214
- @message_stream.scroll_down
201
+ when :page_down
202
+ @message_stream.scroll_down(10)
215
203
  :handled
216
204
  else
217
205
  :pass
@@ -279,7 +267,8 @@ module Legion
279
267
  return unless @llm_chat
280
268
 
281
269
  @status_bar.update(thinking: true)
282
- render_screen
270
+ @streaming = true
271
+ @app.render_frame if @app.respond_to?(:render_frame)
283
272
  start_time = Time.now
284
273
  response_text = +''
285
274
  parser = build_tool_call_parser
@@ -289,13 +278,15 @@ module Legion
289
278
  response_text << chunk.content
290
279
  parser.feed(chunk.content)
291
280
  end
292
- render_screen
281
+ @app.render_frame if @app.respond_to?(:render_frame)
293
282
  end
294
283
  parser.flush
295
284
  record_response_time(Time.now - start_time)
296
285
  @status_bar.update(thinking: false)
297
286
  track_response_tokens(response)
298
287
  speak_response(response_text) if @speak_mode
288
+ ensure
289
+ @streaming = false
299
290
  end
300
291
  # rubocop:enable Metrics/AbcSize
301
292
 
@@ -360,63 +351,6 @@ module Legion
360
351
  cfg.is_a?(Hash) ? cfg : {}
361
352
  end
362
353
 
363
- def render_screen
364
- require 'tty-cursor'
365
- lines = render(terminal_width, terminal_height - 1)
366
- @output.print ::TTY::Cursor.move_to(0, 0)
367
- @output.print ::TTY::Cursor.clear_screen_down
368
- lines.each { |line| @output.puts line }
369
- render_overlay if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
370
- end
371
-
372
- # rubocop:disable Metrics/AbcSize
373
- def render_overlay
374
- require 'tty-box'
375
- text = @app.screen_manager.overlay.to_s
376
- width = (terminal_width - 4).clamp(40, terminal_width)
377
- box = ::TTY::Box.frame(
378
- width: width,
379
- padding: 1,
380
- title: { top_left: ' Help ' },
381
- border: :round
382
- ) { text }
383
- overlay_lines = box.split("\n")
384
- start_row = [(terminal_height - overlay_lines.size) / 2, 0].max
385
- overlay_lines.each_with_index do |line, i|
386
- @output.print ::TTY::Cursor.move_to(2, start_row + i)
387
- @output.print line
388
- end
389
- rescue StandardError => e
390
- Legion::Logging.warn("render_overlay failed: #{e.message}") if defined?(Legion::Logging)
391
- nil
392
- end
393
- # rubocop:enable Metrics/AbcSize
394
-
395
- def read_input
396
- return nil unless @input_bar.respond_to?(:read_line)
397
- return read_multiline_input if @multiline_mode
398
-
399
- @input_bar.read_line
400
- rescue Interrupt => e
401
- Legion::Logging.debug("read_input interrupted: #{e.message}") if defined?(Legion::Logging)
402
- nil
403
- end
404
-
405
- def read_multiline_input
406
- lines = []
407
- loop do
408
- line = @input_bar.read_line
409
- return nil if line.nil? && lines.empty?
410
- break if line.nil? || line.empty?
411
-
412
- lines << line
413
- end
414
- lines.empty? ? nil : lines.join("\n")
415
- rescue Interrupt => e
416
- Legion::Logging.debug("read_multiline_input interrupted: #{e.message}") if defined?(Legion::Logging)
417
- nil
418
- end
419
-
420
354
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
421
355
  def dispatch_slash(cmd, input)
422
356
  case cmd
@@ -641,12 +575,6 @@ module Legion
641
575
  )
642
576
  end
643
577
 
644
- def build_default_input_bar
645
- cfg = safe_config
646
- name = cfg[:name] || 'User'
647
- Components::InputBar.new(name: name, completions: SLASH_COMMANDS)
648
- end
649
-
650
578
  def terminal_width
651
579
  require 'tty-screen'
652
580
  ::TTY::Screen.width
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.35'
5
+ VERSION = '0.4.36'
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.4.35
4
+ version: 0.4.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity