Compare commits

...

3 Commits

Author SHA1 Message Date
Charles Cunningham
91fe7b5d72 Simplify 2026-02-07 16:53:30 -08:00
Charles Cunningham
7b726d1980 Restore pending large-paste payloads on Ctrl+C history recall 2026-02-07 16:52:46 -08:00
Charles Cunningham
191bcae967 Update docs and tests 2026-02-07 16:51:29 -08:00
4 changed files with 95 additions and 26 deletions

View File

@@ -38,6 +38,10 @@ pub struct TextElement {
/// Byte range in the parent `text` buffer that this element occupies.
pub byte_range: ByteRange,
/// Optional human-readable placeholder for the element, displayed in the UI.
///
/// Placeholders are unique within a single composer buffer. This includes both generic
/// text element placeholders (like large paste markers) and image attachment placeholders,
/// enabling exact matching between elements and their backing payloads.
placeholder: Option<String>,
}

View File

@@ -21,9 +21,10 @@
//! The Up/Down history path is managed by [`ChatComposerHistory`]. It merges:
//!
//! - Persistent cross-session history (text-only; no element ranges or attachments).
//! - Local in-session history (full text + text elements + local image paths).
//! - Local in-session history (full text + text elements + local image paths + pending pastes).
//!
//! When recalling a local entry, the composer rehydrates text elements and image attachments.
//! When recalling a local entry, the composer rehydrates text elements, pending pastes, and image
//! attachments.
//! When recalling a persistent entry, only the text is restored.
//!
//! # Submission and Prompt Expansion
@@ -539,13 +540,9 @@ impl ChatComposer {
return false;
};
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
// attachments), but local in-session ↑/↓ history can rehydrate elements, image paths,
// mention bindings, and pending large-paste payloads.
self.restore_history_entry(entry);
true
}
@@ -815,6 +812,16 @@ impl ChatComposer {
self.sync_popups();
}
fn restore_history_entry(&mut self, entry: HistoryEntry) {
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
self.pending_pastes = entry.pending_pastes;
}
/// Update the placeholder text without changing input enablement.
pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.placeholder_text = placeholder;
@@ -832,6 +839,7 @@ impl ChatComposer {
}
let previous = self.current_text();
let text_elements = self.textarea.text_elements();
let pending_pastes = self.pending_pastes.clone();
let local_image_paths = self
.attached_images
.iter()
@@ -845,6 +853,7 @@ impl ChatComposer {
text_elements,
local_image_paths,
mention_bindings,
pending_pastes,
});
Some(previous)
}
@@ -2065,6 +2074,7 @@ impl ChatComposer {
text_elements: text_elements.clone(),
local_image_paths,
mention_bindings: original_mention_bindings,
pending_pastes: Vec::new(),
});
}
self.pending_pastes.clear();
@@ -2352,12 +2362,7 @@ impl ChatComposer {
_ => unreachable!(),
};
if let Some(entry) = replace_entry {
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
self.restore_history_entry(entry);
return (InputResult::None, true);
}
}
@@ -5727,6 +5732,55 @@ mod tests {
assert_eq!(composer.local_image_paths(), vec![path]);
}
#[test]
fn history_navigation_restores_large_paste_payloads_after_ctrl_c() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5);
composer.handle_paste(large.clone());
assert_eq!(composer.pending_pastes.len(), 1);
let placeholder = composer.pending_pastes[0].0.clone();
assert_eq!(composer.current_text(), placeholder);
composer.clear_for_ctrl_c();
assert!(composer.is_empty());
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let text = composer.current_text();
assert_eq!(text, placeholder);
let text_elements = composer.text_elements();
assert_eq!(text_elements.len(), 1);
assert_eq!(
text_elements[0].placeholder(&text),
Some(placeholder.as_str())
);
assert_eq!(composer.pending_pastes.len(), 1);
assert_eq!(composer.pending_pastes[0].1, large);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::Submitted {
text,
text_elements,
} => {
assert_eq!(text, large);
assert!(text_elements.is_empty());
}
_ => panic!("expected Submitted"),
}
}
#[test]
fn set_text_content_reattaches_images_without_placeholder_metadata() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -14,6 +14,7 @@ pub(crate) struct HistoryEntry {
pub(crate) text_elements: Vec<TextElement>,
pub(crate) local_image_paths: Vec<PathBuf>,
pub(crate) mention_bindings: Vec<MentionBinding>,
pub(crate) pending_pastes: Vec<(String, String)>,
}
impl HistoryEntry {
@@ -23,6 +24,7 @@ impl HistoryEntry {
text_elements: Vec::new(),
local_image_paths: Vec::new(),
mention_bindings: Vec::new(),
pending_pastes: Vec::new(),
}
}
@@ -40,6 +42,7 @@ impl HistoryEntry {
path: mention.path,
})
.collect(),
pending_pastes: Vec::new(),
}
}
}
@@ -95,7 +98,10 @@ impl ChatComposerHistory {
/// Record a message submitted by the user in the current session so it can
/// be recalled later.
pub fn record_local_submission(&mut self, entry: HistoryEntry) {
if entry.text.is_empty() && entry.local_image_paths.is_empty() {
if entry.text.is_empty()
&& entry.local_image_paths.is_empty()
&& entry.pending_pastes.is_empty()
{
return;
}

View File

@@ -51,18 +51,21 @@ The solution is to detect paste-like _bursts_ and buffer them into a single expl
- When a slash command name is completed and the user types a space, the `/command` token is
promoted into a text element so it renders distinctly and edits atomically.
### History navigation (↑/↓)
## History navigation (↑/↓)
Up/Down recall is handled by `ChatComposerHistory` and merges two sources:
`ChatComposerHistory` merges two sources:
- **Persistent history** (cross-session, fetched from `~/.codex/history.jsonl`): text-only. It
does **not** carry text element ranges or local image attachments, so recalling one of these
entries only restores the text.
- **Local history** (current session): stores the full submission payload, including text
elements and local image paths. Recalling a local entry rehydrates placeholders and attachments.
- **Persistent history** (cross-session): text-only entries read from the history log. These do not
include text elements, local image paths, or pending large-paste payloads so the on-disk format
stays backwards-compatible.
- **Local history** (in-session): full composer snapshots captured during the current session
(submitted messages and Ctrl+C-cleared drafts).
This distinction keeps the on-disk history backward compatible and avoids persisting attachments,
while still providing a richer recall experience for in-session edits.
When recalling a local entry, the composer restores:
- text elements (so placeholders render as styled elements),
- local image attachments (by matching placeholders),
- pending large-paste payloads (so placeholders still expand on submit).
## Config gating for reuse
@@ -91,7 +94,6 @@ Key effects when disabled:
Built-in slash command availability is centralized in
`codex-rs/tui/src/bottom_pane/slash_commands.rs` and reused by both the composer and the command
popup so gating stays in sync.
## Submission flow (Enter/Tab)
There are multiple submission paths, but they share the same core rules:
@@ -101,6 +103,9 @@ There are multiple submission paths, but they share the same core rules:
`handle_submission` calls `prepare_submission_text` for both submit and queue. That method:
1. Expands any pending paste placeholders so element ranges align with the final text.
- Placeholder text is unique within a composer buffer. Both large paste markers and image
attachment placeholders are suffixed as needed (`#2`, `#3`, …), so payloads can match
elements exactly by placeholder text.
2. Trims whitespace and rebases element ranges to the trimmed buffer.
3. Expands `/prompts:` custom prompts:
- Named args use key=value parsing.