cli-ui 1.2.3 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71e70e5d4866f5af664b7fe2393ae41e74e4995a3e9514b42cccaae9bdf5e62a
4
- data.tar.gz: 30742fe01d001bb0b876c874efb2e2878e120b13334d15d2cc964e0bb0b0ac83
3
+ metadata.gz: 48913d44cb2cdae4f699a6f456cce15ca8b50b450317e4734f4bcd8062982d39
4
+ data.tar.gz: ae1d337eb7b280001153a25a5e92ee9b2e534b47d5e95c79a2b9a20371bdadf0
5
5
  SHA512:
6
- metadata.gz: ba955e4dab0ddc389e1f878f2ddc689a4cb5581aafe7e96f4eacaf7cf7cc79b23081bd2fbf21a85c648b679f265d6f442e0ac1b8abde62c14d4f1dc5c258c90c
7
- data.tar.gz: 1de7e63523615f6a39e756e106fb78000b05be0fa1ad00d70e471ba6b0b39fcfa702e624fa58d36d3d49da58526899337f33a5395b8f74513b726b84b04584d4
6
+ metadata.gz: 1e87d0b821c5911d1e10680619522b5f36a19213042e187cc54097850dd2ccb986db4a6dbf800234c43102377d168d6a5dda573f577763dff754e4cee58fc50b
7
+ data.tar.gz: 2cfb1d76a7b6f49f1f14eadf7237c644a2c2f1896dfff8dcaff9c5b68d354194df0da34339817d8153ef6815ff2898d15b6bb7bd6bb68198bdc614505072aaf3
@@ -0,0 +1 @@
1
+ * @Shopify/dev-infra
data/README.md CHANGED
@@ -115,6 +115,19 @@ puts CLI::UI.fmt "{{*}} {{x}} {{?}} {{v}}"
115
115
 
116
116
  ---
117
117
 
118
+ ### Status Widget
119
+
120
+ ```ruby
121
+ CLI::UI::Spinner.spin("building packages: {{@widget/status:1:2:3:4}}") do |spinner|
122
+ # spinner.update_title(...)
123
+ sleep(3)
124
+ end
125
+ ```
126
+
127
+ ![Status Widget](https://user-images.githubusercontent.com/1284/61405142-11042580-a8a7-11e9-9885-46ba44c46358.gif)
128
+
129
+ ---
130
+
118
131
  ### Progress Bar
119
132
 
120
133
  Show progress of a process or operation.
@@ -11,6 +11,7 @@ module CLI
11
11
  autoload :Truncater, 'cli/ui/truncater'
12
12
  autoload :Formatter, 'cli/ui/formatter'
13
13
  autoload :Spinner, 'cli/ui/spinner'
14
+ autoload :Widgets, 'cli/ui/widgets'
14
15
 
15
16
  # Convenience accessor to +CLI::UI::Spinner::SpinGroup+
16
17
  SpinGroup = Spinner::SpinGroup
@@ -31,6 +31,9 @@ module CLI
31
31
  BOLD = new('1', :bold)
32
32
  WHITE = new('97', :white)
33
33
 
34
+ # 240 is very dark gray; 255 is very light gray. 244 is somewhat dark.
35
+ GRAY = new('38;5;244', :grey)
36
+
34
37
  MAP = {
35
38
  red: RED,
36
39
  green: GREEN,
@@ -40,6 +43,7 @@ module CLI
40
43
  cyan: CYAN,
41
44
  reset: RESET,
42
45
  bold: BOLD,
46
+ gray: GRAY,
43
47
  }.freeze
44
48
 
45
49
  class InvalidColorName < ArgumentError
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
-
3
- require 'cli/ui'
4
- require 'strscan'
2
+ require('cli/ui')
3
+ require('strscan')
5
4
 
6
5
  module CLI
7
6
  module UI
@@ -16,7 +15,7 @@ module CLI
16
15
  'red' => '31',
17
16
  'green' => '32',
18
17
  'yellow' => '33',
19
- # default blue is low-contrast against black in some default terminal color scheme
18
+ # default blue is low-contrast against black in some default terminal color scheme
20
19
  'blue' => '94', # 9x = high-intensity fg color x
21
20
  'magenta' => '35',
22
21
  'cyan' => '36',
@@ -36,16 +35,17 @@ module CLI
36
35
  BEGIN_EXPR = '{{'
37
36
  END_EXPR = '}}'
38
37
 
38
+ SCAN_WIDGET = %r[@widget/(?<handle>\w+):(?<args>.*?)}}]
39
39
  SCAN_FUNCNAME = /\w+:/
40
40
  SCAN_GLYPH = /.}}/
41
- SCAN_BODY = /
41
+ SCAN_BODY = %r{
42
42
  .*?
43
43
  (
44
44
  #{BEGIN_EXPR} |
45
45
  #{END_EXPR} |
46
46
  \z
47
47
  )
48
- /mx
48
+ }mx
49
49
 
50
50
  DISCARD_BRACES = 0..-3
51
51
 
@@ -123,7 +123,7 @@ module CLI
123
123
  end
124
124
 
125
125
  def parse_expr(sc, stack)
126
- if match = sc.scan(SCAN_GLYPH)
126
+ if (match = sc.scan(SCAN_GLYPH))
127
127
  glyph_handle = match[0]
128
128
  begin
129
129
  glyph = Glyph.lookup(glyph_handle)
@@ -136,7 +136,20 @@ module CLI
136
136
  index
137
137
  )
138
138
  end
139
- elsif match = sc.scan(SCAN_FUNCNAME)
139
+ elsif (match = sc.scan(SCAN_WIDGET))
140
+ match_data = SCAN_WIDGET.match(match) # Regexp.last_match doesn't work here
141
+ widget_handle = match_data['handle']
142
+ begin
143
+ widget = Widgets.lookup(widget_handle)
144
+ emit(widget.call(match_data['args']), stack)
145
+ rescue Widgets::InvalidWidgetHandle
146
+ index = sc.pos - 2 # rewind past '}}'
147
+ raise(FormatError.new(
148
+ "invalid widget handle at index #{index}: '#{widget_handle}'",
149
+ @text, index,
150
+ ))
151
+ end
152
+ elsif (match = sc.scan(SCAN_FUNCNAME))
140
153
  funcname = match.chop
141
154
  stack.push(funcname)
142
155
  else
@@ -249,26 +249,32 @@ module CLI
249
249
 
250
250
  o = +''
251
251
 
252
- is_ci = ![0, '', nil].include?(ENV['CI'])
252
+ # Shopify's CI system supports terminal emulation, but not some of
253
+ # the fancier features that we normally use to draw frames
254
+ # extra-reliably, so we fall back to a less foolproof strategy. This
255
+ # is probably better in general for cases with impoverished terminal
256
+ # emulators and no active user.
257
+ if (is_ci = ![0, '', nil].include?(ENV['CI']))
258
+ linewidth = [0, termwidth - (prefix_width + suffix_width)].max
259
+
260
+ o << color.code << prefix
261
+ o << color.code << (CLI::UI::Box::Heavy::HORZ * linewidth)
262
+ o << color.code << suffix
263
+ o << CLI::UI::Color::RESET.code << "\n"
264
+ return o
265
+ end
253
266
 
254
267
  # Jumping around the line can cause some unwanted flashes
255
268
  o << CLI::UI::ANSI.hide_cursor
256
269
 
257
- o << if is_ci
258
- # In CI, we can't use absolute horizontal positions because of timestamps.
259
- # So we move around the line by offset from this cursor position.
260
- CLI::UI::ANSI.cursor_save
261
- else
262
- # Outside of CI, we reset to column 1 so that things like ^C don't
263
- # cause output misformatting.
264
- "\r"
265
- end
270
+ # reset to column 1 so that things like ^C don't ruin formatting
271
+ o << "\r"
266
272
 
267
273
  o << color.code
268
274
  o << CLI::UI::Box::Heavy::HORZ * termwidth # draw a full line
269
- o << print_at_x(prefix_start, prefix, is_ci)
275
+ o << print_at_x(prefix_start, prefix)
270
276
  o << color.code
271
- o << print_at_x(suffix_start, suffix, is_ci)
277
+ o << print_at_x(suffix_start, suffix)
272
278
  o << CLI::UI::Color::RESET.code
273
279
  o << CLI::UI::ANSI.show_cursor
274
280
  o << "\n"
@@ -276,12 +282,8 @@ module CLI
276
282
  o
277
283
  end
278
284
 
279
- def print_at_x(x, str, is_ci)
280
- if is_ci
281
- CLI::UI::ANSI.cursor_restore + CLI::UI::ANSI.cursor_forward(x) + str
282
- else
283
- CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
284
- end
285
+ def print_at_x(x, str)
286
+ CLI::UI::ANSI.cursor_horizontal_absolute(1 + x) + str
285
287
  end
286
288
 
287
289
  module FrameStack
@@ -29,7 +29,7 @@ module CLI
29
29
  @handle = handle
30
30
  @codepoint = codepoint
31
31
  @color = color
32
- @char = [codepoint].pack('U')
32
+ @char = Array(codepoint).pack('U*')
33
33
  @to_s = color.code + char + Color::RESET.code
34
34
  @fmt = "{{#{color.name}:#{char}}}"
35
35
 
@@ -38,20 +38,14 @@ module CLI
38
38
 
39
39
  # Mapping of glyphs to terminal output
40
40
  MAP = {}
41
- # YELLOw SMALL STAR (⭑)
42
- STAR = new('*', 0x2b51, Color::YELLOW)
43
- # BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
44
- INFO = new('i', 0x1d4be, Color::BLUE)
45
- # BLUE QUESTION MARK (?)
46
- QUESTION = new('?', 0x003f, Color::BLUE)
47
- # GREEN CHECK MARK ()
48
- CHECK = new('v', 0x2713, Color::GREEN)
49
- # RED BALLOT X (✗)
50
- X = new('x', 0x2717, Color::RED)
51
- # Bug emoji (🐛)
52
- BUG = new('b', 0x1f41b, Color::WHITE)
53
- # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (»)
54
- CHEVRON = new('>', 0xbb, Color::YELLOW)
41
+ STAR = new('*', 0x2b51, Color::YELLOW) # YELLOW SMALL STAR (⭑)
42
+ INFO = new('i', 0x1d4be, Color::BLUE) # BLUE MATHEMATICAL SCRIPT SMALL i (𝒾)
43
+ QUESTION = new('?', 0x003f, Color::BLUE) # BLUE QUESTION MARK (?)
44
+ CHECK = new('v', 0x2713, Color::GREEN) # GREEN CHECK MARK (✓)
45
+ X = new('x', 0x2717, Color::RED) # RED BALLOT X ()
46
+ BUG = new('b', 0x1f41b, Color::WHITE) # Bug emoji (🐛)
47
+ CHEVRON = new('>', 0xbb, Color::YELLOW) # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (»)
48
+ HOURGLASS = new('H', [0x231b, 0xfe0e], Color::BLUE) # HOURGLASS + VARIATION SELECTOR 15 (⌛︎)
55
49
 
56
50
  # Looks up a glyph by name
57
51
  #
@@ -88,6 +88,33 @@ module CLI
88
88
  end
89
89
  end
90
90
 
91
+ # Asks the user for a single-line answer, without displaying the characters while typing.
92
+ # Typically used for password prompts
93
+ #
94
+ # ==== Return Value
95
+ #
96
+ # The password, without a trailing newline.
97
+ # If the user simply presses "Enter" without typing any password, this will return an empty string.
98
+ def ask_password(question)
99
+ require 'io/console'
100
+
101
+ CLI::UI.with_frame_color(:blue) do
102
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
103
+
104
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
105
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
106
+ password = STDIN.noecho do
107
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
108
+ # " 123 \n".chomp => " 123 "
109
+ STDIN.gets.chomp
110
+ end
111
+
112
+ STDOUT.puts # Complete the line
113
+
114
+ password
115
+ end
116
+ end
117
+
91
118
  # Asks the user a yes/no question.
92
119
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
93
120
  #
@@ -288,6 +288,8 @@ module CLI
288
288
  case char
289
289
  when 'A' ; up
290
290
  when 'B' ; down
291
+ when 'C' ; # Ignore right key
292
+ when 'D' ; # Ignore left key
291
293
  else ; raise Interrupt # unhandled escape sequence.
292
294
  end
293
295
  end
@@ -1,3 +1,4 @@
1
+ # frozen-string-literal: true
1
2
  require 'cli/ui'
2
3
 
3
4
  module CLI
@@ -9,11 +10,30 @@ module CLI
9
10
  PERIOD = 0.1 # seconds
10
11
  TASK_FAILED = :task_failed
11
12
 
13
+ RUNES = %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏).freeze
14
+
12
15
  begin
13
- runes = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
14
16
  colors = [CLI::UI::Color::CYAN.code] * 5 + [CLI::UI::Color::MAGENTA.code] * 5
15
- raise unless runes.size == colors.size
16
- GLYPHS = colors.zip(runes).map(&:join)
17
+ raise unless RUNES.size == colors.size
18
+ GLYPHS = colors.zip(RUNES).map(&:join)
19
+ end
20
+
21
+ class << self
22
+ attr_accessor(:index)
23
+
24
+ # We use this from CLI::UI::Widgets::Status to render an additional
25
+ # spinner next to the "working" element. While this global state looks
26
+ # a bit repulsive at first, it's worth realizing that:
27
+ #
28
+ # * It's managed by the SpinGroup#wait method, not individual tasks; and
29
+ # * It would be complete insanity to run two separate but concurrent SpinGroups.
30
+ #
31
+ # While it would be possible to stitch through some connection between
32
+ # the SpinGroup and the Widgets included in its title, this is simpler
33
+ # in practice and seems unlikely to cause issues in practice.
34
+ def current_rune
35
+ RUNES[index || 0]
36
+ end
17
37
  end
18
38
 
19
39
  # Adds a single spinner
@@ -40,6 +40,7 @@ module CLI
40
40
  #
41
41
  def initialize(title, &block)
42
42
  @title = title
43
+ @always_full_render = title =~ Formatter::SCAN_WIDGET
43
44
  @thread = Thread.new do
44
45
  cap = CLI::UI::StdoutRouter::Capture.new(self, with_frame_inset: false, &block)
45
46
  begin
@@ -75,7 +76,17 @@ module CLI
75
76
  @done
76
77
  end
77
78
 
78
- # Re-renders the task if required
79
+ # Re-renders the task if required:
80
+ #
81
+ # We try to be as lazy as possible in re-rendering the full line. The
82
+ # spinner rune will change on each render for the most part, but the
83
+ # body text will rarely have changed. If the body text *has* changed,
84
+ # we set @force_full_render.
85
+ #
86
+ # Further, if the title string includes any CLI::UI::Widgets, we
87
+ # assume that it may change from render to render, since those
88
+ # evaluate more dynamically than the rest of our format codes, which
89
+ # are just text formatters. This is controlled by @always_full_render.
79
90
  #
80
91
  # ==== Attributes
81
92
  #
@@ -84,8 +95,11 @@ module CLI
84
95
  # * +width+ - current terminal width to format for
85
96
  #
86
97
  def render(index, force = true, width: CLI::UI::Terminal.width)
87
- return full_render(index, width) if force || @force_full_render
88
- partial_render(index)
98
+ if force || @always_full_render || @force_full_render
99
+ full_render(index, width)
100
+ else
101
+ partial_render(index)
102
+ end
89
103
  ensure
90
104
  @force_full_render = false
91
105
  end
@@ -97,6 +111,7 @@ module CLI
97
111
  # * +title+ - title to change the spinner to
98
112
  #
99
113
  def update_title(new_title)
114
+ @always_full_render = new_title =~ Formatter::SCAN_WIDGET
100
115
  @title = new_title
101
116
  @force_full_render = true
102
117
  end
@@ -194,6 +209,7 @@ module CLI
194
209
  break if all_done
195
210
 
196
211
  idx = (idx + 1) % GLYPHS.size
212
+ Spinner.index = idx
197
213
  sleep(PERIOD)
198
214
  end
199
215
 
@@ -1,5 +1,5 @@
1
1
  module CLI
2
2
  module UI
3
- VERSION = "1.2.3"
3
+ VERSION = "1.3.0"
4
4
  end
5
5
  end
@@ -0,0 +1,75 @@
1
+ require('cli/ui')
2
+
3
+ module CLI
4
+ module UI
5
+ # Widgets are formatter objects with more custom implementations than the
6
+ # other features, which all center around formatting text with colours,
7
+ # etc.
8
+ #
9
+ # If you want to extend CLI::UI with your own widgets, you may want to do
10
+ # something like this:
11
+ #
12
+ # require('cli/ui')
13
+ # class MyWidget < CLI::UI::Widgets::Base
14
+ # # ...
15
+ # end
16
+ # CLI::UI::Widgets.register('my-widget') { MyWidget }
17
+ # puts(CLI::UI.fmt("{{@widget/my-widget:args}}"))
18
+ module Widgets
19
+ MAP = {}
20
+
21
+ autoload(:Base, 'cli/ui/widgets/base')
22
+
23
+ def self.register(name, &cb)
24
+ MAP[name] = cb
25
+ end
26
+
27
+ autoload(:Status, 'cli/ui/widgets/status')
28
+ register('status') { Widgets::Status }
29
+
30
+ # Looks up a widget by handle
31
+ #
32
+ # ==== Raises
33
+ # Raises InvalidWidgetHandle if the widget is not available.
34
+ #
35
+ # ==== Returns
36
+ # A callable widget, to be invoked like `.call(argstring)`
37
+ #
38
+ def self.lookup(handle)
39
+ MAP.fetch(handle.to_s).call
40
+ rescue KeyError, NameError
41
+ raise(InvalidWidgetHandle, handle)
42
+ end
43
+
44
+ # All available widgets by name
45
+ #
46
+ def self.available
47
+ MAP.keys
48
+ end
49
+
50
+ class InvalidWidgetHandle < ArgumentError
51
+ def initialize(handle)
52
+ @handle = handle
53
+ end
54
+
55
+ def message
56
+ keys = Widget.available.join(',')
57
+ "invalid widget handle: #{@handle} " \
58
+ "-- must be one of CLI::UI::Widgets.available (#{keys})"
59
+ end
60
+ end
61
+
62
+ class InvalidWidgetArguments < ArgumentError
63
+ def initialize(argstring, pattern)
64
+ @argstring = argstring
65
+ @pattern = pattern
66
+ end
67
+
68
+ def message
69
+ "invalid widget arguments: #{@argstring} " \
70
+ "-- must match pattern: #{@pattern.inspect}"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,27 @@
1
+ require('cli/ui')
2
+
3
+ module CLI
4
+ module UI
5
+ module Widgets
6
+ class Base
7
+ def self.call(argstring)
8
+ new(argstring).render
9
+ end
10
+
11
+ def initialize(argstring)
12
+ pat = self.class.argparse_pattern
13
+ unless (@match_data = pat.match(argstring))
14
+ raise(Widgets::InvalidWidgetArguments.new(argstring, pat))
15
+ end
16
+ @match_data.names.each do |name|
17
+ instance_variable_set(:"@#{name}", @match_data[name])
18
+ end
19
+ end
20
+
21
+ def self.argparse_pattern
22
+ const_get(:ARGPARSE_PATTERN)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,61 @@
1
+ # frozen-string-literal: true
2
+ require('cli/ui')
3
+
4
+ module CLI
5
+ module UI
6
+ module Widgets
7
+ class Status < Widgets::Base
8
+ ARGPARSE_PATTERN = %r{
9
+ \A (?<succeeded> \d+)
10
+ : (?<failed> \d+)
11
+ : (?<working> \d+)
12
+ : (?<pending> \d+) \z
13
+ }x # e.g. "1:23:3:404"
14
+ OPEN = Color::RESET.code + Color::BOLD.code + '[' + Color::RESET.code
15
+ CLOSE = Color::RESET.code + Color::BOLD.code + ']' + Color::RESET.code
16
+ ARROW = Color::RESET.code + Color::GRAY.code + '◂' + Color::RESET.code
17
+ COMMA = Color::RESET.code + Color::GRAY.code + ',' + Color::RESET.code
18
+
19
+ SPINNER_STOPPED = '⠿'
20
+ EMPTY_SET = '∅'
21
+
22
+ def render
23
+ if zero?(@succeeded) && zero?(@failed) && zero?(@working) && zero?(@pending)
24
+ Color::RESET.code + Color::BOLD.code + EMPTY_SET + Color::RESET.code
25
+ else
26
+ # [ 0✓ , 2✗ ◂ 3⠼ ◂ 4⌛︎ ]
27
+ "#{OPEN}#{succeeded_part}#{COMMA}#{failed_part}#{ARROW}#{working_part}#{ARROW}#{pending_part}#{CLOSE}"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def zero?(num_str)
34
+ num_str == '0'
35
+ end
36
+
37
+ def colorize_if_nonzero(num_str, rune, color)
38
+ color = Color::GRAY if zero?(num_str)
39
+ color.code + num_str + rune
40
+ end
41
+
42
+ def succeeded_part
43
+ colorize_if_nonzero(@succeeded, Glyph::CHECK.char, Color::GREEN)
44
+ end
45
+
46
+ def failed_part
47
+ colorize_if_nonzero(@failed, Glyph::X.char, Color::RED)
48
+ end
49
+
50
+ def working_part
51
+ rune = zero?(@working) ? SPINNER_STOPPED : Spinner.current_rune
52
+ colorize_if_nonzero(@working, rune, Color::BLUE)
53
+ end
54
+
55
+ def pending_part
56
+ colorize_if_nonzero(@pending, Glyph::HOURGLASS.char, Color::WHITE)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cli-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Burke Libbey
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: exe
12
12
  cert_chain: []
13
- date: 2019-03-25 00:00:00.000000000 Z
13
+ date: 2019-08-14 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: rake
@@ -49,6 +49,7 @@ executables: []
49
49
  extensions: []
50
50
  extra_rdoc_files: []
51
51
  files:
52
+ - ".github/CODEOWNERS"
52
53
  - ".github/probots.yml"
53
54
  - ".gitignore"
54
55
  - ".rubocop.yml"
@@ -78,6 +79,9 @@ files:
78
79
  - lib/cli/ui/terminal.rb
79
80
  - lib/cli/ui/truncater.rb
80
81
  - lib/cli/ui/version.rb
82
+ - lib/cli/ui/widgets.rb
83
+ - lib/cli/ui/widgets/base.rb
84
+ - lib/cli/ui/widgets/status.rb
81
85
  homepage: https://github.com/shopify/cli-ui
82
86
  licenses:
83
87
  - MIT
@@ -97,7 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
97
101
  - !ruby/object:Gem::Version
98
102
  version: '0'
99
103
  requirements: []
100
- rubygems_version: 3.0.2
104
+ rubygems_version: 3.0.3
101
105
  signing_key:
102
106
  specification_version: 4
103
107
  summary: Terminal UI framework