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    /// Return `true` if `name` is currently present in the navigation stack.
458    pub fn contains(&self, name: &str) -> bool {
459        self.stack.iter().any(|screen| screen == name)
460    }
461
462    /// Reset to only the root screen.
463    pub fn reset(&mut self) {
464        self.stack.truncate(1);
465    }
466
467    /// Remove retained focus state for a screen that is no longer on the stack.
468    ///
469    /// Returns `false` when the screen is still active or stacked, because
470    /// dropping focus for a live screen would make back navigation jumpy.
471    pub fn remove_inactive(&mut self, name: &str) -> bool {
472        if self.contains(name) {
473            return false;
474        }
475        self.focus_state.remove(name).is_some()
476    }
477
478    /// Retain inactive focus-state entries accepted by `keep`.
479    ///
480    /// Screens still present in the stack are always kept. Returns the number
481    /// of focus-state entries removed.
482    pub fn retain_inactive(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
483        let before = self.focus_state.len();
484        let stack = &self.stack;
485        self.focus_state
486            .retain(|name, _| stack.iter().any(|screen| screen == name) || keep(name));
487        before - self.focus_state.len()
488    }
489
490    /// Number of retained per-screen focus entries.
491    ///
492    /// Diagnostic helper for apps with runtime-generated screen names.
493    pub fn focus_state_count(&self) -> usize {
494        self.focus_state.len()
495    }
496
497    /// Apply a deferred navigation request recorded inside a
498    /// [`crate::Context::screen`] closure (issue #279).
499    pub(crate) fn apply_nav(&mut self, nav: ScreenNav) {
500        match nav {
501            ScreenNav::Push(name) => self.push(name),
502            ScreenNav::Pop => self.pop(),
503            ScreenNav::Reset => self.reset(),
504        }
505    }
506
507    pub(crate) fn save_focus(&mut self, name: &str, focus_index: usize, focus_count: usize) {
508        self.focus_state
509            .insert(name.to_string(), (focus_index, focus_count));
510    }
511
512    pub(crate) fn restore_focus(&self, name: &str) -> (usize, usize) {
513        self.focus_state.get(name).copied().unwrap_or((0, 0))
514    }
515}
516
517/// A deferred screen-navigation request recorded inside a
518/// [`crate::Context::screen`] closure and applied to the active
519/// [`ScreenState`] after the closure returns (issue #279).
520#[derive(Debug, Clone)]
521pub(crate) enum ScreenNav {
522    /// Push a new screen onto the stack.
523    Push(String),
524    /// Pop the current screen, preserving the root.
525    Pop,
526    /// Reset to only the root screen.
527    Reset,
528}
529
530/// Named mode system with independent screen stacks.
531///
532/// Each mode contains its own [`ScreenState`]. Switching modes preserves
533/// the previous mode's screen stack, focus, and hook state.
534///
535/// # Example
536///
537/// ```no_run
538/// let mut modes = slt::ModeState::new("app", "home");
539/// modes.add_mode("settings", "general");
540///
541/// slt::run(|ui| {
542///     if ui.key('1') { modes.switch_mode("app"); }
543///     if ui.key('2') { modes.switch_mode("settings"); }
544///     let mode = modes.active_mode().to_string();
545///     ui.text(format!("Mode: {}", mode));
546/// });
547/// ```
548#[derive(Debug, Clone)]
549pub struct ModeState {
550    modes: std::collections::HashMap<String, ScreenState>,
551    active: String,
552}
553
554impl ModeState {
555    /// Create a mode system with an initial mode and screen.
556    pub fn new(mode: impl Into<String>, screen: impl Into<String>) -> Self {
557        let mode = mode.into();
558        let mut modes = std::collections::HashMap::new();
559        modes.insert(mode.clone(), ScreenState::new(screen));
560        Self {
561            modes,
562            active: mode,
563        }
564    }
565
566    /// Add a new mode with an initial screen.
567    pub fn add_mode(&mut self, mode: impl Into<String>, screen: impl Into<String>) {
568        let mode = mode.into();
569        self.modes
570            .entry(mode)
571            .or_insert_with(|| ScreenState::new(screen));
572    }
573
574    /// Switch to a different mode. The mode must have been added with [`Self::add_mode`].
575    ///
576    /// Panics if the mode does not exist. For a non-panicking variant that
577    /// reports success, use [`Self::try_switch_mode`].
578    pub fn switch_mode(&mut self, mode: impl Into<String>) {
579        let mode = mode.into();
580        assert!(self.modes.contains_key(&mode), "mode '{mode}' not found");
581        self.active = mode;
582    }
583
584    /// Switch modes, returning `true` when the mode exists and the switch
585    /// happened, or `false` when the mode has not been registered via
586    /// [`Self::add_mode`].
587    ///
588    /// Prefer this over [`Self::switch_mode`] when the mode name comes from
589    /// user input, key bindings, or anywhere the value could be unexpected
590    /// at runtime — an unknown mode should not crash the host application.
591    pub fn try_switch_mode(&mut self, mode: impl Into<String>) -> bool {
592        let mode = mode.into();
593        if !self.modes.contains_key(&mode) {
594            return false;
595        }
596        self.active = mode;
597        true
598    }
599
600    /// Return the active mode name.
601    pub fn active_mode(&self) -> &str {
602        &self.active
603    }
604
605    /// Get a reference to the active mode's screen state.
606    pub fn screens(&self) -> &ScreenState {
607        self.modes
608            .get(&self.active)
609            .expect("active mode must exist")
610    }
611
612    /// Get a mutable reference to the active mode's screen state.
613    pub fn screens_mut(&mut self) -> &mut ScreenState {
614        self.modes
615            .get_mut(&self.active)
616            .expect("active mode must exist")
617    }
618
619    /// Return `true` when a mode has been registered.
620    pub fn contains_mode(&self, mode: &str) -> bool {
621        self.modes.contains_key(mode)
622    }
623
624    /// Number of registered modes.
625    ///
626    /// Diagnostic helper for dynamic mode sets.
627    pub fn mode_count(&self) -> usize {
628        self.modes.len()
629    }
630
631    /// Remove an inactive mode and its retained screen state.
632    ///
633    /// The active mode is preserved and returns `false`.
634    pub fn remove_mode(&mut self, mode: &str) -> bool {
635        if self.active == mode {
636            return false;
637        }
638        self.modes.remove(mode).is_some()
639    }
640
641    /// Retain inactive modes accepted by `keep`.
642    ///
643    /// The active mode is always retained. Returns the number of modes removed.
644    pub fn retain_modes(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
645        let before = self.modes.len();
646        let active = self.active.as_str();
647        self.modes
648            .retain(|mode, _| mode.as_str() == active || keep(mode.as_str()));
649        before - self.modes.len()
650    }
651}
652
653#[cfg(test)]
654mod mode_state_tests {
655    use super::ModeState;
656
657    #[test]
658    fn try_switch_mode_returns_false_for_unknown_mode() {
659        let mut modes = ModeState::new("app", "home");
660        modes.add_mode("settings", "general");
661        assert!(modes.try_switch_mode("settings"));
662        assert_eq!(modes.active_mode(), "settings");
663        assert!(!modes.try_switch_mode("nonexistent"));
664        // Active mode must not change when the switch is rejected.
665        assert_eq!(modes.active_mode(), "settings");
666    }
667
668    #[test]
669    fn remove_mode_preserves_active_mode() {
670        let mut modes = ModeState::new("app", "home");
671        modes.add_mode("settings", "general");
672        modes.add_mode("admin", "dashboard");
673
674        assert_eq!(modes.mode_count(), 3);
675        assert!(!modes.remove_mode("app"));
676        assert!(modes.remove_mode("admin"));
677        assert!(!modes.contains_mode("admin"));
678        assert_eq!(modes.mode_count(), 2);
679    }
680
681    #[test]
682    fn retain_modes_keeps_active_mode() {
683        let mut modes = ModeState::new("app", "home");
684        modes.add_mode("settings", "general");
685        modes.add_mode("admin", "dashboard");
686
687        let removed = modes.retain_modes(|mode| mode == "admin");
688        assert_eq!(removed, 1);
689        assert!(modes.contains_mode("app"));
690        assert!(modes.contains_mode("admin"));
691        assert!(!modes.contains_mode("settings"));
692    }
693}
694
695#[cfg(test)]
696mod streaming_version_tests {
697    //! Issue #273 — the monotonic `version()` counter on streaming states.
698    use super::{StreamingMarkdownState, StreamingTextState};
699
700    #[test]
701    fn text_version_starts_at_zero_and_bumps_on_mutation() {
702        let mut s = StreamingTextState::new();
703        assert_eq!(s.version(), 0, "fresh state has version 0");
704        s.push("a");
705        assert_eq!(s.version(), 1);
706        s.push("b");
707        assert_eq!(s.version(), 2);
708        s.start();
709        assert_eq!(s.version(), 3, "start() is a mutation");
710        s.clear();
711        assert_eq!(s.version(), 4, "clear() is a mutation");
712    }
713
714    #[test]
715    fn text_finish_does_not_bump_version() {
716        let mut s = StreamingTextState::new();
717        s.push("x");
718        let v = s.version();
719        s.finish();
720        assert_eq!(s.version(), v, "finish() only toggles the streaming flag");
721    }
722
723    #[test]
724    fn markdown_version_bumps_on_mutation() {
725        let mut s = StreamingMarkdownState::new();
726        assert_eq!(s.version(), 0);
727        s.push("# h");
728        assert_eq!(s.version(), 1);
729        s.start();
730        assert_eq!(s.version(), 2);
731        s.clear();
732        assert_eq!(s.version(), 3);
733        let v = s.version();
734        s.finish();
735        assert_eq!(s.version(), v, "finish() does not bump");
736    }
737}
738
739/// Approval state for a tool call.
740#[non_exhaustive]
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742pub enum ApprovalAction {
743    /// No action taken yet.
744    Pending,
745    /// User approved the tool call.
746    Approved,
747    /// User rejected the tool call.
748    Rejected,
749}
750
751/// State for a tool approval widget.
752///
753/// Displays a tool call with approve/reject buttons for human-in-the-loop
754/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
755/// each frame.
756#[derive(Debug, Clone)]
757pub struct ToolApprovalState {
758    /// The name of the tool being invoked.
759    pub tool_name: String,
760    /// A human-readable description of what the tool will do.
761    pub description: String,
762    /// The current approval status.
763    pub action: ApprovalAction,
764}
765
766impl ToolApprovalState {
767    /// Create a new tool approval prompt.
768    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
769        Self {
770            tool_name: tool_name.into(),
771            description: description.into(),
772            action: ApprovalAction::Pending,
773        }
774    }
775
776    /// Reset to pending state.
777    pub fn reset(&mut self) {
778        self.action = ApprovalAction::Pending;
779    }
780}
781
782/// Item in a context bar showing active context sources.
783#[derive(Debug, Clone)]
784pub struct ContextItem {
785    /// Display label for this context source.
786    pub label: String,
787    /// Token count or size indicator.
788    pub tokens: usize,
789}
790
791impl ContextItem {
792    /// Create a new context item with a label and token count.
793    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
794        Self {
795            label: label.into(),
796            tokens,
797        }
798    }
799}