Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 81 additions & 30 deletions crates/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ use crate::streaming::commit_tick::CommitTickScope;
use crate::streaming::commit_tick::run_commit_tick;
use crate::streaming::controller::StreamController;
use crate::theme::ThemeSet;
use crate::tool_result_cell::ToolResultCell;
use crate::tui::frame_requester::FrameRequester;
use devo_utils::ansi_escape::ansi_escape_line;

Expand Down Expand Up @@ -1101,12 +1102,6 @@ impl ChatWidget {
self.set_status_message("Select an action");
}

pub(crate) fn handle_mouse_event(&mut self, _mouse: crossterm::event::MouseEvent) {
// Mouse events are only actionable in alt-screen mode.
// In inline mode, they are ignored.
self.frame_requester.schedule_frame();
}

pub(crate) fn handle_paste(&mut self, text: String) {
if self.resume_browser.is_some() {
return;
Expand Down Expand Up @@ -1292,6 +1287,7 @@ impl ChatWidget {
};
self.active_tool_calls
.insert(tool_use_id.clone(), tool_call.clone());
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
self.add_history_entry_without_redraw(Box::new(
history_cell::AgentMessageCell::new_with_prefix(
tool_call.lines,
Expand All @@ -1307,6 +1303,8 @@ impl ChatWidget {
if let Some(tool_call) = self.active_tool_calls.get_mut(&tool_use_id) {
let line = Line::from(delta.clone()).patch_style(Self::tool_text_style());
tool_call.lines.push(line);
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
self.frame_requester.schedule_frame();
}
}
WorkerEvent::ToolResult {
Expand Down Expand Up @@ -1336,21 +1334,17 @@ impl ChatWidget {
.filter(|tool_title| !tool_title.is_empty())
.unwrap_or(title);

let mut lines = Vec::new();
let mut preview_lines = self.tool_preview_lines(&preview);
if truncated && preview_lines.is_empty() {
preview_lines.push(Line::from("…").patch_style(Self::tool_text_style()));
}
if !resolved_title.is_empty() {
lines.push(Self::ran_tool_line(&resolved_title));
}
lines.extend(preview_lines);
if !lines.is_empty() {
self.add_to_history(history_cell::AgentMessageCell::new_with_prefix(
lines,
let title_line =
(!resolved_title.is_empty()).then(|| Self::ran_tool_line(&resolved_title));
if title_line.is_some() || !preview.is_empty() || truncated {
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
self.add_to_history(ToolResultCell::new(
title_line,
preview,
self.dot_prefix(dot_status),
" ",
false,
Line::from(" "),
Self::tool_text_style(),
truncated,
));
}
self.set_status_message(if is_error {
Expand Down Expand Up @@ -2070,16 +2064,14 @@ impl ChatWidget {
));
}
TranscriptItemKind::ToolResult => {
let mut lines = vec![Self::ran_tool_line(&item.title)];
lines.extend(self.tool_preview_lines(&item.body));
self.add_history_entry_without_redraw(Box::new(
history_cell::AgentMessageCell::new_with_prefix(
lines,
Self::tool_dot_prefix(),
" ",
false,
),
));
self.add_history_entry_without_redraw(Box::new(ToolResultCell::new(
(!item.title.is_empty()).then(|| Self::ran_tool_line(&item.title)),
item.body,
Self::tool_dot_prefix(),
Line::from(" "),
Self::tool_text_style(),
false,
)));
}
TranscriptItemKind::Error => self.add_history_entry_without_redraw(Box::new(
history_cell::new_error_event_with_hint(item.body, Some(item.title)),
Expand Down Expand Up @@ -2388,6 +2380,7 @@ impl ChatWidget {
TextItemKind::Reasoning => self.reasoning_active_cell(&self.active_text_items[index]),
};
self.active_text_items[index].cell = cell;
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
}

fn assistant_active_cell(
Expand Down Expand Up @@ -2746,6 +2739,24 @@ impl ChatWidget {
.unwrap_or_default()
}

pub(crate) fn transcript_overlay_lines(&self, width: u16) -> Vec<Line<'static>> {
let width = width.max(1);
let mut lines = Vec::new();
for cell in &self.history {
Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width));
}
Self::extend_lines_with_separator(&mut lines, self.live_transcript_lines(width));
Self::trim_trailing_blank_lines(&mut lines);
lines
}

pub(crate) fn transcript_overlay_has_live_tail(&self) -> bool {
self.active_cell.is_some()
|| !self.active_text_items.is_empty()
|| !self.active_tool_calls.is_empty()
|| !self.pending_tool_calls.is_empty()
}

pub(crate) fn external_editor_state(&self) -> ExternalEditorState {
self.external_editor_state
}
Expand Down Expand Up @@ -2797,6 +2808,46 @@ impl ChatWidget {
lines
}

fn live_transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if let Some(cell) = &self.active_cell {
Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width));
}
for item in &self.active_text_items {
if let Some(cell) = &item.cell {
Self::extend_lines_with_separator(&mut lines, cell.transcript_lines(width));
}
}
let mut tool_calls = self.active_tool_calls.values().collect::<Vec<_>>();
tool_calls.sort_by(|left, right| left.tool_use_id.cmp(&right.tool_use_id));
for tool_call in tool_calls {
Self::extend_lines_with_separator(
&mut lines,
history_cell::AgentMessageCell::new_with_prefix(
tool_call.lines.clone(),
Self::pending_dot_prefix(),
" ",
false,
)
.transcript_lines(width),
);
}
for pending in &self.pending_tool_calls {
Self::extend_lines_with_separator(
&mut lines,
history_cell::AgentMessageCell::new_with_prefix(
pending.lines.clone(),
Self::pending_dot_prefix(),
" ",
false,
)
.transcript_lines(width),
);
}
Self::trim_trailing_blank_lines(&mut lines);
lines
}

fn extend_lines_with_separator(target: &mut Vec<Line<'static>>, mut next: Vec<Line<'static>>) {
if next.is_empty() {
return;
Expand Down
84 changes: 84 additions & 0 deletions crates/tui/src/chatwidget_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2282,3 +2282,87 @@ fn live_reasoning_cell_renders_without_duplication() {
"reasoning should appear exactly once, got {occurrences}:\n{before}"
);
}

#[test]
fn transcript_overlay_lines_include_full_completed_tool_output() {
let model = Model {
slug: "test-model".to_string(),
display_name: "Test Model".to_string(),
..Model::default()
};
let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from("."));
let output = (1..=8)
.map(|index| format!("line {index}"))
.collect::<Vec<_>>()
.join("\n");

widget.handle_worker_event(crate::events::WorkerEvent::ToolCall {
tool_use_id: "tool-1".to_string(),
summary: "bash".to_string(),
});
widget.handle_worker_event(crate::events::WorkerEvent::ToolResult {
tool_use_id: "tool-1".to_string(),
title: "bash".to_string(),
preview: output,
is_error: false,
truncated: false,
});

let inline = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n");
let transcript = widget
.transcript_overlay_lines(80)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");

assert!(
!inline.contains("line 5"),
"inline output should stay compact: {inline}"
);
assert!(
transcript.contains("line 5") && transcript.contains("line 8"),
"transcript output should include the full tool output: {transcript}"
);
}

#[test]
fn transcript_overlay_lines_include_running_tool_output_delta() {
let model = Model {
slug: "test-model".to_string(),
display_name: "Test Model".to_string(),
..Model::default()
};
let (mut widget, _app_event_rx) = widget_with_model(model, PathBuf::from("."));

widget.handle_worker_event(crate::events::WorkerEvent::ToolCall {
tool_use_id: "tool-1".to_string(),
summary: "bash".to_string(),
});
widget.handle_worker_event(crate::events::WorkerEvent::ToolOutputDelta {
tool_use_id: "tool-1".to_string(),
delta: "streamed output line".to_string(),
});

let transcript = widget
.transcript_overlay_lines(80)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");

assert!(
transcript.contains("streamed output line"),
"transcript output should include running tool deltas: {transcript}"
);
}
4 changes: 1 addition & 3 deletions crates/tui/src/exec_cell/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ pub(crate) const TOOL_CALL_MAX_LINES: usize = 2;
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;

/// TODO: Transcript functionality still not implemented.
// const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
const TRANSCRIPT_HINT: &str = "...";
const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";

pub(crate) struct OutputLinesParams {
pub(crate) line_limit: usize,
Expand Down
33 changes: 24 additions & 9 deletions crates/tui/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::chatwidget::ChatWidget;
use crate::chatwidget::ChatWidgetInit;
use crate::chatwidget::TuiSessionState;
use crate::events::WorkerEvent;
use crate::host_overlay::OverlayState;
use crate::onboarding::save_last_used_model;
use crate::onboarding::save_onboarding_config;
use crate::onboarding::save_project_permission_preset;
Expand Down Expand Up @@ -57,6 +58,7 @@ struct InteractiveLoopState {
// replacement session has been restored into widget state.
session_switch_pending: bool,
last_ctrl_c_at: Option<Instant>,
overlay: OverlayState,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -306,6 +308,16 @@ fn handle_tui_event(
return Ok(LoopAction::ClearAndExit);
};

if loop_state.overlay.is_active() {
if matches!(tui_event, TuiEvent::Draw) {
chat_widget.pre_draw_tick();
}
loop_state
.overlay
.handle_tui_event(tui_event, tui, chat_widget)?;
return Ok(LoopAction::Continue);
}

match tui_event {
TuiEvent::Draw => {
if loop_state.session_switch_pending {
Expand All @@ -317,6 +329,7 @@ fn handle_tui_event(

if !chat_widget.is_resume_browser_open()
&& !loop_state.resume_browser_pending
&& !loop_state.overlay.is_active()
&& tui.is_alt_screen_active()
{
tui.leave_alt_screen()?;
Expand Down Expand Up @@ -362,21 +375,17 @@ fn handle_tui_event(
return Ok(LoopAction::Continue);
}

if key.code == KeyCode::Char('t') && key.modifiers.contains(KeyModifiers::CONTROL) {
if tui.is_alt_screen_active() {
tui.leave_alt_screen()?;
} else {
tui.enter_alt_screen()?;
}
if key.code == KeyCode::Char('t')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& !chat_widget.is_resume_browser_open()
{
loop_state.overlay.open_transcript(tui, chat_widget)?;
return Ok(LoopAction::Continue);
}

loop_state.last_ctrl_c_at = None;
chat_widget.handle_key_event(key);
}
TuiEvent::Mouse(mouse_event) => {
chat_widget.handle_mouse_event(mouse_event);
}
TuiEvent::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
Expand Down Expand Up @@ -437,6 +446,12 @@ fn handle_app_event(
handle_app_command(command, worker, chat_widget, tui, loop_state, context)?;
return Ok(LoopAction::Continue);
}

if let AppEvent::DiffResult(text) = app_event {
loop_state.overlay.open_diff(tui, chat_widget, text)?;
return Ok(LoopAction::Continue);
}

chat_widget.handle_app_event(app_event);

Ok(LoopAction::Continue)
Expand Down
Loading
Loading