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 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 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 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 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 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 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 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 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 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}