mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
268 lines
9.8 KiB
Rust
268 lines
9.8 KiB
Rust
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
// Heuristic thresholds for detecting paste-like input bursts.
|
|
// Detect quickly to avoid showing typed prefix before paste is recognized
|
|
const PASTE_BURST_MIN_CHARS: u16 = 3;
|
|
const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
|
|
const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct PasteBurst {
|
|
last_plain_char_time: Option<Instant>,
|
|
consecutive_plain_char_burst: u16,
|
|
burst_window_until: Option<Instant>,
|
|
buffer: String,
|
|
active: bool,
|
|
// Hold first fast char briefly to avoid rendering flicker
|
|
pending_first_char: Option<(char, Instant)>,
|
|
}
|
|
|
|
pub(crate) enum CharDecision {
|
|
/// Start buffering and retroactively capture some already-inserted chars.
|
|
BeginBuffer { retro_chars: u16 },
|
|
/// We are currently buffering; append the current char into the buffer.
|
|
BufferAppend,
|
|
/// Do not insert/render this char yet; temporarily save the first fast
|
|
/// char while we wait to see if a paste-like burst follows.
|
|
RetainFirstChar,
|
|
/// Begin buffering using the previously saved first char (no retro grab needed).
|
|
BeginBufferFromPending,
|
|
}
|
|
|
|
pub(crate) struct RetroGrab {
|
|
pub start_byte: usize,
|
|
pub grabbed: String,
|
|
}
|
|
|
|
pub(crate) enum FlushResult {
|
|
Paste(String),
|
|
Typed(char),
|
|
None,
|
|
}
|
|
|
|
impl PasteBurst {
|
|
/// Recommended delay to wait between simulated keypresses (or before
|
|
/// scheduling a UI tick) so that a pending fast keystroke is flushed
|
|
/// out of the burst detector as normal typed input.
|
|
///
|
|
/// Primarily used by tests and by the TUI to reliably cross the
|
|
/// paste-burst timing threshold.
|
|
pub fn recommended_flush_delay() -> Duration {
|
|
PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
|
|
}
|
|
|
|
/// Entry point: decide how to treat a plain char with current timing.
|
|
pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
|
|
match self.last_plain_char_time {
|
|
Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
|
|
self.consecutive_plain_char_burst =
|
|
self.consecutive_plain_char_burst.saturating_add(1)
|
|
}
|
|
_ => self.consecutive_plain_char_burst = 1,
|
|
}
|
|
self.last_plain_char_time = Some(now);
|
|
|
|
if self.active {
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
return CharDecision::BufferAppend;
|
|
}
|
|
|
|
// If we already held a first char and receive a second fast char,
|
|
// start buffering without retro-grabbing (we never rendered the first).
|
|
if let Some((held, held_at)) = self.pending_first_char
|
|
&& now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL
|
|
{
|
|
self.active = true;
|
|
// take() to clear pending; we already captured the held char above
|
|
let _ = self.pending_first_char.take();
|
|
self.buffer.push(held);
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
return CharDecision::BeginBufferFromPending;
|
|
}
|
|
|
|
if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
|
|
return CharDecision::BeginBuffer {
|
|
retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
|
|
};
|
|
}
|
|
|
|
// Save the first fast char very briefly to see if a burst follows.
|
|
self.pending_first_char = Some((ch, now));
|
|
CharDecision::RetainFirstChar
|
|
}
|
|
|
|
/// Flush the buffered burst if the inter-key timeout has elapsed.
|
|
///
|
|
/// Returns Some(String) when either:
|
|
/// - We were actively buffering paste-like input and the buffer is now
|
|
/// emitted as a single pasted string; or
|
|
/// - We had saved a single fast first-char with no subsequent burst and we
|
|
/// now emit that char as normal typed input.
|
|
///
|
|
/// Returns None if the timeout has not elapsed or there is nothing to flush.
|
|
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
|
|
let timed_out = self
|
|
.last_plain_char_time
|
|
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
|
|
if timed_out && self.is_active_internal() {
|
|
self.active = false;
|
|
let out = std::mem::take(&mut self.buffer);
|
|
FlushResult::Paste(out)
|
|
} else if timed_out {
|
|
// If we were saving a single fast char and no burst followed,
|
|
// flush it as normal typed input.
|
|
if let Some((ch, _at)) = self.pending_first_char.take() {
|
|
FlushResult::Typed(ch)
|
|
} else {
|
|
FlushResult::None
|
|
}
|
|
} else {
|
|
FlushResult::None
|
|
}
|
|
}
|
|
|
|
/// While bursting: accumulate a newline into the buffer instead of
|
|
/// submitting the textarea.
|
|
///
|
|
/// Returns true if a newline was appended (we are in a burst context),
|
|
/// false otherwise.
|
|
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
|
|
if self.is_active() {
|
|
self.buffer.push('\n');
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Decide if Enter should insert a newline (burst context) vs submit.
|
|
pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
|
|
let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
|
|
self.is_active() || in_burst_window
|
|
}
|
|
|
|
/// Keep the burst window alive.
|
|
pub fn extend_window(&mut self, now: Instant) {
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
}
|
|
|
|
/// Begin buffering with retroactively grabbed text.
|
|
pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
|
|
if !grabbed.is_empty() {
|
|
self.buffer.push_str(&grabbed);
|
|
}
|
|
self.active = true;
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
}
|
|
|
|
/// Append a char into the burst buffer.
|
|
pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
|
|
self.buffer.push(ch);
|
|
self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
|
|
}
|
|
|
|
/// Try to append a char into the burst buffer only if a burst is already active.
|
|
///
|
|
/// Returns true when the char was captured into the existing burst, false otherwise.
|
|
pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
|
|
if self.active || !self.buffer.is_empty() {
|
|
self.append_char_to_buffer(ch, now);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Decide whether to begin buffering by retroactively capturing recent
|
|
/// chars from the slice before the cursor.
|
|
///
|
|
/// Heuristic: if the retro-grabbed slice contains any whitespace or is
|
|
/// sufficiently long (>= 16 characters), treat it as paste-like to avoid
|
|
/// rendering the typed prefix momentarily before the paste is recognized.
|
|
/// This favors responsiveness and prevents flicker for typical pastes
|
|
/// (URLs, file paths, multiline text) while not triggering on short words.
|
|
///
|
|
/// Returns Some(RetroGrab) with the start byte and grabbed text when we
|
|
/// decide to buffer retroactively; otherwise None.
|
|
pub fn decide_begin_buffer(
|
|
&mut self,
|
|
now: Instant,
|
|
before: &str,
|
|
retro_chars: usize,
|
|
) -> Option<RetroGrab> {
|
|
let start_byte = retro_start_index(before, retro_chars);
|
|
let grabbed = before[start_byte..].to_string();
|
|
let looks_pastey =
|
|
grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
|
|
if looks_pastey {
|
|
// Note: caller is responsible for removing this slice from UI text.
|
|
self.begin_with_retro_grabbed(grabbed.clone(), now);
|
|
Some(RetroGrab {
|
|
start_byte,
|
|
grabbed,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Before applying modified/non-char input: flush buffered burst immediately.
|
|
pub fn flush_before_modified_input(&mut self) -> Option<String> {
|
|
if !self.is_active() {
|
|
return None;
|
|
}
|
|
self.active = false;
|
|
let mut out = std::mem::take(&mut self.buffer);
|
|
if let Some((ch, _at)) = self.pending_first_char.take() {
|
|
out.push(ch);
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Clear only the timing window and any pending first-char.
|
|
///
|
|
/// Does not emit or clear the buffered text itself; callers should have
|
|
/// already flushed (if needed) via one of the flush methods above.
|
|
pub fn clear_window_after_non_char(&mut self) {
|
|
self.consecutive_plain_char_burst = 0;
|
|
self.last_plain_char_time = None;
|
|
self.burst_window_until = None;
|
|
self.active = false;
|
|
self.pending_first_char = None;
|
|
}
|
|
|
|
/// Returns true if we are in any paste-burst related transient state
|
|
/// (actively buffering, have a non-empty buffer, or have saved the first
|
|
/// fast char while waiting for a potential burst).
|
|
pub fn is_active(&self) -> bool {
|
|
self.is_active_internal() || self.pending_first_char.is_some()
|
|
}
|
|
|
|
fn is_active_internal(&self) -> bool {
|
|
self.active || !self.buffer.is_empty()
|
|
}
|
|
|
|
pub fn clear_after_explicit_paste(&mut self) {
|
|
self.last_plain_char_time = None;
|
|
self.consecutive_plain_char_burst = 0;
|
|
self.burst_window_until = None;
|
|
self.active = false;
|
|
self.buffer.clear();
|
|
self.pending_first_char = None;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize {
|
|
if retro_chars == 0 {
|
|
return before.len();
|
|
}
|
|
before
|
|
.char_indices()
|
|
.rev()
|
|
.nth(retro_chars.saturating_sub(1))
|
|
.map(|(idx, _)| idx)
|
|
.unwrap_or(0)
|
|
}
|