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.
@@ -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
@@ -0,0 +1,244 @@
1
+ module RubyRich
2
+ class Columns
3
+ class Column
4
+ attr_accessor :content, :width, :align, :padding, :title
5
+
6
+ def initialize(width: nil, align: :left, padding: 1, title: nil)
7
+ @content = []
8
+ @width = width
9
+ @align = align
10
+ @padding = padding
11
+ @title = title
12
+ end
13
+
14
+ def add(text)
15
+ @content << text.to_s
16
+ self
17
+ end
18
+
19
+ def <<(text)
20
+ add(text)
21
+ end
22
+
23
+ def clear
24
+ @content.clear
25
+ self
26
+ end
27
+
28
+ def lines
29
+ @content
30
+ end
31
+
32
+ def height
33
+ @content.length
34
+ end
35
+ end
36
+
37
+ attr_reader :columns, :total_width, :gutter_width
38
+
39
+ def initialize(total_width: 80, gutter_width: 2)
40
+ @columns = []
41
+ @total_width = total_width
42
+ @gutter_width = gutter_width
43
+ end
44
+
45
+ # 添加列
46
+ def add_column(width: nil, align: :left, padding: 1, title: nil)
47
+ column = Column.new(width: width, align: align, padding: padding, title: title)
48
+ @columns << column
49
+
50
+ # 如果没有指定宽度,自动计算平均宽度
51
+ calculate_column_widths if width.nil?
52
+
53
+ column
54
+ end
55
+
56
+ # 删除列
57
+ def remove_column(index)
58
+ @columns.delete_at(index) if index >= 0 && index < @columns.length
59
+ calculate_column_widths
60
+ end
61
+
62
+ # 清空所有列的内容
63
+ def clear_all
64
+ @columns.each(&:clear)
65
+ self
66
+ end
67
+
68
+ # 渲染列布局
69
+ def render(show_headers: true, show_borders: false, equal_height: true)
70
+ return "" if @columns.empty?
71
+
72
+ calculate_column_widths
73
+ lines = []
74
+
75
+ # 渲染标题行
76
+ if show_headers && @columns.any? { |col| col.title }
77
+ header_line = render_header_line(show_borders)
78
+ lines << header_line unless header_line.empty?
79
+
80
+ if show_borders
81
+ separator_line = render_separator_line
82
+ lines << separator_line
83
+ end
84
+ end
85
+
86
+ # 准备内容行
87
+ max_height = equal_height ? @columns.map(&:height).max : 0
88
+
89
+ # 填充较短的列以达到相同高度
90
+ if equal_height && max_height > 0
91
+ @columns.each do |column|
92
+ while column.height < max_height
93
+ column.add("")
94
+ end
95
+ end
96
+ end
97
+
98
+ # 渲染内容行
99
+ content_height = @columns.map(&:height).max || 0
100
+ content_height.times do |row_index|
101
+ line = render_content_line(row_index, show_borders)
102
+ lines << line
103
+ end
104
+
105
+ # 渲染底部边框
106
+ if show_borders
107
+ bottom_line = render_separator_line
108
+ lines << bottom_line
109
+ end
110
+
111
+ lines.join("\n")
112
+ end
113
+
114
+ # 按比例设置列宽
115
+ def set_ratios(*ratios)
116
+ return if ratios.length != @columns.length
117
+
118
+ total_ratio = ratios.sum.to_f
119
+ available_width = @total_width - (@gutter_width * (@columns.length - 1))
120
+
121
+ @columns.each_with_index do |column, index|
122
+ column.width = (available_width * ratios[index] / total_ratio).to_i
123
+ end
124
+
125
+ self
126
+ end
127
+
128
+ # 设置等宽列
129
+ def equal_widths
130
+ calculate_column_widths
131
+ self
132
+ end
133
+
134
+ private
135
+
136
+ def calculate_column_widths
137
+ return if @columns.empty?
138
+
139
+ # 计算可用宽度(总宽度减去间隔)
140
+ available_width = @total_width - (@gutter_width * (@columns.length - 1))
141
+
142
+ # 为每列分配相等的宽度
143
+ base_width = available_width / @columns.length
144
+ remainder = available_width % @columns.length
145
+
146
+ @columns.each_with_index do |column, index|
147
+ column.width = base_width + (index < remainder ? 1 : 0)
148
+ end
149
+ end
150
+
151
+ def render_header_line(show_borders)
152
+ line_parts = []
153
+
154
+ @columns.each_with_index do |column, index|
155
+ header_text = column.title || ""
156
+
157
+ # 根据列的对齐方式格式化标题
158
+ formatted_header = format_text(header_text, column.width, column.align)
159
+
160
+ if show_borders
161
+ formatted_header = "│ #{formatted_header} │"
162
+ end
163
+
164
+ line_parts << formatted_header
165
+
166
+ # 添加间隔(除了最后一列)
167
+ if index < @columns.length - 1 && !show_borders
168
+ line_parts << " " * @gutter_width
169
+ end
170
+ end
171
+
172
+ line_parts.join("")
173
+ end
174
+
175
+ def render_content_line(row_index, show_borders)
176
+ line_parts = []
177
+
178
+ @columns.each_with_index do |column, index|
179
+ content_text = column.lines[row_index] || ""
180
+
181
+ # 根据列的对齐方式格式化内容
182
+ formatted_content = format_text(content_text, column.width, column.align)
183
+
184
+ if show_borders
185
+ formatted_content = "│ #{formatted_content} │"
186
+ end
187
+
188
+ line_parts << formatted_content
189
+
190
+ # 添加间隔(除了最后一列)
191
+ if index < @columns.length - 1 && !show_borders
192
+ line_parts << " " * @gutter_width
193
+ end
194
+ end
195
+
196
+ line_parts.join("")
197
+ end
198
+
199
+ def render_separator_line
200
+ line_parts = []
201
+
202
+ @columns.each_with_index do |column, index|
203
+ separator = "─" * (column.width + 2) # +2 for padding
204
+ line_parts << "├#{separator}┤"
205
+
206
+ if index < @columns.length - 1
207
+ line_parts << "┬"
208
+ end
209
+ end
210
+
211
+ line_parts.join("")
212
+ end
213
+
214
+ def format_text(text, width, align)
215
+ # 移除 ANSI 转义序列计算实际显示宽度
216
+ display_text = text.gsub(/\e\[[0-9;]*m/, '')
217
+
218
+ if display_text.length > width
219
+ # 截断过长的文本
220
+ truncated = display_text[0, width - 3] + "..."
221
+ # 保持原有的 ANSI 样式
222
+ if text.include?("\e[")
223
+ style_start = text.match(/\e\[[0-9;]*m/)&.to_s || ""
224
+ truncated = style_start + truncated + "\e[0m"
225
+ end
226
+ truncated
227
+ else
228
+ # 根据对齐方式填充空格
229
+ padding_needed = width - display_text.length
230
+
231
+ case align
232
+ when :center
233
+ left_padding = padding_needed / 2
234
+ right_padding = padding_needed - left_padding
235
+ " " * left_padding + text + " " * right_padding
236
+ when :right
237
+ " " * padding_needed + text
238
+ else # :left
239
+ text + " " * padding_needed
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end