1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
//! decide — the decision gate, concretely (app-kits/0515).
//!
//! Three flavors of "block the flow on a question", each behind a key:
//!
//! - `1` a destructive confirmation with alternatives — single choice,
//! details, per-option shortcut letters (`o`/`k`/`d`), a danger-
//! tinted option, an "Other" free-text escape, and MUST-CHOOSE mode
//! (`dismissable(false)`: Esc refuses visibly — destructive gates
//! should not be dismissable into limbo),
//! - `2` a feature pick (multiple choice, pre-checked current state)
//! with a BODY (first-app 0287): a scrollable manifest of what the
//! agent asked for, rendered between the prompt and the options —
//! the wheel scrolls it while arrows/Space/Enter stay with the
//! options; the manifest carries a 72-col command row, so the gate
//! declares `body_width(72)` (first-app 0271) — the panel widens to
//! fit it instead of clipping at the options' natural width,
//! - `3` a two-step setup sequence (`ChoiceSequence`).
//!
//! The status line renders the last outcome: the flow CONTINUES in
//! `on_resolve` — that is the whole point of a gate. Esc inside a
//! dismissable gate cancels it (an explicit outcome, never silent;
//! from inside the Other editor the first Esc only retreats to the
//! list); a click outside does nothing (a decision gate has explicit
//! endings only).
//!
//! Keys: 1/2/3 open gates · Ctrl+T theme · q quit.
//!
//! Docs: docs/api.md § "app::ChoicePrompt — the modal decision gate".
//!
//! OWNER: CHOICE (0515).
use abstracttui::prelude::*;
use abstracttui::theme::themes;
use abstracttui::ui::text;
fn describe(outcome: &ChoiceOutcome) -> String {
match outcome {
ChoiceOutcome::Answered(a) => {
let mut parts: Vec<String> = a.selected.clone();
if let Some(other) = &a.other {
parts.push(format!("other: {other}"));
}
if parts.is_empty() {
String::from("answered: (none)")
} else {
format!("answered: {}", parts.join(", "))
}
}
ChoiceOutcome::Cancelled => String::from("cancelled"),
}
}
fn main() -> abstracttui::base::Result<()> {
if !abstracttui::term::have_tty() {
println!("decide: needs an interactive terminal — skipping cleanly");
return Ok(());
}
let mut app = App::new(Size::new(72, 22));
let quitter = app.quitter();
app.mount(move |cx| {
let status = cx.signal(String::from("no decision yet — press 1, 2 or 3"));
let theme_ix = cx.signal(0usize);
Element::new()
.style(LayoutStyle::column().padding(Edges::all(1)).gap(1))
.shortcut(KeyChord::plain(Key::Char('q')), move |_| quitter.quit())
.shortcut(KeyChord::new(Mods::CTRL, Key::Char('t')), move |_| {
theme_ix.update(|i| *i = (*i + 1) % themes().len());
set_theme_by_id(themes()[theme_ix.get_untracked()].id);
})
// -- gate 1: destructive confirmation with alternatives ----
// Shortcut letters (o/k/d), a danger-tinted option, and
// must-choose mode: a destructive decision has explicit
// endings only — its options ARE the exits.
.shortcut(KeyChord::plain(Key::Char('1')), move |_| {
ChoicePrompt::new("Overwrite 3 locally modified files?")
.option_with(
ChoiceOption::new("overwrite", "Overwrite them")
.detail("the local edits are lost")
.key('o')
.danger(true),
)
.option_with(
ChoiceOption::new("keep", "Keep my copies")
.detail("the sync is skipped")
.key('k'),
)
.option_key("diff", "Show me the diff first", 'd')
.allow_other("Something else…")
.initial("keep")
.dismissable(false)
.on_resolve(move |o| status.set(format!("sync · {}", describe(&o))))
.open(cx);
})
// -- gate 2: feature pick (multiple, pre-checked) -----------
// With a BODY (first-app 0287): the approval surface's
// "what am I deciding about" — a scrollable manifest
// between the prompt and the options. The wheel scrolls it
// while the pointer is over it; every key stays with the
// options.
.shortcut(KeyChord::plain(Key::Char('2')), move |_| {
ChoicePrompt::new("Which capabilities may this agent use?")
.body(|mcx| {
let mut rows = Element::new().style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.shrink(0.0),
);
for line in [
"requested by agent \"builder\":",
" 1 write_file src/main.rs (2.1 KB)",
" 2 execute_command cargo test",
" 3 fetch_url https://crates.io/api",
" 4 write_file Cargo.toml (0.4 KB)",
" 5 execute_command cargo run",
// The 72-col case (first-app 0271): a card
// row pre-wrapped wider than the options
// would size the panel.
" 6 execute_command cargo build --release --features kitty --timings -v",
] {
rows = rows.child(text(line));
}
Scroll::new(rows.build()).content_size(72, 7).view(mcx)
})
.body_rows(4)
// The body's declared content width: the panel
// widens to fit the manifest (still clamped into
// the viewport on narrow terminals — the body then
// clips, never the options).
.body_width(72)
.option_detail("fs", "File system", "read + write inside the workspace")
.option_detail("net", "Network", "outbound requests")
.option("shell", "Shell commands")
.option("clipboard", "Clipboard")
.allow_multiple(true)
.checked(["fs"])
.allow_other("Custom grant…")
.on_resolve(move |o| status.set(format!("grants · {}", describe(&o))))
.open(cx);
})
// -- gate 3: a two-question sequence ------------------------
.shortcut(KeyChord::plain(Key::Char('3')), move |_| {
let mut q1 = ChoiceQuestion::new("Where should the project live?");
q1.options.push(ChoiceOption::new("here", "This folder"));
q1.options
.push(ChoiceOption::new("sub", "A new subfolder").detail("keeps things tidy"));
let mut q2 = ChoiceQuestion::new("Which template?");
q2.options.push(ChoiceOption::new("minimal", "Minimal"));
q2.options
.push(ChoiceOption::new("full", "Batteries included"));
q2.other = Some(String::from("Other template…"));
ChoiceSequence::new(vec![q1, q2])
.on_resolve(move |o| {
status.set(match o {
ChoiceSequenceOutcome::Completed(answers) => format!(
"setup · {} → {}",
answers[0].selected.join("+"),
answers[1]
.other
.clone()
.unwrap_or_else(|| answers[1].selected.join("+")),
),
ChoiceSequenceOutcome::Cancelled { index, .. } => {
format!("setup · cancelled at step {}", index + 1)
}
});
})
.open(cx);
})
.child(text("== decide: gate a flow on a structured question =="))
.child(text(
"1 · confirm a destructive sync (single + details + Other)",
))
.child(text(
"2 · grant capabilities (multiple, pre-checked, Other)",
))
.child(text("3 · two-step setup (ChoiceSequence)"))
.child(dyn_view(LayoutStyle::line(1), move || {
text(format!("last outcome: {}", status.get()))
}))
.child(
Element::new()
.style(LayoutStyle::default().grow(1.0))
.build(),
)
// Two self-contained legend lines, grouped in a gapless
// sub-column (as direct children, the outer gap(1) would
// split the legend with a blank row).
.child(
Element::new()
.style(LayoutStyle::column())
.child(text("keys: 1/2/3 open the gates · Ctrl+T theme · q quit"))
.child(text(
"inside a gate: arrows move · Enter commits · Space toggles (multi) · Esc cancels",
))
.build(),
)
.build()
})?;
app.run()
}