thor-interactive 0.1.0.pre.4 → 0.1.0.pre.6

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,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
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Thor
4
+ module Interactive
5
+ module TUI
6
+ # Spinner animation for indicating activity during command execution.
7
+ # Shows rotating fun messages like Claude Code.
8
+ class Spinner
9
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
10
+ INTERVAL = 0.08 # seconds between frames
11
+
12
+ # Rotate through fun messages during long operations.
13
+ # Apps can provide their own list via configure_interactive(spinner_messages: [...])
14
+ DEFAULT_MESSAGES = [
15
+ "Thinking",
16
+ "Pondering",
17
+ "Crunching",
18
+ "Churning",
19
+ "Whirring",
20
+ "Brewing",
21
+ "Conjuring",
22
+ "Simmering",
23
+ "Percolating",
24
+ "Contemplating",
25
+ "Noodling",
26
+ "Ruminating",
27
+ "Calculating",
28
+ "Assembling",
29
+ "Composing"
30
+ ].freeze
31
+
32
+ MESSAGE_ROTATE_INTERVAL = 3.0 # seconds between message changes
33
+
34
+ attr_reader :message
35
+
36
+ def initialize(messages: nil)
37
+ @messages = messages || DEFAULT_MESSAGES
38
+ @message = @messages.first
39
+ @message_index = 0
40
+ @last_message_change = Time.now
41
+ @frame_index = 0
42
+ @last_advance = Time.now
43
+ @active = false
44
+ @start_time = nil
45
+ end
46
+
47
+ def start(message = nil)
48
+ @message = message || @messages.sample
49
+ @message_index = @messages.index(@message) || 0
50
+ @last_message_change = Time.now
51
+ @active = true
52
+ @start_time = Time.now
53
+ @frame_index = 0
54
+ end
55
+
56
+ def stop
57
+ @active = false
58
+ @start_time = nil
59
+ end
60
+
61
+ def active?
62
+ @active
63
+ end
64
+
65
+ def advance
66
+ now = Time.now
67
+ if now - @last_advance >= INTERVAL
68
+ @frame_index = (@frame_index + 1) % FRAMES.length
69
+ @last_advance = now
70
+ end
71
+
72
+ # Rotate message periodically
73
+ if now - @last_message_change >= MESSAGE_ROTATE_INTERVAL
74
+ @message_index = (@message_index + 1) % @messages.length
75
+ @message = @messages[@message_index]
76
+ @last_message_change = now
77
+ end
78
+ end
79
+
80
+ def elapsed
81
+ return 0 unless @start_time
82
+ Time.now - @start_time
83
+ end
84
+
85
+ def to_s
86
+ return "" unless @active
87
+
88
+ advance
89
+ elapsed_str = format_elapsed(elapsed)
90
+ " #{FRAMES[@frame_index]} #{@message}... #{elapsed_str} "
91
+ end
92
+
93
+ private
94
+
95
+ def format_elapsed(seconds)
96
+ if seconds < 60
97
+ "(#{seconds.round(1)}s)"
98
+ else
99
+ mins = (seconds / 60).to_i
100
+ secs = (seconds % 60).to_i
101
+ "(#{mins}m#{secs}s)"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end