1use stynx_code_types::EngineEvent;
2use super::{ConversationState, DiffLine, DiffLineKind, DisplayMessage, DisplayToolUse, InputState, ModalState, ToastState, ToolUseStatus};
3
4fn model_pricing(model: &str) -> (f64, f64) {
10 let m = model.to_ascii_lowercase();
11 match () {
12 _ if m.contains("deepseek-reasoner") || m.contains("deepseek-r1") => (0.55, 2.19),
14 _ if m.contains("deepseek") => (0.27, 1.10),
15 _ 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 _ if m.contains("gpt-4o-mini") => (0.15, 0.60),
21 _ if m.contains("gpt-4o") => (2.50, 10.0),
22 _ => (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 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 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 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}