Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codex-rs/protocol/src/user_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
}

Expand Down
84 changes: 69 additions & 15 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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;
Expand All @@ -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()
Expand All @@ -845,6 +853,7 @@ impl ChatComposer {
text_elements,
local_image_paths,
mention_bindings,
pending_pastes,
});
Some(previous)
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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>();
Expand Down
8 changes: 7 additions & 1 deletion codex-rs/tui/src/bottom_pane/chat_composer_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,6 +24,7 @@ impl HistoryEntry {
text_elements: Vec::new(),
local_image_paths: Vec::new(),
mention_bindings: Vec::new(),
pending_pastes: Vec::new(),
}
}

Expand All @@ -40,6 +42,7 @@ impl HistoryEntry {
path: mention.path,
})
.collect(),
pending_pastes: Vec::new(),
}
}
}
Expand Down Expand Up @@ -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;
}

Expand Down
25 changes: 15 additions & 10 deletions docs/tui-chat-composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
Loading