cli-ui 2.4.0 → 2.6.0

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,209 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module CLI
5
+ module UI
6
+ # Handles terminal progress bar reporting using ConEmu OSC 9;4 sequences
7
+ # Supports:
8
+ # - Numerical progress (0-100%)
9
+ # - Indeterminate/pulsing progress
10
+ # - Success/error states
11
+ # - Paused state
12
+ module ProgressReporter
13
+ # Progress reporter instance that manages the lifecycle of progress reporting
14
+ class Reporter
15
+ # OSC (Operating System Command) escape sequences
16
+ OSC = "\e]"
17
+ ST = "\a" # String Terminator (BEL)
18
+
19
+ # Progress states
20
+ REMOVE = 0
21
+ SET_PROGRESS = 1
22
+ ERROR = 2
23
+ INDETERMINATE = 3
24
+ PAUSED = 4
25
+
26
+ #: (Symbol mode, ?io_like to, ?parent: Reporter?, ?delay_start: bool) -> void
27
+ def initialize(mode, to = $stdout, parent: nil, delay_start: false)
28
+ @mode = mode
29
+ @to = to
30
+ @parent = parent
31
+ @children = []
32
+ @active = ProgressReporter.supports_progress? && @parent.nil?
33
+
34
+ # Register with parent if nested
35
+ @parent&.add_child(self)
36
+
37
+ return unless @active
38
+ return if delay_start # Don't emit initial OSC if delayed
39
+
40
+ case mode
41
+ when :indeterminate
42
+ set_indeterminate
43
+ when :progress
44
+ set_progress(0)
45
+ else
46
+ raise ArgumentError, "Unknown progress mode: #{mode}"
47
+ end
48
+ end
49
+
50
+ #: (Reporter child) -> void
51
+ def add_child(child)
52
+ @children << child
53
+ end
54
+
55
+ #: (Reporter child) -> void
56
+ def remove_child(child)
57
+ @children.delete(child)
58
+ end
59
+
60
+ #: -> bool
61
+ def has_active_children?
62
+ @children.any?
63
+ end
64
+
65
+ #: (Integer percentage) -> void
66
+ def set_progress(percentage) # rubocop:disable Naming/AccessorMethodName
67
+ # Don't emit progress if we have active children (they own the progress)
68
+ return if has_active_children?
69
+ return unless @active
70
+
71
+ @mode = :progress # Update mode when switching to progress
72
+ percentage = percentage.clamp(0, 100)
73
+ @to.print("#{OSC}9;4;#{SET_PROGRESS};#{percentage}#{ST}")
74
+ end
75
+
76
+ #: -> void
77
+ def set_indeterminate
78
+ # Don't emit progress if we have active children
79
+ return if has_active_children?
80
+ return unless @active
81
+
82
+ @mode = :indeterminate # Update mode when switching to indeterminate
83
+ @to.print("#{OSC}9;4;#{INDETERMINATE};#{ST}")
84
+ end
85
+
86
+ # Force progress mode even if there are children - used by SpinGroup
87
+ # when a task needs to show deterministic progress
88
+ #: (Integer percentage) -> void
89
+ def force_set_progress(percentage)
90
+ return unless @active
91
+
92
+ @mode = :progress
93
+ percentage = percentage.clamp(0, 100)
94
+ @to.print("#{OSC}9;4;#{SET_PROGRESS};#{percentage}#{ST}")
95
+ end
96
+
97
+ # Force indeterminate mode even if there are children
98
+ #: -> void
99
+ def force_set_indeterminate
100
+ return unless @active
101
+
102
+ @mode = :indeterminate
103
+ @to.print("#{OSC}9;4;#{INDETERMINATE};#{ST}")
104
+ end
105
+
106
+ #: -> void
107
+ def set_error
108
+ # Error state can be set even with children
109
+ return unless @active
110
+
111
+ @to.print("#{OSC}9;4;#{ERROR};#{ST}")
112
+ end
113
+
114
+ #: (?Integer? percentage) -> void
115
+ def set_paused(percentage = nil)
116
+ return if has_active_children?
117
+ return unless @active
118
+
119
+ if percentage
120
+ percentage = percentage.clamp(0, 100)
121
+ @to.print("#{OSC}9;4;#{PAUSED};#{percentage}#{ST}")
122
+ else
123
+ @to.print("#{OSC}9;4;#{PAUSED};#{ST}")
124
+ end
125
+ end
126
+
127
+ #: -> void
128
+ def clear
129
+ # Only clear if we're the root reporter and have no active children
130
+ return unless @active
131
+ return if has_active_children?
132
+
133
+ @to.print("#{OSC}9;4;#{REMOVE};#{ST}")
134
+ end
135
+
136
+ #: -> void
137
+ def cleanup
138
+ # Remove self from parent's children list
139
+ @parent&.remove_child(self)
140
+
141
+ # If parent exists and has no more children, restore its progress state
142
+ if @parent && !@parent.has_active_children?
143
+ case @parent.instance_variable_get(:@mode)
144
+ when :indeterminate
145
+ @parent.set_indeterminate
146
+ when :progress
147
+ # Parent progress bar should maintain its last state
148
+ # The parent will handle re-emitting its progress on next tick
149
+ end
150
+ elsif !@parent
151
+ # We're the root, clear progress
152
+ clear
153
+ end
154
+ end
155
+ end
156
+
157
+ class << self
158
+ # Thread-local storage for the current reporter stack
159
+ #: -> Array[Reporter]
160
+ def reporter_stack
161
+ Thread.current[:progress_reporter_stack] ||= []
162
+ end
163
+
164
+ #: -> Reporter?
165
+ def current_reporter
166
+ reporter_stack.last
167
+ end
168
+
169
+ # Block-based API that ensures progress is cleared
170
+ #: [T] (?mode: Symbol, ?to: io_like, ?delay_start: bool) { (Reporter reporter) -> T } -> T
171
+ def with_progress(mode: :indeterminate, to: $stdout, delay_start: false, &block)
172
+ parent = current_reporter
173
+ reporter = Reporter.new(mode, to, parent: parent, delay_start: delay_start)
174
+
175
+ reporter_stack.push(reporter)
176
+ yield(reporter)
177
+ ensure
178
+ reporter_stack.pop
179
+ reporter&.cleanup
180
+ end
181
+
182
+ #: -> bool
183
+ def supports_progress?
184
+ # Check if terminal supports ConEmu OSC sequences
185
+ # This is supported by:
186
+ # - ConEmu on Windows
187
+ # - Windows Terminal
188
+ # - Ghostty
189
+ # - Various terminals on Linux (with OSC 9;4 support)
190
+
191
+ # Check common environment variables that indicate support
192
+ return true if ENV['ConEmuPID']
193
+ return true if ENV['WT_SESSION'] # Windows Terminal
194
+ return true if ENV['GHOSTTY_RESOURCES_DIR'] # Ghostty
195
+
196
+ # Check TERM_PROGRAM for known supporting terminals
197
+ term_program = ENV['TERM_PROGRAM']
198
+ return true if term_program == 'ghostty'
199
+
200
+ # For now, we'll be conservative and only enable for known terminals
201
+ # Users can force-enable with an environment variable
202
+ return true if ENV['CLI_UI_ENABLE_PROGRESS'] == '1'
203
+
204
+ false
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -8,14 +8,10 @@ module CLI
8
8
  module UI
9
9
  module Prompt
10
10
  class InteractiveOptions
11
- extend T::Sig
12
-
13
11
  DONE = 'Done'
14
12
  CHECKBOX_ICON = { false => '☐', true => '☑' }
15
13
 
16
14
  class << self
17
- extend T::Sig
18
-
19
15
  # Prompts the user with options
20
16
  # Uses an interactive session to allow the user to pick an answer
21
17
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
@@ -30,18 +26,17 @@ module CLI
30
26
  # Ask an interactive question
31
27
  # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
32
28
  #
33
- sig do
34
- params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String])))
35
- .returns(T.any(String, T::Array[String]))
36
- end
29
+ #: (Array[String] options, ?multiple: bool, ?default: (String | Array[String])?) -> (String | Array[String])
37
30
  def call(options, multiple: false, default: nil)
38
31
  list = new(options, multiple: multiple, default: default)
39
32
  selected = list.call
40
33
  case selected
41
34
  when Array
42
- selected.map { |s| T.must(options[s - 1]) }
35
+ selected.map do |s|
36
+ options[s - 1] #: as !nil
37
+ end
43
38
  else
44
- T.must(options[selected - 1])
39
+ options[selected - 1] #: as !nil
45
40
  end
46
41
  end
47
42
  end
@@ -53,10 +48,7 @@ module CLI
53
48
  #
54
49
  # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
55
50
  #
56
- sig do
57
- params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String])))
58
- .void
59
- end
51
+ #: (Array[String] options, ?multiple: bool, ?default: (String | Array[String])?) -> void
60
52
  def initialize(options, multiple: false, default: nil)
61
53
  @options = options
62
54
  @active = if default && (i = options.index(default))
@@ -82,13 +74,13 @@ module CLI
82
74
  end
83
75
  end
84
76
  @redraw = true
85
- @presented_options = T.let([], T::Array[[String, T.nilable(Integer)]])
77
+ @presented_options = [] #: Array[[String, Integer?]]
86
78
  end
87
79
 
88
80
  # Calls the +InteractiveOptions+ and asks the question
89
81
  # Usually used from +self.call+
90
82
  #
91
- sig { returns(T.any(Integer, T::Array[Integer])) }
83
+ #: -> (Integer | Array[Integer])
92
84
  def call
93
85
  calculate_option_line_lengths
94
86
  CLI::UI.raw { print(ANSI.hide_cursor) }
@@ -108,7 +100,7 @@ module CLI
108
100
 
109
101
  private
110
102
 
111
- sig { void }
103
+ #: -> void
112
104
  def calculate_option_line_lengths
113
105
  @terminal_width_at_calculation_time = CLI::UI::Terminal.width
114
106
  # options will be an array of questions but each option can be multi-line
@@ -138,7 +130,7 @@ module CLI
138
130
  end
139
131
  end
140
132
 
141
- sig { params(number_of_lines: Integer).void }
133
+ #: (?Integer number_of_lines) -> void
142
134
  def reset_position(number_of_lines = num_lines)
143
135
  # This will put us back at the beginning of the options
144
136
  # When we redraw the options, they will be overwritten
@@ -147,7 +139,7 @@ module CLI
147
139
  end
148
140
  end
149
141
 
150
- sig { params(number_of_lines: Integer).void }
142
+ #: (?Integer number_of_lines) -> void
151
143
  def clear_output(number_of_lines = num_lines)
152
144
  CLI::UI.raw do
153
145
  # Write over all lines with whitespace
@@ -163,12 +155,12 @@ module CLI
163
155
 
164
156
  # Don't use this in place of +@displaying_metadata+, this updates too
165
157
  # quickly to be useful when drawing to the screen.
166
- sig { returns(T::Boolean) }
158
+ #: -> bool
167
159
  def display_metadata?
168
160
  filtering? || selecting? || has_filter?
169
161
  end
170
162
 
171
- sig { returns(Integer) }
163
+ #: -> Integer
172
164
  def num_lines
173
165
  calculate_option_line_lengths if terminal_width_changed?
174
166
 
@@ -182,7 +174,7 @@ module CLI
182
174
  option_length + (@displaying_metadata ? 1 : 0)
183
175
  end
184
176
 
185
- sig { returns(T::Boolean) }
177
+ #: -> bool
186
178
  def terminal_width_changed?
187
179
  @terminal_width_at_calculation_time != CLI::UI::Terminal.width
188
180
  end
@@ -192,7 +184,7 @@ module CLI
192
184
  CTRL_C = "\u0003"
193
185
  CTRL_D = "\u0004"
194
186
 
195
- sig { void }
187
+ #: -> void
196
188
  def up
197
189
  active_index = @filtered_options.index { |_, num| num == @active } || 0
198
190
 
@@ -203,7 +195,7 @@ module CLI
203
195
  @redraw = true
204
196
  end
205
197
 
206
- sig { void }
198
+ #: -> void
207
199
  def down
208
200
  active_index = @filtered_options.index { |_, num| num == @active } || 0
209
201
 
@@ -214,19 +206,19 @@ module CLI
214
206
  @redraw = true
215
207
  end
216
208
 
217
- sig { void }
209
+ #: -> void
218
210
  def first_option
219
211
  @active = @filtered_options.first&.last || -1
220
212
  @redraw = true
221
213
  end
222
214
 
223
- sig { void }
215
+ #: -> void
224
216
  def last_option
225
217
  @active = @filtered_options.last&.last || -1
226
218
  @redraw = true
227
219
  end
228
220
 
229
- sig { void }
221
+ #: -> void
230
222
  def next_page
231
223
  active_index = @filtered_options.index { |_, num| num == @active } || 0
232
224
 
@@ -237,7 +229,7 @@ module CLI
237
229
  @redraw = true
238
230
  end
239
231
 
240
- sig { void }
232
+ #: -> void
241
233
  def previous_page
242
234
  active_index = @filtered_options.index { |_, num| num == @active } || 0
243
235
 
@@ -251,7 +243,7 @@ module CLI
251
243
 
252
244
  # n is 1-indexed selection
253
245
  # n == 0 if "Done" was selected in @multiple mode
254
- sig { params(n: Integer, final: T::Boolean).void }
246
+ #: (Integer n, ?final: bool) -> void
255
247
  def select_n(n, final: false)
256
248
  if @multiple
257
249
  if n == 0
@@ -278,7 +270,7 @@ module CLI
278
270
  @redraw = true
279
271
  end
280
272
 
281
- sig { params(n: Integer).returns(T::Boolean) }
273
+ #: (Integer n) -> bool
282
274
  def should_enter_select_mode?(n)
283
275
  # If we have less than 10 options, we don't need to enter select mode
284
276
  # and we can just select the option directly. This just keeps the code easier
@@ -292,29 +284,29 @@ module CLI
292
284
  @options.length >= (n * 10)
293
285
  end
294
286
 
295
- sig { params(char: String).void }
287
+ #: (String char) -> void
296
288
  def select_bool(char)
297
289
  return unless (@options - ['yes', 'no']).empty?
298
290
 
299
- index = T.must(@options.index { |o| o.start_with?(char) })
291
+ index = @options.index { |o| o.start_with?(char) } #: as !nil
300
292
  @active = index + 1
301
293
  @answer = index + 1
302
294
  @redraw = true
303
295
  end
304
296
 
305
- sig { params(char: String).void }
297
+ #: (String char) -> void
306
298
  def build_selection(char)
307
299
  @active = (@active.to_s + char).to_i
308
300
  @redraw = true
309
301
  end
310
302
 
311
- sig { void }
303
+ #: -> void
312
304
  def chop_selection
313
305
  @active = @active.to_s.chop.to_i
314
306
  @redraw = true
315
307
  end
316
308
 
317
- sig { params(char: String).void }
309
+ #: (String char) -> void
318
310
  def update_search(char)
319
311
  @redraw = true
320
312
 
@@ -332,7 +324,7 @@ module CLI
332
324
  end
333
325
  end
334
326
 
335
- sig { void }
327
+ #: -> void
336
328
  def select_current
337
329
  # Prevent selection of invisible options
338
330
  return unless presented_options.any? { |_, num| num == @active }
@@ -340,14 +332,14 @@ module CLI
340
332
  select_n(@active, final: true)
341
333
  end
342
334
 
343
- sig { void }
335
+ #: -> void
344
336
  def process_input_until_redraw_required
345
337
  @redraw = false
346
338
  wait_for_user_input until @redraw
347
339
  end
348
340
 
349
341
  # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
350
- sig { void }
342
+ #: -> void
351
343
  def wait_for_user_input
352
344
  char = Prompt.read_char
353
345
  @last_char = char
@@ -409,42 +401,42 @@ module CLI
409
401
  end
410
402
  # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
411
403
 
412
- sig { returns(T::Boolean) }
404
+ #: -> bool
413
405
  def selecting?
414
406
  @state == :line_select
415
407
  end
416
408
 
417
- sig { returns(T::Boolean) }
409
+ #: -> bool
418
410
  def filtering?
419
411
  @state == :filter
420
412
  end
421
413
 
422
- sig { returns(T::Boolean) }
414
+ #: -> bool
423
415
  def has_filter?
424
416
  !@filter.empty?
425
417
  end
426
418
 
427
- sig { void }
419
+ #: -> void
428
420
  def start_filter
429
421
  @state = :filter
430
422
  @redraw = true
431
423
  end
432
424
 
433
- sig { void }
425
+ #: -> void
434
426
  def start_line_select
435
427
  @state = :line_select
436
428
  @active = 0
437
429
  @redraw = true
438
430
  end
439
431
 
440
- sig { void }
432
+ #: -> void
441
433
  def stop_line_select
442
434
  @state = :root
443
435
  @active = 1 if @active.zero?
444
436
  @redraw = true
445
437
  end
446
438
 
447
- sig { params(recalculate: T::Boolean).returns(T::Array[[String, T.nilable(Integer)]]) }
439
+ #: (?recalculate: bool) -> Array[[String, Integer?]]
448
440
  def presented_options(recalculate: false)
449
441
  return @presented_options unless recalculate
450
442
 
@@ -487,44 +479,44 @@ module CLI
487
479
  @presented_options
488
480
  end
489
481
 
490
- sig { void }
482
+ #: -> void
491
483
  def ensure_visible_is_active
492
484
  unless presented_options.any? { |_, num| num == @active }
493
485
  @active = presented_options.first&.last.to_i
494
486
  end
495
487
  end
496
488
 
497
- sig { returns(Integer) }
489
+ #: -> Integer
498
490
  def distance_from_selection_to_end
499
491
  @presented_options.count - index_of_active_option
500
492
  end
501
493
 
502
- sig { returns(Integer) }
494
+ #: -> Integer
503
495
  def distance_from_start_to_selection
504
496
  index_of_active_option
505
497
  end
506
498
 
507
- sig { returns(Integer) }
499
+ #: -> Integer
508
500
  def index_of_active_option
509
501
  @presented_options.index { |_, num| num == @active }.to_i
510
502
  end
511
503
 
512
- sig { void }
504
+ #: -> void
513
505
  def ensure_last_item_is_continuation_marker
514
506
  @presented_options.push(['...', nil]) if @presented_options.last&.last
515
507
  end
516
508
 
517
- sig { void }
509
+ #: -> void
518
510
  def ensure_first_item_is_continuation_marker
519
511
  @presented_options.unshift(['...', nil]) if @presented_options.first&.last
520
512
  end
521
513
 
522
- sig { returns(Integer) }
514
+ #: -> Integer
523
515
  def max_lines
524
516
  CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
525
517
  end
526
518
 
527
- sig { void }
519
+ #: -> void
528
520
  def render_options
529
521
  previously_displayed_lines = num_lines
530
522
 
@@ -575,7 +567,7 @@ module CLI
575
567
  end
576
568
  end
577
569
 
578
- sig { params(format: String, choice: String).returns(String) }
570
+ #: (String format, String choice) -> String
579
571
  def format_choice(format, choice)
580
572
  eol = CLI::UI::ANSI.clear_to_end_of_line
581
573
  lines = choice.split("\n")
@@ -6,24 +6,22 @@ module CLI
6
6
  module Prompt
7
7
  # A class that handles the various options of an InteractivePrompt and their callbacks
8
8
  class OptionsHandler
9
- extend T::Sig
10
-
11
- sig { void }
9
+ #: -> void
12
10
  def initialize
13
11
  @options = {}
14
12
  end
15
13
 
16
- sig { returns(T::Array[String]) }
14
+ #: -> Array[String]
17
15
  def options
18
16
  @options.keys
19
17
  end
20
18
 
21
- sig { params(option: String, handler: T.proc.params(option: String).returns(String)).void }
19
+ #: (String option) { (String option) -> String } -> void
22
20
  def option(option, &handler)
23
21
  @options[option] = handler
24
22
  end
25
23
 
26
- sig { params(options: T.any(T::Array[String], String)).returns(String) }
24
+ #: ((Array[String] | String) options) -> String
27
25
  def call(options)
28
26
  case options
29
27
  when Array