ruby_rich 0.4.0 → 0.4.1
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/lib/ruby_rich/agent_shell.rb +254 -0
- data/lib/ruby_rich/ansi_code.rb +46 -0
- data/lib/ruby_rich/app_shell.rb +374 -0
- data/lib/ruby_rich/attachment.rb +25 -0
- data/lib/ruby_rich/composer.rb +512 -0
- data/lib/ruby_rich/console.rb +174 -25
- data/lib/ruby_rich/dialog.rb +2 -1
- data/lib/ruby_rich/event.rb +29 -0
- data/lib/ruby_rich/focus_manager.rb +77 -0
- data/lib/ruby_rich/layout.rb +117 -29
- data/lib/ruby_rich/line_editor.rb +325 -0
- data/lib/ruby_rich/live.rb +100 -19
- data/lib/ruby_rich/markdown.rb +100 -230
- data/lib/ruby_rich/panel.rb +1 -1
- data/lib/ruby_rich/print.rb +6 -6
- data/lib/ruby_rich/progress_manager.rb +150 -0
- data/lib/ruby_rich/sidebar.rb +85 -0
- data/lib/ruby_rich/slash_input.rb +197 -0
- data/lib/ruby_rich/table.rb +12 -12
- data/lib/ruby_rich/terminal.rb +510 -0
- data/lib/ruby_rich/text.rb +1 -1
- data/lib/ruby_rich/theme.rb +96 -0
- data/lib/ruby_rich/tool_block.rb +92 -0
- data/lib/ruby_rich/transcript.rb +553 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +468 -0
- data/lib/ruby_rich.rb +38 -13
- metadata +23 -22
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class LineEditor
|
|
5
|
+
WORD_PATTERN = /[^\s]+/
|
|
6
|
+
|
|
7
|
+
attr_reader :history, :cursor
|
|
8
|
+
attr_accessor :multiline, :history_path, :max_history
|
|
9
|
+
|
|
10
|
+
def initialize(multiline: false, history: [], history_path: nil, max_history: 200)
|
|
11
|
+
@multiline = multiline
|
|
12
|
+
@history_path = history_path
|
|
13
|
+
@max_history = max_history
|
|
14
|
+
@history = []
|
|
15
|
+
@history_index = nil
|
|
16
|
+
@chars = []
|
|
17
|
+
@cursor = 0
|
|
18
|
+
load_history
|
|
19
|
+
history.each { |item| add_history(item, persist: false) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def value
|
|
23
|
+
@chars.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def value=(text)
|
|
27
|
+
@chars = text.to_s.chars
|
|
28
|
+
@cursor = @chars.length
|
|
29
|
+
@history_index = nil
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def empty?
|
|
34
|
+
@chars.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def clear
|
|
38
|
+
@chars.clear
|
|
39
|
+
@cursor = 0
|
|
40
|
+
@history_index = nil
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def insert(text)
|
|
45
|
+
incoming = normalize_insert_text(text)
|
|
46
|
+
return self if incoming.empty?
|
|
47
|
+
|
|
48
|
+
new_chars = incoming.chars
|
|
49
|
+
@chars.insert(@cursor, *new_chars)
|
|
50
|
+
@cursor += new_chars.length
|
|
51
|
+
@history_index = nil
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def newline
|
|
56
|
+
insert("\n") if @multiline
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def backspace
|
|
61
|
+
return false if @cursor.zero?
|
|
62
|
+
|
|
63
|
+
@chars.delete_at(@cursor - 1)
|
|
64
|
+
@cursor -= 1
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def delete
|
|
69
|
+
return false if @cursor >= @chars.length
|
|
70
|
+
|
|
71
|
+
@chars.delete_at(@cursor)
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def move_left
|
|
76
|
+
@cursor = [@cursor - 1, 0].max
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def move_right
|
|
81
|
+
@cursor = [@cursor + 1, @chars.length].min
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def home
|
|
86
|
+
@cursor = current_line_start
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def end
|
|
91
|
+
@cursor = current_line_end
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def buffer_start
|
|
96
|
+
@cursor = 0
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def buffer_end
|
|
101
|
+
@cursor = @chars.length
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def move_up
|
|
106
|
+
return history_previous if !@multiline || single_empty_line? || @history_index
|
|
107
|
+
|
|
108
|
+
move_vertical(-1)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def move_down
|
|
112
|
+
return history_next if !@multiline || single_empty_line? || @history_index
|
|
113
|
+
|
|
114
|
+
move_vertical(1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def kill_to_end
|
|
118
|
+
return false if @cursor >= @chars.length
|
|
119
|
+
|
|
120
|
+
@chars.slice!(@cursor...current_line_end)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def kill_to_start
|
|
125
|
+
start = current_line_start
|
|
126
|
+
return false if @cursor <= start
|
|
127
|
+
|
|
128
|
+
@chars.slice!(start...@cursor)
|
|
129
|
+
@cursor = start
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def kill_word_back
|
|
134
|
+
return false if @cursor.zero?
|
|
135
|
+
|
|
136
|
+
start = previous_word_start
|
|
137
|
+
@chars.slice!(start...@cursor)
|
|
138
|
+
@cursor = start
|
|
139
|
+
true
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def submit_value
|
|
143
|
+
submitted = value
|
|
144
|
+
add_history(submitted) unless submitted.strip.empty?
|
|
145
|
+
clear
|
|
146
|
+
submitted
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def add_history(text, persist: true)
|
|
150
|
+
item = text.to_s
|
|
151
|
+
return self if item.strip.empty?
|
|
152
|
+
|
|
153
|
+
@history.delete(item)
|
|
154
|
+
@history << item
|
|
155
|
+
@history.shift while @history.length > @max_history
|
|
156
|
+
persist_history if persist && @history_path
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def lines
|
|
161
|
+
text = value
|
|
162
|
+
text.empty? ? [""] : text.split("\n", -1)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def cursor_line_col
|
|
166
|
+
before = @chars[0...@cursor].join
|
|
167
|
+
parts = before.empty? ? [""] : before.split("\n", -1)
|
|
168
|
+
[parts.length - 1, parts.last.to_s.chars.length]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def render_lines(width:, placeholder: nil, focused: true)
|
|
172
|
+
content = value
|
|
173
|
+
return [placeholder.to_s] if content.empty? && placeholder
|
|
174
|
+
|
|
175
|
+
rendered = []
|
|
176
|
+
line_index, col = cursor_line_col
|
|
177
|
+
lines.each_with_index do |line, index|
|
|
178
|
+
marker_col = focused && index == line_index ? col : nil
|
|
179
|
+
rendered.concat(wrap_line_with_cursor(line, width, marker_col))
|
|
180
|
+
end
|
|
181
|
+
rendered.empty? ? [""] : rendered
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def normalize_insert_text(text)
|
|
187
|
+
incoming = text.to_s.gsub(/\r\n?/, "\n")
|
|
188
|
+
return incoming if @multiline
|
|
189
|
+
|
|
190
|
+
incoming.gsub(/\n/, " ")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def current_line_start
|
|
194
|
+
index = @cursor - 1
|
|
195
|
+
while index >= 0
|
|
196
|
+
return index + 1 if @chars[index] == "\n"
|
|
197
|
+
|
|
198
|
+
index -= 1
|
|
199
|
+
end
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def current_line_end
|
|
204
|
+
index = @cursor
|
|
205
|
+
while index < @chars.length
|
|
206
|
+
return index if @chars[index] == "\n"
|
|
207
|
+
|
|
208
|
+
index += 1
|
|
209
|
+
end
|
|
210
|
+
@chars.length
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def move_vertical(delta)
|
|
214
|
+
current_line, current_col = cursor_line_col
|
|
215
|
+
target_line = current_line + delta
|
|
216
|
+
return self if target_line.negative? || target_line >= lines.length
|
|
217
|
+
|
|
218
|
+
@cursor = index_for_line_col(target_line, current_col)
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def index_for_line_col(line_index, col)
|
|
223
|
+
index = 0
|
|
224
|
+
lines.each_with_index do |line, current|
|
|
225
|
+
line_length = line.chars.length
|
|
226
|
+
return index + [col, line_length].min if current == line_index
|
|
227
|
+
|
|
228
|
+
index += line_length + 1
|
|
229
|
+
end
|
|
230
|
+
@chars.length
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def previous_word_start
|
|
234
|
+
index = @cursor
|
|
235
|
+
index -= 1 while index.positive? && whitespace?(@chars[index - 1])
|
|
236
|
+
index -= 1 while index.positive? && !whitespace?(@chars[index - 1])
|
|
237
|
+
index
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def whitespace?(char)
|
|
241
|
+
char.to_s.match?(/\s/)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def single_empty_line?
|
|
245
|
+
value.empty?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def history_previous
|
|
249
|
+
return self if @history.empty?
|
|
250
|
+
|
|
251
|
+
@history_index = @history_index ? [@history_index - 1, 0].max : @history.length - 1
|
|
252
|
+
self.value = @history[@history_index]
|
|
253
|
+
@history_index = @history.index(value)
|
|
254
|
+
self
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def history_next
|
|
258
|
+
return self unless @history_index
|
|
259
|
+
|
|
260
|
+
@history_index += 1
|
|
261
|
+
if @history_index >= @history.length
|
|
262
|
+
clear
|
|
263
|
+
else
|
|
264
|
+
self.value = @history[@history_index]
|
|
265
|
+
@history_index = @history.index(value)
|
|
266
|
+
end
|
|
267
|
+
self
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def load_history
|
|
271
|
+
return unless @history_path && File.file?(@history_path)
|
|
272
|
+
|
|
273
|
+
File.readlines(@history_path, chomp: true).each { |line| add_history(line, persist: false) }
|
|
274
|
+
rescue IOError, SystemCallError
|
|
275
|
+
nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def persist_history
|
|
279
|
+
File.write(@history_path, @history.join("\n") + "\n")
|
|
280
|
+
rescue IOError, SystemCallError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def wrap_line_with_cursor(line, width, marker_col)
|
|
285
|
+
width = [width, 1].max
|
|
286
|
+
segments = []
|
|
287
|
+
current = +""
|
|
288
|
+
current_width = 0
|
|
289
|
+
chars = line.chars
|
|
290
|
+
chars.each_with_index do |char, index|
|
|
291
|
+
if marker_col == index
|
|
292
|
+
cursor = cursor_marker
|
|
293
|
+
if current_width + 1 > width
|
|
294
|
+
segments << current
|
|
295
|
+
current = +""
|
|
296
|
+
current_width = 0
|
|
297
|
+
end
|
|
298
|
+
current << cursor
|
|
299
|
+
current_width += 1
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
char_width = display_width(char)
|
|
303
|
+
if current_width + char_width > width
|
|
304
|
+
segments << current
|
|
305
|
+
current = +""
|
|
306
|
+
current_width = 0
|
|
307
|
+
end
|
|
308
|
+
current << char
|
|
309
|
+
current_width += char_width
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
current << cursor_marker if marker_col == chars.length
|
|
313
|
+
segments << current
|
|
314
|
+
segments
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def cursor_marker
|
|
318
|
+
"#{AnsiCode.color(:blue, true)}_#{AnsiCode.reset}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def display_width(char)
|
|
322
|
+
Unicode::DisplayWidth.of(char)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
data/lib/ruby_rich/live.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
require 'io/console'
|
|
2
|
+
require 'fileutils'
|
|
2
3
|
require "tty-screen"
|
|
3
4
|
require "tty-cursor"
|
|
5
|
+
require "thread"
|
|
6
|
+
require_relative "terminal"
|
|
4
7
|
|
|
5
8
|
module RubyRich
|
|
6
9
|
|
|
@@ -10,15 +13,14 @@ module RubyRich
|
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def print_with_pos(x,y,char)
|
|
13
|
-
print "\e[
|
|
14
|
-
print "\e[#{y};#{x}H" # 移动光标到左上角
|
|
16
|
+
print "\e[#{y};#{x}H" # Move cursor to top-left
|
|
15
17
|
print char
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def draw(buffer)
|
|
19
21
|
unless @cache
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
RubyRich::Terminal.clear
|
|
23
|
+
draw_full(buffer)
|
|
22
24
|
@cache = buffer
|
|
23
25
|
else
|
|
24
26
|
buffer.each_with_index do |arr, y|
|
|
@@ -31,35 +33,48 @@ module RubyRich
|
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def draw_full(buffer)
|
|
40
|
+
buffer.each_with_index do |line, y|
|
|
41
|
+
print_with_pos(1, y + 1, line.compact.join(""))
|
|
42
|
+
end
|
|
43
|
+
$stdout.flush
|
|
44
|
+
end
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
class Live
|
|
37
48
|
attr_accessor :params, :app, :listening, :layout
|
|
38
49
|
class << self
|
|
39
|
-
def start(layout, refresh_rate: 30, &proc)
|
|
40
|
-
setup_terminal
|
|
50
|
+
def start(layout, refresh_rate: 30, mouse: false, alt_screen: false, autowrap: false, &proc)
|
|
51
|
+
setup_terminal(mouse: mouse, alt_screen: alt_screen, autowrap: autowrap)
|
|
41
52
|
live = new(layout, refresh_rate)
|
|
53
|
+
live.mouse = mouse
|
|
42
54
|
proc.call(live) if proc
|
|
43
55
|
live.run(proc)
|
|
56
|
+
rescue Interrupt
|
|
57
|
+
live&.stop
|
|
44
58
|
rescue => e
|
|
45
59
|
puts e.message
|
|
46
60
|
ensure
|
|
47
|
-
|
|
61
|
+
live&.shutdown
|
|
62
|
+
restore_terminal(mouse: mouse, alt_screen: alt_screen)
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
private
|
|
51
66
|
|
|
52
|
-
def setup_terminal
|
|
53
|
-
|
|
54
|
-
system("stty -echo")
|
|
67
|
+
def setup_terminal(mouse: false, alt_screen: false, autowrap: false)
|
|
68
|
+
RubyRich::Terminal.setup(mouse: mouse, alt_screen: alt_screen, autowrap: autowrap)
|
|
55
69
|
end
|
|
56
70
|
|
|
57
|
-
def restore_terminal
|
|
58
|
-
|
|
59
|
-
print TTY::Cursor.show
|
|
71
|
+
def restore_terminal(mouse: false, alt_screen: false)
|
|
72
|
+
RubyRich::Terminal.restore(mouse: mouse, alt_screen: alt_screen, autowrap: true)
|
|
60
73
|
end
|
|
61
74
|
end
|
|
62
75
|
|
|
76
|
+
attr_accessor :mouse
|
|
77
|
+
|
|
63
78
|
def initialize(layout, refresh_rate)
|
|
64
79
|
@layout = layout
|
|
65
80
|
@layout.live = self
|
|
@@ -69,23 +84,49 @@ module RubyRich
|
|
|
69
84
|
@cursor = TTY::Cursor
|
|
70
85
|
@render = CacheRender.new
|
|
71
86
|
@console = RubyRich::Console.new
|
|
87
|
+
@event_queue = Queue.new
|
|
88
|
+
@action_queue = Queue.new
|
|
89
|
+
@event_thread = nil
|
|
72
90
|
@params = {}
|
|
91
|
+
if (log_path = ENV["RUBY_RICH_LOG"]).to_s.strip != ""
|
|
92
|
+
FileUtils.mkdir_p(File.dirname(log_path))
|
|
93
|
+
RubyRich.logger = Logger.new(log_path)
|
|
94
|
+
end
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
def run(proc = nil)
|
|
98
|
+
start_event_thread if @listening
|
|
76
99
|
while @running
|
|
100
|
+
drain_action_queue
|
|
101
|
+
break unless @running
|
|
102
|
+
|
|
77
103
|
render_frame
|
|
78
|
-
if @listening
|
|
79
|
-
event_data = @console.get_key()
|
|
80
|
-
@layout.notify_listeners(event_data)
|
|
81
|
-
end
|
|
104
|
+
drain_event_queue if @listening
|
|
82
105
|
sleep 1.0 / @refresh_rate
|
|
83
106
|
end
|
|
107
|
+
rescue Interrupt
|
|
108
|
+
@running = false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def post(&block)
|
|
112
|
+
return false unless block
|
|
113
|
+
return false unless @running
|
|
114
|
+
|
|
115
|
+
@action_queue << block
|
|
116
|
+
true
|
|
84
117
|
end
|
|
85
118
|
|
|
86
119
|
def stop
|
|
87
120
|
@running = false
|
|
88
|
-
|
|
121
|
+
shutdown
|
|
122
|
+
RubyRich::Terminal.clear
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def shutdown
|
|
126
|
+
if @event_thread&.alive?
|
|
127
|
+
@event_thread.kill
|
|
128
|
+
@event_thread = nil
|
|
129
|
+
end
|
|
89
130
|
end
|
|
90
131
|
|
|
91
132
|
def move_cursor(x,y)
|
|
@@ -102,6 +143,46 @@ module RubyRich
|
|
|
102
143
|
|
|
103
144
|
private
|
|
104
145
|
|
|
146
|
+
def start_event_thread
|
|
147
|
+
return if @event_thread&.alive?
|
|
148
|
+
|
|
149
|
+
@event_thread = Thread.new do
|
|
150
|
+
while @running
|
|
151
|
+
begin
|
|
152
|
+
event_data = @console.get_event
|
|
153
|
+
@event_queue << event_data if event_data
|
|
154
|
+
rescue IOError, SystemCallError
|
|
155
|
+
break
|
|
156
|
+
rescue Interrupt
|
|
157
|
+
@running = false
|
|
158
|
+
break
|
|
159
|
+
rescue => e
|
|
160
|
+
RubyRich.logger.error("Input event failed: #{e.class}: #{e.message}")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def drain_event_queue
|
|
167
|
+
until @event_queue.empty?
|
|
168
|
+
event_data = @event_queue.pop(true)
|
|
169
|
+
@layout.notify_listeners(event_data)
|
|
170
|
+
end
|
|
171
|
+
rescue ThreadError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def drain_action_queue
|
|
176
|
+
until @action_queue.empty?
|
|
177
|
+
action = @action_queue.pop(true)
|
|
178
|
+
action.call(self)
|
|
179
|
+
end
|
|
180
|
+
rescue ThreadError
|
|
181
|
+
nil
|
|
182
|
+
rescue => e
|
|
183
|
+
RubyRich.logger.error("UI action failed: #{e.class}: #{e.message}")
|
|
184
|
+
end
|
|
185
|
+
|
|
105
186
|
def render_frame
|
|
106
187
|
@layout.calculate_dimensions(terminal_width, terminal_height)
|
|
107
188
|
@render.draw(@layout.render_to_buffer)
|
|
@@ -115,4 +196,4 @@ module RubyRich
|
|
|
115
196
|
TTY::Screen.height
|
|
116
197
|
end
|
|
117
198
|
end
|
|
118
|
-
end
|
|
199
|
+
end
|