ruby_rich 0.4.0 → 0.4.2
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 +32 -25
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class Sidebar
|
|
5
|
+
attr_accessor :width, :height
|
|
6
|
+
attr_reader :plan, :tasks
|
|
7
|
+
|
|
8
|
+
def initialize(plan: "", tasks: [])
|
|
9
|
+
@plan = plan
|
|
10
|
+
@tasks = tasks
|
|
11
|
+
@width = 0
|
|
12
|
+
@height = 0
|
|
13
|
+
@focused = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def focus
|
|
17
|
+
@focused = true
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def blur
|
|
22
|
+
@focused = false
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update_plan(text)
|
|
27
|
+
@plan = text.to_s
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set_tasks(tasks)
|
|
32
|
+
@tasks = tasks
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_task(label, status: :pending)
|
|
37
|
+
@tasks << { label: label, status: status }
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def render
|
|
42
|
+
plan_height = [(@height * 0.48).floor, 3].max
|
|
43
|
+
tasks_height = [@height - plan_height, 3].max
|
|
44
|
+
[
|
|
45
|
+
*panel_lines("Plan", @plan, plan_height),
|
|
46
|
+
*panel_lines("Tasks", render_tasks, tasks_height)
|
|
47
|
+
].first(@height)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def panel_lines(title, content, height)
|
|
53
|
+
panel = Panel.new(content, title: title, border_style: @focused ? :green : :blue, title_align: :left)
|
|
54
|
+
panel.width = @width
|
|
55
|
+
panel.height = height
|
|
56
|
+
panel.render
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def render_tasks
|
|
60
|
+
return "No active tasks" if @tasks.empty?
|
|
61
|
+
|
|
62
|
+
@tasks.map do |task|
|
|
63
|
+
case task
|
|
64
|
+
when Hash
|
|
65
|
+
"#{status_marker(task[:status])} #{task[:label]} #{AnsiCode.color(:black, true)}#{task[:status]}#{AnsiCode.reset}"
|
|
66
|
+
else
|
|
67
|
+
"• #{task}"
|
|
68
|
+
end
|
|
69
|
+
end.join("\n")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def status_marker(status)
|
|
73
|
+
case status
|
|
74
|
+
when :done, :completed
|
|
75
|
+
"#{AnsiCode.color(:green, true)}✓#{AnsiCode.reset}"
|
|
76
|
+
when :running, :in_progress
|
|
77
|
+
"#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset}"
|
|
78
|
+
when :failed, :error
|
|
79
|
+
"#{AnsiCode.color(:red, true)}!#{AnsiCode.reset}"
|
|
80
|
+
else
|
|
81
|
+
"#{AnsiCode.color(:black, true)}○#{AnsiCode.reset}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class SlashInput
|
|
5
|
+
attr_reader :value, :selected_index
|
|
6
|
+
|
|
7
|
+
DEFAULT_MENU_LIMIT = 8
|
|
8
|
+
|
|
9
|
+
def initialize(prompt: "> ", items: [], width: nil, menu_limit: DEFAULT_MENU_LIMIT, on_select: nil, on_submit: nil)
|
|
10
|
+
@prompt = prompt
|
|
11
|
+
@items = normalize_items(items)
|
|
12
|
+
@width = width
|
|
13
|
+
@menu_limit = menu_limit
|
|
14
|
+
@on_select = on_select
|
|
15
|
+
@on_submit = on_submit
|
|
16
|
+
@value = ""
|
|
17
|
+
@selected_index = 0
|
|
18
|
+
@menu_open = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def attach(layout, priority: 100)
|
|
22
|
+
[:string, :backspace, :up, :down, :enter, :escape].each do |event_name|
|
|
23
|
+
layout.key(event_name, priority) do |event_data, live|
|
|
24
|
+
handle_key(event_data, live)
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_key(event_data, live = nil)
|
|
32
|
+
case event_data[:name]
|
|
33
|
+
when :string
|
|
34
|
+
append(event_data[:value].to_s)
|
|
35
|
+
when :backspace
|
|
36
|
+
backspace
|
|
37
|
+
when :up
|
|
38
|
+
move_selection(-1)
|
|
39
|
+
when :down
|
|
40
|
+
move_selection(1)
|
|
41
|
+
when :enter
|
|
42
|
+
enter(live)
|
|
43
|
+
when :escape
|
|
44
|
+
close_menu
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render
|
|
49
|
+
lines = [input_line]
|
|
50
|
+
return fit_lines(lines) unless menu_open?
|
|
51
|
+
|
|
52
|
+
matches = filtered_items.first(@menu_limit)
|
|
53
|
+
if matches.empty?
|
|
54
|
+
lines << "#{AnsiCode.color(:yellow)} No matches#{AnsiCode.reset}"
|
|
55
|
+
else
|
|
56
|
+
matches.each_with_index do |item, index|
|
|
57
|
+
lines << render_item(item, index == @selected_index)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
fit_lines(lines)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def menu_open?
|
|
65
|
+
@menu_open
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def append(text)
|
|
71
|
+
@value += text
|
|
72
|
+
@menu_open = true if text == "/" || @menu_open
|
|
73
|
+
clamp_selection
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def backspace
|
|
77
|
+
@value = @value[0...-1].to_s
|
|
78
|
+
@menu_open = false unless @value.include?("/")
|
|
79
|
+
clamp_selection
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def enter(live)
|
|
83
|
+
if @menu_open
|
|
84
|
+
select_current(live)
|
|
85
|
+
else
|
|
86
|
+
submitted_value = @value
|
|
87
|
+
@on_submit&.call(submitted_value, live)
|
|
88
|
+
reset_input
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def select_current(live)
|
|
93
|
+
item = filtered_items[@selected_index]
|
|
94
|
+
return unless item
|
|
95
|
+
|
|
96
|
+
@value = replace_query_with(item[:value])
|
|
97
|
+
@menu_open = false
|
|
98
|
+
@selected_index = 0
|
|
99
|
+
@on_select&.call(item, live)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def move_selection(delta)
|
|
103
|
+
return unless @menu_open
|
|
104
|
+
|
|
105
|
+
count = [filtered_items.size, @menu_limit].min
|
|
106
|
+
return if count.zero?
|
|
107
|
+
|
|
108
|
+
@selected_index = (@selected_index + delta) % count
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def close_menu
|
|
112
|
+
@menu_open = false
|
|
113
|
+
@selected_index = 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def reset_input
|
|
117
|
+
@value = ""
|
|
118
|
+
close_menu
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def filtered_items
|
|
122
|
+
query = current_query.downcase
|
|
123
|
+
return @items if query.empty?
|
|
124
|
+
|
|
125
|
+
@items.select do |item|
|
|
126
|
+
item[:label].downcase.include?(query) || item[:value].downcase.include?(query)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def current_query
|
|
131
|
+
slash_index = @value.rindex("/")
|
|
132
|
+
return "" unless slash_index
|
|
133
|
+
|
|
134
|
+
@value[(slash_index + 1)..].to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def replace_query_with(replacement)
|
|
138
|
+
slash_index = @value.rindex("/")
|
|
139
|
+
return @value unless slash_index
|
|
140
|
+
|
|
141
|
+
@value[0...slash_index].to_s + replacement
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def clamp_selection
|
|
145
|
+
count = [filtered_items.size, @menu_limit].min
|
|
146
|
+
@selected_index = 0 if count.zero? || @selected_index >= count
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def input_line
|
|
150
|
+
"#{@prompt}#{@value}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def render_item(item, selected)
|
|
154
|
+
marker = selected ? ">" : " "
|
|
155
|
+
color = selected ? AnsiCode.inverse : AnsiCode.color(:white)
|
|
156
|
+
description = item[:description].to_s
|
|
157
|
+
suffix = description.empty? ? "" : " #{AnsiCode.color(:black, true)}#{description}#{AnsiCode.reset}"
|
|
158
|
+
" #{color}#{marker} #{item[:label]}#{AnsiCode.reset}#{suffix}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def fit_lines(lines)
|
|
162
|
+
return lines unless @width
|
|
163
|
+
|
|
164
|
+
lines.map { |line| truncate_display(line, @width) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def truncate_display(line, max_width)
|
|
168
|
+
return line if line.display_width <= max_width
|
|
169
|
+
|
|
170
|
+
result = ""
|
|
171
|
+
width = 0
|
|
172
|
+
line.each_char do |char|
|
|
173
|
+
char_width = char.display_width
|
|
174
|
+
break if width + char_width > max_width
|
|
175
|
+
|
|
176
|
+
result += char
|
|
177
|
+
width += char_width
|
|
178
|
+
end
|
|
179
|
+
result
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def normalize_items(items)
|
|
183
|
+
items.map do |item|
|
|
184
|
+
case item
|
|
185
|
+
when Hash
|
|
186
|
+
{
|
|
187
|
+
label: item.fetch(:label, item.fetch("label", item[:value] || item["value"])).to_s,
|
|
188
|
+
value: item.fetch(:value, item.fetch("value", item[:label] || item["label"])).to_s,
|
|
189
|
+
description: item.fetch(:description, item.fetch("description", "")).to_s
|
|
190
|
+
}
|
|
191
|
+
else
|
|
192
|
+
{ label: item.to_s, value: item.to_s, description: "" }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/ruby_rich/table.rb
CHANGED
|
@@ -219,23 +219,23 @@ module RubyRich
|
|
|
219
219
|
def render_multiline_row(row, column_widths)
|
|
220
220
|
# Prepare each cell's lines
|
|
221
221
|
row_lines = row.map.with_index do |cell, i|
|
|
222
|
-
#
|
|
222
|
+
# Get cell style sequence
|
|
223
223
|
style_sequence = cell.render.match(/\e\[[0-9;]*m/)&.to_s || ""
|
|
224
224
|
reset_sequence = style_sequence.empty? ? "" : "\e[0m"
|
|
225
|
-
|
|
226
|
-
#
|
|
225
|
+
|
|
226
|
+
# Split into multiple lines while preserving styles
|
|
227
227
|
cell_content = cell.render.split("\n")
|
|
228
|
-
|
|
229
|
-
#
|
|
230
|
-
cell_content.map! { |line|
|
|
231
|
-
line = line.gsub(/\e\[[0-9;]*m/, '') #
|
|
232
|
-
style_sequence + line + reset_sequence
|
|
228
|
+
|
|
229
|
+
# Add style to each line
|
|
230
|
+
cell_content.map! { |line|
|
|
231
|
+
line = line.gsub(/\e\[[0-9;]*m/, '') # Remove existing style sequences
|
|
232
|
+
style_sequence + line + reset_sequence
|
|
233
233
|
}
|
|
234
|
-
|
|
235
|
-
#
|
|
234
|
+
|
|
235
|
+
# Pad to specified row height
|
|
236
236
|
padded_content = cell_content + [" "] * [@row_height - cell_content.size, 0].max
|
|
237
|
-
|
|
238
|
-
#
|
|
237
|
+
|
|
238
|
+
# Apply alignment to each line while preserving styles
|
|
239
239
|
padded_content.map { |line| align_cell(line, column_widths[i]) }
|
|
240
240
|
end
|
|
241
241
|
|