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.
@@ -1,54 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "reline"
4
- require "shellwords"
5
- require "thor"
4
+ require_relative "command_dispatch"
6
5
 
7
6
  class Thor
8
7
  module Interactive
9
8
  class Shell
10
9
  DEFAULT_PROMPT = "> "
11
10
  DEFAULT_HISTORY_FILE = "~/.thor_interactive_history"
12
- EXIT_COMMANDS = %w[exit quit q].freeze
13
11
 
14
12
  attr_reader :thor_class, :thor_instance, :prompt
15
13
 
14
+ include CommandDispatch
15
+
16
16
  def initialize(thor_class, options = {})
17
17
  @thor_class = thor_class
18
18
  @thor_instance = thor_class.new
19
-
19
+
20
20
  # Merge class-level interactive options if available
21
21
  merged_options = {}
22
22
  if thor_class.respond_to?(:interactive_options)
23
23
  merged_options.merge!(thor_class.interactive_options)
24
24
  end
25
25
  merged_options.merge!(options)
26
-
26
+
27
27
  @merged_options = merged_options
28
28
  @default_handler = merged_options[:default_handler]
29
29
  @prompt = merged_options[:prompt] || DEFAULT_PROMPT
30
30
  @history_file = File.expand_path(merged_options[:history_file] || DEFAULT_HISTORY_FILE)
31
-
31
+
32
32
  # Ctrl-C handling configuration
33
33
  @ctrl_c_behavior = merged_options[:ctrl_c_behavior] || :clear_prompt
34
- @double_ctrl_c_timeout = merged_options.key?(:double_ctrl_c_timeout) ?
34
+ @double_ctrl_c_timeout = merged_options.key?(:double_ctrl_c_timeout) ?
35
35
  merged_options[:double_ctrl_c_timeout] : 0.5
36
36
  @last_interrupt_time = nil
37
-
37
+
38
38
  setup_completion
39
39
  load_history
40
40
  end
41
41
 
42
42
  def start
43
43
  # Track that we're in an interactive session
44
- was_in_session = ENV['THOR_INTERACTIVE_SESSION']
45
- nesting_level = ENV['THOR_INTERACTIVE_LEVEL'].to_i
46
-
47
- ENV['THOR_INTERACTIVE_SESSION'] = 'true'
48
- ENV['THOR_INTERACTIVE_LEVEL'] = (nesting_level + 1).to_s
49
-
44
+ was_in_session = ENV["THOR_INTERACTIVE_SESSION"]
45
+ nesting_level = ENV["THOR_INTERACTIVE_LEVEL"].to_i
46
+
47
+ ENV["THOR_INTERACTIVE_SESSION"] = "true"
48
+ ENV["THOR_INTERACTIVE_LEVEL"] = (nesting_level + 1).to_s
49
+
50
50
  puts "(Debug: Interactive session started, level #{nesting_level + 1})" if ENV["DEBUG"]
51
-
51
+
52
52
  # Adjust prompt for nested sessions if configured
53
53
  display_prompt = @prompt
54
54
  if nesting_level > 0 && @merged_options[:nested_prompt_format]
@@ -56,37 +56,35 @@ class Thor
56
56
  elsif nesting_level > 0
57
57
  display_prompt = "(#{nesting_level + 1}) #{@prompt}"
58
58
  end
59
-
59
+
60
60
  show_welcome(nesting_level)
61
-
61
+
62
62
  puts "(Debug: Entering main loop)" if ENV["DEBUG"]
63
-
63
+
64
64
  loop do
65
65
  begin
66
66
  line = Reline.readline(display_prompt, true)
67
67
  puts "(Debug: Got input: #{line.inspect})" if ENV["DEBUG"]
68
-
68
+
69
69
  # Reset interrupt tracking on successful input
70
70
  @last_interrupt_time = nil if line
71
-
71
+
72
72
  if should_exit?(line)
73
73
  puts "(Debug: Exit condition met)" if ENV["DEBUG"]
74
74
  break
75
75
  end
76
-
76
+
77
77
  next if line.nil? || line.strip.empty?
78
-
78
+
79
79
  puts "(Debug: Processing input: #{line.strip})" if ENV["DEBUG"]
80
80
  process_input(line.strip)
81
81
  puts "(Debug: Input processed successfully)" if ENV["DEBUG"]
82
-
83
82
  rescue Interrupt
84
83
  # Handle Ctrl-C
85
84
  if handle_interrupt
86
- break # Exit on double Ctrl-C
85
+ break # Exit on double Ctrl-C
87
86
  end
88
- next # Continue on single Ctrl-C
89
-
87
+ next # Continue on single Ctrl-C
90
88
  rescue SystemExit => e
91
89
  puts "A command tried to exit with code #{e.status}. Staying in interactive mode."
92
90
  puts "(Debug: SystemExit caught in main loop)" if ENV["DEBUG"]
@@ -97,19 +95,18 @@ class Thor
97
95
  # Continue the loop - don't let errors break the session
98
96
  end
99
97
  end
100
-
98
+
101
99
  puts "(Debug: Exited main loop)" if ENV["DEBUG"]
102
100
  save_history
103
101
  puts nesting_level > 0 ? "Exiting nested session..." : "Goodbye!"
104
-
105
102
  ensure
106
103
  # Restore previous session state
107
104
  if was_in_session
108
- ENV['THOR_INTERACTIVE_SESSION'] = 'true'
109
- ENV['THOR_INTERACTIVE_LEVEL'] = nesting_level.to_s
105
+ ENV["THOR_INTERACTIVE_SESSION"] = "true"
106
+ ENV["THOR_INTERACTIVE_LEVEL"] = nesting_level.to_s
110
107
  else
111
- ENV.delete('THOR_INTERACTIVE_SESSION')
112
- ENV.delete('THOR_INTERACTIVE_LEVEL')
108
+ ENV.delete("THOR_INTERACTIVE_SESSION")
109
+ ENV.delete("THOR_INTERACTIVE_LEVEL")
113
110
  end
114
111
  end
115
112
 
@@ -121,302 +118,18 @@ class Thor
121
118
  end
122
119
  end
123
120
 
124
- def complete_input(text, preposing)
125
- # Handle completion for slash commands
126
- full_line = preposing + text
127
-
128
- if full_line.start_with?('/')
129
- # Command completion mode
130
- if preposing.strip == '/' || preposing.strip.empty?
131
- # Complete command names with / prefix
132
- command_completions = complete_commands(text.sub(/^\//, ''))
133
- command_completions.map { |cmd| "/#{cmd}" }
134
- else
135
- # Complete command arguments (basic implementation)
136
- complete_command_options(text, preposing)
137
- end
138
- else
139
- # Natural language mode - no completion for now
140
- []
141
- end
142
- end
143
-
144
- def complete_commands(text)
145
- return [] if text.nil?
146
-
147
- command_names = @thor_class.tasks.keys + EXIT_COMMANDS + ["help"]
148
- command_names.select { |cmd| cmd.start_with?(text) }.sort
149
- end
150
-
151
- def complete_command_options(text, preposing)
152
- # Basic implementation - can be enhanced later
153
- # For now, just return empty array to let Reline handle file completion
154
- []
155
- end
156
-
157
- def process_input(input)
158
- # Handle completely empty input
159
- return if input.nil? || input.strip.empty?
160
-
161
- # Check if input starts with / for explicit command mode
162
- if input.strip.start_with?('/')
163
- # Explicit command mode: /command args
164
- handle_slash_command(input.strip[1..-1])
165
- elsif is_help_request?(input)
166
- # Special case: treat bare "help" as /help for convenience
167
- if input.strip.split.length == 1
168
- show_help
169
- else
170
- command_part = input.strip.split[1]
171
- show_help(command_part)
172
- end
173
- else
174
- # Determine if this looks like a command or natural language
175
- command_word = input.strip.split(/\s+/, 2).first
176
-
177
- if thor_command?(command_word)
178
- # Looks like a command - handle it as a command (backward compatibility)
179
- handle_command(input.strip)
180
- elsif @default_handler
181
- # Natural language mode: send whole input to default handler
182
- begin
183
- @default_handler.call(input, @thor_instance)
184
- rescue => e
185
- puts "Error in default handler: #{e.message}"
186
- puts "Input was: #{input}"
187
- puts "Try using /commands or type '/help' for available commands."
188
- end
189
- else
190
- # No default handler, suggest using command mode
191
- puts "No default handler configured. Use /command for commands, or type '/help' for available commands."
192
- end
193
- end
194
- end
195
-
196
- def handle_slash_command(command_input)
197
- return if command_input.empty?
198
- handle_command(command_input)
199
- end
200
-
201
- def handle_command(command_input)
202
- # Extract command and check if it's a single-text command
203
- command_word = command_input.split(/\s+/, 2).first
204
-
205
- if thor_command?(command_word)
206
- task = @thor_class.tasks[command_word]
207
-
208
- if task && single_text_command?(task) && !task.options.any?
209
- # Single text command without options - pass everything after command as one argument
210
- text_part = command_input.sub(/^#{Regexp.escape(command_word)}\s*/, '')
211
- if text_part.empty?
212
- invoke_thor_command(command_word, [])
213
- else
214
- invoke_thor_command(command_word, [text_part])
215
- end
216
- else
217
- # Multi-argument command, use proper parsing
218
- args = safe_parse_input(command_input)
219
- if args && !args.empty?
220
- command = args.shift
221
- invoke_thor_command(command, args)
222
- else
223
- # Parsing failed, try simple split
224
- parts = command_input.split(/\s+/)
225
- command = parts.shift
226
- invoke_thor_command(command, parts)
227
- end
228
- end
229
- else
230
- puts "Unknown command: '#{command_word}'. Type '/help' for available commands."
231
- end
232
- end
233
-
234
- def safe_parse_input(input)
235
- # Try proper shell parsing first
236
- Shellwords.split(input)
237
- rescue ArgumentError
238
- # If parsing fails, return nil so caller can handle it
239
- nil
240
- end
241
-
242
- def parse_input(input)
243
- # Legacy method - kept for backward compatibility
244
- safe_parse_input(input) || []
245
- end
246
-
247
- def handle_unparseable_command(input, command_word)
248
- # For commands that failed shell parsing, try intelligent handling
249
- task = @thor_class.tasks[command_word]
250
-
251
- # Always try single text approach first for better natural language support
252
- text_part = input.strip.sub(/^#{Regexp.escape(command_word)}\s*/, '')
253
- if text_part.empty?
254
- invoke_thor_command(command_word, [])
255
- else
256
- invoke_thor_command(command_word, [text_part])
257
- end
258
- end
259
-
260
- def single_text_command?(task)
261
- # Heuristic: determine if this is likely a single text command
262
- return false unless task
263
-
264
- # Check the method signature to see how many parameters it expects
265
- method_name = task.name.to_sym
266
- if @thor_instance.respond_to?(method_name)
267
- method_obj = @thor_instance.method(method_name)
268
- param_count = method_obj.parameters.count { |type, _| type == :req }
269
-
270
- # Only single required parameter = likely text command
271
- param_count == 1
272
- else
273
- # Fallback for introspection issues
274
- false
275
- end
276
- rescue
277
- # If introspection fails, default to false (safer)
278
- false
279
- end
280
-
281
- def is_help_request?(input)
282
- # Check if input is a help request (help, ?, etc.)
283
- stripped = input.strip.downcase
284
- stripped == "help" || stripped.start_with?("help ")
285
- end
286
-
287
- def thor_command?(command)
288
- @thor_class.tasks.key?(command) ||
289
- @thor_class.subcommand_classes.key?(command) ||
290
- command == "help"
291
- end
292
-
293
- def invoke_thor_command(command, args)
294
- # Use the persistent instance to maintain state
295
- if command == "help"
296
- show_help(args.first)
297
- else
298
- # Get the Thor task/command definition
299
- task = @thor_class.tasks[command]
300
-
301
- if task && task.options && !task.options.empty?
302
- # Parse options if the command has them defined
303
- parsed_args, parsed_options = parse_thor_options(args, task)
304
-
305
- # Set options on the Thor instance
306
- @thor_instance.options = Thor::CoreExt::HashWithIndifferentAccess.new(parsed_options)
307
-
308
- # Call with parsed arguments only (options are in the options hash)
309
- if @thor_instance.respond_to?(command)
310
- @thor_instance.send(command, *parsed_args)
311
- else
312
- @thor_instance.send(command, *parsed_args)
313
- end
314
- else
315
- # No options defined, use original behavior
316
- if @thor_instance.respond_to?(command)
317
- @thor_instance.send(command, *args)
318
- else
319
- @thor_instance.send(command, *args)
320
- end
321
- end
322
- end
323
- rescue SystemExit => e
324
- if e.status == 0
325
- puts "Command completed successfully (would have exited with code 0 in CLI mode)"
326
- else
327
- puts "Command failed with exit code #{e.status}"
328
- end
329
- puts "(Use 'exit' or Ctrl+D to exit the interactive session)" if ENV["DEBUG"]
330
- rescue Thor::Error => e
331
- puts "Thor Error: #{e.message}"
332
- rescue ArgumentError => e
333
- puts "Thor Error: #{e.message}"
334
- puts "Try: help #{command}" if thor_command?(command)
335
- rescue StandardError => e
336
- puts "Error: #{e.message}"
337
- puts "Command: #{command}, Args: #{args.inspect}" if ENV["DEBUG"]
338
- end
339
-
340
- def parse_thor_options(args, task)
341
- # Convert args array to a format Thor's option parser expects
342
- remaining_args = []
343
- parsed_options = {}
344
-
345
- # Create a temporary parser using Thor's options
346
- parser = Thor::Options.new(task.options)
347
-
348
- # Parse the arguments
349
- begin
350
- if args.is_a?(Array)
351
- # Parse the options from the array
352
- parsed_options = parser.parse(args)
353
- remaining_args = parser.remaining
354
- else
355
- # Single string argument, split it first
356
- split_args = safe_parse_input(args) || args.split(/\s+/)
357
- parsed_options = parser.parse(split_args)
358
- remaining_args = parser.remaining
359
- end
360
- rescue Thor::Error => e
361
- # If parsing fails, treat everything as arguments (backward compatibility)
362
- puts "Option parsing error: #{e.message}" if ENV["DEBUG"]
363
- remaining_args = args.is_a?(Array) ? args : [args]
364
- parsed_options = {}
365
- end
366
-
367
- [remaining_args, parsed_options]
368
- end
369
-
370
- def show_help(command = nil)
371
- if command && @thor_class.tasks.key?(command)
372
- @thor_class.command_help(Thor::Base.shell.new, command)
373
- else
374
- puts "Available commands (prefix with /):"
375
- @thor_class.tasks.each do |name, task|
376
- puts " /#{name.ljust(19)} #{task.description}"
377
- end
378
- puts
379
- puts "Special commands:"
380
- puts " /help [COMMAND] Show help for command"
381
- puts " /exit, /quit, /q Exit the REPL"
382
- puts
383
- if @default_handler
384
- puts "Natural language mode:"
385
- puts " Type anything without / to use default handler"
386
- else
387
- puts "Use /command syntax for all commands"
388
- end
389
- puts
390
- if ENV["DEBUG"]
391
- puts "Debug info:"
392
- puts " Thor class: #{@thor_class.name}"
393
- puts " Available tasks: #{@thor_class.tasks.keys.sort}"
394
- puts " Instance methods: #{@thor_instance.methods.grep(/^[a-z]/).sort}" if @thor_instance
395
- puts
396
- end
397
- end
398
- end
399
-
400
- def should_exit?(line)
401
- return true if line.nil? # Ctrl+D
402
-
403
- stripped = line.strip.downcase
404
- # Handle both /exit and exit for convenience
405
- EXIT_COMMANDS.include?(stripped) || EXIT_COMMANDS.include?(stripped.sub(/^\//, ''))
406
- end
407
-
408
121
  def handle_interrupt
409
122
  current_time = Time.now
410
-
123
+
411
124
  # Check for double Ctrl-C
412
125
  if @last_interrupt_time && @double_ctrl_c_timeout && (current_time - @last_interrupt_time) < @double_ctrl_c_timeout
413
126
  puts "\n(Interrupted twice - exiting)"
414
- @last_interrupt_time = nil # Reset for next time
415
- return true # Signal to exit
127
+ @last_interrupt_time = nil # Reset for next time
128
+ return true # Signal to exit
416
129
  end
417
-
130
+
418
131
  @last_interrupt_time = current_time
419
-
132
+
420
133
  # Single Ctrl-C behavior
421
134
  case @ctrl_c_behavior
422
135
  when :clear_prompt
@@ -427,13 +140,13 @@ class Thor
427
140
  puts "Press Ctrl-C again to exit, or type 'help' for commands"
428
141
  when :silent
429
142
  # Just clear the line, no message
430
- print "\r#{' ' * 80}\r"
143
+ print "\r#{" " * 80}\r"
431
144
  else
432
145
  # Default behavior
433
146
  puts "^C"
434
147
  end
435
-
436
- false # Don't exit, just clear prompt
148
+
149
+ false # Don't exit, just clear prompt
437
150
  end
438
151
 
439
152
  def show_welcome(nesting_level = 0)
@@ -449,7 +162,7 @@ class Thor
449
162
 
450
163
  def load_history
451
164
  return unless File.exist?(@history_file)
452
-
165
+
453
166
  File.readlines(@history_file, chomp: true).each do |line|
454
167
  Reline::HISTORY << line
455
168
  end
@@ -459,11 +172,11 @@ class Thor
459
172
 
460
173
  def save_history
461
174
  return unless Reline::HISTORY.size > 0
462
-
175
+
463
176
  File.write(@history_file, Reline::HISTORY.to_a.join("\n"))
464
177
  rescue => e
465
- # Ignore history saving errors
178
+ # Ignore history saving errors
466
179
  end
467
180
  end
468
181
  end
469
- end
182
+ end
@@ -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