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#[derive(Clone)]
5pub struct SessionSummary {
6    pub id: String,
7    pub title: String,
8    pub updated_at: u64,
9    pub pinned: bool,
10}
11
12pub struct SidebarState {
13    pub visible: bool,
14    pub title: String,
15    pub session_id: String,
16    pub version: String,
17    pub sessions: Vec<SessionSummary>,
18}
19
20impl SidebarState {
21    pub fn new() -> Self {
22        Self {
23            visible: true,
24            title: "New session".to_string(),
25            session_id: String::new(),
26            version: env!("CARGO_PKG_VERSION").to_string(),
27            sessions: Vec::new(),
28        }
29    }
30}
31
32impl Default for SidebarState {
33    fn default() -> Self { Self::new() }
34}
35
36pub struct AppState {
37    pub input: InputState,
38    pub conversation: ConversationState,
39    pub modal: ModalState,
40    pub sidebar: SidebarState,
41    pub toasts: ToastState,
42    pub model_name: String,
43    pub permission_mode: String,
44    pub total_cost: f64,
45    pub git_branch: Option<String>,
46    pub cwd: String,
47    pub is_streaming: bool,
48    pub spinner_frame: usize,
49    pub spinner_tick: u8,
50    pub total_input: u64,
51    pub total_output: u64,
52    pub recent_models: Vec<String>,
53    pub tool_details: bool,
54
55    pub live_thinking: String,
56
57    pub sub_agents: Vec<(String, String)>,
58
59    pub last_summary: Option<String>,
60}
61
62impl AppState {
63    pub fn push_recent_model(&mut self, id: &str) {
64        self.recent_models.retain(|m| m != id);
65        self.recent_models.insert(0, id.to_string());
66        if self.recent_models.len() > 8 {
67            self.recent_models.truncate(8);
68        }
69    }
70
71    pub fn cycle_recent_model(&mut self) -> Option<String> {
72        if self.recent_models.len() < 2 {
73            return None;
74        }
75        let next = self.recent_models.remove(1);
76        self.recent_models.insert(0, next.clone());
77        Some(next)
78    }
79}
80
81impl AppState {
82    pub fn new() -> Self {
83        Self {
84            input: InputState::new(),
85            conversation: ConversationState::new(),
86            modal: ModalState::new(),
87            sidebar: SidebarState::new(),
88            toasts: ToastState::new(),
89            model_name: String::from("claude-sonnet-4-20250514"),
90            permission_mode: String::from("Normal"),
91            total_cost: 0.0,
92            git_branch: None,
93            cwd: std::env::current_dir().ok()
94                .and_then(|p| p.to_str().map(|s| s.to_string()))
95                .unwrap_or_default(),
96            is_streaming: false,
97            spinner_frame: 0,
98            spinner_tick: 0,
99            total_input: 0,
100            total_output: 0,
101            recent_models: Vec::new(),
102            tool_details: true,
103            live_thinking: String::new(),
104            sub_agents: Vec::new(),
105            last_summary: None,
106        }
107    }
108
109    pub fn push_user_message(&mut self, text: impl Into<String>) {
110        self.last_summary = None;
111        self.conversation.messages.push(DisplayMessage {
112            role: "user".to_string(),
113            content: text.into(),
114            thinking: String::new(),
115            tool_uses: Vec::new(),
116            is_streaming: false,
117        });
118        self.conversation.auto_scroll = true;
119    }
120
121    pub fn push_system_message(&mut self, text: impl Into<String>) {
122        self.conversation.messages.push(DisplayMessage {
123            role: "system".to_string(),
124            content: text.into(),
125            thinking: String::new(),
126            tool_uses: Vec::new(),
127            is_streaming: false,
128        });
129        self.conversation.auto_scroll = true;
130    }
131
132    pub fn apply_engine_event(&mut self, event: EngineEvent) {
133        match event {
134            EngineEvent::TextDelta(text) => {
135                self.is_streaming = true;
136                match self.conversation.messages.last_mut() {
137                    Some(m) if m.role == "assistant" && m.is_streaming => m.content.push_str(&text),
138                    _ => self.conversation.messages.push(DisplayMessage {
139                        role: "assistant".to_string(), content: text,
140                        thinking: String::new(), tool_uses: Vec::new(), is_streaming: true,
141                    }),
142                }
143            }
144            EngineEvent::ThinkingDelta(text) => {
145                self.is_streaming = true;
146                self.live_thinking.push_str(&text);
147            }
148            EngineEvent::ToolStart { name, .. } => {
149                self.is_streaming = true;
150                let tool = DisplayToolUse {
151                    name,
152                    status: ToolUseStatus::Running,
153                    output_preview: String::new(),
154                    input_json: String::new(),
155                    input_summary: String::new(),
156                    output_excerpt: Vec::new(),
157                    diff: Vec::new(),
158                    sub_progress: Vec::new(),
159                };
160                match self.conversation.messages.last_mut().filter(|m| m.role == "assistant") {
161                    Some(m) => m.tool_uses.push(tool),
162                    None => self.conversation.messages.push(DisplayMessage {
163                        role: "assistant".to_string(), content: String::new(),
164                        thinking: String::new(), tool_uses: vec![tool], is_streaming: true,
165                    }),
166                }
167            }
168            EngineEvent::ToolInput { json_chunk } => {
169                if let Some(m) = self.conversation.messages.last_mut() {
170                    if let Some(t) = m.tool_uses.iter_mut().rev()
171                        .find(|t| t.status == ToolUseStatus::Running)
172                    {
173                        t.input_json.push_str(&json_chunk);
174                        t.input_summary = summarize_tool_input(&t.name, &t.input_json);
175                    }
176                }
177            }
178            EngineEvent::ToolResult { name, output, is_error } => {
179                let clean_output = crate::util::strip_ansi(&output);
180                let preview_limit = if is_error { 400 } else { 80 };
181                if let Some(m) = self.conversation.messages.last_mut() {
182                    if let Some(t) = m.tool_uses.iter_mut().rev()
183                        .find(|t| t.name == name && t.status == ToolUseStatus::Running) {
184                        t.status = if is_error { ToolUseStatus::Error } else { ToolUseStatus::Completed };
185                        t.output_preview = clean_output.lines().next().unwrap_or("").chars().take(preview_limit).collect();
186                        if t.input_summary.is_empty() {
187                            t.input_summary = summarize_tool_input(&t.name, &t.input_json);
188                        }
189                        t.output_excerpt = excerpt_lines(&clean_output, 6, 200);
190                        if t.name == "file_edit" || t.name == "file_write" {
191                            t.diff = build_diff_for(&t.name, &t.input_json);
192                        }
193
194                        if matches!(t.name.as_str(), "read" | "grep" | "glob") && !is_error {
195                            let n = clean_output.lines().filter(|l| !l.trim().is_empty()).count();
196                            if n > 0 && !t.input_summary.contains("(")  {
197                                t.input_summary = format!("{}  ({n} lines)", t.input_summary);
198                            }
199                        }
200                    }
201                }
202                if is_error {
203                    self.conversation.messages.push(DisplayMessage {
204                        role: "error".to_string(),
205                        content: format!("{name}: {clean_output}"),
206                        thinking: String::new(),
207                        tool_uses: Vec::new(),
208                        is_streaming: false,
209                    });
210                    tracing::error!(tool = %name, output = %clean_output, "tool returned error");
211                }
212            }
213            EngineEvent::TurnComplete => {
214                self.is_streaming = false;
215                let tool_summary = self.conversation.messages.last().and_then(|m| {
216                    if m.role != "assistant" { return None; }
217                    if m.tool_uses.is_empty() { return None; }
218                    let parts: Vec<String> = m.tool_uses.iter().map(|t| {
219                        let pretty = match t.name.as_str() {
220                            "bash" => "Bash".into(),
221                            "read" => "Read".into(),
222                            "file_write" => "Write".into(),
223                            "file_edit" => "Edit".into(),
224                            "glob" => "Glob".into(),
225                            "grep" => "Grep".into(),
226                            "web_fetch" => "WebFetch".into(),
227                            "web_search" => "WebSearch".into(),
228                            "todo_write" => "TodoWrite".into(),
229                            "todo_read" => "TodoRead".into(),
230                            "ask_user_question" => "AskUser".into(),
231                            "agent" => "Agent".into(),
232                            other => {
233                                let mut s = other.replace('_', " ");
234                                s = s.split_whitespace()
235                                    .map(|w| { let mut c = w.chars(); c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default() })
236                                    .collect::<Vec<_>>().join("");
237                                s
238                            }
239                        };
240                        if t.input_summary.is_empty() {
241                            pretty
242                        } else {
243                            format!("{}({})", pretty, t.input_summary)
244                        }
245                    }).collect();
246                    Some(parts.join(", "))
247                });
248                if let Some(m) = self.conversation.messages.last_mut() {
249                    m.is_streaming = false;
250                    if !self.live_thinking.is_empty() && m.role == "assistant" {
251                        if m.thinking.is_empty() {
252                            m.thinking = std::mem::take(&mut self.live_thinking);
253                        } else {
254                            m.thinking.push_str(&self.live_thinking);
255                            self.live_thinking.clear();
256                        }
257                    }
258                }
259                self.live_thinking.clear();
260                if let Some(summary) = tool_summary {
261                    self.last_summary = Some(summary.clone());
262                    self.conversation.messages.push(DisplayMessage {
263                        role: "done".to_string(),
264                        content: summary,
265                        thinking: String::new(),
266                        tool_uses: Vec::new(),
267                        is_streaming: false,
268                    });
269                    self.conversation.auto_scroll = true;
270                }
271            }
272            EngineEvent::Usage { input_tokens, output_tokens } => {
273                if input_tokens > 0 { self.total_input += input_tokens; }
274                if output_tokens > 0 { self.total_output += output_tokens; }
275                self.total_cost = (self.total_input as f64 * 3.0 + self.total_output as f64 * 15.0) / 1_000_000.0;
276            }
277            EngineEvent::Error(e) => {
278                self.is_streaming = false;
279                self.conversation.messages.push(DisplayMessage {
280                    role: "error".to_string(), content: e,
281                    thinking: String::new(), tool_uses: Vec::new(), is_streaming: false,
282                });
283            }
284            EngineEvent::ModeChanged { mode } => {
285                self.permission_mode = mode.label().to_string();
286            }
287            EngineEvent::SubAgentProgress { label, summary } => {
288                match self.sub_agents.iter_mut().find(|(l, _)| l == &label) {
289                    Some((_, s)) => *s = summary.clone(),
290                    None => self.sub_agents.push((label.clone(), summary.clone())),
291                }
292                if let Some(m) = self.conversation.messages.last_mut() {
293                    if let Some(t) = m.tool_uses.iter_mut().rev()
294                        .find(|t| t.status == ToolUseStatus::Running
295                            && (t.name == "agent" || t.name == "explore"
296                                || t.name.starts_with("delegate_to_")))
297                    {
298                        t.sub_progress.push(format!("{label}: {summary}"));
299                        if t.sub_progress.len() > 50 {
300                            let drop = t.sub_progress.len() - 50;
301                            t.sub_progress.drain(0..drop);
302                        }
303                    }
304                }
305            }
306            EngineEvent::SubAgentDone { label } => {
307                self.sub_agents.retain(|(l, _)| l != &label);
308            }
309            _ => {}
310        }
311    }
312}
313
314impl Default for AppState {
315    fn default() -> Self { Self::new() }
316}
317
318fn try_parse(json: &str) -> Option<serde_json::Value> {
319    if json.trim().is_empty() { return None; }
320    serde_json::from_str(json).ok()
321}
322
323fn shorten(s: &str, max: usize) -> String {
324    let trimmed = s.trim();
325    if trimmed.chars().count() <= max {
326        return trimmed.to_string();
327    }
328    let mut out: String = trimmed.chars().take(max.saturating_sub(1)).collect();
329    out.push('…');
330    out
331}
332
333fn first_line(s: &str) -> &str {
334    s.lines().next().unwrap_or("")
335}
336
337pub fn summarize_tool_input(tool: &str, json: &str) -> String {
338    let parsed = try_parse(json);
339    let get = |k: &str| -> String {
340        parsed
341            .as_ref()
342            .and_then(|v| v.get(k))
343            .and_then(|v| v.as_str())
344            .map(|s| s.to_string())
345            .unwrap_or_default()
346    };
347    match tool {
348        "bash" => {
349            if parsed.as_ref().and_then(|v| v.get("list")).and_then(|v| v.as_bool()).unwrap_or(false) {
350                return "list background processes".to_string();
351            }
352            if let Some(h) = parsed.as_ref().and_then(|v| v.get("kill")).and_then(|v| v.as_str()) {
353                return format!("kill {h}");
354            }
355            if let Some(h) = parsed.as_ref().and_then(|v| v.get("status")).and_then(|v| v.as_str()) {
356                return format!("status {h}");
357            }
358            let cmd = get("command");
359            if cmd.is_empty() { return String::new(); }
360            let bg = parsed.as_ref().and_then(|v| v.get("background")).and_then(|v| v.as_bool()).unwrap_or(false);
361            let suffix = if bg { "  &" } else { "" };
362            format!("$ {}{suffix}", shorten(first_line(&cmd), 140))
363        }
364        "read" => {
365            let path = get("file_path");
366            if path.is_empty() { return String::new(); }
367            let offset = parsed.as_ref().and_then(|v| v.get("offset")).and_then(|v| v.as_u64());
368            let limit = parsed.as_ref().and_then(|v| v.get("limit")).and_then(|v| v.as_u64());
369            match (offset, limit) {
370                (Some(o), Some(l)) => format!("{path}:{o}-{}", o + l),
371                (Some(o), None) => format!("{path}:{o}-"),
372                _ => path,
373            }
374        }
375        "file_write" => {
376            let path = get("file_path");
377            let content_len = parsed
378                .as_ref()
379                .and_then(|v| v.get("content"))
380                .and_then(|v| v.as_str())
381                .map(|s| s.lines().count())
382                .unwrap_or(0);
383            if path.is_empty() { return String::new(); }
384            if content_len > 0 { format!("{path}  ({content_len} lines)") } else { path }
385        }
386        "file_edit" => {
387            let path = get("file_path");
388            if path.is_empty() { return String::new(); }
389            path
390        }
391        "glob" => {
392            let pattern = get("pattern");
393            if pattern.is_empty() { return String::new(); }
394            shorten(&pattern, 140)
395        }
396        "grep" => {
397            let pattern = get("pattern");
398            let path = get("path");
399            let mut s = shorten(&pattern, 100);
400            if !path.is_empty() {
401                s.push_str(" in ");
402                s.push_str(&shorten(&path, 40));
403            }
404            s
405        }
406        "web_fetch" | "web_search" => {
407            let url = get("url");
408            let q = get("query");
409            if !url.is_empty() { shorten(&url, 140) } else { shorten(&q, 140) }
410        }
411        "delegate_to_intern" => {
412            let task = get("task");
413            shorten(first_line(&task), 140)
414        }
415        "agent" | "explore" => {
416            let task = get("task");
417            shorten(first_line(&task), 140)
418        }
419        "todo_write" | "todo_read" => String::new(),
420        _ => {
421
422            parsed
423                .as_ref()
424                .and_then(|v| v.as_object())
425                .and_then(|m| m.values().find_map(|v| v.as_str()))
426                .map(|s| shorten(first_line(s), 120))
427                .unwrap_or_default()
428        }
429    }
430}
431
432pub fn build_diff_for(tool: &str, input_json: &str) -> Vec<DiffLine> {
433    let Some(v) = try_parse(input_json) else { return Vec::new(); };
434    let max_lines = 14usize;
435    let context_lines = 2usize;
436
437    if tool == "file_write" {
438        let content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
439        return content
440            .lines()
441            .take(max_lines)
442            .map(|l| DiffLine {
443                kind: DiffLineKind::Added,
444                text: l.to_string(),
445            })
446            .collect();
447    }
448
449    let old_s = v.get("old_string").and_then(|s| s.as_str()).unwrap_or("");
450    let new_s = v.get("new_string").and_then(|s| s.as_str()).unwrap_or("");
451
452    let old_lines: Vec<&str> = old_s.split('\n').collect();
453    let new_lines: Vec<&str> = new_s.split('\n').collect();
454
455    let mut p = 0;
456    while p < old_lines.len() && p < new_lines.len() && old_lines[p] == new_lines[p] {
457        p += 1;
458    }
459
460    let mut s = 0;
461    while s < old_lines.len() - p && s < new_lines.len() - p
462        && old_lines[old_lines.len() - 1 - s] == new_lines[new_lines.len() - 1 - s]
463    {
464        s += 1;
465    }
466
467    let mut out: Vec<DiffLine> = Vec::new();
468
469    let ctx_start = p.saturating_sub(context_lines);
470    for line in &old_lines[ctx_start..p] {
471        out.push(DiffLine { kind: DiffLineKind::Context, text: line.to_string() });
472    }
473
474    for line in &old_lines[p..old_lines.len() - s] {
475        out.push(DiffLine { kind: DiffLineKind::Removed, text: line.to_string() });
476        if out.len() >= max_lines { return out; }
477    }
478
479    for line in &new_lines[p..new_lines.len() - s] {
480        out.push(DiffLine { kind: DiffLineKind::Added, text: line.to_string() });
481        if out.len() >= max_lines { return out; }
482    }
483
484    let ctx_end_start = old_lines.len() - s;
485    let ctx_end_stop = (ctx_end_start + context_lines).min(old_lines.len());
486    for line in &old_lines[ctx_end_start..ctx_end_stop] {
487        out.push(DiffLine { kind: DiffLineKind::Context, text: line.to_string() });
488        if out.len() >= max_lines { return out; }
489    }
490
491    out
492}
493
494pub fn excerpt_lines(text: &str, max_lines: usize, max_width: usize) -> Vec<String> {
495    let mut lines: Vec<String> = text
496        .lines()
497        .filter(|l| !l.trim().is_empty())
498        .take(max_lines + 1)
499        .map(|l| {
500            if l.chars().count() > max_width {
501                let mut s: String = l.chars().take(max_width.saturating_sub(1)).collect();
502                s.push('…');
503                s
504            } else {
505                l.to_string()
506            }
507        })
508        .collect();
509    let total = text.lines().filter(|l| !l.trim().is_empty()).count();
510    if total > max_lines {
511        lines.truncate(max_lines);
512        lines.push(format!("… +{} more lines", total - max_lines));
513    }
514    lines
515}