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.
- 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/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 -517
- 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 +14 -3
|
@@ -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
|
|
@@ -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
|