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}