diff --git a/src/access.rs b/src/access.rs index 5d02fb3..4097500 100644 --- a/src/access.rs +++ b/src/access.rs @@ -1,19 +1,14 @@ use serde::{Deserialize, Serialize}; use std::fmt; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AccessMode { + #[default] ReadOnly, FullAccess, } -impl Default for AccessMode { - fn default() -> Self { - Self::ReadOnly - } -} - impl AccessMode { pub fn toggle(self) -> Self { match self { diff --git a/src/app.rs b/src/app.rs index c8bed80..10c0641 100644 --- a/src/app.rs +++ b/src/app.rs @@ -103,6 +103,56 @@ async fn run_tui( let mut autofill_selected = 0usize; loop { + drain_agent_events( + &mut rx, + &mut AgentEventContext { + terminal: &terminal, + input: &input, + transcript: &mut transcript, + active_assistant: &mut active_assistant, + status: &mut status, + stick_to_bottom, + show_full_tools, + scroll: &mut scroll, + }, + )?; + + if handle.as_ref().map(|h| h.is_finished()).unwrap_or(false) { + let h = handle.take().unwrap(); + let result = h.await; + drain_agent_events( + &mut rx, + &mut AgentEventContext { + terminal: &terminal, + input: &input, + transcript: &mut transcript, + active_assistant: &mut active_assistant, + status: &mut status, + stick_to_bottom, + show_full_tools, + scroll: &mut scroll, + }, + )?; + match result { + Ok(Ok(updated)) => { + conversation = updated; + ensure_final_assistant_visible(&mut transcript, &conversation); + } + Ok(Err(err)) => transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "agent error".into(), + content: err.to_string(), + }), + Err(err) => transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "agent task error".into(), + content: err.to_string(), + }), + } + active_assistant = 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 { @@ -131,104 +181,6 @@ async fn run_tui( ) })?; - while let Ok(event) = rx.try_recv() { - match event { - AgentEvent::AssistantChunk(s) => { - if active_assistant.is_none() && s.trim().is_empty() { - continue; - } - let idx = match active_assistant { - Some(i) => i, - None => { - transcript.push(TranscriptBlock { - kind: TranscriptKind::Assistant, - title: "response".into(), - content: String::new(), - }); - let i = transcript.len() - 1; - active_assistant = Some(i); - i - } - }; - transcript[idx].content.push_str(&s); - if stick_to_bottom { - scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; - } - } - AgentEvent::ToolCallStarted { - id, - name, - arguments, - } => { - active_assistant = None; - transcript.push(TranscriptBlock { - kind: TranscriptKind::Tool, - title: format!("call: {name} ({})", short_call_id(&id)), - content: serde_json::to_string_pretty(&arguments) - .unwrap_or_else(|_| arguments.to_string()), - }); - if stick_to_bottom { - scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; - } - } - AgentEvent::ToolResult { - id, - name, - ok, - content, - } => { - transcript.push(TranscriptBlock { - kind: if ok { - TranscriptKind::Tool - } else { - TranscriptKind::Error - }, - title: format!( - "result: {name} {} ({})", - if ok { "✓" } else { "✗" }, - short_call_id(&id) - ), - content, - }); - if stick_to_bottom { - scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; - } - } - AgentEvent::Status(s) => { - transcript.push(TranscriptBlock { - kind: TranscriptKind::Status, - title: "status".into(), - content: s, - }); - if stick_to_bottom { - scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; - } - } - AgentEvent::TurnFinished => { - active_assistant = None; - status = "turn finished".into(); - } - } - } - - if handle.as_ref().map(|h| h.is_finished()).unwrap_or(false) { - let h = handle.take().unwrap(); - match h.await { - Ok(Ok(updated)) => conversation = updated, - Ok(Err(err)) => transcript.push(TranscriptBlock { - kind: TranscriptKind::Error, - title: "agent error".into(), - content: err.to_string(), - }), - Err(err) => transcript.push(TranscriptBlock { - kind: TranscriptKind::Error, - title: "agent task error".into(), - content: err.to_string(), - }), - } - status = "idle".into(); - } - if let Some(event) = poll_event(Duration::from_millis(40))? { match event { Event::Key(key) if key.kind == KeyEventKind::Press => { @@ -497,6 +449,147 @@ async fn run_tui( } } +struct AgentEventContext<'a> { + terminal: &'a terminal::CassTerminal, + input: &'a str, + transcript: &'a mut Vec, + active_assistant: &'a mut Option, + status: &'a mut String, + stick_to_bottom: bool, + show_full_tools: bool, + scroll: &'a mut u16, +} + +fn drain_agent_events( + rx: &mut mpsc::UnboundedReceiver, + ctx: &mut AgentEventContext<'_>, +) -> Result<()> { + while let Ok(event) = rx.try_recv() { + apply_agent_event(event, ctx)?; + } + Ok(()) +} + +fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Result<()> { + match event { + AgentEvent::AssistantChunk(s) => { + if ctx.active_assistant.is_none() && s.trim().is_empty() { + return Ok(()); + } + let idx = match *ctx.active_assistant { + Some(i) => i, + None => { + ctx.transcript.push(TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: String::new(), + }); + let i = ctx.transcript.len() - 1; + *ctx.active_assistant = Some(i); + i + } + }; + ctx.transcript[idx].content.push_str(&s); + update_bottom_scroll(ctx)?; + } + AgentEvent::ToolCallStarted { + id, + name, + arguments, + } => { + *ctx.active_assistant = None; + ctx.transcript.push(TranscriptBlock { + kind: TranscriptKind::Tool, + title: format!("call: {name} ({})", short_call_id(&id)), + content: serde_json::to_string_pretty(&arguments) + .unwrap_or_else(|_| arguments.to_string()), + }); + update_bottom_scroll(ctx)?; + } + AgentEvent::ToolResult { + id, + name, + ok, + content, + } => { + ctx.transcript.push(TranscriptBlock { + kind: if ok { + TranscriptKind::Tool + } else { + TranscriptKind::Error + }, + title: format!( + "result: {name} {} ({})", + if ok { "✓" } else { "✗" }, + short_call_id(&id) + ), + content, + }); + update_bottom_scroll(ctx)?; + } + AgentEvent::Status(s) => { + ctx.transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: "status".into(), + content: s, + }); + update_bottom_scroll(ctx)?; + } + AgentEvent::TurnFinished => { + *ctx.active_assistant = None; + *ctx.status = "turn finished".into(); + } + } + Ok(()) +} + +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)?; + } + Ok(()) +} + +fn ensure_final_assistant_visible( + transcript: &mut Vec, + conversation: &Conversation, +) -> bool { + let Some(content) = conversation.records.last().and_then(|record| match record { + conversation::Record::Assistant { + content, + tool_calls, + .. + } if tool_calls.is_empty() && !content.trim().is_empty() => Some(content), + _ => None, + }) else { + return false; + }; + + if let Some(last) = transcript.last_mut() { + if matches!(last.kind, TranscriptKind::Assistant) { + if assistant_content_matches(&last.content, content) { + return false; + } + if content.trim_start().starts_with(last.content.trim_start()) { + last.content = content.clone(); + return true; + } + return false; + } + } + + transcript.push(TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: content.clone(), + }); + true +} + +fn assistant_content_matches(a: &str, b: &str) -> bool { + a == b || (!a.trim().is_empty() && a.trim() == b.trim()) +} + #[derive(Debug, Clone)] enum LocalCommand { Model(String), @@ -901,9 +994,11 @@ mod tests { fn config_with_models(models_json: &str) -> (tempfile::TempDir, Config) { let root = tempdir().unwrap(); std::fs::write(root.path().join("models.json"), models_json).unwrap(); - let mut config = Config::default(); - config.root = root.path().to_path_buf(); - config.model = "alpha-model".to_string(); + let config = Config { + root: root.path().to_path_buf(), + model: "alpha-model".to_string(), + ..Config::default() + }; (root, config) } @@ -962,4 +1057,59 @@ mod tests { .unwrap() .is_none()); } + + #[test] + fn ensure_final_assistant_visible_appends_missing_final() { + let conversation = Conversation { + id: "chat".into(), + path: PathBuf::new(), + records: vec![conversation::Record::Assistant { + content: "Done.".into(), + tool_calls: Vec::new(), + ts: "now".into(), + }], + }; + let mut transcript = vec![TranscriptBlock { + kind: TranscriptKind::Tool, + title: "result: read ✓ (call_1)".into(), + content: "ok".into(), + }]; + + assert!(ensure_final_assistant_visible( + &mut transcript, + &conversation + )); + assert!(matches!( + transcript.last(), + Some(TranscriptBlock { + kind: TranscriptKind::Assistant, + content, + .. + }) if content == "Done." + )); + } + + #[test] + fn ensure_final_assistant_visible_does_not_duplicate_streamed_final() { + let conversation = Conversation { + id: "chat".into(), + path: PathBuf::new(), + records: vec![conversation::Record::Assistant { + content: "\nDone.".into(), + tool_calls: Vec::new(), + ts: "now".into(), + }], + }; + let mut transcript = vec![TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: "Done.".into(), + }]; + + assert!(!ensure_final_assistant_visible( + &mut transcript, + &conversation + )); + assert_eq!(transcript.len(), 1); + } } diff --git a/src/config.rs b/src/config.rs index e7519c7..621c776 100644 --- a/src/config.rs +++ b/src/config.rs @@ -173,9 +173,11 @@ impl Config { let models = load_or_create_default_model_registry(&root)?; let file = load_config_file(&root)?; - let mut cfg = Config::default(); - cfg.root = root.clone(); - cfg.docs_dir = docs_dir; + let mut cfg = Config { + root: root.clone(), + docs_dir, + ..Config::default() + }; if let Some(file) = &file { if let Some(v) = file.default_access_mode { diff --git a/src/ui/render.rs b/src/ui/render.rs index fcf8e3e..84c601d 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -91,12 +91,12 @@ pub fn max_transcript_scroll( area: Rect, ) -> u16 { // Paragraph::scroll is measured in rendered rows, not logical lines. Long - // tool output and assistant messages wrap, so count the wrapped rows when - // deciding where the bottom of the transcript is. + // 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.saturating_sub(2).max(1) as usize; let row_count = transcript_lines_from(transcript, show_full_tools) .iter() - .map(|line| wrapped_row_count(&line.to_string(), content_width)) + .map(|line| ratatui_wrapped_row_count(&line.to_string(), content_width)) .sum::(); let viewport_rows = area.height.saturating_sub(2) as usize; row_count @@ -215,107 +215,101 @@ fn transcript_lines_from( lines } -fn wrapped_row_count(line: &str, content_width: usize) -> usize { - let content_width = content_width.max(1); - if line.is_empty() { - return 1; - } - - let mut rows = 1usize; - let mut col = 0usize; - let mut token_is_whitespace: Option = None; - let mut token_width = 0usize; +fn ratatui_wrapped_row_count(line: &str, content_width: usize) -> usize { + // This mirrors ratatui's WordWrapper::process_input for Wrap { trim: false } + // closely enough for scroll bounds. Details like whitespace-only wrapped + // lines matter: if we undercount rows, the scroll bottom stops before the + // final assistant message even though it is present in the transcript. + let max_width = content_width.max(1); + let mut rows = 0usize; + let mut pending_line_has_symbols = false; + let mut line_width = 0usize; + let mut word_width = 0usize; + let mut word_symbols = 0usize; + let mut whitespace_width = 0usize; + let mut whitespace_symbols = 0usize; + let mut pending_whitespace = std::collections::VecDeque::new(); + let mut non_whitespace_previous = false; for ch in line.chars() { - let is_whitespace = ch.is_whitespace(); - if token_is_whitespace.is_some_and(|current| current != is_whitespace) { - append_wrapped_token( - token_width, - token_is_whitespace.unwrap(), - content_width, - &mut rows, - &mut col, - ); - token_width = 0; + let symbol_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if symbol_width > max_width { + continue; } - token_is_whitespace = Some(is_whitespace); - token_width = token_width.saturating_add(UnicodeWidthChar::width(ch).unwrap_or(0)); + let is_whitespace = ch.is_whitespace(); + let word_found = non_whitespace_previous && is_whitespace; + let untrimmed_overflow = !pending_line_has_symbols + && word_width + .saturating_add(whitespace_width) + .saturating_add(symbol_width) + > max_width; + + if word_found || untrimmed_overflow { + if whitespace_symbols > 0 { + pending_line_has_symbols = true; + line_width = line_width.saturating_add(whitespace_width); + } + if word_symbols > 0 { + pending_line_has_symbols = true; + line_width = line_width.saturating_add(word_width); + } + pending_whitespace.clear(); + whitespace_width = 0; + whitespace_symbols = 0; + word_width = 0; + word_symbols = 0; + } + + let line_full = line_width >= max_width; + let pending_word_overflow = symbol_width > 0 + && line_width + .saturating_add(whitespace_width) + .saturating_add(word_width) + >= max_width; + + if line_full || pending_word_overflow { + rows = rows.saturating_add(1); + let mut remaining_width = max_width.saturating_sub(line_width); + line_width = 0; + pending_line_has_symbols = false; + + while let Some(width) = pending_whitespace.front().copied() { + if width > remaining_width { + break; + } + whitespace_width = whitespace_width.saturating_sub(width); + whitespace_symbols = whitespace_symbols.saturating_sub(1); + remaining_width = remaining_width.saturating_sub(width); + pending_whitespace.pop_front(); + } + + if is_whitespace && pending_whitespace.is_empty() { + continue; + } + } + + if is_whitespace { + whitespace_width = whitespace_width.saturating_add(symbol_width); + whitespace_symbols = whitespace_symbols.saturating_add(1); + pending_whitespace.push_back(symbol_width); + } else { + word_width = word_width.saturating_add(symbol_width); + word_symbols = word_symbols.saturating_add(1); + } + + non_whitespace_previous = !is_whitespace; } - if let Some(is_whitespace) = token_is_whitespace { - append_wrapped_token( - token_width, - is_whitespace, - content_width, - &mut rows, - &mut col, - ); + if !pending_line_has_symbols && word_symbols == 0 && whitespace_symbols > 0 { + rows = rows.saturating_add(1); } - - rows -} - -fn append_wrapped_token( - token_width: usize, - is_whitespace: bool, - content_width: usize, - rows: &mut usize, - col: &mut usize, -) { - if token_width == 0 { - return; + if whitespace_symbols > 0 || word_symbols > 0 { + pending_line_has_symbols = true; } - - if !is_whitespace && *col > 0 && col.saturating_add(token_width) > content_width { - *rows = rows.saturating_add(1); - *col = 0; - append_hard_wrapped(token_width, content_width, rows, col); - } else { - append_hard_wrapped_from_current(token_width, content_width, rows, col); - } -} - -fn append_hard_wrapped_from_current( - token_width: usize, - content_width: usize, - rows: &mut usize, - col: &mut usize, -) { - if *col == 0 { - append_hard_wrapped(token_width, content_width, rows, col); - return; - } - - let available = content_width.saturating_sub(*col); - if token_width <= available { - *col = col.saturating_add(token_width); - } else { - *rows = rows.saturating_add(1); - *col = 0; - append_hard_wrapped( - token_width.saturating_sub(available), - content_width, - rows, - col, - ); - } -} - -fn append_hard_wrapped( - token_width: usize, - content_width: usize, - rows: &mut usize, - col: &mut usize, -) { - let full_rows = token_width / content_width; - let remainder = token_width % content_width; - if remainder == 0 { - *rows = rows.saturating_add(full_rows.saturating_sub(1)); - *col = content_width; - } else { - *rows = rows.saturating_add(full_rows); - *col = remainder; + if pending_line_has_symbols { + rows = rows.saturating_add(1); } + rows.max(1) } fn style_for(kind: &TranscriptKind) -> Style { @@ -482,4 +476,28 @@ mod tests { assert_eq!(max_transcript_scroll(&transcript, false, area), 0); } + + #[test] + fn max_scroll_counts_whitespace_only_render_rows() { + let content = (0..60) + .map(|i| if i % 2 == 0 { "x" } else { "" }) + .collect::>() + .join("\n"); + let transcript = vec![ + TranscriptBlock { + kind: TranscriptKind::Tool, + title: "result: read ✓ (call_1)".into(), + content, + }, + TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: "Done.".into(), + }, + ]; + let area = Rect::new(0, 0, 80, 5); + let max = max_transcript_scroll(&transcript, false, area); + + assert!(max > 80, "max scroll too low: {max}"); + } } diff --git a/tests/agent_tests.rs b/tests/agent_tests.rs index fbd8247..f4ad54a 100644 --- a/tests/agent_tests.rs +++ b/tests/agent_tests.rs @@ -39,12 +39,17 @@ async fn empty_final_response_is_reprompted_and_persisted() { let root = tempdir().unwrap(); let cwd = tempdir().unwrap(); let docs = tempdir().unwrap(); - let mut config = Config::default(); - config.root = root.path().to_path_buf(); - config.docs_dir = docs.path().to_path_buf(); - config.model = "test-model".into(); - config.active_provider.base_url = server.uri(); - config.active_provider.api_key = "test-key".into(); + 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(),