Show and persist model reasoning

This commit is contained in:
2026-06-22 11:41:36 -05:00
parent 7ceb09fca9
commit 45df1ea0d5
11 changed files with 436 additions and 41 deletions
+5 -1
View File
@@ -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 <id>
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`.
+3 -1
View File
@@ -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`.
+9
View File
@@ -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<Model
}),
Record::Assistant {
content,
reasoning,
reasoning_field,
tool_calls,
..
} => non_system.push(ModelMessage::Assistant {
content: content.clone(),
reasoning: reasoning.clone(),
reasoning_field: reasoning_field.clone(),
tool_calls: tool_calls.clone(),
}),
Record::Tool {
+178 -12
View File
@@ -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<Instant> = None;
let mut handle: Option<JoinHandle<Result<Conversation>>> = None;
let mut active_assistant: Option<usize> = None;
let mut active_reasoning: Option<usize> = 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<TranscriptBlock>,
active_assistant: &'a mut Option<usize>,
active_reasoning: &'a mut Option<usize>,
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<u16> {
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<u16> {
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<TranscriptBlock> {
@@ -993,9 +1147,17 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec<TranscriptBlock>
}),
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(),
}],
+7
View File
@@ -39,6 +39,8 @@ pub struct ConfigFile {
pub model_tool_result_limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ui_tool_result_limit: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_reasoning: Option<bool>,
}
#[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 {
+4
View File
@@ -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<String>,
tool_calls: Vec<StoredToolCall>,
ts: String,
},
+95 -20
View File
@@ -68,6 +68,8 @@ impl OpenAiCompatibleProvider {
}
let mut content = String::new();
let mut reasoning = String::new();
let mut reasoning_field: Option<String> = None;
let mut partials: BTreeMap<usize, PartialToolCall> = 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<String>,
partials: &mut BTreeMap<usize, PartialToolCall>,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> 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<String>,
partials: &mut BTreeMap<usize, PartialToolCall>,
tx: &mpsc::UnboundedSender<AgentEvent>,
) -> 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<ModelMessage>) -> Vec<Value> {
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<String>,
tool_calls: Vec<StoredToolCall>,
) -> 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<ModelMessage>) -> Vec<Value> {
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<ToolSpec>) -> Vec<Value> {
+6
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<StoredToolCall>,
},
@@ -25,5 +29,7 @@ pub enum ModelMessage {
#[derive(Debug, Clone, Default)]
pub struct CompletionResult {
pub content: String,
pub reasoning: String,
pub reasoning_field: Option<String>,
pub tool_calls: Vec<StoredToolCall>,
}
+26 -7
View File
@@ -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::<usize>();
@@ -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<Line<'static>> {
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}");
}
+4
View File
@@ -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)
}
+99
View File
@@ -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::<AgentEvent>();
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::<AgentEvent>();
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;