Improve fork popup focus and background replay

This commit is contained in:
Rakan El Khalil
2026-03-14 00:44:05 -07:00
parent d6e8a05212
commit 7503033ab7
8 changed files with 401 additions and 195 deletions

View File

@@ -1472,7 +1472,11 @@ impl App {
let width = tui.terminal.last_known_screen_size.width;
let header_lines = self.clear_ui_header_lines(width);
if !header_lines.is_empty() {
tui.insert_history_lines(header_lines);
if self.overlay.is_some() {
self.deferred_history_lines.extend(header_lines);
} else if self.fork_session_overlay.is_none() {
tui.insert_history_lines(header_lines);
}
self.has_emitted_history_lines = true;
}
}
@@ -4173,8 +4177,11 @@ impl App {
}
if self.overlay.is_some() {
self.deferred_history_lines.extend(display);
} else {
} else if self.fork_session_overlay.is_none() {
tui.insert_history_lines(display);
} else {
// While the fork overlay is open, rebuild the background from
// transcript state instead of mutating the live terminal.
}
}
}

View File

@@ -89,14 +89,12 @@ fn popup_height_bounds(area: Rect) -> (u16, u16) {
fn default_popup_rect(area: Rect) -> Rect {
let bounds = popup_size_bounds(area);
let width = bounds
.width
.saturating_mul(DEFAULT_POPUP_WIDTH_NUMERATOR)
let width = bounds.width.saturating_mul(DEFAULT_POPUP_WIDTH_NUMERATOR)
/ DEFAULT_POPUP_WIDTH_DENOMINATOR;
let width = width.min(bounds.width).max(POPUP_MIN_WIDTH.min(bounds.width).max(1));
let height = bounds
.height
.saturating_mul(DEFAULT_POPUP_HEIGHT_NUMERATOR)
let width = width
.min(bounds.width)
.max(POPUP_MIN_WIDTH.min(bounds.width).max(1));
let height = bounds.height.saturating_mul(DEFAULT_POPUP_HEIGHT_NUMERATOR)
/ DEFAULT_POPUP_HEIGHT_DENOMINATOR;
let height = height
.min(bounds.height)
@@ -153,7 +151,12 @@ fn resize_left_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let min_left = (right - i32::from(max_width)).max(i32::from(area.x));
let max_left = right - i32::from(min_width);
let next_left = (i32::from(popup.x) + delta).clamp(min_left, max_left);
Rect::new(next_left as u16, popup.y, (right - next_left) as u16, popup.height)
Rect::new(
next_left as u16,
popup.y,
(right - next_left) as u16,
popup.height,
)
}
fn resize_right_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
@@ -173,7 +176,12 @@ fn resize_top_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
let min_top = (bottom - i32::from(max_height)).max(i32::from(area.y));
let max_top = bottom - i32::from(min_height);
let next_top = (i32::from(popup.y) + delta).clamp(min_top, max_top);
Rect::new(popup.x, next_top as u16, popup.width, (bottom - next_top) as u16)
Rect::new(
popup.x,
next_top as u16,
popup.width,
(bottom - next_top) as u16,
)
}
fn resize_bottom_edge(area: Rect, popup: Rect, delta: i32) -> Rect {
@@ -193,25 +201,33 @@ fn resize_all_edges(area: Rect, popup: Rect, delta: i32) -> Rect {
resize_bottom_edge(area, popup, delta)
}
fn focus_toggle_shortcut(key_event: KeyEvent) -> bool {
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& matches!(key_event.code, KeyCode::Char('o') | KeyCode::Char('O'))
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
}
fn popup_hint(command_state: OverlayCommandState) -> Vec<Span<'static>> {
match command_state {
OverlayCommandState::PassThrough => vec!["ctrl+] prefix".dim()],
OverlayCommandState::PassThrough => {
vec!["^O switch".cyan(), " ".into(), "ctrl+] prefix".dim()]
}
OverlayCommandState::AwaitingPrefix => {
vec![
"m move".yellow(),
"m move".cyan(),
" ".into(),
"r resize".yellow(),
"r resize".cyan(),
" ".into(),
"o other".yellow(),
"o other".cyan(),
" ".into(),
"q close".yellow(),
"q close".cyan(),
" ".into(),
"] send ^]".dim(),
]
}
OverlayCommandState::Move => {
vec![
"move".yellow().bold(),
"move".cyan().bold(),
" ".into(),
"hjkl/arrows".dim(),
" ".into(),
@@ -222,7 +238,7 @@ fn popup_hint(command_state: OverlayCommandState) -> Vec<Span<'static>> {
}
OverlayCommandState::Resize => {
vec![
"resize".yellow().bold(),
"resize".cyan().bold(),
" ".into(),
"hjkl HJKL +/-".dim(),
" ".into(),
@@ -244,7 +260,7 @@ fn popup_block(
None => "running".green().bold(),
};
let focus = match focused_pane {
OverlayFocusedPane::Background => "background focus".yellow().bold(),
OverlayFocusedPane::Background => "background focus".cyan().bold(),
OverlayFocusedPane::Popup => "popup focus".cyan().bold(),
};
let mut title = vec![
@@ -303,7 +319,10 @@ fn child_overlay_env(mut env: HashMap<String, String>) -> HashMap<String, String
env.remove(key);
}
if let Some(bg) = crate::terminal_palette::default_bg() {
env.insert(PARENT_BG_RGB_ENV_VAR.to_string(), parent_bg_rgb_env_value(bg));
env.insert(
PARENT_BG_RGB_ENV_VAR.to_string(),
parent_bg_rgb_env_value(bg),
);
}
env
}
@@ -323,6 +342,7 @@ impl App {
tui: &mut tui::Tui,
thread_id: codex_protocol::ThreadId,
) -> Result<()> {
tui.clear_pending_history_lines();
let size = tui.terminal.size()?;
let popup = default_popup_rect(Rect::new(0, 0, size.width, size.height));
let terminal_size = popup_terminal_size(popup);
@@ -370,11 +390,17 @@ impl App {
if let Some(state) = self.fork_session_overlay.as_mut() {
let is_ctrl_prefix =
matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& matches!(key_event.code, KeyCode::Char(']'))
&& key_event.modifiers.contains(KeyModifiers::CONTROL);
&& matches!(key_event.code, KeyCode::Char(']'))
&& key_event.modifiers.contains(KeyModifiers::CONTROL);
match state.command_state {
OverlayCommandState::PassThrough => {
if is_ctrl_prefix {
if focus_toggle_shortcut(key_event) {
state.focused_pane = match state.focused_pane {
OverlayFocusedPane::Background => OverlayFocusedPane::Popup,
OverlayFocusedPane::Popup => OverlayFocusedPane::Background,
};
tui.frame_requester().schedule_frame();
} else if is_ctrl_prefix {
state.command_state = OverlayCommandState::AwaitingPrefix;
tui.frame_requester().schedule_frame();
} else {
@@ -393,15 +419,11 @@ impl App {
{
if matches!(
key_event.code,
KeyCode::Left
| KeyCode::Right
| KeyCode::Up
| KeyCode::Down
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
) {
if let Some((dx, dy)) = move_popup_delta(key_event) {
state.command_state = OverlayCommandState::Move;
state.popup =
move_popup_rect(area, state.popup, dx, dy);
state.popup = move_popup_rect(area, state.popup, dx, dy);
}
} else if matches!(
key_event.code,
@@ -416,51 +438,59 @@ impl App {
state.popup = resize_all_edges(area, state.popup, delta);
} else {
match key_event.code {
KeyCode::Char('m') | KeyCode::Char('M') => {
state.command_state = OverlayCommandState::Move;
}
KeyCode::Char('r') | KeyCode::Char('R') => {
state.command_state = OverlayCommandState::Resize;
}
KeyCode::Char('o') | KeyCode::Char('O') => {
state.focused_pane = match state.focused_pane {
OverlayFocusedPane::Background => OverlayFocusedPane::Popup,
OverlayFocusedPane::Popup => OverlayFocusedPane::Background,
};
state.command_state = OverlayCommandState::PassThrough;
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
close_overlay = true;
}
KeyCode::Char('d')
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
{
close_overlay = true;
}
KeyCode::Char(']') => {
if is_ctrl_prefix {
KeyCode::Char('m') | KeyCode::Char('M') => {
state.command_state = OverlayCommandState::Move;
}
KeyCode::Char('r') | KeyCode::Char('R') => {
state.command_state = OverlayCommandState::Resize;
}
KeyCode::Char('o') | KeyCode::Char('O') => {
state.focused_pane = match state.focused_pane {
OverlayFocusedPane::Background => {
OverlayFocusedPane::Popup
}
OverlayFocusedPane::Popup => {
OverlayFocusedPane::Background
}
};
state.command_state = OverlayCommandState::PassThrough;
} else {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
close_overlay = true;
}
KeyCode::Char('d')
if key_event
.modifiers
.contains(KeyModifiers::CONTROL) =>
{
close_overlay = true;
}
KeyCode::Char(']') => {
if is_ctrl_prefix {
state.command_state =
OverlayCommandState::PassThrough;
} else {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
state.command_state =
OverlayCommandState::PassThrough;
}
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {
if is_ctrl_prefix {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
}
state.command_state = OverlayCommandState::PassThrough;
}
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {
if is_ctrl_prefix {
forward_key = Some(KeyEvent::new(
KeyCode::Char(']'),
KeyModifiers::CONTROL,
));
}
state.command_state = OverlayCommandState::PassThrough;
}
}
}
tui.frame_requester().schedule_frame();
}
@@ -490,73 +520,88 @@ impl App {
state.command_state = OverlayCommandState::PassThrough;
} else {
match key_event.code {
KeyCode::Left => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
1
} else {
-1
};
state.popup = resize_left_edge(area, state.popup, delta);
KeyCode::Left => {
let delta = if key_event
.modifiers
.contains(KeyModifiers::SHIFT)
{
1
} else {
-1
};
state.popup =
resize_left_edge(area, state.popup, delta);
}
KeyCode::Right => {
let delta = if key_event
.modifiers
.contains(KeyModifiers::SHIFT)
{
-1
} else {
1
};
state.popup =
resize_right_edge(area, state.popup, delta);
}
KeyCode::Up => {
let delta = if key_event
.modifiers
.contains(KeyModifiers::SHIFT)
{
1
} else {
-1
};
state.popup = resize_top_edge(area, state.popup, delta);
}
KeyCode::Down => {
let delta = if key_event
.modifiers
.contains(KeyModifiers::SHIFT)
{
-1
} else {
1
};
state.popup =
resize_bottom_edge(area, state.popup, delta);
}
KeyCode::Char('h') => {
state.popup = resize_left_edge(area, state.popup, -1);
}
KeyCode::Char('H') => {
state.popup = resize_left_edge(area, state.popup, 1);
}
KeyCode::Char('j') => {
state.popup = resize_bottom_edge(area, state.popup, 1);
}
KeyCode::Char('J') => {
state.popup = resize_bottom_edge(area, state.popup, -1);
}
KeyCode::Char('k') => {
state.popup = resize_top_edge(area, state.popup, -1);
}
KeyCode::Char('K') => {
state.popup = resize_top_edge(area, state.popup, 1);
}
KeyCode::Char('l') => {
state.popup = resize_right_edge(area, state.popup, 1);
}
KeyCode::Char('L') => {
state.popup = resize_right_edge(area, state.popup, -1);
}
KeyCode::Char('=') | KeyCode::Char('+') => {
state.popup = resize_all_edges(area, state.popup, 1);
}
KeyCode::Char('-') => {
state.popup = resize_all_edges(area, state.popup, -1);
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {}
}
KeyCode::Right => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
-1
} else {
1
};
state.popup = resize_right_edge(area, state.popup, delta);
}
KeyCode::Up => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
1
} else {
-1
};
state.popup = resize_top_edge(area, state.popup, delta);
}
KeyCode::Down => {
let delta = if key_event.modifiers.contains(KeyModifiers::SHIFT) {
-1
} else {
1
};
state.popup = resize_bottom_edge(area, state.popup, delta);
}
KeyCode::Char('h') => {
state.popup = resize_left_edge(area, state.popup, -1);
}
KeyCode::Char('H') => {
state.popup = resize_left_edge(area, state.popup, 1);
}
KeyCode::Char('j') => {
state.popup = resize_bottom_edge(area, state.popup, 1);
}
KeyCode::Char('J') => {
state.popup = resize_bottom_edge(area, state.popup, -1);
}
KeyCode::Char('k') => {
state.popup = resize_top_edge(area, state.popup, -1);
}
KeyCode::Char('K') => {
state.popup = resize_top_edge(area, state.popup, 1);
}
KeyCode::Char('l') => {
state.popup = resize_right_edge(area, state.popup, 1);
}
KeyCode::Char('L') => {
state.popup = resize_right_edge(area, state.popup, -1);
}
KeyCode::Char('=') | KeyCode::Char('+') => {
state.popup = resize_all_edges(area, state.popup, 1);
}
KeyCode::Char('-') => {
state.popup = resize_all_edges(area, state.popup, -1);
}
KeyCode::Esc | KeyCode::Enter => {
state.command_state = OverlayCommandState::PassThrough;
}
_ => {}
}
}
tui.frame_requester().schedule_frame();
}
@@ -596,12 +641,12 @@ impl App {
self.render_transcript_once(tui);
}
self.chat_widget.maybe_post_pending_notification(tui);
let skip_draw_for_background_paste_burst = self
.chat_widget
.handle_paste_burst_tick(tui.frame_requester())
&& self.fork_session_overlay.as_ref().is_some_and(|state| {
state.focused_pane == OverlayFocusedPane::Background
});
let skip_draw_for_background_paste_burst =
self.chat_widget
.handle_paste_burst_tick(tui.frame_requester())
&& self.fork_session_overlay.as_ref().is_some_and(|state| {
state.focused_pane == OverlayFocusedPane::Background
});
if skip_draw_for_background_paste_burst {
return Ok(());
}
@@ -639,29 +684,6 @@ impl App {
Ok(())
}
fn visible_history_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut has_lines = false;
for cell in &self.transcript_cells {
let mut display = cell.display_lines(width);
if display.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_lines {
lines.push(Line::from(""));
} else {
has_lines = true;
}
}
lines.append(&mut display);
}
lines.extend(self.deferred_history_lines.iter().cloned());
lines
}
fn background_history_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let header_lines = self.clear_ui_header_lines(width);
@@ -678,7 +700,7 @@ impl App {
lines.extend(header_lines);
}
let mut history_lines = self.visible_history_lines(width);
let mut history_lines = self.transcript_history_lines(width);
if !lines.is_empty() && !history_lines.is_empty() {
lines.push(Line::from(""));
}
@@ -689,22 +711,27 @@ impl App {
fn render_fork_session_background(&self, frame: &mut Frame<'_>) -> Option<(u16, u16)> {
let area = frame.area();
Clear.render(area, frame.buffer);
let height = self.chat_widget.desired_height(area.width).min(area.height).max(1);
let height = self
.chat_widget
.desired_height(area.width)
.min(area.height)
.max(1);
let background_viewport = Rect::new(area.x, area.y, area.width, height);
let cursor = self
let background_focused = self
.fork_session_overlay
.as_ref()
.and_then(|state| match state.focused_pane {
OverlayFocusedPane::Background => self.chat_widget.cursor_pos(background_viewport),
OverlayFocusedPane::Popup => None,
});
.is_some_and(|state| state.focused_pane == OverlayFocusedPane::Background);
let Ok(mut terminal) = crate::custom_terminal::Terminal::with_options(VT100Backend::new(
area.width,
area.height,
)) else {
self.chat_widget.render(background_viewport, frame.buffer);
return cursor;
return if background_focused {
self.chat_widget.cursor_pos(background_viewport)
} else {
None
};
};
terminal.set_viewport_area(background_viewport);
@@ -713,9 +740,14 @@ impl App {
let _ = insert_history_lines(&mut terminal, history_lines);
}
let mut cursor = None;
let _ = terminal.draw(|offscreen_frame| {
self.chat_widget.render(offscreen_frame.area(), offscreen_frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(offscreen_frame.area()) {
self.chat_widget
.render(offscreen_frame.area(), offscreen_frame.buffer);
if background_focused {
cursor = self.chat_widget.cursor_pos(offscreen_frame.area());
}
if let Some((x, y)) = cursor {
offscreen_frame.set_cursor_position((x, y));
}
});
@@ -968,6 +1000,40 @@ mod tests {
);
}
#[test]
fn focus_toggle_shortcut_matches_control_o() {
assert!(focus_toggle_shortcut(KeyEvent::new(
KeyCode::Char('o'),
KeyModifiers::CONTROL,
)));
assert!(focus_toggle_shortcut(KeyEvent::new(
KeyCode::Char('O'),
KeyModifiers::CONTROL,
)));
assert!(!focus_toggle_shortcut(KeyEvent::new(
KeyCode::Char('o'),
KeyModifiers::NONE,
)));
}
#[tokio::test]
async fn transcript_history_lines_preserve_blank_lines_between_cells() {
let mut app = make_test_app().await;
app.transcript_cells = vec![
Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
Line::from("first"),
])),
Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
Line::from("second"),
])),
];
assert_eq!(
app.transcript_history_lines(80),
vec![Line::from("first"), Line::from(""), Line::from("second")]
);
}
#[test]
fn resize_popup_rect_respects_min_and_max_bounds() {
let area = Rect::new(0, 0, 100, 28);
@@ -1072,7 +1138,103 @@ ready for a fresh turn\r\n",
let _ = app.render_fork_session_background(&mut frame);
let _ = app.render_fork_session_overlay_frame(&mut frame);
insta::assert_snapshot!("fork_session_overlay_popup_background_focus", snapshot_buffer(&buf));
insta::assert_snapshot!(
"fork_session_overlay_popup_background_focus",
snapshot_buffer(&buf)
);
}
#[tokio::test]
async fn fork_session_overlay_background_focus_uses_post_history_cursor_position() {
let mut app = make_test_app().await;
app.transcript_cells = vec![
Arc::new(crate::history_cell::new_user_prompt(
"background session".to_string(),
Vec::new(),
Vec::new(),
Vec::new(),
)),
Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=14).map(|n| n.to_string().into()).collect(),
)),
];
app.chat_widget.set_composer_text(
"Write tests for cursor focus".to_string(),
Vec::new(),
Vec::new(),
);
app.fork_session_overlay = Some(ForkSessionOverlayState {
terminal: ForkSessionTerminal::for_test(vt100::Parser::new(1, 1, 0), None),
popup: default_popup_rect(Rect::new(0, 0, 80, 18)),
command_state: OverlayCommandState::PassThrough,
focused_pane: OverlayFocusedPane::Background,
});
let area = Rect::new(0, 0, 80, 18);
let mut buf = Buffer::empty(area);
let mut frame = Frame {
cursor_position: None,
viewport_area: area,
buffer: &mut buf,
};
let actual_cursor = app.render_fork_session_background(&mut frame);
let height = app
.chat_widget
.desired_height(area.width)
.min(area.height)
.max(1);
let background_viewport = Rect::new(area.x, area.y, area.width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(VT100Backend::new(
area.width,
area.height,
))
.expect("create vt100 terminal");
terminal.set_viewport_area(background_viewport);
let history_lines = app.background_history_lines(area.width);
insert_history_lines(&mut terminal, history_lines).expect("insert history");
let mut expected_cursor = None;
terminal
.draw(|offscreen_frame| {
app.chat_widget
.render(offscreen_frame.area(), offscreen_frame.buffer);
expected_cursor = app.chat_widget.cursor_pos(offscreen_frame.area());
})
.expect("draw background");
assert_eq!(actual_cursor, expected_cursor);
}
#[tokio::test]
async fn fork_session_overlay_background_ignores_deferred_history_lines_snapshot() {
let mut app = make_test_app().await;
app.transcript_cells = vec![Arc::new(crate::history_cell::new_user_prompt(
"background session".to_string(),
Vec::new(),
Vec::new(),
Vec::new(),
))];
app.deferred_history_lines = vec![
Line::from(""),
Line::from("> duplicated buffered line"),
Line::from("duplicated buffered output"),
];
let area = Rect::new(0, 0, 80, 14);
let mut buf = Buffer::empty(area);
let mut frame = Frame {
cursor_position: None,
viewport_area: area,
buffer: &mut buf,
};
let _ = app.render_fork_session_background(&mut frame);
insta::assert_snapshot!(
"fork_session_overlay_background_ignores_deferred_history_lines",
snapshot_buffer(&buf)
);
}
#[tokio::test]

View File

@@ -1,7 +1,7 @@
use super::*;
use super::super::FeedbackAudience;
use super::super::WindowsSandboxState;
use super::super::agent_navigation::AgentNavigationState;
use super::*;
use crate::app::App;
use crate::app_backtrack::BacktrackState;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
@@ -41,9 +41,8 @@ async fn make_test_app() -> App {
config.model_provider.clone(),
),
);
let auth_manager = codex_core::test_support::auth_manager_from_auth(
CodexAuth::from_api_key("Test API Key"),
);
let auth_manager =
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"));
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = SessionTelemetry::new(
@@ -168,8 +167,11 @@ async fn fork_session_overlay_open_from_inline_viewport_snapshot() {
let height = 28;
let mut app = make_test_app().await;
configure_chat_widget(&mut app);
app.chat_widget
.set_composer_text("Summarize recent commits".to_string(), Vec::new(), Vec::new());
app.chat_widget.set_composer_text(
"Summarize recent commits".to_string(),
Vec::new(),
Vec::new(),
);
app.transcript_cells = vec![
Arc::new(history_cell::new_user_prompt(
"count to 10".to_string(),

View File

@@ -0,0 +1,18 @@
---
source: tui/src/app/fork_session_overlay.rs
expression: snapshot_buffer(&buf)
---
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ model: gpt-5.3-codex /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
background session
Ask Codex to do anything
? for shortcuts 100% context left

View File

@@ -8,7 +8,7 @@ expression: snapshot_buffer(&buf)
│ model: gpt-5.3-codex /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
╭ fork session running popup focus ctrl+] prefix───────────
╭ fork session running popup focus ^O switch ctrl+] prefix╮
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │

View File

@@ -8,7 +8,7 @@ expression: snapshot_buffer(&buf)
│ model: gpt-5.3-codex /model to change │
│ directory: ~/code/codex/codex-rs/tui │
╰─────────────────────────────────────────────╯
╭ fork session running background focus ctrl+] prefix──────
╭ fork session running background focus ^O switch ctrl+] p
│Independent Codex session │
background sessi│ │
│> /tmp/worktree │

View File

@@ -251,14 +251,35 @@ 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_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
for cell in &self.transcript_cells {
tui.insert_history_lines(cell.display_lines(width));
}
let width = tui.terminal.last_known_screen_size.width;
let lines = self.transcript_history_lines(width);
if !lines.is_empty() {
tui.insert_history_lines(lines);
}
}
pub(crate) fn transcript_history_lines(&self, width: u16) -> Vec<ratatui::text::Line<'static>> {
let mut lines = Vec::new();
let mut has_lines = false;
for cell in &self.transcript_cells {
let mut display = cell.display_lines(width);
if display.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_lines {
lines.push("".into());
} else {
has_lines = true;
}
}
lines.append(&mut display);
}
lines
}
/// Initialize backtrack state and show composer hint.
fn prime_backtrack(&mut self) {
self.backtrack.primed = true;

View File

@@ -4,8 +4,8 @@ use std::io::Write;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::WindowSize;
use ratatui::backend::CrosstermBackend;
use ratatui::backend::WindowSize;
use ratatui::buffer::Cell;
use ratatui::layout::Position;
use ratatui::layout::Size;
@@ -99,11 +99,7 @@ impl Backend for VT100Backend {
self.crossterm_backend.writer_mut().flush()
}
fn scroll_region_up(
&mut self,
region: std::ops::Range<u16>,
scroll_by: u16,
) -> io::Result<()> {
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, scroll_by: u16) -> io::Result<()> {
self.crossterm_backend.scroll_region_up(region, scroll_by)
}