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