mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
go back in history
This commit is contained in:
@@ -41,6 +41,8 @@ pub(crate) struct App {
|
||||
|
||||
// Transcript overlay state
|
||||
transcript_overlay: Option<TranscriptApp>,
|
||||
// If true, overlay is opened as an Esc-backtrack preview.
|
||||
transcript_overlay_is_backtrack: bool,
|
||||
deferred_history_lines: Vec<Line<'static>>,
|
||||
transcript_saved_viewport: Option<Rect>,
|
||||
|
||||
@@ -48,6 +50,11 @@ pub(crate) struct App {
|
||||
|
||||
/// Controls the animation thread that sends CommitTick events.
|
||||
commit_anim_running: Arc<AtomicBool>,
|
||||
|
||||
// Esc-backtracking state
|
||||
esc_backtrack_primed: bool,
|
||||
esc_backtrack_base: Option<uuid::Uuid>,
|
||||
esc_backtrack_count: usize,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -86,9 +93,13 @@ impl App {
|
||||
enhanced_keys_supported,
|
||||
transcript_lines: Vec::new(),
|
||||
transcript_overlay: None,
|
||||
transcript_overlay_is_backtrack: false,
|
||||
deferred_history_lines: Vec::new(),
|
||||
transcript_saved_viewport: None,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
esc_backtrack_primed: false,
|
||||
esc_backtrack_base: None,
|
||||
esc_backtrack_count: 0,
|
||||
};
|
||||
|
||||
let tui_events = tui.event_stream();
|
||||
@@ -113,19 +124,58 @@ 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 _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen);
|
||||
if let Some(saved) = self.transcript_saved_viewport.take() {
|
||||
tui.terminal.set_viewport_area(saved);
|
||||
if self.transcript_overlay.is_some() {
|
||||
// Intercept Esc/Enter when overlay is a backtrack preview.
|
||||
let mut handled = false;
|
||||
if self.transcript_overlay_is_backtrack {
|
||||
match event {
|
||||
TuiEvent::Key(KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. }) => {
|
||||
if self.esc_backtrack_base.is_some() {
|
||||
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
|
||||
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
|
||||
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
if let Some(off) = offset { overlay.scroll_offset = off; }
|
||||
overlay.set_highlight_range(hl);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. }) => {
|
||||
// Confirm the backtrack: close overlay, fork, and prefill.
|
||||
let base = self.esc_backtrack_base;
|
||||
let count = self.esc_backtrack_count;
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(base_id) = base {
|
||||
if count > 0 {
|
||||
if let Err(e) = self.fork_and_render_backtrack(tui, base_id, count).await {
|
||||
tracing::error!("Backtrack confirm failed: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset backtrack state after confirming.
|
||||
self.esc_backtrack_primed = false;
|
||||
self.esc_backtrack_base = None;
|
||||
self.esc_backtrack_count = 0;
|
||||
handled = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines(lines);
|
||||
}
|
||||
// Forward to overlay if not handled
|
||||
if !handled {
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
overlay.handle_event(tui, event)?;
|
||||
if overlay.is_done {
|
||||
self.close_transcript_overlay(tui);
|
||||
if self.transcript_overlay_is_backtrack {
|
||||
self.esc_backtrack_primed = false;
|
||||
self.esc_backtrack_base = None;
|
||||
self.esc_backtrack_count = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.transcript_overlay = None;
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
} else {
|
||||
@@ -249,16 +299,21 @@ impl App {
|
||||
self.chat_widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
// Keep App-level config in sync with TUI so forks/new sessions inherit overrides.
|
||||
self.chat_widget.set_reasoning_effort(effort);
|
||||
self.config.model_reasoning_effort = effort;
|
||||
}
|
||||
AppEvent::UpdateModel(model) => {
|
||||
self.chat_widget.set_model(model);
|
||||
self.chat_widget.set_model(model.clone());
|
||||
self.config.model = model;
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
self.config.approval_policy = policy;
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
self.chat_widget.set_sandbox_policy(policy.clone());
|
||||
self.config.sandbox_policy = policy;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
@@ -304,6 +359,55 @@ impl App {
|
||||
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
// Only handle backtracking when composer is empty to avoid clobbering edits.
|
||||
if self.chat_widget.composer_is_empty() {
|
||||
if !self.esc_backtrack_primed {
|
||||
// Arm backtracking and record base conversation.
|
||||
self.esc_backtrack_primed = true;
|
||||
self.esc_backtrack_count = 0;
|
||||
self.esc_backtrack_base = self.chat_widget.session_id();
|
||||
} else if self.transcript_overlay.is_none() {
|
||||
// Open transcript overlay in backtrack preview mode and jump to the target message.
|
||||
self.open_transcript_overlay(tui);
|
||||
self.transcript_overlay_is_backtrack = true;
|
||||
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
|
||||
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
|
||||
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
if let Some(off) = offset { overlay.scroll_offset = off; }
|
||||
overlay.set_highlight_range(hl);
|
||||
}
|
||||
} else if self.transcript_overlay_is_backtrack {
|
||||
// Already previewing: step to the next older message.
|
||||
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
|
||||
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
|
||||
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
|
||||
if let Some(overlay) = &mut self.transcript_overlay {
|
||||
if let Some(off) = offset { overlay.scroll_offset = off; }
|
||||
overlay.set_highlight_range(hl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
||||
KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. }
|
||||
if self.esc_backtrack_primed && self.esc_backtrack_count > 0 && self.chat_widget.composer_is_empty() =>
|
||||
{
|
||||
if let Some(base_id) = self.esc_backtrack_base {
|
||||
if let Err(e) = self.fork_and_render_backtrack(tui, base_id, self.esc_backtrack_count).await {
|
||||
tracing::error!("Backtrack confirm failed: {e:#}");
|
||||
}
|
||||
}
|
||||
// Reset backtrack state after confirming.
|
||||
self.esc_backtrack_primed = false;
|
||||
self.esc_backtrack_base = None;
|
||||
self.esc_backtrack_count = 0;
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
@@ -315,4 +419,178 @@ impl App {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
// Enter alternate screen and set viewport to full size.
|
||||
let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen);
|
||||
if let Ok(size) = tui.terminal.size() {
|
||||
self.transcript_saved_viewport = Some(tui.terminal.viewport_area);
|
||||
tui.terminal
|
||||
.set_viewport_area(Rect::new(0, 0, size.width, size.height));
|
||||
let _ = tui.terminal.clear();
|
||||
}
|
||||
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
|
||||
// Exit alternate screen and restore viewport.
|
||||
let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen);
|
||||
if let Some(saved) = self.transcript_saved_viewport.take() {
|
||||
tui.terminal.set_viewport_area(saved);
|
||||
}
|
||||
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.transcript_overlay_is_backtrack = false;
|
||||
}
|
||||
|
||||
async fn fork_and_render_backtrack(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
base_id: uuid::Uuid,
|
||||
drop_last_messages: usize,
|
||||
) -> color_eyre::eyre::Result<()> {
|
||||
// Compute the text to prefill by extracting the N-th last user message
|
||||
// from the UI transcript lines already rendered.
|
||||
let prefill = self.nth_last_user_text_from_transcript(drop_last_messages);
|
||||
|
||||
// Fork conversation with the requested drop.
|
||||
let fork = self
|
||||
.server
|
||||
.fork_conversation(base_id, drop_last_messages, self.config.clone())
|
||||
.await?;
|
||||
// Replace chat widget with one attached to the new conversation.
|
||||
self.chat_widget = ChatWidget::new_from_existing(
|
||||
self.config.clone(),
|
||||
fork.conversation,
|
||||
fork.session_configured,
|
||||
tui.frame_requester(),
|
||||
self.app_event_tx.clone(),
|
||||
self.enhanced_keys_supported,
|
||||
);
|
||||
|
||||
// Trim transcript to preserve only content up to the selected user message.
|
||||
if let Some(cut_idx) = self.nth_last_user_header_index(drop_last_messages) {
|
||||
self.transcript_lines.truncate(cut_idx);
|
||||
} else {
|
||||
self.transcript_lines.clear();
|
||||
}
|
||||
let _ = tui.terminal.clear();
|
||||
self.render_transcript_once(tui);
|
||||
|
||||
// Prefill the composer with the dropped user message text, if any.
|
||||
if let Some(text) = prefill {
|
||||
if !text.is_empty() {
|
||||
self.chat_widget.insert_str(&text);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute the overlay scroll offset for the Nth last user message.
|
||||
fn compute_backtrack_overlay_offset(&self, tui: &mut tui::Tui, n: usize) -> Option<usize> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let header_idx = self.nth_last_user_header_index(n)?;
|
||||
// Compute wrapped offset up to header_idx with current overlay width.
|
||||
let width = tui.terminal.viewport_area.width;
|
||||
let wrapped_before =
|
||||
crate::insert_history::word_wrap_lines(&self.transcript_lines[0..header_idx], width);
|
||||
Some(wrapped_before.len())
|
||||
}
|
||||
|
||||
fn nth_last_user_text_from_transcript(&self, n: usize) -> Option<String> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut found = 0usize;
|
||||
let mut header_idx: Option<usize> = None;
|
||||
for (idx, line) in self.transcript_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 {
|
||||
header_idx = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let start = header_idx? + 1;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in self.transcript_lines.iter().skip(start) {
|
||||
let is_blank = line.spans.iter().all(|s| s.content.trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
let text = 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"))
|
||||
}
|
||||
}
|
||||
|
||||
fn nth_last_user_header_index(&self, n: usize) -> Option<usize> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut found = 0usize;
|
||||
for (idx, line) in self.transcript_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
|
||||
}
|
||||
|
||||
fn backtrack_highlight_range(&self, n: usize) -> Option<(usize, usize)> {
|
||||
let header = self.nth_last_user_header_index(n)?;
|
||||
// Include header and the following message lines up to the first blank line.
|
||||
let mut end = header + 1;
|
||||
while end < self.transcript_lines.len() {
|
||||
let is_blank = self.transcript_lines[end]
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
Some((header, end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,13 @@ impl ChatComposer {
|
||||
};
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
// Close the popup.
|
||||
self.active_popup = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
@@ -503,6 +510,10 @@ impl ChatComposer {
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
match key_event {
|
||||
// Esc is handled at the app layer (conversation backtracking); ignore here.
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => (InputResult::None, false),
|
||||
// -------------------------------------------------------------
|
||||
// History navigation (Up / Down) – only when the composer is not
|
||||
// empty or when the cursor is at the correct position, to avoid
|
||||
@@ -1312,4 +1323,6 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Esc backtracking is handled at the app layer (conversation fork); no composer test here.
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ impl ChatComposerHistory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset transient browsing state so the next navigation starts from the
|
||||
/// most recent entry again.
|
||||
pub fn reset_browsing(&mut self) {
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
}
|
||||
|
||||
/// Update metadata when a new session is configured.
|
||||
pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) {
|
||||
self.history_log_id = Some(log_id);
|
||||
|
||||
@@ -62,6 +62,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;
|
||||
@@ -105,6 +106,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,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -143,7 +146,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);
|
||||
}
|
||||
@@ -565,6 +572,7 @@ impl ChatWidget {
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
show_welcome_banner: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +584,50 @@ impl ChatWidget {
|
||||
.map_or(0, |c| c.desired_height(width))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
show_welcome_banner: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Press {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
@@ -807,6 +859,80 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a conversation history snapshot (e.g., restored items from a forked
|
||||
/// conversation) into the UI history. Only user/assistant messages and
|
||||
/// reasoning blocks are displayed; tool calls and other items are skipped.
|
||||
pub(crate) fn render_conversation_history(&mut self, items: Vec<serde_json::Value>) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
for item in items.into_iter() {
|
||||
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match item_type {
|
||||
"message" => {
|
||||
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
if let Some(contents) = item.get("content").and_then(|v| v.as_array()) {
|
||||
for c in contents {
|
||||
if let Some(ct) = c.get("type").and_then(|v| v.as_str())
|
||||
&& (ct == "input_text" || ct == "output_text")
|
||||
&& c.get("text").and_then(|t| t.as_str()).is_some()
|
||||
{
|
||||
if let Some(t) = c.get("text").and_then(|t| t.as_str()) {
|
||||
text_parts.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = text_parts.join("\n");
|
||||
if role == "user" {
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text));
|
||||
}
|
||||
} else if role == "assistant" {
|
||||
let _ = self.stream.apply_final_answer(&text, &sink);
|
||||
}
|
||||
}
|
||||
"reasoning" => {
|
||||
let mut buf = String::new();
|
||||
if let Some(parts) = item.get("content").and_then(|v| v.as_array()) {
|
||||
for p in parts {
|
||||
if let Some(pt) = p.get("type").and_then(|v| v.as_str())
|
||||
&& (pt == "reasoning_text" || pt == "text")
|
||||
&& p.get("text").and_then(|t| t.as_str()).is_some()
|
||||
{
|
||||
if !buf.is_empty() {
|
||||
buf.push('\n');
|
||||
}
|
||||
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
|
||||
buf.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if buf.is_empty() {
|
||||
if let Some(sum) = item.get("summary").and_then(|v| v.as_array()) {
|
||||
for s in sum {
|
||||
if s.get("type").and_then(|v| v.as_str()) == Some("summary_text")
|
||||
&& s.get("text").and_then(|t| t.as_str()).is_some()
|
||||
{
|
||||
if !buf.is_empty() {
|
||||
buf.push('\n');
|
||||
}
|
||||
if let Some(t) = s.get("text").and_then(|t| t.as_str()) {
|
||||
buf.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
self.add_to_history(history_cell::new_reasoning_block(buf, &self.config));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
@@ -1001,6 +1127,10 @@ impl ChatWidget {
|
||||
&self.total_token_usage
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(&self) -> Option<Uuid> {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ fn make_chatwidget_manual() -> (
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
}
|
||||
|
||||
@@ -18,17 +18,24 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
pub(crate) struct TranscriptApp {
|
||||
// Base (unmodified) transcript lines
|
||||
base_transcript_lines: Vec<Line<'static>>,
|
||||
// Renderable transcript lines (may include highlight styling)
|
||||
pub(crate) transcript_lines: Vec<Line<'static>>,
|
||||
pub(crate) scroll_offset: usize,
|
||||
pub(crate) is_done: bool,
|
||||
// Optional highlight range [start, end) in terms of base_transcript_lines indices
|
||||
highlight_range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl TranscriptApp {
|
||||
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
|
||||
Self {
|
||||
base_transcript_lines: transcript_lines.clone(),
|
||||
transcript_lines,
|
||||
scroll_offset: 0,
|
||||
is_done: false,
|
||||
highlight_range: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +53,47 @@ impl TranscriptApp {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.transcript_lines.extend(lines);
|
||||
self.base_transcript_lines.extend(lines.clone());
|
||||
// If a highlight is active, rebuild with highlight; else append directly.
|
||||
if self.highlight_range.is_some() {
|
||||
self.rebuild_highlighted_lines();
|
||||
} else {
|
||||
self.transcript_lines.extend(lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlight the specified range [start, end) of base transcript lines.
|
||||
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
|
||||
self.highlight_range = range;
|
||||
self.rebuild_highlighted_lines();
|
||||
}
|
||||
|
||||
fn rebuild_highlighted_lines(&mut self) {
|
||||
// Start from base and optionally apply highlight styles to the target range.
|
||||
let mut out = self.base_transcript_lines.clone();
|
||||
if let Some((start, end)) = self.highlight_range {
|
||||
use ratatui::style::Modifier;
|
||||
let len = out.len();
|
||||
let start = start.min(len);
|
||||
let end = end.min(len);
|
||||
for (idx, line) in out.iter_mut().enumerate().take(end).skip(start) {
|
||||
// Apply REVERSED to all spans; add BOLD on the first line (header)
|
||||
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 = style.add_modifier | Modifier::REVERSED;
|
||||
if idx == start && i == 0 {
|
||||
style.add_modifier = style.add_modifier | Modifier::BOLD;
|
||||
}
|
||||
spans.push(ratatui::text::Span {
|
||||
style,
|
||||
content: s.content.clone(),
|
||||
});
|
||||
}
|
||||
line.spans = spans;
|
||||
}
|
||||
}
|
||||
self.transcript_lines = out;
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
||||
|
||||
Reference in New Issue
Block a user