inquirex-tty 0.4.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,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module TTY
5
+ # Shared output path resolution for commands that accept an `-o/--output` option.
6
+ #
7
+ # Rules:
8
+ # - nil or empty output → nil (caller should write to stdout)
9
+ # - output is an existing dir → <dir>/<flow-basename>.<ext>
10
+ # - output has an extension → <output> with its extension replaced by .<ext>
11
+ # - output has no extension → <output>.<ext>
12
+ #
13
+ # For commands that must always write to a file (e.g. images that cannot go
14
+ # to stdout), use {.resolve_with_default}, which falls back to
15
+ # `<flow-basename>.<ext>` in the current directory.
16
+ module OutputPath
17
+ module_function
18
+
19
+ # Resolves an output path, returning nil when the caller should use stdout.
20
+ #
21
+ # @param flow_file [String] source .rb flow file (basename used for defaults)
22
+ # @param output [String, nil] value of the --output option
23
+ # @param extension [String] including leading dot, e.g. ".json"
24
+ # @return [String, nil]
25
+ def resolve(flow_file, output, extension)
26
+ return nil if output.nil? || output.to_s.empty?
27
+ return File.join(output, "#{flow_basename(flow_file)}#{extension}") if File.directory?(output)
28
+
29
+ with_default_extension(output, extension)
30
+ end
31
+
32
+ # Resolves an output path, falling back to "<basename>.<ext>" in cwd when
33
+ # no --output was given. For commands where stdout is not an option.
34
+ #
35
+ # @param flow_file [String]
36
+ # @param output [String, nil]
37
+ # @param extension [String]
38
+ # @return [String]
39
+ def resolve_with_default(flow_file, output, extension)
40
+ resolve(flow_file, output, extension) || "#{flow_basename(flow_file)}#{extension}"
41
+ end
42
+
43
+ # The flow file's basename without its extension.
44
+ #
45
+ # @param flow_file [String]
46
+ # @return [String]
47
+ def flow_basename(flow_file)
48
+ File.basename(flow_file, File.extname(flow_file))
49
+ end
50
+
51
+ # Ensures `path` ends with the given extension. If the path has no
52
+ # extension, appends it. If it has one, replaces it.
53
+ #
54
+ # @param path [String]
55
+ # @param extension [String] including leading dot
56
+ # @return [String]
57
+ def with_default_extension(path, extension)
58
+ if File.extname(path).empty?
59
+ "#{path}#{extension}"
60
+ else
61
+ path.sub(%r{\.[^/.]+\z}, extension)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module TTY
5
+ # Renders flow nodes as interactive TTY prompts. Dispatches collecting steps
6
+ # via the node's TTY widget hint (from +Inquirex::WidgetRegistry+ or an
7
+ # explicit +widget target: :tty+ declaration in the DSL).
8
+ #
9
+ # Display verbs render text/boxes and return +nil+; the caller is responsible
10
+ # for calling +engine.advance+.
11
+ #
12
+ # Widget type → tty-prompt method:
13
+ # text_input → prompt.ask
14
+ # multiline → line-by-line collector (empty line submits)
15
+ # number_input → prompt.ask(convert: :int / :float)
16
+ # currency_input → prompt.ask(convert: :float)
17
+ # yes_no → prompt.yes?
18
+ # select → prompt.select
19
+ # multi_select → prompt.multi_select
20
+ # enum_select → prompt.enum_select
21
+ # mask → prompt.mask
22
+ # slider → prompt.slider
23
+ # (fallback) → prompt.ask
24
+ #
25
+ # Header display verb uses TTY::Font for large ASCII-art section titles.
26
+ class Renderer
27
+ include UIHelper
28
+
29
+ # @return [TTY::Prompt]
30
+ attr_reader :prompt
31
+
32
+ # @param prompt [TTY::Prompt] injectable for testing
33
+ def initialize(prompt: ::TTY::Prompt.new)
34
+ @prompt = prompt
35
+ end
36
+
37
+ # Prints a "thinking" line before the LLM adapter is called.
38
+ # Plain colored text — no animation so it plays nicely with piped output.
39
+ #
40
+ # @param message [String]
41
+ # @return [void]
42
+ def thinking(message)
43
+ sep(:magenta, "─")
44
+ puts pastel.bright_magenta.bold(message)
45
+ sep(:magenta, "─")
46
+ end
47
+
48
+ # Prints the structured data extracted by a clarify step, dimming any
49
+ # fields the LLM left blank so the user can see what still needs asking.
50
+ #
51
+ # @param result [Hash] adapter output
52
+ # @return [void]
53
+ def show_extraction(result)
54
+ puts pastel.bold("📋 LLM extracted:")
55
+ result.each do |key, value|
56
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
57
+ puts pastel.dim(" ❓ #{key}: (unknown — will ask)")
58
+ else
59
+ puts pastel.green(" ✅ #{key}: #{value.inspect}")
60
+ end
61
+ end
62
+ sep(:magenta, "─")
63
+ end
64
+
65
+ # Renders a node. Returns the collected answer, or +nil+ for display verbs.
66
+ # @param node [Inquirex::Node]
67
+ # @return [Object, nil]
68
+ def render(node)
69
+ if node.display?
70
+ render_display_verb(node)
71
+ nil
72
+ else
73
+ render_collecting(node)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Dispatches display verbs to their styled renderers.
80
+ # :header uses TTY::Font for a large ASCII-art title.
81
+ # :btw and :warning use TTY::Box info/warn boxes.
82
+ # :say outputs plain text.
83
+ # @param node [Inquirex::Node]
84
+ # @return [void]
85
+ def render_display_verb(node)
86
+ case node.verb
87
+ when :header
88
+ render_header(node)
89
+ when :say
90
+ puts "\n#{node.text}\n"
91
+ prompt.keypress(pastel.dim("Press any key to continue..."))
92
+ sep(:green, "━")
93
+ when :btw
94
+ info(node.text)
95
+ prompt.keypress(pastel.dim("Press any key to continue..."))
96
+ when :warning
97
+ warning(node.text)
98
+ prompt.keypress(pastel.dim("Press any key to continue..."))
99
+ else
100
+ puts "\n#{node.text}\n"
101
+ prompt.keypress(pastel.dim("Press any key to continue..."))
102
+ end
103
+ end
104
+
105
+ # Renders a header node using TTY::Font for an ASCII-art title.
106
+ # Falls back to a TTY::Box bordered header if font rendering fails
107
+ # (e.g. unsupported characters in the text).
108
+ # @param node [Inquirex::Node]
109
+ # @return [void]
110
+ def render_header(node)
111
+ font = ::TTY::Font.new(:doom)
112
+ title_text = node.text.upcase
113
+ title_text.split.each do |_word|
114
+ title = font.write(node.text.upcase)
115
+ puts pastel.yellow(title)
116
+ end
117
+ sep(:cyan, "━")
118
+ prompt.keypress(pastel.dim("Press any key to continue..."))
119
+ rescue StandardError
120
+ # Fall back to tty-box if TTY::Font cannot render the text
121
+ puts box(node.text, bg: :blue, fg: :white)
122
+ prompt.keypress(pastel.dim("Press any key to continue..."))
123
+ sep(:cyan, "━")
124
+ end
125
+
126
+ # Gets the effective TTY widget hint and dispatches to the right render method.
127
+ # @param node [Inquirex::Node]
128
+ # @return [Object]
129
+ def render_collecting(node)
130
+ hint = effective_tty_hint(node)
131
+ method_name = :"render_#{hint.type}"
132
+ if respond_to?(method_name, true)
133
+ send(method_name, node)
134
+ else
135
+ render_text_input(node)
136
+ end
137
+ end
138
+
139
+ # Returns the effective TTY widget hint for a node, with a text_input fallback.
140
+ # @param node [Inquirex::Node]
141
+ # @return [Inquirex::WidgetHint]
142
+ def effective_tty_hint(node)
143
+ hint =
144
+ if node.respond_to?(:effective_widget_hint_for)
145
+ node.effective_widget_hint_for(target: :tty)
146
+ else
147
+ Inquirex::WidgetRegistry.default_hint_for(node.type, context: :tty)
148
+ end
149
+ hint || Inquirex::WidgetHint.new(type: :text_input)
150
+ end
151
+
152
+ # Single-line text.
153
+ def render_text_input(node)
154
+ prompt.ask(node.question)
155
+ end
156
+
157
+ # Multi-line text (empty line to submit).
158
+ def render_multiline(node)
159
+ puts pastel.bold(node.question)
160
+ puts pastel.dim("Enter your response. Press Enter on a blank line to submit.")
161
+ sep(:cyan, "─")
162
+ collect_multiline_text
163
+ end
164
+
165
+ def collect_multiline_text
166
+ lines = []
167
+ loop do
168
+ line = prompt.ask(">") { |q| q.required(false) }
169
+ break if line.nil? || line.empty?
170
+
171
+ lines << line
172
+ end
173
+ sep(:cyan, "─")
174
+ lines.empty? ? nil : lines.join("\n")
175
+ end
176
+
177
+ # Integer or float depending on the node's data type.
178
+ def render_number_input(node)
179
+ convert = node.type == :integer ? :int : :float
180
+ prompt.ask(node.question, convert:)
181
+ end
182
+
183
+ # Float for currency types.
184
+ def render_currency_input(node)
185
+ prompt.ask(node.question, convert: :float)
186
+ end
187
+
188
+ # Boolean — tty-prompt yes?.
189
+ def render_yes_no(node)
190
+ prompt.yes?(node.question)
191
+ end
192
+
193
+ # Single-choice scrollable menu.
194
+ def render_select(node)
195
+ prompt.select(node.question, select_options(node))
196
+ end
197
+
198
+ # Multiple-choice list (space to toggle, min 1 selection).
199
+ def render_multi_select(node)
200
+ prompt.multi_select(node.question, select_options(node), min: 1)
201
+ end
202
+
203
+ # Numbered choice menu.
204
+ def render_enum_select(node)
205
+ prompt.enum_select(node.question, select_options(node))
206
+ end
207
+
208
+ # Hidden / masked input.
209
+ def render_mask(node)
210
+ prompt.mask(node.question)
211
+ end
212
+
213
+ # Numeric slider. Reads min/max/step from explicit widget hint options.
214
+ def render_slider(node)
215
+ opts = {}
216
+ if node.respond_to?(:effective_widget_hint_for)
217
+ hint_opts = node.effective_widget_hint_for(target: :tty)&.options || {}
218
+ opts[:min] = hint_opts[:min] if hint_opts[:min]
219
+ opts[:max] = hint_opts[:max] if hint_opts[:max]
220
+ opts[:step] = hint_opts[:step] if hint_opts[:step]
221
+ end
222
+ prompt.slider(node.question, **opts)
223
+ end
224
+
225
+ # Aliases: date, email, phone degrade to plain text_input in TTY.
226
+ alias render_date_picker render_text_input
227
+ alias render_email_input render_text_input
228
+ alias render_phone_input render_text_input
229
+ # textarea is multiline in TTY
230
+ alias render_textarea render_multiline
231
+
232
+ # Returns options suitable for TTY::Prompt select/multi_select.
233
+ # Inquirex exposes option values via `node.options` and display labels
234
+ # via `node.option_labels` ({value => label}). TTY::Prompt expects a
235
+ # Hash in the opposite orientation: {label => return_value}.
236
+ def select_options(node)
237
+ values = node.options || []
238
+ labels = node.respond_to?(:option_labels) ? node.option_labels : nil
239
+ return values unless labels && !labels.empty?
240
+
241
+ values.to_h { |v| [labels[v] || v, v] }
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module TTY
5
+ # Mixin providing TTY::Box, Pastel, and screen helpers to CLI commands
6
+ # and the Renderer. Defines +box+, +sep+, +next_step+, +frame+, +info+,
7
+ # +success+, +error+, +warning+, and +width+ on the including class.
8
+ module UIHelper
9
+ class << self
10
+ # @return [Pastel] shared Pastel instance
11
+ def pastel
12
+ @pastel ||= Pastel.new
13
+ end
14
+
15
+ def included(base)
16
+ base.extend(Forwardable)
17
+ base.define_method(:tty_box) { ::TTY::Box }
18
+ base.define_method(:tty_screen) { ::TTY::Screen }
19
+ base.define_method(:pastel) { ::Inquirex::TTY::UIHelper.pastel }
20
+
21
+ %i[frame info success error].each do |method|
22
+ base.define_method(method) { |*args, **kwargs| puts ::TTY::Box.send(method, *args, **kwargs) }
23
+ end
24
+ base.define_method(:warning) { |*args, **kwargs| puts ::TTY::Box.send(:warn, *args, **kwargs) }
25
+
26
+ base.def_delegators :tty_screen, :width
27
+
28
+ base.class_eval do
29
+ # Draw a bordered box with optional title.
30
+ def box(text, title: nil, bg: :green, fg: :white) # rubocop:disable Naming/MethodParameterName
31
+ w = [width, 80].min
32
+ args = {
33
+ width: w,
34
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
35
+ align: :center,
36
+ style: { fg: fg, bg: bg,
37
+ border: { type: :thin, fg: fg, bg: bg } }
38
+ }
39
+ args[:title] = { top_left: title } if title
40
+ frame(text, **args)
41
+ end
42
+
43
+ # Print step progress and separator.
44
+ def next_step(step_id, step_number)
45
+ puts pastel.yellow("Step #{step_number}: #{step_id}")
46
+ sep(:yellow, "━")
47
+ end
48
+
49
+ # Print a full-width separator in the given color.
50
+ def sep(color = :yellow, char = "━")
51
+ puts pastel.send(color, char * 80)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ module TTY
5
+ VERSION = "0.4.0"
6
+ end
7
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inquirex"
4
+ require "inquirex/llm"
5
+ require "dry/cli"
6
+ require "tty-prompt"
7
+ require "tty-box"
8
+ require "tty-screen"
9
+ require "tty-font"
10
+ require "pastel"
11
+ require "forwardable"
12
+ require "json"
13
+ require "yaml"
14
+ require "time"
15
+ require "shellwords"
16
+ require "tempfile"
17
+
18
+ module Inquirex
19
+ # Terminal adapter for Inquirex flows. Renders questions as interactive
20
+ # TTY prompts via tty-prompt, mapping each data type to the appropriate
21
+ # widget via Inquirex widget hints.
22
+ #
23
+ # Entry point:
24
+ # Dry::CLI.new(Inquirex::TTY::Commands).call
25
+ module TTY
26
+ # Raised when inquirex-tty encounters a load, validation, or runtime error.
27
+ class Error < StandardError; end
28
+ end
29
+ end
30
+
31
+ require_relative "tty/version"
32
+ require_relative "tty/ui_helper"
33
+ require_relative "tty/flow_loader"
34
+ require_relative "tty/output_path"
35
+ require_relative "tty/renderer"
36
+ require_relative "tty/commands/run"
37
+ require_relative "tty/commands/validate"
38
+ require_relative "tty/commands/graph"
39
+ require_relative "tty/commands/export"
40
+ require_relative "tty/commands/version"
41
+ require_relative "tty/commands"
@@ -0,0 +1,6 @@
1
+ module Inquirex
2
+ module Tty
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,197 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: inquirex-tty
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Konstantin Gredeskoul
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: inquirex
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: inquirex-llm
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pastel
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.8'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.8'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-box
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.7'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.7'
82
+ - !ruby/object:Gem::Dependency
83
+ name: tty-font
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.5'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.5'
96
+ - !ruby/object:Gem::Dependency
97
+ name: tty-prompt
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.23'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.23'
110
+ - !ruby/object:Gem::Dependency
111
+ name: tty-screen
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.8'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.8'
124
+ description: 'Renders Inquirex flow definitions as interactive ANSI terminal wizards
125
+ using tty-prompt, tty-box, and tty-font. Provides a dry-cli command suite: run,
126
+ validate, graph, version.'
127
+ email:
128
+ - kigster@gmail.com
129
+ executables:
130
+ - inquirex
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - ".envrc"
135
+ - ".relaxed_rubocop.yml"
136
+ - ".secrets.baseline"
137
+ - CHANGELOG.md
138
+ - LICENSE.txt
139
+ - README.md
140
+ - Rakefile
141
+ - docs/badges/coverage_badge.svg
142
+ - examples/01_hello_world.rb
143
+ - examples/02_yes_or_no.rb
144
+ - examples/03_food_preferences.png
145
+ - examples/03_food_preferences.rb
146
+ - examples/04_event_registration.rb
147
+ - examples/05_job_application.rb
148
+ - examples/06_health_assessment.rb
149
+ - examples/07_loan_application.rb
150
+ - examples/08_tax_preparer.rb
151
+ - examples/09_tax_preparer_llm.rb
152
+ - examples/10_answers.json
153
+ - examples/10_real_tax_preparer.rb
154
+ - examples/README.md
155
+ - exe/inquirex
156
+ - justfile
157
+ - lefthook.yml
158
+ - lib/inquirex/tty.rb
159
+ - lib/inquirex/tty/commands.rb
160
+ - lib/inquirex/tty/commands/export.rb
161
+ - lib/inquirex/tty/commands/graph.rb
162
+ - lib/inquirex/tty/commands/run.rb
163
+ - lib/inquirex/tty/commands/validate.rb
164
+ - lib/inquirex/tty/commands/version.rb
165
+ - lib/inquirex/tty/flow_loader.rb
166
+ - lib/inquirex/tty/output_path.rb
167
+ - lib/inquirex/tty/renderer.rb
168
+ - lib/inquirex/tty/ui_helper.rb
169
+ - lib/inquirex/tty/version.rb
170
+ - sig/inquirex/tty.rbs
171
+ homepage: https://github.com/flowengine-rb/inquirex-tty
172
+ licenses:
173
+ - MIT
174
+ metadata:
175
+ allowed_push_host: https://rubygems.org
176
+ homepage_uri: https://github.com/flowengine-rb/inquirex-tty
177
+ source_code_uri: https://github.com/inquirex/inquirex-tty
178
+ changelog_uri: https://github.com/inquirex/inquirex-tty/blob/main/CHANGELOG.md
179
+ rubygems_mfa_required: 'true'
180
+ rdoc_options: []
181
+ require_paths:
182
+ - lib
183
+ required_ruby_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: 4.0.0
188
+ required_rubygems_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: '0'
193
+ requirements: []
194
+ rubygems_version: 4.0.16
195
+ specification_version: 4
196
+ summary: Terminal adapter for Inquirex flows — interactive TTY wizard via tty-prompt
197
+ test_files: []