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.
@@ -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