feat(tui/theme): allow Left/Right to mirror Up/Down; enable Save/Retry navigation via arrows

- Treat Left/Right as Up/Down across Theme/Spinner views, including Create flows.\n- In review screens, Left focuses preview toggle (themes) and cycles buttons; Right moves to Save/Retry.\n- Update title hint to display '▲ ▼ ◀ ▶ select'.\n\nAlso includes other local TUI/core adjustments present in the working tree. Verified with ./build-fast.sh.
This commit is contained in:
James Peter
2025-09-12 16:21:45 +10:00
parent 6be2331872
commit 2994466b77
13 changed files with 677 additions and 57 deletions
+30 -3
View File
@@ -458,14 +458,24 @@ async fn process_chat_sse<S>(
return;
}
Ok(None) => {
// Stream closed gracefully – emit Completed with dummy id.
// Stream closed by server without an explicit end marker – log for diagnostics
tracing::debug!("chat SSE stream closed without [DONE] marker");
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"stream_closed_without_done",
&serde_json::json!({
"assistant_len": assistant_text.len(),
"reasoning_len": reasoning_text.len(),
}),
);
}
let _ = tx_event
.send(Ok(ResponseEvent::Completed {
response_id: String::new(),
token_usage: None,
}))
.await;
// Mark the request log as complete
if let Ok(logger) = debug_logger.lock() {
let _ = logger.end_request_log(&request_id);
}
@@ -525,7 +535,24 @@ async fn process_chat_sse<S>(
// Parse JSON chunk
let chunk: serde_json::Value = match serde_json::from_str(&sse.data) {
Ok(v) => v,
Err(_) => continue,
Err(e) => {
// Surface parse errors to logs and debug logger for diagnostics, then skip
let mut excerpt = sse.data.clone();
const MAX: usize = 600;
if excerpt.len() > MAX { excerpt.truncate(MAX); }
tracing::debug!("chat SSE parse error: {} | data: {}", e, excerpt);
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"sse_parse_error",
&serde_json::json!({
"error": e.to_string(),
"data_excerpt": excerpt,
}),
);
}
continue;
}
};
trace!("chat_completions received SSE chunk: {chunk:?}");
+15 -1
View File
@@ -647,7 +647,21 @@ async fn process_sse<S>(
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
debug!("Failed to parse SSE event: {e}, data: {}", &sse.data);
// Log parse error with data excerpt, and record it in the debug logger as well.
let mut excerpt = sse.data.clone();
const MAX: usize = 600;
if excerpt.len() > MAX { excerpt.truncate(MAX); }
debug!("Failed to parse SSE event: {e}, data: {excerpt}");
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
"sse_parse_error",
&serde_json::json!({
"error": e.to_string(),
"data_excerpt": excerpt,
}),
);
}
continue;
}
};
+27
View File
@@ -537,6 +537,33 @@ pub fn set_custom_theme(
Ok(())
}
/// Persist the alternate screen preference into `CODEX_HOME/config.toml` at `[tui].alternate_screen`.
pub fn set_tui_alternate_screen(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Parse existing config if present; otherwise start a new document.
let mut doc = match std::fs::read_to_string(config_path.clone()) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Write `[tui].alternate_screen = true/false`
doc["tui"]["alternate_screen"] = toml_edit::value(enabled);
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
// create a tmp_file
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
// atomically move the tmp file into config.toml
tmp_file.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+7
View File
@@ -148,6 +148,13 @@ pub struct Tui {
/// Loading spinner style selection
#[serde(default)]
pub spinner: SpinnerSelection,
/// Whether to use the terminal's Alternate Screen (full-screen) mode.
/// When false, Codex renders nothing and leaves the standard terminal
/// buffer visible; users can toggle back to Alternate Screen at runtime
/// with Ctrl+T. Defaults to true.
#[serde(default = "default_true")]
pub alternate_screen: bool,
}
/// Streaming behavior configuration for the TUI.
+110 -9
View File
@@ -17,12 +17,12 @@ use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::supports_keyboard_enhancement;
use crossterm::SynchronizedUpdate; // trait for stdout().sync_update
use std::path::PathBuf;
use ratatui::prelude::Rect;
use ratatui::text::Line;
@@ -114,6 +114,10 @@ pub(crate) struct App<'a> {
/// If true, enable lightweight timing collection and report on exit.
timing_enabled: bool,
timing: TimingStats,
/// True when TUI is currently rendering in the terminal's alternate screen.
alt_screen_active: bool,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -271,6 +275,7 @@ impl App<'_> {
};
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let start_in_alt = config.tui.alternate_screen;
Self {
_server: conversation_manager,
app_event_tx,
@@ -295,6 +300,7 @@ impl App<'_> {
last_esc_time: None,
timing_enabled: enable_perf,
timing: TimingStats::default(),
alt_screen_active: start_in_alt,
}
}
@@ -361,21 +367,56 @@ impl App<'_> {
lines.append(&mut more);
}
tracing::debug!("app: InsertHistory lines={}", lines.len());
widget.insert_history_lines(lines)
if self.alt_screen_active {
widget.insert_history_lines(lines)
} else {
use std::io::stdout;
// Compute desired bottom height now, so growing/shrinking input
// adjusts the reserved region immediately even before the next frame.
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, lines);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
AppEvent::InsertHistoryWithKind { id, kind, lines } => match &mut self.app_state {
AppState::Chat { widget } => {
tracing::debug!("app: InsertHistoryWithKind kind={:?} id={:?} lines={}", kind, id, lines.len());
widget.insert_history_lines_with_kind(kind, id, lines)
// Always update widget history, even in terminal mode.
// In terminal mode, the widget will emit an InsertHistory event
// which we will mirror to scrollback in the handler above.
let to_mirror = lines.clone();
widget.insert_history_lines_with_kind(kind, id, lines);
if !self.alt_screen_active {
use std::io::stdout;
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, to_mirror);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
AppEvent::InsertFinalAnswer { id, lines, source } => match &mut self.app_state {
AppState::Chat { widget } => {
tracing::debug!("app: InsertFinalAnswer id={:?} lines={} source_len={}", id, lines.len(), source.len());
widget.insert_final_answer_with_id(id, lines, source)
let to_mirror = lines.clone();
widget.insert_final_answer_with_id(id, lines, source);
if !self.alt_screen_active {
use std::io::stdout;
let width = terminal.size().map(|s| s.width).unwrap_or(80);
let reserve = widget.desired_bottom_height(width).max(1);
let _ = execute!(stdout(), crossterm::terminal::BeginSynchronizedUpdate);
crate::insert_history::insert_history_lines_above(terminal, reserve, to_mirror);
let _ = execute!(stdout(), crossterm::terminal::EndSynchronizedUpdate);
self.schedule_redraw();
}
},
AppState::Onboarding { .. } => {}
},
@@ -601,18 +642,18 @@ impl App<'_> {
}
}
KeyEvent {
code: KeyCode::Char('r') | KeyCode::Char('t'),
code: KeyCode::Char('r'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('r') | KeyCode::Char('t'),
code: KeyCode::Char('r'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Repeat,
..
} => {
// Toggle reasoning/thinking visibility (Ctrl+R or Ctrl+T)
// Toggle reasoning/thinking visibility (Ctrl+R)
match &mut self.app_state {
AppState::Chat { widget } => {
widget.toggle_reasoning_visibility();
@@ -620,6 +661,18 @@ impl App<'_> {
AppState::Onboarding { .. } => {}
}
}
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let _ = self.toggle_screen_mode(terminal);
// Propagate mode to widget so it can adapt layout
if let AppState::Chat { widget } = &mut self.app_state {
widget.standard_terminal_mode = !self.alt_screen_active;
}
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -1232,7 +1285,9 @@ impl App<'_> {
}
}
}
terminal.clear()?;
if self.alt_screen_active {
terminal.clear()?;
}
Ok(())
}
@@ -1273,6 +1328,50 @@ impl App<'_> {
Ok(())
}
/// Toggle between alternate-screen TUI and standard terminal buffer (Ctrl+T).
fn toggle_screen_mode(&mut self, _terminal: &mut tui::Tui) -> Result<()> {
if self.alt_screen_active {
// Leave alt screen only; keep raw mode enabled for key handling.
let _ = crate::tui::leave_alt_screen_only();
// Clear the normal buffer so our buffered transcript starts at a clean screen
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::style::ResetColor,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::EnableLineWrap
);
self.alt_screen_active = false;
// Persist preference
let _ = codex_core::config::set_tui_alternate_screen(&self.config.codex_home, false);
// Immediately mirror the entire transcript into the terminal scrollback so
// the user sees full history when entering standard mode.
if let AppState::Chat { widget } = &self.app_state {
let transcript = widget.export_transcript_lines_for_buffer();
if !transcript.is_empty() {
// Best-effort: compute current width and bottom reservation.
// We don't have `terminal` here; schedule a one-shot redraw event
// that carries the transcript via InsertHistory to reuse the normal path.
self.app_event_tx.send(AppEvent::InsertHistory(transcript));
}
}
// Ensure the input is painted in its reserved region immediately.
self.schedule_redraw();
} else {
// Re-enter alt screen and force a clean repaint.
let fg = crate::colors::text();
let bg = crate::colors::background();
let _ = crate::tui::enter_alt_screen_only(fg, bg);
self.clear_on_first_frame = true;
self.alt_screen_active = true;
// Persist preference
let _ = codex_core::config::set_tui_alternate_screen(&self.config.codex_home, true);
// Request immediate redraw
self.schedule_redraw();
}
Ok(())
}
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
let usage = match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
@@ -1299,10 +1398,12 @@ impl App<'_> {
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
// Always render a frame. In standard-terminal mode we still draw the
// chat UI (without status/HUD) directly into the normal buffer.
// Hard clear on the very first frame (and while onboarding) to ensure a
// clean background across terminals that don't respect our color attrs
// during EnterAlternateScreen.
if self.clear_on_first_frame || matches!(self.app_state, AppState::Onboarding { .. }) {
if self.alt_screen_active && (self.clear_on_first_frame || matches!(self.app_state, AppState::Onboarding { .. })) {
terminal.clear()?;
self.clear_on_first_frame = false;
}
@@ -1595,6 +1595,10 @@ impl WidgetRef for ChatComposer {
spans.push(Span::from("Ctrl+D").style(key_hint_style));
spans.push(Span::from(" diff viewer").style(label_style));
}
// Screen toggle hint is always available when space permits
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+T").style(key_hint_style));
spans.push(Span::from(" screen").style(label_style));
// Always show help at the end of the command hints
if !spans.is_empty() { spans.push(Span::from(" • ").style(label_style)); }
spans.push(Span::from("Ctrl+H").style(key_hint_style));
@@ -310,7 +310,7 @@ impl ThemeSelectionView {
);
// Build developer guidance and input
let developer = "You are performing a custom task to create a terminal spinner.\n\nRequirements:\n- Output JSON ONLY, no prose.\n- `interval` is the delay in milliseconds between frames; MUST be between 50 and 300 inclusive.\n- `frames` is an array of strings; each element is a frame displayed sequentially at the given interval.\n- The spinner SHOULD have between 2 and 50 frames.\n- Each frame SHOULD be between 1 and 20 characters wide. ALL frames MUST be the SAME width (same number of characters). If you propose frames with varying widths, PAD THEM ON THE LEFT with spaces so they are uniform.\n- You MAY use ASCII and Unicode characters (e.g., box drawing, braille, arrows). Use EMOJIS ONLY if the user explicitly requests emojis in their prompt.\n- Use characters that render well in typical monospaced terminals.\n".to_string();
let developer = "You are performing a custom task to create a terminal spinner.\n\nRequirements:\n- Output JSON ONLY, no prose.\n- `interval` is the delay in milliseconds between frames; MUST be between 50 and 300 inclusive.\n- `frames` is an array of strings; each element is a frame displayed sequentially at the given interval.\n- The spinner SHOULD have between 2 and 60 frames.\n- Each frame SHOULD be between 1 and 30 characters wide. ALL frames MUST be the SAME width (same number of characters). If you propose frames with varying widths, PAD THEM ON THE LEFT with spaces so they are uniform.\n- You MAY use both ASCII and Unicode characters (e.g., box drawing, braille, arrows). Use EMOJIS ONLY if the user explicitly requests emojis in their prompt.\n- Be creative! You have the full range of Unicode to play with!\n".to_string();
let mut input: Vec<codex_protocol::models::ResponseItem> = Vec::new();
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "developer".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: developer }] });
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "user".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: user_prompt }] });
@@ -320,13 +320,13 @@ impl ThemeSelectionView {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 40, "description": "Display name for the spinner (1 - 3 words, shown in the UI)."},
"interval": {"type": "integer", "minimum": 50, "maximum": 300, "description": "Delay between frames in milliseconds (50–300)."},
"interval": {"type": "integer", "minimum": 50, "maximum": 300, "description": "Delay between frames in milliseconds (50 - 300)."},
"frames": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 20},
"items": {"type": "string", "minLength": 1, "maxLength": 30},
"minItems": 2,
"maxItems": 50,
"description": "2–50 Spinner frames, 1–20 characters each (every frame should be the same length of characters)."
"maxItems": 60,
"description": "2 - 60 frames, 1 - 30 characters each (every frame should be the same length of characters)."
}
},
"required": ["name", "interval", "frames"],
@@ -345,6 +345,8 @@ impl ThemeSelectionView {
let mut stream = match client.stream(&prompt).await { Ok(s) => s, Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Request error: {}", e))); tracing::info!("spinner request error: {}", e); return; } };
let mut out = String::new();
let mut think_sum = String::new();
// Capture the last transport/stream error so we can surface it to the UI
let mut last_err: Option<String> = None;
while let Some(ev) = stream.next().await {
match ev {
Ok(codex_core::ResponseEvent::Created) => { tracing::info!("LLM: created"); let _ = progress_tx.send(ProgressMsg::SetStatus("(starting generation)".to_string())); }
@@ -358,13 +360,30 @@ impl ThemeSelectionView {
tracing::info!(target: "spinner", "LLM[item_done]");
}
Ok(codex_core::ResponseEvent::Completed { .. }) => { tracing::info!("LLM: completed"); break; }
Err(e) => { tracing::info!("LLM stream error: {}", e); }
Err(e) => {
let msg = format!("{}", e);
tracing::info!("LLM stream error: {}", msg);
last_err = Some(msg);
break; // Stop consuming after a terminal transport error
}
_ => {}
}
}
let _ = progress_tx.send(ProgressMsg::RawOutput(out.clone()));
// If we received no content at all, surface the transport error explicitly
if out.trim().is_empty() {
let err = last_err
.map(|e| format!("model stream error: {}", e))
.unwrap_or_else(|| "model stream returned no content".to_string());
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: err,
_raw_snippet: String::new(),
});
return;
}
// Parse JSON; on failure, attempt to salvage a top-level object and log raw output
let v: serde_json::Value = match serde_json::from_str(&out) {
Ok(v) => v,
@@ -395,10 +414,24 @@ impl ThemeSelectionView {
if let Some(obj) = extract_first_json_object(&out) {
match serde_json::from_str::<serde_json::Value>(&obj) {
Ok(v) => v,
Err(e2) => { let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e2), _raw_snippet: out.chars().take(200).collect::<String>() }); return; }
Err(e2) => {
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: format!("{}", e2),
_raw_snippet: out.chars().take(200).collect::<String>(),
});
return;
}
}
} else {
let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e), _raw_snippet: out.chars().take(200).collect::<String>() }); return;
// Prefer a clearer message if we saw a transport error
let msg = last_err
.map(|le| format!("model stream error: {}", le))
.unwrap_or_else(|| format!("{}", e));
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: msg,
_raw_snippet: out.chars().take(200).collect::<String>(),
});
return;
}
}
};
@@ -621,8 +654,16 @@ impl ThemeSelectionView {
use futures::StreamExt;
let _ = progress_tx.send(ProgressMsg::ThinkingDelta("(connecting to model)".to_string()));
let mut stream = match client.stream(&prompt).await { Ok(s) => s, Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Request error: {}", e))); return; } };
let mut stream = match client.stream(&prompt).await {
Ok(s) => s,
Err(e) => {
tx.send(AppEvent::InsertBackgroundEventEarly(format!("Request error: {}", e)));
return;
}
};
let mut out = String::new();
// Capture the last transport/stream error so we can surface it to the UI
let mut last_err: Option<String> = None;
while let Some(ev) = stream.next().await {
match ev {
Ok(codex_core::ResponseEvent::Created) => {
@@ -649,13 +690,27 @@ impl ThemeSelectionView {
}
Ok(codex_core::ResponseEvent::Completed { .. }) => break,
Err(e) => {
let _ = progress_tx.send(ProgressMsg::ThinkingDelta(format!("(stream error: {})", e)));
let msg = format!("{}", e);
let _ = progress_tx.send(ProgressMsg::ThinkingDelta(format!("(stream error: {})", msg)));
last_err = Some(msg);
break; // Stop consuming after a terminal transport error
}
_ => {}
}
}
let _ = progress_tx.send(ProgressMsg::RawOutput(out.clone()));
// If we received no content at all, surface the transport error explicitly
if out.trim().is_empty() {
let err = last_err
.map(|e| format!("model stream error: {}", e))
.unwrap_or_else(|| "model stream returned no content".to_string());
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: err,
_raw_snippet: String::new(),
});
return;
}
// Try strict parse first; if that fails, salvage the first JSON object in the text.
let v: serde_json::Value = match serde_json::from_str(&out) {
Ok(v) => v,
@@ -686,12 +741,22 @@ impl ThemeSelectionView {
match serde_json::from_str::<serde_json::Value>(&obj) {
Ok(v) => v,
Err(e2) => {
let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e2), _raw_snippet: out.chars().take(200).collect() });
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: format!("{}", e2),
_raw_snippet: out.chars().take(200).collect(),
});
return;
}
}
} else {
let _ = progress_tx.send(ProgressMsg::CompletedErr { error: format!("{}", e), _raw_snippet: out.chars().take(200).collect() });
// Prefer a clearer message if we saw a transport error
let msg = last_err
.map(|le| format!("model stream error: {}", le))
.unwrap_or_else(|| format!("{}", e));
let _ = progress_tx.send(ProgressMsg::CompletedErr {
error: msg,
_raw_snippet: out.chars().take(200).collect(),
});
return;
}
}
@@ -754,6 +819,8 @@ struct CreateState {
proposed_interval: std::cell::Cell<Option<u64>>,
proposed_frames: std::cell::RefCell<Option<Vec<String>>>,
proposed_name: std::cell::RefCell<Option<String>>,
/// Last raw model output captured (for debugging parse errors)
last_raw_output: std::cell::RefCell<Option<String>>,
}
struct CreateThemeState {
@@ -942,12 +1009,93 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
code: KeyCode::Left,
modifiers: KeyModifiers::NONE,
..
} => {}
} => {
// Treat Left like Up for navigation everywhere in this view
if let Mode::CreateSpinner(ref mut s) = self.mode {
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => {
if s.action_idx > 0 {
s.action_idx -= 1;
}
CreateStep::Action
}
CreateStep::Review => {
if s.action_idx > 0 {
s.action_idx -= 1;
}
CreateStep::Review
}
};
s.step.set(new_step);
} else if let Mode::CreateTheme(ref mut s) = self.mode {
match s.step.get() {
CreateStep::Prompt => s.step.set(CreateStep::Action),
CreateStep::Action => {
if s.action_idx > 0 {
s.action_idx -= 1;
}
}
CreateStep::Review => {
// In review, Left focuses the toggle (mirrors Up)
s.review_focus_is_toggle.set(true);
}
}
} else {
match self.mode {
Mode::Overview => {
self.overview_selected_index =
self.overview_selected_index.saturating_sub(1) % 3;
}
_ => self.move_selection_up(),
}
}
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::NONE,
..
} => {}
} => {
// Treat Right like Down for navigation everywhere in this view
if let Mode::CreateSpinner(ref mut s) = self.mode {
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => {
if s.action_idx < 1 {
s.action_idx += 1;
}
CreateStep::Action
}
CreateStep::Review => {
if s.action_idx < 1 {
s.action_idx += 1;
}
CreateStep::Review
}
};
s.step.set(new_step);
} else if let Mode::CreateTheme(ref mut s) = self.mode {
match s.step.get() {
CreateStep::Prompt => s.step.set(CreateStep::Action),
CreateStep::Action => {
if s.action_idx < 1 {
s.action_idx += 1;
}
}
CreateStep::Review => {
// In review, Right moves focus to buttons (mirrors Down)
s.review_focus_is_toggle.set(false);
}
}
} else {
match &self.mode {
Mode::Overview => {
self.overview_selected_index = (self.overview_selected_index + 1) % 3;
}
_ => self.move_selection_down(),
}
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -1024,6 +1172,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
proposed_interval: std::cell::Cell::new(None),
proposed_frames: std::cell::RefCell::new(None),
proposed_name: std::cell::RefCell::new(None),
last_raw_output: std::cell::RefCell::new(None),
});
} else {
self.confirm_spinner()
@@ -1285,7 +1434,11 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
_ => self.cancel_detail(),
},
KeyEvent { code: KeyCode::Char(c), modifiers, .. } => {
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
if s.is_loading.get() {
return;
@@ -1309,7 +1462,10 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
}
}
KeyEvent { code: KeyCode::Backspace, .. } => {
KeyEvent {
code: KeyCode::Backspace,
..
} => {
if let Mode::CreateSpinner(ref mut s) = self.mode {
if s.is_loading.get() {
return;
@@ -1376,7 +1532,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
let mut title_spans = vec![Span::styled(" ", t_dim), Span::styled("/theme", t_fg)];
title_spans.extend_from_slice(&[
Span::styled(" ——— ", t_dim),
Span::styled("▲ ▼", t_fg),
Span::styled("▲ ▼ ◀ ▶", t_fg),
Span::styled(" select ", t_dim),
Span::styled("——— ", t_dim),
Span::styled("Enter", t_fg),
@@ -1635,7 +1791,11 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::RawOutput(_raw)) => {}
Ok(ProgressMsg::RawOutput(raw)) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
sm.last_raw_output.replace(Some(raw));
}
}
Ok(ProgressMsg::SetStatus(s)) => {
if let Mode::CreateSpinner(ref sm) = self.mode {
let mut cur = sm.thinking_current.borrow_mut();
@@ -1924,6 +2084,18 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
last,
Style::default().fg(crate::colors::error()),
)));
if let Some(raw) = s.last_raw_output.borrow().as_ref() {
form_lines.push(Line::from(Span::styled(
"Model output (raw):",
Style::default().fg(theme.text_dim),
)));
for ln in raw.split('\n') {
form_lines.push(Line::from(Span::styled(
ln.to_string(),
Style::default().fg(theme.text),
)));
}
}
form_lines.push(Line::default());
}
}
+122 -7
View File
@@ -292,6 +292,9 @@ pub(crate) struct ChatWidget<'a> {
// Track and manage the access-mode background status cell so mode changes
// replace the existing status instead of stacking multiple entries.
access_status_idx: Option<usize>,
/// When true, render without the top status bar and HUD so the normal
/// terminal scrollback remains usable (Ctrl+T standard terminal mode).
pub(crate) standard_terminal_mode: bool,
// Pending one-shot note about the latest access-mode change to inject into
// the agent's conversation history on the next user message.
pending_access_note: Option<String>,
@@ -1750,10 +1753,12 @@ impl ChatWidget<'_> {
last_history_viewport_height: std::cell::Cell::new(0),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
scrollbar_visible_until: std::cell::Cell::new(None),
last_bottom_reserved_rows: std::cell::Cell::new(0),
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
last_frame_height: std::cell::Cell::new(0),
last_frame_width: std::cell::Cell::new(0),
},
prefix_sums: std::cell::RefCell::new(Vec::new()),
last_prefix_width: std::cell::Cell::new(0),
@@ -1788,6 +1793,7 @@ impl ChatWidget<'_> {
pending_access_note: None,
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
standard_terminal_mode: !config.tui.alternate_screen,
};
// Seed footer access indicator based on current config
new_widget.apply_access_mode_indicator_from_config();
@@ -1795,6 +1801,7 @@ impl ChatWidget<'_> {
// appears below it. Also insert the Popular commands immediately so users
// don't wait for MCP initialization to finish.
let mut w = new_widget;
w.standard_terminal_mode = !config.tui.alternate_screen;
w.history_push_top_next_req(history_cell::new_animated_welcome()); // tag: prelude
let connecting_mcp = !w.config.mcp_servers.is_empty();
w.history_push_top_next_req(history_cell::new_popular_commands_notice(false)); // tag: prelude
@@ -1925,10 +1932,12 @@ impl ChatWidget<'_> {
last_history_viewport_height: std::cell::Cell::new(0),
vertical_scrollbar_state: std::cell::RefCell::new(ScrollbarState::default()),
scrollbar_visible_until: std::cell::Cell::new(None),
last_bottom_reserved_rows: std::cell::Cell::new(0),
last_hud_present: std::cell::Cell::new(false),
browser_hud_expanded: false,
agents_hud_expanded: false,
last_frame_height: std::cell::Cell::new(0),
last_frame_width: std::cell::Cell::new(0),
},
prefix_sums: std::cell::RefCell::new(Vec::new()),
last_prefix_width: std::cell::Cell::new(0),
@@ -1960,6 +1969,7 @@ impl ChatWidget<'_> {
show_order_overlay,
scroll_history_hint_shown: false,
access_status_idx: None,
standard_terminal_mode: !config.tui.alternate_screen,
pending_access_note: None,
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
@@ -2462,6 +2472,7 @@ impl ChatWidget<'_> {
};
self.history_cells.insert(pos, cell);
// In terminal mode, App mirrors history lines into the native buffer.
// Ensure order vector is also long enough for position after cell insert
if self.cell_order_seq.len() < pos {
self.cell_order_seq.resize(
@@ -4388,7 +4399,7 @@ impl ChatWidget<'_> {
// Global
lines.push(kv("Ctrl+H", "Help overlay"));
lines.push(kv("Ctrl+R", "Toggle reasoning"));
// Removed Ctrl+T per request
lines.push(kv("Ctrl+T", "Toggle screen"));
lines.push(kv("Ctrl+D", "Diff viewer"));
lines.push(kv("Esc", "Edit previous message / close popups"));
// Task control shortcuts
@@ -5855,6 +5866,15 @@ impl ChatWidget<'_> {
// Collapsed state changes affect heights; clear cache
self.invalidate_height_cache();
self.request_redraw();
// In standard terminal mode, re-mirror the transcript so scrollback reflects
// the new collapsed/expanded state. We cannot edit prior lines in scrollback,
// so append a fresh view.
if self.standard_terminal_mode {
let mut lines = Vec::new();
lines.push(ratatui::text::Line::from(""));
lines.extend(self.export_transcript_lines_for_buffer());
self.app_event_tx.send(crate::app_event::AppEvent::InsertHistory(lines));
}
}
pub(crate) fn is_reasoning_shown(&self) -> bool {
@@ -7876,6 +7896,71 @@ impl ChatWidget<'_> {
);
}
/// Export transcript for buffer-mode mirroring: omit internal sentinels
/// and include gutter icons and a blank line between items for readability.
pub(crate) fn export_transcript_lines_for_buffer(&self) -> Vec<ratatui::text::Line<'static>> {
let mut out: Vec<ratatui::text::Line<'static>> = Vec::new();
for cell in &self.history_cells {
out.extend(self.render_lines_for_terminal(cell.as_ref()));
}
// Include streaming preview if present (treat like assistant output)
let mut streaming_lines = self
.live_builder
.display_rows()
.into_iter()
.map(|r| ratatui::text::Line::from(r.text))
.collect::<Vec<_>>();
if !streaming_lines.is_empty() {
// Apply gutter to streaming preview (first line gets " • ", continuations get 3 spaces)
if let Some(first) = streaming_lines.first_mut() {
first.spans.insert(0, ratatui::text::Span::raw(" • "));
}
for line in streaming_lines.iter_mut().skip(1) {
line.spans.insert(0, ratatui::text::Span::raw(" "));
}
out.extend(streaming_lines);
out.push(ratatui::text::Line::from(""));
}
out
}
/// Render a single history cell into terminal-friendly lines:
/// - Prepend a gutter icon (symbol + space) to the first line when defined.
/// - Add a single blank line after the cell as a separator.
fn render_lines_for_terminal(&self, cell: &dyn crate::history_cell::HistoryCell) -> Vec<ratatui::text::Line<'static>> {
let mut lines = cell.display_lines();
let _has_icon = cell.gutter_symbol().is_some();
let first_prefix = if let Some(sym) = cell.gutter_symbol() {
format!(" {} ", sym) // one space, icon, one space
} else {
" ".to_string() // three spaces when no icon
};
if let Some(first) = lines.first_mut() {
first
.spans
.insert(0, ratatui::text::Span::raw(first_prefix));
}
// For wrapped/subsequent lines, use a 3-space gutter to maintain alignment
if lines.len() > 1 {
for (_idx, line) in lines.iter_mut().enumerate().skip(1) {
// Always 3 spaces for continuation lines
line.spans.insert(0, ratatui::text::Span::raw(" "));
}
}
lines.push(ratatui::text::Line::from(""));
lines
}
/// Desired bottom pane height (in rows) for a given terminal width.
pub(crate) fn desired_bottom_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
}
/// The last bottom pane height (rows) that the layout actually used.
/// If not yet set, fall back to a conservative estimate from BottomPane.
/// Clear the conversation and start fresh with a new welcome animation
pub(crate) fn new_conversation(&mut self, enhanced_keys_supported: bool) {
// Clear all history cells
@@ -9768,7 +9853,7 @@ impl WidgetRef for &ChatWidget<'_> {
// spacing can show through after we reduced full clears.
// Cost: one Block render across the frame (O(area)); acceptable and
// fixes visual artifacts reported after redraw reductions.
{
if !self.standard_terminal_mode {
use ratatui::style::Style;
use ratatui::widgets::Block;
let bg = Block::default().style(Style::default().bg(crate::colors::background()));
@@ -9777,6 +9862,7 @@ impl WidgetRef for &ChatWidget<'_> {
// Remember full frame height for HUD sizing logic
self.layout.last_frame_height.set(area.height);
self.layout.last_frame_width.set(area.width);
let layout_areas = self.layout_areas(area);
let (status_bar_area, hud_area, history_area, bottom_pane_area) = if layout_areas.len() == 4
@@ -9793,12 +9879,29 @@ impl WidgetRef for &ChatWidget<'_> {
(layout_areas[0], None, layout_areas[1], layout_areas[2])
};
// Render status bar
self.render_status_bar(status_bar_area, buf);
// Record the effective bottom pane height for buffer-mode scrollback inserts.
self.layout
.last_bottom_reserved_rows
.set(bottom_pane_area.height);
// Render HUD if present (browser and/or agents)
if let Some(hud_area) = hud_area {
self.render_hud(hud_area, buf);
// Render status bar and HUD only in full TUI mode
if !self.standard_terminal_mode {
self.render_status_bar(status_bar_area, buf);
if let Some(hud_area) = hud_area {
self.render_hud(hud_area, buf);
}
}
// In standard-terminal mode, do not paint the history region: committed
// content is appended to the terminal's own scrollback via
// insert_history_lines and repainting here would overwrite it.
if self.standard_terminal_mode {
// Render only the bottom pane (composer or its active view) without painting
// backgrounds to preserve the terminal's native theme.
ratatui::widgets::WidgetRef::render_ref(&(&self.bottom_pane), bottom_pane_area, buf);
// Scrub backgrounds in the bottom pane region so any widget-set bg becomes transparent.
self.clear_backgrounds_in(buf, bottom_pane_area);
return;
}
// Create a unified scrollable container for all chat content
@@ -11165,11 +11268,14 @@ struct LayoutState {
vertical_scrollbar_state: std::cell::RefCell<ScrollbarState>,
// Auto-hide scrollbar timer
scrollbar_visible_until: std::cell::Cell<Option<std::time::Instant>>,
// Last effective bottom pane height used by layout (rows)
last_bottom_reserved_rows: std::cell::Cell<u16>,
// HUD visibility and sizing
last_hud_present: std::cell::Cell<bool>,
browser_hud_expanded: bool,
agents_hud_expanded: bool,
last_frame_height: std::cell::Cell<u16>,
last_frame_width: std::cell::Cell<u16>,
}
#[derive(Default)]
@@ -11204,6 +11310,15 @@ struct PerfState {
}
impl ChatWidget<'_> {
fn clear_backgrounds_in(&self, buf: &mut Buffer, rect: Rect) {
for y in rect.y..rect.y.saturating_add(rect.height) {
for x in rect.x..rect.x.saturating_add(rect.width) {
let cell = &mut buf[(x, y)];
// Reset background; keep fg/content as-is
cell.set_bg(ratatui::style::Color::Reset);
}
}
}
pub(crate) fn set_github_watcher(&mut self, enabled: bool) {
self.config.github.check_workflows_on_push = enabled;
match find_codex_home() {
+11 -2
View File
@@ -133,7 +133,8 @@ pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
.map(|lock| lock.is_some())
.unwrap_or(false);
let has_active_agents = !chat.active_agents.is_empty() || chat.agents_ready_to_start;
let hud_present = has_browser_screenshot || has_active_agents;
// In standard terminal mode, suppress HUD entirely.
let hud_present = if chat.standard_terminal_mode { false } else { has_browser_screenshot || has_active_agents };
let bottom_desired = chat.bottom_pane.desired_height(area.width);
let font_cell = chat.measured_font_size();
@@ -161,5 +162,13 @@ pub(super) fn layout_areas(chat: &ChatWidget<'_>, area: Rect) -> Vec<Rect> {
Some(target)
};
hm.begin_frame(area, hud_present, bottom_desired, font_cell, hud_target)
hm.begin_frame(
area,
hud_present,
bottom_desired,
font_cell,
hud_target,
// Disable status bar when in standard terminal mode
!chat.standard_terminal_mode,
)
}
+4 -2
View File
@@ -116,6 +116,8 @@ impl HeightManager {
// Optional target height for HUD computed by caller (e.g., stacked/collapsed layout).
// When None, a default aspect-based calculation is used.
hud_target_override: Option<u16>,
// When false, do not reserve rows for the status bar.
status_enabled: bool,
) -> Vec<Rect> {
#[cfg(debug_assertions)]
{
@@ -128,8 +130,8 @@ impl HeightManager {
self.last_area = Some(area);
}
// Status bar height is fixed at 3.
let status_h = 3u16;
// Status bar height is fixed at 3 when enabled.
let status_h = if status_enabled { 3u16 } else { 0u16 };
// Cap the bottom pane to a percentage of screen height, with a minimum of 5 rows.
let percent_cap: u16 = ((area.height as u32).saturating_mul(self.cfg.bottom_percent_cap as u32) / 100) as u16;
+78 -14
View File
@@ -4,7 +4,7 @@ use std::io::Write;
use crate::tui;
use crossterm::Command;
use crossterm::cursor::{MoveTo, MoveToNextLine};
use crossterm::cursor::MoveTo;
use crossterm::queue;
use crossterm::style::Color as CColor;
use crossterm::style::Colors;
@@ -13,7 +13,7 @@ use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use crossterm::terminal::{Clear, ClearType};
// No terminal clears in terminal-mode insertion; preserve user's theme.
use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -47,7 +47,9 @@ pub fn insert_history_lines_to_writer<B, W>(
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines(&lines, area.width.max(1));
// Wrap to the full content width of the viewport in standard mode.
let content_width = area.width.max(1);
let wrapped = word_wrap_lines(&lines, content_width);
let wrapped_lines = wrapped.len() as u16;
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
@@ -95,14 +97,7 @@ pub fn insert_history_lines_to_writer<B, W>(
// └──────────────────────────────┘
queue!(writer, SetScrollRegion(1..area.top())).ok();
// Set theme colors for the newlines and any unstyled content
let theme_fg = crate::colors::text();
let theme_bg = crate::colors::background();
queue!(
writer,
SetColors(Colors::new(theme_fg.into(), theme_bg.into()))
)
.ok();
// Do not force theme colors in terminal mode; let native terminal theme show.
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
@@ -110,10 +105,12 @@ pub fn insert_history_lines_to_writer<B, W>(
queue!(writer, MoveTo(0, cursor_top)).ok();
for line in wrapped {
queue!(writer, MoveToNextLine(1)).ok();
queue!(writer, Clear(ClearType::UntilNewLine)).ok(); // Clear to end of line with current bg
// Emit a real newline so terminals reliably scroll when at the bottom
// of the scroll region. Some terminals do not scroll on CSI E
// (MoveToNextLine); LF is the most portable.
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
queue!(writer, Clear(ClearType::UntilNewLine)).ok(); // Clear remainder of line after content
// Avoid Clear(EOL) painting solid backgrounds over terminal theme.
}
queue!(writer, ResetScrollRegion).ok();
@@ -126,6 +123,73 @@ pub fn insert_history_lines_to_writer<B, W>(
writer.flush().ok();
}
/// Variant of `insert_history_lines` that reserves `reserved_bottom_rows` at the
/// bottom of the screen for a live UI (e.g., the input composer) and inserts
/// history lines into the scrollback above that region.
#[allow(dead_code)]
pub(crate) fn insert_history_lines_above(terminal: &mut tui::Tui, reserved_bottom_rows: u16, lines: Vec<Line>) {
let mut out = std::io::stdout();
insert_history_lines_to_writer_above(terminal, &mut out, reserved_bottom_rows, lines);
}
#[allow(dead_code)]
pub fn insert_history_lines_to_writer_above<B, W>(
terminal: &mut ratatui::Terminal<B>,
writer: &mut W,
reserved_bottom_rows: u16,
lines: Vec<Line>,
) where
B: ratatui::backend::Backend,
W: Write,
{
if lines.is_empty() { return; }
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let cursor_pos = terminal.get_cursor_position().ok();
// Compute the bottom of the reserved region; ensure at least 1 visible row remains
let screen_h = screen_size.height.max(1);
let reserved = reserved_bottom_rows.min(screen_h.saturating_sub(1));
let region_bottom = screen_h.saturating_sub(reserved).max(1);
// Pre-wrap to avoid terminal hard-wrap artifacts
let content_width = screen_size.width.max(1);
let wrapped = word_wrap_lines(&lines, content_width);
if region_bottom <= 1 {
// Degenerate case (startup or unknown size): fall back to simple
// line-by-line prints that let the terminal naturally scroll. This is
// safe before the first bottom-pane draw and avoids a 1-line scroll
// region that would overwrite the same line repeatedly.
for line in word_wrap_lines(&lines, screen_size.width.max(1)) {
write_spans(writer, line.iter()).ok();
queue!(writer, Print("\r\n")).ok();
}
writer.flush().ok();
return;
}
// Limit scroll region to rows [1 .. region_bottom] so the bottom reserved rows are untouched
queue!(writer, SetScrollRegion(1..region_bottom)).ok();
// Place cursor at the last line of the scroll region
queue!(writer, MoveTo(0, region_bottom.saturating_sub(1))).ok();
// Do not force theme colors in terminal mode; let native terminal theme show.
for line in wrapped {
// Ensure we're at the bottom row of the scroll region; printing a newline
// while at the bottom margin scrolls the region by one.
write_spans(writer, line.iter()).ok();
// Newline scrolls the region up by one when at the bottom margin.
queue!(writer, Print("\r\n")).ok();
}
queue!(writer, ResetScrollRegion).ok();
if let Some(cursor_pos) = cursor_pos {
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
}
writer.flush().ok();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReverseIndex;
+13 -1
View File
@@ -295,7 +295,19 @@ fn run_ratatui_app(
prev_hook(info);
}));
let (mut terminal, terminal_info) = tui::init(&config)?;
terminal.clear()?;
if config.tui.alternate_screen {
terminal.clear()?;
} else {
// Start in standard terminal mode: leave alt screen and DO NOT clear
// the normal buffer. We want prior shell history to remain intact and
// new chat output to append inline into scrollback. Ensure line wrap is
// enabled and cursor is left where the shell put it.
let _ = tui::leave_alt_screen_only();
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::terminal::EnableLineWrap
);
}
// Show update banner in terminal history (instead of stderr) so it is visible
// within the TUI scrollback. Building spans keeps styling consistent.
+66
View File
@@ -16,6 +16,8 @@ use crossterm::event::PushKeyboardEnhancementFlags;
use crossterm::style::SetColors;
use crossterm::style::{Color as CtColor, SetBackgroundColor, SetForegroundColor};
use crossterm::style::Print;
use crossterm::style::ResetColor;
use crossterm::cursor::MoveToNextLine;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::Terminal;
@@ -238,6 +240,13 @@ fn set_panic_hook() {
pub fn restore() -> Result<()> {
// Pop may fail on platforms that didn't support the push; ignore errors.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
// Belt-and-suspenders: on terminals that do not maintain a clean stack,
// explicitly set enhancement flags to empty, then pop again. This avoids
// leaving kitty/xterm enhanced keyboard protocols active after exit.
if supports_keyboard_enhancement().unwrap_or(false) {
let _ = execute!(stdout(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::empty()));
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
}
execute!(stdout(), DisableBracketedPaste)?;
// Best‑effort: disable focus change notifications if supported.
let _ = execute!(stdout(), DisableFocusChange);
@@ -245,9 +254,66 @@ pub fn restore() -> Result<()> {
disable_raw_mode()?;
// Leave alternate screen mode
execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;
// Reset colors and move to a fresh line so the shell prompt doesn't
// overlap any residual UI.
execute!(stdout(), ResetColor, MoveToNextLine(1))?;
Ok(())
}
/// Leave only the alternate screen, keeping raw mode and input configuration intact.
/// This is used for the Ctrl+T "standard terminal" mode so users can scroll
/// and select text in the host terminal.
pub fn leave_alt_screen_only() -> Result<()> {
// Best effort: disable mouse capture so selection/scroll works naturally.
let _ = execute!(stdout(), DisableMouseCapture);
// Also disable bracketed paste and focus tracking to avoid escape sequences
// being echoed into the normal buffer by some terminals.
let _ = execute!(stdout(), DisableBracketedPaste);
let _ = execute!(stdout(), DisableFocusChange);
// Pop keyboard enhancement flags so keys like Enter/Arrows don't emit
// enhanced escape sequences (e.g., kitty/xterm modifyOtherKeys) into the buffer.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
if supports_keyboard_enhancement().unwrap_or(false) {
let _ = execute!(stdout(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::empty()));
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
}
execute!(stdout(), crossterm::terminal::LeaveAlternateScreen)?;
Ok(())
}
/// Re-enter the alternate screen without reinitializing global state.
/// Restores title and colors and performs a full clear to ensure a clean frame.
pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui::style::Color) -> Result<()> {
// Re-enable enhanced keyboard and focus/paste signaling for full TUI fidelity.
if supports_keyboard_enhancement().unwrap_or(false) {
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
}
if should_enable_focus_change() {
let _ = execute!(stdout(), EnableFocusChange);
}
let _ = execute!(stdout(), EnableBracketedPaste);
execute!(
stdout(),
crossterm::terminal::EnterAlternateScreen,
SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())),
Clear(ClearType::All),
MoveTo(0, 0),
crossterm::terminal::SetTitle("Code"),
crossterm::terminal::EnableLineWrap
)?;
Ok(())
}
/// Clear the current screen (normal buffer) with the theme background and reset cursor.
// Removed: clear_screen_with_theme — we no longer hard-clear the normal buffer in terminal mode.
/// Determine whether to enable xterm focus change tracking for the current
/// environment. We default to enabling on modern terminals, but disable for
/// known-problematic combinations — especially Windows Terminal + Git Bash