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,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # TUI Demo - demonstrates the ratatui_ruby-powered TUI mode
5
+ #
6
+ # Usage:
7
+ # ruby examples/tui_demo.rb interactive
8
+ # ruby examples/tui_demo.rb interactive --theme dark
9
+ #
10
+ # Key bindings:
11
+ # Enter - Submit input
12
+ # Shift+Enter - New line (requires Kitty keyboard protocol)
13
+ # Alt+Enter - New line (alternative)
14
+ # Ctrl+N - Toggle multi-line mode (fallback for older terminals)
15
+ # Ctrl+J - Always submit (even in multi-line mode)
16
+ # Tab - Auto-complete commands
17
+ # Ctrl+C - Clear input / double-tap to exit
18
+ # Ctrl+D - Exit
19
+ # Escape - Clear input / exit multi-line mode
20
+ #
21
+ # Requires: gem install ratatui_ruby
22
+
23
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
24
+
25
+ require "thor"
26
+ require "thor/interactive"
27
+
28
+ class TuiDemo < Thor
29
+ include Thor::Interactive::Command
30
+
31
+ configure_interactive(
32
+ ui_mode: :tui,
33
+ prompt: "demo> ",
34
+ # theme: :dark, # Try: :default, :dark, :light, :minimal
35
+ status_bar: {
36
+ left: ->(instance) { " TUI Demo" },
37
+ right: ->(instance) { " commands: #{instance.class.tasks.count} " }
38
+ },
39
+ # Custom spinner messages (optional - defaults are fun too)
40
+ spinner_messages: ["Thinking", "Brewing", "Crunching", "Vibing", "Noodling"]
41
+ )
42
+
43
+ desc "hello", "Say hello"
44
+ def hello
45
+ puts "Hello from TUI mode!"
46
+ end
47
+
48
+ desc "greet NAME", "Greet someone by name"
49
+ def greet(name)
50
+ puts "Hello, #{name}! Welcome to the TUI demo."
51
+ end
52
+
53
+ desc "count N", "Count from 1 to N"
54
+ def count(n)
55
+ n.to_i.times do |i|
56
+ puts "#{i + 1}..."
57
+ sleep(0.1) # Slow enough to see the spinner
58
+ end
59
+ puts "Done counting!"
60
+ end
61
+
62
+ desc "status", "Show current status"
63
+ def status
64
+ puts "TUI Demo Status:"
65
+ puts " Session: active"
66
+ puts " Mode: TUI (ratatui_ruby)"
67
+ puts " Theme: #{self.class.interactive_options[:theme] || :default}"
68
+ puts " Commands available: #{self.class.tasks.keys.join(", ")}"
69
+ end
70
+
71
+ desc "error_demo", "Demonstrate error handling"
72
+ def error_demo
73
+ puts "About to raise an error..."
74
+ raise "This is a demo error to show error handling"
75
+ end
76
+
77
+ desc "slow", "Demonstrate spinner with a slow command"
78
+ def slow
79
+ puts "Starting slow operation..."
80
+ sleep(3)
81
+ puts "Slow operation complete!"
82
+ end
83
+
84
+ desc "multiline", "Print multiple lines of output"
85
+ def multiline
86
+ puts "Line 1: This is a multi-line output demo"
87
+ puts "Line 2: Each line appears in the output buffer"
88
+ puts "Line 3: You can scroll up/down with PageUp/PageDown"
89
+ puts "Line 4: The output persists between commands"
90
+ puts "Line 5: End of demo output"
91
+ end
92
+ end
93
+
94
+ TuiDemo.start(ARGV)
@@ -25,7 +25,18 @@ class Thor
25
25
  opts[:prompt] = options[:prompt] || options["prompt"] if options[:prompt] || options["prompt"]
26
26
  opts[:history_file] = options[:history_file] || options["history_file"] if options[:history_file] || options["history_file"]
27
27
 
28
- Thor::Interactive::Shell.new(self.class, opts).start
28
+ if opts[:ui_mode] == :tui
29
+ require_relative "tui"
30
+ if Thor::Interactive::TUI.available?
31
+ require_relative "tui/ratatui_shell"
32
+ Thor::Interactive::TUI::RatatuiShell.new(self.class, opts).start
33
+ else
34
+ warn "ratatui_ruby gem not found, falling back to standard shell"
35
+ Thor::Interactive::Shell.new(self.class, opts).start
36
+ end
37
+ else
38
+ Thor::Interactive::Shell.new(self.class, opts).start
39
+ end
29
40
  end
30
41
  end
31
42
  end
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "thor"
5
+
6
+ class Thor
7
+ module Interactive
8
+ # Shared command dispatch logic used by both the Reline-based Shell
9
+ # and the ratatui_ruby-based TUI shell.
10
+ #
11
+ # Includers must provide:
12
+ # @thor_class - the Thor class
13
+ # @thor_instance - a persistent Thor instance
14
+ # @default_handler - optional proc for natural language input
15
+ # @merged_options - merged configuration options
16
+ module CommandDispatch
17
+ EXIT_COMMANDS = %w[exit quit q].freeze
18
+
19
+ def process_input(input)
20
+ return if input.nil? || input.strip.empty?
21
+
22
+ if input.strip.start_with?("/")
23
+ handle_slash_command(input.strip[1..-1])
24
+ elsif is_help_request?(input)
25
+ if input.strip.split.length == 1
26
+ show_help
27
+ else
28
+ command_part = input.strip.split[1]
29
+ show_help(command_part)
30
+ end
31
+ else
32
+ command_word = input.strip.split(/\s+/, 2).first
33
+
34
+ if thor_command?(command_word)
35
+ handle_command(input.strip)
36
+ elsif @default_handler
37
+ begin
38
+ @default_handler.call(input, @thor_instance)
39
+ rescue => e
40
+ puts "Error in default handler: #{e.message}"
41
+ puts "Input was: #{input}"
42
+ puts "Try using /commands or type '/help' for available commands."
43
+ end
44
+ else
45
+ puts "No default handler configured. Use /command for commands, or type '/help' for available commands."
46
+ end
47
+ end
48
+ end
49
+
50
+ def handle_slash_command(command_input)
51
+ return if command_input.empty?
52
+ handle_command(command_input)
53
+ end
54
+
55
+ def handle_command(command_input)
56
+ command_word = command_input.split(/\s+/, 2).first
57
+
58
+ if thor_command?(command_word)
59
+ task = @thor_class.tasks[command_word]
60
+
61
+ if task && single_text_command?(task) && !task.options.any?
62
+ text_part = command_input.sub(/^#{Regexp.escape(command_word)}\s*/, "")
63
+ if text_part.empty?
64
+ invoke_thor_command(command_word, [])
65
+ else
66
+ invoke_thor_command(command_word, [text_part])
67
+ end
68
+ else
69
+ args = safe_parse_input(command_input)
70
+ if args && !args.empty?
71
+ command = args.shift
72
+ invoke_thor_command(command, args)
73
+ else
74
+ parts = command_input.split(/\s+/)
75
+ command = parts.shift
76
+ invoke_thor_command(command, parts)
77
+ end
78
+ end
79
+ else
80
+ puts "Unknown command: '#{command_word}'. Type '/help' for available commands."
81
+ end
82
+ end
83
+
84
+ def safe_parse_input(input)
85
+ Shellwords.split(input)
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ def parse_input(input)
91
+ safe_parse_input(input) || []
92
+ end
93
+
94
+ def handle_unparseable_command(input, command_word)
95
+ text_part = input.strip.sub(/^#{Regexp.escape(command_word)}\s*/, "")
96
+ if text_part.empty?
97
+ invoke_thor_command(command_word, [])
98
+ else
99
+ invoke_thor_command(command_word, [text_part])
100
+ end
101
+ end
102
+
103
+ def single_text_command?(task)
104
+ return false unless task
105
+
106
+ method_name = task.name.to_sym
107
+ if @thor_instance.respond_to?(method_name)
108
+ method_obj = @thor_instance.method(method_name)
109
+ param_count = method_obj.parameters.count { |type, _| type == :req }
110
+ param_count == 1
111
+ else
112
+ false
113
+ end
114
+ rescue
115
+ false
116
+ end
117
+
118
+ def is_help_request?(input)
119
+ stripped = input.strip.downcase
120
+ stripped == "help" || stripped.start_with?("help ")
121
+ end
122
+
123
+ def thor_command?(command)
124
+ @thor_class.tasks.key?(command) ||
125
+ @thor_class.subcommand_classes.key?(command) ||
126
+ command == "help"
127
+ end
128
+
129
+ def invoke_thor_command(command, args)
130
+ if command == "help"
131
+ show_help(args.first)
132
+ else
133
+ task = @thor_class.tasks[command]
134
+
135
+ if task && task.options && !task.options.empty?
136
+ result = parse_thor_options(args, task)
137
+ return unless result
138
+
139
+ parsed_args, parsed_options = result
140
+
141
+ @thor_instance.options = Thor::CoreExt::HashWithIndifferentAccess.new(parsed_options)
142
+
143
+ if @thor_instance.respond_to?(command)
144
+ @thor_instance.send(command, *parsed_args)
145
+ else
146
+ @thor_instance.send(command, *parsed_args)
147
+ end
148
+ else
149
+ if @thor_instance.respond_to?(command)
150
+ @thor_instance.send(command, *args)
151
+ else
152
+ @thor_instance.send(command, *args)
153
+ end
154
+ end
155
+ end
156
+ rescue SystemExit => e
157
+ if e.status == 0
158
+ puts "Command completed successfully (would have exited with code 0 in CLI mode)"
159
+ else
160
+ puts "Command failed with exit code #{e.status}"
161
+ end
162
+ puts "(Use 'exit' or Ctrl+D to exit the interactive session)" if ENV["DEBUG"]
163
+ rescue Thor::Error => e
164
+ puts "Thor Error: #{e.message}"
165
+ rescue ArgumentError => e
166
+ puts "Thor Error: #{e.message}"
167
+ puts "Try: help #{command}" if thor_command?(command)
168
+ rescue StandardError => e
169
+ puts "Error: #{e.message}"
170
+ puts "Command: #{command}, Args: #{args.inspect}" if ENV["DEBUG"]
171
+ end
172
+
173
+ def parse_thor_options(args, task)
174
+ remaining_args = []
175
+ parsed_options = {}
176
+
177
+ begin
178
+ parser = Thor::Options.new(task.options)
179
+
180
+ if args.is_a?(Array)
181
+ parsed_options = parser.parse(args)
182
+ remaining_args = parser.remaining
183
+ else
184
+ split_args = safe_parse_input(args) || args.split(/\s+/)
185
+ parsed_options = parser.parse(split_args)
186
+ remaining_args = parser.remaining
187
+ end
188
+ rescue Thor::Error => e
189
+ puts "Option error: #{e.message}"
190
+ return nil
191
+ end
192
+
193
+ unknown = remaining_args.select { |a| a.start_with?("--") || (a.start_with?("-") && a.length > 1 && !a.match?(/^-\d/)) }
194
+ unless unknown.empty?
195
+ puts "Unknown option#{"s" if unknown.length > 1}: #{unknown.join(", ")}"
196
+ puts "Run '/help #{task.name}' to see available options."
197
+ return nil
198
+ end
199
+
200
+ [remaining_args, parsed_options]
201
+ end
202
+
203
+ def show_help(command = nil)
204
+ if command && @thor_class.subcommand_classes.key?(command)
205
+ subcommand_class = @thor_class.subcommand_classes[command]
206
+ puts "Commands for '#{command}':"
207
+ subcommand_class.tasks.each do |name, task|
208
+ puts " /#{command} #{name.ljust(15)} #{task.description}"
209
+ end
210
+ puts
211
+ elsif command && @thor_class.tasks.key?(command)
212
+ @thor_class.command_help(Thor::Base.shell.new, command)
213
+ else
214
+ puts "Available commands (prefix with /):"
215
+ @thor_class.tasks.each do |name, task|
216
+ puts " /#{name.ljust(19)} #{task.description}"
217
+ end
218
+ puts
219
+ puts "Special commands:"
220
+ puts " /help [COMMAND] Show help for command"
221
+ puts " /exit, /quit, /q Exit the REPL"
222
+ puts
223
+ if @default_handler
224
+ puts "Natural language mode:"
225
+ puts " Type anything without / to use default handler"
226
+ else
227
+ puts "Use /command syntax for all commands"
228
+ end
229
+ puts
230
+ if ENV["DEBUG"]
231
+ puts "Debug info:"
232
+ puts " Thor class: #{@thor_class.name}"
233
+ puts " Available tasks: #{@thor_class.tasks.keys.sort}"
234
+ puts " Instance methods: #{@thor_instance.methods.grep(/^[a-z]/).sort}" if @thor_instance
235
+ puts
236
+ end
237
+ end
238
+ end
239
+
240
+ def should_exit?(line)
241
+ return true if line.nil?
242
+
243
+ stripped = line.strip.downcase
244
+ EXIT_COMMANDS.include?(stripped) || EXIT_COMMANDS.include?(stripped.sub(/^\//, ""))
245
+ end
246
+
247
+ # Completion methods
248
+
249
+ def complete_input(text, preposing)
250
+ full_line = preposing + text
251
+
252
+ if full_line.start_with?("/")
253
+ if preposing.strip == "/" || preposing.strip.empty?
254
+ command_completions = complete_commands(text.sub(/^\//, ""))
255
+ command_completions.map { |cmd| "/#{cmd}" }
256
+ else
257
+ complete_command_options(text, preposing)
258
+ end
259
+ else
260
+ []
261
+ end
262
+ end
263
+
264
+ def complete_commands(text)
265
+ return [] if text.nil?
266
+
267
+ command_names = @thor_class.tasks.keys + EXIT_COMMANDS + ["help"]
268
+ command_names.select { |cmd| cmd.start_with?(text) }.sort
269
+ end
270
+
271
+ def complete_command_options(text, preposing)
272
+ parts = preposing.split(/\s+/)
273
+ command = parts[0].sub(/^\//, "") if parts[0]
274
+
275
+ subcommand_class = @thor_class.subcommand_classes[command] if command
276
+ if subcommand_class
277
+ return complete_subcommand_args(subcommand_class, text, parts)
278
+ end
279
+
280
+ task = @thor_class.tasks[command] if command
281
+
282
+ if path_like?(text) || after_path_option?(preposing)
283
+ complete_path(text)
284
+ elsif text.start_with?("--") || text.start_with?("-")
285
+ complete_option_names(task, text)
286
+ else
287
+ complete_path(text)
288
+ end
289
+ end
290
+
291
+ def complete_subcommand_args(subcommand_class, text, parts)
292
+ if parts.length <= 1
293
+ complete_subcommands(subcommand_class, text)
294
+ else
295
+ sub_cmd_name = parts[1]
296
+ sub_task = subcommand_class.tasks[sub_cmd_name]
297
+
298
+ if text.start_with?("--") || text.start_with?("-")
299
+ complete_option_names(sub_task, text)
300
+ else
301
+ if parts.length == 2 && !text.empty?
302
+ complete_subcommands(subcommand_class, text)
303
+ else
304
+ complete_path(text)
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ def complete_subcommands(subcommand_class, text)
311
+ return [] if text.nil?
312
+
313
+ command_names = subcommand_class.tasks.keys
314
+ command_names.select { |cmd| cmd.start_with?(text) }.sort
315
+ end
316
+
317
+ def path_like?(text)
318
+ text.match?(%r{^[~./]|/}) || text.match?(/\.(txt|rb|md|json|xml|yaml|yml|csv|log|html|css|js)$/i)
319
+ end
320
+
321
+ def after_path_option?(preposing)
322
+ preposing.match?(/(?:--file|--output|--input|--path|--dir|--directory|-f|-o|-i|-d)\s*$/)
323
+ end
324
+
325
+ def complete_path(text)
326
+ return [] if text.nil?
327
+
328
+ if text.empty?
329
+ matches = Dir.glob("*", File::FNM_DOTMATCH).select do |path|
330
+ basename = File.basename(path)
331
+ basename != "." && basename != ".."
332
+ end
333
+ return format_path_completions(matches, text)
334
+ end
335
+
336
+ expanded = text.start_with?("~") ? File.expand_path(text) : text
337
+
338
+ if text.end_with?("/")
339
+ dir = expanded
340
+ prefix = ""
341
+ elsif File.directory?(expanded) && !text.end_with?("/")
342
+ dir = File.dirname(expanded)
343
+ prefix = File.basename(expanded)
344
+ else
345
+ dir = File.dirname(expanded)
346
+ prefix = File.basename(expanded)
347
+ end
348
+
349
+ pattern = File.join(dir, "#{prefix}*")
350
+ matches = Dir.glob(pattern, File::FNM_DOTMATCH).select do |path|
351
+ basename = File.basename(path)
352
+ basename != "." && basename != ".."
353
+ end
354
+
355
+ format_path_completions(matches, text)
356
+ rescue => e
357
+ []
358
+ end
359
+
360
+ def format_path_completions(matches, original_text)
361
+ matches.map do |path|
362
+ display_path = File.directory?(path) && !path.end_with?("/") ? "#{path}/" : path
363
+ display_path = display_path.gsub(" ", '\ ')
364
+
365
+ if original_text.start_with?("~")
366
+ home = ENV["HOME"]
367
+ if display_path.start_with?(home)
368
+ "~#{display_path[home.length..-1]}"
369
+ else
370
+ display_path.sub(ENV["HOME"], "~")
371
+ end
372
+ elsif original_text.start_with?("./")
373
+ if display_path.start_with?(Dir.pwd)
374
+ rel_path = display_path.sub(/^#{Regexp.escape(Dir.pwd)}\//, "")
375
+ "./#{rel_path}"
376
+ else
377
+ display_path.start_with?("./") ? display_path : "./#{File.basename(display_path)}"
378
+ end
379
+ elsif original_text.start_with?("/")
380
+ display_path
381
+ else
382
+ dir = File.dirname(display_path)
383
+ if dir == "." || display_path.start_with?("./")
384
+ basename = File.basename(display_path)
385
+ basename += "/" if File.directory?(display_path.gsub('\ ', " ")) && !basename.end_with?("/")
386
+ basename
387
+ else
388
+ display_path.sub(/^#{Regexp.escape(Dir.pwd)}\//, "")
389
+ end
390
+ end
391
+ end.sort
392
+ end
393
+
394
+ def complete_option_names(task, text)
395
+ return [] unless task && task.options
396
+
397
+ options = []
398
+ task.options.each do |name, option|
399
+ options << "--#{name}"
400
+ if option.aliases
401
+ aliases = option.aliases.is_a?(Array) ? option.aliases : [option.aliases]
402
+ aliases.each { |a| options << a if a.start_with?("-") }
403
+ end
404
+ end
405
+
406
+ options.select { |opt| opt.start_with?(text) }.sort
407
+ end
408
+ end
409
+ end
410
+ end