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.
- checksums.yaml +4 -4
- data/README.md +143 -399
- data/docs/assets/thor-interactive-wide.png +0 -0
- data/docs/assets/thor-interactive.png +0 -0
- data/examples/completion_demo.rb +191 -0
- data/examples/tui_demo.rb +94 -0
- data/lib/thor/interactive/command.rb +12 -1
- data/lib/thor/interactive/command_dispatch.rb +410 -0
- data/lib/thor/interactive/shell.rb +41 -328
- data/lib/thor/interactive/tui/output_buffer.rb +87 -0
- data/lib/thor/interactive/tui/ratatui_shell.rb +606 -0
- data/lib/thor/interactive/tui/spinner.rb +107 -0
- data/lib/thor/interactive/tui/status_bar.rb +58 -0
- data/lib/thor/interactive/tui/text_input.rb +218 -0
- data/lib/thor/interactive/tui/theme.rb +158 -0
- data/lib/thor/interactive/tui.rb +14 -0
- data/lib/thor/interactive/version_constant.rb +1 -1
- metadata +15 -3
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "reline"
|
|
4
|
-
|
|
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[
|
|
45
|
-
nesting_level = ENV[
|
|
46
|
-
|
|
47
|
-
ENV[
|
|
48
|
-
ENV[
|
|
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
|
|
85
|
+
break # Exit on double Ctrl-C
|
|
87
86
|
end
|
|
88
|
-
next
|
|
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[
|
|
109
|
-
ENV[
|
|
105
|
+
ENV["THOR_INTERACTIVE_SESSION"] = "true"
|
|
106
|
+
ENV["THOR_INTERACTIVE_LEVEL"] = nesting_level.to_s
|
|
110
107
|
else
|
|
111
|
-
ENV.delete(
|
|
112
|
-
ENV.delete(
|
|
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
|
|
415
|
-
return true
|
|
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#{
|
|
143
|
+
print "\r#{" " * 80}\r"
|
|
431
144
|
else
|
|
432
145
|
# Default behavior
|
|
433
146
|
puts "^C"
|
|
434
147
|
end
|
|
435
|
-
|
|
436
|
-
false
|
|
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
|