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
|
@@ -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
|