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,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class AppShell
|
|
5
|
+
attr_reader :layout, :transcript, :viewport, :sidebar, :composer, :focus_manager, :theme, :live, :token_usage, :progress_manager
|
|
6
|
+
|
|
7
|
+
DEFAULT_COMMANDS = [
|
|
8
|
+
{ label: "/help", value: "/help", description: "Show commands" },
|
|
9
|
+
{ label: "/plan", value: "/plan", description: "Append a plan note" },
|
|
10
|
+
{ label: "/thinking", value: "/thinking", description: "Add a thinking block" },
|
|
11
|
+
{ label: "/tool", value: "/tool", description: "Add a tool call" },
|
|
12
|
+
{ label: "/quit", value: "/quit", description: "Exit demo" }
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(title: "Agent", subtitle: nil, model: "deepseek-v4-pro", theme: Theme.agent_dark, commands: DEFAULT_COMMANDS, on_submit: nil)
|
|
16
|
+
@title = title
|
|
17
|
+
@subtitle = subtitle || "DeepSeek-TUI · #{model}"
|
|
18
|
+
@model = model
|
|
19
|
+
@theme = theme
|
|
20
|
+
@on_submit = on_submit
|
|
21
|
+
@status = "agent · #{model}"
|
|
22
|
+
@token_usage = nil
|
|
23
|
+
@progress_text = nil
|
|
24
|
+
|
|
25
|
+
@transcript = Transcript.new
|
|
26
|
+
@progress_manager = ProgressManager.new(on_change: ->(text) { @progress_text = text })
|
|
27
|
+
@viewport = Viewport.new(@transcript, scrollbar: true, auto_scroll: true)
|
|
28
|
+
@sidebar = Sidebar.new
|
|
29
|
+
@composer = Composer.new(
|
|
30
|
+
placeholder: "编写任务或使用 /。",
|
|
31
|
+
commands: commands,
|
|
32
|
+
on_submit: method(:handle_submit),
|
|
33
|
+
on_select: method(:handle_select)
|
|
34
|
+
)
|
|
35
|
+
@focus_manager = FocusManager.new
|
|
36
|
+
@layout = build_layout
|
|
37
|
+
attach_components
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add_user(text)
|
|
41
|
+
@transcript.add_user(text)
|
|
42
|
+
@viewport.scroll_to_bottom
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_assistant(text)
|
|
47
|
+
@transcript.add_assistant(text)
|
|
48
|
+
@viewport.scroll_to_bottom
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_thinking(text, status: "idle", collapsed: true)
|
|
53
|
+
@transcript.add_thinking(text, status: status, collapsed: collapsed)
|
|
54
|
+
@viewport.scroll_to_bottom
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add_tool(name, status: :running, result: nil, collapsed: false)
|
|
59
|
+
@transcript.add_tool(name, status: status, result: result, collapsed: collapsed)
|
|
60
|
+
@viewport.scroll_to_bottom
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_separator(label = nil)
|
|
65
|
+
@transcript.add_separator(label)
|
|
66
|
+
@viewport.scroll_to_bottom
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_markdown(text)
|
|
71
|
+
@transcript.add_markdown(text)
|
|
72
|
+
@viewport.scroll_to_bottom
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_diff(title: nil, content:, language: "diff")
|
|
77
|
+
label = title ? "#{title}\n#{content}" : content
|
|
78
|
+
@transcript.add_block(:diff, label, language: language)
|
|
79
|
+
@viewport.scroll_to_bottom
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_plan(text)
|
|
84
|
+
@sidebar.update_plan(text)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_tasks(tasks)
|
|
89
|
+
@sidebar.set_tasks(tasks)
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status=(text)
|
|
94
|
+
@status = text.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def update_status(text)
|
|
98
|
+
self.status = text
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def show_token_usage(input: nil, output: nil, total: nil, **extra)
|
|
103
|
+
@token_usage = { input: input, output: output, total: total }.merge(extra).compact
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def start_progress(message = nil, owner: Thread.current.object_id, style: :primary, quiet_on_fast_finish: false)
|
|
108
|
+
_ = style
|
|
109
|
+
_ = quiet_on_fast_finish
|
|
110
|
+
@progress_manager.start(message, owner: owner)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_progress(message = nil, style: :primary, quiet_on_fast_finish: false, &block)
|
|
114
|
+
_ = style
|
|
115
|
+
_ = quiet_on_fast_finish
|
|
116
|
+
@progress_manager.with_progress(message, &block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def confirm(title:, message:, choices:, default: nil, &callback)
|
|
120
|
+
result = default || choices.first&.fetch(:key)
|
|
121
|
+
callback.call(result) if callback
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def form(title:, fields:, &callback)
|
|
126
|
+
values = {}
|
|
127
|
+
fields.each do |field|
|
|
128
|
+
name = field.fetch(:name).to_sym
|
|
129
|
+
values[name] = field.key?(:default) ? field[:default] : default_field_value(field)
|
|
130
|
+
end
|
|
131
|
+
callback.call(values) if callback
|
|
132
|
+
values
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def open_pager(text, command: ENV.fetch("PAGER", "less -R"))
|
|
136
|
+
Terminal.with_cooked(mouse: true) do
|
|
137
|
+
IO.popen(command, "w") { |io| io.write(text.to_s) }
|
|
138
|
+
end
|
|
139
|
+
true
|
|
140
|
+
rescue
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def start(refresh_rate: 24, mouse: true, alt_screen: false)
|
|
145
|
+
Live.start(@layout, refresh_rate: refresh_rate, mouse: mouse, alt_screen: alt_screen, autowrap: false) do |live|
|
|
146
|
+
@live = live
|
|
147
|
+
live.listening = true
|
|
148
|
+
end
|
|
149
|
+
ensure
|
|
150
|
+
@live = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stop
|
|
154
|
+
return false unless @live
|
|
155
|
+
|
|
156
|
+
@live.post { |live| live.stop } || false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def build_layout
|
|
162
|
+
root = Layout.new(name: :root)
|
|
163
|
+
root.split_column(
|
|
164
|
+
Layout.new(name: :header, size: 1),
|
|
165
|
+
Layout.new(name: :body, ratio: 1),
|
|
166
|
+
Layout.new(name: :composer, size: 6),
|
|
167
|
+
Layout.new(name: :status, size: 1)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
root[:body].split_row(
|
|
171
|
+
Layout.new(name: :transcript, ratio: 1),
|
|
172
|
+
Layout.new(name: :sidebar, size: 36)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
root[:header].content = HeaderView.new(self)
|
|
176
|
+
root[:transcript].content = @viewport
|
|
177
|
+
root[:sidebar].content = @sidebar
|
|
178
|
+
root[:composer].content = FramedView.new(@composer, title: "Composer", theme: @theme) { @composer.focused? }
|
|
179
|
+
root[:status].content = StatusView.new(self)
|
|
180
|
+
root
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def attach_components
|
|
184
|
+
@viewport.attach(@layout[:transcript])
|
|
185
|
+
@transcript.attach(@layout[:transcript])
|
|
186
|
+
@composer.focus.attach(@layout[:composer])
|
|
187
|
+
|
|
188
|
+
@focus_manager
|
|
189
|
+
.register(:transcript, @layout[:transcript], FocusTarget.new(@transcript, @viewport))
|
|
190
|
+
.register(:composer, @layout[:composer], @composer)
|
|
191
|
+
.attach(@layout)
|
|
192
|
+
@focus_manager.focus(:composer)
|
|
193
|
+
|
|
194
|
+
@layout.key(:ctrl_c, 1_000) do |_event, live|
|
|
195
|
+
live.stop if @stop_on_ctrl_c != false
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_select(command, _live)
|
|
201
|
+
case command[:value]
|
|
202
|
+
when "/plan"
|
|
203
|
+
@status = "plan command selected"
|
|
204
|
+
when "/thinking"
|
|
205
|
+
@status = "thinking command selected"
|
|
206
|
+
when "/tool"
|
|
207
|
+
@status = "tool command selected"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def handle_submit(value, live, attachments = [])
|
|
212
|
+
case value.strip
|
|
213
|
+
when "/quit"
|
|
214
|
+
live&.stop
|
|
215
|
+
when "/help"
|
|
216
|
+
add_assistant("Commands: /help, /plan, /thinking, /tool, /quit")
|
|
217
|
+
when "/plan"
|
|
218
|
+
@sidebar.add_task("Plan updated #{Time.now.strftime('%H:%M:%S')}", status: :in_progress)
|
|
219
|
+
when "/thinking"
|
|
220
|
+
add_thinking("Let me inspect the current state and keep the details collapsible.", status: "idle", collapsed: false)
|
|
221
|
+
when "/tool"
|
|
222
|
+
add_tool("read_file", status: :done, result: "name: read_file\nresult: <demo output>", collapsed: false)
|
|
223
|
+
else
|
|
224
|
+
add_user(value)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
@on_submit&.call(value, live, self, attachments)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
class FocusTarget
|
|
231
|
+
def initialize(*targets)
|
|
232
|
+
@targets = targets
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def focus
|
|
236
|
+
@targets.each { |target| target.focus if target.respond_to?(:focus) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def blur
|
|
240
|
+
@targets.each { |target| target.blur if target.respond_to?(:blur) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class HeaderView
|
|
245
|
+
attr_accessor :width, :height
|
|
246
|
+
|
|
247
|
+
def initialize(shell)
|
|
248
|
+
@shell = shell
|
|
249
|
+
@width = 0
|
|
250
|
+
@height = 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def render
|
|
254
|
+
theme = @shell.theme
|
|
255
|
+
left = "#{theme.style(@shell.instance_variable_get(:@title), :accent)} #{theme.style(@shell.instance_variable_get(:@subtitle), :muted)}"
|
|
256
|
+
usage = @shell.token_usage
|
|
257
|
+
usage_text = if usage && !usage.empty?
|
|
258
|
+
total = usage[:total] || usage[:tokens]
|
|
259
|
+
total ? "#{total} tok" : usage.map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
260
|
+
else
|
|
261
|
+
"tokens --"
|
|
262
|
+
end
|
|
263
|
+
right = "#{theme.style(@shell.instance_variable_get(:@model), :status)} #{theme.style('● Live', :body)} #{theme.style(usage_text, :status)}"
|
|
264
|
+
[join_edges(left, right, @width)]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def join_edges(left, right, width)
|
|
270
|
+
space = [width - visible_width(left) - visible_width(right), 1].max
|
|
271
|
+
truncate_display(left + (" " * space) + right, width)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def visible_width(text)
|
|
275
|
+
text.gsub(/\e\[[0-9;:]*m/, "").display_width
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def truncate_display(text, width)
|
|
279
|
+
return text if visible_width(text) <= width
|
|
280
|
+
|
|
281
|
+
result = +""
|
|
282
|
+
used = 0
|
|
283
|
+
in_escape = false
|
|
284
|
+
text.each_char do |char|
|
|
285
|
+
if in_escape
|
|
286
|
+
result << char
|
|
287
|
+
in_escape = false if char == "m"
|
|
288
|
+
next
|
|
289
|
+
elsif char.ord == 27
|
|
290
|
+
result << char
|
|
291
|
+
in_escape = true
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
296
|
+
break if used + char_width > width
|
|
297
|
+
|
|
298
|
+
result << char
|
|
299
|
+
used += char_width
|
|
300
|
+
end
|
|
301
|
+
result
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
class StatusView
|
|
306
|
+
attr_accessor :width, :height
|
|
307
|
+
|
|
308
|
+
def initialize(shell)
|
|
309
|
+
@shell = shell
|
|
310
|
+
@width = 0
|
|
311
|
+
@height = 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def render
|
|
315
|
+
theme = @shell.theme
|
|
316
|
+
focus = @shell.focus_manager.focused_name || :none
|
|
317
|
+
progress = @shell.instance_variable_get(:@progress_text)
|
|
318
|
+
status = progress || @shell.instance_variable_get(:@status)
|
|
319
|
+
line = "#{theme.style(status, :accent)} #{theme.style('focus: ' + focus.to_s, :muted)} #{theme.style('Tab focus · Ctrl+C quit · /quit', :dim)}"
|
|
320
|
+
[line]
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
class FramedView
|
|
325
|
+
attr_accessor :width, :height
|
|
326
|
+
|
|
327
|
+
def initialize(component, title:, theme:, &focused)
|
|
328
|
+
@component = component
|
|
329
|
+
@title = title
|
|
330
|
+
@theme = theme
|
|
331
|
+
@focused = focused
|
|
332
|
+
@width = 0
|
|
333
|
+
@height = 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def render
|
|
337
|
+
sync_component_dimensions
|
|
338
|
+
panel = Panel.new(rendered_content, title: @title, border_style: @theme.panel_border(focused: @focused.call), title_align: :left)
|
|
339
|
+
panel.width = @width
|
|
340
|
+
panel.height = @height
|
|
341
|
+
panel.render
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def desired_height
|
|
345
|
+
return @height unless @component.respond_to?(:desired_height)
|
|
346
|
+
|
|
347
|
+
@component.desired_height + 2
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
private
|
|
351
|
+
|
|
352
|
+
def sync_component_dimensions
|
|
353
|
+
inner_width = [@width - 2, 1].max
|
|
354
|
+
inner_height = [@height - 2, 1].max
|
|
355
|
+
@component.width = inner_width if @component.respond_to?(:width=)
|
|
356
|
+
@component.height = inner_height if @component.respond_to?(:height=)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def rendered_content
|
|
360
|
+
rendered = @component.render
|
|
361
|
+
rendered.is_a?(Array) ? rendered.join("\n") : rendered.to_s
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def default_field_value(field)
|
|
366
|
+
case field[:type]
|
|
367
|
+
when :boolean then false
|
|
368
|
+
when :multi_select then []
|
|
369
|
+
when :number then nil
|
|
370
|
+
else ""
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class Attachment
|
|
5
|
+
attr_accessor :type, :path, :mime_type, :display_name, :metadata
|
|
6
|
+
|
|
7
|
+
def initialize(type:, path:, mime_type: nil, display_name: nil, metadata: {})
|
|
8
|
+
@type = type.to_sym
|
|
9
|
+
@path = path.to_s
|
|
10
|
+
@mime_type = mime_type
|
|
11
|
+
@display_name = display_name || File.basename(@path)
|
|
12
|
+
@metadata = metadata.dup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
type: @type,
|
|
18
|
+
path: @path,
|
|
19
|
+
mime_type: @mime_type,
|
|
20
|
+
display_name: @display_name,
|
|
21
|
+
metadata: @metadata
|
|
22
|
+
}.compact
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|