Skip to main content

slt/widgets/
commanding.rs

1/// Default tick budget (~1s at 60Hz) after which a partially-typed chord
2/// is abandoned. Matches the tick clock used by notifications/animation.
3///
4/// Override per call site with
5/// [`Context::key_chord_timeout`](crate::Context::key_chord_timeout).
6pub const DEFAULT_CHORD_TIMEOUT_TICKS: u64 = 60;
7
8/// Cross-frame partial-sequence buffer for
9/// [`Context::key_chord`](crate::Context::key_chord).
10///
11/// Persisted in `FrameState` across frames (same out/in policy as
12/// `keyed_states`). Holds at most one in-flight chord prefix; a mismatching
13/// key or a timeout clears it. You never construct this directly — SLT owns a
14/// single instance per [`Context`](crate::Context) and threads it through the
15/// frame loop for you.
16///
17/// # Example
18///
19/// ```no_run
20/// slt::run(|ui: &mut slt::Context| {
21///     // The buffer is managed internally; just call `key_chord`.
22///     if ui.key_chord("gg") {
23///         // jump to top
24///     }
25/// });
26/// ```
27#[derive(Debug, Default, Clone)]
28pub struct ChordState {
29    /// Characters accumulated so far toward some registered chord.
30    pub(crate) pending: String,
31    /// Tick of the most recent accepted key; used for timeout expiry.
32    pub(crate) last_tick: u64,
33}
34
35/// State for a command palette overlay.
36///
37/// Renders as a modal with a search input and filtered command list.
38#[derive(Debug, Clone)]
39pub struct CommandPaletteState {
40    /// Available commands.
41    pub commands: Vec<PaletteCommand>,
42    /// Current search query.
43    pub input: String,
44    /// Cursor index within `input`.
45    pub cursor: usize,
46    /// Whether the palette modal is open.
47    pub open: bool,
48    /// The last selected command index, set when the user confirms a selection.
49    /// Check this after `response.changed` is true.
50    pub last_selected: Option<usize>,
51    selected: usize,
52    /// Cached filtered indices for the last `input` value. Avoids running
53    /// `fuzzy_score` twice per frame (clamp + render).
54    filter_cache: Option<(String, Vec<usize>)>,
55}
56
57impl CommandPaletteState {
58    /// Create command palette state from a command list.
59    pub fn new(commands: Vec<PaletteCommand>) -> Self {
60        Self {
61            commands,
62            input: String::new(),
63            cursor: 0,
64            open: false,
65            last_selected: None,
66            selected: 0,
67            filter_cache: None,
68        }
69    }
70
71    /// Toggle open/closed state and reset input when opening.
72    pub fn toggle(&mut self) {
73        self.open = !self.open;
74        if self.open {
75            self.input.clear();
76            self.cursor = 0;
77            self.selected = 0;
78            self.filter_cache = None;
79        }
80    }
81
82    pub(crate) fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
83        let pattern = pattern.trim();
84        if pattern.is_empty() {
85            return Some(0);
86        }
87
88        let text_chars: Vec<char> = text.chars().collect();
89        let mut score = 0;
90        let mut search_start = 0usize;
91        let mut prev_match: Option<usize> = None;
92
93        for p in pattern.chars() {
94            let mut found = None;
95            for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
96                if ch.eq_ignore_ascii_case(&p) {
97                    found = Some(idx);
98                    break;
99                }
100            }
101
102            let idx = found?;
103            if prev_match.is_some_and(|prev| idx == prev + 1) {
104                score += 3;
105            } else {
106                score += 1;
107            }
108
109            if idx == 0 {
110                score += 2;
111            } else {
112                let prev = text_chars[idx - 1];
113                let curr = text_chars[idx];
114                if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
115                    score += 2;
116                }
117            }
118
119            prev_match = Some(idx);
120            search_start = idx + 1;
121        }
122
123        Some(score)
124    }
125
126    /// Cached variant of [`Self::filtered_indices`].
127    ///
128    /// Reuses the previous result when `self.input` has not changed since the
129    /// last call. `command_palette()` invokes this twice per frame (before key
130    /// handling, to clamp the selection index, and again for render); on idle
131    /// frames the second call is served from cache instead of re-running
132    /// `fuzzy_score` over the full command list.
133    pub(crate) fn filtered_indices_cached(&mut self) -> &[usize] {
134        let needs_recompute = match &self.filter_cache {
135            Some((cached_input, _)) => *cached_input != self.input,
136            None => true,
137        };
138        if needs_recompute {
139            let indices = self.filtered_indices();
140            self.filter_cache = Some((self.input.clone(), indices));
141        }
142        &self
143            .filter_cache
144            .as_ref()
145            .expect("filter_cache populated above")
146            .1
147    }
148
149    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
150        let query = self.input.trim();
151        if query.is_empty() {
152            return (0..self.commands.len()).collect();
153        }
154
155        let mut scored: Vec<(usize, i32)> = self
156            .commands
157            .iter()
158            .enumerate()
159            .filter_map(|(i, cmd)| {
160                let mut haystack =
161                    String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
162                haystack.push_str(&cmd.label);
163                haystack.push(' ');
164                haystack.push_str(&cmd.description);
165                Self::fuzzy_score(query, &haystack).map(|score| (i, score))
166            })
167            .collect();
168
169        if scored.is_empty() {
170            let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
171            return self
172                .commands
173                .iter()
174                .enumerate()
175                .filter(|(_, cmd)| {
176                    let label = cmd.label.to_lowercase();
177                    let desc = cmd.description.to_lowercase();
178                    tokens.iter().all(|token| {
179                        label.contains(token.as_str()) || desc.contains(token.as_str())
180                    })
181                })
182                .map(|(i, _)| i)
183                .collect();
184        }
185
186        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
187        scored.into_iter().map(|(idx, _)| idx).collect()
188    }
189
190    pub(crate) fn selected(&self) -> usize {
191        self.selected
192    }
193
194    pub(crate) fn set_selected(&mut self, s: usize) {
195        self.selected = s;
196    }
197}
198
199/// State for a streaming text display.
200///
201/// Accumulates text chunks as they arrive from an LLM stream.
202/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
203#[derive(Debug, Clone)]
204pub struct StreamingTextState {
205    /// The accumulated text content.
206    pub content: String,
207    /// Whether the stream is still receiving data.
208    pub streaming: bool,
209    /// Cursor blink state (for the typing indicator).
210    pub(crate) cursor_visible: bool,
211    pub(crate) cursor_tick: u64,
212    /// Monotonic content version, bumped on every content mutation
213    /// (`push` / `start` / `clear`). See [`StreamingTextState::version`].
214    pub(crate) version: u64,
215}
216
217impl StreamingTextState {
218    /// Create a new empty streaming text state.
219    pub fn new() -> Self {
220        Self {
221            content: String::new(),
222            streaming: false,
223            cursor_visible: true,
224            cursor_tick: 0,
225            version: 0,
226        }
227    }
228
229    /// Append a chunk of text (e.g., from an LLM stream delta).
230    pub fn push(&mut self, chunk: &str) {
231        self.content.push_str(chunk);
232        self.version = self.version.wrapping_add(1);
233    }
234
235    /// Mark the stream as complete (hides the typing cursor).
236    pub fn finish(&mut self) {
237        self.streaming = false;
238    }
239
240    /// Start a new streaming session, clearing previous content.
241    pub fn start(&mut self) {
242        self.content.clear();
243        self.streaming = true;
244        self.cursor_visible = true;
245        self.cursor_tick = 0;
246        self.version = self.version.wrapping_add(1);
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.version = self.version.wrapping_add(1);
256    }
257
258    /// Monotonic version counter, bumped on every content mutation
259    /// (`push` / `start` / `clear`).
260    ///
261    /// The stream itself changes every token, so this value is **not** a
262    /// useful cache key for the streaming region. Its purpose is the
263    /// inverse: it lets you detect when the stream *did* change so you can
264    /// decide whether the *surrounding static chrome* is stable. Combine a
265    /// hash of your non-streaming inputs into a key for
266    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) and wrap
267    /// the chrome — not the stream — in it.
268    ///
269    /// Since 0.21.0.
270    ///
271    /// # Example
272    /// ```no_run
273    /// # slt::run(|ui: &mut slt::Context| {
274    /// let mut stream = slt::StreamingTextState::new();
275    /// stream.push("hello");
276    /// assert_eq!(stream.version(), 1);
277    /// stream.push(" world");
278    /// assert_eq!(stream.version(), 2);
279    /// # });
280    /// ```
281    pub fn version(&self) -> u64 {
282        self.version
283    }
284}
285
286impl Default for StreamingTextState {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292/// State for a streaming markdown display.
293///
294/// Accumulates markdown chunks as they arrive from an LLM stream.
295/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
296#[derive(Debug, Clone)]
297pub struct StreamingMarkdownState {
298    /// The accumulated markdown content.
299    pub content: String,
300    /// Whether the stream is still receiving data.
301    pub streaming: bool,
302    /// Cursor blink state (for the typing indicator).
303    pub cursor_visible: bool,
304    /// Cursor animation tick counter.
305    pub cursor_tick: u64,
306    /// Whether the parser is currently inside a fenced code block.
307    pub in_code_block: bool,
308    /// Language label of the active fenced code block.
309    pub code_block_lang: String,
310    /// Monotonic content version, bumped on every content mutation
311    /// (`push` / `start` / `clear`). See [`StreamingMarkdownState::version`].
312    pub(crate) version: u64,
313}
314
315impl StreamingMarkdownState {
316    /// Create a new empty streaming markdown state.
317    pub fn new() -> Self {
318        Self {
319            content: String::new(),
320            streaming: false,
321            cursor_visible: true,
322            cursor_tick: 0,
323            in_code_block: false,
324            code_block_lang: String::new(),
325            version: 0,
326        }
327    }
328
329    /// Append a markdown chunk (e.g., from an LLM stream delta).
330    pub fn push(&mut self, chunk: &str) {
331        self.content.push_str(chunk);
332        self.version = self.version.wrapping_add(1);
333    }
334
335    /// Start a new streaming session, clearing previous content.
336    pub fn start(&mut self) {
337        self.content.clear();
338        self.streaming = true;
339        self.cursor_visible = true;
340        self.cursor_tick = 0;
341        self.in_code_block = false;
342        self.code_block_lang.clear();
343        self.version = self.version.wrapping_add(1);
344    }
345
346    /// Mark the stream as complete (hides the typing cursor).
347    pub fn finish(&mut self) {
348        self.streaming = false;
349    }
350
351    /// Clear all content and reset state.
352    pub fn clear(&mut self) {
353        self.content.clear();
354        self.streaming = false;
355        self.cursor_visible = true;
356        self.cursor_tick = 0;
357        self.in_code_block = false;
358        self.code_block_lang.clear();
359        self.version = self.version.wrapping_add(1);
360    }
361
362    /// Monotonic version counter, bumped on every content mutation
363    /// (`push` / `start` / `clear`).
364    ///
365    /// As with [`StreamingTextState::version`], use this to detect stream
366    /// deltas and key the *surrounding static chrome* into
367    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) — not to
368    /// cache the stream region itself.
369    ///
370    /// Since 0.21.0.
371    ///
372    /// # Example
373    /// ```no_run
374    /// # slt::run(|ui: &mut slt::Context| {
375    /// let mut md = slt::StreamingMarkdownState::new();
376    /// md.push("# Title");
377    /// assert_eq!(md.version(), 1);
378    /// # });
379    /// ```
380    pub fn version(&self) -> u64 {
381        self.version
382    }
383}
384
385impl Default for StreamingMarkdownState {
386    fn default() -> Self {
387        Self::new()
388    }
389}
390
391/// Navigation stack state for multi-screen apps.
392///
393/// Tracks screen names in a push/pop stack while preserving the root screen.
394/// Each screen gets isolated focus and hook state when used with
395/// [`crate::Context::screen`].
396///
397/// # Example
398///
399/// ```no_run
400/// let mut screens = slt::ScreenState::new("main");
401///
402/// slt::run(|ui| {
403///     let current = screens.current().to_string();
404///     if current == "main" {
405///         if ui.button("Settings").clicked { screens.push("settings"); }
406///     }
407///     if current == "settings" {
408///         if ui.button("Back").clicked { screens.pop(); }
409///     }
410/// });
411/// ```
412#[derive(Debug, Clone)]
413pub struct ScreenState {
414    stack: Vec<String>,
415    focus_state: std::collections::HashMap<String, (usize, usize)>,
416}
417
418impl ScreenState {
419    /// Create a screen stack with an initial root screen.
420    pub fn new(initial: impl Into<String>) -> Self {
421        Self {
422            stack: vec![initial.into()],
423            focus_state: std::collections::HashMap::new(),
424        }
425    }
426
427    /// Return the current screen name (top of the stack).
428    pub fn current(&self) -> &str {
429        self.stack
430            .last()
431            .expect("ScreenState always contains at least one screen")
432            .as_str()
433    }
434
435    /// Push a new screen onto the stack.
436    pub fn push(&mut self, name: impl Into<String>) {
437        self.stack.push(name.into());
438    }
439
440    /// Pop the current screen, preserving the root screen.
441    pub fn pop(&mut self) {
442        if self.can_pop() {
443            self.stack.pop();
444        }
445    }
446
447    /// Return current stack depth.
448    pub fn depth(&self) -> usize {
449        self.stack.len()
450    }
451
452    /// Return `true` if popping is allowed.
453    pub fn can_pop(&self) -> bool {
454        self.stack.len() > 1
455    }
456
457    /// Reset to only the root screen.
458    pub fn reset(&mut self) {
459        self.stack.truncate(1);
460    }
461
462    pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
463        self.focus_state
464            .insert(name.to_string(), (focus_index, focus_count));
465    }
466
467    pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
468        self.focus_state.get(name).copied().unwrap_or((0, 0))
469    }
470}
471
472/// Named mode system with independent screen stacks.
473///
474/// Each mode contains its own [`ScreenState`]. Switching modes preserves
475/// the previous mode's screen stack, focus, and hook state.
476///
477/// # Example
478///
479/// ```no_run
480/// let mut modes = slt::ModeState::new("app", "home");
481/// modes.add_mode("settings", "general");
482///
483/// slt::run(|ui| {
484///     if ui.key('1') { modes.switch_mode("app"); }
485///     if ui.key('2') { modes.switch_mode("settings"); }
486///     let mode = modes.active_mode().to_string();
487///     ui.text(format!("Mode: {}", mode));
488/// });
489/// ```
490#[derive(Debug, Clone)]
491pub struct ModeState {
492    modes: std::collections::HashMap<String, ScreenState>,
493    active: String,
494}
495
496impl ModeState {
497    /// Create a mode system with an initial mode and screen.
498    pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
499        let mode = mode.into();
500        let mut modes = std::collections::HashMap::new();
501        modes.insert(mode.clone(), ScreenState::new(screen));
502        Self {
503            modes,
504            active: mode,
505        }
506    }
507
508    /// Add a new mode with an initial screen.
509    pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
510        let mode = mode.into();
511        self.modes
512            .entry(mode)
513            .or_insert_with(|| ScreenState::new(screen));
514    }
515
516    /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
517    ///
518    /// Panics if the mode does not exist. For a non-panicking variant that
519    /// reports success, use [`Self::try_switch_mode`].
520    pub fn switch_mode(&mut self, mode: impl Into<String>) {
521        let mode = mode.into();
522        assert!(
523            self.modes.contains_key(&mode),
524            "mode '{}' not found",
525            mode
526        );
527        self.active = mode;
528    }
529
530    /// Switch modes, returning `true` when the mode exists and the switch
531    /// happened, or `false` when the mode has not been registered via
532    /// [`Self::add_mode`].
533    ///
534    /// Prefer this over [`Self::switch_mode`] when the mode name comes from
535    /// user input, key bindings, or anywhere the value could be unexpected
536    /// at runtime — an unknown mode should not crash the host application.
537    pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
538        let mode = mode.into();
539        if !self.modes.contains_key(&mode) {
540            return false;
541        }
542        self.active = mode;
543        true
544    }
545
546    /// Return the active mode name.
547    pub fn active_mode(&self) -> &str {
548        &self.active
549    }
550
551    /// Get a reference to the active mode's screen state.
552    pub fn screens(&self) -> &ScreenState {
553        self.modes
554            .get(&self.active)
555            .expect("active mode must exist")
556    }
557
558    /// Get a mutable reference to the active mode's screen state.
559    pub fn screens_mut(&mut self) -> &mut ScreenState {
560        self.modes
561            .get_mut(&self.active)
562            .expect("active mode must exist")
563    }
564}
565
566#[cfg(test)]
567mod mode_state_tests {
568    use super::ModeState;
569
570    #[test]
571    fn try_switch_mode_returns_false_for_unknown_mode() {
572        let mut modes = ModeState::new("app", "home");
573        modes.add_mode("settings", "general");
574        assert!(modes.try_switch_mode("settings"));
575        assert_eq!(modes.active_mode(), "settings");
576        assert!(!modes.try_switch_mode("nonexistent"));
577        // Active mode must not change when the switch is rejected.
578        assert_eq!(modes.active_mode(), "settings");
579    }
580}
581
582#[cfg(test)]
583mod streaming_version_tests {
584    //! Issue #273 — the monotonic `version()` counter on streaming states.
585    use super::{StreamingMarkdownState, StreamingTextState};
586
587    #[test]
588    fn text_version_starts_at_zero_and_bumps_on_mutation() {
589        let mut s = StreamingTextState::new();
590        assert_eq!(s.version(), 0, "fresh state has version 0");
591        s.push("a");
592        assert_eq!(s.version(), 1);
593        s.push("b");
594        assert_eq!(s.version(), 2);
595        s.start();
596        assert_eq!(s.version(), 3, "start() is a mutation");
597        s.clear();
598        assert_eq!(s.version(), 4, "clear() is a mutation");
599    }
600
601    #[test]
602    fn text_finish_does_not_bump_version() {
603        let mut s = StreamingTextState::new();
604        s.push("x");
605        let v = s.version();
606        s.finish();
607        assert_eq!(s.version(), v, "finish() only toggles the streaming flag");
608    }
609
610    #[test]
611    fn markdown_version_bumps_on_mutation() {
612        let mut s = StreamingMarkdownState::new();
613        assert_eq!(s.version(), 0);
614        s.push("# h");
615        assert_eq!(s.version(), 1);
616        s.start();
617        assert_eq!(s.version(), 2);
618        s.clear();
619        assert_eq!(s.version(), 3);
620        let v = s.version();
621        s.finish();
622        assert_eq!(s.version(), v, "finish() does not bump");
623    }
624}
625
626/// Approval state for a tool call.
627#[non_exhaustive]
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub enum ApprovalAction {
630    /// No action taken yet.
631    Pending,
632    /// User approved the tool call.
633    Approved,
634    /// User rejected the tool call.
635    Rejected,
636}
637
638/// State for a tool approval widget.
639///
640/// Displays a tool call with approve/reject buttons for human-in-the-loop
641/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
642/// each frame.
643#[derive(Debug, Clone)]
644pub struct ToolApprovalState {
645    /// The name of the tool being invoked.
646    pub tool_name: String,
647    /// A human-readable description of what the tool will do.
648    pub description: String,
649    /// The current approval status.
650    pub action: ApprovalAction,
651}
652
653impl ToolApprovalState {
654    /// Create a new tool approval prompt.
655    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
656        Self {
657            tool_name: tool_name.into(),
658            description: description.into(),
659            action: ApprovalAction::Pending,
660        }
661    }
662
663    /// Reset to pending state.
664    pub fn reset(&mut self) {
665        self.action = ApprovalAction::Pending;
666    }
667}
668
669/// Item in a context bar showing active context sources.
670#[derive(Debug, Clone)]
671pub struct ContextItem {
672    /// Display label for this context source.
673    pub label: String,
674    /// Token count or size indicator.
675    pub tokens: usize,
676}
677
678impl ContextItem {
679    /// Create a new context item with a label and token count.
680    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
681        Self {
682            label: label.into(),
683            tokens,
684        }
685    }
686}