Fix final transcript updates

This commit is contained in:
2026-06-21 15:30:38 -05:00
parent 9b6de22e61
commit 60f1addd87
5 changed files with 383 additions and 213 deletions
+2 -7
View File
@@ -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 {
+251 -101
View File
@@ -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<TranscriptBlock>,
active_assistant: &'a mut Option<usize>,
status: &'a mut String,
stick_to_bottom: bool,
show_full_tools: bool,
scroll: &'a mut u16,
}
fn drain_agent_events(
rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
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<TranscriptBlock>,
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);
}
}
+5 -3
View File
@@ -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 {
+114 -96
View File
@@ -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::<usize>();
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<bool> = 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::<Vec<_>>()
.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}");
}
}
+11 -6
View File
@@ -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(),