Skip to main content

vtcode_commons/
modal_hints.rs

1//! Shared copy for inline list modals: footer key hints and recurring sentences.
2//!
3//! Modal headers stay descriptive (full sentences, no keybindings); all
4//! keybinding guidance lives in `footer_hint` / summary rows using these
5//! constants so approval, limit, and picker modals read identically.
6
7/// Navigate, select, or deny: tool approval, hook approval, session-limit, and
8/// policy-denied prompts.
9pub const APPROVAL_NAVIGATE_DENY: &str = "Use ↑↓ or Tab to navigate • Enter to select • Esc to deny";
10/// Navigate, select, or cancel: MCP approval variant.
11pub const APPROVAL_NAVIGATE_CANCEL: &str = "Use ↑↓ or Tab to navigate • Enter to select • Esc to cancel";
12/// Navigate, select, or stop: tool-loop-limit variant.
13pub const APPROVAL_NAVIGATE_STOP: &str = "Use ↑↓ or Tab to navigate • Enter to select • Esc to stop";
14
15/// Model picker: navigate a long provider/model list with live filtering.
16pub const MODEL_PICKER_NAVIGATE_FILTER: &str =
17    "Use ↑↓ or Tab to navigate • Enter to select • Type to filter • Esc to cancel";
18/// Model picker follow-ups (reasoning, service tier): short single-choice list.
19/// Esc cancels the whole picker; `← Back to model list` returns to step 1.
20///
21/// Only pass this to lists without `InlineListSelection::ConfigAction` items:
22/// `ConfigAction` lists are `FixedComfortable` and render the shared
23/// `CONFIG_LIST_NAVIGATION_HINT`, dropping any explicit footer.
24pub const MODEL_PICKER_FOLLOW_UP_HINT: &str =
25    "Use ↑↓ or Tab to navigate • Enter to select • ← Back to model list • Esc cancels the picker";
26
27/// Trailing sentence before approval options, e.g. `choose_handling_line("this tool")`.
28pub fn choose_handling_line(object: &str) -> String {
29    format!("Choose how to handle {object}:")
30}
31
32/// Truncate modal body text to `max_chars` characters, appending `…` only when
33/// truncation actually happened so shortened text never reads as complete.
34pub fn truncate_modal_text(text: &str, max_chars: usize) -> String {
35    if text.chars().count() <= max_chars {
36        return text.to_string();
37    }
38    let mut truncated = text.chars().take(max_chars.saturating_sub(1)).collect::<String>();
39    truncated.push('…');
40    truncated
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn approval_hints_share_navigation_prefix_but_differ_by_exit_verb() {
49        for hint in [APPROVAL_NAVIGATE_DENY, APPROVAL_NAVIGATE_CANCEL, APPROVAL_NAVIGATE_STOP] {
50            assert!(hint.starts_with("Use ↑↓ or Tab to navigate • Enter to select • Esc to "));
51        }
52        assert!(APPROVAL_NAVIGATE_DENY.ends_with("deny"));
53        assert!(APPROVAL_NAVIGATE_CANCEL.ends_with("cancel"));
54        assert!(APPROVAL_NAVIGATE_STOP.ends_with("stop"));
55    }
56
57    #[test]
58    fn model_picker_hints_share_navigation_prefix() {
59        for hint in [MODEL_PICKER_NAVIGATE_FILTER, MODEL_PICKER_FOLLOW_UP_HINT] {
60            assert!(hint.starts_with("Use ↑↓ or Tab to navigate • Enter to "));
61        }
62        assert!(MODEL_PICKER_NAVIGATE_FILTER.contains("Type to filter"));
63        assert!(MODEL_PICKER_FOLLOW_UP_HINT.contains("Back to model list"));
64        assert!(MODEL_PICKER_FOLLOW_UP_HINT.contains("Esc"));
65    }
66
67    #[test]
68    fn choose_handling_line_interpolates_object_as_sentence() {
69        assert_eq!(choose_handling_line("this tool"), "Choose how to handle this tool:");
70        assert_eq!(
71            choose_handling_line("workspace lifecycle hooks"),
72            "Choose how to handle workspace lifecycle hooks:"
73        );
74    }
75
76    #[test]
77    fn truncate_modal_text_marks_truncation_with_ellipsis_only_when_shortened() {
78        assert_eq!(truncate_modal_text("short", 10), "short");
79        assert_eq!(truncate_modal_text("exactly-ten", 11), "exactly-ten");
80        assert_eq!(truncate_modal_text("over the limit text", 10), "over the …");
81    }
82}