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.
@@ -0,0 +1,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'tty-cursor'
5
+ require_relative 'event'
6
+
7
+ module RubyRich
8
+ class Terminal
9
+ MOUSE_ENABLE = "\e[?1000h\e[?1002h\e[?1006h"
10
+ MOUSE_DISABLE = "\e[?1006l\e[?1002l\e[?1000l"
11
+ AUTOWRAP_ENABLE = "\e[?7h"
12
+ AUTOWRAP_DISABLE = "\e[?7l"
13
+ ALT_SCREEN_ENABLE = "\e[?1049h"
14
+ ALT_SCREEN_DISABLE = "\e[?1049l"
15
+ BRACKETED_PASTE_ENABLE = "\e[?2004h"
16
+ BRACKETED_PASTE_DISABLE = "\e[?2004l"
17
+ STD_INPUT_HANDLE = -10
18
+ STD_OUTPUT_HANDLE = -11
19
+ ENABLE_MOUSE_INPUT = 0x0010
20
+ ENABLE_WINDOW_INPUT = 0x0008
21
+ ENABLE_QUICK_EDIT_MODE = 0x0040
22
+ ENABLE_EXTENDED_FLAGS = 0x0080
23
+ ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
24
+ ENABLE_PROCESSED_OUTPUT = 0x0001
25
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
26
+ INPUT_RECORD_SIZE = 20
27
+ KEY_EVENT = 0x0001
28
+ MOUSE_EVENT = 0x0002
29
+ FROM_LEFT_1ST_BUTTON_PRESSED = 0x0001
30
+ RIGHTMOST_BUTTON_PRESSED = 0x0002
31
+ FROM_LEFT_2ND_BUTTON_PRESSED = 0x0004
32
+ MOUSE_MOVED = 0x0001
33
+ DOUBLE_CLICK = 0x0002
34
+ MOUSE_WHEELED = 0x0004
35
+ MOUSE_HWHEELED = 0x0008
36
+ RIGHT_ALT_PRESSED = 0x0001
37
+ LEFT_ALT_PRESSED = 0x0002
38
+ RIGHT_CTRL_PRESSED = 0x0004
39
+ LEFT_CTRL_PRESSED = 0x0008
40
+ SHIFT_PRESSED = 0x0010
41
+
42
+ class << self
43
+ attr_accessor :debug_input
44
+
45
+ def setup(mouse: false, hide_cursor: true, autowrap: true, alt_screen: false)
46
+ capture_state
47
+ enable_virtual_terminal_on_windows
48
+ system('stty -echo') unless windows?
49
+ enter_alt_screen if alt_screen
50
+ enable_bracketed_paste
51
+ set_autowrap(autowrap)
52
+ print TTY::Cursor.hide if hide_cursor
53
+ enable_mouse if mouse
54
+ end
55
+
56
+ def restore(mouse: false, show_cursor: true, autowrap: true, alt_screen: false)
57
+ disable_mouse if mouse
58
+ disable_bracketed_paste
59
+ set_autowrap(autowrap)
60
+ leave_alt_screen if alt_screen
61
+ restore_virtual_terminal_on_windows
62
+ system("stty #{@original_state}") if @original_state && !windows?
63
+ print TTY::Cursor.show if show_cursor
64
+ end
65
+
66
+ def enable_mouse
67
+ @mouse_reporting_enabled = true
68
+ enable_windows_input_mode if windows?
69
+ print MOUSE_ENABLE
70
+ $stdout.flush
71
+ end
72
+
73
+ def disable_mouse
74
+ @mouse_reporting_enabled = false
75
+ print MOUSE_DISABLE
76
+ $stdout.flush
77
+ end
78
+
79
+ def set_autowrap(enabled)
80
+ print(enabled ? AUTOWRAP_ENABLE : AUTOWRAP_DISABLE)
81
+ $stdout.flush
82
+ end
83
+
84
+ def enter_alt_screen
85
+ print ALT_SCREEN_ENABLE
86
+ $stdout.flush
87
+ end
88
+
89
+ def leave_alt_screen
90
+ print ALT_SCREEN_DISABLE
91
+ $stdout.flush
92
+ end
93
+
94
+ def enable_bracketed_paste
95
+ print BRACKETED_PASTE_ENABLE
96
+ $stdout.flush
97
+ end
98
+
99
+ def disable_bracketed_paste
100
+ print BRACKETED_PASTE_DISABLE
101
+ $stdout.flush
102
+ end
103
+
104
+ def clear
105
+ print "\e[2J\e[H"
106
+ $stdout.flush
107
+ end
108
+
109
+ def with_cooked(mouse: false, alt_screen: false)
110
+ restore(mouse: mouse, alt_screen: alt_screen)
111
+ yield
112
+ ensure
113
+ setup(mouse: mouse, alt_screen: alt_screen, autowrap: false)
114
+ end
115
+
116
+ def prepare_input
117
+ return unless windows?
118
+
119
+ enable_windows_input_mode
120
+ end
121
+
122
+ def windows_input_mode
123
+ return nil unless windows?
124
+
125
+ console_mode(get_std_handle.call(STD_INPUT_HANDLE))
126
+ rescue
127
+ nil
128
+ end
129
+
130
+ def windows?
131
+ RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
132
+ end
133
+
134
+ def windows_mouse_reporting?
135
+ windows? && @mouse_reporting_enabled
136
+ end
137
+
138
+ def read_windows_input_event
139
+ return nil unless windows?
140
+ pending = next_windows_pending_event
141
+ return pending if pending
142
+
143
+ enable_windows_input_mode
144
+ handle = get_std_handle.call(STD_INPUT_HANDLE)
145
+ record = Fiddle::Pointer.malloc(INPUT_RECORD_SIZE)
146
+ read_count = Fiddle::Pointer.malloc(4)
147
+
148
+ loop do
149
+ return nil unless read_console_input.call(handle, record, 1, read_count) != 0
150
+ next unless read_count[0, 4].unpack1('L') == 1
151
+
152
+ event = parse_windows_input_record(record)
153
+ event = coalesce_windows_paste(handle, event) if paste_seed_event?(event)
154
+ return event if event
155
+ end
156
+ rescue
157
+ nil
158
+ end
159
+
160
+ private
161
+
162
+ def capture_state
163
+ @original_state = windows? ? nil : `stty -g`.strip
164
+ rescue
165
+ @original_state = nil
166
+ end
167
+
168
+ def enable_virtual_terminal_on_windows
169
+ return unless windows?
170
+
171
+ @windows_console_modes ||= {}
172
+
173
+ enable_windows_output_mode
174
+ enable_windows_input_mode
175
+ rescue
176
+ nil
177
+ end
178
+
179
+ def enable_windows_output_mode
180
+ handle = get_std_handle.call(STD_OUTPUT_HANDLE)
181
+ mode = console_mode(handle)
182
+ return unless mode
183
+
184
+ @windows_console_modes ||= {}
185
+ @windows_console_modes[handle.to_i] ||= { handle: handle, mode: mode }
186
+ set_console_mode.call(handle, mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
187
+ end
188
+
189
+ def enable_windows_input_mode
190
+ handle = get_std_handle.call(STD_INPUT_HANDLE)
191
+ mode = console_mode(handle)
192
+ return unless mode
193
+
194
+ @windows_console_modes ||= {}
195
+ @windows_console_modes[handle.to_i] ||= { handle: handle, mode: mode }
196
+ next_mode = mode
197
+ next_mode |= ENABLE_EXTENDED_FLAGS
198
+ next_mode |= ENABLE_MOUSE_INPUT
199
+ next_mode |= ENABLE_WINDOW_INPUT
200
+ next_mode &= ~ENABLE_QUICK_EDIT_MODE
201
+ set_console_mode.call(handle, next_mode)
202
+ end
203
+
204
+ def restore_virtual_terminal_on_windows
205
+ return unless windows?
206
+ return unless @windows_console_modes && !@windows_console_modes.empty?
207
+
208
+ require 'fiddle'
209
+ kernel32 = Fiddle.dlopen('kernel32')
210
+ set_console_mode = Fiddle::Function.new(
211
+ kernel32['SetConsoleMode'],
212
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG],
213
+ Fiddle::TYPE_INT
214
+ )
215
+
216
+ @windows_console_modes.each_value do |entry|
217
+ set_console_mode.call(entry[:handle], entry[:mode])
218
+ end
219
+ @windows_console_modes.clear
220
+ rescue
221
+ nil
222
+ end
223
+
224
+ def console_mode(handle)
225
+ mode_ptr = Fiddle::Pointer.malloc(4)
226
+ return nil unless get_console_mode.call(handle, mode_ptr) != 0
227
+
228
+ mode_ptr[0, 4].unpack1('L')
229
+ end
230
+
231
+ def kernel32
232
+ @kernel32 ||= begin
233
+ ensure_fiddle
234
+ Fiddle.dlopen('kernel32')
235
+ end
236
+ end
237
+
238
+ def get_std_handle
239
+ ensure_fiddle
240
+ @get_std_handle ||= Fiddle::Function.new(
241
+ kernel32['GetStdHandle'],
242
+ [Fiddle::TYPE_LONG],
243
+ Fiddle::TYPE_VOIDP
244
+ )
245
+ end
246
+
247
+ def get_console_mode
248
+ ensure_fiddle
249
+ @get_console_mode ||= Fiddle::Function.new(
250
+ kernel32['GetConsoleMode'],
251
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
252
+ Fiddle::TYPE_INT
253
+ )
254
+ end
255
+
256
+ def set_console_mode
257
+ ensure_fiddle
258
+ @set_console_mode ||= Fiddle::Function.new(
259
+ kernel32['SetConsoleMode'],
260
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG],
261
+ Fiddle::TYPE_INT
262
+ )
263
+ end
264
+
265
+ def read_console_input
266
+ ensure_fiddle
267
+ @read_console_input ||= Fiddle::Function.new(
268
+ kernel32['ReadConsoleInputW'],
269
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP],
270
+ Fiddle::TYPE_INT
271
+ )
272
+ end
273
+
274
+ def get_number_of_console_input_events
275
+ ensure_fiddle
276
+ @get_number_of_console_input_events ||= Fiddle::Function.new(
277
+ kernel32['GetNumberOfConsoleInputEvents'],
278
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
279
+ Fiddle::TYPE_INT
280
+ )
281
+ end
282
+
283
+ def parse_windows_input_record(record)
284
+ event_type = record[0, 2].unpack1('S')
285
+
286
+ case event_type
287
+ when KEY_EVENT
288
+ parse_windows_key_record(record)
289
+ when MOUSE_EVENT
290
+ parse_windows_mouse_record(record)
291
+ end
292
+ end
293
+
294
+ def next_windows_pending_event
295
+ return nil unless @windows_pending_events && !@windows_pending_events.empty?
296
+
297
+ @windows_pending_events.shift
298
+ end
299
+
300
+ def paste_seed_event?(event)
301
+ event && event[:type] == :key && [:string, :enter, :tab].include?(event[:name])
302
+ end
303
+
304
+ def coalesce_windows_paste(handle, first_event)
305
+ events = [first_event]
306
+ sleep 0.002
307
+ available = windows_input_event_count(handle)
308
+
309
+ while available && available.positive?
310
+ record = Fiddle::Pointer.malloc(INPUT_RECORD_SIZE)
311
+ read_count = Fiddle::Pointer.malloc(4)
312
+ break unless read_console_input.call(handle, record, 1, read_count) != 0
313
+ break unless read_count[0, 4].unpack1('L') == 1
314
+
315
+ event = parse_windows_input_record(record)
316
+ if paste_seed_event?(event)
317
+ events << event
318
+ elsif event
319
+ (@windows_pending_events ||= []) << event
320
+ end
321
+ available = windows_input_event_count(handle)
322
+ end
323
+
324
+ finish_windows_coalesced_events(events, first_event)
325
+ rescue
326
+ first_event
327
+ end
328
+
329
+ def finish_windows_coalesced_events(events, first_event)
330
+ if windows_enter_burst?(events)
331
+ first_event
332
+ elsif windows_paste_burst?(events)
333
+ Event.key(:paste, value: events.map { |event| windows_paste_text(event) }.join)
334
+ else
335
+ (@windows_pending_events ||= []).concat(events[1..] || [])
336
+ first_event
337
+ end
338
+ end
339
+
340
+ def windows_enter_burst?(events)
341
+ events.length <= 2 && events.all? { |event| event[:name] == :enter }
342
+ end
343
+
344
+ def windows_paste_burst?(events)
345
+ return false if events.length <= 1
346
+
347
+ events.any? { |event| event[:name] == :enter } || events.length >= 4
348
+ end
349
+
350
+ def windows_paste_text(event)
351
+ case event[:name]
352
+ when :string
353
+ event[:value].to_s
354
+ when :enter
355
+ "\n"
356
+ when :tab
357
+ "\t"
358
+ else
359
+ ""
360
+ end
361
+ end
362
+
363
+ def windows_input_event_count(handle)
364
+ count_ptr = Fiddle::Pointer.malloc(4)
365
+ return nil unless get_number_of_console_input_events.call(handle, count_ptr) != 0
366
+
367
+ count_ptr[0, 4].unpack1('L')
368
+ end
369
+
370
+ def parse_windows_key_record(record)
371
+ key_down = record[4, 4].unpack1('L') != 0
372
+ return nil unless key_down
373
+
374
+ virtual_key = record[10, 2].unpack1('S')
375
+ control_state = record[12, 4].unpack1('L')
376
+ char_code = record[14, 2].unpack1('S')
377
+ char = char_code.zero? ? nil : char_code.chr(Encoding::UTF_8)
378
+ modifiers = windows_key_modifiers(control_state)
379
+
380
+ case virtual_key
381
+ when 13
382
+ return annotate_windows_key_event(Event.key(:alt_enter), virtual_key, char_code, control_state, modifiers) if modifiers.include?(:alt)
383
+ return annotate_windows_key_event(Event.key(:ctrl_enter), virtual_key, char_code, control_state, modifiers) if char_code == 10 && modifiers.include?(:ctrl)
384
+ return annotate_windows_key_event(Event.key(:shift_enter), virtual_key, char_code, control_state, modifiers) if char_code == 10
385
+
386
+ annotate_windows_key_event(Event.key(:enter), virtual_key, char_code, control_state, modifiers)
387
+ when 9
388
+ modifiers.include?(:shift) ? Event.key(:shift_tab) : Event.key(:tab)
389
+ when 8 then Event.key(:backspace)
390
+ when 27 then Event.key(:escape)
391
+ when 37 then Event.key(:left)
392
+ when 38 then Event.key(:up)
393
+ when 39 then Event.key(:right)
394
+ when 40 then Event.key(:down)
395
+ when 33 then Event.key(:page_up)
396
+ when 34 then Event.key(:page_down)
397
+ when 35 then Event.key(:end)
398
+ when 36 then Event.key(:home)
399
+ when 46 then Event.key(:delete)
400
+ else
401
+ ctrl_event = windows_ctrl_key_event(char_code)
402
+ return ctrl_event if ctrl_event
403
+ return Event.key(:ctrl_c) if char_code == 3
404
+ return nil unless char && !char.empty?
405
+
406
+ Event.key(:string, value: char)
407
+ end
408
+ rescue RangeError
409
+ nil
410
+ end
411
+
412
+ def parse_windows_mouse_record(record)
413
+ raw_x = record[4, 2].unpack1('s')
414
+ raw_y = record[6, 2].unpack1('s')
415
+ button_state = record[8, 4].unpack1('L')
416
+ control_state = record[12, 4].unpack1('L')
417
+ event_flags = record[16, 4].unpack1('L')
418
+
419
+ if (event_flags & MOUSE_WHEELED) == MOUSE_WHEELED
420
+ delta = signed_high_word(button_state)
421
+ return Event.mouse(
422
+ :mouse_wheel,
423
+ button: :wheel,
424
+ x: raw_x,
425
+ y: raw_y,
426
+ raw_x: raw_x + 1,
427
+ raw_y: raw_y + 1,
428
+ code: button_state,
429
+ modifiers: windows_mouse_modifiers(control_state),
430
+ direction: delta.negative? ? :down : :up
431
+ )
432
+ end
433
+
434
+ if (event_flags & MOUSE_MOVED) == MOUSE_MOVED
435
+ return nil if button_state.zero?
436
+
437
+ return windows_mouse_event(:mouse_drag, raw_x, raw_y, button_state, control_state)
438
+ end
439
+
440
+ if button_state.zero?
441
+ return windows_mouse_event(:mouse_up, raw_x, raw_y, button_state, control_state)
442
+ end
443
+
444
+ windows_mouse_event(:mouse_down, raw_x, raw_y, button_state, control_state)
445
+ end
446
+
447
+ def windows_mouse_event(name, raw_x, raw_y, button_state, control_state)
448
+ Event.mouse(
449
+ name,
450
+ button: windows_mouse_button(button_state),
451
+ x: raw_x,
452
+ y: raw_y,
453
+ raw_x: raw_x + 1,
454
+ raw_y: raw_y + 1,
455
+ code: button_state,
456
+ modifiers: windows_mouse_modifiers(control_state)
457
+ )
458
+ end
459
+
460
+ def windows_mouse_button(button_state)
461
+ return :left if (button_state & FROM_LEFT_1ST_BUTTON_PRESSED) != 0
462
+ return :right if (button_state & RIGHTMOST_BUTTON_PRESSED) != 0
463
+ return :middle if (button_state & FROM_LEFT_2ND_BUTTON_PRESSED) != 0
464
+
465
+ :unknown
466
+ end
467
+
468
+ def windows_mouse_modifiers(control_state)
469
+ windows_key_modifiers(control_state)
470
+ end
471
+
472
+ def windows_key_modifiers(control_state)
473
+ modifiers = []
474
+ modifiers << :shift if (control_state & SHIFT_PRESSED) != 0
475
+ modifiers << :ctrl if (control_state & LEFT_CTRL_PRESSED) != 0 || (control_state & RIGHT_CTRL_PRESSED) != 0
476
+ modifiers << :alt if (control_state & LEFT_ALT_PRESSED) != 0 || (control_state & RIGHT_ALT_PRESSED) != 0
477
+ modifiers
478
+ end
479
+
480
+ def windows_ctrl_key_event(char_code)
481
+ return nil unless char_code.between?(1, 26)
482
+
483
+ ctrl_char = (char_code + 64).chr.downcase
484
+ Event.key("ctrl_#{ctrl_char}".to_sym)
485
+ end
486
+
487
+ def annotate_windows_key_event(event, virtual_key, char_code, control_state, modifiers)
488
+ return event unless @debug_input
489
+
490
+ event.merge(
491
+ raw: {
492
+ virtual_key: virtual_key,
493
+ char_code: char_code,
494
+ control_state: control_state,
495
+ modifiers: modifiers
496
+ }
497
+ )
498
+ end
499
+
500
+ def signed_high_word(value)
501
+ high = (value >> 16) & 0xffff
502
+ high >= 0x8000 ? high - 0x10000 : high
503
+ end
504
+
505
+ def ensure_fiddle
506
+ require 'fiddle'
507
+ end
508
+ end
509
+ end
510
+ end
@@ -1,6 +1,6 @@
1
1
  module RubyRich
2
2
  class RichText
3
- # 默认主题
3
+ # Default theme
4
4
  @@theme = {
5
5
  error: { color: :red, bold: true },
6
6
  success: { color: :green, bold: true },
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class Theme
5
+ DEFAULT_ROLES = {
6
+ accent: { color: :blue, bright: true, bold: true },
7
+ body: { color: :white, bright: true },
8
+ muted: { color: :black, bright: true },
9
+ dim: { color: :black, bright: true },
10
+ thinking: { color: :white, bright: true, italic: true },
11
+ success: { color: :green, bright: true },
12
+ warning: { color: :yellow, bright: true },
13
+ error: { color: :red, bright: true },
14
+ status: { color: :cyan, bright: true }
15
+ }.freeze
16
+
17
+ attr_reader :roles, :border, :focused_border
18
+
19
+ def self.auto
20
+ ENV["COLORFGBG"].to_s.split(";").last.to_i >= 8 ? light : agent_dark
21
+ rescue
22
+ agent_dark
23
+ end
24
+
25
+ def self.agent_dark
26
+ new(
27
+ border: :blue,
28
+ focused_border: :cyan,
29
+ roles: {
30
+ accent: { color: :blue, bright: true, bold: true },
31
+ body: { color: :white, bright: true },
32
+ muted: { color: :black, bright: true },
33
+ dim: { color: :black, bright: true },
34
+ thinking: { color: :white, bright: true, italic: true },
35
+ success: { color: :green, bright: true },
36
+ warning: { color: :yellow, bright: true },
37
+ error: { color: :red, bright: true },
38
+ status: { color: :cyan, bright: true }
39
+ }
40
+ )
41
+ end
42
+
43
+ def self.light
44
+ new(
45
+ border: :blue,
46
+ focused_border: :cyan,
47
+ roles: {
48
+ accent: { color: :blue, bright: false, bold: true },
49
+ body: { color: :black, bright: false },
50
+ muted: { color: :black, bright: true },
51
+ dim: { color: :black, bright: true },
52
+ success: { color: :green, bright: false },
53
+ warning: { color: :yellow, bright: false },
54
+ error: { color: :red, bright: false },
55
+ status: { color: :cyan, bright: false }
56
+ }
57
+ )
58
+ end
59
+
60
+ def self.no_color
61
+ new(roles: DEFAULT_ROLES.transform_values { { color: :white } }, border: :white, focused_border: :white)
62
+ end
63
+
64
+ def initialize(roles: {}, border: :blue, focused_border: :cyan)
65
+ @roles = DEFAULT_ROLES.merge(roles)
66
+ @border = border
67
+ @focused_border = focused_border
68
+ end
69
+
70
+ def style(text, role = :body)
71
+ options = @roles.fetch(role, @roles[:body])
72
+ "#{style_code(options)}#{text}#{AnsiCode.reset}"
73
+ end
74
+
75
+ def color(name, bright: false)
76
+ AnsiCode.color(name, bright)
77
+ end
78
+
79
+ def panel_border(focused: false)
80
+ focused ? @focused_border : @border
81
+ end
82
+
83
+ private
84
+
85
+ def style_code(options)
86
+ code = AnsiCode.font(
87
+ options.fetch(:color, :white),
88
+ font_bright: options.fetch(:bright, false),
89
+ bold: options[:bold],
90
+ italic: options[:italic]
91
+ )
92
+ code += AnsiCode.faint if options[:faint]
93
+ code
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class ToolBlock
5
+ STATES = [:pending, :running, :done, :error, :cancelled, :denied].freeze
6
+
7
+ def initialize(entry, width: 80)
8
+ @entry = entry
9
+ @width = [width, 20].max
10
+ end
11
+
12
+ def render
13
+ name = @entry.name || @entry.metadata[:name] || "tool"
14
+ status = normalize_status(@entry.status || :pending)
15
+ header = "#{AnsiCode.color(color(status), true)}• #{status_marker(status)} #{name} #{status}#{AnsiCode.reset}"
16
+ return [header, " #{summary(@entry.content)}", " details collapsed; press Ctrl+O for full output"] if @entry.collapsed
17
+
18
+ [header] + details(@entry.content)
19
+ end
20
+
21
+ private
22
+
23
+ def normalize_status(status)
24
+ status = status.to_sym
25
+ return :error if status == :failed || status == :issue
26
+ return status if STATES.include?(status)
27
+
28
+ :pending
29
+ end
30
+
31
+ def color(status)
32
+ case status
33
+ when :done then :green
34
+ when :error, :denied then :red
35
+ when :cancelled then :yellow
36
+ else :blue
37
+ end
38
+ end
39
+
40
+ def status_marker(status)
41
+ case status
42
+ when :done then "✓"
43
+ when :error then "!"
44
+ when :cancelled then "-"
45
+ when :denied then "x"
46
+ else "▸"
47
+ end
48
+ end
49
+
50
+ def summary(content)
51
+ plain = content.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").first.to_s
52
+ plain.empty? ? "<no output>" : plain[0, [@width - 4, 20].max]
53
+ end
54
+
55
+ def details(content)
56
+ text = content.to_s
57
+ return [" <no output>"] if text.empty?
58
+
59
+ text.split("\n").flat_map { |line| wrap(line).map { |part| " #{part}" } }
60
+ end
61
+
62
+ def wrap(line)
63
+ max_width = [@width - 2, 20].max
64
+ result = []
65
+ current = +""
66
+ width = 0
67
+ in_escape = false
68
+ line.each_char do |char|
69
+ if in_escape
70
+ current << char
71
+ in_escape = false if char == "m"
72
+ next
73
+ elsif char.ord == 27
74
+ current << char
75
+ in_escape = true
76
+ next
77
+ end
78
+
79
+ char_width = Unicode::DisplayWidth.of(char)
80
+ if width + char_width > max_width
81
+ result << current
82
+ current = +""
83
+ width = 0
84
+ end
85
+ current << char
86
+ width += char_width
87
+ end
88
+ result << current unless current.empty?
89
+ result
90
+ end
91
+ end
92
+ end