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    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    /// Return all available commands.
72    pub fn commands(&self) -> &[PaletteCommand] {
73        &self.commands
74    }
75
76    /// Replace all commands and synchronize filter and selection state.
77    pub fn set_commands(&mut self, commands: Vec<PaletteCommand>) {
78        self.commands = commands;
79        self.synchronize_commands();
80    }
81
82    /// Append a command and invalidate the filter cache.
83    pub fn push_command(&mut self, command: PaletteCommand) {
84        self.commands.push(command);
85        self.synchronize_commands();
86    }
87
88    /// Remove and return a command by data index.
89    pub fn remove_command(&mut self, index: usize) -> Option<PaletteCommand> {
90        if index >= self.commands.len() {
91            return None;
92        }
93        let command = self.commands.remove(index);
94        self.synchronize_commands();
95        Some(command)
96    }
97
98    /// Remove all commands and reset cache-coupled selection state.
99    pub fn clear_commands(&mut self) {
100        self.commands.clear();
101        self.synchronize_commands();
102    }
103
104    fn synchronize_commands(&mut self) {
105        self.filter_cache = None;
106        self.selected = 0;
107        self.last_selected = self
108            .last_selected
109            .filter(|&index| index < self.commands.len());
110    }
111
112    /// Toggle open/closed state and reset input when opening.
113    pub fn toggle(&mut self) {
114        self.open = !self.open;
115        if self.open {
116            self.input.clear();
117            self.cursor = 0;
118            self.selected = 0;
119            self.filter_cache = None;
120        }
121    }
122
123    pub(crate) fn fuzzy_score(pattern: &str, text: &str) -> Option<i32> {
124        let pattern = pattern.trim();
125        if pattern.is_empty() {
126            return Some(0);
127        }
128
129        let text_chars: Vec<char> = text.chars().collect();
130        let mut score = 0;
131        let mut search_start = 0usize;
132        let mut prev_match: Option<usize> = None;
133
134        for p in pattern.chars() {
135            let mut found = None;
136            for (idx, ch) in text_chars.iter().enumerate().skip(search_start) {
137                if ch.eq_ignore_ascii_case(&p) {
138                    found = Some(idx);
139                    break;
140                }
141            }
142
143            let idx = found?;
144            if prev_match.is_some_and(|prev| idx == prev + 1) {
145                score += 3;
146            } else {
147                score += 1;
148            }
149
150            if idx == 0 {
151                score += 2;
152            } else {
153                let prev = text_chars[idx - 1];
154                let curr = text_chars[idx];
155                if matches!(prev, ' ' | '_' | '-') || prev.is_uppercase() || curr.is_uppercase() {
156                    score += 2;
157                }
158            }
159
160            prev_match = Some(idx);
161            search_start = idx + 1;
162        }
163
164        Some(score)
165    }
166
167    /// Cached variant of [`Self::filtered_indices`].
168    ///
169    /// Reuses the previous result when `self.input` has not changed since the
170    /// last call. `command_palette()` invokes this twice per frame (before key
171    /// handling, to clamp the selection index, and again for render); on idle
172    /// frames the second call is served from cache instead of re-running
173    /// `fuzzy_score` over the full command list.
174    pub(crate) fn filtered_indices_cached(&mut self) -> &[usize] {
175        let needs_recompute = match &self.filter_cache {
176            Some((cached_input, _)) => *cached_input != self.input,
177            None => true,
178        };
179        if needs_recompute {
180            let indices = self.filtered_indices();
181            self.filter_cache = Some((self.input.clone(), indices));
182        }
183        &self
184            .filter_cache
185            .as_ref()
186            .expect("filter_cache populated above")
187            .1
188    }
189
190    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
191        let query = self.input.trim();
192        if query.is_empty() {
193            return (0..self.commands.len()).collect();
194        }
195
196        let mut scored: Vec<(usize, i32)> = self
197            .commands
198            .iter()
199            .enumerate()
200            .filter_map(|(i, cmd)| {
201                let mut haystack =
202                    String::with_capacity(cmd.label.len() + cmd.description.len() + 1);
203                haystack.push_str(&cmd.label);
204                haystack.push(' ');
205                haystack.push_str(&cmd.description);
206                Self::fuzzy_score(query, &haystack).map(|score| (i, score))
207            })
208            .collect();
209
210        if scored.is_empty() {
211            let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
212            return self
213                .commands
214                .iter()
215                .enumerate()
216                .filter(|(_, cmd)| {
217                    let label = cmd.label.to_lowercase();
218                    let desc = cmd.description.to_lowercase();
219                    tokens.iter().all(|token| {
220                        label.contains(token.as_str()) || desc.contains(token.as_str())
221                    })
222                })
223                .map(|(i, _)| i)
224                .collect();
225        }
226
227        scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
228        scored.into_iter().map(|(idx, _)| idx).collect()
229    }
230
231    pub(crate) fn selected(&self) -> usize {
232        self.selected
233    }
234
235    pub(crate) fn set_selected(&mut self, s: usize) {
236        self.selected = s;
237    }
238}
239
240/// State for a streaming text display.
241///
242/// Accumulates text chunks as they arrive from an LLM stream.
243/// Pass to [`Context::streaming_text`](crate::Context::streaming_text) each frame.
244#[derive(Debug, Clone)]
245pub struct StreamingTextState {
246    /// The accumulated text content.
247    pub content: String,
248    /// Whether the stream is still receiving data.
249    pub streaming: bool,
250    /// Cursor blink state (for the typing indicator).
251    pub(crate) cursor_visible: bool,
252    pub(crate) cursor_tick: u64,
253    /// Monotonic content version, bumped on every content mutation
254    /// (`push` / `start` / `clear`). See [`StreamingTextState::version`].
255    pub(crate) version: u64,
256}
257
258impl StreamingTextState {
259    /// Create a new empty streaming text state.
260    pub fn new() -> Self {
261        Self {
262            content: String::new(),
263            streaming: false,
264            cursor_visible: true,
265            cursor_tick: 0,
266            version: 0,
267        }
268    }
269
270    /// Append a chunk of text (e.g., from an LLM stream delta).
271    pub fn push(&mut self, chunk: &str) {
272        self.content.push_str(chunk);
273        self.version = self.version.wrapping_add(1);
274    }
275
276    /// Mark the stream as complete (hides the typing cursor).
277    pub fn finish(&mut self) {
278        self.streaming = false;
279    }
280
281    /// Start a new streaming session, clearing previous content.
282    pub fn start(&mut self) {
283        self.content.clear();
284        self.streaming = true;
285        self.cursor_visible = true;
286        self.cursor_tick = 0;
287        self.version = self.version.wrapping_add(1);
288    }
289
290    /// Clear all content and reset state.
291    pub fn clear(&mut self) {
292        self.content.clear();
293        self.streaming = false;
294        self.cursor_visible = true;
295        self.cursor_tick = 0;
296        self.version = self.version.wrapping_add(1);
297    }
298
299    /// Monotonic version counter, bumped on every content mutation
300    /// (`push` / `start` / `clear`).
301    ///
302    /// The stream itself changes every token, so this value is **not** a
303    /// useful cache key for the streaming region. Its purpose is the
304    /// inverse: it lets you detect when the stream *did* change so you can
305    /// decide whether the *surrounding static chrome* is stable. Combine a
306    /// hash of your non-streaming inputs into a key for
307    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) and wrap
308    /// the chrome — not the stream — in it.
309    ///
310    /// Since 0.21.0.
311    ///
312    /// # Example
313    /// ```no_run
314    /// # slt::run(|ui: &mut slt::Context| {
315    /// let mut stream = slt::StreamingTextState::new();
316    /// stream.push("hello");
317    /// assert_eq!(stream.version(), 1);
318    /// stream.push(" world");
319    /// assert_eq!(stream.version(), 2);
320    /// # });
321    /// ```
322    pub fn version(&self) -> u64 {
323        self.version
324    }
325}
326
327impl Default for StreamingTextState {
328    fn default() -> Self {
329        Self::new()
330    }
331}
332
333/// State for a streaming markdown display.
334///
335/// Accumulates markdown chunks as they arrive from an LLM stream.
336/// Pass to [`Context::streaming_markdown`](crate::Context::streaming_markdown) each frame.
337#[derive(Debug, Clone)]
338pub struct StreamingMarkdownState {
339    /// The accumulated markdown content.
340    pub content: String,
341    /// Whether the stream is still receiving data.
342    pub streaming: bool,
343    /// Cursor blink state (for the typing indicator).
344    pub cursor_visible: bool,
345    /// Cursor animation tick counter.
346    pub cursor_tick: u64,
347    /// Whether the parser is currently inside a fenced code block.
348    pub in_code_block: bool,
349    /// Language label of the active fenced code block.
350    pub code_block_lang: String,
351    /// Monotonic content version, bumped on every content mutation
352    /// (`push` / `start` / `clear`). See [`StreamingMarkdownState::version`].
353    pub(crate) version: u64,
354}
355
356impl StreamingMarkdownState {
357    /// Create a new empty streaming markdown state.
358    pub fn new() -> Self {
359        Self {
360            content: String::new(),
361            streaming: false,
362            cursor_visible: true,
363            cursor_tick: 0,
364            in_code_block: false,
365            code_block_lang: String::new(),
366            version: 0,
367        }
368    }
369
370    /// Append a markdown chunk (e.g., from an LLM stream delta).
371    pub fn push(&mut self, chunk: &str) {
372        self.content.push_str(chunk);
373        self.version = self.version.wrapping_add(1);
374    }
375
376    /// Start a new streaming session, clearing previous content.
377    pub fn start(&mut self) {
378        self.content.clear();
379        self.streaming = true;
380        self.cursor_visible = true;
381        self.cursor_tick = 0;
382        self.in_code_block = false;
383        self.code_block_lang.clear();
384        self.version = self.version.wrapping_add(1);
385    }
386
387    /// Mark the stream as complete (hides the typing cursor).
388    pub fn finish(&mut self) {
389        self.streaming = false;
390    }
391
392    /// Clear all content and reset state.
393    pub fn clear(&mut self) {
394        self.content.clear();
395        self.streaming = false;
396        self.cursor_visible = true;
397        self.cursor_tick = 0;
398        self.in_code_block = false;
399        self.code_block_lang.clear();
400        self.version = self.version.wrapping_add(1);
401    }
402
403    /// Monotonic version counter, bumped on every content mutation
404    /// (`push` / `start` / `clear`).
405    ///
406    /// As with [`StreamingTextState::version`], use this to detect stream
407    /// deltas and key the *surrounding static chrome* into
408    /// [`ContainerBuilder::cached`](crate::ContainerBuilder::cached) — not to
409    /// cache the stream region itself.
410    ///
411    /// Since 0.21.0.
412    ///
413    /// # Example
414    /// ```no_run
415    /// # slt::run(|ui: &mut slt::Context| {
416    /// let mut md = slt::StreamingMarkdownState::new();
417    /// md.push("# Title");
418    /// assert_eq!(md.version(), 1);
419    /// # });
420    /// ```
421    pub fn version(&self) -> u64 {
422        self.version
423    }
424}
425
426impl Default for StreamingMarkdownState {
427    fn default() -> Self {
428        Self::new()
429    }
430}
431
432/// Navigation stack state for multi-screen apps.
433///
434/// Tracks screen names in a push/pop stack while preserving the root screen.
435/// Each screen gets isolated focus and hook state when used with
436/// [`crate::Context::screen`].
437///
438/// # Example
439///
440/// ```no_run
441/// let mut screens = slt::ScreenState::new("main");
442///
443/// slt::run(|ui| {
444///     let current = screens.current().to_string();
445///     if current == "main" {
446///         if ui.button("Settings").clicked { screens.push("settings"); }
447///     }
448///     if current == "settings" {
449///         if ui.button("Back").clicked { screens.pop(); }
450///     }
451/// });
452/// ```
453#[derive(Debug)]
454pub struct ScreenState {
455    id: u64,
456    stack: Vec<String>,
457    focus_state: std::collections::HashMap<String, (usize, usize)>,
458}
459
460impl Clone for ScreenState {
461    fn clone(&self) -> Self {
462        Self {
463            id: next_screen_state_id(),
464            stack: self.stack.clone(),
465            focus_state: self.focus_state.clone(),
466        }
467    }
468}
469
470fn next_screen_state_id() -> u64 {
471    static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
472    NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
473}
474
475impl ScreenState {
476    /// Create a screen stack with an initial root screen.
477    pub fn new(initial: impl Into<String>) -> Self {
478        Self {
479            id: next_screen_state_id(),
480            stack: vec![initial.into()],
481            focus_state: std::collections::HashMap::new(),
482        }
483    }
484
485    pub(crate) fn id(&self) -> u64 {
486        self.id
487    }
488
489    /// Return the current screen name (top of the stack).
490    pub fn current(&self) -> &str {
491        self.stack
492            .last()
493            .expect("ScreenState always contains at least one screen")
494            .as_str()
495    }
496
497    /// Push a new screen onto the stack.
498    pub fn push(&mut self, name: impl Into<String>) {
499        self.stack.push(name.into());
500    }
501
502    /// Pop the current screen, preserving the root screen.
503    pub fn pop(&mut self) {
504        if self.can_pop() {
505            self.stack.pop();
506        }
507    }
508
509    /// Return current stack depth.
510    pub fn depth(&self) -> usize {
511        self.stack.len()
512    }
513
514    /// Return `true` if popping is allowed.
515    pub fn can_pop(&self) -> bool {
516        self.stack.len() > 1
517    }
518
519    /// Return `true` if `name` is currently present in the navigation stack.
520    pub fn contains(&self, name: &str) -> bool {
521        self.stack.iter().any(|screen| screen == name)
522    }
523
524    /// Reset to only the root screen.
525    pub fn reset(&mut self) {
526        self.stack.truncate(1);
527    }
528
529    /// Remove retained focus state for a screen that is no longer on the stack.
530    ///
531    /// Returns `false` when the screen is still active or stacked, because
532    /// dropping focus for a live screen would make back navigation jumpy.
533    pub fn remove_inactive(&mut self, name: &str) -> bool {
534        if self.contains(name) {
535            return false;
536        }
537        self.focus_state.remove(name).is_some()
538    }
539
540    /// Retain inactive focus-state entries accepted by `keep`.
541    ///
542    /// Screens still present in the stack are always kept. Returns the number
543    /// of focus-state entries removed.
544    pub fn retain_inactive(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
545        let before = self.focus_state.len();
546        let stack = &self.stack;
547        self.focus_state
548            .retain(|name, _| stack.iter().any(|screen| screen == name) || keep(name));
549        before - self.focus_state.len()
550    }
551
552    /// Number of retained per-screen focus entries.
553    ///
554    /// Diagnostic helper for apps with runtime-generated screen names.
555    pub fn focus_state_count(&self) -> usize {
556        self.focus_state.len()
557    }
558
559    /// Apply a deferred navigation request recorded inside a
560    /// [`crate::Context::screen`] closure (issue #279).
561    pub(crate) fn apply_nav(&mut self, nav: ScreenNav) {
562        match nav {
563            ScreenNav::Push(name) => self.push(name),
564            ScreenNav::Pop => self.pop(),
565            ScreenNav::Reset => self.reset(),
566        }
567    }
568
569    pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
570        self.focus_state
571            .insert(name.to_string(), (focus_index, focus_count));
572    }
573
574    pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
575        self.focus_state.get(name).copied().unwrap_or((0, 0))
576    }
577}
578
579/// A deferred screen-navigation request recorded inside a
580/// [`crate::Context::screen`] closure and applied to the active
581/// [`ScreenState`] after the closure returns (issue #279).
582#[derive(Debug, Clone)]
583pub(crate) enum ScreenNav {
584    /// Push a new screen onto the stack.
585    Push(String),
586    /// Pop the current screen, preserving the root.
587    Pop,
588    /// Reset to only the root screen.
589    Reset,
590}
591
592/// Named mode system with independent screen stacks.
593///
594/// Each mode contains its own [`ScreenState`]. Switching modes preserves
595/// the previous mode's screen stack, focus, and hook state.
596///
597/// # Example
598///
599/// ```no_run
600/// let mut modes = slt::ModeState::new("app", "home");
601/// modes.add_mode("settings", "general");
602///
603/// slt::run(|ui| {
604///     if ui.key('1') { modes.switch_mode("app"); }
605///     if ui.key('2') { modes.switch_mode("settings"); }
606///     let mode = modes.active_mode().to_string();
607///     ui.text(format!("Mode: {}", mode));
608/// });
609/// ```
610#[derive(Debug, Clone)]
611pub struct ModeState {
612    modes: std::collections::HashMap<String, ScreenState>,
613    active: String,
614}
615
616impl ModeState {
617    /// Create a mode system with an initial mode and screen.
618    pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
619        let mode = mode.into();
620        let mut modes = std::collections::HashMap::new();
621        modes.insert(mode.clone(), ScreenState::new(screen));
622        Self {
623            modes,
624            active: mode,
625        }
626    }
627
628    /// Add a new mode with an initial screen.
629    pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
630        let mode = mode.into();
631        self.modes
632            .entry(mode)
633            .or_insert_with(|| ScreenState::new(screen));
634    }
635
636    /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
637    ///
638    /// Panics if the mode does not exist. For a non-panicking variant that
639    /// reports success, use [`Self::try_switch_mode`].
640    pub fn switch_mode(&mut self, mode: impl Into<String>) {
641        let mode = mode.into();
642        assert!(self.modes.contains_key(&mode), "mode '{mode}' not found");
643        self.active = mode;
644    }
645
646    /// Switch modes, returning `true` when the mode exists and the switch
647    /// happened, or `false` when the mode has not been registered via
648    /// [`Self::add_mode`].
649    ///
650    /// Prefer this over [`Self::switch_mode`] when the mode name comes from
651    /// user input, key bindings, or anywhere the value could be unexpected
652    /// at runtime — an unknown mode should not crash the host application.
653    pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
654        let mode = mode.into();
655        if !self.modes.contains_key(&mode) {
656            return false;
657        }
658        self.active = mode;
659        true
660    }
661
662    /// Return the active mode name.
663    pub fn active_mode(&self) -> &str {
664        &self.active
665    }
666
667    /// Get a reference to the active mode's screen state.
668    pub fn screens(&self) -> &ScreenState {
669        self.modes
670            .get(&self.active)
671            .expect("active mode must exist")
672    }
673
674    /// Get a mutable reference to the active mode's screen state.
675    pub fn screens_mut(&mut self) -> &mut ScreenState {
676        self.modes
677            .get_mut(&self.active)
678            .expect("active mode must exist")
679    }
680
681    /// Return `true` when a mode has been registered.
682    pub fn contains_mode(&self, mode: &str) -> bool {
683        self.modes.contains_key(mode)
684    }
685
686    /// Number of registered modes.
687    ///
688    /// Diagnostic helper for dynamic mode sets.
689    pub fn mode_count(&self) -> usize {
690        self.modes.len()
691    }
692
693    /// Remove an inactive mode and its retained screen state.
694    ///
695    /// The active mode is preserved and returns `false`.
696    pub fn remove_mode(&mut self, mode: &str) -> bool {
697        if self.active == mode {
698            return false;
699        }
700        self.modes.remove(mode).is_some()
701    }
702
703    /// Retain inactive modes accepted by `keep`.
704    ///
705    /// The active mode is always retained. Returns the number of modes removed.
706    pub fn retain_modes(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
707        let before = self.modes.len();
708        let active = self.active.as_str();
709        self.modes
710            .retain(|mode, _| mode.as_str() == active || keep(mode.as_str()));
711        before - self.modes.len()
712    }
713}
714
715#[cfg(test)]
716mod mode_state_tests {
717    use super::ModeState;
718
719    #[test]
720    fn try_switch_mode_returns_false_for_unknown_mode() {
721        let mut modes = ModeState::new("app", "home");
722        modes.add_mode("settings", "general");
723        assert!(modes.try_switch_mode("settings"));
724        assert_eq!(modes.active_mode(), "settings");
725        assert!(!modes.try_switch_mode("nonexistent"));
726        // Active mode must not change when the switch is rejected.
727        assert_eq!(modes.active_mode(), "settings");
728    }
729
730    #[test]
731    fn remove_mode_preserves_active_mode() {
732        let mut modes = ModeState::new("app", "home");
733        modes.add_mode("settings", "general");
734        modes.add_mode("admin", "dashboard");
735
736        assert_eq!(modes.mode_count(), 3);
737        assert!(!modes.remove_mode("app"));
738        assert!(modes.remove_mode("admin"));
739        assert!(!modes.contains_mode("admin"));
740        assert_eq!(modes.mode_count(), 2);
741    }
742
743    #[test]
744    fn retain_modes_keeps_active_mode() {
745        let mut modes = ModeState::new("app", "home");
746        modes.add_mode("settings", "general");
747        modes.add_mode("admin", "dashboard");
748
749        let removed = modes.retain_modes(|mode| mode == "admin");
750        assert_eq!(removed, 1);
751        assert!(modes.contains_mode("app"));
752        assert!(modes.contains_mode("admin"));
753        assert!(!modes.contains_mode("settings"));
754    }
755}
756
757#[cfg(test)]
758mod streaming_version_tests {
759    //! Issue #273 — the monotonic `version()` counter on streaming states.
760    use super::{StreamingMarkdownState, StreamingTextState};
761
762    #[test]
763    fn text_version_starts_at_zero_and_bumps_on_mutation() {
764        let mut s = StreamingTextState::new();
765        assert_eq!(s.version(), 0, "fresh state has version 0");
766        s.push("a");
767        assert_eq!(s.version(), 1);
768        s.push("b");
769        assert_eq!(s.version(), 2);
770        s.start();
771        assert_eq!(s.version(), 3, "start() is a mutation");
772        s.clear();
773        assert_eq!(s.version(), 4, "clear() is a mutation");
774    }
775
776    #[test]
777    fn text_finish_does_not_bump_version() {
778        let mut s = StreamingTextState::new();
779        s.push("x");
780        let v = s.version();
781        s.finish();
782        assert_eq!(s.version(), v, "finish() only toggles the streaming flag");
783    }
784
785    #[test]
786    fn markdown_version_bumps_on_mutation() {
787        let mut s = StreamingMarkdownState::new();
788        assert_eq!(s.version(), 0);
789        s.push("# h");
790        assert_eq!(s.version(), 1);
791        s.start();
792        assert_eq!(s.version(), 2);
793        s.clear();
794        assert_eq!(s.version(), 3);
795        let v = s.version();
796        s.finish();
797        assert_eq!(s.version(), v, "finish() does not bump");
798    }
799}
800
801/// Approval state for a tool call.
802#[non_exhaustive]
803#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804pub enum ApprovalAction {
805    /// No action taken yet.
806    Pending,
807    /// User approved the tool call.
808    Approved,
809    /// User rejected the tool call.
810    Rejected,
811}
812
813/// State for a tool approval widget.
814///
815/// Displays a tool call with approve/reject buttons for human-in-the-loop
816/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
817/// each frame.
818#[derive(Debug, Clone)]
819pub struct ToolApprovalState {
820    /// The name of the tool being invoked.
821    pub tool_name: String,
822    /// A human-readable description of what the tool will do.
823    pub description: String,
824    /// The current approval status.
825    pub action: ApprovalAction,
826}
827
828impl ToolApprovalState {
829    /// Create a new tool approval prompt.
830    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
831        Self {
832            tool_name: tool_name.into(),
833            description: description.into(),
834            action: ApprovalAction::Pending,
835        }
836    }
837
838    /// Reset to pending state.
839    pub fn reset(&mut self) {
840        self.action = ApprovalAction::Pending;
841    }
842}
843
844/// Item in a context bar showing active context sources.
845#[derive(Debug, Clone)]
846pub struct ContextItem {
847    /// Display label for this context source.
848    pub label: String,
849    /// Token count or size indicator.
850    pub tokens: usize,
851}
852
853impl ContextItem {
854    /// Create a new context item with a label and token count.
855    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
856        Self {
857            label: label.into(),
858            tokens,
859        }
860    }
861}