brute_cli 0.1.2 → 0.1.3
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 +4 -4
- data/exe/brute +3 -0
- data/lib/brute_cli/bat.rb +67 -0
- data/lib/brute_cli/commands.rb +38 -0
- data/lib/brute_cli/emoji.rb +2 -0
- data/lib/brute_cli/fzf_menu.rb +150 -0
- data/lib/brute_cli/question_screen.rb +334 -0
- data/lib/brute_cli/repl.rb +496 -194
- data/lib/brute_cli/stream_formatter.rb +114 -0
- data/lib/brute_cli/styles.rb +36 -35
- data/lib/brute_cli/version.rb +1 -1
- data/lib/brute_cli.rb +18 -5
- metadata +94 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f33b8ddd973b65aaa7541e4105c3d87d52f39b22ea9500d613a3b3e193024ac
|
|
4
|
+
data.tar.gz: 4c02c1b1cf76a2564062a120e235212ea86304366901bb1f574fa95bbecc95b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d0bcbadf66e5d7d0abc8445f89c5bdb153b44eb08effad501f43bf5847d6475b476e5215ad1be4fbdf712af83e947002037e78d7d50462b1b139e79d825a9d9
|
|
7
|
+
data.tar.gz: e93b600e57fda32304b8ec61c097f82cd4ecc06e659eba08dcbcb6f0c6a74d1d54012fd0907c70ea94aea5e0cb8e3d351c4b51925fb3f015ca60f815f27f0be6
|
data/exe/brute
CHANGED
|
@@ -11,10 +11,13 @@ OptionParser.new do |opts|
|
|
|
11
11
|
opts.on("-d", "--directory DIR", "Working directory") { |d| options[:cwd] = File.expand_path(d) }
|
|
12
12
|
opts.on("-s", "--session ID", "Resume a session") { |id| options[:session_id] = id }
|
|
13
13
|
opts.on("--list-sessions", "List saved sessions") { options[:list] = true }
|
|
14
|
+
opts.on("--theme NAME", "Color theme (#{BruteCLI::THEMES.keys.join(', ')})") { |t| options[:theme] = t }
|
|
14
15
|
opts.on("-v", "--version", "Show version") { puts "brute #{Brute::VERSION}"; exit }
|
|
15
16
|
opts.on("-h", "--help", "Show help") { puts opts; exit }
|
|
16
17
|
end.parse!
|
|
17
18
|
|
|
19
|
+
BruteCLI.apply_theme!(options[:theme]) if options[:theme]
|
|
20
|
+
|
|
18
21
|
# ── List sessions ──
|
|
19
22
|
|
|
20
23
|
if options[:list]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module BruteCLI
|
|
6
|
+
# Thin wrapper around the `bat` command for syntax-highlighted terminal output.
|
|
7
|
+
# Provides two rendering modes: one for unified diffs and one for markdown prose.
|
|
8
|
+
module Bat
|
|
9
|
+
BAT_BIN = ENV.fetch("BRUTE_BAT_BIN", "bat")
|
|
10
|
+
|
|
11
|
+
COMMON_FLAGS = %w[
|
|
12
|
+
--color=always
|
|
13
|
+
--paging=never
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Render a unified diff with line numbers and a grid border.
|
|
17
|
+
#
|
|
18
|
+
# BruteCLI::Bat.diff_mode(patch_text, width: 100)
|
|
19
|
+
#
|
|
20
|
+
def self.diff_mode(text, width: 80)
|
|
21
|
+
run(text, language: "diff", style: "numbers,grid", width: width)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Render markdown source with syntax highlighting (headers, bold, fenced
|
|
25
|
+
# code blocks, etc.) — no extra decorations so it reads like prose.
|
|
26
|
+
#
|
|
27
|
+
# BruteCLI::Bat.markdown_mode(md_text, width: 120)
|
|
28
|
+
#
|
|
29
|
+
def self.markdown_mode(text, width: 80)
|
|
30
|
+
run(text, language: "markdown", style: "plain", width: width)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns true if the bat binary is found on PATH.
|
|
34
|
+
def self.available?
|
|
35
|
+
return @available if defined?(@available)
|
|
36
|
+
@available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, BAT_BIN)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Low-level: pipe +text+ through bat with arbitrary options.
|
|
40
|
+
def self.run(text, language:, style:, width: 80)
|
|
41
|
+
cmd = [
|
|
42
|
+
BAT_BIN,
|
|
43
|
+
*COMMON_FLAGS,
|
|
44
|
+
"--language=#{language}",
|
|
45
|
+
"--style=#{style}",
|
|
46
|
+
"--terminal-width=#{width}",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
stdout, status = Open3.capture2(*cmd, stdin_data: text)
|
|
50
|
+
|
|
51
|
+
if status.success?
|
|
52
|
+
stdout
|
|
53
|
+
else
|
|
54
|
+
# If bat exits non-zero, return the raw text so we never swallow output.
|
|
55
|
+
text
|
|
56
|
+
end
|
|
57
|
+
rescue Errno::ENOENT
|
|
58
|
+
unless @bat_missing_warned
|
|
59
|
+
msg = " bat not found — diff syntax highlighting unavailable.\n" \
|
|
60
|
+
" Install: https://github.com/sharkdp/bat#installation "
|
|
61
|
+
$stderr.puts msg.colorize(background: :red, color: :white)
|
|
62
|
+
@bat_missing_warned = true
|
|
63
|
+
end
|
|
64
|
+
text
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteCLI
|
|
4
|
+
# Registry of slash commands available in the REPL.
|
|
5
|
+
#
|
|
6
|
+
# Each command maps to a method name on the REPL instance.
|
|
7
|
+
# Reline's completion_proc uses +names+ to offer autocomplete suggestions
|
|
8
|
+
# when the user types "/" at the start of a line.
|
|
9
|
+
#
|
|
10
|
+
module Commands
|
|
11
|
+
Entry = Struct.new(:name, :description, :method_name, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
REGISTRY = [
|
|
14
|
+
Entry.new(name: "/menu", description: "Open main menu", method_name: :cmd_menu),
|
|
15
|
+
Entry.new(name: "/model", description: "Change model", method_name: :cmd_model),
|
|
16
|
+
Entry.new(name: "/provider", description: "Change provider", method_name: :cmd_provider),
|
|
17
|
+
Entry.new(name: "/help", description: "Show available commands", method_name: :cmd_help),
|
|
18
|
+
Entry.new(name: "/compact", description: "Compact conversation", method_name: :cmd_compact),
|
|
19
|
+
Entry.new(name: "/exit", description: "Exit brute", method_name: :cmd_exit),
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# All command names, for Reline completion.
|
|
23
|
+
def self.names
|
|
24
|
+
REGISTRY.map(&:name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Does this input look like a slash command?
|
|
28
|
+
def self.match?(input)
|
|
29
|
+
input.strip.start_with?("/")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Find the matching Entry for the given input, or nil.
|
|
33
|
+
def self.find(input)
|
|
34
|
+
cmd = input.strip.split(/\s+/, 2).first
|
|
35
|
+
REGISTRY.detect { |e| e.name == cmd }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/brute_cli/emoji.rb
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BruteCLI
|
|
4
|
+
# A simple state-machine menu system powered by fzf.
|
|
5
|
+
#
|
|
6
|
+
# Each menu is a named bag of choices. Each choice points to either:
|
|
7
|
+
# - a Symbol → the name of the next menu to show
|
|
8
|
+
# - nil → exit the menu loop (Ctrl-C / Escape does the same)
|
|
9
|
+
# - anything else → returned to the caller as an "action" value
|
|
10
|
+
#
|
|
11
|
+
# The engine is just: while current.is_a?(Symbol) { current = show(menu) }
|
|
12
|
+
#
|
|
13
|
+
# Menus can be static (block evaluated at definition time) or dynamic
|
|
14
|
+
# (block with arity > 0, evaluated fresh each time the menu is shown).
|
|
15
|
+
#
|
|
16
|
+
# Titles can be strings or callables (lambdas) for dynamic labels.
|
|
17
|
+
#
|
|
18
|
+
# Example:
|
|
19
|
+
#
|
|
20
|
+
# app = BruteCLI::FzfMenu.new do
|
|
21
|
+
# menu :main, "Cool Menu" do
|
|
22
|
+
# choice "Status", :status
|
|
23
|
+
# choice "Manage", :manage
|
|
24
|
+
# choice "Exit", nil
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# menu :status, "Status" do
|
|
28
|
+
# choice "Back", :main
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# # Dynamic menu — block receives a fresh Menu at render time
|
|
32
|
+
# menu :models, -> { "Select Model" } do |m|
|
|
33
|
+
# models.each { |id| m.choice(id, [:set_model, id]) }
|
|
34
|
+
# m.choice "Back", :main
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# result = app.call # starts at :main
|
|
39
|
+
# result = app.call(:models) # jump straight to :models
|
|
40
|
+
#
|
|
41
|
+
class FzfMenu
|
|
42
|
+
class Menu
|
|
43
|
+
attr_reader :title, :choices
|
|
44
|
+
|
|
45
|
+
def initialize(title = nil)
|
|
46
|
+
@title = title
|
|
47
|
+
@choices = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolved_title
|
|
51
|
+
@title.respond_to?(:call) ? @title.call : @title.to_s
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def choice(label, target)
|
|
55
|
+
@choices << [label, target]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(&definition)
|
|
60
|
+
@menus = {} # name → Menu (static)
|
|
61
|
+
@dynamic = {} # name → [title, block] (dynamic, built at render time)
|
|
62
|
+
instance_eval(&definition) if definition
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Define a named menu.
|
|
66
|
+
#
|
|
67
|
+
# Static (block with no params — evaluated once at definition):
|
|
68
|
+
# menu :main, "Title" do
|
|
69
|
+
# choice "Foo", :foo
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# Dynamic (block with one param — evaluated each time the menu is shown):
|
|
73
|
+
# menu :models, "Title" do |m|
|
|
74
|
+
# m.choice "Foo", :foo
|
|
75
|
+
# end
|
|
76
|
+
#
|
|
77
|
+
def menu(name, title = nil, &block)
|
|
78
|
+
if block.arity > 0
|
|
79
|
+
@dynamic[name] = [title, block]
|
|
80
|
+
else
|
|
81
|
+
m = Menu.new(title)
|
|
82
|
+
m.instance_eval(&block)
|
|
83
|
+
@menus[name] = m
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Run the menu loop starting at the given menu name.
|
|
88
|
+
# Returns the final non-Symbol value chosen (or nil on escape/Ctrl-C).
|
|
89
|
+
def call(start = nil)
|
|
90
|
+
start ||= @menus.keys.first || @dynamic.keys.first
|
|
91
|
+
current = start
|
|
92
|
+
|
|
93
|
+
while current.is_a?(Symbol)
|
|
94
|
+
current = show(resolve_menu(current))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
current
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# All registered menu names (static + dynamic).
|
|
101
|
+
def menu_names
|
|
102
|
+
@menus.keys | @dynamic.keys
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def resolve_menu(name)
|
|
108
|
+
if @dynamic.key?(name)
|
|
109
|
+
title, builder = @dynamic[name]
|
|
110
|
+
m = Menu.new(title)
|
|
111
|
+
builder.call(m)
|
|
112
|
+
m
|
|
113
|
+
else
|
|
114
|
+
@menus.fetch(name) { raise KeyError, "Unknown menu: #{name.inspect}" }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def show(menu)
|
|
119
|
+
labels = menu.choices.map(&:first)
|
|
120
|
+
return nil if labels.empty?
|
|
121
|
+
|
|
122
|
+
selected = fzf(labels, prompt: menu.resolved_title)
|
|
123
|
+
return nil unless selected
|
|
124
|
+
|
|
125
|
+
menu.choices.detect { |label, _| label == selected }&.last
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fzf(items, prompt:)
|
|
129
|
+
unless fzf_available?
|
|
130
|
+
warn "fzf not found in PATH. Install fzf to use interactive menus."
|
|
131
|
+
return nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
cmd = ["fzf", "--prompt=#{prompt} › ", "--height=~#{items.size + 2}", "--reverse", "--no-info"]
|
|
135
|
+
IO.popen(cmd, "r+") do |io|
|
|
136
|
+
io.puts items
|
|
137
|
+
io.close_write
|
|
138
|
+
io.gets&.strip
|
|
139
|
+
end
|
|
140
|
+
rescue Errno::ENOENT
|
|
141
|
+
warn "fzf not found in PATH. Install fzf to use interactive menus."
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def fzf_available?
|
|
146
|
+
return @fzf_available if defined?(@fzf_available)
|
|
147
|
+
@fzf_available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "fzf")) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bubbletea"
|
|
4
|
+
require "lipgloss"
|
|
5
|
+
require "bubbles"
|
|
6
|
+
|
|
7
|
+
module BruteCLI
|
|
8
|
+
# Full-screen alternate-buffer question form built directly on Bubbletea.
|
|
9
|
+
#
|
|
10
|
+
# Opens a new terminal screen (like fzf/vim) via alt_screen: true,
|
|
11
|
+
# renders select/multi-select forms with Lipgloss styling, handles
|
|
12
|
+
# keyboard input through Bubbletea's raw-mode event loop (termios),
|
|
13
|
+
# and restores the original screen when done.
|
|
14
|
+
class QuestionScreen
|
|
15
|
+
include Bubbletea::Model
|
|
16
|
+
|
|
17
|
+
# ── Styles ──
|
|
18
|
+
|
|
19
|
+
ACCENT = "#FFCC00"
|
|
20
|
+
DIM = "#666666"
|
|
21
|
+
SELECTED = "#00FF88"
|
|
22
|
+
HELP_COLOR = "#555555"
|
|
23
|
+
|
|
24
|
+
# ── Public API ──
|
|
25
|
+
|
|
26
|
+
def self.ask(questions)
|
|
27
|
+
screen = new(questions)
|
|
28
|
+
screen.run
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(questions)
|
|
32
|
+
@questions = questions.map { |q| normalize(q) }
|
|
33
|
+
@question_idx = 0
|
|
34
|
+
@cursor = 0
|
|
35
|
+
@selected = Set.new # for multi-select
|
|
36
|
+
@answers = [] # collected answers per question
|
|
37
|
+
@state = :selecting # :selecting | :other_input | :done | :aborted
|
|
38
|
+
@text_input = Bubbles::TextInput.new
|
|
39
|
+
@text_input.prompt = " > "
|
|
40
|
+
@text_input.placeholder = "Type your response..."
|
|
41
|
+
@width = 80
|
|
42
|
+
@height = 24
|
|
43
|
+
|
|
44
|
+
build_styles
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run
|
|
48
|
+
# Release the terminal from reline before bubbletea takes over.
|
|
49
|
+
# Reline may hold termios flags (cooked mode, echo, signal handling)
|
|
50
|
+
# that prevent bubbletea's Go FFI from entering raw mode and reading
|
|
51
|
+
# escape sequences (arrow keys). deprep_terminal restores the
|
|
52
|
+
# terminal to its pre-reline state so bubbletea gets a clean slate.
|
|
53
|
+
reline_active = defined?(Reline) && Reline.respond_to?(:deprep_terminal)
|
|
54
|
+
Reline.deprep_terminal if reline_active
|
|
55
|
+
|
|
56
|
+
Bubbletea.run(self, alt_screen: true)
|
|
57
|
+
@state == :aborted ? @questions.map { [] } : @answers
|
|
58
|
+
ensure
|
|
59
|
+
# Re-prep reline so the REPL prompt works when we return.
|
|
60
|
+
Reline.prep_terminal if reline_active
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ── Bubbletea::Model interface ──
|
|
64
|
+
|
|
65
|
+
def init
|
|
66
|
+
[self, nil]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def update(message)
|
|
70
|
+
case message
|
|
71
|
+
when Bubbletea::WindowSizeMessage
|
|
72
|
+
@width = message.width
|
|
73
|
+
@height = message.height
|
|
74
|
+
[self, nil]
|
|
75
|
+
when Bubbletea::KeyMessage
|
|
76
|
+
handle_key(message)
|
|
77
|
+
else
|
|
78
|
+
[self, nil]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def view
|
|
83
|
+
return "" if @state == :done || @state == :aborted
|
|
84
|
+
|
|
85
|
+
case @state
|
|
86
|
+
when :selecting
|
|
87
|
+
view_selecting
|
|
88
|
+
when :other_input
|
|
89
|
+
view_other_input
|
|
90
|
+
else
|
|
91
|
+
""
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# ── Key handling ──
|
|
98
|
+
|
|
99
|
+
def handle_key(msg)
|
|
100
|
+
case @state
|
|
101
|
+
when :selecting
|
|
102
|
+
handle_selecting_key(msg)
|
|
103
|
+
when :other_input
|
|
104
|
+
handle_other_input_key(msg)
|
|
105
|
+
else
|
|
106
|
+
[self, nil]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_selecting_key(msg)
|
|
111
|
+
opts = current_options_with_other
|
|
112
|
+
|
|
113
|
+
# Match on KeyMessage type methods first (reliable across Go FFI
|
|
114
|
+
# versions), then fall back to string name for character keys.
|
|
115
|
+
if msg.to_s == "ctrl+c" || msg.to_s == "q"
|
|
116
|
+
@state = :aborted
|
|
117
|
+
return [self, Bubbletea.quit]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if msg.up? || msg.to_s == "k"
|
|
121
|
+
@cursor = (@cursor - 1) % opts.size
|
|
122
|
+
return [self, nil]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if msg.down? || msg.to_s == "j"
|
|
126
|
+
@cursor = (@cursor + 1) % opts.size
|
|
127
|
+
return [self, nil]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if msg.space? || msg.to_s == "x"
|
|
131
|
+
if current_multiple?
|
|
132
|
+
if @selected.include?(@cursor)
|
|
133
|
+
@selected.delete(@cursor)
|
|
134
|
+
else
|
|
135
|
+
@selected.add(@cursor)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
return [self, nil]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if msg.enter?
|
|
142
|
+
return confirm_selection
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
[self, nil]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def handle_other_input_key(msg)
|
|
149
|
+
if msg.enter?
|
|
150
|
+
val = @text_input.value.strip
|
|
151
|
+
current_answer = pending_selected_labels
|
|
152
|
+
current_answer << val unless val.empty?
|
|
153
|
+
return finalize_answer(current_answer)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if msg.esc?
|
|
157
|
+
@state = :selecting
|
|
158
|
+
return [self, nil]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if msg.to_s == "ctrl+c"
|
|
162
|
+
@state = :aborted
|
|
163
|
+
return [self, Bubbletea.quit]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@text_input, cmd = @text_input.update(msg)
|
|
167
|
+
[self, cmd]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ── Selection logic ──
|
|
171
|
+
|
|
172
|
+
def confirm_selection
|
|
173
|
+
opts = current_options_with_other
|
|
174
|
+
|
|
175
|
+
if current_multiple?
|
|
176
|
+
labels = @selected.sort.map { |i| opts[i] }
|
|
177
|
+
else
|
|
178
|
+
labels = [opts[@cursor]]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if labels.include?(:other)
|
|
182
|
+
labels.delete(:other)
|
|
183
|
+
@state = :other_input
|
|
184
|
+
@text_input.value = ""
|
|
185
|
+
@text_input.focus
|
|
186
|
+
@pending_labels = labels.map { |l| l.is_a?(Hash) ? l[:value] : l }
|
|
187
|
+
return [self, nil]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
answer = labels.map { |l| l.is_a?(Hash) ? l[:value] : l.to_s }
|
|
191
|
+
finalize_answer(answer)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def pending_selected_labels
|
|
195
|
+
(@pending_labels || []).map(&:to_s)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def finalize_answer(answer)
|
|
199
|
+
@answers << answer.map(&:to_s)
|
|
200
|
+
|
|
201
|
+
# Advance to next question
|
|
202
|
+
@question_idx += 1
|
|
203
|
+
if @question_idx >= @questions.size
|
|
204
|
+
@state = :done
|
|
205
|
+
[self, Bubbletea.quit]
|
|
206
|
+
else
|
|
207
|
+
@cursor = 0
|
|
208
|
+
@selected = Set.new
|
|
209
|
+
@state = :selecting
|
|
210
|
+
[self, nil]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ── View rendering ──
|
|
215
|
+
|
|
216
|
+
def view_selecting
|
|
217
|
+
q = current_question
|
|
218
|
+
opts = current_options_with_other
|
|
219
|
+
lines = []
|
|
220
|
+
|
|
221
|
+
# Header
|
|
222
|
+
lines << ""
|
|
223
|
+
lines << @header_style.render(" #{q['header'] || 'Question'}")
|
|
224
|
+
lines << ""
|
|
225
|
+
|
|
226
|
+
# Question text
|
|
227
|
+
lines << @question_style.render(" #{q['question']}")
|
|
228
|
+
lines << ""
|
|
229
|
+
|
|
230
|
+
# Options
|
|
231
|
+
opts.each_with_index do |opt, i|
|
|
232
|
+
is_cursor = i == @cursor
|
|
233
|
+
label = opt == :other ? "Other (custom answer)" : "#{opt[:label]} -- #{opt[:desc]}"
|
|
234
|
+
|
|
235
|
+
if current_multiple?
|
|
236
|
+
check = @selected.include?(i) ? "[x]" : "[ ]"
|
|
237
|
+
prefix = is_cursor ? " > #{check} " : " #{check} "
|
|
238
|
+
else
|
|
239
|
+
prefix = is_cursor ? " > " : " "
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
text = "#{prefix}#{label}"
|
|
243
|
+
lines << if is_cursor
|
|
244
|
+
@cursor_style.render(text)
|
|
245
|
+
else
|
|
246
|
+
@option_style.render(text)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Help bar
|
|
251
|
+
lines << ""
|
|
252
|
+
help = if current_multiple?
|
|
253
|
+
" up/down navigate | space toggle | enter confirm | q quit"
|
|
254
|
+
else
|
|
255
|
+
" up/down navigate | enter select | q quit"
|
|
256
|
+
end
|
|
257
|
+
lines << @help_style.render(help)
|
|
258
|
+
|
|
259
|
+
# Progress
|
|
260
|
+
if @questions.size > 1
|
|
261
|
+
lines << @help_style.render(" question #{@question_idx + 1}/#{@questions.size}")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
content = lines.join("\n")
|
|
265
|
+
|
|
266
|
+
# Center vertically
|
|
267
|
+
content_lines = content.split("\n").size
|
|
268
|
+
pad = [(@height - content_lines) / 2, 1].max
|
|
269
|
+
("\n" * pad) + content
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def view_other_input
|
|
273
|
+
q = current_question
|
|
274
|
+
lines = []
|
|
275
|
+
|
|
276
|
+
lines << ""
|
|
277
|
+
lines << @header_style.render(" #{q['header'] || 'Question'}")
|
|
278
|
+
lines << ""
|
|
279
|
+
lines << @question_style.render(" Your answer:")
|
|
280
|
+
lines << ""
|
|
281
|
+
lines << " #{@text_input.view}"
|
|
282
|
+
lines << ""
|
|
283
|
+
lines << @help_style.render(" enter submit | esc back | ctrl+c quit")
|
|
284
|
+
|
|
285
|
+
content = lines.join("\n")
|
|
286
|
+
content_lines = content.split("\n").size
|
|
287
|
+
pad = [(@height - content_lines) / 2, 1].max
|
|
288
|
+
("\n" * pad) + content
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ── Helpers ──
|
|
292
|
+
|
|
293
|
+
def normalize(q)
|
|
294
|
+
q.transform_keys(&:to_s).tap do |h|
|
|
295
|
+
h["options"] = (h["options"] || []).map { |o| o.transform_keys(&:to_s) }
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def current_question
|
|
300
|
+
@questions[@question_idx]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def current_multiple?
|
|
304
|
+
current_question["multiple"]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def current_options_with_other
|
|
308
|
+
opts = current_question["options"].map do |o|
|
|
309
|
+
{ label: o["label"], desc: o["description"], value: o["label"] }
|
|
310
|
+
end
|
|
311
|
+
opts << :other
|
|
312
|
+
opts
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def build_styles
|
|
316
|
+
@header_style = Lipgloss::Style.new
|
|
317
|
+
.bold(true)
|
|
318
|
+
.foreground(ACCENT)
|
|
319
|
+
|
|
320
|
+
@question_style = Lipgloss::Style.new
|
|
321
|
+
.bold(true)
|
|
322
|
+
|
|
323
|
+
@cursor_style = Lipgloss::Style.new
|
|
324
|
+
.foreground(SELECTED)
|
|
325
|
+
.bold(true)
|
|
326
|
+
|
|
327
|
+
@option_style = Lipgloss::Style.new
|
|
328
|
+
|
|
329
|
+
@help_style = Lipgloss::Style.new
|
|
330
|
+
.foreground(HELP_COLOR)
|
|
331
|
+
.italic(true)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|