mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
> large behavior change to how the TUI owns its viewport, history, and suspend behavior. > Core model is in place; a few items are still being polished before this is ready to merge. We've moved this over to a new tui2 crate from being directly on the tui crate. To enable use --enable tui2 (or the equivalent in your config.toml). See https://developers.openai.com/codex/local-config#feature-flags Note that this serves as a baseline for the changes that we're making to be applied rapidly. Tui2 may not track later changes in the main tui. It's experimental and may not be where we land on things. --- ## Summary This PR moves the Codex TUI off of “cooperating” with the terminal’s scrollback and onto a model where the in‑memory transcript is the single source of truth. The TUI now owns scrolling, selection, copy, and suspend/exit printing based on that transcript, and only writes to terminal scrollback in append‑only fashion on suspend/exit. It also fixes streaming wrapping so streamed responses reflow with the viewport, and introduces configuration to control whether we print history on suspend or only on exit. High‑level goals: - Ensure history is complete, ordered, and never silently dropped. - Print each logical history cell at most once into scrollback, even with resizes and suspends. - Make scrolling, selection, and copy match the visible transcript, not the terminal’s notion of scrollback. - Keep suspend/alt‑screen behavior predictable across terminals. --- ## Core Design Changes ### Transcript & viewport ownership - Treat the transcript as a list of **cells** (user prompts, agent messages, system/info rows, streaming segments). - On each frame: - Compute a **transcript region** as “full terminal frame minus the bottom input area”. - Flatten all cells into visual lines plus metadata (which cell + which line within that cell). - Use scroll state to choose which visual line is at the top of the region. - Clear that region and draw just the visible slice of lines. - The terminal’s scrollback is no longer part of the live layout algorithm; it is only ever written to when we decide to print history. ### User message styling - User prompts now render as clear blocks with: - A blank padding line above and below. - A full‑width background for every line in the block (including the prompt line itself). - The same block styling is used when we print history into scrollback, so the transcript looks consistent whether you are in the TUI or scrolling back after exit/suspend. --- ## Scrolling, Mouse, Selection, and Copy ### Scrolling - Scrolling is defined in terms of the flattened transcript lines: - Mouse wheel scrolls up/down by fixed line increments. - PgUp/PgDn/Home/End operate on the same scroll model. - The footer shows: - Whether you are “following live output” vs “scrolled up”. - Current scroll position (line / total). - When there is no history yet, the bottom pane is **pegged high** and gradually moves down as the transcript fills, matching the existing UX. ### Selection - Click‑and‑drag defines a **linear selection** over transcript line/column coordinates, not raw screen rows. - Selection is **content‑anchored**: - When you scroll, the selection moves with the underlying lines instead of sticking to a fixed Y position. - This holds both when scrolling manually and when new content streams in, as long as you are in “follow” mode. - The selection only covers the “transcript text” area: - Left gutter/prefix (bullets, markers) is intentionally excluded. - This keeps copy/paste cleaner and avoids including structural margin characters. ### Copy (`Ctrl+Y`) - Introduce a small clipboard abstraction (`ClipboardManager`‑style) and use a cross‑platform clipboard crate under the hood. - When `Ctrl+Y` is pressed and a non‑empty selection exists: - Re‑render the transcript region off‑screen using the same wrapping as the visible viewport. - Walk the selected line/column range over that buffer to reconstruct the exact text: - Includes spaces between words. - Preserves empty lines within the selection. - Send the resulting text to the system clipboard. - Show a short status message in the footer indicating success/failure. - Copy is **best‑effort**: - Clipboard failures (headless environment, sandbox, remote sessions) are handled gracefully via status messages; they do not crash the TUI. - Copy does *not* insert a new history entry; it only affects the status bar. --- ## Streaming and Wrapping ### Previous behavior Previously, streamed markdown: - Was wrapped at a fixed width **at commit time** inside the streaming collector. - Those wrapped `Line<'static>` values were then wrapped again at display time. - As a result, streamed paragraphs could not “un‑wrap” when the terminal width increased; they were permanently split according to the width at the start of the stream. ### New behavior This PR implements the first step from `codex-rs/tui/streaming_wrapping_design.md`: - Streaming collector is constructed **without** a fixed width for wrapping. - It still: - Buffers the full markdown source for the current stream. - Commits only at newline boundaries. - Emits logical lines as new content becomes available. - Agent message cells now wrap streamed content only at **display time**, based on the current viewport width, just like non‑streaming messages. - Consequences: - Streamed responses reflow correctly when the terminal is resized. - Animation steps are per logical line instead of per “pre‑wrapped” visual line; this makes some commits slightly larger but keeps the behavior simple and predictable. Streaming responses are still represented as a sequence of logical history entries (first line + continuations) and integrate with the same scrolling, selection, and printing model. --- ## Printing History on Suspend and Exit ### High‑water mark and append‑only scrollback - Introduce a **cell‑based high‑water mark** (`printed_history_cells`) on the transcript: - Represents “how many cells at the front of the transcript have already been printed”. - Completely independent of wrapped line counts or terminal geometry. - Whenever we print history (suspend or exit): - Take the suffix of `transcript_cells` beyond `printed_history_cells`. - Render just that suffix into styled lines at the **current** width. - Write those lines to stdout. - Advance `printed_history_cells` to cover all cells we just printed. - Older cells are never re‑rendered for scrollback. They stay in whatever wrapping they had when printed, which is acceptable as long as the logical content is present once. ### Suspend (`Ctrl+Z`) - On suspend: - Leave alt screen if active and restore normal terminal modes. - Render the not‑yet‑printed suffix of the transcript and append it to normal scrollback. - Advance the high‑water mark. - Suspend the process. - On resume (`fg`): - Re‑enter the TUI mode (alt screen + input modes). - Clear the viewport region and fully redraw from in‑memory transcript and state. This gives predictable behavior across terminals without trying to maintain scrollback live. ### Exit - On exit: - Render any remaining unprinted cells once and write them to stdout. - Add an extra blank line after the final Codex history cell before printing token usage, so the transcript and usage info are visually separated. - If you never suspended, exit prints the entire transcript exactly once. - If you suspended one or more times, exit prints only the cells appended after the last suspend. --- ## Configuration: Suspend Printing This PR also adds configuration to control **when** we print history: - New TUI config option to gate printing on suspend: - At minimum: - `print_on_suspend = true` – current behavior: print new history at each suspend *and* on exit. - `print_on_suspend = false` – only print on exit. - Default is tuned to preserve current behavior, but this can be revisited based on feedback. - The config is respected in the suspend path: - If disabled, suspend only restores terminal modes and stops rendering but does not print new history. - Exit still prints the full not‑yet‑printed suffix once. This keeps the core viewport logic agnostic to preference, while letting users who care about quiet scrollback opt out of suspend printing. --- ## Tradeoffs What we gain: - A single authoritative history model (the in‑memory transcript). - Deterministic viewport rendering independent of terminal quirks. - Suspend/exit flows that: - Print each logical history cell exactly once. - Work across resizes and different terminals. - Interact cleanly with alt screen and raw‑mode toggling. - Consistent, content‑anchored scrolling, selection, and copy. - Streaming messages that reflow correctly with the viewport width. What we accept: - Scrollback may contain older cells wrapped differently than newer ones. - Streaming responses appear in scrollback as a sequence of blocks corresponding to their streaming structure, not as a single retroactively reflowed paragraph. - We do not attempt to rewrite or reflow already‑printed scrollback. For deeper rationale and diagrams, see `docs/tui_viewport_and_history.md` and `codex-rs/tui/streaming_wrapping_design.md`. --- ## Still to Do Before This PR Is Ready These are scoped to this PR (not long‑term future work): - [ ] **Streaming wrapping polish** - Double‑check all streaming paths use display‑time wrapping only. - Ensure tests cover resizing after streaming has started. - [ ] **Suspend printing config** - Finalize config shape and default (keep existing behavior vs opt‑out). - Wire config through TUI startup and document it in the appropriate config docs. - [x] **Bottom pane positioning** - Ensure the bottom pane is pegged high when there’s no history and smoothly moves down as the transcript fills, matching the current behavior across startup and resume. - [x] **Transcript mouse scrolling** - Re‑enable wheel‑based transcript scrolling on top of the new scroll model. - Make sure mouse scroll does not get confused with “alternate scroll” modes from terminals. - [x] **Mouse selection vs streaming** - When selection is active, stop auto‑scrolling on streaming so the selection remains stable on the selected content. - Ensure that when streaming continues after selection is cleared, “follow latest output” mode resumes correctly. - [ ] **Auto‑scroll during drag** - While the user is dragging a selection, auto‑scroll when the cursor is at/near the top or bottom of the transcript viewport to allow selecting beyond the current visible window. - [ ] **Feature flag / rollout** - Investigate gating the new viewport/history behavior behind a feature flag for initial rollout, so we can fall back to the old behavior if needed during early testing. - [ ] **Before/after videos** - Capture short clips showing: - Scrolling (mouse + keys). - Selection and copy. - Streaming behavior under resize. - Suspend/resume and exit printing. - Use these to validate UX and share context in the PR discussion.
2610 lines
100 KiB
Rust
2610 lines
100 KiB
Rust
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::bottom_pane::ApprovalRequest;
|
|
use crate::chatwidget::ChatWidget;
|
|
use crate::clipboard_copy;
|
|
use crate::custom_terminal::Frame;
|
|
use crate::diff_render::DiffSummary;
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::model_migration::ModelMigrationOutcome;
|
|
use crate::model_migration::migration_copy_for_models;
|
|
use crate::model_migration::run_model_migration_prompt;
|
|
use crate::pager_overlay::Overlay;
|
|
use crate::render::highlight::highlight_bash_to_lines;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::resume_picker::ResumeSelection;
|
|
use crate::skill_error_prompt::SkillErrorPromptOutcome;
|
|
use crate::skill_error_prompt::run_skill_error_prompt;
|
|
use crate::tui;
|
|
use crate::tui::TuiEvent;
|
|
use crate::update_action::UpdateAction;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_line;
|
|
use crate::wrapping::word_wrap_lines_borrowed;
|
|
use codex_ansi_escape::ansi_escape_line;
|
|
use codex_core::AuthManager;
|
|
use codex_core::ConversationManager;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::edit::ConfigEditsBuilder;
|
|
#[cfg(target_os = "windows")]
|
|
use codex_core::features::Feature;
|
|
use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
|
use codex_core::openai_models::models_manager::ModelsManager;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::FinalOutput;
|
|
use codex_core::protocol::ListSkillsResponseEvent;
|
|
use codex_core::protocol::Op;
|
|
use codex_core::protocol::SessionSource;
|
|
use codex_core::protocol::SkillErrorInfo;
|
|
use codex_core::protocol::TokenUsage;
|
|
use codex_core::skills::SkillError;
|
|
use codex_protocol::ConversationId;
|
|
use codex_protocol::openai_models::ModelPreset;
|
|
use codex_protocol::openai_models::ModelUpgrade;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use color_eyre::eyre::Result;
|
|
use color_eyre::eyre::WrapErr;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyEventKind;
|
|
use crossterm::event::MouseButton;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Clear;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::WidgetRef;
|
|
use ratatui::widgets::Wrap;
|
|
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
use tokio::select;
|
|
use tokio::sync::mpsc::unbounded_channel;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
use crate::history_cell::UpdateAvailableHistoryCell;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppExitInfo {
|
|
pub token_usage: TokenUsage,
|
|
pub conversation_id: Option<ConversationId>,
|
|
pub update_action: Option<UpdateAction>,
|
|
/// ANSI-styled transcript lines to print after the TUI exits.
|
|
///
|
|
/// These lines are rendered against the same width as the final TUI
|
|
/// viewport and include styling (colors, bold, etc.) so that scrollback
|
|
/// preserves the visual structure of the on-screen transcript.
|
|
pub session_lines: Vec<String>,
|
|
}
|
|
|
|
impl From<AppExitInfo> for codex_tui::AppExitInfo {
|
|
fn from(info: AppExitInfo) -> Self {
|
|
codex_tui::AppExitInfo {
|
|
token_usage: info.token_usage,
|
|
conversation_id: info.conversation_id,
|
|
update_action: info.update_action.map(Into::into),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn session_summary(
|
|
token_usage: TokenUsage,
|
|
conversation_id: Option<ConversationId>,
|
|
) -> Option<SessionSummary> {
|
|
if token_usage.is_zero() {
|
|
return None;
|
|
}
|
|
|
|
let usage_line = FinalOutput::from(token_usage).to_string();
|
|
let resume_command =
|
|
conversation_id.map(|conversation_id| format!("codex resume {conversation_id}"));
|
|
Some(SessionSummary {
|
|
usage_line,
|
|
resume_command,
|
|
})
|
|
}
|
|
|
|
fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec<SkillError> {
|
|
errors
|
|
.iter()
|
|
.map(|err| SkillError {
|
|
path: err.path.clone(),
|
|
message: err.message.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec<SkillErrorInfo> {
|
|
response
|
|
.skills
|
|
.iter()
|
|
.find(|entry| entry.cwd.as_path() == cwd)
|
|
.map(|entry| entry.errors.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SessionSummary {
|
|
usage_line: String,
|
|
resume_command: Option<String>,
|
|
}
|
|
|
|
fn should_show_model_migration_prompt(
|
|
current_model: &str,
|
|
target_model: &str,
|
|
seen_migrations: &BTreeMap<String, String>,
|
|
available_models: &[ModelPreset],
|
|
) -> bool {
|
|
if target_model == current_model {
|
|
return false;
|
|
}
|
|
|
|
if let Some(seen_target) = seen_migrations.get(current_model)
|
|
&& seen_target == target_model
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if available_models
|
|
.iter()
|
|
.any(|preset| preset.model == current_model && preset.upgrade.is_some())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if available_models
|
|
.iter()
|
|
.any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
|
|
match migration_config_key {
|
|
HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
|
|
.notices
|
|
.hide_gpt_5_1_codex_max_migration_prompt
|
|
.unwrap_or(false),
|
|
HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
|
|
config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
async fn handle_model_migration_prompt_if_needed(
|
|
tui: &mut tui::Tui,
|
|
config: &mut Config,
|
|
model: &str,
|
|
app_event_tx: &AppEventSender,
|
|
models_manager: Arc<ModelsManager>,
|
|
) -> Option<AppExitInfo> {
|
|
let available_models = models_manager.list_models(config).await;
|
|
let upgrade = available_models
|
|
.iter()
|
|
.find(|preset| preset.model == model)
|
|
.and_then(|preset| preset.upgrade.as_ref());
|
|
|
|
if let Some(ModelUpgrade {
|
|
id: target_model,
|
|
reasoning_effort_mapping,
|
|
migration_config_key,
|
|
}) = upgrade
|
|
{
|
|
if migration_prompt_hidden(config, migration_config_key.as_str()) {
|
|
return None;
|
|
}
|
|
|
|
let target_model = target_model.to_string();
|
|
if !should_show_model_migration_prompt(
|
|
model,
|
|
&target_model,
|
|
&config.notices.model_migrations,
|
|
&available_models,
|
|
) {
|
|
return None;
|
|
}
|
|
|
|
let current_preset = available_models.iter().find(|preset| preset.model == model);
|
|
let target_preset = available_models
|
|
.iter()
|
|
.find(|preset| preset.model == target_model);
|
|
let target_display_name = target_preset
|
|
.map(|preset| preset.display_name.clone())
|
|
.unwrap_or_else(|| target_model.clone());
|
|
let heading_label = if target_display_name == model {
|
|
target_model.clone()
|
|
} else {
|
|
target_display_name.clone()
|
|
};
|
|
let target_description = target_preset.and_then(|preset| {
|
|
if preset.description.is_empty() {
|
|
None
|
|
} else {
|
|
Some(preset.description.clone())
|
|
}
|
|
});
|
|
let can_opt_out = current_preset.is_some();
|
|
let prompt_copy = migration_copy_for_models(
|
|
model,
|
|
&target_model,
|
|
heading_label,
|
|
target_description,
|
|
can_opt_out,
|
|
);
|
|
match run_model_migration_prompt(tui, prompt_copy).await {
|
|
ModelMigrationOutcome::Accepted => {
|
|
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model: model.to_string(),
|
|
to_model: target_model.clone(),
|
|
});
|
|
config.model = Some(target_model.clone());
|
|
|
|
let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping
|
|
&& let Some(reasoning_effort) = config.model_reasoning_effort
|
|
{
|
|
reasoning_effort_mapping
|
|
.get(&reasoning_effort)
|
|
.cloned()
|
|
.or(config.model_reasoning_effort)
|
|
} else {
|
|
config.model_reasoning_effort
|
|
};
|
|
|
|
config.model_reasoning_effort = mapped_effort;
|
|
|
|
app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
|
|
app_event_tx.send(AppEvent::UpdateReasoningEffort(mapped_effort));
|
|
app_event_tx.send(AppEvent::PersistModelSelection {
|
|
model: target_model.clone(),
|
|
effort: mapped_effort,
|
|
});
|
|
}
|
|
ModelMigrationOutcome::Rejected => {
|
|
app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model: model.to_string(),
|
|
to_model: target_model.clone(),
|
|
});
|
|
}
|
|
ModelMigrationOutcome::Exit => {
|
|
return Some(AppExitInfo {
|
|
token_usage: TokenUsage::default(),
|
|
conversation_id: None,
|
|
update_action: None,
|
|
session_lines: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) struct App {
|
|
pub(crate) server: Arc<ConversationManager>,
|
|
pub(crate) app_event_tx: AppEventSender,
|
|
pub(crate) chat_widget: ChatWidget,
|
|
pub(crate) auth_manager: Arc<AuthManager>,
|
|
/// Config is stored here so we can recreate ChatWidgets as needed.
|
|
pub(crate) config: Config,
|
|
pub(crate) current_model: String,
|
|
pub(crate) active_profile: Option<String>,
|
|
|
|
pub(crate) file_search: FileSearchManager,
|
|
|
|
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
|
|
|
#[allow(dead_code)]
|
|
transcript_scroll: TranscriptScroll,
|
|
transcript_selection: TranscriptSelection,
|
|
transcript_view_top: usize,
|
|
transcript_total_lines: usize,
|
|
|
|
// Pager overlay state (Transcript or Static like Diff)
|
|
pub(crate) overlay: Option<Overlay>,
|
|
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
|
has_emitted_history_lines: bool,
|
|
|
|
pub(crate) enhanced_keys_supported: bool,
|
|
|
|
/// Controls the animation thread that sends CommitTick events.
|
|
pub(crate) commit_anim_running: Arc<AtomicBool>,
|
|
|
|
// Esc-backtracking state grouped
|
|
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
|
|
pub(crate) feedback: codex_feedback::CodexFeedback,
|
|
/// Set when the user confirms an update; propagated on exit.
|
|
pub(crate) pending_update_action: Option<UpdateAction>,
|
|
|
|
/// Ignore the next ShutdownComplete event when we're intentionally
|
|
/// stopping a conversation (e.g., before starting a new one).
|
|
suppress_shutdown_complete: bool,
|
|
|
|
// One-shot suppression of the next world-writable scan after user confirmation.
|
|
skip_world_writable_scan_once: bool,
|
|
}
|
|
|
|
/// Scroll state for the inline transcript viewport.
|
|
///
|
|
/// This tracks whether the transcript is pinned to the latest line or anchored
|
|
/// at a specific cell/line pair so later viewport changes can implement
|
|
/// scrollback without losing the notion of "bottom".
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
enum TranscriptScroll {
|
|
#[default]
|
|
ToBottom,
|
|
Scrolled {
|
|
cell_index: usize,
|
|
line_in_cell: usize,
|
|
},
|
|
}
|
|
/// Content-relative selection within the inline transcript viewport.
|
|
///
|
|
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
|
|
/// line indices and columns, so the highlight tracks logical conversation
|
|
/// content even when the viewport scrolls or the terminal is resized.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
struct TranscriptSelection {
|
|
anchor: Option<TranscriptSelectionPoint>,
|
|
head: Option<TranscriptSelectionPoint>,
|
|
}
|
|
|
|
/// A single endpoint of a transcript selection.
|
|
///
|
|
/// `line_index` is an index into the flattened wrapped transcript lines, and
|
|
/// `column` is a zero-based column offset within that visual line, counted from
|
|
/// the first content column to the right of the transcript gutter.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct TranscriptSelectionPoint {
|
|
line_index: usize,
|
|
column: u16,
|
|
}
|
|
impl App {
|
|
async fn shutdown_current_conversation(&mut self) {
|
|
if let Some(conversation_id) = self.chat_widget.conversation_id() {
|
|
self.suppress_shutdown_complete = true;
|
|
self.chat_widget.submit_op(Op::Shutdown);
|
|
self.server.remove_conversation(&conversation_id).await;
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn run(
|
|
tui: &mut tui::Tui,
|
|
auth_manager: Arc<AuthManager>,
|
|
mut config: Config,
|
|
active_profile: Option<String>,
|
|
initial_prompt: Option<String>,
|
|
initial_images: Vec<PathBuf>,
|
|
resume_selection: ResumeSelection,
|
|
feedback: codex_feedback::CodexFeedback,
|
|
is_first_run: bool,
|
|
) -> Result<AppExitInfo> {
|
|
use tokio_stream::StreamExt;
|
|
let (app_event_tx, mut app_event_rx) = unbounded_channel();
|
|
let app_event_tx = AppEventSender::new(app_event_tx);
|
|
|
|
let conversation_manager = Arc::new(ConversationManager::new(
|
|
auth_manager.clone(),
|
|
SessionSource::Cli,
|
|
));
|
|
let mut model = conversation_manager
|
|
.get_models_manager()
|
|
.get_model(&config.model, &config)
|
|
.await;
|
|
let exit_info = handle_model_migration_prompt_if_needed(
|
|
tui,
|
|
&mut config,
|
|
model.as_str(),
|
|
&app_event_tx,
|
|
conversation_manager.get_models_manager(),
|
|
)
|
|
.await;
|
|
if let Some(exit_info) = exit_info {
|
|
return Ok(exit_info);
|
|
}
|
|
if let Some(updated_model) = config.model.clone() {
|
|
model = updated_model;
|
|
}
|
|
|
|
let enhanced_keys_supported = tui.enhanced_keys_supported();
|
|
let model_family = conversation_manager
|
|
.get_models_manager()
|
|
.construct_model_family(model.as_str(), &config)
|
|
.await;
|
|
let mut chat_widget = match resume_selection {
|
|
ResumeSelection::StartFresh | ResumeSelection::Exit => {
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_prompt: initial_prompt.clone(),
|
|
initial_images: initial_images.clone(),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: conversation_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
is_first_run,
|
|
model_family: model_family.clone(),
|
|
};
|
|
ChatWidget::new(init, conversation_manager.clone())
|
|
}
|
|
ResumeSelection::Resume(path) => {
|
|
let resumed = conversation_manager
|
|
.resume_conversation_from_rollout(
|
|
config.clone(),
|
|
path.clone(),
|
|
auth_manager.clone(),
|
|
)
|
|
.await
|
|
.wrap_err_with(|| {
|
|
format!("Failed to resume session from {}", path.display())
|
|
})?;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: app_event_tx.clone(),
|
|
initial_prompt: initial_prompt.clone(),
|
|
initial_images: initial_images.clone(),
|
|
enhanced_keys_supported,
|
|
auth_manager: auth_manager.clone(),
|
|
models_manager: conversation_manager.get_models_manager(),
|
|
feedback: feedback.clone(),
|
|
is_first_run,
|
|
model_family: model_family.clone(),
|
|
};
|
|
ChatWidget::new_from_existing(
|
|
init,
|
|
resumed.conversation,
|
|
resumed.session_configured,
|
|
)
|
|
}
|
|
};
|
|
|
|
chat_widget.maybe_prompt_windows_sandbox_enable();
|
|
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
#[cfg(not(debug_assertions))]
|
|
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
|
|
|
let mut app = Self {
|
|
server: conversation_manager.clone(),
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager: auth_manager.clone(),
|
|
config,
|
|
current_model: model.clone(),
|
|
active_profile,
|
|
file_search,
|
|
enhanced_keys_supported,
|
|
transcript_cells: Vec::new(),
|
|
transcript_scroll: TranscriptScroll::ToBottom,
|
|
transcript_selection: TranscriptSelection::default(),
|
|
transcript_view_top: 0,
|
|
transcript_total_lines: 0,
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
feedback: feedback.clone(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
};
|
|
|
|
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let should_check = codex_core::get_platform_sandbox().is_some()
|
|
&& matches!(
|
|
app.config.sandbox_policy,
|
|
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_core::protocol::SandboxPolicy::ReadOnly
|
|
)
|
|
&& !app
|
|
.config
|
|
.notices
|
|
.hide_world_writable_warning
|
|
.unwrap_or(false);
|
|
if should_check {
|
|
let cwd = app.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
|
let tx = app.app_event_tx.clone();
|
|
let logs_base_dir = app.config.codex_home.clone();
|
|
let sandbox_policy = app.config.sandbox_policy.clone();
|
|
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(debug_assertions))]
|
|
if let Some(latest_version) = upgrade_version {
|
|
app.handle_event(
|
|
tui,
|
|
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
|
latest_version,
|
|
crate::update_action::get_update_action(),
|
|
))),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
let tui_events = tui.event_stream();
|
|
tokio::pin!(tui_events);
|
|
|
|
tui.frame_requester().schedule_frame();
|
|
|
|
while select! {
|
|
Some(event) = app_event_rx.recv() => {
|
|
app.handle_event(tui, event).await?
|
|
}
|
|
Some(event) = tui_events.next() => {
|
|
app.handle_tui_event(tui, event).await?
|
|
}
|
|
} {}
|
|
let width = tui.terminal.last_known_screen_size.width;
|
|
let session_lines = if width == 0 {
|
|
Vec::new()
|
|
} else {
|
|
let (lines, meta) = Self::build_transcript_lines(&app.transcript_cells, width);
|
|
let is_user_cell: Vec<bool> = app
|
|
.transcript_cells
|
|
.iter()
|
|
.map(|cell| cell.as_any().is::<UserHistoryCell>())
|
|
.collect();
|
|
Self::render_lines_to_ansi(&lines, &meta, &is_user_cell, width)
|
|
};
|
|
|
|
tui.terminal.clear()?;
|
|
Ok(AppExitInfo {
|
|
token_usage: app.token_usage(),
|
|
conversation_id: app.chat_widget.conversation_id(),
|
|
update_action: app.pending_update_action,
|
|
session_lines,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn handle_tui_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
event: TuiEvent,
|
|
) -> Result<bool> {
|
|
if self.overlay.is_some() {
|
|
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
|
|
} else {
|
|
match event {
|
|
TuiEvent::Key(key_event) => {
|
|
self.handle_key_event(tui, key_event).await;
|
|
}
|
|
TuiEvent::Mouse(mouse_event) => {
|
|
self.handle_mouse_event(tui, mouse_event);
|
|
}
|
|
TuiEvent::Paste(pasted) => {
|
|
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
|
|
// but tui-textarea expects \n. Normalize CR to LF.
|
|
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
|
|
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|
|
let pasted = pasted.replace("\r", "\n");
|
|
self.chat_widget.handle_paste(pasted);
|
|
}
|
|
TuiEvent::Draw => {
|
|
self.chat_widget.maybe_post_pending_notification(tui);
|
|
if self
|
|
.chat_widget
|
|
.handle_paste_burst_tick(tui.frame_requester())
|
|
{
|
|
return Ok(true);
|
|
}
|
|
let cells = self.transcript_cells.clone();
|
|
tui.draw(tui.terminal.size()?.height, |frame| {
|
|
let chat_height = self.chat_widget.desired_height(frame.area().width);
|
|
let chat_top = self.render_transcript_cells(frame, &cells, chat_height);
|
|
let chat_area = Rect {
|
|
x: frame.area().x,
|
|
y: chat_top,
|
|
width: frame.area().width,
|
|
height: chat_height.min(
|
|
frame
|
|
.area()
|
|
.height
|
|
.saturating_sub(chat_top.saturating_sub(frame.area().y)),
|
|
),
|
|
};
|
|
self.chat_widget.render(chat_area, frame.buffer);
|
|
let chat_bottom = chat_area.y.saturating_add(chat_area.height);
|
|
if chat_bottom < frame.area().bottom() {
|
|
Clear.render_ref(
|
|
Rect {
|
|
x: frame.area().x,
|
|
y: chat_bottom,
|
|
width: frame.area().width,
|
|
height: frame.area().bottom().saturating_sub(chat_bottom),
|
|
},
|
|
frame.buffer,
|
|
);
|
|
}
|
|
if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
|
|
frame.set_cursor_position((x, y));
|
|
}
|
|
})?;
|
|
let transcript_scrolled =
|
|
!matches!(self.transcript_scroll, TranscriptScroll::ToBottom);
|
|
let selection_active = matches!(
|
|
(self.transcript_selection.anchor, self.transcript_selection.head),
|
|
(Some(a), Some(b)) if a != b
|
|
);
|
|
let scroll_position = if self.transcript_total_lines == 0 {
|
|
None
|
|
} else {
|
|
Some((
|
|
self.transcript_view_top.saturating_add(1),
|
|
self.transcript_total_lines,
|
|
))
|
|
};
|
|
self.chat_widget.set_transcript_ui_state(
|
|
transcript_scrolled,
|
|
selection_active,
|
|
scroll_position,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
pub(crate) fn render_transcript_cells(
|
|
&mut self,
|
|
frame: &mut Frame,
|
|
cells: &[Arc<dyn HistoryCell>],
|
|
chat_height: u16,
|
|
) -> u16 {
|
|
let area = frame.area();
|
|
if area.width == 0 || area.height == 0 {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
self.transcript_view_top = 0;
|
|
self.transcript_total_lines = 0;
|
|
return area.bottom().saturating_sub(chat_height);
|
|
}
|
|
|
|
let chat_height = chat_height.min(area.height);
|
|
let max_transcript_height = area.height.saturating_sub(chat_height);
|
|
if max_transcript_height == 0 {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
self.transcript_view_top = 0;
|
|
self.transcript_total_lines = 0;
|
|
return area.y;
|
|
}
|
|
|
|
let transcript_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: max_transcript_height,
|
|
};
|
|
|
|
let (lines, meta) = Self::build_transcript_lines(cells, transcript_area.width);
|
|
if lines.is_empty() {
|
|
Clear.render_ref(transcript_area, frame.buffer);
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
self.transcript_view_top = 0;
|
|
self.transcript_total_lines = 0;
|
|
return area.y;
|
|
}
|
|
|
|
let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize);
|
|
if wrapped.is_empty() {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
self.transcript_view_top = 0;
|
|
self.transcript_total_lines = 0;
|
|
return area.y;
|
|
}
|
|
|
|
let is_user_cell: Vec<bool> = cells
|
|
.iter()
|
|
.map(|c| c.as_any().is::<UserHistoryCell>())
|
|
.collect();
|
|
let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize);
|
|
let mut wrapped_is_user_row: Vec<bool> = Vec::with_capacity(wrapped.len());
|
|
let mut first = true;
|
|
for (idx, line) in lines.iter().enumerate() {
|
|
let opts = if first {
|
|
base_opts.clone()
|
|
} else {
|
|
base_opts
|
|
.clone()
|
|
.initial_indent(base_opts.subsequent_indent.clone())
|
|
};
|
|
let seg_count = word_wrap_line(line, opts).len();
|
|
let is_user_row = meta
|
|
.get(idx)
|
|
.and_then(Option::as_ref)
|
|
.map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false))
|
|
.unwrap_or(false);
|
|
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
|
|
first = false;
|
|
}
|
|
|
|
let total_lines = wrapped.len();
|
|
self.transcript_total_lines = total_lines;
|
|
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
|
|
let max_start = total_lines.saturating_sub(max_visible);
|
|
|
|
let top_offset = match self.transcript_scroll {
|
|
TranscriptScroll::ToBottom => max_start,
|
|
TranscriptScroll::Scrolled {
|
|
cell_index,
|
|
line_in_cell,
|
|
} => {
|
|
let mut anchor = None;
|
|
for (idx, entry) in meta.iter().enumerate() {
|
|
if let Some((ci, li)) = entry
|
|
&& *ci == cell_index
|
|
&& *li == line_in_cell
|
|
{
|
|
anchor = Some(idx);
|
|
break;
|
|
}
|
|
}
|
|
if let Some(idx) = anchor {
|
|
idx.min(max_start)
|
|
} else {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
max_start
|
|
}
|
|
}
|
|
};
|
|
self.transcript_view_top = top_offset;
|
|
|
|
let transcript_visible_height = max_visible as u16;
|
|
let chat_top = if total_lines <= max_transcript_height as usize {
|
|
let gap = if transcript_visible_height == 0 { 0 } else { 1 };
|
|
area.y
|
|
.saturating_add(transcript_visible_height)
|
|
.saturating_add(gap)
|
|
} else {
|
|
area.bottom().saturating_sub(chat_height)
|
|
};
|
|
|
|
let clear_height = chat_top.saturating_sub(area.y);
|
|
if clear_height > 0 {
|
|
Clear.render_ref(
|
|
Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: clear_height,
|
|
},
|
|
frame.buffer,
|
|
);
|
|
}
|
|
|
|
let transcript_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: area.width,
|
|
height: transcript_visible_height,
|
|
};
|
|
|
|
for (row_index, line_index) in (top_offset..total_lines).enumerate() {
|
|
if row_index >= max_visible {
|
|
break;
|
|
}
|
|
|
|
let y = transcript_area.y + row_index as u16;
|
|
let row_area = Rect {
|
|
x: transcript_area.x,
|
|
y,
|
|
width: transcript_area.width,
|
|
height: 1,
|
|
};
|
|
|
|
if wrapped_is_user_row
|
|
.get(line_index)
|
|
.copied()
|
|
.unwrap_or(false)
|
|
{
|
|
let base_style = crate::style::user_message_style();
|
|
for x in row_area.x..row_area.right() {
|
|
let cell = &mut frame.buffer[(x, y)];
|
|
let style = cell.style().patch(base_style);
|
|
cell.set_style(style);
|
|
}
|
|
}
|
|
|
|
wrapped[line_index].render_ref(row_area, frame.buffer);
|
|
}
|
|
|
|
self.apply_transcript_selection(transcript_area, frame.buffer);
|
|
chat_top
|
|
}
|
|
|
|
/// Handle mouse interaction in the main transcript view.
|
|
///
|
|
/// - Mouse wheel movement scrolls the conversation history by small, fixed increments,
|
|
/// independent of the terminal's own scrollback.
|
|
/// - Mouse clicks and drags adjust a text selection defined in terms of
|
|
/// flattened transcript lines and columns, so the selection is anchored
|
|
/// to the underlying content rather than absolute screen rows.
|
|
/// - When the user drags to extend a selection while the view is following the bottom
|
|
/// and a task is actively running (e.g., streaming a response), the scroll mode is
|
|
/// first converted into an anchored position so that ongoing updates no longer move
|
|
/// the viewport under the selection. A simple click without a drag does not change
|
|
/// scroll behavior.
|
|
fn handle_mouse_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
mouse_event: crossterm::event::MouseEvent,
|
|
) {
|
|
use crossterm::event::MouseEventKind;
|
|
|
|
if self.overlay.is_some() {
|
|
return;
|
|
}
|
|
|
|
let size = tui.terminal.last_known_screen_size;
|
|
let width = size.width;
|
|
let height = size.height;
|
|
if width == 0 || height == 0 {
|
|
return;
|
|
}
|
|
|
|
let chat_height = self.chat_widget.desired_height(width);
|
|
if chat_height >= height {
|
|
return;
|
|
}
|
|
|
|
// Only handle events over the transcript area above the composer.
|
|
let transcript_height = height.saturating_sub(chat_height);
|
|
if transcript_height == 0 {
|
|
return;
|
|
}
|
|
|
|
let transcript_area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height: transcript_height,
|
|
};
|
|
let base_x = transcript_area.x.saturating_add(2);
|
|
let max_x = transcript_area.right().saturating_sub(1);
|
|
|
|
let mut clamped_x = mouse_event.column;
|
|
let mut clamped_y = mouse_event.row;
|
|
|
|
if clamped_y < transcript_area.y || clamped_y >= transcript_area.bottom() {
|
|
clamped_y = transcript_area.y;
|
|
}
|
|
if clamped_x < base_x {
|
|
clamped_x = base_x;
|
|
}
|
|
if clamped_x > max_x {
|
|
clamped_x = max_x;
|
|
}
|
|
|
|
let streaming = self.chat_widget.is_task_running();
|
|
|
|
match mouse_event.kind {
|
|
MouseEventKind::ScrollUp => {
|
|
self.scroll_transcript(
|
|
tui,
|
|
-3,
|
|
transcript_area.height as usize,
|
|
transcript_area.width,
|
|
);
|
|
}
|
|
MouseEventKind::ScrollDown => {
|
|
self.scroll_transcript(
|
|
tui,
|
|
3,
|
|
transcript_area.height as usize,
|
|
transcript_area.width,
|
|
);
|
|
}
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
if let Some(point) = self.transcript_point_from_coordinates(
|
|
transcript_area,
|
|
base_x,
|
|
clamped_x,
|
|
clamped_y,
|
|
) {
|
|
self.transcript_selection.anchor = Some(point);
|
|
self.transcript_selection.head = Some(point);
|
|
}
|
|
}
|
|
MouseEventKind::Drag(MouseButton::Left) => {
|
|
if let Some(anchor) = self.transcript_selection.anchor
|
|
&& let Some(point) = self.transcript_point_from_coordinates(
|
|
transcript_area,
|
|
base_x,
|
|
clamped_x,
|
|
clamped_y,
|
|
)
|
|
{
|
|
if streaming
|
|
&& matches!(self.transcript_scroll, TranscriptScroll::ToBottom)
|
|
&& point != anchor
|
|
{
|
|
self.lock_transcript_scroll_to_current_view(
|
|
transcript_area.height as usize,
|
|
transcript_area.width,
|
|
);
|
|
}
|
|
self.transcript_selection.head = Some(point);
|
|
}
|
|
}
|
|
MouseEventKind::Up(MouseButton::Left) => {
|
|
if self.transcript_selection.anchor == self.transcript_selection.head {
|
|
self.transcript_selection = TranscriptSelection::default();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Scroll the transcript by a fixed number of visual lines.
|
|
///
|
|
/// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in
|
|
/// the main view. Scroll state is expressed in terms of transcript cells and their
|
|
/// internal line indices, so scrolling refers to logical conversation content and
|
|
/// remains stable even as wrapping or streaming causes visual reflows.
|
|
fn scroll_transcript(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
delta_lines: i32,
|
|
visible_lines: usize,
|
|
width: u16,
|
|
) {
|
|
if visible_lines == 0 {
|
|
return;
|
|
}
|
|
|
|
let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
|
let total_lines = lines.len();
|
|
if total_lines <= visible_lines {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
return;
|
|
}
|
|
|
|
let max_start = total_lines.saturating_sub(visible_lines);
|
|
|
|
let current_top = match self.transcript_scroll {
|
|
TranscriptScroll::ToBottom => max_start,
|
|
TranscriptScroll::Scrolled {
|
|
cell_index,
|
|
line_in_cell,
|
|
} => {
|
|
let mut anchor = None;
|
|
for (idx, entry) in meta.iter().enumerate() {
|
|
if let Some((ci, li)) = entry
|
|
&& *ci == cell_index
|
|
&& *li == line_in_cell
|
|
{
|
|
anchor = Some(idx);
|
|
break;
|
|
}
|
|
}
|
|
anchor.unwrap_or(max_start).min(max_start)
|
|
}
|
|
};
|
|
|
|
if delta_lines == 0 {
|
|
return;
|
|
}
|
|
|
|
let new_top = if delta_lines < 0 {
|
|
current_top.saturating_sub(delta_lines.unsigned_abs() as usize)
|
|
} else {
|
|
current_top
|
|
.saturating_add(delta_lines as usize)
|
|
.min(max_start)
|
|
};
|
|
|
|
if new_top == max_start {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
} else {
|
|
let anchor = meta.iter().skip(new_top).find_map(|entry| *entry);
|
|
if let Some((cell_index, line_in_cell)) = anchor {
|
|
self.transcript_scroll = TranscriptScroll::Scrolled {
|
|
cell_index,
|
|
line_in_cell,
|
|
};
|
|
} else if let Some(prev_idx) = (0..=new_top).rfind(|&idx| meta[idx].is_some()) {
|
|
if let Some((cell_index, line_in_cell)) = meta[prev_idx] {
|
|
self.transcript_scroll = TranscriptScroll::Scrolled {
|
|
cell_index,
|
|
line_in_cell,
|
|
};
|
|
} else {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
}
|
|
} else {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
}
|
|
}
|
|
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
|
|
/// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view.
|
|
///
|
|
/// When the user begins a mouse selection while new output is streaming in, the view
|
|
/// should stop auto-following the latest line so the selection stays on the intended
|
|
/// content. This helper inspects the flattened transcript at the given width, derives
|
|
/// a concrete position corresponding to the current top row, and switches into a scroll
|
|
/// mode that keeps that position stable until the user scrolls again.
|
|
fn lock_transcript_scroll_to_current_view(&mut self, visible_lines: usize, width: u16) {
|
|
if self.transcript_cells.is_empty() || visible_lines == 0 || width == 0 {
|
|
return;
|
|
}
|
|
|
|
let (lines, meta) = Self::build_transcript_lines(&self.transcript_cells, width);
|
|
if lines.is_empty() || meta.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let total_lines = lines.len();
|
|
let max_visible = std::cmp::min(visible_lines, total_lines);
|
|
if max_visible == 0 {
|
|
return;
|
|
}
|
|
|
|
let max_start = total_lines.saturating_sub(max_visible);
|
|
let top_offset = match self.transcript_scroll {
|
|
TranscriptScroll::ToBottom => max_start,
|
|
TranscriptScroll::Scrolled { .. } => {
|
|
// Already anchored; nothing to lock.
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut anchor = None;
|
|
if let Some((cell_index, line_in_cell)) = meta.iter().skip(top_offset).flatten().next() {
|
|
anchor = Some((*cell_index, *line_in_cell));
|
|
}
|
|
if anchor.is_none()
|
|
&& let Some((cell_index, line_in_cell)) =
|
|
meta[..top_offset].iter().rev().flatten().next()
|
|
{
|
|
anchor = Some((*cell_index, *line_in_cell));
|
|
}
|
|
|
|
if let Some((cell_index, line_in_cell)) = anchor {
|
|
self.transcript_scroll = TranscriptScroll::Scrolled {
|
|
cell_index,
|
|
line_in_cell,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Build the flattened transcript lines for rendering, scrolling, and exit transcripts.
|
|
///
|
|
/// Returns both the visible `Line` buffer and a parallel metadata vector
|
|
/// that maps each line back to its originating `(cell_index, line_in_cell)`
|
|
/// pair, or `None` for spacer lines. This allows the scroll state to anchor
|
|
/// to a specific history cell even as new content arrives or the viewport
|
|
/// size changes, and gives exit transcript renderers enough structure to
|
|
/// style user rows differently from agent rows.
|
|
fn build_transcript_lines(
|
|
cells: &[Arc<dyn HistoryCell>],
|
|
width: u16,
|
|
) -> (Vec<Line<'static>>, Vec<Option<(usize, usize)>>) {
|
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
let mut meta: Vec<Option<(usize, usize)>> = Vec::new();
|
|
let mut has_emitted_lines = false;
|
|
|
|
for (cell_index, cell) in cells.iter().enumerate() {
|
|
let cell_lines = cell.display_lines(width);
|
|
if cell_lines.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if !cell.is_stream_continuation() {
|
|
if has_emitted_lines {
|
|
lines.push(Line::from(""));
|
|
meta.push(None);
|
|
} else {
|
|
has_emitted_lines = true;
|
|
}
|
|
}
|
|
|
|
for (line_in_cell, line) in cell_lines.into_iter().enumerate() {
|
|
meta.push(Some((cell_index, line_in_cell)));
|
|
lines.push(line);
|
|
}
|
|
}
|
|
|
|
(lines, meta)
|
|
}
|
|
|
|
/// Render flattened transcript lines into ANSI strings suitable for
|
|
/// printing after the TUI exits.
|
|
///
|
|
/// This helper mirrors the original TUI viewport behavior:
|
|
/// - Merges line-level style into each span so the ANSI output matches
|
|
/// the on-screen styling (e.g., blockquotes, lists).
|
|
/// - For user-authored rows, pads the background style out to the full
|
|
/// terminal width so prompts appear as solid blocks in scrollback.
|
|
/// - Streams spans through the shared vt100 writer so downstream tests
|
|
/// and tools see consistent escape sequences.
|
|
fn render_lines_to_ansi(
|
|
lines: &[Line<'static>],
|
|
meta: &[Option<(usize, usize)>],
|
|
is_user_cell: &[bool],
|
|
width: u16,
|
|
) -> Vec<String> {
|
|
lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, line)| {
|
|
let is_user_row = meta
|
|
.get(idx)
|
|
.and_then(|entry| entry.as_ref())
|
|
.map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false))
|
|
.unwrap_or(false);
|
|
|
|
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
|
|
.spans
|
|
.iter()
|
|
.map(|span| ratatui::text::Span {
|
|
style: span.style.patch(line.style),
|
|
content: span.content.clone(),
|
|
})
|
|
.collect();
|
|
|
|
if is_user_row && width > 0 {
|
|
let text: String = merged_spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect();
|
|
let text_width = UnicodeWidthStr::width(text.as_str());
|
|
let total_width = usize::from(width);
|
|
if text_width < total_width {
|
|
let pad_len = total_width.saturating_sub(text_width);
|
|
if pad_len > 0 {
|
|
let pad_style = crate::style::user_message_style();
|
|
merged_spans.push(ratatui::text::Span {
|
|
style: pad_style,
|
|
content: " ".repeat(pad_len).into(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
|
|
String::from_utf8(buf).unwrap_or_default()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Apply the current transcript selection to the given buffer.
|
|
///
|
|
/// The selection is defined in terms of flattened wrapped transcript line
|
|
/// indices and columns. This method maps those content-relative endpoints
|
|
/// into the currently visible viewport based on `transcript_view_top` and
|
|
/// `transcript_total_lines`, so the highlight moves with the content as the
|
|
/// user scrolls.
|
|
fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) {
|
|
let (anchor, head) = match (
|
|
self.transcript_selection.anchor,
|
|
self.transcript_selection.head,
|
|
) {
|
|
(Some(a), Some(h)) => (a, h),
|
|
_ => return,
|
|
};
|
|
|
|
if self.transcript_total_lines == 0 {
|
|
return;
|
|
}
|
|
|
|
let base_x = area.x.saturating_add(2);
|
|
let max_x = area.right().saturating_sub(1);
|
|
|
|
let mut start = anchor;
|
|
let mut end = head;
|
|
if (end.line_index < start.line_index)
|
|
|| (end.line_index == start.line_index && end.column < start.column)
|
|
{
|
|
std::mem::swap(&mut start, &mut end);
|
|
}
|
|
|
|
let visible_start = self.transcript_view_top;
|
|
let visible_end = self
|
|
.transcript_view_top
|
|
.saturating_add(area.height as usize)
|
|
.min(self.transcript_total_lines);
|
|
|
|
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
|
if line_index < start.line_index || line_index > end.line_index {
|
|
continue;
|
|
}
|
|
|
|
let y = area.y + row_index as u16;
|
|
|
|
let mut first_text_x = None;
|
|
let mut last_text_x = None;
|
|
for x in base_x..=max_x {
|
|
let cell = &buf[(x, y)];
|
|
if cell.symbol() != " " {
|
|
if first_text_x.is_none() {
|
|
first_text_x = Some(x);
|
|
}
|
|
last_text_x = Some(x);
|
|
}
|
|
}
|
|
|
|
let (text_start, text_end) = match (first_text_x, last_text_x) {
|
|
// Treat indentation spaces as part of the selectable region by
|
|
// starting from the first content column to the right of the
|
|
// transcript gutter, but still clamp to the last non-space
|
|
// glyph so trailing padding is not included.
|
|
(Some(_), Some(e)) => (base_x, e),
|
|
_ => continue,
|
|
};
|
|
|
|
let line_start_col = if line_index == start.line_index {
|
|
start.column
|
|
} else {
|
|
0
|
|
};
|
|
let line_end_col = if line_index == end.line_index {
|
|
end.column
|
|
} else {
|
|
max_x.saturating_sub(base_x)
|
|
};
|
|
|
|
let row_sel_start = base_x.saturating_add(line_start_col);
|
|
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
|
|
|
|
if row_sel_start > row_sel_end {
|
|
continue;
|
|
}
|
|
|
|
let from_x = row_sel_start.max(text_start);
|
|
let to_x = row_sel_end.min(text_end);
|
|
|
|
if from_x > to_x {
|
|
continue;
|
|
}
|
|
|
|
for x in from_x..=to_x {
|
|
let cell = &mut buf[(x, y)];
|
|
let style = cell.style();
|
|
cell.set_style(style.add_modifier(ratatui::style::Modifier::REVERSED));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Copy the currently selected transcript region to the system clipboard.
|
|
///
|
|
/// The selection is defined in terms of flattened wrapped transcript line
|
|
/// indices and columns, and this method reconstructs the same wrapped
|
|
/// transcript used for on-screen rendering so the copied text closely
|
|
/// matches the highlighted region.
|
|
fn copy_transcript_selection(&mut self, tui: &tui::Tui) {
|
|
let (anchor, head) = match (
|
|
self.transcript_selection.anchor,
|
|
self.transcript_selection.head,
|
|
) {
|
|
(Some(a), Some(h)) if a != h => (a, h),
|
|
_ => return,
|
|
};
|
|
|
|
let size = tui.terminal.last_known_screen_size;
|
|
let width = size.width;
|
|
let height = size.height;
|
|
if width == 0 || height == 0 {
|
|
return;
|
|
}
|
|
|
|
let chat_height = self.chat_widget.desired_height(width);
|
|
if chat_height >= height {
|
|
return;
|
|
}
|
|
|
|
let transcript_height = height.saturating_sub(chat_height);
|
|
if transcript_height == 0 {
|
|
return;
|
|
}
|
|
|
|
let transcript_area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width,
|
|
height: transcript_height,
|
|
};
|
|
|
|
let cells = self.transcript_cells.clone();
|
|
let (lines, _) = Self::build_transcript_lines(&cells, transcript_area.width);
|
|
if lines.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let wrapped = crate::wrapping::word_wrap_lines_borrowed(
|
|
&lines,
|
|
transcript_area.width.max(1) as usize,
|
|
);
|
|
let total_lines = wrapped.len();
|
|
if total_lines == 0 {
|
|
return;
|
|
}
|
|
|
|
let max_visible = transcript_area.height as usize;
|
|
let visible_start = self
|
|
.transcript_view_top
|
|
.min(total_lines.saturating_sub(max_visible));
|
|
let visible_end = std::cmp::min(visible_start + max_visible, total_lines);
|
|
|
|
let mut buf = Buffer::empty(transcript_area);
|
|
Clear.render_ref(transcript_area, &mut buf);
|
|
|
|
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
|
let row_area = Rect {
|
|
x: transcript_area.x,
|
|
y: transcript_area.y + row_index as u16,
|
|
width: transcript_area.width,
|
|
height: 1,
|
|
};
|
|
wrapped[line_index].render_ref(row_area, &mut buf);
|
|
}
|
|
|
|
let base_x = transcript_area.x.saturating_add(2);
|
|
let max_x = transcript_area.right().saturating_sub(1);
|
|
|
|
let mut start = anchor;
|
|
let mut end = head;
|
|
if (end.line_index < start.line_index)
|
|
|| (end.line_index == start.line_index && end.column < start.column)
|
|
{
|
|
std::mem::swap(&mut start, &mut end);
|
|
}
|
|
|
|
let mut lines_out: Vec<String> = Vec::new();
|
|
|
|
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
|
|
if line_index < start.line_index || line_index > end.line_index {
|
|
continue;
|
|
}
|
|
|
|
let y = transcript_area.y + row_index as u16;
|
|
|
|
let line_start_col = if line_index == start.line_index {
|
|
start.column
|
|
} else {
|
|
0
|
|
};
|
|
let line_end_col = if line_index == end.line_index {
|
|
end.column
|
|
} else {
|
|
max_x.saturating_sub(base_x)
|
|
};
|
|
|
|
let row_sel_start = base_x.saturating_add(line_start_col);
|
|
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
|
|
|
|
if row_sel_start > row_sel_end {
|
|
continue;
|
|
}
|
|
|
|
let mut first_text_x = None;
|
|
let mut last_text_x = None;
|
|
for x in base_x..=max_x {
|
|
let cell = &buf[(x, y)];
|
|
if cell.symbol() != " " {
|
|
if first_text_x.is_none() {
|
|
first_text_x = Some(x);
|
|
}
|
|
last_text_x = Some(x);
|
|
}
|
|
}
|
|
|
|
let (text_start, text_end) = match (first_text_x, last_text_x) {
|
|
// Treat indentation spaces as part of the copyable region by
|
|
// starting from the first content column to the right of the
|
|
// transcript gutter, but still clamp to the last non-space
|
|
// glyph so trailing padding is not included.
|
|
(Some(_), Some(e)) => (base_x, e),
|
|
_ => {
|
|
lines_out.push(String::new());
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let from_x = row_sel_start.max(text_start);
|
|
let to_x = row_sel_end.min(text_end);
|
|
if from_x > to_x {
|
|
continue;
|
|
}
|
|
|
|
let mut line_text = String::new();
|
|
for x in from_x..=to_x {
|
|
let cell = &buf[(x, y)];
|
|
let symbol = cell.symbol();
|
|
if !symbol.is_empty() {
|
|
line_text.push_str(symbol);
|
|
}
|
|
}
|
|
|
|
lines_out.push(line_text);
|
|
}
|
|
|
|
if lines_out.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let text = lines_out.join("\n");
|
|
if let Err(err) = clipboard_copy::copy_text(text) {
|
|
tracing::error!(error = %err, "failed to copy selection to clipboard");
|
|
}
|
|
}
|
|
|
|
/// Map a mouse position in the transcript area to a content-relative
|
|
/// selection point, if there is transcript content to select.
|
|
fn transcript_point_from_coordinates(
|
|
&self,
|
|
transcript_area: Rect,
|
|
base_x: u16,
|
|
x: u16,
|
|
y: u16,
|
|
) -> Option<TranscriptSelectionPoint> {
|
|
if self.transcript_total_lines == 0 {
|
|
return None;
|
|
}
|
|
|
|
let mut row_index = y.saturating_sub(transcript_area.y);
|
|
if row_index >= transcript_area.height {
|
|
if transcript_area.height == 0 {
|
|
return None;
|
|
}
|
|
row_index = transcript_area.height.saturating_sub(1);
|
|
}
|
|
|
|
let max_line = self.transcript_total_lines.saturating_sub(1);
|
|
let line_index = self
|
|
.transcript_view_top
|
|
.saturating_add(usize::from(row_index))
|
|
.min(max_line);
|
|
let column = x.saturating_sub(base_x);
|
|
|
|
Some(TranscriptSelectionPoint { line_index, column })
|
|
}
|
|
|
|
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
|
let model_family = self
|
|
.server
|
|
.get_models_manager()
|
|
.construct_model_family(self.current_model.as_str(), &self.config)
|
|
.await;
|
|
match event {
|
|
AppEvent::NewSession => {
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.conversation_id(),
|
|
);
|
|
self.shutdown_current_conversation().await;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: self.config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
initial_prompt: None,
|
|
initial_images: Vec::new(),
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
is_first_run: false,
|
|
model_family: model_family.clone(),
|
|
};
|
|
self.chat_widget = ChatWidget::new(init, self.server.clone());
|
|
self.current_model = model_family.get_model_slug().to_string();
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
|
if let Some(command) = summary.resume_command {
|
|
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::OpenResumePicker => {
|
|
match crate::resume_picker::run_resume_picker(
|
|
tui,
|
|
&self.config.codex_home,
|
|
&self.config.model_provider_id,
|
|
false,
|
|
)
|
|
.await?
|
|
{
|
|
ResumeSelection::Resume(path) => {
|
|
let summary = session_summary(
|
|
self.chat_widget.token_usage(),
|
|
self.chat_widget.conversation_id(),
|
|
);
|
|
match self
|
|
.server
|
|
.resume_conversation_from_rollout(
|
|
self.config.clone(),
|
|
path.clone(),
|
|
self.auth_manager.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(resumed) => {
|
|
self.shutdown_current_conversation().await;
|
|
let init = crate::chatwidget::ChatWidgetInit {
|
|
config: self.config.clone(),
|
|
frame_requester: tui.frame_requester(),
|
|
app_event_tx: self.app_event_tx.clone(),
|
|
initial_prompt: None,
|
|
initial_images: Vec::new(),
|
|
enhanced_keys_supported: self.enhanced_keys_supported,
|
|
auth_manager: self.auth_manager.clone(),
|
|
models_manager: self.server.get_models_manager(),
|
|
feedback: self.feedback.clone(),
|
|
is_first_run: false,
|
|
model_family: model_family.clone(),
|
|
};
|
|
self.chat_widget = ChatWidget::new_from_existing(
|
|
init,
|
|
resumed.conversation,
|
|
resumed.session_configured,
|
|
);
|
|
self.current_model = model_family.get_model_slug().to_string();
|
|
if let Some(summary) = summary {
|
|
let mut lines: Vec<Line<'static>> =
|
|
vec![summary.usage_line.clone().into()];
|
|
if let Some(command) = summary.resume_command {
|
|
let spans = vec![
|
|
"To continue this session, run ".into(),
|
|
command.cyan(),
|
|
];
|
|
lines.push(spans.into());
|
|
}
|
|
self.chat_widget.add_plain_history_lines(lines);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to resume session from {}: {err}",
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
ResumeSelection::Exit | ResumeSelection::StartFresh => {}
|
|
}
|
|
|
|
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::InsertHistoryCell(cell) => {
|
|
let cell: Arc<dyn HistoryCell> = cell.into();
|
|
if let Some(Overlay::Transcript(transcript)) = &mut self.overlay {
|
|
transcript.insert_cell(cell.clone());
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
self.transcript_cells.push(cell.clone());
|
|
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
|
|
if !display.is_empty() {
|
|
// Only insert a separating blank line for new cells that are not
|
|
// part of an ongoing stream. Streaming continuations should not
|
|
// accrue extra blank lines between chunks.
|
|
if !cell.is_stream_continuation() {
|
|
if self.has_emitted_history_lines {
|
|
display.insert(0, Line::from(""));
|
|
} else {
|
|
self.has_emitted_history_lines = true;
|
|
}
|
|
}
|
|
if self.overlay.is_some() {
|
|
self.deferred_history_lines.extend(display);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::StartCommitAnimation => {
|
|
if self
|
|
.commit_anim_running
|
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
|
.is_ok()
|
|
{
|
|
let tx = self.app_event_tx.clone();
|
|
let running = self.commit_anim_running.clone();
|
|
thread::spawn(move || {
|
|
while running.load(Ordering::Relaxed) {
|
|
thread::sleep(Duration::from_millis(50));
|
|
tx.send(AppEvent::CommitTick);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
AppEvent::StopCommitAnimation => {
|
|
self.commit_anim_running.store(false, Ordering::Release);
|
|
}
|
|
AppEvent::CommitTick => {
|
|
self.chat_widget.on_commit_tick();
|
|
}
|
|
AppEvent::CodexEvent(event) => {
|
|
if self.suppress_shutdown_complete
|
|
&& matches!(event.msg, EventMsg::ShutdownComplete)
|
|
{
|
|
self.suppress_shutdown_complete = false;
|
|
return Ok(true);
|
|
}
|
|
if let EventMsg::ListSkillsResponse(response) = &event.msg {
|
|
let cwd = self.chat_widget.config_ref().cwd.clone();
|
|
let errors = errors_for_cwd(&cwd, response);
|
|
if errors.is_empty() {
|
|
self.chat_widget.handle_codex_event(event);
|
|
return Ok(true);
|
|
}
|
|
let errors = skill_errors_from_info(&errors);
|
|
match run_skill_error_prompt(tui, &errors).await {
|
|
SkillErrorPromptOutcome::Exit => {
|
|
self.chat_widget.submit_op(Op::Shutdown);
|
|
return Ok(false);
|
|
}
|
|
SkillErrorPromptOutcome::Continue => {}
|
|
}
|
|
}
|
|
self.chat_widget.handle_codex_event(event);
|
|
}
|
|
AppEvent::ConversationHistory(ev) => {
|
|
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
|
}
|
|
AppEvent::ExitRequest => {
|
|
return Ok(false);
|
|
}
|
|
AppEvent::CodexOp(op) => self.chat_widget.submit_op(op),
|
|
AppEvent::DiffResult(text) => {
|
|
// Clear the in-progress state in the bottom pane
|
|
self.chat_widget.on_diff_complete();
|
|
// Enter alternate screen using TUI helper and build pager lines
|
|
let _ = tui.enter_alt_screen();
|
|
let pager_lines: Vec<ratatui::text::Line<'static>> = if text.trim().is_empty() {
|
|
vec!["No changes detected.".italic().into()]
|
|
} else {
|
|
text.lines().map(ansi_escape_line).collect()
|
|
};
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
pager_lines,
|
|
"D I F F".to_string(),
|
|
));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
AppEvent::StartFileSearch(query) => {
|
|
if !query.is_empty() {
|
|
self.file_search.on_user_query(query);
|
|
}
|
|
}
|
|
AppEvent::FileSearchResult { query, matches } => {
|
|
self.chat_widget.apply_file_search_result(query, matches);
|
|
}
|
|
AppEvent::RateLimitSnapshotFetched(snapshot) => {
|
|
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
|
|
}
|
|
AppEvent::UpdateReasoningEffort(effort) => {
|
|
self.on_update_reasoning_effort(effort);
|
|
}
|
|
AppEvent::UpdateModel(model) => {
|
|
let model_family = self
|
|
.server
|
|
.get_models_manager()
|
|
.construct_model_family(&model, &self.config)
|
|
.await;
|
|
self.chat_widget.set_model(&model, model_family);
|
|
self.current_model = model;
|
|
}
|
|
AppEvent::OpenReasoningPopup { model } => {
|
|
self.chat_widget.open_reasoning_popup(model);
|
|
}
|
|
AppEvent::OpenAllModelsPopup { models } => {
|
|
self.chat_widget.open_all_models_popup(models);
|
|
}
|
|
AppEvent::OpenFullAccessConfirmation { preset } => {
|
|
self.chat_widget.open_full_access_confirmation(preset);
|
|
}
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
} => {
|
|
self.chat_widget.open_world_writable_warning_confirmation(
|
|
preset,
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
);
|
|
}
|
|
AppEvent::OpenFeedbackNote {
|
|
category,
|
|
include_logs,
|
|
} => {
|
|
self.chat_widget.open_feedback_note(category, include_logs);
|
|
}
|
|
AppEvent::OpenFeedbackConsent { category } => {
|
|
self.chat_widget.open_feedback_consent(category);
|
|
}
|
|
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
|
|
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
|
}
|
|
AppEvent::EnableWindowsSandboxForAgentMode { preset } => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let profile = self.active_profile.as_deref();
|
|
let feature_key = Feature::WindowsSandbox.key();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_feature_enabled(feature_key, true)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
self.config.set_windows_sandbox_globally(true);
|
|
self.chat_widget.clear_forced_auto_mode_downgrade();
|
|
if let Some((sample_paths, extra_count, failed_scan)) =
|
|
self.chat_widget.world_writable_warning_details()
|
|
{
|
|
self.app_event_tx.send(
|
|
AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset: Some(preset.clone()),
|
|
sample_paths,
|
|
extra_count,
|
|
failed_scan,
|
|
},
|
|
);
|
|
} else {
|
|
self.app_event_tx.send(AppEvent::CodexOp(
|
|
Op::OverrideTurnContext {
|
|
cwd: None,
|
|
approval_policy: Some(preset.approval),
|
|
sandbox_policy: Some(preset.sandbox.clone()),
|
|
model: None,
|
|
effort: None,
|
|
summary: None,
|
|
},
|
|
));
|
|
self.app_event_tx
|
|
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
|
|
self.app_event_tx
|
|
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
|
|
self.chat_widget.add_info_message(
|
|
"Enabled experimental Windows sandbox.".to_string(),
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to enable Windows sandbox feature"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to enable the Windows sandbox feature: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = preset;
|
|
}
|
|
}
|
|
AppEvent::PersistModelSelection { model, effort } => {
|
|
let profile = self.active_profile.as_deref();
|
|
match ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.with_profile(profile)
|
|
.set_model(Some(model.as_str()), effort)
|
|
.apply()
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
let mut message = format!("Model changed to {model}");
|
|
if let Some(label) = Self::reasoning_label_for(&model, effort) {
|
|
message.push(' ');
|
|
message.push_str(label);
|
|
}
|
|
if let Some(profile) = profile {
|
|
message.push_str(" for ");
|
|
message.push_str(profile);
|
|
message.push_str(" profile");
|
|
}
|
|
self.chat_widget.add_info_message(message, None);
|
|
}
|
|
Err(err) => {
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist model selection"
|
|
);
|
|
if let Some(profile) = profile {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save model for profile `{profile}`: {err}"
|
|
));
|
|
} else {
|
|
self.chat_widget
|
|
.add_error_message(format!("Failed to save default model: {err}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
|
self.chat_widget.set_approval_policy(policy);
|
|
}
|
|
AppEvent::UpdateSandboxPolicy(policy) => {
|
|
#[cfg(target_os = "windows")]
|
|
let policy_is_workspace_write_or_ro = matches!(
|
|
policy,
|
|
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
|
| codex_core::protocol::SandboxPolicy::ReadOnly
|
|
);
|
|
|
|
self.config.sandbox_policy = policy.clone();
|
|
#[cfg(target_os = "windows")]
|
|
if !matches!(policy, codex_core::protocol::SandboxPolicy::ReadOnly)
|
|
|| codex_core::get_platform_sandbox().is_some()
|
|
{
|
|
self.config.forced_auto_mode_downgraded_on_windows = false;
|
|
}
|
|
self.chat_widget.set_sandbox_policy(policy);
|
|
|
|
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
// One-shot suppression if the user just confirmed continue.
|
|
if self.skip_world_writable_scan_once {
|
|
self.skip_world_writable_scan_once = false;
|
|
return Ok(true);
|
|
}
|
|
|
|
let should_check = codex_core::get_platform_sandbox().is_some()
|
|
&& policy_is_workspace_write_or_ro
|
|
&& !self.chat_widget.world_writable_warning_hidden();
|
|
if should_check {
|
|
let cwd = self.config.cwd.clone();
|
|
let env_map: std::collections::HashMap<String, String> =
|
|
std::env::vars().collect();
|
|
let tx = self.app_event_tx.clone();
|
|
let logs_base_dir = self.config.codex_home.clone();
|
|
let sandbox_policy = self.config.sandbox_policy.clone();
|
|
Self::spawn_world_writable_scan(
|
|
cwd,
|
|
env_map,
|
|
logs_base_dir,
|
|
sandbox_policy,
|
|
tx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AppEvent::SkipNextWorldWritableScan => {
|
|
self.skip_world_writable_scan_once = true;
|
|
}
|
|
AppEvent::UpdateFullAccessWarningAcknowledged(ack) => {
|
|
self.chat_widget.set_full_access_warning_acknowledged(ack);
|
|
}
|
|
AppEvent::UpdateWorldWritableWarningAcknowledged(ack) => {
|
|
self.chat_widget
|
|
.set_world_writable_warning_acknowledged(ack);
|
|
}
|
|
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
|
|
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
|
|
}
|
|
AppEvent::PersistFullAccessWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_full_access_warning(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist full access warning acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save full access confirmation preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistWorldWritableWarningAcknowledged => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_world_writable_warning(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist world-writable warning acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save Agent mode warning preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.set_hide_rate_limit_model_nudge(true)
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist rate limit switch prompt preference"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save rate limit reminder preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::PersistModelMigrationPromptAcknowledged {
|
|
from_model,
|
|
to_model,
|
|
} => {
|
|
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
|
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
|
|
.apply()
|
|
.await
|
|
{
|
|
tracing::error!(
|
|
error = %err,
|
|
"failed to persist model migration prompt acknowledgement"
|
|
);
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to save model migration prompt preference: {err}"
|
|
));
|
|
}
|
|
}
|
|
AppEvent::OpenApprovalsPopup => {
|
|
self.chat_widget.open_approvals_popup();
|
|
}
|
|
AppEvent::OpenReviewBranchPicker(cwd) => {
|
|
self.chat_widget.show_review_branch_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCommitPicker(cwd) => {
|
|
self.chat_widget.show_review_commit_picker(&cwd).await;
|
|
}
|
|
AppEvent::OpenReviewCustomPrompt => {
|
|
self.chat_widget.show_review_custom_prompt();
|
|
}
|
|
AppEvent::FullScreenApprovalRequest(request) => match request {
|
|
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
|
let _ = tui.enter_alt_screen();
|
|
let diff_summary = DiffSummary::new(changes, cwd);
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![diff_summary.into()],
|
|
"P A T C H".to_string(),
|
|
));
|
|
}
|
|
ApprovalRequest::Exec { command, .. } => {
|
|
let _ = tui.enter_alt_screen();
|
|
let full_cmd = strip_bash_lc_and_escape(&command);
|
|
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
|
self.overlay = Some(Overlay::new_static_with_lines(
|
|
full_cmd_lines,
|
|
"E X E C".to_string(),
|
|
));
|
|
}
|
|
ApprovalRequest::McpElicitation {
|
|
server_name,
|
|
message,
|
|
..
|
|
} => {
|
|
let _ = tui.enter_alt_screen();
|
|
let paragraph = Paragraph::new(vec![
|
|
Line::from(vec!["Server: ".into(), server_name.bold()]),
|
|
Line::from(""),
|
|
Line::from(message),
|
|
])
|
|
.wrap(Wrap { trim: false });
|
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
|
vec![Box::new(paragraph)],
|
|
"E L I C I T A T I O N".to_string(),
|
|
));
|
|
}
|
|
},
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {
|
|
match reasoning_effort {
|
|
Some(ReasoningEffortConfig::Minimal) => "minimal",
|
|
Some(ReasoningEffortConfig::Low) => "low",
|
|
Some(ReasoningEffortConfig::Medium) => "medium",
|
|
Some(ReasoningEffortConfig::High) => "high",
|
|
Some(ReasoningEffortConfig::XHigh) => "xhigh",
|
|
None | Some(ReasoningEffortConfig::None) => "default",
|
|
}
|
|
}
|
|
|
|
fn reasoning_label_for(
|
|
model: &str,
|
|
reasoning_effort: Option<ReasoningEffortConfig>,
|
|
) -> Option<&'static str> {
|
|
(!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort))
|
|
}
|
|
|
|
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
|
self.chat_widget.token_usage()
|
|
}
|
|
|
|
fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
|
|
self.chat_widget.set_reasoning_effort(effort);
|
|
self.config.model_reasoning_effort = effort;
|
|
}
|
|
|
|
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
|
|
match key_event {
|
|
KeyEvent {
|
|
code: KeyCode::Char('t'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} => {
|
|
// Enter alternate screen and set viewport to full size.
|
|
let _ = tui.enter_alt_screen();
|
|
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
// Esc primes/advances backtracking only in normal (not working) mode
|
|
// with the composer focused and empty. In any other state, forward
|
|
// Esc so the active UI (e.g. status indicator, modals, popups)
|
|
// handles it.
|
|
KeyEvent {
|
|
code: KeyCode::Esc,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
if self.chat_widget.is_normal_backtrack_mode()
|
|
&& self.chat_widget.composer_is_empty()
|
|
{
|
|
self.handle_backtrack_esc_key(tui);
|
|
} else {
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Char('y'),
|
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.copy_transcript_selection(tui);
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::PageUp,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
let size = tui.terminal.last_known_screen_size;
|
|
let width = size.width;
|
|
let height = size.height;
|
|
if width > 0 && height > 0 {
|
|
let chat_height = self.chat_widget.desired_height(width);
|
|
if chat_height < height {
|
|
let transcript_height = height.saturating_sub(chat_height);
|
|
if transcript_height > 0 {
|
|
let delta = -i32::from(transcript_height);
|
|
self.scroll_transcript(
|
|
tui,
|
|
delta,
|
|
usize::from(transcript_height),
|
|
width,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::PageDown,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
let size = tui.terminal.last_known_screen_size;
|
|
let width = size.width;
|
|
let height = size.height;
|
|
if width > 0 && height > 0 {
|
|
let chat_height = self.chat_widget.desired_height(width);
|
|
if chat_height < height {
|
|
let transcript_height = height.saturating_sub(chat_height);
|
|
if transcript_height > 0 {
|
|
let delta = i32::from(transcript_height);
|
|
self.scroll_transcript(
|
|
tui,
|
|
delta,
|
|
usize::from(transcript_height),
|
|
width,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::Home,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
if !self.transcript_cells.is_empty() {
|
|
self.transcript_scroll = TranscriptScroll::Scrolled {
|
|
cell_index: 0,
|
|
line_in_cell: 0,
|
|
};
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
}
|
|
KeyEvent {
|
|
code: KeyCode::End,
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
self.transcript_scroll = TranscriptScroll::ToBottom;
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
|
KeyEvent {
|
|
code: KeyCode::Enter,
|
|
kind: KeyEventKind::Press,
|
|
..
|
|
} if self.backtrack.primed
|
|
&& self.backtrack.nth_user_message != usize::MAX
|
|
&& self.chat_widget.composer_is_empty() =>
|
|
{
|
|
// Delegate to helper for clarity; preserves behavior.
|
|
self.confirm_backtrack_from_main();
|
|
}
|
|
KeyEvent {
|
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
|
..
|
|
} => {
|
|
// Any non-Esc key press should cancel a primed backtrack.
|
|
// This avoids stale "Esc-primed" state after the user starts typing
|
|
// (even if they later backspace to empty).
|
|
if key_event.code != KeyCode::Esc && self.backtrack.primed {
|
|
self.reset_backtrack_state();
|
|
}
|
|
self.chat_widget.handle_key_event(key_event);
|
|
}
|
|
_ => {
|
|
// Ignore Release key events.
|
|
}
|
|
};
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn spawn_world_writable_scan(
|
|
cwd: PathBuf,
|
|
env_map: std::collections::HashMap<String, String>,
|
|
logs_base_dir: PathBuf,
|
|
sandbox_policy: codex_core::protocol::SandboxPolicy,
|
|
tx: AppEventSender,
|
|
) {
|
|
tokio::task::spawn_blocking(move || {
|
|
let result = codex_windows_sandbox::apply_world_writable_scan_and_denies(
|
|
&logs_base_dir,
|
|
&cwd,
|
|
&env_map,
|
|
&sandbox_policy,
|
|
Some(logs_base_dir.as_path()),
|
|
);
|
|
if result.is_err() {
|
|
// Scan failed: warn without examples.
|
|
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
|
preset: None,
|
|
sample_paths: Vec::new(),
|
|
extra_count: 0usize,
|
|
failed_scan: true,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::app_backtrack::BacktrackState;
|
|
use crate::app_backtrack::user_count;
|
|
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
|
use crate::file_search::FileSearchManager;
|
|
use crate::history_cell::AgentMessageCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::history_cell::new_session_info;
|
|
use codex_core::AuthManager;
|
|
use codex_core::CodexAuth;
|
|
use codex_core::ConversationManager;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::Event;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use codex_core::protocol::SessionConfiguredEvent;
|
|
use codex_protocol::ConversationId;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::prelude::Line;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
fn make_test_app() -> App {
|
|
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender();
|
|
let config = chat_widget.config_ref().clone();
|
|
let current_model = chat_widget.get_model_family().get_model_slug().to_string();
|
|
let server = Arc::new(ConversationManager::with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
));
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
|
|
App {
|
|
server,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
current_model,
|
|
active_profile: None,
|
|
file_search,
|
|
transcript_cells: Vec::new(),
|
|
transcript_scroll: TranscriptScroll::ToBottom,
|
|
transcript_selection: TranscriptSelection::default(),
|
|
transcript_view_top: 0,
|
|
transcript_total_lines: 0,
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
enhanced_keys_supported: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
}
|
|
}
|
|
|
|
fn make_test_app_with_channels() -> (
|
|
App,
|
|
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
|
tokio::sync::mpsc::UnboundedReceiver<Op>,
|
|
) {
|
|
let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender();
|
|
let config = chat_widget.config_ref().clone();
|
|
let current_model = chat_widget.get_model_family().get_model_slug().to_string();
|
|
let server = Arc::new(ConversationManager::with_models_provider(
|
|
CodexAuth::from_api_key("Test API Key"),
|
|
config.model_provider.clone(),
|
|
));
|
|
let auth_manager =
|
|
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
|
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
|
|
|
(
|
|
App {
|
|
server,
|
|
app_event_tx,
|
|
chat_widget,
|
|
auth_manager,
|
|
config,
|
|
current_model,
|
|
active_profile: None,
|
|
file_search,
|
|
transcript_cells: Vec::new(),
|
|
transcript_scroll: TranscriptScroll::ToBottom,
|
|
transcript_selection: TranscriptSelection::default(),
|
|
transcript_view_top: 0,
|
|
transcript_total_lines: 0,
|
|
overlay: None,
|
|
deferred_history_lines: Vec::new(),
|
|
has_emitted_history_lines: false,
|
|
enhanced_keys_supported: false,
|
|
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
|
backtrack: BacktrackState::default(),
|
|
feedback: codex_feedback::CodexFeedback::new(),
|
|
pending_update_action: None,
|
|
suppress_shutdown_complete: false,
|
|
skip_world_writable_scan_once: false,
|
|
},
|
|
rx,
|
|
op_rx,
|
|
)
|
|
}
|
|
|
|
fn all_model_presets() -> Vec<ModelPreset> {
|
|
codex_core::openai_models::model_presets::all_model_presets().clone()
|
|
}
|
|
|
|
#[test]
|
|
fn model_migration_prompt_only_shows_for_deprecated_models() {
|
|
let seen = BTreeMap::new();
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex",
|
|
"gpt-5.1-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5-codex-mini",
|
|
"gpt-5.1-codex-mini",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex-max",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1-codex",
|
|
"gpt-5.1-codex",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn model_migration_prompt_respects_hide_flag_and_self_target() {
|
|
let mut seen = BTreeMap::new();
|
|
seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
assert!(!should_show_model_migration_prompt(
|
|
"gpt-5.1",
|
|
"gpt-5.1",
|
|
&seen,
|
|
&all_model_presets()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn update_reasoning_effort_updates_config() {
|
|
let mut app = make_test_app();
|
|
app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Medium);
|
|
app.chat_widget
|
|
.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));
|
|
|
|
app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High));
|
|
|
|
assert_eq!(
|
|
app.config.model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
assert_eq!(
|
|
app.chat_widget.config_ref().model_reasoning_effort,
|
|
Some(ReasoningEffortConfig::High)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
|
|
let mut app = make_test_app();
|
|
|
|
let user_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(UserHistoryCell {
|
|
message: text.to_string(),
|
|
}) as Arc<dyn HistoryCell>
|
|
};
|
|
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
|
|
Arc::new(AgentMessageCell::new(
|
|
vec![Line::from(text.to_string())],
|
|
true,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
let make_header = |is_first| {
|
|
let event = SessionConfiguredEvent {
|
|
session_id: ConversationId::new(),
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: PathBuf::new(),
|
|
};
|
|
Arc::new(new_session_info(
|
|
app.chat_widget.config_ref(),
|
|
app.current_model.as_str(),
|
|
event,
|
|
is_first,
|
|
)) as Arc<dyn HistoryCell>
|
|
};
|
|
|
|
// Simulate the transcript after trimming for a fork, replaying history, and
|
|
// appending the edited turn. The session header separates the retained history
|
|
// from the forked conversation's replayed turns.
|
|
app.transcript_cells = vec![
|
|
make_header(true),
|
|
user_cell("first question"),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up"),
|
|
agent_cell("answer follow-up"),
|
|
make_header(false),
|
|
user_cell("first question"),
|
|
agent_cell("answer first"),
|
|
user_cell("follow-up (edited)"),
|
|
agent_cell("answer edited"),
|
|
];
|
|
|
|
assert_eq!(user_count(&app.transcript_cells), 2);
|
|
|
|
app.backtrack.base_id = Some(ConversationId::new());
|
|
app.backtrack.primed = true;
|
|
app.backtrack.nth_user_message = user_count(&app.transcript_cells).saturating_sub(1);
|
|
|
|
app.confirm_backtrack_from_main();
|
|
|
|
let (_, nth, prefill) = app.backtrack.pending.clone().expect("pending backtrack");
|
|
assert_eq!(nth, 1);
|
|
assert_eq!(prefill, "follow-up (edited)");
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_selection_moves_with_scroll() {
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
|
|
let mut app = make_test_app();
|
|
app.transcript_total_lines = 3;
|
|
|
|
let area = Rect {
|
|
x: 0,
|
|
y: 0,
|
|
width: 10,
|
|
height: 2,
|
|
};
|
|
|
|
// Anchor selection to logical line 1, columns 2..4.
|
|
app.transcript_selection = TranscriptSelection {
|
|
anchor: Some(TranscriptSelectionPoint {
|
|
line_index: 1,
|
|
column: 2,
|
|
}),
|
|
head: Some(TranscriptSelectionPoint {
|
|
line_index: 1,
|
|
column: 4,
|
|
}),
|
|
};
|
|
|
|
// First render: top of view is line 0, so line 1 maps to the second row.
|
|
app.transcript_view_top = 0;
|
|
let mut buf = Buffer::empty(area);
|
|
for x in 2..area.width {
|
|
buf[(x, 0)].set_symbol("A");
|
|
buf[(x, 1)].set_symbol("B");
|
|
}
|
|
|
|
app.apply_transcript_selection(area, &mut buf);
|
|
|
|
// No selection should be applied to the first row when the view is anchored at the top.
|
|
for x in 0..area.width {
|
|
let cell = &buf[(x, 0)];
|
|
assert!(cell.style().add_modifier.is_empty());
|
|
}
|
|
|
|
// After scrolling down by one line, the same logical line should now be
|
|
// rendered on the first row, and the highlight should move with it.
|
|
app.transcript_view_top = 1;
|
|
let mut buf_scrolled = Buffer::empty(area);
|
|
for x in 2..area.width {
|
|
buf_scrolled[(x, 0)].set_symbol("B");
|
|
buf_scrolled[(x, 1)].set_symbol("C");
|
|
}
|
|
|
|
app.apply_transcript_selection(area, &mut buf_scrolled);
|
|
|
|
// After scrolling, the selection should now be applied on the first row rather than the
|
|
// second.
|
|
for x in 0..area.width {
|
|
let cell = &buf_scrolled[(x, 1)];
|
|
assert!(cell.style().add_modifier.is_empty());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn new_session_requests_shutdown_for_previous_conversation() {
|
|
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels();
|
|
|
|
let conversation_id = ConversationId::new();
|
|
let event = SessionConfiguredEvent {
|
|
session_id: conversation_id,
|
|
model: "gpt-test".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
approval_policy: AskForApproval::Never,
|
|
sandbox_policy: SandboxPolicy::ReadOnly,
|
|
cwd: PathBuf::from("/home/user/project"),
|
|
reasoning_effort: None,
|
|
history_log_id: 0,
|
|
history_entry_count: 0,
|
|
initial_messages: None,
|
|
rollout_path: PathBuf::new(),
|
|
};
|
|
|
|
app.chat_widget.handle_codex_event(Event {
|
|
id: String::new(),
|
|
msg: EventMsg::SessionConfigured(event),
|
|
});
|
|
|
|
while app_event_rx.try_recv().is_ok() {}
|
|
while op_rx.try_recv().is_ok() {}
|
|
|
|
app.shutdown_current_conversation().await;
|
|
|
|
match op_rx.try_recv() {
|
|
Ok(Op::Shutdown) => {}
|
|
Ok(other) => panic!("expected Op::Shutdown, got {other:?}"),
|
|
Err(_) => panic!("expected shutdown op to be sent"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn session_summary_skip_zero_usage() {
|
|
assert!(session_summary(TokenUsage::default(), None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn render_lines_to_ansi_pads_user_rows_to_full_width() {
|
|
let line: Line<'static> = Line::from("hi");
|
|
let lines = vec![line];
|
|
let meta = vec![Some((0usize, 0usize))];
|
|
let is_user_cell = vec![true];
|
|
let width: u16 = 10;
|
|
|
|
let rendered = App::render_lines_to_ansi(&lines, &meta, &is_user_cell, width);
|
|
assert_eq!(rendered.len(), 1);
|
|
assert!(rendered[0].contains("hi"));
|
|
}
|
|
|
|
#[test]
|
|
fn session_summary_includes_resume_hint() {
|
|
let usage = TokenUsage {
|
|
input_tokens: 10,
|
|
output_tokens: 2,
|
|
total_tokens: 12,
|
|
..Default::default()
|
|
};
|
|
let conversation =
|
|
ConversationId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap();
|
|
|
|
let summary = session_summary(usage, Some(conversation)).expect("summary");
|
|
assert_eq!(
|
|
summary.usage_line,
|
|
"Token usage: total=12 input=10 output=2"
|
|
);
|
|
assert_eq!(
|
|
summary.resume_command,
|
|
Some("codex resume 123e4567-e89b-12d3-a456-426614174000".to_string())
|
|
);
|
|
}
|
|
}
|