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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
//! Overlay modals: the dashboard's drill-in and editing surfaces.
//!
//! Modals form a stack (`DashboardApp::modals`) so nesting — server → routing
//! → handler → script editor — unwinds naturally with Esc. The topmost modal
//! owns all input while it is open.
pub mod composer;
pub mod confirm;
pub mod form;
pub mod help;
pub mod intercept;
pub mod protocol_picker;
pub mod request_detail;
pub mod routing;
pub mod text_editor;
use crate::state::app_state::{AccessLogEntry, WebApprovalResponse};
use crate::tui::app::{Section, UiKey};
use composer::ComposerModel;
use form::{FieldTarget, FormModel};
use protocol_picker::ProtocolEntry;
use text_editor::TextEditorModel;
/// A modal's preferred size: percentages of the screen, each capped by what
/// the content needs so a short modal stays short on a big terminal.
#[derive(Debug, Clone, Copy)]
pub struct ModalSize {
pub percent_x: u16,
pub percent_y: u16,
pub max_cols: u16,
pub max_rows: u16,
}
impl ModalSize {
pub fn new(percent_x: u16, percent_y: u16, max_cols: u16, max_rows: u16) -> Self {
Self {
percent_x,
percent_y,
max_cols,
max_rows,
}
}
}
/// Rows the handler editor's body needs for the kind being edited.
fn draft_rows(model: &routing::RoutingModel, draft: &routing::HandlerDraft) -> u16 {
use routing::{DraftFocus, HandlerKind};
// Kind control + its blurb (2 rows) + the pattern row + a blank.
let mut rows = 5u16;
// The pattern chooser lists the protocol's events while it has focus.
if draft.focus == DraftFocus::Pattern && draft.editing.is_none() {
rows += (model.event_ids.len() as u16 + 1).min(12);
}
rows += match draft.kind {
HandlerKind::Static => 1 + draft.actions.len() as u16,
HandlerKind::Script => 3 + draft.code.lines().count().min(6) as u16,
HandlerKind::Llm => 1,
// The timeout row plus its two-line explanation.
HandlerKind::Manual => 3,
};
if draft.error.is_some() {
rows += 2;
}
rows
}
/// An action deferred behind a confirmation. Closure-free so `Modal` stays a
/// plain data enum that can be inspected and tested.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingAction {
StopServer(crate::state::ServerId),
StopClient(crate::state::ClientId),
StopAll,
Quit,
}
pub enum Modal {
/// Keybinding reference.
Help { scroll: u16 },
/// Full request/response JSON for one access-log entry.
RequestDetail {
entry: Box<AccessLogEntry>,
scroll: u16,
},
/// Yes/no confirmation carrying the action to run on confirm.
Confirm {
message: String,
action: PendingAction,
},
/// Web-search approval (ASK mode), answering the oneshot the LLM waits on.
WebApproval {
url: String,
response_tx: tokio::sync::oneshot::Sender<WebApprovalResponse>,
},
/// Read-only detail of a band's full config.
BandDetail { key: UiKey, scroll: u16 },
/// The Wireshark / tshark command and filters that capture one instance's
/// traffic — reachable from a running instance's row and from the
/// create/edit form, so the capture can start before the instance does.
Wireshark {
plan: Box<crate::tui::wireshark::CapturePlan>,
scroll: u16,
},
/// Choose a protocol — and with it the kind — for a new instance.
ProtocolPicker {
entries: Vec<ProtocolEntry>,
filter: String,
selected: usize,
/// When set, the created client is aimed at this address (the
/// `[+ client]` affordance on a running server).
prefill_remote: Option<String>,
},
/// Create/edit form for an instance.
Form(Box<FormModel>),
/// Multi-line editor for one form field.
TextEditor {
editor: Box<TextEditorModel>,
target: FieldTarget,
},
/// Compose and send an action through a running client.
Composer(Box<ComposerModel>),
/// Edit the instance's handler table (static / script / LLM / manual).
Routing(Box<routing::RoutingModel>),
/// Answer a request a `manual` rule parked for you.
Intercept(Box<intercept::InterceptModel>),
}
impl Modal {
/// Title shown in the modal's border.
pub fn title(&self) -> String {
match self {
Modal::Help { .. } => "Keys".to_string(),
Modal::RequestDetail { entry, .. } => format!("Request #{}", entry.id),
Modal::Confirm { .. } => "Confirm".to_string(),
Modal::WebApproval { .. } => "Web search approval".to_string(),
Modal::BandDetail { key, .. } => match key {
UiKey::Server(id) => format!("Server #{}", id.as_u32()),
UiKey::Client(id) => format!("Client #{}", id.as_u32()),
},
Modal::Wireshark { plan, .. } => {
format!("View in Wireshark — {}", plan.target.protocol)
}
Modal::ProtocolPicker { .. } => "New server or client — pick a protocol".to_string(),
Modal::Form(form) => match &form.mode {
form::FormMode::Create(Section::Servers) => {
format!("New {} server", form.protocol)
}
form::FormMode::Create(Section::Clients) => {
format!("New {} client", form.protocol)
}
form::FormMode::Edit(UiKey::Server(id)) => {
format!("Edit server #{}", id.as_u32())
}
form::FormMode::Edit(UiKey::Client(id)) => {
format!("Edit client #{}", id.as_u32())
}
},
Modal::TextEditor { editor, .. } => format!("Edit {}", editor.label),
Modal::Composer(composer) => match composer.target {
composer::ComposerTarget::Client(id) => {
format!("Send a request — client #{}", id.as_u32())
}
composer::ComposerTarget::Peer { server, connection } => format!(
"Message peer — connection #{connection} of server #{}",
server.as_u32()
),
composer::ComposerTarget::Intercept { id, .. } => {
format!("Answer request #{id} — pick what to send")
}
},
Modal::Routing(model) => match model.key {
UiKey::Server(id) => format!("Routing — server #{}", id.as_u32()),
UiKey::Client(id) => format!("Auto-reply rules — client #{}", id.as_u32()),
},
Modal::Intercept(model) => match model.owner {
UiKey::Server(id) => {
format!("Answer this request — server #{}", id.as_u32())
}
UiKey::Client(id) => {
format!("Answer this reply — client #{}", id.as_u32())
}
},
}
}
/// Footer hint line for the modal.
pub fn hint(&self) -> &'static str {
match self {
Modal::Help { .. } => "↑/↓ scroll · Esc close",
Modal::RequestDetail { .. } | Modal::BandDetail { .. } => "↑/↓ scroll · Esc close",
Modal::Wireshark { .. } => {
"Ctrl-T frees the mouse to select text · ↑/↓ scroll · Esc close"
}
Modal::Confirm { .. } => "y confirm · n/Esc cancel",
Modal::WebApproval { .. } => "y allow once · a always · n/Esc deny",
Modal::ProtocolPicker { .. } => {
"type to filter · ↑/↓ select · Enter choose · Esc cancel"
}
Modal::Form(form) => {
if form.busy {
"working… (network call in flight)"
} else if form.editing.is_some() {
"type to edit · Enter accept · Esc cancel field"
} else {
"↑/↓ field · Enter edit · Tab → buttons · Esc cancel"
}
}
Modal::TextEditor { editor, .. } => {
if editor.focused_button.is_some() {
"Tab next button · Enter press · type to go back to the text"
} else {
"Tab → buttons · Esc cancel"
}
}
Modal::Composer(composer) => {
if composer.busy {
"sending…"
} else if composer.editing.is_some() {
"type to edit · Enter accept · Esc cancel field"
} else if composer.chosen.is_some() {
"↑/↓ field · Enter edit (Space flips a checkbox) · Tab → buttons · Esc back"
} else {
"↑/↓ action · Enter choose · Esc cancel"
}
}
Modal::Routing(model) => {
if model.busy {
"applying…"
} else if model.draft.is_some() {
"Tab moves through everything · ←/→ change a choice · Enter act · Esc back"
} else {
"Tab moves through everything · Enter act · Esc close"
}
}
Modal::Intercept(_) => {
"Tab moves between the buttons · Enter act · Esc keeps it waiting"
}
}
}
/// How big this modal wants to be.
///
/// Everything used to be 85% x 85%, so a two-line confirmation and a
/// four-field form both opened as a near-fullscreen box of whitespace.
/// Each modal now caps itself at what its content actually needs; the
/// percentages only bound it on a small terminal.
pub fn size(&self) -> ModalSize {
// Border top + bottom, plus the button row where there is one.
const CHROME: u16 = 4;
// Scrolling modals want the screen; content-sized ones do not.
const TALL: u16 = 500;
match self {
Modal::Confirm { .. } => ModalSize::new(50, 40, 72, 5),
Modal::WebApproval { .. } => ModalSize::new(60, 40, 88, 8),
Modal::Help { .. } => ModalSize::new(72, 88, 92, TALL),
Modal::RequestDetail { .. } | Modal::BandDetail { .. } => {
ModalSize::new(80, 85, 140, TALL)
}
// A copyable command line plus its explanation: wide, and tall
// enough to scroll rather than sized to the content.
Modal::Wireshark { .. } => ModalSize::new(80, 75, 132, TALL),
Modal::ProtocolPicker { .. } => ModalSize::new(78, 80, 128, TALL),
Modal::TextEditor { .. } => ModalSize::new(76, 70, 120, TALL),
Modal::Form(form) => {
// One row per field, plus the help line under the selected one.
let rows = form.fields.len() as u16 + 3;
ModalSize::new(70, 70, 104, rows + CHROME)
}
Modal::Composer(composer) => {
let rows = match composer.chosen {
// Parameter form: a row per field, the help line under the
// selected one (which can wrap to three), the heading (which
// wraps too for long descriptions), or the raw JSON text.
Some(_) => match &composer.raw_json {
Some(raw) => raw.lines().count() as u16 + 5,
None => composer.fields.len() as u16 + 9,
},
// Action list: one row each, plus the heading.
None => composer.actions.len() as u16 + 3,
};
// The validation line ("'code' is required") lives below the
// fields; without room for it the popup clipped it and a send
// with an empty required field looked like nothing happened.
let error = if composer.error.is_some() { 2 } else { 0 };
ModalSize::new(70, 75, 112, rows + error + CHROME)
}
Modal::Intercept(model) => {
let payload = model
.event_data
.as_ref()
.map(|data| {
serde_json::to_string_pretty(data)
.map(|text| text.lines().count() as u16)
.unwrap_or(1)
.min(20)
+ 1
})
.unwrap_or(0);
ModalSize::new(72, 75, 120, payload + 7 + CHROME)
}
Modal::Routing(model) => {
let rows = match &model.draft {
Some(draft) => draft_rows(model, draft),
// Heading, a row per rule, the fallback note.
None => model.handlers.len().max(1) as u16 + 5,
};
ModalSize::new(74, 80, 124, rows + CHROME)
}
}
}
pub fn scroll_by(&mut self, delta: i16) {
let slot = match self {
Modal::Help { scroll }
| Modal::RequestDetail { scroll, .. }
| Modal::BandDetail { scroll, .. }
| Modal::Wireshark { scroll, .. } => scroll,
_ => return,
};
*slot = if delta < 0 {
slot.saturating_sub(delta.unsigned_abs())
} else {
slot.saturating_add(delta as u16)
};
}
}