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/// Pass this state through your render closure and branch on [`ScreenState::current`].
270#[derive(Debug, Clone)]
271pub struct ScreenState {
272    stack: Vec<String>,
273}
274
275impl ScreenState {
276    /// Create a screen stack with an initial root screen.
277    pub fn new(initial: impl Into<String>) -> Self {
278        Self {
279            stack: vec![initial.into()],
280        }
281    }
282
283    /// Return the current screen name (top of the stack).
284    pub fn current(&self) -> &str {
285        self.stack
286            .last()
287            .expect("ScreenState always contains at least one screen")
288            .as_str()
289    }
290
291    /// Push a new screen onto the stack.
292    pub fn push(&mut self, name: impl Into<String>) {
293        self.stack.push(name.into());
294    }
295
296    /// Pop the current screen, preserving the root screen.
297    pub fn pop(&mut self) {
298        if self.can_pop() {
299            self.stack.pop();
300        }
301    }
302
303    /// Return current stack depth.
304    pub fn depth(&self) -> usize {
305        self.stack.len()
306    }
307
308    /// Return `true` if popping is allowed.
309    pub fn can_pop(&self) -> bool {
310        self.stack.len() > 1
311    }
312
313    /// Reset to only the root screen.
314    pub fn reset(&mut self) {
315        self.stack.truncate(1);
316    }
317}
318
319/// Approval state for a tool call.
320#[non_exhaustive]
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum ApprovalAction {
323    /// No action taken yet.
324    Pending,
325    /// User approved the tool call.
326    Approved,
327    /// User rejected the tool call.
328    Rejected,
329}
330
331/// State for a tool approval widget.
332///
333/// Displays a tool call with approve/reject buttons for human-in-the-loop
334/// AI workflows. Pass to [`Context::tool_approval`](crate::Context::tool_approval)
335/// each frame.
336#[derive(Debug, Clone)]
337pub struct ToolApprovalState {
338    /// The name of the tool being invoked.
339    pub tool_name: String,
340    /// A human-readable description of what the tool will do.
341    pub description: String,
342    /// The current approval status.
343    pub action: ApprovalAction,
344}
345
346impl ToolApprovalState {
347    /// Create a new tool approval prompt.
348    pub fn new(tool_name: impl Into<String>, description: impl Into<String>) -> Self {
349        Self {
350            tool_name: tool_name.into(),
351            description: description.into(),
352            action: ApprovalAction::Pending,
353        }
354    }
355
356    /// Reset to pending state.
357    pub fn reset(&mut self) {
358        self.action = ApprovalAction::Pending;
359    }
360}
361
362/// Item in a context bar showing active context sources.
363#[derive(Debug, Clone)]
364pub struct ContextItem {
365    /// Display label for this context source.
366    pub label: String,
367    /// Token count or size indicator.
368    pub tokens: usize,
369}
370
371impl ContextItem {
372    /// Create a new context item with a label and token count.
373    pub fn new(label: impl Into<String>, tokens: usize) -> Self {
374        Self {
375            label: label.into(),
376            tokens,
377        }
378    }
379}