Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Edrisian
43a8a1645e Stream active tool calls in transcript mode 2025-10-07 19:48:55 -07:00
Daniel Edrisian
10e0805d65 Fix transcript mode rendering issue when showing tab chars 2025-10-07 18:55:27 -07:00
5 changed files with 80 additions and 6 deletions

View File

@@ -3,11 +3,30 @@ use ansi_to_tui::IntoText;
use ratatui::text::Line;
use ratatui::text::Text;
// Expand tabs in a best-effort way for transcript rendering.
// Tabs can interact poorly with left-gutter prefixes in our TUI and CLI
// transcript views (e.g., `nl` separates line numbers from content with a tab).
// Replacing tabs with spaces avoids odd visual artifacts without changing
// semantics for our use cases.
fn expand_tabs(s: &str) -> std::borrow::Cow<'_, str> {
if s.contains('\t') {
// Keep it simple: replace each tab with 4 spaces.
// We do not try to align to tab stops since most usages (like `nl`)
// look acceptable with a fixed substitution and this avoids stateful math
// across spans.
std::borrow::Cow::Owned(s.replace('\t', " "))
} else {
std::borrow::Cow::Borrowed(s)
}
}
/// This function should be used when the contents of `s` are expected to match
/// a single line. If multiple lines are found, a warning is logged and only the
/// first line is returned.
pub fn ansi_escape_line(s: &str) -> Line<'static> {
let text = ansi_escape(s);
// Normalize tabs to spaces to avoid odd gutter collisions in transcript mode.
let s = expand_tabs(s);
let text = ansi_escape(&s);
match text.lines.as_slice() {
[] => "".into(),
[only] => only.clone(),

View File

@@ -421,10 +421,8 @@ impl App {
kind: KeyEventKind::Press,
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
// Open transcript overlay: flush explore stack, enter alt screen, and enable transcript mode.
self.open_transcript_overlay(tui);
}
// Esc primes/advances backtracking only in normal (not working) mode
// with an empty composer. In any other state, forward Esc so the

View File

@@ -109,6 +109,9 @@ impl App {
/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
// Flush any inprogress explore stack so it lands in history first.
self.chat_widget.flush_active_cell();
self.chat_widget.set_transcript_mode(true);
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
@@ -117,6 +120,8 @@ impl App {
/// Close transcript overlay and restore normal UI.
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.leave_alt_screen();
// Leave transcript mode so future tool calls return to the explore stack.
self.chat_widget.set_transcript_mode(false);
let was_backtrack = self.backtrack.overlay_preview_active;
if !self.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);

View File

@@ -260,6 +260,9 @@ pub(crate) struct ChatWidget {
needs_final_message_separator: bool,
last_rendered_width: std::cell::Cell<Option<usize>>,
// When true, route tool calls directly into history (transcript mode).
transcript_mode_active: bool,
}
struct UserMessage {
@@ -285,6 +288,12 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
/// Enable or disable transcript mode. When enabled, tool calls are
/// recorded directly into history instead of the active explore stack.
pub(crate) fn set_transcript_mode(&mut self, active: bool) {
self.transcript_mode_active = active;
}
fn model_description_for(slug: &str) -> Option<&'static str> {
if slug.starts_with("gpt-5-codex") {
Some("Optimized for coding tasks with many tools.")
@@ -706,6 +715,24 @@ impl ChatWidget {
None => (vec![ev.call_id.clone()], Vec::new()),
};
// In transcript mode, avoid the active explore stack and emit a
// completed exec cell directly to history.
if self.transcript_mode_active {
let mut cell = new_active_exec_command(ev.call_id.clone(), command, parsed);
cell.complete_call(
&ev.call_id,
CommandOutput {
exit_code: ev.exit_code,
stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
},
ev.duration,
);
self.add_boxed_history(Box::new(cell));
return;
}
let needs_new = self
.active_cell
.as_ref()
@@ -797,6 +824,11 @@ impl ChatWidget {
parsed_cmd: ev.parsed_cmd.clone(),
},
);
// In transcript mode, skip creating/updating the active explore stack.
if self.transcript_mode_active {
self.request_redraw();
return;
}
if let Some(cell) = self
.active_cell
.as_mut()
@@ -823,6 +855,12 @@ impl ChatWidget {
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.flush_answer_stream_with_separator();
if self.transcript_mode_active {
// In transcript mode, don't create an active cell; history will be
// emitted on the corresponding end event.
self.request_redraw();
return;
}
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
ev.call_id,
@@ -840,6 +878,17 @@ impl ChatWidget {
result,
} = ev;
// In transcript mode, bypass the active stack and emit a completed tool call.
if self.transcript_mode_active {
let mut cell = history_cell::new_active_mcp_tool_call(call_id, invocation);
let extra = cell.complete(duration, result);
self.add_boxed_history(Box::new(cell));
if let Some(extra_cell) = extra {
self.add_boxed_history(extra_cell);
}
return;
}
let extra_cell = match self
.active_cell
.as_mut()
@@ -938,6 +987,7 @@ impl ChatWidget {
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
transcript_mode_active: false,
}
}
@@ -1001,6 +1051,7 @@ impl ChatWidget {
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
transcript_mode_active: false,
}
}
@@ -1225,7 +1276,7 @@ impl ChatWidget {
}
}
fn flush_active_cell(&mut self) {
pub(crate) fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() {
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));

View File

@@ -287,6 +287,7 @@ fn make_chatwidget_manual() -> (
ghost_snapshots_disabled: false,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
transcript_mode_active: false,
};
(widget, rx, op_rx)
}