ruby_rich 0.3.1 → 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/columns.rb +244 -0
- data/lib/ruby_rich/composer.rb +512 -0
- data/lib/ruby_rich/console.rb +191 -29
- 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 +213 -0
- data/lib/ruby_rich/panel.rb +1 -1
- data/lib/ruby_rich/print.rb +6 -6
- data/lib/ruby_rich/progress_bar.rb +229 -11
- 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/status.rb +246 -0
- data/lib/ruby_rich/syntax.rb +171 -0
- data/lib/ruby_rich/table.rb +167 -21
- data/lib/ruby_rich/terminal.rb +510 -0
- data/lib/ruby_rich/text.rb +112 -2
- 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/tree.rb +200 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +468 -0
- data/lib/ruby_rich.rb +65 -10
- metadata +93 -3
|
@@ -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
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
module RubyRich
|
|
2
|
+
class Status
|
|
3
|
+
# 状态指示器类型
|
|
4
|
+
INDICATORS = {
|
|
5
|
+
# 简单状态
|
|
6
|
+
success: { symbol: '✅', color: "\e[92m", text: 'Success' },
|
|
7
|
+
error: { symbol: '❌', color: "\e[91m", text: 'Error' },
|
|
8
|
+
warning: { symbol: '⚠️', color: "\e[93m", text: 'Warning' },
|
|
9
|
+
info: { symbol: 'ℹ️', color: "\e[94m", text: 'Info' },
|
|
10
|
+
|
|
11
|
+
# 进度状态
|
|
12
|
+
pending: { symbol: '⏳', color: "\e[93m", text: 'Pending' },
|
|
13
|
+
running: { symbol: '🏃', color: "\e[94m", text: 'Running' },
|
|
14
|
+
completed: { symbol: '✅', color: "\e[92m", text: 'Completed' },
|
|
15
|
+
failed: { symbol: '💥', color: "\e[91m", text: 'Failed' },
|
|
16
|
+
|
|
17
|
+
# 系统状态
|
|
18
|
+
online: { symbol: '🟢', color: "\e[92m", text: 'Online' },
|
|
19
|
+
offline: { symbol: '🔴', color: "\e[91m", text: 'Offline' },
|
|
20
|
+
maintenance: { symbol: '🔧', color: "\e[93m", text: 'Maintenance' },
|
|
21
|
+
|
|
22
|
+
# 安全状态
|
|
23
|
+
secure: { symbol: '🔒', color: "\e[92m", text: 'Secure' },
|
|
24
|
+
insecure: { symbol: '🔓', color: "\e[91m", text: 'Insecure' },
|
|
25
|
+
|
|
26
|
+
# 等级状态
|
|
27
|
+
low: { symbol: '🔵', color: "\e[94m", text: 'Low' },
|
|
28
|
+
medium: { symbol: '🟡', color: "\e[93m", text: 'Medium' },
|
|
29
|
+
high: { symbol: '🔴', color: "\e[91m", text: 'High' },
|
|
30
|
+
critical: { symbol: '💀', color: "\e[95m", text: 'Critical' }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# 加载动画帧
|
|
34
|
+
SPINNER_FRAMES = {
|
|
35
|
+
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
36
|
+
line: ['|', '/', '-', '\\'],
|
|
37
|
+
arrow: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'],
|
|
38
|
+
bounce: ['⠁', '⠂', '⠄', '⠂'],
|
|
39
|
+
pulse: ['●', '◐', '◑', '◒', '◓', '◔', '◕', '○'],
|
|
40
|
+
clock: ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛']
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
def self.indicator(type, text: nil, show_text: true, colorize: true)
|
|
44
|
+
config = INDICATORS[type.to_sym]
|
|
45
|
+
return "Unknown status: #{type}" unless config
|
|
46
|
+
|
|
47
|
+
symbol = config[:symbol]
|
|
48
|
+
color = colorize ? config[:color] : ""
|
|
49
|
+
reset = colorize ? "\e[0m" : ""
|
|
50
|
+
status_text = text || config[:text]
|
|
51
|
+
|
|
52
|
+
if show_text
|
|
53
|
+
"#{color}#{symbol} #{status_text}#{reset}"
|
|
54
|
+
else
|
|
55
|
+
"#{color}#{symbol}#{reset}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.spinner(type: :dots, text: 'Loading...', delay: 0.1)
|
|
60
|
+
frames = SPINNER_FRAMES[type.to_sym] || SPINNER_FRAMES[:dots]
|
|
61
|
+
|
|
62
|
+
Thread.new do
|
|
63
|
+
frame_index = 0
|
|
64
|
+
loop do
|
|
65
|
+
print "\r\e[K\e[94m#{frames[frame_index]}\e[0m #{text}"
|
|
66
|
+
$stdout.flush
|
|
67
|
+
sleep delay
|
|
68
|
+
frame_index = (frame_index + 1) % frames.length
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.stop_spinner(final_message: nil)
|
|
74
|
+
if final_message
|
|
75
|
+
print "\r\e[K#{final_message}\n"
|
|
76
|
+
else
|
|
77
|
+
print "\r\e[K"
|
|
78
|
+
end
|
|
79
|
+
$stdout.flush
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 静态进度条
|
|
83
|
+
def self.progress_bar(current, total, width: 30, style: :filled)
|
|
84
|
+
percentage = (current.to_f / total * 100).round(1)
|
|
85
|
+
filled_width = (current.to_f / total * width).round
|
|
86
|
+
|
|
87
|
+
case style
|
|
88
|
+
when :filled
|
|
89
|
+
filled = '█' * filled_width
|
|
90
|
+
empty = '░' * (width - filled_width)
|
|
91
|
+
bar = "#{filled}#{empty}"
|
|
92
|
+
when :blocks
|
|
93
|
+
filled = '■' * filled_width
|
|
94
|
+
empty = '□' * (width - filled_width)
|
|
95
|
+
bar = "#{filled}#{empty}"
|
|
96
|
+
when :dots
|
|
97
|
+
filled = '●' * filled_width
|
|
98
|
+
empty = '○' * (width - filled_width)
|
|
99
|
+
bar = "#{filled}#{empty}"
|
|
100
|
+
else
|
|
101
|
+
filled = '=' * filled_width
|
|
102
|
+
empty = '-' * (width - filled_width)
|
|
103
|
+
bar = "#{filled}#{empty}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
"\e[92m[#{bar}]\e[0m #{percentage}% (#{current}/#{total})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# 状态板
|
|
110
|
+
class StatusBoard
|
|
111
|
+
def initialize(width: 60)
|
|
112
|
+
@width = width
|
|
113
|
+
@items = []
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def add_item(label, status, description: nil)
|
|
117
|
+
@items << {
|
|
118
|
+
label: label,
|
|
119
|
+
status: status,
|
|
120
|
+
description: description
|
|
121
|
+
}
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def render(show_descriptions: true, align_status: :right)
|
|
126
|
+
lines = []
|
|
127
|
+
lines << "┌#{'─' * (@width - 2)}┐"
|
|
128
|
+
|
|
129
|
+
@items.each do |item|
|
|
130
|
+
label = item[:label]
|
|
131
|
+
status_text = RubyRich::Status.indicator(item[:status])
|
|
132
|
+
description = item[:description]
|
|
133
|
+
|
|
134
|
+
# 计算实际显示宽度(排除 ANSI 代码)
|
|
135
|
+
status_display_width = status_text.gsub(/\e\[[0-9;]*m/, '').length
|
|
136
|
+
|
|
137
|
+
case align_status
|
|
138
|
+
when :right
|
|
139
|
+
padding = @width - 4 - label.length - status_display_width
|
|
140
|
+
padding = [padding, 1].max
|
|
141
|
+
main_line = "│ #{label}#{' ' * padding}#{status_text} │"
|
|
142
|
+
when :left
|
|
143
|
+
padding = @width - 4 - label.length - status_display_width
|
|
144
|
+
padding = [padding, 1].max
|
|
145
|
+
main_line = "│ #{status_text} #{label}#{' ' * padding}│"
|
|
146
|
+
else # center
|
|
147
|
+
total_content = label.length + status_display_width + 1
|
|
148
|
+
left_padding = [(@width - 2 - total_content) / 2, 1].max
|
|
149
|
+
right_padding = @width - 2 - total_content - left_padding
|
|
150
|
+
main_line = "│#{' ' * left_padding}#{label} #{status_text}#{' ' * right_padding}│"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
lines << main_line
|
|
154
|
+
|
|
155
|
+
if show_descriptions && description
|
|
156
|
+
desc_lines = wrap_text(description, @width - 4)
|
|
157
|
+
desc_lines.each do |desc_line|
|
|
158
|
+
padding = @width - 4 - desc_line.length
|
|
159
|
+
lines << "│ \e[90m#{desc_line}#{' ' * padding}\e[0m │"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
lines << "└#{'─' * (@width - 2)}┘"
|
|
165
|
+
lines.join("\n")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def wrap_text(text, max_width)
|
|
171
|
+
words = text.split(' ')
|
|
172
|
+
lines = []
|
|
173
|
+
current_line = ''
|
|
174
|
+
|
|
175
|
+
words.each do |word|
|
|
176
|
+
if (current_line + ' ' + word).length <= max_width
|
|
177
|
+
current_line += current_line.empty? ? word : ' ' + word
|
|
178
|
+
else
|
|
179
|
+
lines << current_line unless current_line.empty?
|
|
180
|
+
current_line = word
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
lines << current_line unless current_line.empty?
|
|
185
|
+
lines
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# 实时状态监控
|
|
190
|
+
class Monitor
|
|
191
|
+
def initialize(refresh_rate: 1.0)
|
|
192
|
+
@refresh_rate = refresh_rate
|
|
193
|
+
@items = {}
|
|
194
|
+
@running = false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def add_item(key, label, &block)
|
|
198
|
+
@items[key] = {
|
|
199
|
+
label: label,
|
|
200
|
+
block: block
|
|
201
|
+
}
|
|
202
|
+
self
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def start
|
|
206
|
+
@running = true
|
|
207
|
+
|
|
208
|
+
Thread.new do
|
|
209
|
+
while @running
|
|
210
|
+
system('clear')
|
|
211
|
+
puts render_status
|
|
212
|
+
sleep @refresh_rate
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def stop
|
|
218
|
+
@running = false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def render_status
|
|
224
|
+
lines = []
|
|
225
|
+
lines << "\e[1m\e[96mSystem Status Monitor\e[0m"
|
|
226
|
+
lines << "─" * 40
|
|
227
|
+
lines << ""
|
|
228
|
+
|
|
229
|
+
@items.each do |key, item|
|
|
230
|
+
begin
|
|
231
|
+
status = item[:block].call
|
|
232
|
+
status_indicator = RubyRich::Status.indicator(status)
|
|
233
|
+
lines << "#{item[:label]}: #{status_indicator}"
|
|
234
|
+
rescue => e
|
|
235
|
+
error_indicator = RubyRich::Status.indicator(:error, text: "Error: #{e.message}")
|
|
236
|
+
lines << "#{item[:label]}: #{error_indicator}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
lines << ""
|
|
241
|
+
lines << "\e[90mPress Ctrl+C to stop monitoring\e[0m"
|
|
242
|
+
lines.join("\n")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'rouge'
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class Syntax
|
|
5
|
+
# 支持的语言别名映射
|
|
6
|
+
LANGUAGE_ALIASES = {
|
|
7
|
+
'rb' => 'ruby',
|
|
8
|
+
'py' => 'python',
|
|
9
|
+
'js' => 'javascript',
|
|
10
|
+
'ts' => 'typescript',
|
|
11
|
+
'sh' => 'shell',
|
|
12
|
+
'bash' => 'shell',
|
|
13
|
+
'zsh' => 'shell',
|
|
14
|
+
'yml' => 'yaml',
|
|
15
|
+
'md' => 'markdown'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# 语法高亮主题颜色映射
|
|
19
|
+
THEME_COLORS = {
|
|
20
|
+
# Rouge token types to ANSI colors
|
|
21
|
+
'Comment' => "\e[90m", # Bright black (gray)
|
|
22
|
+
'Comment.Single' => "\e[90m",
|
|
23
|
+
'Comment.Multiline' => "\e[90m",
|
|
24
|
+
'Comment.Preproc' => "\e[95m", # Bright magenta
|
|
25
|
+
|
|
26
|
+
'Keyword' => "\e[94m", # Bright blue
|
|
27
|
+
'Keyword.Constant' => "\e[94m",
|
|
28
|
+
'Keyword.Declaration' => "\e[94m",
|
|
29
|
+
'Keyword.Namespace' => "\e[94m",
|
|
30
|
+
'Keyword.Pseudo' => "\e[94m",
|
|
31
|
+
'Keyword.Reserved' => "\e[94m",
|
|
32
|
+
'Keyword.Type' => "\e[94m",
|
|
33
|
+
|
|
34
|
+
'Literal' => "\e[96m", # Bright cyan
|
|
35
|
+
'Literal.Date' => "\e[96m",
|
|
36
|
+
'Literal.Number' => "\e[93m", # Bright yellow
|
|
37
|
+
'Literal.Number.Bin' => "\e[93m",
|
|
38
|
+
'Literal.Number.Float' => "\e[93m",
|
|
39
|
+
'Literal.Number.Hex' => "\e[93m",
|
|
40
|
+
'Literal.Number.Integer' => "\e[93m",
|
|
41
|
+
'Literal.Number.Oct' => "\e[93m",
|
|
42
|
+
|
|
43
|
+
'Literal.String' => "\e[92m", # Bright green
|
|
44
|
+
'Literal.String.Affix' => "\e[92m",
|
|
45
|
+
'Literal.String.Backtick' => "\e[92m",
|
|
46
|
+
'Literal.String.Char' => "\e[92m",
|
|
47
|
+
'Literal.String.Doc' => "\e[92m",
|
|
48
|
+
'Literal.String.Double' => "\e[92m",
|
|
49
|
+
'Literal.String.Escape' => "\e[96m",
|
|
50
|
+
'Literal.String.Heredoc' => "\e[92m",
|
|
51
|
+
'Literal.String.Interpol' => "\e[96m",
|
|
52
|
+
'Literal.String.Other' => "\e[92m",
|
|
53
|
+
'Literal.String.Regex' => "\e[91m", # Bright red
|
|
54
|
+
'Literal.String.Single' => "\e[92m",
|
|
55
|
+
'Literal.String.Symbol' => "\e[95m", # Bright magenta
|
|
56
|
+
|
|
57
|
+
'Name' => "\e[97m", # Bright white
|
|
58
|
+
'Name.Attribute' => "\e[93m", # Bright yellow
|
|
59
|
+
'Name.Builtin' => "\e[96m", # Bright cyan
|
|
60
|
+
'Name.Builtin.Pseudo' => "\e[96m",
|
|
61
|
+
'Name.Class' => "\e[93m", # Bright yellow
|
|
62
|
+
'Name.Constant' => "\e[93m",
|
|
63
|
+
'Name.Decorator' => "\e[95m", # Bright magenta
|
|
64
|
+
'Name.Entity' => "\e[93m",
|
|
65
|
+
'Name.Exception' => "\e[91m", # Bright red
|
|
66
|
+
'Name.Function' => "\e[96m", # Bright cyan
|
|
67
|
+
'Name.Property' => "\e[96m",
|
|
68
|
+
'Name.Label' => "\e[95m",
|
|
69
|
+
'Name.Namespace' => "\e[93m",
|
|
70
|
+
'Name.Other' => "\e[97m",
|
|
71
|
+
'Name.Tag' => "\e[94m", # Bright blue
|
|
72
|
+
'Name.Variable' => "\e[97m",
|
|
73
|
+
'Name.Variable.Class' => "\e[97m",
|
|
74
|
+
'Name.Variable.Global' => "\e[97m",
|
|
75
|
+
'Name.Variable.Instance' => "\e[97m",
|
|
76
|
+
|
|
77
|
+
'Operator' => "\e[91m", # Bright red
|
|
78
|
+
'Operator.Word' => "\e[94m", # Bright blue
|
|
79
|
+
|
|
80
|
+
'Punctuation' => "\e[97m", # Bright white
|
|
81
|
+
|
|
82
|
+
'Error' => "\e[101m\e[97m", # Red background, white text
|
|
83
|
+
'Generic.Deleted' => "\e[91m", # Bright red
|
|
84
|
+
'Generic.Emph' => "\e[3m", # Italic
|
|
85
|
+
'Generic.Error' => "\e[91m", # Bright red
|
|
86
|
+
'Generic.Heading' => "\e[1m\e[94m", # Bold bright blue
|
|
87
|
+
'Generic.Inserted' => "\e[92m", # Bright green
|
|
88
|
+
'Generic.Output' => "\e[90m", # Bright black (gray)
|
|
89
|
+
'Generic.Prompt' => "\e[1m", # Bold
|
|
90
|
+
'Generic.Strong' => "\e[1m", # Bold
|
|
91
|
+
'Generic.Subheading' => "\e[95m", # Bright magenta
|
|
92
|
+
'Generic.Traceback' => "\e[91m" # Bright red
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
def self.highlight(code, language = nil, theme: :default)
|
|
96
|
+
new(theme: theme).highlight(code, language)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def initialize(theme: :default)
|
|
100
|
+
@theme = theme
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def highlight(code, language = nil)
|
|
104
|
+
return code if code.nil? || code.empty?
|
|
105
|
+
|
|
106
|
+
# 检测或规范化语言
|
|
107
|
+
language = detect_language(code) if language.nil?
|
|
108
|
+
language = normalize_language(language) if language
|
|
109
|
+
|
|
110
|
+
# 如果无法检测语言,返回原始代码
|
|
111
|
+
return code unless language
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
lexer = Rouge::Lexer.find(language)
|
|
115
|
+
return code unless lexer
|
|
116
|
+
|
|
117
|
+
# 使用自定义格式化器
|
|
118
|
+
formatter = AnsiFormatter.new
|
|
119
|
+
formatter.format(lexer.lex(code))
|
|
120
|
+
rescue => e
|
|
121
|
+
# 如果高亮失败,返回原始代码
|
|
122
|
+
code
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def list_languages
|
|
127
|
+
Rouge::Lexer.all.map(&:tag).sort
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def detect_language(code)
|
|
133
|
+
# 简单的语言检测启发式
|
|
134
|
+
return 'ruby' if code.include?('def ') && code.include?('end')
|
|
135
|
+
return 'python' if code.include?('def ') && code.include?(':')
|
|
136
|
+
return 'javascript' if code.include?('function') || code.include?('=>')
|
|
137
|
+
return 'html' if code.include?('<html') || code.include?('<!DOCTYPE')
|
|
138
|
+
return 'css' if code.match?(/\w+\s*{[^}]*}/)
|
|
139
|
+
return 'shell' if code.start_with?('#!/bin/') || code.include?('$ ')
|
|
140
|
+
return 'json' if code.strip.start_with?('{') && code.include?(':')
|
|
141
|
+
return 'yaml' if code.include?('---') || code.match?(/^\w+:/)
|
|
142
|
+
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize_language(language)
|
|
147
|
+
return nil if language.nil?
|
|
148
|
+
language = language.to_s.downcase.strip
|
|
149
|
+
LANGUAGE_ALIASES[language] || language
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# 自定义 ANSI 格式化器
|
|
153
|
+
class AnsiFormatter
|
|
154
|
+
def format(tokens)
|
|
155
|
+
output = ''
|
|
156
|
+
tokens.each do |token, value|
|
|
157
|
+
color_code = THEME_COLORS[token.qualname] ||
|
|
158
|
+
THEME_COLORS[token.token_chain.map(&:qualname).find { |t| THEME_COLORS[t] }] ||
|
|
159
|
+
''
|
|
160
|
+
|
|
161
|
+
if color_code.empty?
|
|
162
|
+
output << value
|
|
163
|
+
else
|
|
164
|
+
output << "#{color_code}#{value}\e[0m"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
output
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|