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,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class Transcript
5
+ ENTRY_TYPES = [
6
+ :user,
7
+ :assistant,
8
+ :thinking,
9
+ :tool,
10
+ :tool_result,
11
+ :system,
12
+ :error,
13
+ :markdown,
14
+ :diff,
15
+ :separator,
16
+ :progress
17
+ ].freeze
18
+
19
+ class Entry
20
+ attr_accessor :id, :type, :content, :metadata, :status, :collapsed, :name
21
+ attr_reader :version
22
+
23
+ def initialize(id:, type:, content: "", metadata: {}, status: nil, collapsed: false, name: nil)
24
+ @id = id
25
+ @type = type
26
+ @content = +content.to_s
27
+ @metadata = metadata.dup
28
+ @status = status
29
+ @collapsed = collapsed
30
+ @name = name
31
+ @version = 0
32
+ @render_cache = {}
33
+ end
34
+
35
+ def text
36
+ @content
37
+ end
38
+
39
+ def text=(value)
40
+ replace(value)
41
+ end
42
+
43
+ def content=(value)
44
+ replace(value)
45
+ end
46
+
47
+ def metadata=(value)
48
+ @metadata = (value || {}).dup
49
+ touch
50
+ end
51
+
52
+ def status=(value)
53
+ @status = value
54
+ touch
55
+ end
56
+
57
+ def collapsed=(value)
58
+ @collapsed = value
59
+ touch
60
+ end
61
+
62
+ def name=(value)
63
+ @name = value
64
+ touch
65
+ end
66
+
67
+ def append(delta)
68
+ @content << delta.to_s
69
+ touch
70
+ self
71
+ end
72
+
73
+ def replace(new_content)
74
+ @content = +new_content.to_s
75
+ touch
76
+ self
77
+ end
78
+
79
+ def update
80
+ yield self
81
+ touch
82
+ self
83
+ end
84
+
85
+ def [](key)
86
+ case key.to_sym
87
+ when :text, :content
88
+ @content
89
+ when :id
90
+ @id
91
+ when :type
92
+ @type
93
+ when :metadata
94
+ @metadata
95
+ when :status
96
+ @status
97
+ when :collapsed
98
+ @collapsed
99
+ when :name
100
+ @name
101
+ else
102
+ @metadata[key] || @metadata[key.to_sym] || @metadata[key.to_s]
103
+ end
104
+ end
105
+
106
+ def []=(key, value)
107
+ case key.to_sym
108
+ when :text, :content
109
+ replace(value)
110
+ when :type
111
+ @type = value
112
+ touch
113
+ when :metadata
114
+ @metadata = value || {}
115
+ touch
116
+ when :status
117
+ @status = value
118
+ touch
119
+ when :collapsed
120
+ @collapsed = value
121
+ touch
122
+ when :name
123
+ @name = value
124
+ touch
125
+ else
126
+ @metadata[key] = value
127
+ touch
128
+ end
129
+ end
130
+
131
+ def cache_fetch(cache_key)
132
+ versioned_key = [@version, cache_key]
133
+ return @render_cache[versioned_key] if @render_cache.key?(versioned_key)
134
+
135
+ @render_cache.clear
136
+ @render_cache[versioned_key] = yield
137
+ end
138
+
139
+ def to_h
140
+ {
141
+ id: @id,
142
+ type: @type,
143
+ text: @content,
144
+ content: @content,
145
+ metadata: @metadata,
146
+ status: @status,
147
+ collapsed: @collapsed,
148
+ name: @name
149
+ }.compact
150
+ end
151
+
152
+ private
153
+
154
+ def touch
155
+ @version += 1
156
+ @render_cache.clear
157
+ end
158
+ end
159
+
160
+ class Store
161
+ include Enumerable
162
+
163
+ attr_reader :entries
164
+
165
+ def initialize
166
+ @entries = []
167
+ @sequence = 0
168
+ @mutex = Mutex.new
169
+ end
170
+
171
+ def add(type:, content: "", metadata: {}, status: nil, collapsed: nil, id: nil, name: nil)
172
+ normalized_type = normalize_type(type)
173
+ collapsed = default_collapsed(normalized_type) if collapsed.nil?
174
+
175
+ @mutex.synchronize do
176
+ entry = Entry.new(
177
+ id: id || next_id(normalized_type),
178
+ type: normalized_type,
179
+ content: content,
180
+ metadata: metadata,
181
+ status: status,
182
+ collapsed: collapsed,
183
+ name: name
184
+ )
185
+ @entries << entry
186
+ entry
187
+ end
188
+ end
189
+
190
+ def append(id, delta)
191
+ mutate(id) { |entry| entry.append(delta) }
192
+ end
193
+
194
+ def replace(id, new_content)
195
+ mutate(id) { |entry| entry.replace(new_content) }
196
+ end
197
+
198
+ def remove(id)
199
+ @mutex.synchronize do
200
+ index = @entries.index { |entry| entry.id == id }
201
+ return false unless index
202
+
203
+ @entries.delete_at(index)
204
+ true
205
+ end
206
+ end
207
+
208
+ def update(id)
209
+ mutate(id) { |entry| entry.update { |target| yield target } }
210
+ end
211
+
212
+ def find(id)
213
+ @mutex.synchronize { @entries.find { |entry| entry.id == id } }
214
+ end
215
+
216
+ def index(id)
217
+ @mutex.synchronize { @entries.index { |entry| entry.id == id } }
218
+ end
219
+
220
+ def expand(id)
221
+ update(id) { |entry| entry.collapsed = false }
222
+ end
223
+
224
+ def collapse(id)
225
+ update(id) { |entry| entry.collapsed = true }
226
+ end
227
+
228
+ def toggle(id)
229
+ update(id) { |entry| entry.collapsed = !entry.collapsed }
230
+ end
231
+
232
+ def each(&block)
233
+ @entries.each(&block)
234
+ end
235
+
236
+ private
237
+
238
+ def mutate(id)
239
+ @mutex.synchronize do
240
+ entry = @entries.find { |item| item.id == id }
241
+ return false unless entry
242
+
243
+ yield entry
244
+ true
245
+ end
246
+ end
247
+
248
+ def normalize_type(type)
249
+ normalized = type.to_sym
250
+ normalized = :tool if normalized == :tool_call
251
+ return normalized if ENTRY_TYPES.include?(normalized)
252
+
253
+ :system
254
+ end
255
+
256
+ def default_collapsed(type)
257
+ [:thinking, :tool, :tool_result].include?(type)
258
+ end
259
+
260
+ def next_id(type)
261
+ @sequence += 1
262
+ "#{type}-#{@sequence}"
263
+ end
264
+ end
265
+
266
+ attr_accessor :width, :height
267
+ attr_reader :store
268
+
269
+ def initialize(store: Store.new)
270
+ @store = store
271
+ @width = 0
272
+ @height = 0
273
+ @selected_collapsible_id = nil
274
+ @focused = true
275
+ end
276
+
277
+ def blocks
278
+ @store.entries
279
+ end
280
+
281
+ def focus
282
+ @focused = true
283
+ self
284
+ end
285
+
286
+ def blur
287
+ @focused = false
288
+ self
289
+ end
290
+
291
+ def attach(layout, priority: 150)
292
+ [:ctrl_o, :alt_v].each do |event_name|
293
+ layout.key(event_name, priority) do |event_data, _live|
294
+ handle_event(event_data)
295
+ false
296
+ end
297
+ end
298
+
299
+ self
300
+ end
301
+
302
+ def handle_event(event_data)
303
+ return false unless @focused
304
+
305
+ case event_data[:name]
306
+ when :ctrl_o
307
+ toggle_next_collapsible
308
+ when :alt_v
309
+ toggle_next(:tool)
310
+ end
311
+ end
312
+
313
+ def add_user(text, **options)
314
+ add_block(:user, text, **options)
315
+ end
316
+
317
+ def add_assistant(text, **options)
318
+ add_block(:assistant, text, **options)
319
+ end
320
+
321
+ def add_thinking(text, status: "idle", collapsed: true, **options)
322
+ add_block(:thinking, text, status: status, collapsed: collapsed, **options)
323
+ end
324
+
325
+ def add_tool(name, status: :running, result: nil, collapsed: false, **options)
326
+ metadata = (options.delete(:metadata) || {}).merge(name: name)
327
+ add_block(:tool, result.to_s, name: name, status: status, collapsed: collapsed, metadata: metadata, **options)
328
+ end
329
+
330
+ def add_separator(label = nil, **options)
331
+ add_block(:separator, label.to_s, **options)
332
+ end
333
+
334
+ def add_markdown(text, **options)
335
+ add_block(:markdown, text, **options)
336
+ end
337
+
338
+ def add_block(type, text = "", **options)
339
+ id = options.delete(:id)
340
+ status = options.delete(:status)
341
+ collapsed = options.delete(:collapsed)
342
+ name = options.delete(:name)
343
+ metadata = options.delete(:metadata) || options
344
+ @store.add(
345
+ type: type,
346
+ content: text,
347
+ id: id,
348
+ status: status,
349
+ collapsed: collapsed,
350
+ metadata: metadata,
351
+ name: name
352
+ ).id
353
+ end
354
+
355
+ def append_block(id, delta)
356
+ @store.append(id, delta)
357
+ end
358
+
359
+ def replace_block(id, text, **options)
360
+ return false unless @store.replace(id, text)
361
+ return true if options.empty?
362
+
363
+ @store.update(id) do |entry|
364
+ options.each do |key, value|
365
+ entry[key] = value
366
+ end
367
+ end
368
+ end
369
+
370
+ def remove_block(id)
371
+ removed = @store.remove(id)
372
+ @selected_collapsible_id = nil if removed && @selected_collapsible_id == id
373
+ removed
374
+ end
375
+
376
+ def find_block(id)
377
+ @store.find(id)
378
+ end
379
+
380
+ def expand_entry(id)
381
+ @store.expand(id)
382
+ end
383
+
384
+ def collapse_entry(id)
385
+ @store.collapse(id)
386
+ end
387
+
388
+ def toggle_entry(id)
389
+ @store.toggle(id)
390
+ end
391
+
392
+ def render
393
+ lines = []
394
+ @store.entries.each_with_index do |entry, index|
395
+ lines.concat(render_entry(entry, index))
396
+ end
397
+ lines
398
+ end
399
+
400
+ private
401
+
402
+ def render_entry(entry, index)
403
+ case entry.type
404
+ when :user
405
+ render_plain_message(entry.content, first_prefix: "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset} ", rest_prefix: " ")
406
+ when :assistant
407
+ render_plain_message(entry.content, first_prefix: " ", rest_prefix: " ")
408
+ when :thinking
409
+ render_thinking(entry)
410
+ when :tool
411
+ render_tool(entry)
412
+ when :tool_result
413
+ render_tool_result(entry)
414
+ when :system
415
+ wrap_with_prefix(entry.content, "#{AnsiCode.color(:black, true)}system#{AnsiCode.reset} ")
416
+ when :error
417
+ wrap_with_prefix(entry.content, "#{AnsiCode.color(:red, true)}error#{AnsiCode.reset} ")
418
+ when :separator
419
+ [separator_line(entry.content)]
420
+ when :markdown
421
+ render_markdown(entry)
422
+ when :diff
423
+ render_diff(entry)
424
+ when :progress
425
+ render_progress(entry)
426
+ else
427
+ entry.content.to_s.split("\n", -1)
428
+ end
429
+ end
430
+
431
+ def render_plain_message(content, first_prefix:, rest_prefix:)
432
+ lines = content.to_s.split("\n", -1)
433
+ lines = [""] if lines.empty?
434
+ lines.each_with_index.flat_map do |line, index|
435
+ prefix = index.zero? ? first_prefix : rest_prefix
436
+ wrap_line(line, [@width - visible_width(prefix), 20].max).map { |part| prefix + part }
437
+ end
438
+ end
439
+
440
+ def render_thinking(entry)
441
+ status = entry.status || "idle"
442
+ header = "#{AnsiCode.color(:white, true)}... thinking #{status}#{AnsiCode.reset}"
443
+ return [header, "#{AnsiCode.italic}thinking collapsed; press Ctrl+O for full text#{AnsiCode.reset}"] if entry.collapsed
444
+
445
+ [header] + wrap_with_prefix(entry.content, "#{AnsiCode.color(:black, true)}│#{AnsiCode.reset} ")
446
+ end
447
+
448
+ def render_tool(entry)
449
+ ToolBlock.new(entry, width: @width).render
450
+ end
451
+
452
+ def render_tool_result(entry)
453
+ status = entry.status || :done
454
+ header = "#{AnsiCode.color(:cyan, true)}• result #{status}#{AnsiCode.reset}"
455
+ return [header, " result collapsed; press Ctrl+O for full output"] if entry.collapsed
456
+
457
+ [header] + wrap_with_prefix(entry.content, " ")
458
+ end
459
+
460
+ def render_markdown(entry)
461
+ cache_key = [:markdown, @width]
462
+ entry.cache_fetch(cache_key) do
463
+ rendered = Markdown.render(entry.content, width: [@width, 20].max, table_border_style: :full)
464
+ rendered.split("\n")
465
+ end
466
+ end
467
+
468
+ def render_diff(entry)
469
+ cache_key = [:diff, @width]
470
+ entry.cache_fetch(cache_key) do
471
+ entry.content.split("\n").flat_map do |line|
472
+ color = if line.start_with?("+")
473
+ :green
474
+ elsif line.start_with?("-")
475
+ :red
476
+ elsif line.start_with?("@@")
477
+ :cyan
478
+ else
479
+ :white
480
+ end
481
+ wrap_line(line, [@width, 20].max).map { |part| "#{AnsiCode.color(color, true)}#{part}#{AnsiCode.reset}" }
482
+ end
483
+ end
484
+ end
485
+
486
+ def render_progress(entry)
487
+ total = entry.metadata[:total].to_f
488
+ current = entry.metadata[:current].to_f
489
+ percent = total.positive? ? [[current / total, 0.0].max, 1.0].min : 0.0
490
+ width = [[@width - 20, 10].max, 40].min
491
+ filled = (width * percent).round
492
+ bar = ("█" * filled) + ("░" * (width - filled))
493
+ label = entry.metadata[:label] || entry.content
494
+ ["#{label} #{AnsiCode.color(:blue, true)}#{bar}#{AnsiCode.reset} #{(percent * 100).round}%"]
495
+ end
496
+
497
+ def wrap_with_prefix(text, prefix)
498
+ text.split("\n").flat_map do |line|
499
+ wrap_line(line, [@width - visible_width(prefix), 20].max).map { |part| prefix + part }
500
+ end
501
+ end
502
+
503
+ def wrap_line(text, max_width)
504
+ return [""] if text.empty?
505
+
506
+ result = []
507
+ current = +""
508
+ current_width = 0
509
+ text.each_char do |char|
510
+ char_width = Unicode::DisplayWidth.of(char)
511
+ if current_width + char_width > max_width
512
+ result << current
513
+ current = +""
514
+ current_width = 0
515
+ end
516
+ current << char
517
+ current_width += char_width
518
+ end
519
+ result << current unless current.empty?
520
+ result
521
+ end
522
+
523
+ def separator_line(label)
524
+ width = [@width, 20].max
525
+ return "─" * width if label.empty?
526
+
527
+ " #{label} ".center(width, "─")
528
+ end
529
+
530
+ def visible_width(text)
531
+ text.to_s.gsub(/\e\[[0-9;:]*m/, "").display_width
532
+ end
533
+
534
+ def toggle_next_collapsible
535
+ ids = @store.entries.select { |entry| [:thinking, :tool, :tool_result].include?(entry.type) }.map(&:id)
536
+ toggle_next_id(ids)
537
+ end
538
+
539
+ def toggle_next(type)
540
+ ids = @store.entries.select { |entry| entry.type == type }.map(&:id)
541
+ toggle_next_id(ids)
542
+ end
543
+
544
+ def toggle_next_id(ids)
545
+ return false if ids.empty?
546
+
547
+ current_position = ids.index(@selected_collapsible_id)
548
+ next_id = ids[current_position ? (current_position + 1) % ids.length : 0]
549
+ @selected_collapsible_id = next_id
550
+ @store.toggle(next_id)
551
+ end
552
+ end
553
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end