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