1use ratatui::prelude::*;
2use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
3
4use crate::state::{
5 AppState, CommandPalette, CreateWizard, CreateWizardStep, LaunchAppPrompt, LaunchAppStep, Modal, OpenUrlPrompt,
6 PrivacyWizard, PrivacyWizardStep,
7};
8use crate::theme::Theme;
9
10const HELP_ENTRIES: &[(&str, &str)] = &[
11 ("↑ ↓ / j k", "Navigate the simulator list"),
12 ("Enter / Space", "Boot or shut down the selection"),
13 ("R", "Force refresh"),
14 ("r", "Start / stop recording"),
15 ("p", "Screenshot"),
16 ("a", "Appearance (↑/↓ select light/dark · Enter apply)"),
17 ("n", "New simulator wizard"),
18 ("f", "Focus Simulator.app on the selection"),
19 ("e", "Erase (shut-down sims only; y/n confirm)"),
20 ("d", "Delete (y/n confirm)"),
21 ("P", "Privacy wizard"),
22 ("l", "Launch an app (l again to pick a per-launch language)"),
23 ("/", "Filter simulators by name"),
24 ("i", "Inspect the selected simulator"),
25 ("o", "Open a URL / deep link on the selected booted sim"),
26 (":", "Command palette"),
27 ("?", "Help overlay"),
28 ("q / Esc", "Quit (or cancel the active modal)"),
29];
30
31pub fn render(frame: &mut Frame, state: &AppState, theme: &Theme) {
32 render_main(frame, state, theme);
34
35 match &state.modal {
36 Some(Modal::Help) => render_help(frame, theme),
37 Some(Modal::Inspector(id)) => render_inspector(frame, state, theme, *id),
38 Some(Modal::OpenUrl(prompt)) => render_open_url(frame, theme, prompt),
39 Some(Modal::CreateWizard(wizard)) => render_create_wizard(frame, state, theme, wizard),
40 Some(Modal::PrivacyWizard(wizard)) => render_privacy_wizard(frame, state, theme, wizard),
41 Some(Modal::LaunchApp(prompt)) => render_launch_app(frame, theme, prompt),
42 Some(Modal::CommandPalette(palette)) => render_command_palette_overlay(frame, state, theme, palette, frame.area()),
43 Some(Modal::Appearance(index)) => render_appearance(frame, theme, *index),
44 Some(Modal::ConfirmErase(id, index)) => render_confirm(frame, state, theme, *id, *index, ConfirmKind::Erase),
45 Some(Modal::ConfirmDelete(id, index)) => render_confirm(frame, state, theme, *id, *index, ConfirmKind::Delete),
46 _ => {}
47 }
48}
49
50fn popup_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
57 let width = (area.width * width_pct / 100).min(area.width);
58 let height = (area.height * height_pct / 100).min(area.height);
59 let x = area.x + (area.width.saturating_sub(width)) / 2;
60 let y = area.y + (area.height.saturating_sub(height)) / 2;
61 Rect { x, y, width, height }
62}
63
64fn render_popup(frame: &mut Frame, area: Rect, width_pct: u16, height_pct: u16, title: &str, theme: &Theme) -> Rect {
67 let popup = popup_rect(area, width_pct, height_pct);
68 frame.render_widget(Clear, popup);
69 let block =
70 Block::default().borders(Borders::ALL).border_style(theme.accent_style()).title(format!(" {title} ")).style(theme.base());
71 let inner = block.inner(popup);
72 frame.render_widget(block, popup);
73 inner
74}
75
76fn render_main(frame: &mut Frame, state: &AppState, theme: &Theme) {
79 let mut banner_rows: Vec<Line> = Vec::new();
80 if state.is_recording() {
81 banner_rows.push(Line::styled(" ● Recording — press r or q to stop", theme.error().add_modifier(Modifier::BOLD)));
82 }
83
84 if state.is_filter_focused || !state.filter_query.is_empty() {
85 banner_rows.push(Line::styled(format!(" Filter: {}_", state.filter_query), theme.base()));
86 }
87
88 let mut constraints = vec![Constraint::Length(1)]; constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(banner_rows.len() as u16));
91 constraints.push(Constraint::Min(1)); constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); let areas = Layout::vertical(constraints).split(frame.area());
95
96 let header_left = " holodeck ";
97 let header_right = " ⏎ toggle : cmd ? help q quit ";
98 let left_width = header_left.chars().count();
99 let right_width = header_right.chars().count();
100 let gap = (state.cols as usize).saturating_sub(left_width + right_width);
101 let header_line = Line::from(vec![
102 Span::styled(header_left, Style::new().fg(theme.chrome).add_modifier(Modifier::BOLD)),
103 Span::styled(" ".repeat(gap), Style::new()),
104 Span::styled(header_right, theme.hint()),
105 ]);
106 frame.render_widget(Paragraph::new(header_line), areas[0]);
107
108 let rule_line = Line::styled("─".repeat(state.cols as usize), theme.rule());
109 frame.render_widget(Paragraph::new(rule_line), areas[1]);
110
111 if !banner_rows.is_empty() {
112 frame.render_widget(Paragraph::new(banner_rows), areas[2]);
113 }
114 render_simulator_list(frame, state, theme, areas[3]);
115
116 let bottom_rule = Line::styled("─".repeat(state.cols as usize), theme.rule());
117 frame.render_widget(Paragraph::new(bottom_rule), areas[4]);
118 render_status_bar(frame, state, theme, areas[5]);
119}
120
121fn render_simulator_list(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
122 let visible = state.visible_simulators();
123 if visible.is_empty() {
124 let message = if state.simulators.is_empty() { "(no simulators)" } else { "(no matches)" };
125 frame.render_widget(Paragraph::new(message).style(theme.hint()), area);
126 return;
127 }
128
129 let mut items = Vec::with_capacity(visible.len() + 4);
130 let mut selected_row = None;
131 let mut current_runtime = None;
132 for (i, sim) in visible.iter().enumerate() {
133 if current_runtime != Some(&sim.runtime) {
134 items.push(ListItem::new(Line::styled(format!(" {}", sim.runtime.display_name()), theme.accent_style())));
135 current_runtime = Some(&sim.runtime);
136 }
137 if i as i64 == state.selected_index {
138 selected_row = Some(items.len());
139 }
140 let pending = state.pending_operations.get(&sim.id);
141 let status = pending.map(|op| format!(" ({op:?})")).unwrap_or_default();
142 let (dot, dot_style) = match sim.state.raw_value() {
143 "Booted" => ("●", theme.success()),
144 _ => ("○", theme.hint()),
145 };
146 let line = Line::from(vec![
147 Span::styled(format!(" {dot} "), dot_style),
148 Span::styled(format!("{}{status} [{}]", sim.name, sim.state.raw_value()), theme.base()),
149 ]);
150 items.push(ListItem::new(line));
151 }
152
153 let mut list_state = ListState::default();
154 list_state.select(selected_row);
155 let list = List::new(items).highlight_style(theme.bar());
156 frame.render_stateful_widget(list, area, &mut list_state);
157}
158
159fn render_status_bar(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
160 let (text, style) = if let Some(err) = &state.last_error {
161 (format!(" {err}"), theme.error())
162 } else if let Some(msg) = &state.status_message {
163 (format!(" {msg}"), theme.warning())
164 } else if let Some(sim) = state.selected_simulator() {
165 (format!(" {} — {}", sim.name, sim.id), theme.hint())
166 } else {
167 (String::new(), theme.hint())
168 };
169 frame.render_widget(Paragraph::new(text).style(style), area);
170}
171
172fn render_help(frame: &mut Frame, theme: &Theme) {
175 let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
176 let content_height = (2 + HELP_ENTRIES.len() + 2) as u16;
178 let popup_height_pct = ((content_height + 2) * 100 / frame.area().height.max(1)).min(90);
180
181 let inner = render_popup(frame, frame.area(), 60, popup_height_pct.max(40), "Keybindings", theme);
182
183 let mut lines = vec![Line::from("")];
184 for (key, description) in HELP_ENTRIES {
185 lines.push(Line::styled(format!(" {key:key_width$} {description}"), theme.base()));
186 }
187 lines.push(Line::from(""));
188 lines.push(Line::styled(" Press any key to close", theme.hint()));
189 frame.render_widget(Paragraph::new(lines), inner);
190}
191
192fn render_inspector(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid) {
195 let inner = render_popup(frame, frame.area(), 70, 60, "Inspector", theme);
196
197 let Some(sim) = state.simulators.iter().find(|s| s.id == id) else {
198 frame
199 .render_widget(Paragraph::new(" Simulator no longer available. Press any key to close.").style(theme.hint()), inner);
200 return;
201 };
202 let rows = [
203 ("Name", sim.name.clone()),
204 ("UDID", sim.id.to_string().to_uppercase()),
205 ("Runtime", sim.runtime.display_name()),
206 ("Device type", sim.device_type.name.clone()),
207 ("State", sim.state.raw_value().to_string()),
208 ("Available", sim.is_available.to_string()),
209 ("Data path", sim.data_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
210 ("Log path", sim.log_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
211 ];
212 let label_width = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
213 let mut lines = vec![Line::from("")];
214 for (label, value) in rows {
215 lines.push(Line::styled(format!(" {label:label_width$} {value}"), theme.base()));
216 }
217 lines.push(Line::from(""));
218 lines.push(Line::styled(" Press any key to close", theme.hint()));
219 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
220}
221
222fn render_open_url(frame: &mut Frame, theme: &Theme, prompt: &OpenUrlPrompt) {
225 let inner = render_popup(frame, frame.area(), 70, 40, "Open URL", theme);
226
227 let mut lines = vec![Line::from("")];
228 lines.push(Line::styled(format!(" {}_", prompt.url), theme.base()));
229 lines.push(Line::from(""));
230 if let Some(error) = &prompt.error {
231 lines.push(Line::styled(format!(" ⚠ {error}"), theme.error()));
232 } else if prompt.is_submitting {
233 lines.push(Line::styled(" Opening…", theme.warning()));
234 } else {
235 lines.push(Line::from(""));
236 }
237 lines.push(Line::from(""));
238 lines.push(Line::styled(" ↑/↓ history · Enter open · Esc cancel", theme.hint()));
239 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
240}
241
242fn render_create_wizard(frame: &mut Frame, state: &AppState, theme: &Theme, wizard: &CreateWizard) {
245 let inner = render_popup(frame, frame.area(), 80, 80, breadcrumb(wizard.step), theme);
246
247 let filter_visible = wizard.is_device_type_filter_focused || !wizard.device_type_filter.is_empty();
249 let filter_height = if filter_visible && wizard.step == CreateWizardStep::PickDeviceType { 1 } else { 0 };
250 let areas = Layout::vertical([Constraint::Length(filter_height), Constraint::Min(1), Constraint::Length(1)]).split(inner);
251
252 if filter_height > 0 {
253 frame.render_widget(Paragraph::new(format!(" Filter: {}_", wizard.device_type_filter)).style(theme.base()), areas[0]);
254 }
255
256 match wizard.step {
257 CreateWizardStep::Loading | CreateWizardStep::Submitting => {
258 frame.render_widget(Paragraph::new(" Loading…").style(theme.hint()), areas[1]);
259 }
260 CreateWizardStep::PickDeviceType => {
261 let visible = wizard.visible_device_types();
262 let items: Vec<ListItem> =
263 visible.iter().map(|d| ListItem::new(Span::styled(format!(" {}", d.name), theme.base()))).collect();
264 let mut list_state = ListState::default();
265 list_state.select(usize::try_from(wizard.device_type_index).ok());
266 let list = List::new(items).highlight_style(theme.bar());
267 frame.render_stateful_widget(list, areas[1], &mut list_state);
268 }
269 CreateWizardStep::PickRuntime => {
270 let items: Vec<ListItem> = wizard
271 .runtimes
272 .iter()
273 .map(|r| ListItem::new(Span::styled(format!(" {}", r.display_name()), theme.base())))
274 .collect();
275 let mut list_state = ListState::default();
276 list_state.select(usize::try_from(wizard.runtime_index).ok());
277 let list = List::new(items).highlight_style(theme.bar());
278 frame.render_stateful_widget(list, areas[1], &mut list_state);
279 }
280 CreateWizardStep::Confirm => {
281 let mut lines = vec![Line::from("")];
282 lines.push(Line::styled(format!(" Name: {}", wizard.default_name()), theme.base()));
283 if let Some(d) = wizard.selected_device_type() {
284 lines.push(Line::styled(format!(" Device: {}", d.name), theme.base()));
285 }
286 if let Some(r) = wizard.selected_runtime() {
287 lines.push(Line::styled(format!(" Runtime: {}", r.display_name()), theme.base()));
288 }
289 if let Some(error) = &wizard.error {
290 lines.push(Line::from(""));
291 lines.push(Line::styled(format!(" ⚠ {error}"), theme.error()));
292 }
293 frame.render_widget(Paragraph::new(lines), areas[1]);
294 }
295 }
296
297 frame.render_widget(Paragraph::new(wizard_footer_hint(wizard.step)).style(theme.hint()), areas[2]);
298
299 let _ = state;
301}
302
303fn breadcrumb(step: CreateWizardStep) -> &'static str {
304 match step {
305 CreateWizardStep::Loading => "New simulator — loading…",
306 CreateWizardStep::PickDeviceType => "New simulator — device type",
307 CreateWizardStep::PickRuntime => "New simulator — runtime",
308 CreateWizardStep::Confirm => "New simulator — confirm",
309 CreateWizardStep::Submitting => "New simulator — creating…",
310 }
311}
312
313fn wizard_footer_hint(step: CreateWizardStep) -> &'static str {
314 match step {
315 CreateWizardStep::PickDeviceType => "↑/↓ select · / filter · Enter next · Esc cancel",
316 CreateWizardStep::PickRuntime => "↑/↓ select · Enter next · b back · Esc cancel",
317 CreateWizardStep::Confirm => "Enter/y confirm · b back · Esc cancel",
318 _ => "Esc cancel",
319 }
320}
321
322fn render_privacy_wizard(frame: &mut Frame, state: &AppState, theme: &Theme, wizard: &PrivacyWizard) {
325 let inner = render_popup(frame, frame.area(), 80, 80, privacy_breadcrumb(wizard.step), theme);
326
327 let areas = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
328
329 match wizard.step {
330 PrivacyWizardStep::LoadingApps | PrivacyWizardStep::Submitting => {
331 frame.render_widget(Paragraph::new(" Loading…").style(theme.hint()), areas[0]);
332 }
333 PrivacyWizardStep::PickApp => {
334 let apps = wizard.apps();
335 if apps.is_empty() {
336 frame.render_widget(Paragraph::new(" (no apps)").style(theme.hint()), areas[0]);
337 } else {
338 let items: Vec<ListItem> = apps
339 .iter()
340 .map(|a| ListItem::new(Span::styled(format!(" {} ({})", a.name, a.bundle_id), theme.base())))
341 .collect();
342 let mut list_state = ListState::default();
343 list_state.select(usize::try_from(wizard.app_index).ok());
344 let list = List::new(items).highlight_style(theme.bar());
345 frame.render_stateful_widget(list, areas[0], &mut list_state);
346 }
347 }
348 PrivacyWizardStep::PickPermission => {
349 let items: Vec<ListItem> = holodeck_core::models::PrivacyPermission::ALL
350 .iter()
351 .map(|p| ListItem::new(Span::styled(format!(" {}", p.raw_value()), theme.base())))
352 .collect();
353 let mut list_state = ListState::default();
354 list_state.select(usize::try_from(wizard.permission_index).ok());
355 let list = List::new(items).highlight_style(theme.bar());
356 frame.render_stateful_widget(list, areas[0], &mut list_state);
357 }
358 PrivacyWizardStep::PickAction => {
359 let items: Vec<ListItem> = holodeck_core::models::PrivacyAction::ALL
360 .iter()
361 .map(|a| ListItem::new(Span::styled(format!(" {}", a.raw_value()), theme.base())))
362 .collect();
363 let mut list_state = ListState::default();
364 list_state.select(usize::try_from(wizard.action_index).ok());
365 let list = List::new(items).highlight_style(theme.bar());
366 frame.render_stateful_widget(list, areas[0], &mut list_state);
367 }
368 }
369
370 frame.render_widget(Paragraph::new(privacy_footer_hint(wizard.step)).style(theme.hint()), areas[1]);
371
372 let _ = state;
373}
374
375fn privacy_breadcrumb(step: PrivacyWizardStep) -> &'static str {
376 match step {
377 PrivacyWizardStep::LoadingApps => "Privacy — loading apps…",
378 PrivacyWizardStep::PickApp => "Privacy — pick an app",
379 PrivacyWizardStep::PickPermission => "Privacy — pick a permission",
380 PrivacyWizardStep::PickAction => "Privacy — pick an action",
381 PrivacyWizardStep::Submitting => "Privacy — applying…",
382 }
383}
384
385fn privacy_footer_hint(step: PrivacyWizardStep) -> &'static str {
386 match step {
387 PrivacyWizardStep::PickApp => "↑/↓ select · s toggle system apps · Enter next · Esc cancel",
388 PrivacyWizardStep::PickPermission => "↑/↓ select · Enter next · b back · Esc cancel",
389 PrivacyWizardStep::PickAction => "↑/↓ select · Enter apply · b back · Esc cancel",
390 _ => "Esc cancel",
391 }
392}
393
394fn render_launch_app(frame: &mut Frame, theme: &Theme, prompt: &LaunchAppPrompt) {
397 let inner = render_popup(frame, frame.area(), 80, 80, launch_breadcrumb(prompt.step), theme);
398
399 let filter_visible = prompt.is_language_filter_focused || !prompt.language_filter.is_empty();
400 let filter_height = u16::from(filter_visible && prompt.step == LaunchAppStep::PickLanguage);
401 let areas = Layout::vertical([Constraint::Length(filter_height), Constraint::Min(1), Constraint::Length(1)]).split(inner);
402
403 if filter_height > 0 {
404 frame.render_widget(Paragraph::new(format!(" Filter: {}_", prompt.language_filter)).style(theme.base()), areas[0]);
405 }
406
407 match prompt.step {
408 LaunchAppStep::LoadingApps | LaunchAppStep::Submitting => {
409 frame.render_widget(Paragraph::new(" Loading…").style(theme.hint()), areas[1]);
410 }
411 LaunchAppStep::PickApp => {
412 let apps = prompt.apps();
413 if apps.is_empty() {
414 frame.render_widget(Paragraph::new(" (no apps)").style(theme.hint()), areas[1]);
415 } else {
416 let items: Vec<ListItem> = apps
417 .iter()
418 .map(|a| ListItem::new(Span::styled(format!(" {} ({})", a.name, a.bundle_id), theme.base())))
419 .collect();
420 let mut list_state = ListState::default();
421 list_state.select(usize::try_from(prompt.app_index).ok());
422 let list = List::new(items).highlight_style(theme.bar());
423 frame.render_stateful_widget(list, areas[1], &mut list_state);
424 }
425 }
426 LaunchAppStep::PickLanguage => {
427 let visible = prompt.visible_languages();
428 if visible.is_empty() {
429 frame.render_widget(Paragraph::new(" (no matches)").style(theme.hint()), areas[1]);
430 } else {
431 let items: Vec<ListItem> =
432 visible.iter().map(|l| ListItem::new(Span::styled(format!(" {}", l.display_name), theme.base()))).collect();
433 let mut list_state = ListState::default();
434 list_state.select(usize::try_from(prompt.language_index).ok());
435 let list = List::new(items).highlight_style(theme.bar());
436 frame.render_stateful_widget(list, areas[1], &mut list_state);
437 }
438 }
439 }
440
441 match &prompt.error {
442 Some(error) => frame.render_widget(Paragraph::new(format!(" ⚠ {error}")).style(theme.error()), areas[2]),
443 None => frame.render_widget(Paragraph::new(launch_footer_hint(prompt)).style(theme.hint()), areas[2]),
444 }
445}
446
447fn launch_breadcrumb(step: LaunchAppStep) -> &'static str {
448 match step {
449 LaunchAppStep::LoadingApps => "Launch — loading apps…",
450 LaunchAppStep::PickApp => "Launch — pick an app",
451 LaunchAppStep::PickLanguage => "Launch — pick a language",
452 LaunchAppStep::Submitting => "Launch — launching…",
453 }
454}
455
456fn launch_footer_hint(prompt: &LaunchAppPrompt) -> &'static str {
457 match prompt.step {
458 LaunchAppStep::PickApp => "↑/↓ select · s toggle system apps · l language · Enter launch · Esc cancel",
459 LaunchAppStep::PickLanguage => "↑/↓ select · / filter · Enter launch · Esc back",
460 _ => "Esc cancel",
461 }
462}
463
464fn render_appearance(frame: &mut Frame, theme: &Theme, index: i64) {
467 let inner = render_popup(frame, frame.area(), 40, 35, "Appearance", theme);
468 let options = ["Light", "Dark"];
469 let mut lines = vec![Line::from("")];
470 for (i, label) in options.iter().enumerate() {
471 let (bullet, style) =
472 if i as i64 == index { ("●", theme.base().add_modifier(Modifier::BOLD)) } else { ("○", theme.hint()) };
473 lines.push(Line::styled(format!(" {bullet} {label}"), style));
474 }
475 lines.push(Line::from(""));
476 lines.push(Line::styled(" ↑/↓ select · Enter apply · Esc cancel", theme.hint()));
477 frame.render_widget(Paragraph::new(lines), inner);
478}
479
480enum ConfirmKind {
483 Erase,
484 Delete,
485}
486
487fn render_confirm(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid, index: i64, kind: ConfirmKind) {
488 let sim_name = state.simulators.iter().find(|s| s.id == id).map(|s| s.name.as_str()).unwrap_or("?");
489 let title = match kind {
490 ConfirmKind::Erase => format!("Erase \u{201c}{sim_name}\u{201d}?"),
491 ConfirmKind::Delete => format!("Delete \u{201c}{sim_name}\u{201d}?"),
492 };
493 let inner = render_popup(frame, frame.area(), 44, 35, &title, theme);
494 let options = ["Yes", "No"];
495 let mut lines = vec![Line::from("")];
496 for (i, label) in options.iter().enumerate() {
497 let (bullet, style) =
498 if i as i64 == index { ("●", theme.base().add_modifier(Modifier::BOLD)) } else { ("○", theme.hint()) };
499 lines.push(Line::styled(format!(" {bullet} {label}"), style));
500 }
501 lines.push(Line::from(""));
502 lines.push(Line::styled(" ↑/↓ select · Enter confirm · Esc cancel", theme.hint()));
503 frame.render_widget(Paragraph::new(lines), inner);
504}
505
506fn render_command_palette_overlay(frame: &mut Frame, state: &AppState, theme: &Theme, palette: &CommandPalette, area: Rect) {
509 let box_width = area.width.saturating_sub(4).clamp(24, 60);
510 let box_height = 5u16.min(area.height);
511 let x = area.x + (area.width.saturating_sub(box_width)) / 2;
512 let y = area.y + (area.height.saturating_sub(box_height)) / 2;
513 let popup = Rect { x, y, width: box_width, height: box_height };
514
515 frame.render_widget(Clear, popup);
516 let block =
517 Block::default().borders(Borders::ALL).border_style(theme.accent_style()).title(" Command palette ").style(theme.base());
518 let inner = block.inner(popup);
519 frame.render_widget(block, popup);
520
521 let matched = crate::state::PaletteCommand::all()
522 .into_iter()
523 .find(|c| c.is_applicable(state.selected_simulator(), state.is_recording()) && c.matches(&palette.query));
524 let ghost = matched
525 .map(|c| c.display_name())
526 .filter(|name| name.len() > palette.query.len())
527 .map(|name| name[palette.query.chars().count()..].to_string())
528 .unwrap_or_default();
529
530 let mut lines =
531 vec![Line::from(vec![Span::styled(format!("> {}", palette.query), theme.base()), Span::styled(ghost, theme.hint())])];
532 if let Some(command) = matched {
533 lines.push(Line::from(""));
534 lines.push(Line::styled(command.description(), theme.hint()));
535 }
536 frame.render_widget(Paragraph::new(lines), inner);
537}