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