Skip to main content

stynx_code_tui/state/
app_state.rs

1use stynx_code_types::EngineEvent;
2use super::{ConversationState, DiffLine, DiffLineKind, DisplayMessage, DisplayToolUse, InputState, ModalState, ToastState, ToolUseStatus};
3
4/// USD price per million tokens `(input, output)` for a given model.
5///
6/// Matched on substrings so provider prefixes / date suffixes still resolve
7/// (e.g. `deepseek-chat`, `claude-opus-4-20250514`). Falls back to Claude
8/// Sonnet rates for unknown models.
9fn model_pricing(model: &str) -> (f64, f64) {
10    let m = model.to_ascii_lowercase();
11    match () {
12        // DeepSeek (per their published API pricing, cache-miss rates)
13        _ if m.contains("deepseek-reasoner") || m.contains("deepseek-r1") => (0.55, 2.19),
14        _ if m.contains("deepseek") => (0.27, 1.10),
15        // Anthropic
16        _ if m.contains("opus") => (15.0, 75.0),
17        _ if m.contains("haiku") => (0.80, 4.0),
18        _ if m.contains("sonnet") => (3.0, 15.0),
19        // OpenAI (common ones)
20        _ if m.contains("gpt-4o-mini") => (0.15, 0.60),
21        _ if m.contains("gpt-4o") => (2.50, 10.0),
22        // Unknown → assume Sonnet-class so the figure is non-zero but sane.
23        _ => (3.0, 15.0),
24    }
25}
26
27#[derive(Clone)]
28pub struct SessionSummary {
29    pub id: String,
30    pub title: String,
31    pub updated_at: u64,
32    pub pinned: bool,
33}
34
35pub struct SidebarState {
36    pub visible: bool,
37    pub title: String,
38    pub session_id: String,
39    pub version: String,
40    pub sessions: Vec<SessionSummary>,
41}
42
43impl SidebarState {
44    pub fn new() -> Self {
45        Self {
46            visible: true,
47            title: "New session".to_string(),
48            session_id: String::new(),
49            version: env!("CARGO_PKG_VERSION").to_string(),
50            sessions: Vec::new(),
51        }
52    }
53}
54
55impl Default for SidebarState {
56    fn default() -> Self { Self::new() }
57}
58
59pub struct AppState {
60    pub input: InputState,
61    pub conversation: ConversationState,
62    pub modal: ModalState,
63    pub sidebar: SidebarState,
64    pub toasts: ToastState,
65    pub model_name: String,
66    pub permission_mode: String,
67    pub total_cost: f64,
68    pub git_branch: Option<String>,
69    pub cwd: String,
70    pub is_streaming: bool,
71    pub is_paused: bool,
72    pub spinner_frame: usize,
73    pub spinner_tick: u8,
74    pub total_input: u64,
75    pub total_output: u64,
76    pub recent_models: Vec<String>,
77    pub tool_details: bool,
78
79    pub live_thinking: String,
80
81    pub sub_agents: Vec<(String, String)>,
82
83    pub last_summary: Option<Vec<String>>,
84
85    pub tool_history: ToolHistoryState,
86
87    pub is_pending: bool,
88    pub elapsed_secs: u64,
89    pub stale_warned: bool,
90}
91
92#[derive(Default)]
93pub struct ToolHistoryState {
94    pub selected: Option<usize>,
95    pub scroll: usize,
96    pub focused: bool,
97    pub detail_open: bool,
98}
99
100impl AppState {
101    pub fn push_recent_model(&mut self, id: &str) {
102        self.recent_models.retain(|m| m != id);
103        self.recent_models.insert(0, id.to_string());
104        if self.recent_models.len() > 8 {
105            self.recent_models.truncate(8);
106        }
107    }
108
109    pub fn cycle_recent_model(&mut self) -> Option<String> {
110        if self.recent_models.len() < 2 {
111            return None;
112        }
113        let next = self.recent_models.remove(1);
114        self.recent_models.insert(0, next.clone());
115        Some(next)
116    }
117}
118
119impl AppState {
120    pub fn new() -> Self {
121        Self {
122            input: InputState::new(),
123            conversation: ConversationState::new(),
124            modal: ModalState::new(),
125            sidebar: SidebarState::new(),
126            toasts: ToastState::new(),
127            model_name: String::from("claude-sonnet-4-20250514"),
128            permission_mode: String::from("Normal"),
129            total_cost: 0.0,
130            git_branch: None,
131            cwd: std::env::current_dir().ok()
132                .and_then(|p| p.to_str().map(|s| s.to_string()))
133                .unwrap_or_default(),
134            is_streaming: false,
135            is_paused: false,
136            spinner_frame: 0,
137            spinner_tick: 0,
138            total_input: 0,
139            total_output: 0,
140            recent_models: Vec::new(),
141            tool_details: true,
142            live_thinking: String::new(),
143            sub_agents: Vec::new(),
144            last_summary: None,
145            tool_history: ToolHistoryState::default(),
146            is_pending: false,
147            elapsed_secs: 0,
148            stale_warned: false,
149        }
150    }
151
152    pub fn push_user_message(&mut self, text: impl Into<String>) {
153        self.last_summary = None;
154        self.is_paused = false;
155        self.conversation.messages.push(DisplayMessage {
156            role: "user".to_string(),
157            content: text.into(),
158            thinking: String::new(),
159            tool_uses: Vec::new(),
160            is_streaming: false,
161        });
162        self.conversation.auto_scroll = true;
163    }
164
165    pub fn push_system_message(&mut self, text: impl Into<String>) {
166        self.conversation.messages.push(DisplayMessage {
167            role: "system".to_string(),
168            content: text.into(),
169            thinking: String::new(),
170            tool_uses: Vec::new(),
171            is_streaming: false,
172        });
173        self.conversation.auto_scroll = true;
174    }
175
176    pub fn apply_engine_event(&mut self, event: EngineEvent) {
177        self.is_pending = false;
178        match event {
179            EngineEvent::TextDelta(text) => {
180                self.is_streaming = true;
181                match self.conversation.messages.last_mut() {
182                    Some(m) if m.role == "assistant" && m.is_streaming => m.content.push_str(&text),
183                    _ => self.conversation.messages.push(DisplayMessage {
184                        role: "assistant".to_string(), content: text,
185                        thinking: String::new(), tool_uses: Vec::new(), is_streaming: true,
186                    }),
187                }
188            }
189            EngineEvent::ThinkingDelta(text) => {
190                self.is_streaming = true;
191                self.live_thinking.push_str(&text);
192                // Make sure a streaming assistant bubble exists so the "Stynx"
193                // header and its thinking indicator render while only reasoning
194                // (no text/tools yet) has streamed in.
195                if !matches!(self.conversation.messages.last(),
196                    Some(m) if m.role == "assistant" && m.is_streaming)
197                {
198                    self.conversation.messages.push(DisplayMessage {
199                        role: "assistant".to_string(), content: String::new(),
200                        thinking: String::new(), tool_uses: Vec::new(), is_streaming: true,
201                    });
202                }
203            }
204            EngineEvent::ToolStart { name, .. } => {
205                self.is_streaming = true;
206                let tool = DisplayToolUse {
207                    name,
208                    status: ToolUseStatus::Running,
209                    output_preview: String::new(),
210                    input_json: String::new(),
211                    input_summary: String::new(),
212                    output_excerpt: Vec::new(),
213                    diff: Vec::new(),
214                    sub_progress: Vec::new(),
215                    live_output: String::new(),
216                };
217                match self.conversation.messages.last_mut().filter(|m| m.role == "assistant") {
218                    Some(m) => m.tool_uses.push(tool),
219                    None => self.conversation.messages.push(DisplayMessage {
220                        role: "assistant".to_string(), content: String::new(),
221                        thinking: String::new(), tool_uses: vec![tool], is_streaming: true,
222                    }),
223                }
224            }
225            EngineEvent::ToolInput { json_chunk } => {
226                if let Some(m) = self.conversation.messages.last_mut() {
227                    if let Some(t) = m.tool_uses.iter_mut().rev()
228                        .find(|t| t.status == ToolUseStatus::Running)
229                    {
230                        t.input_json.push_str(&json_chunk);
231                        t.input_summary = summarize_tool_input(&t.name, &t.input_json);
232                    }
233                }
234            }
235            EngineEvent::ToolOutput { name, chunk } => {
236                if let Some(m) = self.conversation.messages.last_mut() {
237                    if let Some(t) = m.tool_uses.iter_mut().rev()
238                        .find(|t| t.name == name && t.status == ToolUseStatus::Running)
239                    {
240                        t.live_output.push_str(&chunk);
241                        // Cap the retained buffer so a noisy command can't grow it forever.
242                        if t.live_output.len() > 64_000 {
243                            let cut = t.live_output.len() - 48_000;
244                            t.live_output = t.live_output.split_off(cut);
245                        }
246                        let clean = crate::util::strip_ansi(&t.live_output);
247                        t.output_preview = clean
248                            .lines()
249                            .rev()
250                            .find(|l| !l.trim().is_empty())
251                            .unwrap_or("")
252                            .chars()
253                            .take(80)
254                            .collect();
255                        t.output_excerpt = excerpt_lines(&clean, 6, 200);
256                    }
257                }
258            }
259            EngineEvent::ToolResult { name, output, is_error } => {
260                let clean_output = crate::util::strip_ansi(&output);
261                let preview_limit = if is_error { 400 } else { 80 };
262                if let Some(m) = self.conversation.messages.last_mut() {
263                    if let Some(t) = m.tool_uses.iter_mut().rev()
264                        .find(|t| t.name == name && t.status == ToolUseStatus::Running) {
265                        t.status = if is_error { ToolUseStatus::Error } else { ToolUseStatus::Completed };
266                        t.output_preview = clean_output.lines().next().unwrap_or("").chars().take(preview_limit).collect();
267                        if t.input_summary.is_empty() {
268                            t.input_summary = summarize_tool_input(&t.name, &t.input_json);
269                        }
270                        t.output_excerpt = excerpt_lines(&clean_output, 6, 200);
271                        if t.name == "file_edit" || t.name == "file_write" {
272                            t.diff = build_diff_for(&t.name, &t.input_json);
273                        }
274
275                        if matches!(t.name.as_str(), "read" | "grep" | "glob") && !is_error {
276                            let n = clean_output.lines().filter(|l| !l.trim().is_empty()).count();
277                            if n > 0 && !t.input_summary.contains("(")  {
278                                t.input_summary = format!("{}  ({n} lines)", t.input_summary);
279                            }
280                        }
281                    }
282                }
283                if is_error {
284                    self.conversation.messages.push(DisplayMessage {
285                        role: "error".to_string(),
286                        content: format!("{name}: {clean_output}"),
287                        thinking: String::new(),
288                        tool_uses: Vec::new(),
289                        is_streaming: false,
290                    });
291                    tracing::error!(tool = %name, output = %clean_output, "tool returned error");
292                }
293            }
294            EngineEvent::TurnComplete => {
295                self.is_streaming = false;
296                let tool_summary = self.conversation.messages.last().and_then(|m| {
297                    if m.role != "assistant" { return None; }
298                    if m.tool_uses.is_empty() { return None; }
299                    let parts: Vec<String> = m.tool_uses.iter().map(|t| {
300                        let pretty = match t.name.as_str() {
301                            "bash" => "Bash".into(),
302                            "read" => "Read".into(),
303                            "file_write" => "Write".into(),
304                            "file_edit" => "Edit".into(),
305                            "glob" => "Glob".into(),
306                            "grep" => "Grep".into(),
307                            "web_fetch" => "WebFetch".into(),
308                            "web_search" => "WebSearch".into(),
309                            "todo_write" => "TodoWrite".into(),
310                            "todo_read" => "TodoRead".into(),
311                            "ask_user_question" => "AskUser".into(),
312                            "agent" => "Agent".into(),
313                            other => {
314                                let mut s = other.replace('_', " ");
315                                s = s.split_whitespace()
316                                    .map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() })
317                                    .collect::<Vec<_>>().join("");
318                                s
319                            }
320                        };
321                        if t.input_summary.is_empty() {
322                            pretty
323                        } else {
324                            format!("{}({})", pretty, t.input_summary)
325                        }
326                    }).collect();
327                    if parts.is_empty() { None } else { Some(parts) }
328                });
329                if let Some(m) = self.conversation.messages.last_mut() {
330                    m.is_streaming = false;
331                    if !self.live_thinking.is_empty() && m.role == "assistant" {
332                        if m.thinking.is_empty() {
333                            m.thinking = std::mem::take(&mut self.live_thinking);
334                        } else {
335                            m.thinking.push_str(&self.live_thinking);
336                            self.live_thinking.clear();
337                        }
338                    }
339                }
340                self.live_thinking.clear();
341                if let Some(summary) = tool_summary {
342                    self.last_summary = Some(summary);
343                    self.conversation.auto_scroll = true;
344                }
345            }
346            EngineEvent::Usage { input_tokens, output_tokens } => {
347                if input_tokens > 0 { self.total_input += input_tokens; }
348                if output_tokens > 0 { self.total_output += output_tokens; }
349                let (in_price, out_price) = model_pricing(&self.model_name);
350                self.total_cost = (self.total_input as f64 * in_price
351                    + self.total_output as f64 * out_price) / 1_000_000.0;
352            }
353            EngineEvent::Error(e) => {
354                self.is_streaming = false;
355                self.conversation.messages.push(DisplayMessage {
356                    role: "error".to_string(), content: e,
357                    thinking: String::new(), tool_uses: Vec::new(), is_streaming: false,
358                });
359            }
360            EngineEvent::ModeChanged { mode } => {
361                self.permission_mode = mode.label().to_string();
362            }
363            EngineEvent::SubAgentProgress { label, summary } => {
364                match self.sub_agents.iter_mut().find(|(l, _)| l == &label) {
365                    Some((_, s)) => *s = summary.clone(),
366                    None => self.sub_agents.push((label.clone(), summary.clone())),
367                }
368                if let Some(m) = self.conversation.messages.last_mut() {
369                    if let Some(t) = m.tool_uses.iter_mut().rev()
370                        .find(|t| t.status == ToolUseStatus::Running
371                            && (t.name == "agent" || t.name == "explore"
372                                || t.name.starts_with("delegate_to_")))
373                    {
374                        t.sub_progress.push(format!("{label}: {summary}"));
375                        if t.sub_progress.len() > 50 {
376                            let drop = t.sub_progress.len() - 50;
377                            t.sub_progress.drain(0..drop);
378                        }
379                    }
380                }
381            }
382            EngineEvent::SubAgentDone { label } => {
383                // Surface a notify card when a delegated agent/intern finishes,
384                // carrying its last recorded action as the card body.
385                let last = self
386                    .sub_agents
387                    .iter()
388                    .find(|(l, _)| l == &label)
389                    .map(|(_, s)| s.clone())
390                    .filter(|s| !s.trim().is_empty());
391                self.sub_agents.retain(|(l, _)| l != &label);
392                let msg = match last {
393                    Some(s) => format!("{label} done\n{s}"),
394                    None => format!("{label} done"),
395                };
396                self.toasts.success(msg);
397            }
398            _ => {}
399        }
400    }
401}
402
403impl Default for AppState {
404    fn default() -> Self { Self::new() }
405}
406
407fn try_parse(json: &str) -> Option<serde_json::Value> {
408    if json.trim().is_empty() { return None; }
409    serde_json::from_str(json).ok()
410}
411
412fn shorten(s: &str, max: usize) -> String {
413    let trimmed = s.trim();
414    if trimmed.chars().count() <= max {
415        return trimmed.to_string();
416    }
417    let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect();
418    out.push('…');
419    out
420}
421
422fn first_line(s: &str) -> &str {
423    s.lines().next().unwrap_or("")
424}
425
426pub fn summarize_tool_input(tool: &str, json: &str) -> String {
427    let parsed = try_parse(json);
428    let get = |k: &str| -> String {
429        parsed
430            .as_ref()
431            .and_then(|v| v.get(k))
432            .and_then(|v| v.as_str())
433            .map(|s| s.to_string())
434            .unwrap_or_default()
435    };
436    match tool {
437        "bash" => {
438            if parsed.as_ref().and_then(|v| v.get("list")).and_then(|v| v.as_bool()).unwrap_or(false) {
439                return "list background processes".to_string();
440            }
441            if let Some(h) = parsed.as_ref().and_then(|v| v.get("kill")).and_then(|v| v.as_str()) {
442                return format!("kill {h}");
443            }
444            if let Some(h) = parsed.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()) {
445                return format!("status {h}");
446            }
447            let cmd = get("command");
448            if cmd.is_empty() { return String::new(); }
449            let bg = parsed.as_ref().and_then(|v| v.get("background")).and_then(|v| v.as_bool()).unwrap_or(false);
450            let suffix = if bg { "  &" } else { "" };
451            format!("$ {}{suffix}", shorten(first_line(&cmd), 140))
452        }
453        "read" => {
454            let path = get("file_path");
455            if path.is_empty() { return String::new(); }
456            let offset = parsed.as_ref().and_then(|v| v.get("offset")).and_then(|v| v.as_u64());
457            let limit = parsed.as_ref().and_then(|v| v.get("limit")).and_then(|v| v.as_u64());
458            match (offset, limit) {
459                (Some(o), Some(l)) => format!("{path}:{o}-{}", o + l),
460                (Some(o), None) => format!("{path}:{o}-"),
461                _ => path,
462            }
463        }
464        "file_write" => {
465            let path = get("file_path");
466            let content_len = parsed
467                .as_ref()
468                .and_then(|v| v.get("content"))
469                .and_then(|v| v.as_str())
470                .map(|s| s.lines().count())
471                .unwrap_or(0);
472            if path.is_empty() { return String::new(); }
473            if content_len > 0 { format!("{path}  ({content_len} lines)") } else { path }
474        }
475        "file_edit" => {
476            let path = get("file_path");
477            if path.is_empty() { return String::new(); }
478            path
479        }
480        "glob" => {
481            let pattern = get("pattern");
482            if pattern.is_empty() { return String::new(); }
483            shorten(&pattern, 140)
484        }
485        "grep" => {
486            let pattern = get("pattern");
487            let path = get("path");
488            let mut s = shorten(&pattern, 100);
489            if !path.is_empty() {
490                s.push_str(" in ");
491                s.push_str(&shorten(&path, 40));
492            }
493            s
494        }
495        "web_fetch" | "web_search" => {
496            let url = get("url");
497            let q = get("query");
498            if !url.is_empty() { shorten(&url, 140) } else { shorten(&q, 140) }
499        }
500        "delegate_to_intern" => {
501            let task = get("task");
502            shorten(first_line(&task), 140)
503        }
504        "agent" | "explore" => {
505            let task = get("task");
506            shorten(first_line(&task), 140)
507        }
508        "todo_write" | "todo_read" => String::new(),
509        _ => {
510
511            parsed
512                .as_ref()
513                .and_then(|v| v.as_object())
514                .and_then(|m| m.values().find_map(|v| v.as_str()))
515                .map(|s| shorten(first_line(s), 120))
516                .unwrap_or_default()
517        }
518    }
519}
520
521pub fn build_diff_for(tool: &str, input_json: &str) -> Vec<DiffLine> {
522    let Some(v) = try_parse(input_json) else { return Vec::new(); };
523    let max_lines = 14usize;
524    let context_lines = 2usize;
525
526    if tool == "file_write" {
527        let content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
528        return content
529            .lines()
530            .take(max_lines)
531            .map(|l| DiffLine {
532                kind: DiffLineKind::Added,
533                text: l.to_string(),
534            })
535            .collect();
536    }
537
538    let old_s = v.get("old_string").and_then(|s| s.as_str()).unwrap_or("");
539    let new_s = v.get("new_string").and_then(|s| s.as_str()).unwrap_or("");
540
541    let old_lines: Vec<&str> = old_s.split('\n').collect();
542    let new_lines: Vec<&str> = new_s.split('\n').collect();
543
544    let mut p = 0;
545    while p < old_lines.len() && p < new_lines.len() && old_lines[p] == new_lines[p] {
546        p += 1;
547    }
548
549    let mut s = 0;
550    while s < old_lines.len() - p && s < new_lines.len() - p
551        && old_lines[old_lines.len() - 1 - s] == new_lines[new_lines.len() - 1 - s]
552    {
553        s += 1;
554    }
555
556    let mut out: Vec<DiffLine> = Vec::new();
557
558    let ctx_start = p.saturating_sub(context_lines);
559    for line in &old_lines[ctx_start..p] {
560        out.push(DiffLine { kind: DiffLineKind::Context, text: line.to_string() });
561    }
562
563    for line in &old_lines[p..old_lines.len() - s] {
564        out.push(DiffLine { kind: DiffLineKind::Removed, text: line.to_string() });
565        if out.len() >= max_lines { return out; }
566    }
567
568    for line in &new_lines[p..new_lines.len() - s] {
569        out.push(DiffLine { kind: DiffLineKind::Added, text: line.to_string() });
570        if out.len() >= max_lines { return out; }
571    }
572
573    let ctx_end_start = old_lines.len() - s;
574    let ctx_end_stop = (ctx_end_start + context_lines).min(old_lines.len());
575    for line in &old_lines[ctx_end_start..ctx_end_stop] {
576        out.push(DiffLine { kind: DiffLineKind::Context, text: line.to_string() });
577        if out.len() >= max_lines { return out; }
578    }
579
580    out
581}
582
583pub fn excerpt_lines(text: &str, max_lines: usize, max_width: usize) -> Vec<String> {
584    let mut lines: Vec<String> = text
585        .lines()
586        .filter(|l| !l.trim().is_empty())
587        .take(max_lines + 1)
588        .map(|l| {
589            if l.chars().count() > max_width {
590                let mut s: String = l.chars().take(max_width.saturating_sub(1)).collect();
591                s.push('…');
592                s
593            } else {
594                l.to_string()
595            }
596        })
597        .collect();
598    let total = text.lines().filter(|l| !l.trim().is_empty()).count();
599    if total > max_lines {
600        lines.truncate(max_lines);
601        lines.push(format!("… +{} more lines", total - max_lines));
602    }
603    lines
604}