cli-ui 2.3.1 → 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,10 +206,45 @@ module CLI
214
206
  @redraw = true
215
207
  end
216
208
 
209
+ #: -> void
210
+ def first_option
211
+ @active = @filtered_options.first&.last || -1
212
+ @redraw = true
213
+ end
214
+
215
+ #: -> void
216
+ def last_option
217
+ @active = @filtered_options.last&.last || -1
218
+ @redraw = true
219
+ end
220
+
221
+ #: -> void
222
+ def next_page
223
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
224
+
225
+ previous_visible = @filtered_options[active_index + max_lines]
226
+ previous_visible ||= @filtered_options.last
227
+
228
+ @active = previous_visible ? previous_visible.last : -1
229
+ @redraw = true
230
+ end
231
+
232
+ #: -> void
233
+ def previous_page
234
+ active_index = @filtered_options.index { |_, num| num == @active } || 0
235
+
236
+ # do not jump into the end of the options if the subtraction result is non-positive
237
+ previous_visible = @filtered_options[active_index - max_lines] if active_index - max_lines >= 0
238
+ previous_visible ||= @filtered_options.first
239
+
240
+ @active = previous_visible ? previous_visible.last : -1
241
+ @redraw = true
242
+ end
243
+
217
244
  # n is 1-indexed selection
218
245
  # n == 0 if "Done" was selected in @multiple mode
219
- sig { params(n: Integer).void }
220
- def select_n(n)
246
+ #: (Integer n, ?final: bool) -> void
247
+ def select_n(n, final: false)
221
248
  if @multiple
222
249
  if n == 0
223
250
  @answer = []
@@ -230,7 +257,7 @@ module CLI
230
257
  end
231
258
  elsif n == 0
232
259
  # Ignore pressing "0" when not in multiple mode
233
- elsif should_enter_select_mode?(n)
260
+ elsif !final && should_enter_select_mode?(n)
234
261
  # When we have more than 9 options, we need to enter select mode
235
262
  # to avoid pre-selecting (e.g) 1 when the user wanted 10.
236
263
  # This also applies to 2 and 20+ options, 3/30+, etc.
@@ -243,7 +270,7 @@ module CLI
243
270
  @redraw = true
244
271
  end
245
272
 
246
- sig { params(n: Integer).returns(T::Boolean) }
273
+ #: (Integer n) -> bool
247
274
  def should_enter_select_mode?(n)
248
275
  # If we have less than 10 options, we don't need to enter select mode
249
276
  # and we can just select the option directly. This just keeps the code easier
@@ -257,29 +284,29 @@ module CLI
257
284
  @options.length >= (n * 10)
258
285
  end
259
286
 
260
- sig { params(char: String).void }
287
+ #: (String char) -> void
261
288
  def select_bool(char)
262
289
  return unless (@options - ['yes', 'no']).empty?
263
290
 
264
- index = T.must(@options.index { |o| o.start_with?(char) })
291
+ index = @options.index { |o| o.start_with?(char) } #: as !nil
265
292
  @active = index + 1
266
293
  @answer = index + 1
267
294
  @redraw = true
268
295
  end
269
296
 
270
- sig { params(char: String).void }
297
+ #: (String char) -> void
271
298
  def build_selection(char)
272
299
  @active = (@active.to_s + char).to_i
273
300
  @redraw = true
274
301
  end
275
302
 
276
- sig { void }
303
+ #: -> void
277
304
  def chop_selection
278
305
  @active = @active.to_s.chop.to_i
279
306
  @redraw = true
280
307
  end
281
308
 
282
- sig { params(char: String).void }
309
+ #: (String char) -> void
283
310
  def update_search(char)
284
311
  @redraw = true
285
312
 
@@ -297,22 +324,22 @@ module CLI
297
324
  end
298
325
  end
299
326
 
300
- sig { void }
327
+ #: -> void
301
328
  def select_current
302
329
  # Prevent selection of invisible options
303
330
  return unless presented_options.any? { |_, num| num == @active }
304
331
 
305
- select_n(@active)
332
+ select_n(@active, final: true)
306
333
  end
307
334
 
308
- sig { void }
335
+ #: -> void
309
336
  def process_input_until_redraw_required
310
337
  @redraw = false
311
338
  wait_for_user_input until @redraw
312
339
  end
313
340
 
314
341
  # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
315
- sig { void }
342
+ #: -> void
316
343
  def wait_for_user_input
317
344
  char = Prompt.read_char
318
345
  @last_char = char
@@ -363,48 +390,53 @@ module CLI
363
390
  when 'B' ; down
364
391
  when 'C' ; # Ignore right key
365
392
  when 'D' ; # Ignore left key
393
+ when '3' ; print("\a")
394
+ when '5' ; previous_page
395
+ when '6' ; next_page
396
+ when 'H' ; first_option
397
+ when 'F' ; last_option
366
398
  else ; raise Interrupt # unhandled escape sequence.
367
399
  end
368
400
  end
369
401
  end
370
402
  # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
371
403
 
372
- sig { returns(T::Boolean) }
404
+ #: -> bool
373
405
  def selecting?
374
406
  @state == :line_select
375
407
  end
376
408
 
377
- sig { returns(T::Boolean) }
409
+ #: -> bool
378
410
  def filtering?
379
411
  @state == :filter
380
412
  end
381
413
 
382
- sig { returns(T::Boolean) }
414
+ #: -> bool
383
415
  def has_filter?
384
416
  !@filter.empty?
385
417
  end
386
418
 
387
- sig { void }
419
+ #: -> void
388
420
  def start_filter
389
421
  @state = :filter
390
422
  @redraw = true
391
423
  end
392
424
 
393
- sig { void }
425
+ #: -> void
394
426
  def start_line_select
395
427
  @state = :line_select
396
428
  @active = 0
397
429
  @redraw = true
398
430
  end
399
431
 
400
- sig { void }
432
+ #: -> void
401
433
  def stop_line_select
402
434
  @state = :root
403
435
  @active = 1 if @active.zero?
404
436
  @redraw = true
405
437
  end
406
438
 
407
- sig { params(recalculate: T::Boolean).returns(T::Array[[String, T.nilable(Integer)]]) }
439
+ #: (?recalculate: bool) -> Array[[String, Integer?]]
408
440
  def presented_options(recalculate: false)
409
441
  return @presented_options unless recalculate
410
442
 
@@ -447,44 +479,44 @@ module CLI
447
479
  @presented_options
448
480
  end
449
481
 
450
- sig { void }
482
+ #: -> void
451
483
  def ensure_visible_is_active
452
484
  unless presented_options.any? { |_, num| num == @active }
453
485
  @active = presented_options.first&.last.to_i
454
486
  end
455
487
  end
456
488
 
457
- sig { returns(Integer) }
489
+ #: -> Integer
458
490
  def distance_from_selection_to_end
459
491
  @presented_options.count - index_of_active_option
460
492
  end
461
493
 
462
- sig { returns(Integer) }
494
+ #: -> Integer
463
495
  def distance_from_start_to_selection
464
496
  index_of_active_option
465
497
  end
466
498
 
467
- sig { returns(Integer) }
499
+ #: -> Integer
468
500
  def index_of_active_option
469
501
  @presented_options.index { |_, num| num == @active }.to_i
470
502
  end
471
503
 
472
- sig { void }
504
+ #: -> void
473
505
  def ensure_last_item_is_continuation_marker
474
506
  @presented_options.push(['...', nil]) if @presented_options.last&.last
475
507
  end
476
508
 
477
- sig { void }
509
+ #: -> void
478
510
  def ensure_first_item_is_continuation_marker
479
511
  @presented_options.unshift(['...', nil]) if @presented_options.first&.last
480
512
  end
481
513
 
482
- sig { returns(Integer) }
514
+ #: -> Integer
483
515
  def max_lines
484
516
  CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
485
517
  end
486
518
 
487
- sig { void }
519
+ #: -> void
488
520
  def render_options
489
521
  previously_displayed_lines = num_lines
490
522
 
@@ -535,7 +567,7 @@ module CLI
535
567
  end
536
568
  end
537
569
 
538
- sig { params(format: String, choice: String).returns(String) }
570
+ #: (String format, String choice) -> String
539
571
  def format_choice(format, choice)
540
572
  eol = CLI::UI::ANSI.clear_to_end_of_line
541
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