Resume conversation from an earlier point in history (#2607)
Fixing merge conflict of this: #2588 https://github.com/user-attachments/assets/392c7c37-cf8f-4ed6-952e-8215e8c57bc4
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
@@ -25,27 +26,31 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
// use uuid::Uuid;
|
||||
|
||||
pub(crate) struct App {
|
||||
server: Arc<ConversationManager>,
|
||||
app_event_tx: AppEventSender,
|
||||
chat_widget: ChatWidget,
|
||||
pub(crate) server: Arc<ConversationManager>,
|
||||
pub(crate) app_event_tx: AppEventSender,
|
||||
pub(crate) chat_widget: ChatWidget,
|
||||
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
config: Config,
|
||||
pub(crate) config: Config,
|
||||
|
||||
file_search: FileSearchManager,
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
transcript_lines: Vec<Line<'static>>,
|
||||
pub(crate) transcript_lines: Vec<Line<'static>>,
|
||||
|
||||
// Transcript overlay state
|
||||
transcript_overlay: Option<TranscriptApp>,
|
||||
deferred_history_lines: Vec<Line<'static>>,
|
||||
pub(crate) transcript_overlay: Option<TranscriptApp>,
|
||||
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
pub(crate) enhanced_keys_supported: bool,
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
commit_anim_running: Arc<AtomicBool>,
|
||||
pub(crate) commit_anim_running: Arc<AtomicBool>,
|
||||
|
||||
// Esc-backtracking state grouped
|
||||
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -87,6 +92,7 @@ impl App {
|
||||
transcript_overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
backtrack: BacktrackState::default(),
|
||||
};
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
@@ -96,7 +102,7 @@ impl App {
|
||||
|
||||
while select! {
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
app.handle_event(tui, event)?
|
||||
app.handle_event(tui, event).await?
|
||||
}
|
||||
Some(event) = tui_events.next() => {
|
||||
app.handle_tui_event(tui, event).await?
|
||||
@@ -111,18 +117,8 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done {
|
||||
// Exit alternate screen and restore viewport.
|
||||
let _ = tui.leave_alt_screen();
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
self.transcript_overlay = None;
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
if self.transcript_overlay.is_some() {
|
||||
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
|
||||
} else {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
@@ -161,7 +157,7 @@ impl App {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
self.chat_widget = ChatWidget::new(
|
||||
@@ -227,6 +223,9 @@ impl App {
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -304,10 +303,36 @@ impl App {
|
||||
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
// Esc primes/advances backtracking when composer is empty.
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
}
|
||||
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.backtrack.primed
|
||||
&& self.backtrack.count > 0
|
||||
&& self.chat_widget.composer_is_empty() =>
|
||||
{
|
||||
// Delegate to helper for clarity; preserves behavior.
|
||||
self.confirm_backtrack_from_main();
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
// Any non-Esc key press should cancel a primed backtrack.
|
||||
// This avoids stale "Esc-primed" state after the user starts typing
|
||||
// (even if they later backspace to empty).
|
||||
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
_ => {
|
||||
|
||||
349
codex-rs/tui/src/app_backtrack.rs
Normal file
349
codex-rs/tui/src/app_backtrack.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
use crate::app::App;
|
||||
use crate::backtrack_helpers;
|
||||
use crate::transcript_app::TranscriptApp;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use codex_core::protocol::ConversationHistoryResponseEvent;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
/// Aggregates all backtrack-related state used by the App.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BacktrackState {
|
||||
/// True when Esc has primed backtrack mode in the main view.
|
||||
pub(crate) primed: bool,
|
||||
/// Session id of the base conversation to fork from.
|
||||
pub(crate) base_id: Option<uuid::Uuid>,
|
||||
/// Current step count (Nth last user message).
|
||||
pub(crate) count: usize,
|
||||
/// True when the transcript overlay is showing a backtrack preview.
|
||||
pub(crate) overlay_preview_active: bool,
|
||||
/// Pending fork request: (base_id, drop_count, prefill).
|
||||
pub(crate) pending: Option<(uuid::Uuid, usize, String)>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Route overlay events when transcript overlay is active.
|
||||
/// - If backtrack preview is active: Esc steps selection; Enter confirms.
|
||||
/// - Otherwise: Esc begins preview; all other events forward to overlay.
|
||||
/// interactions (Esc to step target, Enter to confirm) and overlay lifecycle.
|
||||
pub(crate) async fn handle_backtrack_overlay_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
match event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_step_backtrack(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}) => {
|
||||
self.overlay_confirm_backtrack(tui);
|
||||
Ok(true)
|
||||
}
|
||||
// Catchall: forward any other events to the overlay widget.
|
||||
_ => {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
} else if let TuiEvent::Key(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
}) = event
|
||||
{
|
||||
// First Esc in transcript overlay: begin backtrack preview at latest user message.
|
||||
self.begin_overlay_backtrack_preview(tui);
|
||||
Ok(true)
|
||||
} else {
|
||||
// Not in backtrack mode: forward events to the overlay widget.
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle global Esc presses for backtracking when no overlay is present.
|
||||
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
|
||||
// Only handle backtracking when composer is empty to avoid clobbering edits.
|
||||
if self.chat_widget.composer_is_empty() {
|
||||
if !self.backtrack.primed {
|
||||
self.prime_backtrack();
|
||||
} else if self.transcript_overlay.is_none() {
|
||||
self.open_backtrack_preview(tui);
|
||||
} else if self.backtrack.overlay_preview_active {
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a backtrack and request conversation history from the agent.
|
||||
pub(crate) fn request_backtrack(
|
||||
&mut self,
|
||||
prefill: String,
|
||||
base_id: uuid::Uuid,
|
||||
drop_last_messages: usize,
|
||||
) {
|
||||
self.backtrack.pending = Some((base_id, drop_last_messages, prefill));
|
||||
self.app_event_tx.send(crate::app_event::AppEvent::CodexOp(
|
||||
codex_core::protocol::Op::GetHistory,
|
||||
));
|
||||
}
|
||||
|
||||
/// Open transcript overlay (enters alternate screen and shows full transcript).
|
||||
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.enter_alt_screen();
|
||||
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Close transcript overlay and restore normal UI.
|
||||
pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
let _ = tui.leave_alt_screen();
|
||||
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);
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
self.transcript_overlay = None;
|
||||
self.backtrack.overlay_preview_active = false;
|
||||
if was_backtrack {
|
||||
// Ensure backtrack state is fully reset when overlay closes (e.g. via 'q').
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-render the full transcript into the terminal scrollback in one call.
|
||||
/// Useful when switching sessions to ensure prior history remains visible.
|
||||
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
|
||||
if !self.transcript_lines.is_empty() {
|
||||
tui.insert_history_lines(self.transcript_lines.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize backtrack state and show composer hint.
|
||||
fn prime_backtrack(&mut self) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.count = 0;
|
||||
self.backtrack.base_id = self.chat_widget.session_id();
|
||||
self.chat_widget.show_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
/// Open overlay and begin backtrack preview flow (first step + highlight).
|
||||
fn open_backtrack_preview(&mut self, tui: &mut tui::Tui) {
|
||||
self.open_transcript_overlay(tui);
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
// Composer is hidden by overlay; clear its hint.
|
||||
self.chat_widget.clear_esc_backtrack_hint();
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
}
|
||||
|
||||
/// When overlay is already open, begin preview mode and select latest user message.
|
||||
fn begin_overlay_backtrack_preview(&mut self, tui: &mut tui::Tui) {
|
||||
self.backtrack.primed = true;
|
||||
self.backtrack.base_id = self.chat_widget.session_id();
|
||||
self.backtrack.overlay_preview_active = true;
|
||||
let sel = self.compute_backtrack_selection(tui, 1);
|
||||
self.apply_backtrack_selection(sel);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Step selection to the next older user message and update overlay.
|
||||
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
|
||||
let next = self.backtrack.count.saturating_add(1);
|
||||
let sel = self.compute_backtrack_selection(tui, next);
|
||||
self.apply_backtrack_selection(sel);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Compute normalized target, scroll offset, and highlight for requested step.
|
||||
fn compute_backtrack_selection(
|
||||
&self,
|
||||
tui: &tui::Tui,
|
||||
requested_n: usize,
|
||||
) -> (usize, Option<usize>, Option<(usize, usize)>) {
|
||||
let nth = backtrack_helpers::normalize_backtrack_n(&self.transcript_lines, requested_n);
|
||||
let header_idx =
|
||||
backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, nth);
|
||||
let offset = header_idx.map(|idx| {
|
||||
backtrack_helpers::wrapped_offset_before(
|
||||
&self.transcript_lines,
|
||||
idx,
|
||||
tui.terminal.viewport_area.width,
|
||||
)
|
||||
});
|
||||
let hl = backtrack_helpers::highlight_range_for_nth_last_user(&self.transcript_lines, nth);
|
||||
(nth, offset, hl)
|
||||
}
|
||||
|
||||
/// Apply a computed backtrack selection to the overlay and internal counter.
|
||||
fn apply_backtrack_selection(
|
||||
&mut self,
|
||||
selection: (usize, Option<usize>, Option<(usize, usize)>),
|
||||
) {
|
||||
let (nth, offset, hl) = selection;
|
||||
self.backtrack.count = nth;
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
if let Some(off) = offset {
|
||||
overlay.scroll_offset = off;
|
||||
}
|
||||
overlay.set_highlight_range(hl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward any event to the overlay and close it if done.
|
||||
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done {
|
||||
self.close_transcript_overlay(tui);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle Enter in overlay backtrack preview: confirm selection and reset state.
|
||||
fn overlay_confirm_backtrack(&mut self, tui: &mut tui::Tui) {
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let drop_last_messages = self.backtrack.count;
|
||||
let prefill =
|
||||
backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages)
|
||||
.unwrap_or_default();
|
||||
self.close_transcript_overlay(tui);
|
||||
self.request_backtrack(prefill, base_id, drop_last_messages);
|
||||
}
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
|
||||
/// Handle Esc in overlay backtrack preview: step selection if armed, else forward.
|
||||
fn overlay_step_backtrack(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if self.backtrack.base_id.is_some() {
|
||||
self.step_backtrack_and_highlight(tui);
|
||||
} else {
|
||||
self.overlay_forward_event(tui, event)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirm a primed backtrack from the main view (no overlay visible).
|
||||
/// Computes the prefill from the selected user message and requests history.
|
||||
pub(crate) fn confirm_backtrack_from_main(&mut self) {
|
||||
if let Some(base_id) = self.backtrack.base_id {
|
||||
let drop_last_messages = self.backtrack.count;
|
||||
let prefill =
|
||||
backtrack_helpers::nth_last_user_text(&self.transcript_lines, drop_last_messages)
|
||||
.unwrap_or_default();
|
||||
self.request_backtrack(prefill, base_id, drop_last_messages);
|
||||
}
|
||||
self.reset_backtrack_state();
|
||||
}
|
||||
|
||||
/// Clear all backtrack-related state and composer hints.
|
||||
pub(crate) fn reset_backtrack_state(&mut self) {
|
||||
self.backtrack.primed = false;
|
||||
self.backtrack.base_id = None;
|
||||
self.backtrack.count = 0;
|
||||
// In case a hint is somehow still visible (e.g., race with overlay open/close).
|
||||
self.chat_widget.clear_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
/// Handle a ConversationHistory response while a backtrack is pending.
|
||||
/// If it matches the primed base session, fork and switch to the new conversation.
|
||||
pub(crate) async fn on_conversation_history_for_backtrack(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationHistoryResponseEvent,
|
||||
) -> Result<()> {
|
||||
if let Some((base_id, _, _)) = self.backtrack.pending.as_ref()
|
||||
&& ev.conversation_id == *base_id
|
||||
&& let Some((_, drop_count, prefill)) = self.backtrack.pending.take()
|
||||
{
|
||||
self.fork_and_switch_to_new_conversation(tui, ev, drop_count, prefill)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fork the conversation using provided history and switch UI/state accordingly.
|
||||
async fn fork_and_switch_to_new_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
ev: ConversationHistoryResponseEvent,
|
||||
drop_count: usize,
|
||||
prefill: String,
|
||||
) {
|
||||
let cfg = self.chat_widget.config_ref().clone();
|
||||
// Perform the fork via a thin wrapper for clarity/testability.
|
||||
let result = self
|
||||
.perform_fork(ev.entries.clone(), drop_count, cfg.clone())
|
||||
.await;
|
||||
match result {
|
||||
Ok(new_conv) => {
|
||||
self.install_forked_conversation(tui, cfg, new_conv, drop_count, &prefill)
|
||||
}
|
||||
Err(e) => tracing::error!("error forking conversation: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around ConversationManager::fork_conversation.
|
||||
async fn perform_fork(
|
||||
&self,
|
||||
entries: Vec<codex_protocol::models::ResponseItem>,
|
||||
drop_count: usize,
|
||||
cfg: codex_core::config::Config,
|
||||
) -> codex_core::error::Result<codex_core::NewConversation> {
|
||||
self.server
|
||||
.fork_conversation(entries, drop_count, cfg)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Install a forked conversation into the ChatWidget and update UI to reflect selection.
|
||||
fn install_forked_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cfg: codex_core::config::Config,
|
||||
new_conv: codex_core::NewConversation,
|
||||
drop_count: usize,
|
||||
prefill: &str,
|
||||
) {
|
||||
let conv = new_conv.conversation;
|
||||
let session_configured = new_conv.session_configured;
|
||||
self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(
|
||||
cfg,
|
||||
conv,
|
||||
session_configured,
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
// Trim transcript up to the selected user message and re-render it.
|
||||
self.trim_transcript_for_backtrack(drop_count);
|
||||
self.render_transcript_once(tui);
|
||||
if !prefill.is_empty() {
|
||||
self.chat_widget.insert_str(prefill);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
/// Trim transcript_lines to preserve only content up to the selected user message.
|
||||
fn trim_transcript_for_backtrack(&mut self, drop_count: usize) {
|
||||
if let Some(cut_idx) =
|
||||
backtrack_helpers::find_nth_last_user_header_index(&self.transcript_lines, drop_count)
|
||||
{
|
||||
self.transcript_lines.truncate(cut_idx);
|
||||
} else {
|
||||
self.transcript_lines.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_core::protocol::ConversationHistoryResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use ratatui::text::Line;
|
||||
@@ -57,4 +58,7 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Update the current sandbox policy in the running app and widget.
|
||||
UpdateSandboxPolicy(SandboxPolicy),
|
||||
|
||||
/// Forwarded conversation history snapshot from the current conversation.
|
||||
ConversationHistory(ConversationHistoryResponseEvent),
|
||||
}
|
||||
|
||||
154
codex-rs/tui/src/backtrack_helpers.rs
Normal file
154
codex-rs/tui/src/backtrack_helpers.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Convenience: compute the highlight range for the Nth last user message.
|
||||
pub(crate) fn highlight_range_for_nth_last_user(
|
||||
lines: &[Line<'_>],
|
||||
n: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
let header = find_nth_last_user_header_index(lines, n)?;
|
||||
Some(highlight_range_from_header(lines, header))
|
||||
}
|
||||
|
||||
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
|
||||
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
|
||||
let before = &lines[0..header_idx];
|
||||
crate::insert_history::word_wrap_lines(before, width).len()
|
||||
}
|
||||
|
||||
/// Find the header index for the Nth last user message in the transcript.
|
||||
/// Returns `None` if `n == 0` or there are fewer than `n` user messages.
|
||||
pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option<usize> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut found = 0usize;
|
||||
for (idx, line) in lines.iter().enumerate().rev() {
|
||||
let content: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if content.trim() == "user" {
|
||||
found += 1;
|
||||
if found == n {
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Normalize a requested backtrack step `n` against the available user messages.
|
||||
/// - Returns `0` if there are no user messages.
|
||||
/// - Returns `n` if the Nth last user message exists.
|
||||
/// - Otherwise wraps to `1` (the most recent user message).
|
||||
pub(crate) fn normalize_backtrack_n(lines: &[Line<'_>], n: usize) -> usize {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
if find_nth_last_user_header_index(lines, n).is_some() {
|
||||
return n;
|
||||
}
|
||||
if find_nth_last_user_header_index(lines, 1).is_some() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the text content of the Nth last user message.
|
||||
/// The message body is considered to be the lines following the "user" header
|
||||
/// until the first blank line.
|
||||
pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option<String> {
|
||||
let header_idx = find_nth_last_user_header_index(lines, n)?;
|
||||
extract_message_text_after_header(lines, header_idx)
|
||||
}
|
||||
|
||||
/// Extract message text starting after `header_idx` until the first blank line.
|
||||
fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option<String> {
|
||||
let start = header_idx + 1;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in lines.iter().skip(start) {
|
||||
let is_blank = line
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.as_ref().trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
out.push(text);
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a header index, return the inclusive range for the message block
|
||||
/// [header_idx, end) where end is the first blank line after the header or the
|
||||
/// end of the transcript.
|
||||
fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) {
|
||||
let mut end = header_idx + 1;
|
||||
while end < lines.len() {
|
||||
let is_blank = lines[end]
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.as_ref().trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
(header_idx, end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::text::Span;
|
||||
|
||||
fn line(s: &str) -> Line<'static> {
|
||||
Line::from(Span::raw(s.to_string()))
|
||||
}
|
||||
|
||||
fn transcript_with_users(count: usize) -> Vec<Line<'static>> {
|
||||
// Build a transcript with `count` user messages, each followed by one body line and a blank line.
|
||||
let mut v = Vec::new();
|
||||
for i in 0..count {
|
||||
v.push(line("user"));
|
||||
v.push(line(&format!("message {i}")));
|
||||
v.push(line(""));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wraps_to_one_when_past_oldest() {
|
||||
let lines = transcript_with_users(2);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 1), 1);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
|
||||
// Requesting 3rd when only 2 exist wraps to 1
|
||||
assert_eq!(normalize_backtrack_n(&lines, 3), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_returns_zero_when_no_user_messages() {
|
||||
let lines = transcript_with_users(0);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 1), 0);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 5), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_valid_n() {
|
||||
let lines = transcript_with_users(3);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ pub(crate) struct ChatComposer {
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
ctrl_c_quit_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
use_shift_enter_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
@@ -121,6 +122,7 @@ impl ChatComposer {
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint,
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
@@ -1091,6 +1093,10 @@ impl ChatComposer {
|
||||
fn set_has_focus(&mut self, has_focus: bool) {
|
||||
self.has_focus = has_focus;
|
||||
}
|
||||
|
||||
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
|
||||
self.esc_backtrack_hint = show;
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatComposer {
|
||||
@@ -1137,6 +1143,12 @@ impl WidgetRef for &ChatComposer {
|
||||
]
|
||||
};
|
||||
|
||||
if !self.ctrl_c_quit_hint && self.esc_backtrack_hint {
|
||||
hint.push(Span::from(" "));
|
||||
hint.push("Esc".set_style(key_hint_style));
|
||||
hint.push(Span::from(" edit prev"));
|
||||
}
|
||||
|
||||
// Append token/context usage info to the footer hints when available.
|
||||
if let Some(token_usage_info) = &self.token_usage_info {
|
||||
let token_usage = &token_usage_info.total_token_usage;
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(crate) struct BottomPane {
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
|
||||
/// True if the active view is the StatusIndicatorView that replaces the
|
||||
/// composer during a running task.
|
||||
@@ -87,6 +88,7 @@ impl BottomPane {
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
esc_backtrack_hint: false,
|
||||
status_view_active: false,
|
||||
}
|
||||
}
|
||||
@@ -240,6 +242,22 @@ impl BottomPane {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.esc_backtrack_hint = true;
|
||||
self.composer.set_esc_backtrack_hint(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_esc_backtrack_hint(&mut self) {
|
||||
if self.esc_backtrack_hint {
|
||||
self.esc_backtrack_hint = false;
|
||||
self.composer.set_esc_backtrack_hint(false);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
// esc_backtrack_hint_visible removed; hints are controlled internally.
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod agent;
|
||||
use self::agent::spawn_agent;
|
||||
use self::agent::spawn_agent_from_existing;
|
||||
use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
@@ -107,6 +108,8 @@ pub(crate) struct ChatWidget {
|
||||
full_reasoning_buffer: String,
|
||||
session_id: Option<Uuid>,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
last_history_was_exec: bool,
|
||||
}
|
||||
|
||||
@@ -146,7 +149,11 @@ impl ChatWidget {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.session_id = Some(event.session_id);
|
||||
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
|
||||
self.add_to_history(history_cell::new_session_info(
|
||||
&self.config,
|
||||
event,
|
||||
self.show_welcome_banner,
|
||||
));
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
@@ -587,6 +594,52 @@ impl ChatWidget {
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
last_history_was_exec: false,
|
||||
show_welcome_banner: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
|
||||
pub(crate) fn new_from_existing(
|
||||
config: Config,
|
||||
conversation: std::sync::Arc<codex_core::CodexConversation>,
|
||||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||||
frame_requester: FrameRequester,
|
||||
app_event_tx: AppEventSender,
|
||||
enhanced_keys_supported: bool,
|
||||
) -> Self {
|
||||
let mut rng = rand::rng();
|
||||
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
|
||||
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
|
||||
Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
bottom_pane: BottomPane::new(BottomPaneParams {
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: placeholder,
|
||||
}),
|
||||
active_exec_cell: None,
|
||||
config: config.clone(),
|
||||
initial_user_message: None,
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
stream: StreamController::new(config),
|
||||
running_commands: HashMap::new(),
|
||||
pending_exec_completions: Vec::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
needs_redraw: false,
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
last_history_was_exec: false,
|
||||
show_welcome_banner: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,7 +907,11 @@ impl ChatWidget {
|
||||
self.on_background_event(message)
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message),
|
||||
EventMsg::ConversationHistory(_) => {}
|
||||
EventMsg::ConversationHistory(ev) => {
|
||||
// Forward to App so it can process backtrack flows.
|
||||
self.app_event_tx
|
||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||
}
|
||||
}
|
||||
// Coalesce redraws: issue at most one after handling the event
|
||||
if self.needs_redraw {
|
||||
@@ -1029,6 +1086,14 @@ impl ChatWidget {
|
||||
pub(crate) fn insert_str(&mut self, text: &str) {
|
||||
self.bottom_pane.insert_str(text);
|
||||
}
|
||||
|
||||
pub(crate) fn show_esc_backtrack_hint(&mut self) {
|
||||
self.bottom_pane.show_esc_backtrack_hint();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_esc_backtrack_hint(&mut self) {
|
||||
self.bottom_pane.clear_esc_backtrack_hint();
|
||||
}
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
// Record outbound operation for session replay fidelity.
|
||||
@@ -1056,6 +1121,16 @@ impl ChatWidget {
|
||||
&self.total_token_usage
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(&self) -> Option<Uuid> {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
/// Return a reference to the widget's current config (includes any
|
||||
/// runtime overrides applied via TUI, e.g., model or approval policy).
|
||||
pub(crate) fn config_ref(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub(crate) fn clear_token_usage(&mut self) {
|
||||
self.total_token_usage = TokenUsage::default();
|
||||
self.bottom_pane.set_token_usage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::config::Config;
|
||||
@@ -59,3 +60,40 @@ pub(crate) fn spawn_agent(
|
||||
|
||||
codex_op_tx
|
||||
}
|
||||
|
||||
/// Spawn agent loops for an existing conversation (e.g., a forked conversation).
|
||||
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
|
||||
/// events and accepts Ops for submission.
|
||||
pub(crate) fn spawn_agent_from_existing(
|
||||
conversation: std::sync::Arc<CodexConversation>,
|
||||
session_configured: codex_core::protocol::SessionConfiguredEvent,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> UnboundedSender<Op> {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
let app_event_tx_clone = app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
|
||||
let ev = codex_core::protocol::Event {
|
||||
id: "".to_string(),
|
||||
msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
|
||||
};
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
|
||||
|
||||
let conversation_clone = conversation.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(op) = codex_op_rx.recv().await {
|
||||
let id = conversation_clone.submit(op).await;
|
||||
if let Err(e) = id {
|
||||
tracing::error!("failed to submit op: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Ok(event) = conversation.next_event().await {
|
||||
app_event_tx_clone.send(AppEvent::CodexEvent(event));
|
||||
}
|
||||
});
|
||||
|
||||
codex_op_tx
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ fn make_chatwidget_manual() -> (
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
last_history_was_exec: false,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
|
||||
@@ -25,8 +25,10 @@ use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod app_backtrack;
|
||||
mod app_event;
|
||||
mod app_event_sender;
|
||||
mod backtrack_helpers;
|
||||
mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
|
||||
@@ -22,6 +22,7 @@ pub(crate) struct TranscriptApp {
|
||||
pub(crate) scroll_offset: usize,
|
||||
pub(crate) is_done: bool,
|
||||
title: String,
|
||||
highlight_range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TranscriptApp {
|
||||
@@ -31,6 +32,7 @@ impl TranscriptApp {
|
||||
scroll_offset: usize::MAX,
|
||||
is_done: false,
|
||||
title: "T R A N S C R I P T".to_string(),
|
||||
highlight_range: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +42,17 @@ impl TranscriptApp {
|
||||
scroll_offset: 0,
|
||||
is_done: false,
|
||||
title,
|
||||
highlight_range: None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.transcript_lines.extend(lines);
|
||||
}
|
||||
|
||||
/// Highlight the specified range [start, end) of transcript lines.
|
||||
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
|
||||
self.highlight_range = range;
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
@@ -56,8 +67,163 @@ impl TranscriptApp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.transcript_lines.extend(lines);
|
||||
// set_backtrack_mode removed: overlay always shows backtrack guidance now.
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.render_header(area, buf);
|
||||
|
||||
// Main content area (excludes header and bottom status section)
|
||||
let content_area = self.scroll_area(area);
|
||||
let mut lines = self.transcript_lines.clone();
|
||||
self.apply_highlight_to_lines(&mut lines);
|
||||
let wrapped = insert_history::word_wrap_lines(&lines, content_area.width);
|
||||
|
||||
self.render_content_page(content_area, buf, &wrapped);
|
||||
self.render_bottom_section(area, content_area, buf, &wrapped);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||
Span::from("/ ".repeat(area.width as usize / 2))
|
||||
.dim()
|
||||
.render_ref(area, buf);
|
||||
let header = format!("/ {}", self.title);
|
||||
Span::from(header).dim().render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn apply_highlight_to_lines(&self, lines: &mut [Line<'static>]) {
|
||||
if let Some((start, end)) = self.highlight_range {
|
||||
use ratatui::style::Modifier;
|
||||
let len = lines.len();
|
||||
let start = start.min(len);
|
||||
let end = end.min(len);
|
||||
for (idx, line) in lines.iter_mut().enumerate().take(end).skip(start) {
|
||||
let mut spans = Vec::with_capacity(line.spans.len());
|
||||
for (i, s) in line.spans.iter().enumerate() {
|
||||
let mut style = s.style;
|
||||
style.add_modifier |= Modifier::REVERSED;
|
||||
if idx == start && i == 0 {
|
||||
style.add_modifier |= Modifier::BOLD;
|
||||
}
|
||||
spans.push(Span {
|
||||
style,
|
||||
content: s.content.clone(),
|
||||
});
|
||||
}
|
||||
line.spans = spans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content_page(&mut self, area: Rect, buf: &mut Buffer, wrapped: &[Line<'static>]) {
|
||||
// Clamp scroll offset to valid range
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(wrapped.len().saturating_sub(area.height as usize));
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + area.height as usize).min(wrapped.len());
|
||||
let page = &wrapped[start..end];
|
||||
Paragraph::new(page.to_vec()).render_ref(area, buf);
|
||||
|
||||
// Fill remaining visible lines (if any) with a leading '~' in the first column.
|
||||
let visible = (end - start) as u16;
|
||||
if area.height > visible {
|
||||
let extra = area.height - visible;
|
||||
for i in 0..extra {
|
||||
let y = area.y.saturating_add(visible + i);
|
||||
Span::from("~")
|
||||
.dim()
|
||||
.render_ref(Rect::new(area.x, y, 1, 1), buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the bottom status section (separator, percent scrolled, key hints).
|
||||
fn render_bottom_section(
|
||||
&self,
|
||||
full_area: Rect,
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
wrapped: &[Line<'static>],
|
||||
) {
|
||||
let sep_y = content_area.bottom();
|
||||
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
|
||||
let hints_rect = Rect::new(full_area.x, sep_y + 1, full_area.width, 2);
|
||||
|
||||
self.render_separator(buf, sep_rect);
|
||||
let percent = self.compute_scroll_percent(wrapped.len(), content_area.height);
|
||||
self.render_scroll_percentage(buf, sep_rect, percent);
|
||||
self.render_hints(buf, hints_rect);
|
||||
}
|
||||
|
||||
/// Draw a dim horizontal separator line across the provided rect.
|
||||
fn render_separator(&self, buf: &mut Buffer, sep_rect: Rect) {
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
}
|
||||
|
||||
/// Compute percent scrolled (0–100) based on wrapped length and content height.
|
||||
fn compute_scroll_percent(&self, wrapped_len: usize, content_height: u16) -> u8 {
|
||||
let max_scroll = wrapped_len.saturating_sub(content_height as usize);
|
||||
if max_scroll == 0 {
|
||||
100
|
||||
} else {
|
||||
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
|
||||
as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-align and render the dim percent scrolled label on the separator line.
|
||||
fn render_scroll_percentage(&self, buf: &mut Buffer, sep_rect: Rect, percent: u8) {
|
||||
let pct_text = format!(" {percent}% ");
|
||||
let pct_w = pct_text.chars().count() as u16;
|
||||
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
|
||||
Span::from(pct_text)
|
||||
.dim()
|
||||
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
}
|
||||
|
||||
/// Render the dimmed key hints (scroll/page/jump and backtrack cue).
|
||||
fn render_hints(&self, buf: &mut Buffer, hints_rect: Rect) {
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
let hints1 = vec![
|
||||
" ".into(),
|
||||
"↑".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"↓".set_style(key_hint_style),
|
||||
" scroll ".into(),
|
||||
"PgUp".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"PgDn".set_style(key_hint_style),
|
||||
" page ".into(),
|
||||
"Home".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"End".set_style(key_hint_style),
|
||||
" jump".into(),
|
||||
];
|
||||
let mut hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()];
|
||||
hints2.extend([
|
||||
" ".into(),
|
||||
"Esc".set_style(key_hint_style),
|
||||
" edit prev".into(),
|
||||
]);
|
||||
self.maybe_append_enter_edit_hint(&mut hints2, key_hint_style);
|
||||
Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()])
|
||||
.render_ref(hints_rect, buf);
|
||||
}
|
||||
|
||||
/// Conditionally append the "⏎ edit message" hint when a valid highlight is active.
|
||||
fn maybe_append_enter_edit_hint(&self, hints: &mut Vec<Span<'static>>, key_hint_style: Style) {
|
||||
if let Some((start, end)) = self.highlight_range
|
||||
&& end > start
|
||||
{
|
||||
hints.extend([
|
||||
" ".into(),
|
||||
"⏎".set_style(key_hint_style),
|
||||
" edit message".into(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
@@ -140,84 +306,32 @@ impl TranscriptApp {
|
||||
area.height = area.height.saturating_sub(5);
|
||||
area
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Span::from("/ ".repeat(area.width as usize / 2))
|
||||
.dim()
|
||||
.render_ref(area, buf);
|
||||
let header = format!("/ {}", self.title);
|
||||
Span::from(header).dim().render_ref(area, buf);
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Main content area (excludes header and bottom status section)
|
||||
let content_area = self.scroll_area(area);
|
||||
let wrapped = insert_history::word_wrap_lines(&self.transcript_lines, content_area.width);
|
||||
#[test]
|
||||
fn edit_prev_hint_is_visible() {
|
||||
let mut app = TranscriptApp::new(vec![Line::from("hello")]);
|
||||
|
||||
// Clamp scroll offset to valid range
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(wrapped.len().saturating_sub(content_area.height as usize));
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + content_area.height as usize).min(wrapped.len());
|
||||
let page = &wrapped[start..end];
|
||||
Paragraph::new(page.to_vec()).render_ref(content_area, buf);
|
||||
// Render into a small buffer and assert the backtrack hint is present
|
||||
let area = Rect::new(0, 0, 40, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
app.render(area, &mut buf);
|
||||
|
||||
// Fill remaining visible lines (if any) with a leading '~' in the first column.
|
||||
let visible = (end - start) as u16;
|
||||
if content_area.height > visible {
|
||||
let extra = content_area.height - visible;
|
||||
for i in 0..extra {
|
||||
let y = content_area.y.saturating_add(visible + i);
|
||||
Span::from("~")
|
||||
.dim()
|
||||
.render_ref(Rect::new(content_area.x, y, 1, 1), buf);
|
||||
// Flatten buffer to a string and check for the hint text
|
||||
let mut s = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
|
||||
// Bottom status section (4 lines): separator with % scrolled, then key hints (styled like chat composer)
|
||||
let sep_y = content_area.bottom();
|
||||
let sep_rect = Rect::new(area.x, sep_y, area.width, 1);
|
||||
let hints_rect = Rect::new(area.x, sep_y + 1, area.width, 2);
|
||||
|
||||
// Separator line (dim)
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
|
||||
// Scroll percentage (0-100%) aligned near the right edge
|
||||
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
|
||||
let percent: u8 = if max_scroll == 0 {
|
||||
100
|
||||
} else {
|
||||
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
|
||||
as u8
|
||||
};
|
||||
let pct_text = format!(" {percent}% ");
|
||||
let pct_w = pct_text.chars().count() as u16;
|
||||
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
|
||||
Span::from(pct_text)
|
||||
.dim()
|
||||
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
|
||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||
|
||||
let hints1 = vec![
|
||||
" ".into(),
|
||||
"↑".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"↓".set_style(key_hint_style),
|
||||
" scroll ".into(),
|
||||
"PgUp".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"PgDn".set_style(key_hint_style),
|
||||
" page ".into(),
|
||||
"Home".set_style(key_hint_style),
|
||||
"/".into(),
|
||||
"End".set_style(key_hint_style),
|
||||
" jump".into(),
|
||||
];
|
||||
|
||||
let hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()];
|
||||
Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()])
|
||||
.render_ref(hints_rect, buf);
|
||||
assert!(
|
||||
s.contains("edit prev"),
|
||||
"expected 'edit prev' hint in overlay footer, got: {s:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user