Skip to main content

slt/widgets/
commanding.rs

1/// State for a command palette overlay.
2///
3/// Renders as a modal with a search input and filtered command list.
4#[derive(Debug, Clone)]
5pub struct CommandPaletteState {
6    /// Available commands.
7    pub commands: Vec<PaletteCommand>,
8    /// Current search query.
9    pub input: String,
10    /// Cursor index within `input`.
11    pub cursor: usize,
12    /// Whether the palette modal is open.
13    pub open: bool,
14    /// The last selected command index, set when the user confirms a selection.
15    /// Check this after `response.changed` is true.
16    pub last_selected: Option<usize>,
17    selected: usize,
18    /// Cached filtered indices for the last `input` value. Avoids running
19    /// `fuzzy_score` twice per frame (clamp + render).
20    filter_cache: Option<(String, Vec<usize>)>,
21}
22
23impl CommandPaletteState {
24    /// Create command palette state from a command list.
25    pub fn new(commands: Vec<PaletteCommand>) -> Self {
26        Self {
27            commands,
28            input: String::new(),
29            cursor: 0,
30            open: false,
31            last_selected: None,
32            selected: 0,
33            filter_cache: None,
34        }
35    }
36
37    /// Toggle open/closed state and reset input when opening.
38    pub fn toggle(&mut self) {
39        self.open = !self.open;
40        if self.open {
41            self.input.clear();
42            self.cursor = 0;
43            self.selected = 0;
44            self.filter_cache = None;
45        }
46    }
47
48    fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
49        let pattern = pattern.trim();
50        if pattern.is_empty() {
51            return Some(0);
52        }
53
54        let text_chars: Vec<char> = text.chars().collect();
55        let mut score = 0;
56        let mut search_start = 0usize;
57        let mut prev_match: Option<usize> = None;
58
59        for p in pattern.chars() {
60            let mut found = None;
61            for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
62                if ch.eq_ignore_ascii_case(&p) {
63                    found = Some(idx);
64                    break;
65                }
66            }
67
68            let idx = found?;
69            if prev_match.is_some_and(|prev| idx == prev + 1) {
70                score += 3;
71            } else {
72                score += 1;
73            }
74
75            if idx == 0 {
76                score += 2;
77            } else {
78                let prev = text_chars[idx - 1];
79                let curr = text_chars[idx];
80                if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
81                    score += 2;
82                }
83            }
84
85            prev_match = Some(idx);
86            search_start = idx + 1;
87        }
88
89        Some(score)
90    }
91
92    /// Cached variant of [`Self::filtered_indices`].
93    ///
94    /// Reuses the previous result when `self.input` has not changed since the
95    /// last call. `command_palette()` invokes this twice per frame (before key
96    /// handling, to clamp the selection index, and again for render); on idle
97    /// frames the second call is served from cache instead of re-running
98    /// `fuzzy_score` over the full command list.
99    pub(crate) fn filtered_indices_cached(&mut self) -> &[usize] {
100        let needs_recompute = match &self.filter_cache {
101            Some((cached_input, _)) => *cached_input != self.input,
102            None => true,
103        };
104        if needs_recompute {
105            let indices = self.filtered_indices();
106            self.filter_cache = Some((self.input.clone(), indices));
107        }
108        &self
109            .filter_cache
110            .as_ref()
111            .expect("filter_cache populated above")
112            .1
113    }
114
115    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
116        let query = self.input.trim();
117        if query.is_empty() {
118            return (0..self.commands.len()).collect();
119        }
120
121        let mut scored: Vec<(usize, i32)> = self
122            .commands
123            .iter()
124            .enumerate()
125            .filter_map(|(i, cmd)| {
126                let mut haystack =
127                    String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
128                haystack.push_str(&cmd.label);
129                haystack.push(' ');
130                haystack.push_str(&cmd.description);
131                Self::fuzzy_score(query, &haystack).map(|score| (i, score))
132            })
133            .collect();
134
135        if scored.is_empty() {
136            let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
137            return self
138                .commands
139                .iter()
140                .enumerate()
141                .filter(|(_, cmd)| {
142                    let label = cmd.label.to_lowercase();
143                    let desc = cmd.description.to_lowercase();
144                    tokens.iter().all(|token| {
145                        label.contains(token.as_str()) || desc.contains(token.as_str())
146                    })
147                })
148                .map(|(i, _)| i)
149                .collect();
150        }
151
152        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
153        scored.into_iter().map(|(idx, _)| idx).collect()
154    }
155
156    pub(crate) fn selected(&self) -> usize {
157        self.selected
158    }
159
160    pub(crate) fn set_selected(&mut self, s: usize) {
161        self.selected = s;
162    }
163}
164
165/// State for a streaming text display.
166///
167/// Accumulates text chunks as they arrive from an LLM stream.
168/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
169#[derive(Debug, Clone)]
170pub struct StreamingTextState {
171    /// The accumulated text content.
172    pub content: String,
173    /// Whether the stream is still receiving data.
174    pub streaming: bool,
175    /// Cursor blink state (for the typing indicator).
176    pub(crate) cursor_visible: bool,
177    pub(crate) cursor_tick: u64,
178}
179
180impl StreamingTextState {
181    /// Create a new empty streaming text state.
182    pub fn new() -> Self {
183        Self {
184            content: String::new(),
185            streaming: false,
186            cursor_visible: true,
187            cursor_tick: 0,
188        }
189    }
190
191    /// Append a chunk of text (e.g., from an LLM stream delta).
192    pub fn push(&mut self, chunk: &str) {
193        self.content.push_str(chunk);
194    }
195
196    /// Mark the stream as complete (hides the typing cursor).
197    pub fn finish(&mut self) {
198        self.streaming = false;
199    }
200
201    /// Start a new streaming session, clearing previous content.
202    pub fn start(&mut self) {
203        self.content.clear();
204        self.streaming = true;
205        self.cursor_visible = true;
206        self.cursor_tick = 0;
207    }
208
209    /// Clear all content and reset state.
210    pub fn clear(&mut self) {
211        self.content.clear();
212        self.streaming = false;
213        self.cursor_visible = true;
214        self.cursor_tick = 0;
215    }
216}
217
218impl Default for StreamingTextState {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224/// State for a streaming markdown display.
225///
226/// Accumulates markdown chunks as they arrive from an LLM stream.
227/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
228#[derive(Debug, Clone)]
229pub struct StreamingMarkdownState {
230    /// The accumulated markdown content.
231    pub content: String,
232    /// Whether the stream is still receiving data.
233    pub streaming: bool,
234    /// Cursor blink state (for the typing indicator).
235    pub cursor_visible: bool,
236    /// Cursor animation tick counter.
237    pub cursor_tick: u64,
238    /// Whether the parser is currently inside a fenced code block.
239    pub in_code_block: bool,
240    /// Language label of the active fenced code block.
241    pub code_block_lang: String,
242}
243
244impl StreamingMarkdownState {
245    /// Create a new empty streaming markdown state.
246    pub fn new() -> Self {
247        Self {
248            content: String::new(),
249            streaming: false,
250            cursor_visible: true,
251            cursor_tick: 0,
252            in_code_block: false,
253            code_block_lang: String::new(),
254        }
255    }
256
257    /// Append a markdown chunk (e.g., from an LLM stream delta).
258    pub fn push(&mut self, chunk: &str) {
259        self.content.push_str(chunk);
260    }
261
262    /// Start a new streaming session, clearing previous content.
263    pub fn start(&mut self) {
264        self.content.clear();
265        self.streaming = true;
266        self.cursor_visible = true;
267        self.cursor_tick = 0;
268        self.in_code_block = false;
269        self.code_block_lang.clear();
270    }
271
272    /// Mark the stream as complete (hides the typing cursor).
273    pub fn finish(&mut self) {
274        self.streaming = false;
275    }
276
277    /// Clear all content and reset state.
278    pub fn clear(&mut self) {
279        self.content.clear();
280        self.streaming = false;
281        self.cursor_visible = true;
282        self.cursor_tick = 0;
283        self.in_code_block = false;
284        self.code_block_lang.clear();
285    }
286}
287
288impl Default for StreamingMarkdownState {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294/// Navigation stack state for multi-screen apps.
295///
296/// Tracks screen names in a push/pop stack while preserving the root screen.
297/// Each screen gets isolated focus and hook state when used with
298/// [`crate::Context::screen`].
299///
300/// # Example
301///
302/// ```no_run
303/// let mut screens = slt::ScreenState::new("main");
304///
305/// slt::run(|ui| {
306///     let current = screens.current().to_string();
307///     if current == "main" {
308///         if ui.button("Settings").clicked { screens.push("settings"); }
309///     }
310///     if current == "settings" {
311///         if ui.button("Back").clicked { screens.pop(); }
312///     }
313/// });
314/// ```
315#[derive(Debug, Clone)]
316pub struct ScreenState {
317    stack: Vec<String>,
318    focus_state: std::collections::HashMap<String, (usize, usize)>,
319}
320
321impl ScreenState {
322    /// Create a screen stack with an initial root screen.
323    pub fn new(initial: impl Into<String>) -> Self {
324        Self {
325            stack: vec![initial.into()],
326            focus_state: std::collections::HashMap::new(),
327        }
328    }
329
330    /// Return the current screen name (top of the stack).
331    pub fn current(&self) -> &str {
332        self.stack
333            .last()
334            .expect("ScreenState always contains at least one screen")
335            .as_str()
336    }
337
338    /// Push a new screen onto the stack.
339    pub fn push(&mut self, name: impl Into<String>) {
340        self.stack.push(name.into());
341    }
342
343    /// Pop the current screen, preserving the root screen.
344    pub fn pop(&mut self) {
345        if self.can_pop() {
346            self.stack.pop();
347        }
348    }
349
350    /// Return current stack depth.
351    pub fn depth(&self) -> usize {
352        self.stack.len()
353    }
354
355    /// Return `true` if popping is allowed.
356    pub fn can_pop(&self) -> bool {
357        self.stack.len() > 1
358    }
359
360    /// Reset to only the root screen.
361    pub fn reset(&mut self) {
362        self.stack.truncate(1);
363    }
364
365    pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
366        self.focus_state
367            .insert(name.to_string(), (focus_index, focus_count));
368    }
369
370    pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
371        self.focus_state.get(name).copied().unwrap_or((0, 0))
372    }
373}
374
375/// Named mode system with independent screen stacks.
376///
377/// Each mode contains its own [`ScreenState`]. Switching modes preserves
378/// the previous mode's screen stack, focus, and hook state.
379///
380/// # Example
381///
382/// ```no_run
383/// let mut modes = slt::ModeState::new("app", "home");
384/// modes.add_mode("settings", "general");
385///
386/// slt::run(|ui| {
387///     if ui.key('1') { modes.switch_mode("app"); }
388///     if ui.key('2') { modes.switch_mode("settings"); }
389///     let mode = modes.active_mode().to_string();
390///     ui.text(format!("Mode: {}", mode));
391/// });
392/// ```
393#[derive(Debug, Clone)]
394pub struct ModeState {
395    modes: std::collections::HashMap<String, ScreenState>,
396    active: String,
397}
398
399impl ModeState {
400    /// Create a mode system with an initial mode and screen.
401    pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
402        let mode = mode.into();
403        let mut modes = std::collections::HashMap::new();
404        modes.insert(mode.clone(), ScreenState::new(screen));
405        Self {
406            modes,
407            active: mode,
408        }
409    }
410
411    /// Add a new mode with an initial screen.
412    pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
413        let mode = mode.into();
414        self.modes
415            .entry(mode)
416            .or_insert_with(|| ScreenState::new(screen));
417    }
418
419    /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
420    ///
421    /// Panics if the mode does not exist. For a non-panicking variant that
422    /// reports success, use [`Self::try_switch_mode`].
423    pub fn switch_mode(&mut self, mode: impl Into<String>) {
424        let mode = mode.into();
425        assert!(
426            self.modes.contains_key(&mode),
427            "mode '{}' not found",
428            mode
429        );
430        self.active = mode;
431    }
432
433    /// Switch modes, returning `true` when the mode exists and the switch
434    /// happened, or `false` when the mode has not been registered via
435    /// [`Self::add_mode`].
436    ///
437    /// Prefer this over [`Self::switch_mode`] when the mode name comes from
438    /// user input, key bindings, or anywhere the value could be unexpected
439    /// at runtime — an unknown mode should not crash the host application.
440    pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
441        let mode = mode.into();
442        if !self.modes.contains_key(&mode) {
443            return false;
444        }
445        self.active = mode;
446        true
447    }
448
449    /// Return the active mode name.
450    pub fn active_mode(&self) -> &str {
451        &self.active
452    }
453
454    /// Get a reference to the active mode's screen state.
455    pub fn screens(&self) -> &ScreenState {
456        self.modes
457            .get(&self.active)
458            .expect("active mode must exist")
459    }
460
461    /// Get a mutable reference to the active mode's screen state.
462    pub fn screens_mut(&mut self) -> &mut ScreenState {
463        self.modes
464            .get_mut(&self.active)
465            .expect("active mode must exist")
466    }
467}
468
469#[cfg(test)]
470mod mode_state_tests {
471    use super::ModeState;
472
473    #[test]
474    fn try_switch_mode_returns_false_for_unknown_mode() {
475        let mut modes = ModeState::new("app", "home");
476        modes.add_mode("settings", "general");
477        assert!(modes.try_switch_mode("settings"));
478        assert_eq!(modes.active_mode(), "settings");
479        assert!(!modes.try_switch_mode("nonexistent"));
480        // Active mode must not change when the switch is rejected.
481        assert_eq!(modes.active_mode(), "settings");
482    }
483}
484
485/// Approval state for a tool call.
486#[non_exhaustive]
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum ApprovalAction {
489    /// No action taken yet.
490    Pending,
491    /// User approved the tool call.
492    Approved,
493    /// User rejected the tool call.
494    Rejected,
495}
496
497/// State for a tool approval widget.
498///
499/// Displays a tool call with approve/reject buttons for human-in-the-loop
500/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
501/// each frame.
502#[derive(Debug, Clone)]
503pub struct ToolApprovalState {
504    /// The name of the tool being invoked.
505    pub tool_name: String,
506    /// A human-readable description of what the tool will do.
507    pub description: String,
508    /// The current approval status.
509    pub action: ApprovalAction,
510}
511
512impl ToolApprovalState {
513    /// Create a new tool approval prompt.
514    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
515        Self {
516            tool_name: tool_name.into(),
517            description: description.into(),
518            action: ApprovalAction::Pending,
519        }
520    }
521
522    /// Reset to pending state.
523    pub fn reset(&mut self) {
524        self.action = ApprovalAction::Pending;
525    }
526}
527
528/// Item in a context bar showing active context sources.
529#[derive(Debug, Clone)]
530pub struct ContextItem {
531    /// Display label for this context source.
532    pub label: String,
533    /// Token count or size indicator.
534    pub tokens: usize,
535}
536
537impl ContextItem {
538    /// Create a new context item with a label and token count.
539    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
540        Self {
541            label: label.into(),
542            tokens,
543        }
544    }
545}