clack 0.4.4 → 0.4.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5a8d3270e58cb1ad88664e73215c2ea5217211829680707d7616714ba93c926
4
- data.tar.gz: c8f58966b18a015c26b7669ebd495c0e7ba75d830088ad3135a0971dd03bc874
3
+ metadata.gz: e27f6e6ab9835d2855570ebdcd9b74a581becaffad5ba6a814e54ec5d44097b2
4
+ data.tar.gz: 8788e05802f917a1a6abe06697a4ca47ff451da0235d7336b560adf8558451a0
5
5
  SHA512:
6
- metadata.gz: f3dbfd192f557ee0cd476be3ed80f54a57273e786d9120e3372c85527582ad8a30994b4202c83c309135383e6bedaaf275e756961912420961c09a24e53576e2
7
- data.tar.gz: a692229800f14a3160aee3eca80f6c12b296c288e27dbf57b307b509090fdf0c72ca4e3712213f9515427ed0a48c70ee73534f85db887bb02ea1bfb9f0cf0177
6
+ metadata.gz: 173ead18da184ea230092d263bb4e607eb623c788fcdfae7d4315afbebe140bbe5335e42fa7c7ee460481916e46a6fda464f9b13f8d011228648dcf13a799704
7
+ data.tar.gz: 56fd6c8cbca6c610cb73941f565c84e62c6bcc38d5b48741c35c52b86c14e45ff858219e5973669d1db7beb4bb045259d11daa60d172d3e2603ad16c04e2e812
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.6] - 2026-03-22
4
+
5
+ ### Fixed
6
+ - `Settings.printable?` now accepts combining characters and multi-codepoint grapheme clusters (accented letters, emoji)
7
+ - Signal handlers use `write_nonblock` instead of `print` for async safety
8
+ - `S_STEP_ERROR` ASCII fallback changed from `x` to `!` (was identical to `S_STEP_CANCEL`)
9
+ - CI mode validation warnings now print to stderr instead of stdout
10
+
11
+ ### Changed
12
+ - Colors, Cursor, and Symbols ANSI detection unified through `Environment.colors_supported?`
13
+ - Extracted `build_final_frame` template into `Core::Prompt` with `final_display` hook (11 prompt classes simplified)
14
+ - Date prompt: consolidated duplicated segment logic into `update_segment`
15
+
16
+ ## [0.4.5] - 2026-02-22
17
+
18
+ ### Fixed
19
+ - `Autocomplete` and `AutocompleteMultiselect` now route all printable characters to the search field first, fixing vim aliases (`j`/`k`/`h`/`l`) hijacking text input
20
+ - `AutocompleteMultiselect` no longer intercepts `a`/`i` keys as shortcuts — they conflicted with typing in the search field
21
+ - `AutocompleteMultiselect#toggle_current` now respects `:disabled` options, matching `Multiselect` behavior
22
+
23
+ ### Changed
24
+ - `AutocompleteMultiselect` internals aligned with `Multiselect`: `@selected` naming, `submit` override pattern, `keyboard_hints` method, `build_frame` validation rendering, `initial_values: []` default
25
+
26
+ ### Added
27
+ - `examples/showcase.rb` — full-featured demo with branching, autocomplete, date, range, group multiselect, tasks
28
+ - `examples/migration_from_tty_prompt.rb` — side-by-side migration guide from tty-prompt
29
+ - README: "Why Clack?" comparison table vs tty-prompt/highline, quick reference table, TL;DR tagline, vim key notes on autocomplete sections
30
+
3
31
  ## [0.4.4] - 2026-02-21
4
32
 
5
33
  ### Fixed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Effortlessly beautiful CLI prompts for Ruby.**
4
4
 
5
- A faithful Ruby port of [@clack/prompts](https://github.com/bombshell-dev/clack).
5
+ A faithful Ruby port of [@clack/prompts](https://github.com/bombshell-dev/clack). 16+ prompt types, zero dependencies, Ruby 3.2+. `gem "clack"` and go.
6
6
 
7
7
  <p align="center">
8
8
  <img src="examples/demo.gif?v=2" width="640" alt="Clack demo">
@@ -10,10 +10,11 @@ A faithful Ruby port of [@clack/prompts](https://github.com/bombshell-dev/clack)
10
10
 
11
11
  ## Why Clack?
12
12
 
13
- - **Beautiful by default** - Thoughtfully designed prompts that just look right
14
- - **Vim-friendly** - Navigate with `hjkl` or arrow keys
15
- - **Accessible** - Graceful ASCII fallbacks for limited terminals
16
- - **Composable** - Group prompts together with `Clack.group`
13
+ - **Beautiful by default** Thoughtfully designed prompts that just look right
14
+ - **Vim-friendly** Navigate with `hjkl` or arrow keys
15
+ - **Accessible** Graceful ASCII fallbacks for limited terminals
16
+ - **Composable** Group prompts together with `Clack.group`
17
+ - **Zero dependencies** — Everything in one gem
17
18
 
18
19
  ## Installation
19
20
 
@@ -299,7 +300,8 @@ Type to filter from a list of options. Filtering uses **fuzzy matching** by defa
299
300
  color = Clack.autocomplete(
300
301
  message: "Pick a color",
301
302
  options: %w[red orange yellow green blue indigo violet],
302
- placeholder: "Type to search..."
303
+ placeholder: "Type to search...",
304
+ max_items: 5 # Default; scrollable via ↑↓
303
305
  )
304
306
 
305
307
  # Custom filter logic (receives option hash and query string)
@@ -310,6 +312,8 @@ cmd = Clack.autocomplete(
310
312
  )
311
313
  ```
312
314
 
315
+ > Vim-style `j`/`k`/`h`/`l` navigation is not available — all keyboard input feeds into the search field. Use `↑↓` arrow keys to navigate results.
316
+
313
317
  ### Autocomplete Multiselect
314
318
 
315
319
  Type to filter with multi-selection support.
@@ -320,11 +324,14 @@ colors = Clack.autocomplete_multiselect(
320
324
  options: %w[red orange yellow green blue indigo violet],
321
325
  placeholder: "Type to filter...",
322
326
  required: true, # At least one selection required
323
- initial_values: ["red"] # Pre-selected values
327
+ initial_values: ["red"], # Pre-selected values
328
+ max_items: 5 # Default; scrollable via ↑↓
324
329
  )
325
330
  ```
326
331
 
327
- **Shortcuts:** `Space` toggle | `a` toggle all | `i` invert | `Enter` confirm
332
+ **Shortcuts:** `Space` toggle | `Enter` confirm
333
+
334
+ > The `a` (select all), `i` (invert), and `j`/`k`/`h`/`l` shortcuts from `Multiselect` are not available here — all keyboard input feeds into the search field instead. Use `↑↓` arrow keys to navigate.
328
335
 
329
336
  ### Path
330
337
 
@@ -493,6 +500,32 @@ features = Clack.group_multiselect(
493
500
  )
494
501
  ```
495
502
 
503
+ <details>
504
+ <summary><h3 style="display:inline">Quick Reference</h3></summary>
505
+
506
+ | Prompt | Method | Key Options | Defaults |
507
+ |--------|--------|-------------|----------|
508
+ | Text | `Clack.text` | `placeholder:`, `default_value:`, `initial_value:`, `completions:` | — |
509
+ | Password | `Clack.password` | `mask:`, `validate:` | `mask: "▪"` |
510
+ | Confirm | `Clack.confirm` | `active:`, `inactive:`, `initial_value:` | `active: "Yes"`, `inactive: "No"`, `initial_value: true` |
511
+ | Select | `Clack.select` | `options:`, `initial_value:`, `max_items:` | — |
512
+ | Multiselect | `Clack.multiselect` | `options:`, `initial_values:`, `required:`, `cursor_at:` | `required: true` |
513
+ | Group Multiselect | `Clack.group_multiselect` | `options:` (nested), `selectable_groups:`, `group_spacing:` | `selectable_groups: false` |
514
+ | Autocomplete | `Clack.autocomplete` | `options:`, `placeholder:`, `filter:`, `max_items:` | `max_items: 5` |
515
+ | Autocomplete Multiselect | `Clack.autocomplete_multiselect` | `options:`, `required:`, `initial_values:`, `filter:` | `required: true`, `max_items: 5` |
516
+ | Select Key | `Clack.select_key` | `options:` (with `:key`) | — |
517
+ | Path | `Clack.path` | `root:`, `only_directories:` | `root: "."` |
518
+ | Date | `Clack.date` | `format:`, `initial_value:`, `min:`, `max:` | `format: :iso` |
519
+ | Range | `Clack.range` | `min:`, `max:`, `step:`, `initial_value:` | `min: 0`, `max: 100`, `step: 1` |
520
+ | Multiline Text | `Clack.multiline_text` | `initial_value:`, `validate:` | Submit with **Ctrl+D** |
521
+ | Spinner | `Clack.spinner` / `Clack.spin` | block form auto-handles success/error | — |
522
+ | Tasks | `Clack.tasks` | `tasks:` (`{title:, task:, enabled:}`) | `enabled: true` |
523
+ | Progress | `Clack.progress` | `total:`, `message:` | — |
524
+
525
+ All prompts accept `message:`, `validate:`, `help:`, and return `Clack::CANCEL` on Escape/Ctrl+C.
526
+
527
+ </details>
528
+
496
529
  ## Prompt Groups
497
530
 
498
531
  Chain multiple prompts and collect results in a hash. Cancellation is handled automatically.
@@ -673,6 +706,10 @@ bundle exec rake spec # Tests only
673
706
  COVERAGE=true bundle exec rake spec # With coverage
674
707
  ```
675
708
 
709
+ ## Roadmap
710
+
711
+ - **Wizard mode** - Multi-step flows with back navigation (`Clack.wizard`). The current `Clack.group` runs prompts as sequential Ruby code, so there's no way to "go back" and re-answer a previous question. A declarative wizard API would define steps as a graph with branching and let the engine handle forward/back navigation.
712
+
676
713
  ## Credits
677
714
 
678
715
  This is a Ruby port of [Clack](https://github.com/bombshell-dev/clack), created by [Nate Moore](https://github.com/natemoo-re) and the [Astro](https://astro.build) team.
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Migration Guide: tty-prompt -> Clack
5
+ #
6
+ # This file shows common tty-prompt patterns and their Clack equivalents.
7
+ # Each section shows the tty-prompt code (commented out) followed by
8
+ # runnable Clack code that does the same thing.
9
+ #
10
+ # Run with: ruby examples/migration_from_tty_prompt.rb
11
+
12
+ require_relative "../lib/clack" # Or: require "clack" if installed as a gem
13
+
14
+ Clack.intro "Migrating from tty-prompt to Clack"
15
+
16
+ # ─────────────────────────────────────────────
17
+ # 1. Basic text input
18
+ # ─────────────────────────────────────────────
19
+ #
20
+ # tty-prompt:
21
+ # prompt = TTY::Prompt.new
22
+ # name = prompt.ask("What is your name?", default: "World")
23
+ #
24
+ # Clack equivalent:
25
+
26
+ name = Clack.text(
27
+ message: "What is your name?",
28
+ placeholder: "World",
29
+ default_value: "World"
30
+ )
31
+ exit 0 if Clack.cancel?(name)
32
+
33
+ Clack.log.info "Name: #{name}"
34
+
35
+ # ─────────────────────────────────────────────
36
+ # 2. Password input
37
+ # ─────────────────────────────────────────────
38
+ #
39
+ # tty-prompt:
40
+ # secret = prompt.mask("Enter your API key:")
41
+ #
42
+ # Clack equivalent:
43
+
44
+ secret = Clack.password(message: "Enter your API key:")
45
+ exit 0 if Clack.cancel?(secret)
46
+
47
+ Clack.log.info "Key entered (#{secret.length} chars)"
48
+
49
+ # ─────────────────────────────────────────────
50
+ # 3. Yes/No confirmation
51
+ # ─────────────────────────────────────────────
52
+ #
53
+ # tty-prompt:
54
+ # prompt.yes?("Continue setup?") # => true/false
55
+ # prompt.no?("Skip optional steps?") # => true/false (inverted default)
56
+ #
57
+ # Clack equivalent:
58
+ # Use initial_value: false to mimic prompt.no? (default to "No")
59
+
60
+ continue = Clack.confirm(message: "Continue setup?")
61
+ exit 0 if Clack.cancel?(continue)
62
+
63
+ Clack.log.info "Continue: #{continue}"
64
+
65
+ # ─────────────────────────────────────────────
66
+ # 4. Single select
67
+ # ─────────────────────────────────────────────
68
+ #
69
+ # tty-prompt:
70
+ # color = prompt.select("Pick a color", %w[Red Green Blue])
71
+ #
72
+ # # or with values:
73
+ # color = prompt.select("Pick a color") do |menu|
74
+ # menu.choice "Red", :red
75
+ # menu.choice "Green", :green
76
+ # menu.choice "Blue", :blue
77
+ # end
78
+ #
79
+ # Clack equivalent:
80
+
81
+ color = Clack.select(
82
+ message: "Pick a color",
83
+ options: [
84
+ {value: :red, label: "Red"},
85
+ {value: :green, label: "Green"},
86
+ {value: :blue, label: "Blue"}
87
+ ]
88
+ )
89
+ exit 0 if Clack.cancel?(color)
90
+
91
+ Clack.log.info "Color: #{color}"
92
+
93
+ # ─────────────────────────────────────────────
94
+ # 5. Multi-select
95
+ # ─────────────────────────────────────────────
96
+ #
97
+ # tty-prompt:
98
+ # toppings = prompt.multi_select("Choose toppings") do |menu|
99
+ # menu.choice "Cheese", :cheese
100
+ # menu.choice "Pepperoni", :pepperoni
101
+ # menu.choice "Mushrooms", :mushrooms
102
+ # menu.choice "Olives", :olives
103
+ # end
104
+ #
105
+ # Clack equivalent:
106
+
107
+ toppings = Clack.multiselect(
108
+ message: "Choose toppings",
109
+ options: [
110
+ {value: :cheese, label: "Cheese"},
111
+ {value: :pepperoni, label: "Pepperoni"},
112
+ {value: :mushrooms, label: "Mushrooms"},
113
+ {value: :olives, label: "Olives"}
114
+ ],
115
+ required: false
116
+ )
117
+ exit 0 if Clack.cancel?(toppings)
118
+
119
+ Clack.log.info "Toppings: #{toppings.join(", ")}"
120
+
121
+ # ─────────────────────────────────────────────
122
+ # 6. Autocomplete / filtering
123
+ # ─────────────────────────────────────────────
124
+ #
125
+ # tty-prompt:
126
+ # lang = prompt.select("Pick a language", %w[Ruby Python JavaScript Go Rust], filter: true)
127
+ #
128
+ # Clack has a dedicated autocomplete prompt with built-in fuzzy matching:
129
+
130
+ lang = Clack.autocomplete(
131
+ message: "Pick a language (type to filter)",
132
+ options: [
133
+ {value: "ruby", label: "Ruby"},
134
+ {value: "python", label: "Python"},
135
+ {value: "javascript", label: "JavaScript"},
136
+ {value: "go", label: "Go"},
137
+ {value: "rust", label: "Rust"}
138
+ ]
139
+ )
140
+ exit 0 if Clack.cancel?(lang)
141
+
142
+ Clack.log.info "Language: #{lang}"
143
+
144
+ # ─────────────────────────────────────────────
145
+ # 7. Validation
146
+ # ─────────────────────────────────────────────
147
+ #
148
+ # tty-prompt:
149
+ # email = prompt.ask("Email?", validate: /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z]+)*\z/i)
150
+ # age = prompt.ask("Age?") { |q| q.validate(->(v) { v.to_i > 0 }, "Must be positive") }
151
+ #
152
+ # Clack uses a validate lambda that returns nil (pass) or an error string:
153
+
154
+ email = Clack.text(
155
+ message: "Email?",
156
+ validate: ->(v) {
157
+ "Invalid email" unless v.match?(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z]+)*\z/i)
158
+ }
159
+ )
160
+ exit 0 if Clack.cancel?(email)
161
+
162
+ Clack.log.info "Email: #{email}"
163
+
164
+ # ─────────────────────────────────────────────
165
+ # 8. Collecting multiple prompts (group)
166
+ # ─────────────────────────────────────────────
167
+ #
168
+ # tty-prompt:
169
+ # result = prompt.collect do
170
+ # key(:name).ask("Name?")
171
+ # key(:role).select("Role?", %w[Admin User Guest])
172
+ # key(:notify).yes?("Send welcome email?")
173
+ # end
174
+ # # => { name: "Alice", role: "User", notify: true }
175
+ #
176
+ # Clack.group collects answers into a hash and handles cancellation:
177
+
178
+ result = Clack.group(
179
+ on_cancel: ->(_partial) {
180
+ Clack.cancel "Setup cancelled"
181
+ exit 0
182
+ }
183
+ ) do |g|
184
+ g.prompt(:name) { Clack.text(message: "Name?", placeholder: "Alice") }
185
+
186
+ g.prompt(:role) do
187
+ Clack.select(
188
+ message: "Role?",
189
+ options: %w[Admin User Guest].map { |r| {value: r.downcase, label: r} }
190
+ )
191
+ end
192
+
193
+ g.prompt(:notify) { Clack.confirm(message: "Send welcome email?") }
194
+ end
195
+
196
+ Clack.log.info "Name: #{result[:name]}"
197
+ Clack.log.info "Role: #{result[:role]}"
198
+ Clack.log.info "Notify: #{result[:notify]}"
199
+
200
+ # ─────────────────────────────────────────────
201
+ # Quick reference
202
+ # ─────────────────────────────────────────────
203
+
204
+ Clack.note <<~TABLE, title: "Quick Reference"
205
+ tty-prompt Clack
206
+ ───────────────────── ──────────────────────────────
207
+ prompt.ask Clack.text
208
+ prompt.mask Clack.password
209
+ prompt.yes? / prompt.no? Clack.confirm
210
+ prompt.select Clack.select
211
+ prompt.multi_select Clack.multiselect
212
+ prompt.select(filter:) Clack.autocomplete
213
+ prompt.collect Clack.group
214
+ prompt.say / prompt.warn Clack.log.info / .warning
215
+ TTY::Spinner Clack.spinner / Clack.spin
216
+ TABLE
217
+
218
+ Clack.outro "Migration complete! See the README for the full API."
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Ultimate Clack showcase — every impressive feature in one flow.
5
+ # Run with: ruby -Ilib examples/showcase.rb
6
+
7
+ require "clack"
8
+
9
+ Clack.intro "deploy-wizard"
10
+
11
+ # --- Text with tab completions ---
12
+ project = Clack.text(
13
+ message: "Project name:",
14
+ placeholder: "my-app",
15
+ completions: %w[acme-api acme-web acme-admin acme-workers billing-service payment-gateway
16
+ notification-hub auth-proxy data-pipeline analytics-engine search-indexer
17
+ cdn-edge config-server feature-flags rate-limiter],
18
+ validate: ->(v) { "Name is required" if v.to_s.strip.empty? }
19
+ )
20
+ exit 0 if Clack.cancel?(project)
21
+
22
+ # --- Autocomplete with fuzzy search (big list) ---
23
+ stack = Clack.autocomplete(
24
+ message: "Tech stack:",
25
+ placeholder: "Type to search...",
26
+ options: [
27
+ {value: "rails", label: "Ruby on Rails", hint: "full-stack, batteries included"},
28
+ {value: "sinatra", label: "Sinatra", hint: "micro, DSL-style"},
29
+ {value: "hanami", label: "Hanami", hint: "clean architecture"},
30
+ {value: "roda", label: "Roda", hint: "routing tree"},
31
+ {value: "grape", label: "Grape", hint: "API framework"},
32
+ {value: "nextjs", label: "Next.js", hint: "React SSR"},
33
+ {value: "nuxt", label: "Nuxt", hint: "Vue SSR"},
34
+ {value: "sveltekit", label: "SvelteKit", hint: "Svelte SSR"},
35
+ {value: "phoenix", label: "Phoenix", hint: "Elixir"},
36
+ {value: "django", label: "Django", hint: "Python full-stack"},
37
+ {value: "fastapi", label: "FastAPI", hint: "Python async"},
38
+ {value: "express", label: "Express.js", hint: "Node.js minimal"},
39
+ {value: "fastify", label: "Fastify", hint: "Node.js fast"},
40
+ {value: "gin", label: "Gin", hint: "Go HTTP"},
41
+ {value: "actix", label: "Actix Web", hint: "Rust async"},
42
+ {value: "spring", label: "Spring Boot", hint: "Java enterprise"},
43
+ {value: "laravel", label: "Laravel", hint: "PHP full-stack"}
44
+ ]
45
+ )
46
+ exit 0 if Clack.cancel?(stack)
47
+
48
+ # --- Branching: choice determines follow-up ---
49
+ deploy_target = Clack.select(
50
+ message: "Deploy target:",
51
+ options: [
52
+ {value: "kubernetes", label: "Kubernetes", hint: "container orchestration"},
53
+ {value: "serverless", label: "Serverless", hint: "Lambda / Cloud Functions"},
54
+ {value: "bare_metal", label: "Bare Metal", hint: "traditional VPS"}
55
+ ]
56
+ )
57
+ exit 0 if Clack.cancel?(deploy_target)
58
+
59
+ cloud = nil
60
+ replicas = nil
61
+
62
+ case deploy_target
63
+ when "kubernetes"
64
+ cloud = Clack.select(
65
+ message: "Cloud provider:",
66
+ options: [
67
+ {value: "aws", label: "AWS EKS", hint: "Amazon"},
68
+ {value: "gcp", label: "GCP GKE", hint: "Google"},
69
+ {value: "azure", label: "Azure AKS", hint: "Microsoft"},
70
+ {value: "do", label: "DigitalOcean DOKS"}
71
+ ]
72
+ )
73
+ exit 0 if Clack.cancel?(cloud)
74
+
75
+ replicas = Clack.range(
76
+ message: "Pod replicas:",
77
+ min: 1,
78
+ max: 20,
79
+ step: 1,
80
+ initial_value: 3
81
+ )
82
+ exit 0 if Clack.cancel?(replicas)
83
+
84
+ when "serverless"
85
+ cloud = Clack.select_key(
86
+ message: "Serverless platform:",
87
+ options: [
88
+ {value: "lambda", label: "AWS Lambda", key: "a"},
89
+ {value: "cloud_functions", label: "GCP Cloud Functions", key: "g"},
90
+ {value: "azure_functions", label: "Azure Functions", key: "z"},
91
+ {value: "cloudflare", label: "Cloudflare Workers", key: "c"}
92
+ ]
93
+ )
94
+ exit 0 if Clack.cancel?(cloud)
95
+ replicas = "auto"
96
+
97
+ when "bare_metal"
98
+ cloud = "self-hosted"
99
+ replicas = Clack.range(
100
+ message: "Server count:",
101
+ min: 1,
102
+ max: 50,
103
+ step: 1,
104
+ initial_value: 2
105
+ )
106
+ exit 0 if Clack.cancel?(replicas)
107
+ end
108
+
109
+ # --- Group multiselect ---
110
+ addons = Clack.group_multiselect(
111
+ message: "Add-ons:",
112
+ selectable_groups: true,
113
+ group_spacing: 1,
114
+ options: [
115
+ {
116
+ label: "Observability",
117
+ options: [
118
+ {value: "prometheus", label: "Prometheus + Grafana"},
119
+ {value: "datadog", label: "Datadog"},
120
+ {value: "sentry", label: "Sentry", hint: "error tracking"}
121
+ ]
122
+ },
123
+ {
124
+ label: "Data",
125
+ options: [
126
+ {value: "postgres", label: "PostgreSQL"},
127
+ {value: "redis", label: "Redis"},
128
+ {value: "elasticsearch", label: "Elasticsearch"},
129
+ {value: "kafka", label: "Kafka", hint: "event streaming"}
130
+ ]
131
+ },
132
+ {
133
+ label: "Security",
134
+ options: [
135
+ {value: "vault", label: "HashiCorp Vault", hint: "secrets"},
136
+ {value: "oauth", label: "OAuth2 / OIDC"},
137
+ {value: "waf", label: "WAF", hint: "web application firewall"}
138
+ ]
139
+ }
140
+ ],
141
+ required: false
142
+ )
143
+ exit 0 if Clack.cancel?(addons)
144
+
145
+ # --- Autocomplete multiselect ---
146
+ team = Clack.autocomplete_multiselect(
147
+ message: "Notify team members:",
148
+ options: [
149
+ {value: "alice", label: "Alice Chen", hint: "backend"},
150
+ {value: "bob", label: "Bob Martinez", hint: "frontend"},
151
+ {value: "carol", label: "Carol Kim", hint: "SRE"},
152
+ {value: "dave", label: "Dave Patel", hint: "security"},
153
+ {value: "eve", label: "Eve Johansson", hint: "PM"},
154
+ {value: "frank", label: "Frank Okafor", hint: "QA"},
155
+ {value: "grace", label: "Grace Liu", hint: "data"},
156
+ {value: "hank", label: "Hank Müller", hint: "DevOps"}
157
+ ],
158
+ required: false
159
+ )
160
+ exit 0 if Clack.cancel?(team)
161
+
162
+ # --- Date picker ---
163
+ deploy_date = Clack.date(
164
+ message: "Target deploy date:",
165
+ initial_value: Date.today + 7
166
+ )
167
+ exit 0 if Clack.cancel?(deploy_date)
168
+
169
+ # --- Confirm ---
170
+ go_live = Clack.confirm(
171
+ message: "Schedule deployment?",
172
+ initial_value: true
173
+ )
174
+ exit 0 if Clack.cancel?(go_live)
175
+
176
+ unless go_live
177
+ Clack.outro "Deployment cancelled."
178
+ exit 0
179
+ end
180
+
181
+ # --- Tasks with mid-task message updates ---
182
+ Clack.tasks(tasks: [
183
+ {title: "Validating configuration", task: -> {
184
+ sleep 0.6
185
+ }},
186
+ {title: "Provisioning infrastructure", task: ->(msg) {
187
+ msg.call("Creating #{deploy_target} cluster...")
188
+ sleep 0.8
189
+ msg.call("Configuring networking...")
190
+ sleep 0.5
191
+ msg.call("Setting up DNS...")
192
+ sleep 0.4
193
+ }},
194
+ {title: "Deploying #{project}", task: ->(msg) {
195
+ msg.call("Building container image...")
196
+ sleep 0.7
197
+ msg.call("Pushing to registry...")
198
+ sleep 0.5
199
+ msg.call("Rolling out #{replicas} replicas...")
200
+ sleep 0.6
201
+ }},
202
+ {title: "Running health checks", task: -> {
203
+ sleep 0.8
204
+ }}
205
+ ])
206
+
207
+ # --- Summary ---
208
+ Clack.log.success "Deployment scheduled!"
209
+ Clack.log.step "Project: #{project}"
210
+ Clack.log.step "Stack: #{stack}"
211
+ Clack.log.step "Target: #{deploy_target} (#{cloud})"
212
+ Clack.log.step "Replicas: #{replicas}"
213
+ Clack.log.step "Add-ons: #{addons.join(", ")}" unless addons.empty?
214
+ Clack.log.step "Team: #{team.join(", ")}" unless team.empty?
215
+ Clack.log.step "Deploy date: #{deploy_date}"
216
+
217
+ Clack.note <<~MSG, title: "What happens next"
218
+ 1. CI pipeline will run at #{deploy_date}
219
+ 2. Canary deploy to 10% traffic
220
+ 3. Full rollout after 30min soak
221
+ 4. Team notified via Slack
222
+ MSG
223
+
224
+ Clack.outro "Happy shipping!"
data/lib/clack/colors.rb CHANGED
@@ -8,12 +8,7 @@ module Clack
8
8
  # - FORCE_COLOR environment variable forces colors on
9
9
  module Colors
10
10
  class << self
11
- def enabled?
12
- return true if ENV["FORCE_COLOR"] && ENV["FORCE_COLOR"] != "0"
13
- return false if ENV["NO_COLOR"]
14
-
15
- $stdout.tty?
16
- end
11
+ def enabled? = Environment.colors_supported?
17
12
 
18
13
  # @!group Foreground Colors (standard)
19
14
 
@@ -13,9 +13,7 @@ module Clack
13
13
  def enabled?
14
14
  return @enabled unless @enabled.nil?
15
15
 
16
- # Default: check if output supports ANSI escape sequences
17
- return true if ENV["FORCE_COLOR"] && ENV["FORCE_COLOR"] != "0"
18
- $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
16
+ Environment.colors_supported?
19
17
  end
20
18
 
21
19
  # Visibility
@@ -316,13 +316,23 @@ module Clack
316
316
  end
317
317
 
318
318
  # Build the final frame shown after interaction ends.
319
- # Override to show a different view for completed prompts.
319
+ # Default renders a one-line summary: bar, symbol+message, styled final value.
320
+ # Override {#final_display} to customize what value is shown.
321
+ # Override this entirely for multi-line final output (e.g. MultilineText).
320
322
  #
321
323
  # @return [String] the final frame content
322
324
  def build_final_frame
323
- build_frame
325
+ "#{bar}\n" \
326
+ "#{symbol_for_state} #{@message}\n" \
327
+ "#{bar} #{styled_final_display}\n"
324
328
  end
325
329
 
330
+ # The text to display in the final frame after submit/cancel.
331
+ # Override in subclasses to customize (e.g. masked password, formatted date).
332
+ #
333
+ # @return [String]
334
+ def final_display = @value.to_s
335
+
326
336
  # Check if prompt has reached a terminal state.
327
337
  #
328
338
  # @return [Boolean] true if state is :submit or :cancel
@@ -337,13 +347,18 @@ module Clack
337
347
  def run_ci_mode
338
348
  submit
339
349
  if @state == :error
340
- @output.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
350
+ $stderr.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
341
351
  end
342
352
  @value
343
353
  end
344
354
 
345
355
  private
346
356
 
357
+ def styled_final_display
358
+ text = final_display
359
+ (@state == :cancel) ? Colors.strikethrough(Colors.dim(text)) : Colors.dim(text)
360
+ end
361
+
347
362
  def warn_narrow_terminal
348
363
  return unless Environment.tty?(@output)
349
364
 
@@ -91,9 +91,9 @@ module Clack
91
91
  aliases[key] if ACTIONS.include?(aliases[key])
92
92
  end
93
93
 
94
- # Check if a key is a printable character
94
+ # Check if a key is a printable character (handles combining marks and multi-codepoint grapheme clusters)
95
95
  def printable?(key)
96
- key && key.length == 1 && key.ord >= PRINTABLE_CHAR_MIN
96
+ key && key.grapheme_clusters.length == 1 && key.ord >= PRINTABLE_CHAR_MIN
97
97
  end
98
98
 
99
99
  # Check if a key is a backspace/delete
@@ -63,19 +63,21 @@ module Clack
63
63
  def handle_key(key)
64
64
  return if terminal_state?
65
65
 
66
+ # Printable characters always feed the search field.
67
+ # This prevents vim aliases (j/k/h/l) from hijacking text input.
68
+ if Core::Settings.printable?(key)
69
+ handle_text_input(key)
70
+ return
71
+ end
72
+
66
73
  action = Core::Settings.action?(key)
67
74
 
68
75
  case action
69
- when :cancel
70
- @state = :cancel
71
- when :enter
72
- submit_selection
73
- when :up
74
- move_selection(-1)
75
- when :down
76
- move_selection(1)
77
- else
78
- handle_text_input(key)
76
+ when :cancel then @state = :cancel
77
+ when :enter then submit_selection
78
+ when :up then move_selection(-1)
79
+ when :down then move_selection(1)
80
+ else handle_text_input(key)
79
81
  end
80
82
  end
81
83
 
@@ -119,17 +121,7 @@ module Clack
119
121
  lines.join
120
122
  end
121
123
 
122
- def build_final_frame
123
- lines = []
124
- lines << "#{bar}\n"
125
- lines << "#{symbol_for_state} #{@message}\n"
126
-
127
- display_value = @filtered[@selected_index]&.[](:label) || @value
128
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_value)) : Colors.dim(display_value)
129
- lines << "#{bar} #{display}\n"
130
-
131
- lines.join
132
- end
124
+ def final_display = @filtered[@selected_index]&.[](:label) || @value
133
125
 
134
126
  private
135
127
 
@@ -7,10 +7,9 @@ module Clack
7
7
  # Combines text input filtering with checkbox-style selection.
8
8
  # Type to filter, Space to toggle, Enter to confirm.
9
9
  #
10
- # Shortcuts:
11
- # - Space: toggle current option
12
- # - 'a': toggle all options
13
- # - 'i': invert selection
10
+ # Unlike {Multiselect}, the 'a' (select all) and 'i' (invert) shortcuts
11
+ # are not available because those characters are needed for the search field.
12
+ # Similarly, vim-style j/k navigation is disabled in favor of typing.
14
13
  #
15
14
  # @example Basic usage
16
15
  # colors = Clack.autocomplete_multiselect(
@@ -37,12 +36,12 @@ module Clack
37
36
  # @param max_items [Integer] max visible options (default: 5)
38
37
  # @param placeholder [String, nil] placeholder text when empty
39
38
  # @param required [Boolean] require at least one selection (default: true)
40
- # @param initial_values [Array, nil] values to pre-select
39
+ # @param initial_values [Array] values to pre-select
41
40
  # @param filter [Proc, nil] custom filter proc receiving (option_hash, query_string)
42
41
  # and returning true/false. When nil, the default fuzzy matching
43
42
  # across label, value, and hint is used.
44
43
  # @param opts [Hash] additional options passed to {Core::Prompt}
45
- def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: nil, filter: nil, **opts)
44
+ def initialize(message:, options:, max_items: 5, placeholder: nil, required: true, initial_values: [], filter: nil, **opts)
46
45
  super(message:, **opts)
47
46
  @all_options = normalize_options(options)
48
47
  @max_items = max_items
@@ -54,7 +53,7 @@ module Clack
54
53
  @selected_index = 0
55
54
  @scroll_offset = 0
56
55
  valid_values = Set.new(@all_options.map { |o| o[:value] })
57
- @selected_values = Set.new(initial_values || []) & valid_values
56
+ @selected = Set.new(initial_values) & valid_values
58
57
  update_filtered
59
58
  end
60
59
 
@@ -63,78 +62,47 @@ module Clack
63
62
  def handle_key(key)
64
63
  return if terminal_state?
65
64
 
65
+ # Printable characters always feed the search field (except space, which toggles).
66
+ # This prevents vim aliases (j/k/h/l) from hijacking text input.
67
+ if Core::Settings.printable?(key) && key != " "
68
+ handle_text_input(key)
69
+ return
70
+ end
71
+
66
72
  action = Core::Settings.action?(key)
67
73
 
68
74
  case action
69
- when :cancel
70
- @state = :cancel
71
- when :enter
72
- submit_selection
73
- when :up
74
- move_selection(-1)
75
- when :down
76
- move_selection(1)
77
- when :space
78
- toggle_current
79
- else
80
- handle_char(key)
81
- end
82
- end
83
-
84
- def handle_char(key)
85
- # Shortcut keys only work when search field is empty
86
- # to avoid interfering with typing filter text
87
- if @search_text.empty?
88
- case key&.downcase
89
- when "a"
90
- toggle_all
91
- return
92
- when "i"
93
- invert_selection
94
- return
95
- end
75
+ when :cancel then @state = :cancel
76
+ when :enter then submit
77
+ when :up then move_selection(-1)
78
+ when :down then move_selection(1)
79
+ when :space then toggle_current
80
+ else handle_text_input(key)
96
81
  end
97
- handle_text_input(key)
98
82
  end
99
83
 
100
84
  def toggle_current
101
85
  return if @filtered.empty?
102
86
 
103
- current_value = @filtered[@selected_index][:value]
104
- if @selected_values.include?(current_value)
105
- @selected_values.delete(current_value)
106
- else
107
- @selected_values.add(current_value)
108
- end
109
- end
87
+ opt = @filtered[@selected_index]
88
+ return if opt[:disabled]
110
89
 
111
- def toggle_all
112
- if @selected_values.size == @all_options.size
113
- @selected_values.clear
90
+ if @selected.include?(opt[:value])
91
+ @selected.delete(opt[:value])
114
92
  else
115
- @all_options.each { |opt| @selected_values.add(opt[:value]) }
93
+ @selected.add(opt[:value])
116
94
  end
117
95
  end
118
96
 
119
- def invert_selection
120
- @all_options.each do |opt|
121
- if @selected_values.include?(opt[:value])
122
- @selected_values.delete(opt[:value])
123
- else
124
- @selected_values.add(opt[:value])
125
- end
126
- end
127
- end
128
-
129
- def submit_selection
130
- if @required && @selected_values.empty?
97
+ def submit
98
+ if @required && @selected.empty?
131
99
  @error_message = "Please select at least one option. Press #{Colors.cyan("space")} to select, #{Colors.cyan("enter")} to submit"
132
100
  @state = :error
133
101
  return
134
102
  end
135
103
 
136
- @value = @selected_values.to_a
137
- submit
104
+ @value = @selected.to_a
105
+ super
138
106
  end
139
107
 
140
108
  def build_frame
@@ -151,29 +119,19 @@ module Clack
151
119
 
152
120
  lines << "#{active_bar} #{Colors.yellow("No matches found")}\n" if @filtered.empty? && !@search_text.empty?
153
121
 
154
- lines << "#{active_bar} #{instructions}\n"
155
- lines << "#{bar_end}\n"
122
+ lines << "#{active_bar} #{keyboard_hints}\n"
156
123
 
157
- validation_lines = validation_message_lines
158
- if validation_lines.any?
159
- lines[-1] = validation_lines.first
160
- lines.concat(validation_lines[1..])
124
+ if @state in :error | :warning
125
+ lines.concat(validation_message_lines)
126
+ else
127
+ lines << "#{bar_end}\n"
161
128
  end
162
129
 
163
130
  lines.join
164
131
  end
165
132
 
166
- def build_final_frame
167
- lines = []
168
- lines << "#{bar}\n"
169
- lines << "#{symbol_for_state} #{@message}\n"
170
-
171
- labels = @all_options.select { |o| @selected_values.include?(o[:value]) }.map { |o| o[:label] }
172
- display_text = labels.join(", ")
173
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
174
- lines << "#{bar} #{display}\n"
175
-
176
- lines.join
133
+ def final_display
134
+ @all_options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }.join(", ")
177
135
  end
178
136
 
179
137
  private
@@ -199,12 +157,10 @@ module Clack
199
157
  Colors.dim(" (#{@filtered.size} match#{"es" unless @filtered.size == 1})")
200
158
  end
201
159
 
202
- def instructions
160
+ def keyboard_hints
203
161
  Colors.dim([
204
162
  "up/down: navigate",
205
163
  "space: select",
206
- "a: all",
207
- "i: invert",
208
164
  "enter: confirm"
209
165
  ].join(" | "))
210
166
  end
@@ -220,8 +176,8 @@ module Clack
220
176
  def scroll_items = @filtered
221
177
 
222
178
  def option_display(opt, active)
223
- is_selected = @selected_values.include?(opt[:value])
224
- checkbox = if is_selected
179
+ selected = @selected.include?(opt[:value])
180
+ checkbox = if selected
225
181
  Colors.green(Symbols::S_CHECKBOX_SELECTED)
226
182
  else
227
183
  Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
@@ -71,17 +71,7 @@ module Clack
71
71
  lines.join
72
72
  end
73
73
 
74
- def build_final_frame
75
- lines = []
76
- lines << "#{bar}\n"
77
- lines << "#{symbol_for_state} #{@message}\n"
78
-
79
- selected = @value ? @active_label : @inactive_label
80
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(selected)) : Colors.dim(selected)
81
- lines << "#{bar} #{display}\n"
82
-
83
- lines.join
84
- end
74
+ def final_display = @value ? @active_label : @inactive_label
85
75
 
86
76
  private
87
77
 
@@ -99,17 +99,7 @@ module Clack
99
99
  lines.join
100
100
  end
101
101
 
102
- def build_final_frame
103
- lines = []
104
- lines << "#{bar}\n"
105
- lines << "#{symbol_for_state} #{@message}\n"
106
-
107
- display_text = formatted_date
108
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
109
- lines << "#{bar} #{display}\n"
110
-
111
- lines.join
112
- end
102
+ def final_display = formatted_date
113
103
 
114
104
  private
115
105
 
@@ -164,28 +154,7 @@ module Clack
164
154
  def adjust_segment(delta)
165
155
  commit_input_buffer
166
156
  @input_buffer = ""
167
-
168
- case current_segment_type
169
- when :year
170
- @year = (@year + delta).clamp(1, 9999)
171
- clamp_day_to_month
172
- when :month
173
- @month += delta
174
- @month = wrap_value(@month, 1, 12)
175
- clamp_day_to_month
176
- when :day
177
- max_day = days_in_month(@year, @month)
178
- @day = wrap_value(@day + delta, 1, max_day)
179
- end
180
-
181
- enforce_bounds
182
- end
183
-
184
- def wrap_value(val, min, max)
185
- return min if val > max
186
- return max if val < min
187
-
188
- val
157
+ update_segment(segment_value + delta, wrap: true)
189
158
  end
190
159
 
191
160
  def handle_digit(digit)
@@ -201,23 +170,38 @@ module Clack
201
170
  def commit_input_buffer
202
171
  return if @input_buffer.empty?
203
172
 
204
- value = @input_buffer.to_i
173
+ update_segment(@input_buffer.to_i)
205
174
  @input_buffer = ""
175
+ end
206
176
 
177
+ def segment_value
207
178
  case current_segment_type
208
- when :year
209
- @year = value.clamp(1, 9999)
210
- clamp_day_to_month
211
- when :month
212
- @month = value.clamp(1, 12)
213
- clamp_day_to_month
214
- when :day
215
- @day = value.clamp(1, days_in_month(@year, @month))
179
+ when :year then @year
180
+ when :month then @month
181
+ when :day then @day
216
182
  end
183
+ end
184
+
185
+ def update_segment(value, wrap: false)
186
+ constrain = wrap ? method(:wrap_value) : :clamp.to_proc
217
187
 
188
+ case current_segment_type
189
+ when :year then @year = value.clamp(1, 9999)
190
+ when :month then @month = constrain.call(value, 1, 12)
191
+ when :day then @day = constrain.call(value, 1, days_in_month(@year, @month))
192
+ end
193
+
194
+ clamp_day_to_month
218
195
  enforce_bounds
219
196
  end
220
197
 
198
+ def wrap_value(val, min, max)
199
+ return min if val > max
200
+ return max if val < min
201
+
202
+ val
203
+ end
204
+
221
205
  def current_segment_type = FORMATS[@format][:order][@segment]
222
206
 
223
207
  def days_in_month(year, month)
@@ -126,18 +126,7 @@ module Clack
126
126
  lines.join
127
127
  end
128
128
 
129
- def build_final_frame
130
- lines = []
131
- lines << "#{bar}\n"
132
- lines << "#{symbol_for_state} #{@message}\n"
133
-
134
- labels = selected_options.map { |o| o[:label] }
135
- display_text = labels.join(", ")
136
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
137
- lines << "#{bar} #{display}\n"
138
-
139
- lines.join
140
- end
129
+ def final_display = selected_options.map { |o| o[:label] }.join(", ")
141
130
 
142
131
  private
143
132
 
@@ -103,17 +103,8 @@ module Clack
103
103
  lines.join
104
104
  end
105
105
 
106
- def build_final_frame
107
- lines = []
108
- lines << "#{bar}\n"
109
- lines << "#{symbol_for_state} #{@message}\n"
110
-
111
- labels = @options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }
112
- display_text = labels.join(", ")
113
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_text)) : Colors.dim(display_text)
114
- lines << "#{bar} #{display}\n"
115
-
116
- lines.join
106
+ def final_display
107
+ @options.select { |o| @selected.include?(o[:value]) }.map { |o| o[:label] }.join(", ")
117
108
  end
118
109
 
119
110
  private
@@ -51,17 +51,7 @@ module Clack
51
51
  lines.join
52
52
  end
53
53
 
54
- def build_final_frame
55
- lines = []
56
- lines << "#{bar}\n"
57
- lines << "#{symbol_for_state} #{@message}\n"
58
-
59
- masked = @mask * @value.grapheme_clusters.length
60
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(masked)) : Colors.dim(masked)
61
- lines << "#{bar} #{display}\n"
62
-
63
- lines.join
64
- end
54
+ def final_display = @mask * @value.grapheme_clusters.length
65
55
 
66
56
  private
67
57
 
@@ -124,17 +124,6 @@ module Clack
124
124
  lines.join
125
125
  end
126
126
 
127
- def build_final_frame
128
- lines = []
129
- lines << "#{bar}\n"
130
- lines << "#{symbol_for_state} #{@message}\n"
131
-
132
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
133
- lines << "#{bar} #{display}\n"
134
-
135
- lines.join
136
- end
137
-
138
127
  private
139
128
 
140
129
  def update_suggestions
@@ -67,17 +67,7 @@ module Clack
67
67
  lines.join
68
68
  end
69
69
 
70
- def build_final_frame
71
- lines = []
72
- lines << "#{bar}\n"
73
- lines << "#{symbol_for_state} #{@message}\n"
74
-
75
- display = format_value(@value)
76
- styled = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display)) : Colors.dim(display)
77
- lines << "#{bar} #{styled}\n"
78
-
79
- lines.join
80
- end
70
+ def final_display = format_value(@value)
81
71
 
82
72
  private
83
73
 
@@ -80,17 +80,7 @@ module Clack
80
80
  lines.join
81
81
  end
82
82
 
83
- def build_final_frame
84
- lines = []
85
- lines << "#{bar}\n"
86
- lines << "#{symbol_for_state} #{@message}\n"
87
-
88
- label = current_option[:label]
89
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
90
- lines << "#{bar} #{display}\n"
91
-
92
- lines.join
93
- end
83
+ def final_display = current_option[:label]
94
84
 
95
85
  private
96
86
 
@@ -63,17 +63,7 @@ module Clack
63
63
  lines.join
64
64
  end
65
65
 
66
- def build_final_frame
67
- lines = []
68
- lines << "#{bar}\n"
69
- lines << "#{symbol_for_state} #{@message}\n"
70
-
71
- label = @options.find { |o| o[:value] == @value }&.dig(:label).to_s
72
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(label)) : Colors.dim(label)
73
- lines << "#{bar} #{display}\n"
74
-
75
- lines.join
76
- end
66
+ def final_display = @options.find { |o| o[:value] == @value }&.dig(:label).to_s
77
67
 
78
68
  private
79
69
 
@@ -104,17 +104,6 @@ module Clack
104
104
  lines.join
105
105
  end
106
106
 
107
- def build_final_frame
108
- lines = []
109
- lines << "#{bar}\n"
110
- lines << "#{symbol_for_state} #{@message}\n"
111
-
112
- display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(@value)) : Colors.dim(@value)
113
- lines << "#{bar} #{display}\n"
114
-
115
- lines.join
116
- end
117
-
118
107
  private
119
108
 
120
109
  # Complete the current input using the longest common prefix of matching candidates.
data/lib/clack/symbols.rb CHANGED
@@ -26,8 +26,7 @@ module Clack
26
26
  # Explicit override
27
27
  return ENV["CLACK_UNICODE"] == "1" if ENV["CLACK_UNICODE"]
28
28
 
29
- # Default: TTY and not dumb terminal
30
- $stdout.tty? && ENV["TERM"] != "dumb" && !ENV["NO_COLOR"]
29
+ Environment.colors_supported?
31
30
  end
32
31
  end
33
32
 
@@ -36,7 +35,7 @@ module Clack
36
35
  # Unicode cancel step indicator, or ASCII fallback.
37
36
  S_STEP_CANCEL = unicode? ? "■" : "x"
38
37
  # Unicode error step indicator, or ASCII fallback.
39
- S_STEP_ERROR = unicode? ? "▲" : "x"
38
+ S_STEP_ERROR = unicode? ? "▲" : "!"
40
39
  # Unicode submit step indicator, or ASCII fallback.
41
40
  S_STEP_SUBMIT = unicode? ? "◇" : "o"
42
41
 
data/lib/clack/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Clack
4
4
  # Current gem version.
5
- VERSION = "0.4.4"
5
+ VERSION = "0.4.6"
6
6
  end
data/lib/clack.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "clack/version"
4
+ require_relative "clack/environment"
4
5
  require_relative "clack/symbols"
5
6
  require_relative "clack/colors"
6
- require_relative "clack/environment"
7
7
  require_relative "clack/utils"
8
8
  require_relative "clack/core/cursor"
9
9
  require_relative "clack/core/settings"
@@ -508,15 +508,20 @@ module Clack
508
508
  end
509
509
  end
510
510
 
511
- # Terminal cleanup on exit - show cursor if it was hidden
511
+ # Terminal cleanup on exit show cursor if it was hidden.
512
+ # Uses raw write(2) for async-signal safety in trap handlers.
513
+ CURSOR_SHOW = "\e[?25h"
514
+
512
515
  at_exit do
513
- print "\e[?25h"
516
+ $stdout.print Clack::Core::Cursor.show
517
+ rescue IOError, SystemCallError
518
+ # Output unavailable
514
519
  end
515
520
 
516
- # Chain INT handler to restore cursor before passing to previous handler
521
+ # Chain INT handler to restore cursor before passing to previous handler.
517
522
  previous_int_handler = trap("INT") do
518
523
  begin
519
- print "\e[?25h"
524
+ $stdout.write_nonblock(CURSOR_SHOW)
520
525
  rescue IOError, SystemCallError
521
526
  # Output unavailable — nothing we can do
522
527
  end
@@ -532,10 +537,10 @@ previous_int_handler = trap("INT") do
532
537
  end
533
538
  end
534
539
 
535
- # Handle SIGTERM similarly to INT — restore cursor on graceful kill
540
+ # Handle SIGTERM similarly to INT — restore cursor on graceful kill.
536
541
  trap("TERM") do
537
542
  begin
538
- print "\e[?25h"
543
+ $stdout.write_nonblock(CURSOR_SHOW)
539
544
  rescue IOError, SystemCallError
540
545
  # Output unavailable
541
546
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Whittaker
@@ -34,6 +34,8 @@ files:
34
34
  - examples/images/select_example.rb
35
35
  - examples/images/spinner_example.rb
36
36
  - examples/images/text_example.rb
37
+ - examples/migration_from_tty_prompt.rb
38
+ - examples/showcase.rb
37
39
  - examples/spinner_demo.rb
38
40
  - examples/tasks_demo.rb
39
41
  - examples/validation.rb
@@ -101,7 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
101
103
  - !ruby/object:Gem::Version
102
104
  version: '0'
103
105
  requirements: []
104
- rubygems_version: 3.6.9
106
+ rubygems_version: 4.0.8
105
107
  specification_version: 4
106
108
  summary: Beautiful, minimal CLI prompts
107
109
  test_files: []