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