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