ive harnessed the harness
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix tools, show tools inline reasoning and show reflection in the tui history itself

dawn d8989424 048b86bf

+353 -253
+42 -20
klbr-core/src/agent.rs
··· 90 90 91 91 // tool loop: keep calling the model until it produces a plain text response 92 92 let mut tool_iterations = 0usize; 93 + let mut accumulated_thinking = String::new(); 93 94 const MAX_TOOL_ITERATIONS: usize = 20; 95 + 96 + // emit Started once — all tokens/thinking for the whole turn go into one bubble 97 + let _ = output.send(AgentEvent::Started); 94 98 95 99 loop { 96 100 let (tok_tx, mut tok_rx) = mpsc::channel(256); ··· 100 104 tokio::spawn(async move { 101 105 let _ = llm2.stream(&msgs, &defs, tok_tx).await; 102 106 }); 103 - let _ = output.send(AgentEvent::Started); 104 107 105 108 let mut response = String::new(); 106 109 let mut thinking = String::new(); ··· 110 113 match ev { 111 114 LlmEvent::ThinkToken(tok) => { 112 115 thinking.push_str(&tok); 116 + accumulated_thinking.push_str(&tok); 113 117 let _ = output.send(AgentEvent::ThinkToken(tok)); 114 118 } 115 119 LlmEvent::Token(tok) => { ··· 127 131 128 132 if !tool_calls.is_empty() && tool_iterations < MAX_TOOL_ITERATIONS { 129 133 tool_iterations += 1; 130 - ctx.push_assistant_tool_calls(tool_calls.clone()); 134 + let text_content = if response.is_empty() { 135 + None 136 + } else { 137 + Some(response.clone()) 138 + }; 139 + ctx.push_assistant_tool_calls(tool_calls.clone(), text_content); 131 140 132 141 for call in &tool_calls { 133 142 let name = call.function.name.clone(); ··· 146 155 147 156 ctx.push_tool_result(&call.id, &result); 148 157 } 149 - 150 - // loop back to let the model process tool results 151 158 continue; 152 159 } 153 160 154 161 // plain text response (or tool limit hit) — wrap up the turn 155 162 ctx.push_assistant(&response); 156 - let thinking_ref = (!thinking.is_empty()).then_some(thinking.as_str()); 163 + let thinking_ref = 164 + (!accumulated_thinking.is_empty()).then_some(accumulated_thinking.as_str()); 157 165 let _ = memory.log_turn("assistant", &response, thinking_ref); 158 166 turn_count += 1; 159 167 ··· 190 198 output: &broadcast::Sender<AgentEvent>, 191 199 ) -> Result<()> { 192 200 // run reflection before draining so the agent can curate memories 193 - let _ = output.send(AgentEvent::Status("reflecting...".into())); 194 - if let Err(e) = reflect(tool_ctx, ctx).await { 201 + if let Err(e) = reflect(tool_ctx, ctx, output).await { 195 202 tracing::warn!(err = %e, "reflection failed"); 196 203 } 197 204 ··· 234 241 235 242 /// ephemeral reflection loop: let the agent review and curate its memories 236 243 /// without touching the main conversation context 237 - async fn reflect(tool_ctx: &ToolContext, ctx: &Context) -> Result<()> { 244 + async fn reflect( 245 + tool_ctx: &ToolContext, 246 + ctx: &Context, 247 + output: &broadcast::Sender<AgentEvent>, 248 + ) -> Result<()> { 238 249 let pinned = tool_ctx.memory.pinned_memories().unwrap_or_default(); 239 250 let unpinned = tool_ctx.memory.recent_unpinned(20).unwrap_or_default(); 240 251 ··· 274 285 .take(10) 275 286 .filter_map(|m| { 276 287 let content = m.content.as_deref()?; 277 - let snippet = if content.len() > 120 { 278 - format!("{}...", &content[..120]) 279 - } else { 280 - content.to_string() 281 - }; 282 - Some(format!("{}: {snippet}", m.role)) 288 + Some(format!("{}: {content}", m.role)) 283 289 }) 284 290 .collect::<Vec<_>>() 285 291 .into_iter() ··· 301 307 let reflect_registry = tools::memory_tools(); 302 308 let mut msgs = vec![Message::user(reflection_prompt)]; 303 309 304 - // mini tool loop, max 6 iterations 305 - for _ in 0..6 { 306 - let (tok_tx, mut tok_rx) = mpsc::channel(128); 310 + let _ = output.send(AgentEvent::ReflectStarted); 311 + 312 + for _ in 0..20 { 313 + let (tok_tx, mut tok_rx) = mpsc::channel(512); 307 314 let llm2 = tool_ctx.llm.clone(); 308 315 let msgs_snap = msgs.clone(); 309 316 let defs_snap = reflect_registry.definitions(); ··· 312 319 }); 313 320 314 321 let mut tool_calls = vec![]; 315 - let mut response = String::new(); 316 322 while let Some(ev) = tok_rx.recv().await { 317 323 match ev { 318 324 LlmEvent::ToolCalls(calls) => tool_calls = calls, 319 - LlmEvent::Token(t) => response.push_str(&t), 325 + LlmEvent::Token(t) => { 326 + let _ = output.send(AgentEvent::Token(t)); 327 + } 328 + LlmEvent::ThinkToken(t) => { 329 + let _ = output.send(AgentEvent::ThinkToken(t)); 330 + } 320 331 _ => {} 321 332 } 322 333 } ··· 325 336 break; 326 337 } 327 338 328 - msgs.push(Message::with_tool_calls(tool_calls.clone())); 339 + msgs.push(Message::with_tool_calls(tool_calls.clone(), None)); 329 340 for call in &tool_calls { 341 + let name = call.function.name.clone(); 342 + let args = call.function.arguments.clone(); 343 + let _ = output.send(AgentEvent::ToolCall { 344 + name: name.clone(), 345 + args: args.clone(), 346 + }); 330 347 let result = reflect_registry.execute(call, tool_ctx).await; 348 + let _ = output.send(AgentEvent::ToolResult { 349 + name: name.clone(), 350 + content: result.clone(), 351 + }); 331 352 msgs.push(Message::tool_result(&call.id, &result)); 332 353 } 333 354 } 334 355 356 + let _ = output.send(AgentEvent::ReflectDone); 335 357 Ok(()) 336 358 }
+6 -4
klbr-core/src/context.rs
··· 90 90 self.turns.push(Message::assistant(content.to_string())); 91 91 } 92 92 93 - /// push an assistant message that contains tool calls (no text content) 94 - pub fn push_assistant_tool_calls(&mut self, calls: Vec<ToolCall>) { 95 - self.turns.push(Message::with_tool_calls(calls)); 93 + /// push an assistant message that contains tool calls, optionally with text 94 + /// content the model emitted before deciding to call tools. 95 + pub fn push_assistant_tool_calls(&mut self, calls: Vec<ToolCall>, content: Option<String>) { 96 + self.turns.push(Message::with_tool_calls(calls, content)); 96 97 } 97 98 98 - /// push a tool result back into the context 99 + /// push a tool result back into the context as a separate role:"tool" message. 100 + /// LM Studio converts these to Gemma 4's bundled tool_responses format internally. 99 101 pub fn push_tool_result(&mut self, tool_call_id: &str, content: &str) { 100 102 self.turns.push(Message::tool_result(tool_call_id, content)); 101 103 }
+12 -2
klbr-core/src/lib.rs
··· 24 24 Token(String), 25 25 ThinkToken(String), 26 26 Done, 27 + /// reflection loop started (between turns, during compaction) 28 + ReflectStarted, 29 + /// reflection loop finished 30 + ReflectDone, 27 31 Status(String), 28 32 Metrics(AgentMetrics), 29 - ToolCall { name: String, args: String }, 30 - ToolResult { name: String, content: String }, 33 + ToolCall { 34 + name: String, 35 + args: String, 36 + }, 37 + ToolResult { 38 + name: String, 39 + content: String, 40 + }, 31 41 }
+2 -2
klbr-core/src/llm.rs
··· 63 63 } 64 64 } 65 65 66 - pub fn with_tool_calls(calls: Vec<ToolCall>) -> Self { 66 + pub fn with_tool_calls(calls: Vec<ToolCall>, content: Option<String>) -> Self { 67 67 Self { 68 68 role: "assistant".into(), 69 - content: None, 69 + content, 70 70 tool_calls: Some(calls), 71 71 tool_call_id: None, 72 72 }
+2 -3
klbr-core/src/memory.rs
··· 290 290 "SELECT m.id, m.content, m.tags, v.distance 291 291 FROM vec_memories v 292 292 JOIN memories m ON m.id = v.rowid 293 - WHERE v.embedding MATCH ?1 294 - ORDER BY v.distance 295 - LIMIT ?2", 293 + WHERE v.embedding MATCH ?1 AND k = ?2 294 + ORDER BY v.distance", 296 295 )?; 297 296 let rows = stmt.query_map(params![blob, k as i64], |row| { 298 297 Ok((
+6 -3
klbr-core/src/tools/recall.rs
··· 14 14 fn definition() -> ToolDef { 15 15 ToolDef::function( 16 16 "recall", 17 - "semantic search over long-term memory. \ 17 + "semantic search over long-term memory (facts stored across sessions). \ 18 + does NOT search the current conversation — use this only for things you \ 19 + might have stored previously with remember(). \ 20 + use a specific, noun-heavy query like \"user's preferred editor\" not \ 21 + \"what we just discussed\". \ 18 22 with no tags: searches all memories by meaning. \ 19 23 with tags: searches only within memories that match those tags, \ 20 - ranked by semantic similarity (never misses a tag-matched memory due to global ranking). \ 21 - provide at least a query.", 24 + ranked by semantic similarity.", 22 25 json!({ 23 26 "type": "object", 24 27 "properties": {
+12 -2
klbr-daemon/src/daemon.rs
··· 138 138 } 139 139 } 140 140 } 141 - ev = rx.recv() => { 142 - let ev = ev?; 141 + res = rx.recv() => { 142 + let ev = match res { 143 + Ok(ev) => ev, 144 + Err(broadcast::error::RecvError::Lagged(n)) => { 145 + tracing::warn!(n, "client lagged, events dropped"); 146 + continue; 147 + } 148 + Err(broadcast::error::RecvError::Closed) => break, 149 + }; 150 + 143 151 let msg = match ev { 144 152 AgentEvent::Started => ServerMsg::Started, 145 153 AgentEvent::Token(content) => ServerMsg::Token { content }, 146 154 AgentEvent::ThinkToken(content) => ServerMsg::ThinkToken { content }, 147 155 AgentEvent::Done => ServerMsg::Done, 156 + AgentEvent::ReflectStarted => ServerMsg::ReflectStarted, 157 + AgentEvent::ReflectDone => ServerMsg::ReflectDone, 148 158 AgentEvent::Status(content) => ServerMsg::Status { content }, 149 159 AgentEvent::Metrics(m) => ServerMsg::Metrics { 150 160 turn_count: m.turn_count,
+1 -1
klbr-daemon/src/main.rs
··· 15 15 let snapshot = Arc::new(RwLock::new(None)) as MetricsSnapshot; 16 16 17 17 let (interrupt_tx, interrupt_rx) = mpsc::channel(32); 18 - let (output_tx, _) = broadcast::channel::<AgentEvent>(256); 18 + let (output_tx, _) = broadcast::channel::<AgentEvent>(4096); 19 19 20 20 let history_window = config.compaction_keep; 21 21 let agent = tokio::spawn(agent::run(
+2
klbr-ipc/src/lib.rs
··· 34 34 content: String, 35 35 }, 36 36 Done, 37 + ReflectStarted, 38 + ReflectDone, 37 39 Status { 38 40 content: String, 39 41 },
+268 -216
klbr-tui/src/main.rs
··· 25 25 26 26 use klbr_ipc::{sock_path, ClientMsg, ServerMsg}; 27 27 28 - // ── message model ──────────────────────────────────────────────────────────── 28 + #[derive(Clone, Default)] 29 + struct ToolCall { 30 + name: String, 31 + args: String, 32 + result: Option<String>, 33 + } 29 34 30 - #[derive(Default, Clone)] 31 - struct Reason { 32 - content: String, 33 - expanded: bool, 34 - done: bool, 35 + /// interleaved items inside an assistant turn, in arrival order 36 + #[derive(Clone)] 37 + enum AssistantItem { 38 + Think(String), 39 + Tool(ToolCall), 40 + Text(String), 35 41 } 36 42 37 - #[derive(Clone)] 43 + #[derive(Clone, PartialEq, Eq)] 38 44 enum AssistantStep { 39 45 PromptProcessing, 40 46 Reasoning, ··· 44 50 45 51 #[derive(Clone)] 46 52 enum Role { 47 - System, 48 - User, 53 + System { 54 + content: String, 55 + }, 56 + User { 57 + content: String, 58 + }, 49 59 Assistant { 50 - reason: Option<Reason>, 60 + items: Vec<AssistantItem>, 51 61 step: AssistantStep, 52 - }, 53 - /// a tool call/result pair shown inline during generation 54 - Tool { 55 - name: String, 56 - args: String, 57 - result: Option<String>, 62 + thinking_expanded: bool, 63 + /// true for background reflection turns 64 + is_reflect: bool, 58 65 }, 59 66 } 60 67 ··· 62 69 fn is_assistant(&self) -> bool { 63 70 matches!(self, Self::Assistant { .. }) 64 71 } 65 - 66 - fn if_assistant<F, T>(&mut self, f: F) -> Option<T> 67 - where 68 - F: FnOnce(&mut Option<Reason>, &mut AssistantStep) -> T, 69 - { 70 - match self { 71 - Role::Assistant { reason, step } => Some(f(reason, step)), 72 - _ => None, 73 - } 74 - } 75 72 } 76 73 77 74 #[derive(Clone)] 78 75 struct ChatMsg { 79 76 role: Role, 80 - content: String, 81 - expanded: bool, 82 77 } 83 78 84 79 impl ChatMsg { 85 80 fn user(content: String) -> Self { 86 81 Self { 87 - role: Role::User, 88 - content, 89 - expanded: true, 82 + role: Role::User { content }, 90 83 } 91 84 } 92 85 93 86 fn system(content: String) -> Self { 94 87 Self { 95 - role: Role::System, 96 - content, 97 - expanded: true, 88 + role: Role::System { content }, 98 89 } 99 90 } 100 91 101 92 fn assistant() -> Self { 102 93 Self { 103 94 role: Role::Assistant { 95 + items: vec![], 104 96 step: AssistantStep::PromptProcessing, 105 - reason: None, 97 + thinking_expanded: false, 98 + is_reflect: false, 99 + }, 100 + } 101 + } 102 + 103 + fn reflect() -> Self { 104 + Self { 105 + role: Role::Assistant { 106 + items: vec![], 107 + step: AssistantStep::PromptProcessing, 108 + thinking_expanded: false, 109 + is_reflect: true, 106 110 }, 107 - content: String::new(), 108 - expanded: false, 109 111 } 110 112 } 111 113 } ··· 221 223 "user" => Some(ChatMsg::user(entry.content)), 222 224 "system" => Some(ChatMsg::system(entry.content)), 223 225 "assistant" => { 224 - let mut m = ChatMsg::assistant(); 225 - m.content = entry.content; 226 - m.role = Role::Assistant { 227 - reason: entry.reasoning.map(|content| Reason { 228 - content, 229 - expanded: false, 230 - done: true, 231 - }), 232 - step: AssistantStep::Done, 233 - }; 234 - Some(m) 226 + let mut items = vec![]; 227 + if let Some(t) = entry.reasoning.filter(|s| !s.is_empty()) { 228 + items.push(AssistantItem::Think(t)); 229 + } 230 + if !entry.content.is_empty() { 231 + items.push(AssistantItem::Text(entry.content)); 232 + } 233 + Some(ChatMsg { 234 + role: Role::Assistant { 235 + items, 236 + step: AssistantStep::Done, 237 + thinking_expanded: false, 238 + is_reflect: false, 239 + }, 240 + }) 235 241 } 236 242 _ => None, 237 243 } ··· 251 257 // gets the last assistant message that we are streaming 252 258 fn streaming_mut(&mut self) -> Option<&mut ChatMsg> { 253 259 self.history.last_mut().filter(|m| { 254 - matches!( 255 - m.role, 256 - Role::Assistant { 257 - step: AssistantStep::PromptProcessing 258 - | AssistantStep::Reasoning 259 - | AssistantStep::Response, 260 - .. 261 - } 262 - ) 260 + if let Role::Assistant { step, .. } = &m.role { 261 + *step != AssistantStep::Done 262 + } else { 263 + false 264 + } 263 265 }) 264 266 } 265 267 ··· 273 275 return; 274 276 }; 275 277 276 - m.role.if_assistant(|r, _| { 277 - if let Some(r) = r { 278 - r.expanded = !r.expanded; 279 - } 280 - }); 278 + if let Role::Assistant { 279 + thinking_expanded, .. 280 + } = &mut m.role 281 + { 282 + *thinking_expanded = !*thinking_expanded; 283 + } 281 284 } 282 285 283 286 fn token_entry(&mut self) -> &mut ChatMsg { 284 287 if self.stream_start.is_none() { 285 288 self.stream_start = Some(Instant::now()); 286 289 } 290 + // safety net: Started / ReflectStarted normally creates the block first 287 291 if self.streaming_mut().is_none() { 288 292 self.history.push(ChatMsg::assistant()); 289 293 self.stream_tokens = 0; ··· 349 353 let mut was_assistant = false; 350 354 for msg in history { 351 355 match &msg.role { 352 - Role::System => { 353 - for line in msg.content.lines() { 356 + Role::System { content } => { 357 + for line in content.lines() { 354 358 lines.push(Line::from(vec![Span::styled( 355 359 line, 356 360 Style::default() ··· 360 364 } 361 365 was_assistant = false; 362 366 } 363 - Role::User => { 367 + Role::User { content } => { 364 368 if was_assistant { 365 369 lines.push(Line::raw("")); 366 370 } 367 - for (i, line) in msg.content.lines().enumerate() { 371 + for (i, line) in content.lines().enumerate() { 368 372 let prefix = if i == 0 { "you " } else { "" }; 369 373 lines.push(Line::from(vec![ 370 374 Span::styled(prefix, Style::default().fg(Color::Cyan)), ··· 373 377 } 374 378 was_assistant = false; 375 379 } 376 - Role::Tool { name, args, result } => { 377 - // parse args for display: show key=value pairs or raw if not object 378 - let args_display = if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) { 379 - if let Some(obj) = v.as_object() { 380 - obj.iter() 381 - .map(|(k, v)| { 382 - let val = match v { 383 - serde_json::Value::String(s) => { 384 - if s.len() > 60 { 385 - format!("{}...", &s[..60]) 386 - } else { 387 - s.clone() 388 - } 389 - } 390 - other => other.to_string(), 391 - }; 392 - format!("{k}={val}") 393 - }) 394 - .collect::<Vec<_>>() 395 - .join(" ") 396 - } else { 397 - args.clone() 398 - } 380 + Role::Assistant { 381 + items, 382 + step, 383 + thinking_expanded, 384 + is_reflect, 385 + } => { 386 + was_assistant = true; 387 + let (label, label_pad) = if *is_reflect { 388 + ("klbr~", "klbr~ ") 399 389 } else { 400 - args.clone() 390 + ("klbr", "klbr ") 401 391 }; 402 - 403 - lines.push(Line::from(vec![ 404 - Span::styled("$ ", Style::default().fg(Color::Yellow)), 392 + let klbr_span = || { 405 393 Span::styled( 406 - format!("{name}({args_display})"), 407 - Style::default().fg(Color::Yellow), 408 - ), 409 - ])); 394 + label, 395 + Style::default() 396 + .fg(Color::Green) 397 + .add_modifier(Modifier::DIM), 398 + ) 399 + }; 410 400 411 - if let Some(res) = result { 412 - for line in res.lines().take(10) { 413 - lines.push(Line::from(vec![ 414 - Span::raw(" "), 415 - Span::styled(line, Style::default().fg(Color::DarkGray)), 416 - ])); 417 - } 418 - let total_lines = res.lines().count(); 419 - if total_lines > 10 { 420 - lines.push(Line::from(vec![ 421 - Span::raw(" "), 422 - Span::styled( 423 - format!("...{} more lines", total_lines - 10), 424 - Style::default() 425 - .fg(Color::DarkGray) 426 - .add_modifier(Modifier::DIM), 427 - ), 428 - ])); 429 - } 430 - } else { 431 - lines.push(Line::from(vec![ 432 - Span::raw(" "), 433 - Span::styled( 434 - "running...", 435 - Style::default() 436 - .fg(Color::DarkGray) 437 - .add_modifier(Modifier::DIM), 438 - ), 439 - ])); 440 - } 441 - was_assistant = false; 442 - } 443 - Role::Assistant { reason, step } => { 444 - was_assistant = true; 445 - let klbr_span = Span::styled( 446 - "klbr", 447 - Style::default() 448 - .fg(Color::Green) 449 - .add_modifier(Modifier::DIM), 450 - ); 451 - if let AssistantStep::PromptProcessing = step { 401 + // prompt processing placeholder 402 + if *step == AssistantStep::PromptProcessing && items.is_empty() { 403 + let placeholder = if *is_reflect { 404 + " is reflecting..." 405 + } else { 406 + " is processing prompt..." 407 + }; 452 408 lines.push(Line::from(vec![ 453 - klbr_span, 409 + klbr_span(), 454 410 Span::styled( 455 - " is processing prompt...", 411 + placeholder, 456 412 Style::default() 457 413 .fg(Color::DarkGray) 458 414 .add_modifier(Modifier::DIM), ··· 460 416 ])); 461 417 continue; 462 418 } 463 - let think_expanded = if let Some(think) = reason { 464 - let think_token = think 465 - .done 466 - .then_some("has reasoned") 467 - .unwrap_or("is reasoning..."); 468 - if think.expanded { 469 - lines.push(Line::from(vec![ 470 - klbr_span, 471 - Span::styled( 472 - format!(" {think_token} ▾"), 473 - Style::default() 474 - .fg(Color::DarkGray) 475 - .add_modifier(Modifier::DIM), 476 - ), 477 - ])); 478 - for line in think.content.lines() { 479 - lines.push(Line::from(vec![Span::styled( 480 - line, 481 - Style::default().fg(Color::DarkGray), 482 - )])); 419 + 420 + for (i, item) in items.iter().enumerate() { 421 + match item { 422 + AssistantItem::Think(thinking) => { 423 + // done if another item follows or we're past the reasoning phase 424 + let done = i + 1 < items.len() 425 + || matches!(step, AssistantStep::Done | AssistantStep::Response); 426 + let think_token = if done { 427 + "has reasoned" 428 + } else { 429 + "is reasoning..." 430 + }; 431 + if *thinking_expanded { 432 + lines.push(Line::from(vec![ 433 + klbr_span(), 434 + Span::styled( 435 + format!(" {think_token} ▾"), 436 + Style::default() 437 + .fg(Color::DarkGray) 438 + .add_modifier(Modifier::DIM), 439 + ), 440 + ])); 441 + for line in thinking.lines() { 442 + lines.push(Line::from(vec![Span::styled( 443 + line, 444 + Style::default().fg(Color::DarkGray), 445 + )])); 446 + } 447 + } else { 448 + lines.push(Line::from(vec![ 449 + klbr_span(), 450 + Span::styled( 451 + format!(" {think_token}"), 452 + Style::default() 453 + .fg(Color::DarkGray) 454 + .add_modifier(Modifier::DIM), 455 + ), 456 + ])); 457 + } 483 458 } 484 - } else { 485 - lines.push(Line::from(vec![ 486 - klbr_span, 487 - Span::styled( 488 - format!(" {think_token}"), 489 - Style::default() 490 - .fg(Color::DarkGray) 491 - .add_modifier(Modifier::DIM), 492 - ), 493 - ])); 459 + AssistantItem::Tool(tool) => { 460 + render_tool(tool, &mut lines); 461 + } 462 + AssistantItem::Text(text) => { 463 + if !text.is_empty() { 464 + if i > 0 { 465 + lines.push(Line::raw("")); 466 + } 467 + for (j, line) in text.lines().enumerate() { 468 + let prefix = if j == 0 { label_pad } else { "" }; 469 + lines.push(Line::from(vec![ 470 + Span::styled(prefix, Style::default().fg(Color::Green)), 471 + Span::raw(line), 472 + ])); 473 + } 474 + } 475 + } 494 476 } 495 - think.expanded 496 - } else { 497 - false 498 - }; 499 - if think_expanded { 500 - lines.push(Line::default()); 501 - } 502 - for (i, line) in msg.content.lines().enumerate() { 503 - let prefix = if i == 0 { "klbr " } else { "" }; 504 - lines.push(Line::from(vec![ 505 - Span::styled(prefix, Style::default().fg(Color::Green)), 506 - Span::raw(line), 507 - ])); 508 477 } 509 478 } 510 479 } 511 480 } 512 481 lines 482 + } 483 + 484 + fn render_tool<'a>(tool: &'a ToolCall, lines: &mut Vec<Line<'a>>) { 485 + let ToolCall { name, args, result } = tool; 486 + let args_display = if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) { 487 + if let Some(obj) = v.as_object() { 488 + obj.iter() 489 + .map(|(k, v)| { 490 + let val = match v { 491 + serde_json::Value::String(s) => { 492 + if s.len() > 60 { 493 + format!("{}...", &s[..60]) 494 + } else { 495 + s.clone() 496 + } 497 + } 498 + other => other.to_string(), 499 + }; 500 + format!("{k}={val}") 501 + }) 502 + .collect::<Vec<_>>() 503 + .join(" ") 504 + } else { 505 + args.clone() 506 + } 507 + } else { 508 + args.clone() 509 + }; 510 + 511 + lines.push(Line::from(vec![ 512 + Span::styled("$ ", Style::default().fg(Color::Yellow)), 513 + Span::styled( 514 + format!("{name}({args_display})"), 515 + Style::default().fg(Color::Yellow), 516 + ), 517 + ])); 518 + 519 + if let Some(res) = result { 520 + for line in res.lines().take(10) { 521 + lines.push(Line::from(vec![ 522 + Span::raw(" "), 523 + Span::styled(line, Style::default().fg(Color::DarkGray)), 524 + ])); 525 + } 526 + let total_lines = res.lines().count(); 527 + if total_lines > 10 { 528 + lines.push(Line::from(vec![ 529 + Span::raw(" "), 530 + Span::styled( 531 + format!("...{} more lines", total_lines - 10), 532 + Style::default() 533 + .fg(Color::DarkGray) 534 + .add_modifier(Modifier::DIM), 535 + ), 536 + ])); 537 + } 538 + } else { 539 + lines.push(Line::from(vec![ 540 + Span::raw(" "), 541 + Span::styled( 542 + "running...", 543 + Style::default() 544 + .fg(Color::DarkGray) 545 + .add_modifier(Modifier::DIM), 546 + ), 547 + ])); 548 + } 513 549 } 514 550 515 551 // ── main ───────────────────────────────────────────────────────────────────── ··· 749 785 app.stream_tokens = 0; 750 786 } 751 787 } 788 + ServerMsg::ThinkToken { content } => { 789 + let entry = app.token_entry(); 790 + if let Role::Assistant { items, step, .. } = &mut entry.role { 791 + *step = AssistantStep::Reasoning; 792 + if let Some(AssistantItem::Think(s)) = items.last_mut() { 793 + s.push_str(&content); 794 + } else { 795 + items.push(AssistantItem::Think(content)); 796 + } 797 + } 798 + } 752 799 ServerMsg::Token { content } => { 753 800 let entry = app.token_entry(); 754 - entry.content.push_str(&content); 755 - // mark reasoning as done 756 - entry.role.if_assistant(|r, step| { 801 + if let Role::Assistant { items, step, .. } = &mut entry.role { 757 802 *step = AssistantStep::Response; 758 - if let Some(r) = r { 759 - r.done = true; 803 + if let Some(AssistantItem::Text(s)) = items.last_mut() { 804 + s.push_str(&content); 805 + } else { 806 + items.push(AssistantItem::Text(content)); 760 807 } 761 - }); 808 + } 762 809 } 763 - ServerMsg::ThinkToken { content } => { 764 - app.token_entry().role.if_assistant(|r, step| { 765 - *step = AssistantStep::Reasoning; 766 - r.get_or_insert_with(Reason::default) 767 - .content 768 - .push_str(&content) 769 - }); 810 + ServerMsg::ReflectStarted => { 811 + app.history.push(ChatMsg::reflect()); 812 + app.stream_tokens = 0; 813 + app.status.clear(); 814 + app.snap_to_bottom(); 815 + } 816 + ServerMsg::ReflectDone => { 817 + if let Some(entry) = app.streaming_mut() { 818 + if let Role::Assistant { step, .. } = &mut entry.role { 819 + *step = AssistantStep::Done; 820 + } 821 + } 770 822 } 771 823 ServerMsg::Done => { 772 - // mark assistant message as done 773 - app.token_entry() 774 - .role 775 - .if_assistant(|_, step| *step = AssistantStep::Done); 824 + if let Some(entry) = app.streaming_mut() { 825 + if let Role::Assistant { step, .. } = &mut entry.role { 826 + *step = AssistantStep::Done; 827 + } 828 + } 776 829 app.status.clear(); 777 830 app.stream_start = None; 778 831 } ··· 795 848 } 796 849 } 797 850 ServerMsg::ToolCall { name, args } => { 798 - app.snap_to_bottom(); 799 - app.history.push(ChatMsg { 800 - role: Role::Tool { 851 + let entry = app.token_entry(); 852 + if let Role::Assistant { items, .. } = &mut entry.role { 853 + items.push(AssistantItem::Tool(ToolCall { 801 854 name, 802 855 args, 803 856 result: None, 804 - }, 805 - content: String::new(), 806 - expanded: true, 807 - }); 857 + })); 858 + } 859 + app.snap_to_bottom(); 808 860 } 809 861 ServerMsg::ToolResult { name, content } => { 810 - // find last pending tool message with matching name and fill in result 811 - for msg in app.history.iter_mut().rev() { 812 - if let Role::Tool { 813 - name: n, result, .. 814 - } = &mut msg.role 815 - { 816 - if *n == name && result.is_none() { 817 - *result = Some(content); 818 - break; 862 + if let Some(entry) = app.streaming_mut() { 863 + if let Role::Assistant { items, .. } = &mut entry.role { 864 + for item in items.iter_mut().rev() { 865 + if let AssistantItem::Tool(tc) = item { 866 + if tc.name == name && tc.result.is_none() { 867 + tc.result = Some(content); 868 + break; 869 + } 870 + } 819 871 } 820 872 } 821 873 }