diff --git a/README.md b/README.md index 596b49a..a3d0fe4 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ User preferences live at `~/.cass/config.json`: ```json { "default_model": "accounts/fireworks/models/qwen3p7-plus", - "default_access_mode": "read-only" + "default_access_mode": "read-only", + "show_reasoning": false } ``` @@ -70,6 +71,7 @@ cass check - `Ctrl-J`: insert newline - `Shift-Tab`: toggle read-only/full-access mode while idle - `Ctrl-O`: toggle compact/full tool output display +- `Ctrl-Shift-R`: toggle reasoning display - `Up`/`Down` or mouse wheel: scroll transcript when no autocomplete menu is open - `PageUp`/`PageDown`: transcript scroll - `Ctrl-C` twice within 1.5 seconds: exit @@ -92,6 +94,8 @@ Resume this chat with: cass --resume Tool calls are shown compactly by default; press `Ctrl-O` to expand full tool output. +Reasoning is hidden by default unless `show_reasoning` is enabled; press `Ctrl-Shift-R` to toggle it. When providers stream reasoning fields, Cass persists that reasoning and sends it back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`. + Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd` and the bundled docs directory at `~/.cass/docs`. Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. `write` and `edit` are always blocked under `~/.cass/docs`. diff --git a/docs/configuration.md b/docs/configuration.md index e7fd988..cf35b0f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,7 +20,8 @@ Example: "default_access_mode": "read-only", "context_message_limit": 80, "model_tool_result_limit": 24000, - "ui_tool_result_limit": 4000 + "ui_tool_result_limit": 4000, + "show_reasoning": false } ``` @@ -32,6 +33,7 @@ Fields: - `context_message_limit`: optional number of recent non-system messages sent to the model. - `model_tool_result_limit`: optional max bytes of tool output sent back to the model. - `ui_tool_result_limit`: optional max bytes of tool output shown in the UI unless full output is toggled. +- `show_reasoning`: optional boolean, defaults to `false`. Shows provider-streamed reasoning in the transcript. Reasoning is persisted and sent back in future model context using the provider's reasoning field, such as `reasoning_content` or `reasoning`. Deprecated compatibility fields from older Cass versions are still accepted: `provider`, `model`, `base_url`, and `api_key_env`. Prefer moving provider connection details to `providers.json`. diff --git a/src/agent.rs b/src/agent.rs index 3f0b618..b192a65 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -13,6 +13,7 @@ use tokio::sync::mpsc; #[derive(Debug, Clone)] pub enum AgentEvent { AssistantChunk(String), + ReasoningChunk(String), ToolCallStarted { id: String, name: String, @@ -131,6 +132,8 @@ pub async fn run_turn( conversation.append(Record::Assistant { content: completion.content, + reasoning: completion.reasoning, + reasoning_field: completion.reasoning_field, tool_calls: tool_calls.clone(), ts: now_ts(), })?; @@ -172,6 +175,8 @@ fn append_visible_assistant( let _ = tx.send(AgentEvent::AssistantChunk(content.clone())); conversation.append(Record::Assistant { content, + reasoning: String::new(), + reasoning_field: None, tool_calls: Vec::new(), ts: now_ts(), }) @@ -186,10 +191,14 @@ fn build_messages(records: &[Record], system: String, limit: usize) -> Vec non_system.push(ModelMessage::Assistant { content: content.clone(), + reasoning: reasoning.clone(), + reasoning_field: reasoning_field.clone(), tool_calls: tool_calls.clone(), }), Record::Tool { diff --git a/src/app.rs b/src/app.rs index 1dd2151..3c1b2df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -95,10 +95,12 @@ async fn run_tui( let mut mode = config.default_access_mode; let mut status = String::new(); let mut show_full_tools = false; + let mut show_reasoning = config.show_reasoning; let mut scroll: u16 = 0; let mut last_ctrl_c: Option = None; let mut handle: Option>> = None; let mut active_assistant: Option = None; + let mut active_reasoning: Option = None; let mut stick_to_bottom = true; let mut chat_id = conversation.id.clone(); let mut autofill_selected = 0usize; @@ -111,9 +113,11 @@ async fn run_tui( input: &input, transcript: &mut transcript, active_assistant: &mut active_assistant, + active_reasoning: &mut active_reasoning, status: &mut status, stick_to_bottom, show_full_tools, + show_reasoning, scroll: &mut scroll, }, )?; @@ -128,9 +132,11 @@ async fn run_tui( input: &input, transcript: &mut transcript, active_assistant: &mut active_assistant, + active_reasoning: &mut active_reasoning, status: &mut status, stick_to_bottom, show_full_tools, + show_reasoning, scroll: &mut scroll, }, )?; @@ -151,15 +157,29 @@ async fn run_tui( }), } active_assistant = None; + active_reasoning = None; status = "idle".into(); } let autofill = build_autofill(&input, autofill_selected, &config, &cwd)?; autofill_selected = autofill.as_ref().map(|m| m.selected).unwrap_or(0); scroll = if stick_to_bottom { - bottom_scroll(&terminal, &input, &transcript, show_full_tools)? + bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )? } else { - clamp_scroll(&terminal, &input, &transcript, show_full_tools, scroll)? + clamp_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + scroll, + )? }; terminal.draw(|f| { @@ -176,6 +196,7 @@ async fn run_tui( status: &status, busy: handle.is_some(), show_full_tools, + show_reasoning, scroll, autofill: autofill.as_ref(), }, @@ -221,13 +242,20 @@ async fn run_tui( (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => { show_full_tools = !show_full_tools; scroll = if stick_to_bottom { - bottom_scroll(&terminal, &input, &transcript, show_full_tools)? + bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )? } else { clamp_scroll( &terminal, &input, &transcript, show_full_tools, + show_reasoning, scroll, )? }; @@ -238,6 +266,63 @@ async fn run_tui( } .into(); } + (KeyCode::Char('R'), m) if m.contains(KeyModifiers::CONTROL) => { + show_reasoning = !show_reasoning; + scroll = if stick_to_bottom { + bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )? + } else { + clamp_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + scroll, + )? + }; + status = if show_reasoning { + "showing reasoning" + } else { + "hiding reasoning" + } + .into(); + } + (KeyCode::Char('r'), m) + if m.contains(KeyModifiers::CONTROL) + && m.contains(KeyModifiers::SHIFT) => + { + show_reasoning = !show_reasoning; + scroll = if stick_to_bottom { + bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )? + } else { + clamp_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + scroll, + )? + }; + status = if show_reasoning { + "showing reasoning" + } else { + "hiding reasoning" + } + .into(); + } (KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => { input.push('\n'); autofill_selected = 0; @@ -280,6 +365,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } } @@ -302,6 +388,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } } @@ -318,6 +405,7 @@ async fn run_tui( input.clear(); autofill_selected = 0; active_assistant = None; + active_reasoning = None; status = format!("new chat {chat_id}"); stick_to_bottom = true; scroll = bottom_scroll( @@ -325,6 +413,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } Err(err) => { @@ -340,6 +429,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } } @@ -364,6 +454,7 @@ async fn run_tui( input.clear(); autofill_selected = 0; active_assistant = None; + active_reasoning = None; status = format!("resumed chat {chat_id}"); stick_to_bottom = true; scroll = bottom_scroll( @@ -371,6 +462,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } Err(err) => { @@ -386,6 +478,7 @@ async fn run_tui( &input, &transcript, show_full_tools, + show_reasoning, )?; } } @@ -408,6 +501,7 @@ async fn run_tui( content: msg.clone(), }); active_assistant = None; + active_reasoning = None; status = "running".into(); let settings = AgentSettings { config: config.clone(), @@ -420,8 +514,13 @@ async fn run_tui( agent::run_turn(convo, msg, settings, tx2).await })); stick_to_bottom = true; - scroll = - bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + scroll = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )?; } } (KeyCode::Backspace, _) => { @@ -440,8 +539,13 @@ async fn run_tui( if let Some(menu) = &autofill { autofill_selected = menu.next_index(); } else { - let max = - bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + let max = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )?; scroll = scroll.saturating_add(1).min(max); stick_to_bottom = scroll >= max; } @@ -451,8 +555,13 @@ async fn run_tui( stick_to_bottom = false; } (KeyCode::PageDown, _) => { - let max = - bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + let max = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )?; scroll = scroll.saturating_add(10).min(max); stick_to_bottom = scroll >= max; } @@ -472,7 +581,13 @@ async fn run_tui( stick_to_bottom = false; } MouseEventKind::ScrollDown => { - let max = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + let max = bottom_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + show_reasoning, + )?; scroll = scroll.saturating_add(3).min(max); stick_to_bottom = scroll >= max; } @@ -496,9 +611,11 @@ struct AgentEventContext<'a> { input: &'a str, transcript: &'a mut Vec, active_assistant: &'a mut Option, + active_reasoning: &'a mut Option, status: &'a mut String, stick_to_bottom: bool, show_full_tools: bool, + show_reasoning: bool, scroll: &'a mut u16, } @@ -534,12 +651,33 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu ctx.transcript[idx].content.push_str(&s); update_bottom_scroll(ctx)?; } + AgentEvent::ReasoningChunk(s) => { + if ctx.active_reasoning.is_none() && s.trim().is_empty() { + return Ok(()); + } + let idx = match *ctx.active_reasoning { + Some(i) => i, + None => { + ctx.transcript.push(TranscriptBlock { + kind: TranscriptKind::Reasoning, + title: "reasoning".into(), + content: String::new(), + }); + let i = ctx.transcript.len() - 1; + *ctx.active_reasoning = Some(i); + i + } + }; + ctx.transcript[idx].content.push_str(&s); + update_bottom_scroll(ctx)?; + } AgentEvent::ToolCallStarted { id, name, arguments, } => { *ctx.active_assistant = None; + *ctx.active_reasoning = None; ctx.transcript.push(TranscriptBlock { kind: TranscriptKind::Tool, title: format!("{name} … ({})", short_call_id(&id)), @@ -579,6 +717,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu } AgentEvent::TurnFinished => { *ctx.active_assistant = None; + *ctx.active_reasoning = None; *ctx.status = "turn finished".into(); } } @@ -587,7 +726,13 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu fn update_bottom_scroll(ctx: &mut AgentEventContext<'_>) -> Result<()> { if ctx.stick_to_bottom { - *ctx.scroll = bottom_scroll(ctx.terminal, ctx.input, ctx.transcript, ctx.show_full_tools)?; + *ctx.scroll = bottom_scroll( + ctx.terminal, + ctx.input, + ctx.transcript, + ctx.show_full_tools, + ctx.show_reasoning, + )?; } Ok(()) } @@ -963,11 +1108,13 @@ fn bottom_scroll( input: &str, transcript: &[TranscriptBlock], show_full_tools: bool, + show_reasoning: bool, ) -> Result { let area = render::transcript_area(terminal_area(terminal)?, input); Ok(render::max_transcript_scroll( transcript, show_full_tools, + show_reasoning, area, )) } @@ -977,9 +1124,16 @@ fn clamp_scroll( input: &str, transcript: &[TranscriptBlock], show_full_tools: bool, + show_reasoning: bool, scroll: u16, ) -> Result { - Ok(scroll.min(bottom_scroll(terminal, input, transcript, show_full_tools)?)) + Ok(scroll.min(bottom_scroll( + terminal, + input, + transcript, + show_full_tools, + show_reasoning, + )?)) } fn blocks_from_conversation(conversation: &Conversation) -> Vec { @@ -993,9 +1147,17 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec }), conversation::Record::Assistant { content, + reasoning, tool_calls, .. } => { + if !reasoning.trim().is_empty() { + blocks.push(TranscriptBlock { + kind: TranscriptKind::Reasoning, + title: "reasoning".into(), + content: reasoning.clone(), + }); + } if !content.trim().is_empty() { blocks.push(TranscriptBlock { kind: TranscriptKind::Assistant, @@ -1133,6 +1295,8 @@ mod tests { path: PathBuf::new(), records: vec![conversation::Record::Assistant { content: "Done.".into(), + reasoning: String::new(), + reasoning_field: None, tool_calls: Vec::new(), ts: "now".into(), }], @@ -1164,6 +1328,8 @@ mod tests { path: PathBuf::new(), records: vec![conversation::Record::Assistant { content: "\nDone.".into(), + reasoning: String::new(), + reasoning_field: None, tool_calls: Vec::new(), ts: "now".into(), }], diff --git a/src/config.rs b/src/config.rs index 621c776..eeb37d6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,8 @@ pub struct ConfigFile { pub model_tool_result_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ui_tool_result_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub show_reasoning: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,6 +109,7 @@ pub struct Config { pub context_message_limit: usize, pub model_tool_result_limit: usize, pub ui_tool_result_limit: usize, + pub show_reasoning: bool, pub root: PathBuf, pub docs_dir: PathBuf, } @@ -131,6 +134,7 @@ impl Default for Config { context_message_limit: 80, model_tool_result_limit: 24_000, ui_tool_result_limit: 4_000, + show_reasoning: false, root, docs_dir, } @@ -192,6 +196,9 @@ impl Config { if let Some(v) = file.ui_tool_result_limit { cfg.ui_tool_result_limit = v; } + if let Some(v) = file.show_reasoning { + cfg.show_reasoning = v; + } } if cli.readonly { diff --git a/src/conversation.rs b/src/conversation.rs index 79dd5f5..91ca55e 100644 --- a/src/conversation.rs +++ b/src/conversation.rs @@ -24,6 +24,10 @@ pub enum Record { }, Assistant { content: String, + #[serde(default)] + reasoning: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_field: Option, tool_calls: Vec, ts: String, }, diff --git a/src/providers/openai_compatible.rs b/src/providers/openai_compatible.rs index 70f4a7b..812af00 100644 --- a/src/providers/openai_compatible.rs +++ b/src/providers/openai_compatible.rs @@ -68,6 +68,8 @@ impl OpenAiCompatibleProvider { } let mut content = String::new(); + let mut reasoning = String::new(); + let mut reasoning_field: Option = None; let mut partials: BTreeMap = BTreeMap::new(); let mut buf = String::new(); let mut stream = resp.bytes_stream(); @@ -78,11 +80,25 @@ impl OpenAiCompatibleProvider { while let Some(pos) = buf.find("\n\n") { let frame = buf[..pos].to_string(); buf = buf[pos + 2..].to_string(); - process_frame(&frame, &mut content, &mut partials, tx)?; + process_frame( + &frame, + &mut content, + &mut reasoning, + &mut reasoning_field, + &mut partials, + tx, + )?; } } if !buf.trim().is_empty() { - process_frame(&buf, &mut content, &mut partials, tx)?; + process_frame( + &buf, + &mut content, + &mut reasoning, + &mut reasoning_field, + &mut partials, + tx, + )?; } let tool_calls = partials @@ -101,6 +117,8 @@ impl OpenAiCompatibleProvider { .collect(); Ok(CompletionResult { content, + reasoning, + reasoning_field, tool_calls, }) } @@ -109,6 +127,8 @@ impl OpenAiCompatibleProvider { fn process_frame( frame: &str, content: &mut String, + reasoning: &mut String, + reasoning_field: &mut Option, partials: &mut BTreeMap, tx: &mpsc::UnboundedSender, ) -> Result<()> { @@ -121,7 +141,7 @@ fn process_frame( if data == "[DONE]" { continue; } - handle_chunk(data, content, partials, tx)?; + handle_chunk(data, content, reasoning, reasoning_field, partials, tx)?; } Ok(()) } @@ -129,6 +149,8 @@ fn process_frame( fn handle_chunk( data: &str, content: &mut String, + reasoning: &mut String, + reasoning_field: &mut Option, partials: &mut BTreeMap, tx: &mpsc::UnboundedSender, ) -> Result<()> { @@ -141,6 +163,11 @@ fn handle_chunk( .or_else(|| choice.get("message")) .cloned() .unwrap_or(Value::Null); + if let Some((field, s)) = reasoning_delta(&delta) { + reasoning_field.get_or_insert_with(|| field.to_string()); + reasoning.push_str(s); + let _ = tx.send(AgentEvent::ReasoningChunk(s.to_string())); + } if let Some(s) = delta.get("content").and_then(|c| c.as_str()) { content.push_str(s); let _ = tx.send(AgentEvent::AssistantChunk(s.to_string())); @@ -165,24 +192,72 @@ fn handle_chunk( Ok(()) } -fn messages_to_openai(messages: Vec) -> Vec { - messages.into_iter().map(|m| match m { - ModelMessage::System { content } => json!({"role":"system", "content":content}), - ModelMessage::User { content } => json!({"role":"user", "content":content}), - ModelMessage::Assistant { content, tool_calls } => { - if tool_calls.is_empty() { - json!({"role":"assistant", "content":content}) - } else { - let calls: Vec<_> = tool_calls.into_iter().map(|tc| json!({ - "id": tc.id, - "type": "function", - "function": {"name": tc.name, "arguments": tc.arguments.to_string()} - })).collect(); - json!({"role":"assistant", "content": if content.is_empty() { Value::Null } else { Value::String(content) }, "tool_calls": calls}) - } +fn reasoning_delta(delta: &Value) -> Option<(&'static str, &str)> { + ["reasoning_content", "reasoning", "thinking", "thought"] + .into_iter() + .find_map(|field| { + delta + .get(field) + .and_then(|v| v.as_str()) + .map(|s| (field, s)) + }) +} + +fn assistant_message_to_openai( + content: String, + reasoning: String, + reasoning_field: Option, + tool_calls: Vec, +) -> Value { + let mut message = json!({"role":"assistant", "content":content}); + if let Value::Object(ref mut obj) = message { + if !reasoning.trim().is_empty() { + let field = reasoning_field.unwrap_or_else(|| "reasoning_content".to_string()); + obj.insert(field, Value::String(reasoning)); } - ModelMessage::Tool { tool_call_id, name: _, content } => json!({"role":"tool", "tool_call_id":tool_call_id, "content":content}), - }).collect() + if !tool_calls.is_empty() { + let calls: Vec<_> = tool_calls + .into_iter() + .map(|tc| { + json!({ + "id": tc.id, + "type": "function", + "function": {"name": tc.name, "arguments": tc.arguments.to_string()} + }) + }) + .collect(); + if obj + .get("content") + .and_then(|v| v.as_str()) + .is_some_and(|s| s.is_empty()) + { + obj.insert("content".to_string(), Value::Null); + } + obj.insert("tool_calls".to_string(), Value::Array(calls)); + } + } + message +} + +fn messages_to_openai(messages: Vec) -> Vec { + messages + .into_iter() + .map(|m| match m { + ModelMessage::System { content } => json!({"role":"system", "content":content}), + ModelMessage::User { content } => json!({"role":"user", "content":content}), + ModelMessage::Assistant { + content, + reasoning, + reasoning_field, + tool_calls, + } => assistant_message_to_openai(content, reasoning, reasoning_field, tool_calls), + ModelMessage::Tool { + tool_call_id, + name: _, + content, + } => json!({"role":"tool", "tool_call_id":tool_call_id, "content":content}), + }) + .collect() } fn tools_to_openai(tools: Vec) -> Vec { diff --git a/src/providers/types.rs b/src/providers/types.rs index ec882a0..12778aa 100644 --- a/src/providers/types.rs +++ b/src/providers/types.rs @@ -12,6 +12,10 @@ pub enum ModelMessage { }, Assistant { content: String, + #[serde(default)] + reasoning: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reasoning_field: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] tool_calls: Vec, }, @@ -25,5 +29,7 @@ pub enum ModelMessage { #[derive(Debug, Clone, Default)] pub struct CompletionResult { pub content: String, + pub reasoning: String, + pub reasoning_field: Option, pub tool_calls: Vec, } diff --git a/src/ui/render.rs b/src/ui/render.rs index 8c0115d..5cb7edb 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -11,6 +11,7 @@ use unicode_width::UnicodeWidthChar; pub enum TranscriptKind { User, Assistant, + Reasoning, Tool, Status, Error, @@ -35,6 +36,7 @@ pub struct RenderState<'a> { pub status: &'a str, pub busy: bool, pub show_full_tools: bool, + pub show_reasoning: bool, pub scroll: u16, pub autofill: Option<&'a AutoFillMenu>, } @@ -43,10 +45,15 @@ pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { let menu_height = autofill_height(state.autofill); let chunks = main_layout(f.area(), state.input, menu_height); - let lines = transcript_lines_from(state.transcript, state.show_full_tools); + let lines = transcript_lines_from( + state.transcript, + state.show_full_tools, + state.show_reasoning, + ); let effective_scroll = state.scroll.min(max_transcript_scroll( state.transcript, state.show_full_tools, + state.show_reasoning, chunks[0], )); let transcript = Paragraph::new(Text::from(lines)) @@ -73,13 +80,14 @@ pub fn transcript_area(area: Rect, input: &str) -> Rect { pub fn max_transcript_scroll( transcript: &[TranscriptBlock], show_full_tools: bool, + show_reasoning: bool, area: Rect, ) -> u16 { // Paragraph::scroll is measured in rendered rows, not logical lines. Long // tool output and assistant messages wrap, so count rows with the same // word-wrapping behavior ratatui uses for Paragraph::wrap(trim: false). let content_width = area.width.max(1) as usize; - let row_count = transcript_lines_from(transcript, show_full_tools) + let row_count = transcript_lines_from(transcript, show_full_tools, show_reasoning) .iter() .map(|line| ratatui_wrapped_row_count(&line.to_string(), content_width)) .sum::(); @@ -143,17 +151,21 @@ fn render_autofill_menu(f: &mut Frame<'_>, area: Rect, menu: &AutoFillMenu) { fn transcript_lines_from( transcript: &[TranscriptBlock], show_full_tools: bool, + show_reasoning: bool, ) -> Vec> { let mut lines = Vec::new(); for block in transcript { if matches!(block.kind, TranscriptKind::Assistant) && block.content.trim().is_empty() { continue; } + if matches!(block.kind, TranscriptKind::Reasoning) && !show_reasoning { + continue; + } let style = style_for(&block.kind); lines.push(Line::styled(heading_for(block), style)); - let content = display_content(block, show_full_tools); + let content = display_content(block, show_full_tools, show_reasoning); if !content.trim().is_empty() { for line in content.lines() { lines.push(Line::raw(format!(" {}", sanitize_line(line)))); @@ -291,6 +303,7 @@ fn style_for(kind: &TranscriptKind) -> Style { match kind { TranscriptKind::User => theme::user(), TranscriptKind::Assistant => theme::assistant(), + TranscriptKind::Reasoning => theme::reasoning(), TranscriptKind::Tool => theme::tool(), TranscriptKind::Status => Style::default().fg(Color::DarkGray), TranscriptKind::Error => theme::error(), @@ -301,6 +314,7 @@ fn heading_for(block: &TranscriptBlock) -> String { match block.kind { TranscriptKind::User => "› you".into(), TranscriptKind::Assistant => "cass".into(), + TranscriptKind::Reasoning => "· reasoning".into(), TranscriptKind::Tool => format!("· {}", block.title), TranscriptKind::Status => { if block.title.trim().is_empty() || block.title == "status" { @@ -313,9 +327,11 @@ fn heading_for(block: &TranscriptBlock) -> String { } } -fn display_content(block: &TranscriptBlock, show_full_tools: bool) -> String { +fn display_content(block: &TranscriptBlock, show_full_tools: bool, show_reasoning: bool) -> String { if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools { String::new() + } else if matches!(block.kind, TranscriptKind::Reasoning) && !show_reasoning { + String::new() } else { block.content.clone() } @@ -338,6 +354,9 @@ fn footer_text(state: &RenderState<'_>) -> String { if state.show_full_tools { parts.push("tools:full".into()); } + if state.show_reasoning { + parts.push("reasoning:on".into()); + } if !state.status.trim().is_empty() { parts.push(state.status.trim().to_string()); } @@ -438,7 +457,7 @@ mod tests { }]; let area = Rect::new(0, 0, 12, 3); - assert!(max_transcript_scroll(&transcript, false, area) > 0); + assert!(max_transcript_scroll(&transcript, false, false, area) > 0); } #[test] @@ -450,7 +469,7 @@ mod tests { }]; let area = Rect::new(0, 0, 80, 10); - assert_eq!(max_transcript_scroll(&transcript, false, area), 0); + assert_eq!(max_transcript_scroll(&transcript, false, false, area), 0); } #[test] @@ -472,7 +491,7 @@ mod tests { }, ]; let area = Rect::new(0, 0, 80, 5); - let max = max_transcript_scroll(&transcript, true, area); + let max = max_transcript_scroll(&transcript, true, false, area); assert!(max > 50, "max scroll was {max}"); } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index e51194a..40cfccd 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -10,6 +10,10 @@ pub fn assistant() -> Style { Style::default().fg(Color::White) } +pub fn reasoning() -> Style { + Style::default().fg(Color::DarkGray) +} + pub fn tool() -> Style { Style::default().fg(Color::DarkGray) } diff --git a/tests/agent_tests.rs b/tests/agent_tests.rs index f4ad54a..9bf41ab 100644 --- a/tests/agent_tests.rs +++ b/tests/agent_tests.rs @@ -11,6 +11,105 @@ fn sse(body: &str) -> ResponseTemplate { ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream") } +#[tokio::test] +async fn reasoning_is_streamed_persisted_and_sent_back() { + let server = MockServer::start().await; + + let reasoning_token = "internal-cass-reasoning-token"; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains(&format!( + "\"reasoning_content\":\"{reasoning_token}\"" + ))) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Second.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(1) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"internal-cass-reasoning-token\",\"content\":\"First.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(10) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + docs_dir: docs.path().to_path_buf(), + model: "test-model".into(), + active_provider: cassady::config::ResolvedProviderConfig { + base_url: server.uri(), + api_key: "test-key".into(), + ..Config::default().active_provider + }, + ..Config::default() + }; + + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let updated = run_turn( + conversation, + "first".into(), + AgentSettings { + config: config.clone(), + cwd: cwd.path().to_path_buf(), + mode: AccessMode::ReadOnly, + }, + tx, + ) + .await + .unwrap(); + + let mut streamed_reasoning = String::new(); + while let Ok(event) = rx.try_recv() { + if let AgentEvent::ReasoningChunk(chunk) = event { + streamed_reasoning.push_str(&chunk); + } + } + assert_eq!(streamed_reasoning, reasoning_token); + assert!(matches!( + updated.records.last().unwrap(), + Record::Assistant { + content, + reasoning, + reasoning_field, + .. + } if content == "First." + && reasoning == reasoning_token + && reasoning_field.as_deref() == Some("reasoning_content") + )); + + let (tx, _rx) = mpsc::unbounded_channel::(); + run_turn( + updated, + "second".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::ReadOnly, + }, + tx, + ) + .await + .unwrap(); +} + #[tokio::test] async fn empty_final_response_is_reprompted_and_persisted() { let server = MockServer::start().await;