thor-interactive 0.1.0.pre.5 → 0.1.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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ # Stores captured command output with scrollback support.
7
+ # Each entry is a hash with :text and optional :style keys.
8
+ class OutputBuffer
9
+ DEFAULT_MAX_LINES = 10_000
10
+
11
+ attr_reader :scroll_offset
12
+
13
+ def initialize(max_lines: DEFAULT_MAX_LINES)
14
+ @lines = []
15
+ @max_lines = max_lines
16
+ @scroll_offset = 0
17
+ end
18
+
19
+ def append(text, style: nil)
20
+ text.to_s.split("\n", -1).each do |line|
21
+ @lines << {text: line, style: style}
22
+ end
23
+ trim_to_max
24
+ # Auto-scroll to bottom when new content arrives
25
+ @scroll_offset = 0
26
+ end
27
+
28
+ def lines
29
+ @lines.dup
30
+ end
31
+
32
+ def line_count
33
+ @lines.length
34
+ end
35
+
36
+ def empty?
37
+ @lines.empty?
38
+ end
39
+
40
+ def clear
41
+ @lines.clear
42
+ @scroll_offset = 0
43
+ end
44
+
45
+ # Returns lines visible in a viewport of given height,
46
+ # accounting for scroll_offset (0 = bottom, positive = scrolled up).
47
+ def visible_lines(viewport_height)
48
+ return @lines.last(viewport_height) if @scroll_offset == 0
49
+
50
+ end_index = @lines.length - @scroll_offset
51
+ end_index = @lines.length if end_index > @lines.length
52
+ start_index = end_index - viewport_height
53
+ start_index = 0 if start_index < 0
54
+
55
+ @lines[start_index...end_index] || []
56
+ end
57
+
58
+ def scroll_up(amount = 1)
59
+ max_offset = [@lines.length - 1, 0].max
60
+ @scroll_offset = [@scroll_offset + amount, max_offset].min
61
+ end
62
+
63
+ def scroll_down(amount = 1)
64
+ @scroll_offset = [@scroll_offset - amount, 0].max
65
+ end
66
+
67
+ def scroll_to_bottom
68
+ @scroll_offset = 0
69
+ end
70
+
71
+ def scroll_to_top
72
+ @scroll_offset = [@lines.length - 1, 0].max
73
+ end
74
+
75
+ def at_bottom?
76
+ @scroll_offset == 0
77
+ end
78
+
79
+ private
80
+
81
+ def trim_to_max
82
+ @lines.shift(@lines.length - @max_lines) if @lines.length > @max_lines
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "io/console"
5
+ require "ratatui_ruby"
6
+ require_relative "../command_dispatch"
7
+ require_relative "output_buffer"
8
+ require_relative "text_input"
9
+ require_relative "status_bar"
10
+ require_relative "spinner"
11
+ require_relative "theme"
12
+
13
+ class Thor
14
+ module Interactive
15
+ module TUI
16
+ class RatatuiShell
17
+ DEFAULT_PROMPT = "> "
18
+ DEFAULT_HISTORY_FILE = "~/.thor_interactive_history"
19
+
20
+ # Viewport: status bar (1) + input box with room for multi-line (7)
21
+ INPUT_VIEWPORT_HEIGHT = 8
22
+
23
+ attr_reader :thor_class, :thor_instance, :prompt
24
+
25
+ include CommandDispatch
26
+
27
+ def initialize(thor_class, options = {})
28
+ @thor_class = thor_class
29
+ @thor_instance = thor_class.new
30
+
31
+ merged_options = {}
32
+ if thor_class.respond_to?(:interactive_options)
33
+ merged_options.merge!(thor_class.interactive_options)
34
+ end
35
+ merged_options.merge!(options)
36
+
37
+ @merged_options = merged_options
38
+ @default_handler = merged_options[:default_handler]
39
+ @prompt = merged_options[:prompt] || DEFAULT_PROMPT
40
+ @history_file = File.expand_path(merged_options[:history_file] || DEFAULT_HISTORY_FILE)
41
+
42
+ @text_input = TextInput.new
43
+ @status_bar = StatusBar.new(thor_class, @thor_instance, merged_options)
44
+ @spinner = Spinner.new(messages: merged_options[:spinner_messages])
45
+ @theme = Theme.new(merged_options[:theme] || :default)
46
+ @running = false
47
+ @executing_command = false
48
+ @completions = []
49
+ @completion_index = -1
50
+
51
+ # Multi-line input mode
52
+ @kitty_protocol_active = false
53
+ @multiline_mode = false # fallback toggle when Kitty protocol unavailable
54
+
55
+ # Ctrl-C handling
56
+ @last_interrupt_time = nil
57
+ @double_ctrl_c_timeout = merged_options.key?(:double_ctrl_c_timeout) ?
58
+ merged_options[:double_ctrl_c_timeout] : 0.5
59
+
60
+ load_history
61
+ end
62
+
63
+ def start
64
+ was_in_session = ENV["THOR_INTERACTIVE_SESSION"]
65
+ nesting_level = ENV["THOR_INTERACTIVE_LEVEL"].to_i
66
+
67
+ ENV["THOR_INTERACTIVE_SESSION"] = "true"
68
+ ENV["THOR_INTERACTIVE_LEVEL"] = (nesting_level + 1).to_s
69
+
70
+ @running = true
71
+
72
+ RatatuiRuby.run(viewport: :inline, height: INPUT_VIEWPORT_HEIGHT, bracketed_paste: true) do |tui|
73
+ @tui = tui
74
+ disable_mouse_capture
75
+ enable_kitty_keyboard
76
+
77
+ # Welcome message above viewport (scrollback)
78
+ emit_above(tui, "#{@thor_class.name} Interactive Shell (TUI mode)", style: :system)
79
+ if @kitty_protocol_active
80
+ emit_above(tui, "Enter to submit, Shift+Enter for newline, Ctrl+D to exit", style: :system)
81
+ else
82
+ emit_above(tui, "Enter to submit, Ctrl+N for multi-line mode, Ctrl+D to exit", style: :system)
83
+ end
84
+
85
+ run_event_loop(tui)
86
+ end
87
+
88
+ save_history
89
+ puts "Goodbye!"
90
+ ensure
91
+ disable_kitty_keyboard
92
+ @running = false
93
+ if was_in_session
94
+ ENV["THOR_INTERACTIVE_SESSION"] = "true"
95
+ ENV["THOR_INTERACTIVE_LEVEL"] = nesting_level.to_s
96
+ else
97
+ ENV.delete("THOR_INTERACTIVE_SESSION")
98
+ ENV.delete("THOR_INTERACTIVE_LEVEL")
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def run_event_loop(tui)
105
+ while @running
106
+ render(tui)
107
+ event = tui.poll_event(timeout: 0.05)
108
+ handle_event(tui, event)
109
+ end
110
+ end
111
+
112
+ def render(tui)
113
+ tui.draw do |frame|
114
+ area = frame.area
115
+ areas = RatatuiRuby::Layout::Layout.split(
116
+ area,
117
+ constraints: [
118
+ RatatuiRuby::Layout::Constraint.length(1),
119
+ RatatuiRuby::Layout::Constraint.fill(1)
120
+ ],
121
+ direction: :vertical
122
+ )
123
+ status_area, input_area = areas
124
+
125
+ render_status_bar(frame, status_area)
126
+ render_input(frame, input_area)
127
+ render_completions(frame, input_area) unless @completions.empty?
128
+ end
129
+ end
130
+
131
+ def render_status_bar(frame, area)
132
+ width = area.to_ary[2]
133
+
134
+ override_right = @spinner.active? ? @spinner.to_s : nil
135
+ status_text = @status_bar.render_text(width, override_right: override_right)
136
+
137
+ line = RatatuiRuby::Text::Line.new(
138
+ spans: [RatatuiRuby::Text::Span.new(content: status_text, style: @theme.status_bar_style)]
139
+ )
140
+
141
+ paragraph = RatatuiRuby::Widgets::Paragraph.new(text: [line])
142
+ frame.render_widget(paragraph, area)
143
+ end
144
+
145
+ def render_input(frame, area)
146
+ input_lines = @text_input.lines
147
+ cursor_row = @text_input.cursor_row
148
+ cursor_col = @text_input.cursor_col
149
+
150
+ text_lines = input_lines.map do |line|
151
+ RatatuiRuby::Text::Line.new(
152
+ spans: [RatatuiRuby::Text::Span.new(content: line)]
153
+ )
154
+ end
155
+
156
+ title = input_title
157
+
158
+ block = RatatuiRuby::Widgets::Block.new(
159
+ title: title,
160
+ title_style: @theme.input_title_style,
161
+ borders: [:all],
162
+ border_style: @theme.input_border_style
163
+ )
164
+
165
+ paragraph = RatatuiRuby::Widgets::Paragraph.new(
166
+ text: text_lines,
167
+ block: block,
168
+ wrap: true
169
+ )
170
+
171
+ frame.render_widget(paragraph, area)
172
+
173
+ # Place the real terminal cursor inside the input area.
174
+ # +1 offsets account for the block border.
175
+ if @running && !@executing_command
176
+ ax, ay = area.to_ary[0], area.to_ary[1]
177
+ frame.set_cursor_position(ax + 1 + cursor_col, ay + 1 + cursor_row)
178
+ end
179
+ end
180
+
181
+ def input_title
182
+ base = @prompt.strip
183
+ if @multiline_mode && !@kitty_protocol_active
184
+ "#{base} [MULTI] (Ctrl+J to submit)"
185
+ elsif @kitty_protocol_active && @text_input.line_count > 1
186
+ "#{base} (Enter to submit)"
187
+ else
188
+ base
189
+ end
190
+ end
191
+
192
+ def render_completions(frame, input_area)
193
+ max_visible = [(@completions.length), 8].min
194
+ height = max_visible + 2
195
+
196
+ ia = input_area.to_ary
197
+ comp_y = ia[1] - height
198
+ comp_y = 0 if comp_y < 0
199
+
200
+ comp_area = RatatuiRuby::Layout::Rect.new(ia[0] + 1, comp_y, [ia[2] - 2, 30].min, height)
201
+
202
+ lines = @completions.first(max_visible).each_with_index.map do |comp, i|
203
+ style = i == @completion_index ? @theme.completion_selected_style : @theme.completion_style
204
+ RatatuiRuby::Text::Line.new(
205
+ spans: [RatatuiRuby::Text::Span.new(content: " #{comp} ", style: style)]
206
+ )
207
+ end
208
+
209
+ block = RatatuiRuby::Widgets::Block.new(
210
+ title: "completions",
211
+ borders: [:all],
212
+ border_style: RatatuiRuby::Style::Style.new(fg: @theme[:completion_selected_bg]),
213
+ style: @theme.completion_bg_style
214
+ )
215
+
216
+ paragraph = RatatuiRuby::Widgets::Paragraph.new(
217
+ text: lines,
218
+ block: block
219
+ )
220
+
221
+ frame.render_widget(paragraph, comp_area)
222
+ end
223
+
224
+ def handle_event(tui, event)
225
+ case event
226
+ when RatatuiRuby::Event::None
227
+ # Timeout, nothing to do
228
+ when RatatuiRuby::Event::Key
229
+ handle_key_event(tui, event)
230
+ when RatatuiRuby::Event::Paste
231
+ handle_paste_event(event)
232
+ when RatatuiRuby::Event::Resize
233
+ # Will re-render on next loop
234
+ end
235
+ end
236
+
237
+ def handle_key_event(tui, event)
238
+ code = event.code
239
+ modifiers = event.modifiers || []
240
+ has_ctrl = modifiers.include?("ctrl")
241
+ has_alt = modifiers.include?("alt")
242
+ has_shift = modifiers.include?("shift")
243
+
244
+ if !@completions.empty?
245
+ handled = handle_completion_key(tui, code, has_ctrl, has_alt)
246
+ return if handled
247
+ end
248
+
249
+ if has_ctrl
250
+ handle_ctrl_key(tui, code)
251
+ elsif (has_shift || has_alt) && code == "enter"
252
+ # Shift+Enter or Alt+Enter: always newline
253
+ @text_input.newline
254
+ else
255
+ handle_normal_key(tui, code)
256
+ end
257
+ end
258
+
259
+ def handle_ctrl_key(tui, code)
260
+ case code
261
+ when "d"
262
+ if @text_input.empty?
263
+ @running = false
264
+ else
265
+ @text_input.delete_char
266
+ end
267
+ when "c"
268
+ handle_interrupt(tui)
269
+ when "j", "enter"
270
+ # Ctrl+J / Ctrl+Enter: always submit regardless of mode
271
+ submit_input(tui)
272
+ when "n"
273
+ # Toggle multi-line mode (fallback for terminals without Kitty protocol)
274
+ unless @kitty_protocol_active
275
+ @multiline_mode = !@multiline_mode
276
+ end
277
+ when "a"
278
+ @text_input.move_home
279
+ when "e"
280
+ @text_input.move_end
281
+ when "u"
282
+ @text_input.clear
283
+ @multiline_mode = false unless @kitty_protocol_active
284
+ end
285
+ end
286
+
287
+ def handle_normal_key(tui, code)
288
+ case code
289
+ when "enter"
290
+ if @multiline_mode && !@kitty_protocol_active
291
+ # In multi-line fallback mode, Enter inserts newline
292
+ @text_input.newline
293
+ else
294
+ submit_input(tui)
295
+ end
296
+ when "backspace"
297
+ @text_input.backspace
298
+ dismiss_completions
299
+ # Auto-exit multiline mode if we backspaced to single line
300
+ if @multiline_mode && @text_input.line_count == 1 && @text_input.empty?
301
+ @multiline_mode = false
302
+ end
303
+ when "delete"
304
+ @text_input.delete_char
305
+ dismiss_completions
306
+ when "left"
307
+ @text_input.move_left
308
+ dismiss_completions
309
+ when "right"
310
+ @text_input.move_right
311
+ dismiss_completions
312
+ when "up"
313
+ if @text_input.empty? || (@text_input.line_count == 1 && @text_input.cursor_row == 0)
314
+ @text_input.history_back
315
+ else
316
+ @text_input.move_up
317
+ end
318
+ when "down"
319
+ if @text_input.line_count == 1
320
+ @text_input.history_forward
321
+ else
322
+ @text_input.move_down
323
+ end
324
+ when "home"
325
+ @text_input.move_home
326
+ when "end"
327
+ @text_input.move_end
328
+ when "tab"
329
+ handle_tab_completion
330
+ when "escape"
331
+ if !@completions.empty?
332
+ dismiss_completions
333
+ elsif @multiline_mode
334
+ @multiline_mode = false
335
+ @text_input.clear
336
+ else
337
+ @text_input.clear
338
+ end
339
+ else
340
+ if code.length == 1 && code.ord >= 32
341
+ @text_input.insert_char(code)
342
+ dismiss_completions
343
+ end
344
+ end
345
+ end
346
+
347
+ def handle_completion_key(tui, code, has_ctrl, has_alt)
348
+ case code
349
+ when "tab"
350
+ @completion_index = (@completion_index + 1) % @completions.length
351
+ true
352
+ when "enter"
353
+ if @completion_index >= 0 && @completion_index < @completions.length
354
+ accept_completion(@completions[@completion_index])
355
+ end
356
+ dismiss_completions
357
+ true
358
+ when "escape"
359
+ dismiss_completions
360
+ true
361
+ else
362
+ false
363
+ end
364
+ end
365
+
366
+ def accept_completion(completion)
367
+ line = @text_input.current_line
368
+ col = @text_input.cursor_col
369
+ before_cursor = line[0...col] || +""
370
+ words = before_cursor.split(/\s+/, -1)
371
+ current_word = words.last || +""
372
+
373
+ current_word.length.times { @text_input.backspace }
374
+ @text_input.insert_char(completion)
375
+ @text_input.insert_char(" ")
376
+ end
377
+
378
+ def dismiss_completions
379
+ @completions = []
380
+ @completion_index = -1
381
+ end
382
+
383
+ def handle_paste_event(event)
384
+ return unless event.respond_to?(:content)
385
+ text = event.content
386
+ @text_input.insert_text(text)
387
+ # Auto-enter multiline mode if paste contains newlines
388
+ if text.include?("\n") && !@kitty_protocol_active
389
+ @multiline_mode = true
390
+ end
391
+ end
392
+
393
+ def handle_interrupt(tui)
394
+ current_time = Time.now
395
+
396
+ if @last_interrupt_time && @double_ctrl_c_timeout &&
397
+ (current_time - @last_interrupt_time) < @double_ctrl_c_timeout
398
+ @running = false
399
+ @last_interrupt_time = nil
400
+ return
401
+ end
402
+
403
+ @last_interrupt_time = current_time
404
+ @text_input.clear
405
+ @multiline_mode = false unless @kitty_protocol_active
406
+ emit_above(tui, "^C (press Ctrl+C again to exit, Ctrl+D to exit)", style: :system)
407
+ end
408
+
409
+ def submit_input(tui)
410
+ input = @text_input.submit
411
+ @multiline_mode = false unless @kitty_protocol_active
412
+ return if input.strip.empty?
413
+
414
+ emit_above(tui, "#{@prompt}#{input}", style: :command)
415
+
416
+ if should_exit?(input)
417
+ @running = false
418
+ return
419
+ end
420
+
421
+ execute_with_capture(tui) do
422
+ process_input(input.strip)
423
+ end
424
+ end
425
+
426
+ def execute_with_capture(tui, &block)
427
+ @executing_command = true
428
+ @spinner.start
429
+
430
+ captured_stdout = StringIO.new
431
+ captured_stderr = StringIO.new
432
+
433
+ command_thread = Thread.new do
434
+ old_stdout = $stdout
435
+ old_stderr = $stderr
436
+ $stdout = captured_stdout
437
+ $stderr = captured_stderr
438
+
439
+ begin
440
+ block.call
441
+ rescue SystemExit => e
442
+ captured_stdout.puts "Command exited with code #{e.status}"
443
+ rescue => e
444
+ captured_stderr.puts "Error: #{e.message}"
445
+ ensure
446
+ $stdout = old_stdout
447
+ $stderr = old_stderr
448
+ end
449
+ end
450
+
451
+ while command_thread.alive?
452
+ render(tui)
453
+ event = tui.poll_event(timeout: 0.05)
454
+
455
+ if event.is_a?(RatatuiRuby::Event::Key)
456
+ code = event.code
457
+ modifiers = event.modifiers || []
458
+ if modifiers.include?("ctrl") && code == "c"
459
+ command_thread.kill
460
+ emit_above(tui, "^C Command interrupted", style: :error)
461
+ break
462
+ end
463
+ end
464
+ end
465
+
466
+ command_thread.join(1)
467
+
468
+ @spinner.stop
469
+ @executing_command = false
470
+
471
+ stdout_text = captured_stdout.string
472
+ stderr_text = captured_stderr.string
473
+
474
+ unless stdout_text.strip.empty?
475
+ emit_above(tui, strip_ansi(stdout_text).chomp)
476
+ end
477
+
478
+ unless stderr_text.strip.empty?
479
+ emit_above(tui, strip_ansi(stderr_text).chomp, style: :error)
480
+ end
481
+ end
482
+
483
+ def emit_above(tui, text, style: nil)
484
+ lines = text.split("\n", -1).map do |line_text|
485
+ ratatui_style = case style
486
+ when :error then @theme.error_style
487
+ when :command then @theme.command_echo_style
488
+ when :system then @theme.system_style
489
+ else nil
490
+ end
491
+
492
+ if ratatui_style
493
+ RatatuiRuby::Text::Line.new(
494
+ spans: [RatatuiRuby::Text::Span.new(content: line_text, style: ratatui_style)]
495
+ )
496
+ else
497
+ RatatuiRuby::Text::Line.new(
498
+ spans: [RatatuiRuby::Text::Span.new(content: line_text)]
499
+ )
500
+ end
501
+ end
502
+
503
+ paragraph = RatatuiRuby::Widgets::Paragraph.new(text: lines, wrap: true)
504
+ tui.insert_before(lines.length, paragraph)
505
+ render(tui)
506
+ rescue => e
507
+ # Silently ignore if insert_before fails
508
+ end
509
+
510
+ def handle_tab_completion
511
+ input = @text_input.content
512
+ return if input.empty?
513
+
514
+ line = @text_input.current_line
515
+ col = @text_input.cursor_col
516
+ before_cursor = line[0...col] || +""
517
+
518
+ words = before_cursor.split(/\s+/, -1)
519
+ current_word = words.last || +""
520
+ preposing = before_cursor[0...(before_cursor.length - current_word.length)]
521
+
522
+ completions = complete_input(current_word, preposing)
523
+ return if completions.empty?
524
+
525
+ if completions.length == 1
526
+ accept_completion(completions.first)
527
+ else
528
+ @completions = completions
529
+ @completion_index = 0
530
+ end
531
+ end
532
+
533
+ # Enable the Kitty keyboard protocol so the terminal sends modifier
534
+ # information with key events (e.g., Shift+Enter becomes distinguishable
535
+ # from plain Enter). Supported by iTerm2, Kitty, WezTerm, Ghostty,
536
+ # VS Code terminal, and others.
537
+ def enable_kitty_keyboard
538
+ tty = IO.console
539
+ return unless tty
540
+
541
+ # Check if the terminal supports it
542
+ supported = begin
543
+ RatatuiRuby::Terminal.supports_keyboard_enhancement?
544
+ rescue
545
+ false
546
+ end
547
+
548
+ if supported
549
+ # Push keyboard mode 1 (disambiguate escape codes)
550
+ tty.write("\e[>1u")
551
+ tty.flush
552
+ @kitty_protocol_active = true
553
+ end
554
+ rescue
555
+ @kitty_protocol_active = false
556
+ end
557
+
558
+ # Restore the keyboard protocol to normal on exit
559
+ def disable_kitty_keyboard
560
+ return unless @kitty_protocol_active
561
+
562
+ tty = IO.console
563
+ return unless tty
564
+
565
+ tty.write("\e[<u")
566
+ tty.flush
567
+ @kitty_protocol_active = false
568
+ rescue
569
+ # Not critical
570
+ end
571
+
572
+ def disable_mouse_capture
573
+ tty = IO.console
574
+ return unless tty
575
+
576
+ tty.write("\e[?1000l\e[?1002l\e[?1003l\e[?1006l")
577
+ tty.flush
578
+ rescue
579
+ # Not critical
580
+ end
581
+
582
+ def strip_ansi(text)
583
+ text.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
584
+ end
585
+
586
+ def load_history
587
+ return unless File.exist?(@history_file)
588
+
589
+ entries = File.readlines(@history_file, chomp: true)
590
+ @text_input.load_history(entries)
591
+ rescue
592
+ # Ignore history loading errors
593
+ end
594
+
595
+ def save_history
596
+ entries = @text_input.history_entries
597
+ return if entries.empty?
598
+
599
+ File.write(@history_file, entries.join("\n"))
600
+ rescue
601
+ # Ignore history saving errors
602
+ end
603
+ end
604
+ end
605
+ end
606
+ end