mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fixing drag/drop photos bug in codex state of the world before: sometimes, when you drag screenshots into codex, the image does not properly render into context. instead, the file name is shown in quotation marks. https://github.com/user-attachments/assets/3c0e540a-505c-4ec0-b634-e9add6a73119 the screenshot is not actually included in agent context. the agent needs to manually call the view_image tool to see the screenshot. this can be unreliable especially if the image is part of a longer prompt and is dependent on the agent going out of its way to view the image. state of the world after: https://github.com/user-attachments/assets/5f2b7bf7-8a3f-4708-85f3-d68a017bfd97 now, images will always be directly embedded into chat context ## Technical Details - MacOS sends screenshot paths with a narrow no‑break space right before the “AM/PM” suffix, which used to trigger our non‑ASCII fallback in the paste burst detector. - That fallback flushed the partially buffered paste immediately, so the path arrived in two separate `handle_paste` calls (quoted prefix + `PM.png'`). The split string could not be normalized to a real path, so we showed the quoted filename instead of embedding the image. - We now append non‑ASCII characters into the burst buffer when a burst is already active. Finder’s payload stays intact, the path normalizes, and the image attaches automatically. - When no burst is active (e.g. during IME typing), non‑ASCII characters still bypass the buffer so text entry remains responsive.
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)
|
|
}
|