Stream model responses (#1810)
Stream models thoughts and responses instead of waiting for the whole thing to come through. Very rough right now, but I'm making the risk call to push through.
This commit is contained in:
50
codex-rs/Cargo.lock
generated
50
codex-rs/Cargo.lock
generated
@@ -881,6 +881,7 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1473,7 +1474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1553,7 +1554,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1756,7 +1757,7 @@ version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1"
|
||||
dependencies = [
|
||||
"unicode-width 0.2.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2336,7 +2337,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3392,7 +3393,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ratatui"
|
||||
version = "0.29.0"
|
||||
source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e"
|
||||
source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cassowary",
|
||||
@@ -3406,7 +3407,7 @@ dependencies = [
|
||||
"strum 0.26.3",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width 0.2.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3720,7 +3721,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3733,7 +3734,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4499,7 +4500,7 @@ dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix 1.0.8",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4546,7 +4547,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
"unicode-linebreak",
|
||||
"unicode-width 0.2.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4994,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "911e93158bf80bbc94bad533b2b16e3d711e1132d69a6a6980c3920a63422c19"
|
||||
dependencies = [
|
||||
"ratatui",
|
||||
"unicode-width 0.2.0",
|
||||
"unicode-width 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5062,9 +5063,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
@@ -5149,6 +5150,27 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width 0.2.1",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
@@ -5337,7 +5359,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -260,6 +260,11 @@ async fn process_chat_sse<S>(
|
||||
.and_then(|d| d.get("content"))
|
||||
.and_then(|c| c.as_str())
|
||||
{
|
||||
// Emit a delta so downstream consumers can stream text live.
|
||||
let _ = tx_event
|
||||
.send(Ok(ResponseEvent::OutputTextDelta(content.to_string())))
|
||||
.await;
|
||||
|
||||
let item = ResponseItem::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
@@ -439,11 +444,14 @@ where
|
||||
// will never appear in a Chat Completions stream.
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(_))))
|
||||
| Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(_)))) => {
|
||||
// Deltas are ignored here since aggregation waits for the
|
||||
// final OutputItemDone.
|
||||
continue;
|
||||
Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => {
|
||||
// Forward deltas unchanged so callers can stream text
|
||||
// live while still receiving a single aggregated
|
||||
// OutputItemDone at the end of the turn.
|
||||
return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta))));
|
||||
}
|
||||
Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta)))) => {
|
||||
return Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta(delta))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Codex {
|
||||
let resume_path = config.experimental_resume.clone();
|
||||
info!("resume_path: {resume_path:?}");
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(64);
|
||||
let (tx_event, rx_event) = async_channel::bounded(1600);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let user_instructions = get_user_instructions(&config).await;
|
||||
|
||||
@@ -701,7 +701,7 @@ async fn submission_loop(
|
||||
cwd,
|
||||
resume_path,
|
||||
} => {
|
||||
info!(
|
||||
debug!(
|
||||
"Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}"
|
||||
);
|
||||
if !cwd.is_absolute() {
|
||||
@@ -1374,6 +1374,11 @@ async fn try_run_turn(
|
||||
return Ok(output);
|
||||
}
|
||||
ResponseEvent::OutputTextDelta(delta) => {
|
||||
{
|
||||
let mut st = sess.state.lock().unwrap();
|
||||
st.history.append_assistant_text(&delta);
|
||||
}
|
||||
|
||||
let event = Event {
|
||||
id: sub_id.to_string(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }),
|
||||
@@ -1921,7 +1926,8 @@ async fn handle_sandbox_error(
|
||||
// include additional metadata on the command to indicate whether non-zero
|
||||
// exit codes merit a retry.
|
||||
|
||||
// For now, we categorically ask the user to retry without sandbox.
|
||||
// For now, we categorically ask the user to retry without sandbox and
|
||||
// emit the raw error as a background event.
|
||||
sess.notify_background_event(&sub_id, format!("Execution failed: {error}"))
|
||||
.await;
|
||||
|
||||
|
||||
@@ -24,9 +24,52 @@ impl ConversationHistory {
|
||||
I::Item: std::ops::Deref<Target = ResponseItem>,
|
||||
{
|
||||
for item in items {
|
||||
if is_api_message(&item) {
|
||||
// Note agent-loop.ts also does filtering on some of the fields.
|
||||
self.items.push(item.clone());
|
||||
if !is_api_message(&item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Merge adjacent assistant messages into a single history entry.
|
||||
// This prevents duplicates when a partial assistant message was
|
||||
// streamed into history earlier in the turn and the final full
|
||||
// message is recorded at turn end.
|
||||
match (&*item, self.items.last_mut()) {
|
||||
(
|
||||
ResponseItem::Message {
|
||||
role: new_role,
|
||||
content: new_content,
|
||||
..
|
||||
},
|
||||
Some(ResponseItem::Message {
|
||||
role: last_role,
|
||||
content: last_content,
|
||||
..
|
||||
}),
|
||||
) if new_role == "assistant" && last_role == "assistant" => {
|
||||
append_text_content(last_content, new_content);
|
||||
}
|
||||
_ => {
|
||||
self.items.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a text `delta` to the latest assistant message, creating a new
|
||||
/// assistant entry if none exists yet (e.g. first delta for this turn).
|
||||
pub(crate) fn append_assistant_text(&mut self, delta: &str) {
|
||||
match self.items.last_mut() {
|
||||
Some(ResponseItem::Message { role, content, .. }) if role == "assistant" => {
|
||||
append_text_delta(content, delta);
|
||||
}
|
||||
_ => {
|
||||
// Start a new assistant message with the delta.
|
||||
self.items.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentItem::OutputText {
|
||||
text: delta.to_string(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,3 +115,140 @@ fn is_api_message(message: &ResponseItem) -> bool {
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to append the textual content from `src` into `dst` in place.
|
||||
fn append_text_content(
|
||||
dst: &mut Vec<crate::models::ContentItem>,
|
||||
src: &Vec<crate::models::ContentItem>,
|
||||
) {
|
||||
for c in src {
|
||||
if let crate::models::ContentItem::OutputText { text } = c {
|
||||
append_text_delta(dst, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single text delta to the last OutputText item in `content`, or
|
||||
/// push a new OutputText item if none exists.
|
||||
fn append_text_delta(content: &mut Vec<crate::models::ContentItem>, delta: &str) {
|
||||
if let Some(crate::models::ContentItem::OutputText { text }) = content
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|c| matches!(c, crate::models::ContentItem::OutputText { .. }))
|
||||
{
|
||||
text.push_str(delta);
|
||||
} else {
|
||||
content.push(crate::models::ContentItem::OutputText {
|
||||
text: delta.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ContentItem;
|
||||
|
||||
fn assistant_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn user_msg(text: &str) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_adjacent_assistant_messages() {
|
||||
let mut h = ConversationHistory::default();
|
||||
let a1 = assistant_msg("Hello");
|
||||
let a2 = assistant_msg(", world!");
|
||||
h.record_items([&a1, &a2]);
|
||||
|
||||
let items = h.contents();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "Hello, world!".to_string()
|
||||
}]
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_assistant_text_creates_and_appends() {
|
||||
let mut h = ConversationHistory::default();
|
||||
h.append_assistant_text("Hello");
|
||||
h.append_assistant_text(", world");
|
||||
|
||||
// Now record a final full assistant message and verify it merges.
|
||||
let final_msg = assistant_msg("!");
|
||||
h.record_items([&final_msg]);
|
||||
|
||||
let items = h.contents();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "Hello, world!".to_string()
|
||||
}]
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_non_api_messages() {
|
||||
let mut h = ConversationHistory::default();
|
||||
// System message is not an API message; Other is ignored.
|
||||
let system = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "system".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "ignored".to_string(),
|
||||
}],
|
||||
};
|
||||
h.record_items([&system, &ResponseItem::Other]);
|
||||
|
||||
// User and assistant should be retained.
|
||||
let u = user_msg("hi");
|
||||
let a = assistant_msg("hello");
|
||||
h.record_items([&u, &a]);
|
||||
|
||||
let items = h.contents();
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "hi".to_string()
|
||||
}]
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "hello".to_string()
|
||||
}]
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::ser::Serializer;
|
||||
|
||||
use crate::protocol::InputItem;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseInputItem {
|
||||
Message {
|
||||
@@ -26,7 +26,7 @@ pub enum ResponseInputItem {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentItem {
|
||||
InputText { text: String },
|
||||
@@ -34,7 +34,7 @@ pub enum ContentItem {
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ResponseItem {
|
||||
Message {
|
||||
@@ -107,7 +107,7 @@ impl From<ResponseInputItem> for ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalShellStatus {
|
||||
Completed,
|
||||
@@ -115,13 +115,13 @@ pub enum LocalShellStatus {
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum LocalShellAction {
|
||||
Exec(LocalShellExecAction),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct LocalShellExecAction {
|
||||
pub command: Vec<String>,
|
||||
pub timeout_ms: Option<u64>,
|
||||
@@ -130,7 +130,7 @@ pub struct LocalShellExecAction {
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ReasoningItemReasoningSummary {
|
||||
SummaryText { text: String },
|
||||
@@ -185,10 +185,9 @@ pub struct ShellToolCallParams {
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FunctionCallOutputPayload {
|
||||
pub content: String,
|
||||
#[expect(dead_code)]
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ path = "src/main.rs"
|
||||
name = "codex_tui"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
# Enable vt100-based tests (emulator) when running with `--features vt100-tests`.
|
||||
vt100-tests = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -73,3 +77,4 @@ insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
rand = "0.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
vt100 = "0.16.2"
|
||||
|
||||
45
codex-rs/tui/src/bottom_pane/live_ring_widget.rs
Normal file
45
codex-rs/tui/src/bottom_pane/live_ring_widget.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
/// Minimal rendering-only widget for the transient ring rows.
|
||||
pub(crate) struct LiveRingWidget {
|
||||
max_rows: u16,
|
||||
rows: Vec<Line<'static>>, // newest at the end
|
||||
}
|
||||
|
||||
impl LiveRingWidget {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_rows: 3,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_max_rows(&mut self, n: u16) {
|
||||
self.max_rows = n.max(1);
|
||||
}
|
||||
|
||||
pub fn set_rows(&mut self, rows: Vec<Line<'static>>) {
|
||||
self.rows = rows;
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, _width: u16) -> u16 {
|
||||
let len = self.rows.len() as u16;
|
||||
len.min(self.max_rows)
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for LiveRingWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let visible = self.rows.len().saturating_sub(self.max_rows as usize);
|
||||
let slice = &self.rows[visible..];
|
||||
let para = Paragraph::new(slice.to_vec());
|
||||
para.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ConditionalUpdate;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
mod approval_modal_view;
|
||||
@@ -18,6 +18,7 @@ mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
mod file_search_popup;
|
||||
mod live_ring_widget;
|
||||
mod status_indicator_view;
|
||||
mod textarea;
|
||||
|
||||
@@ -30,6 +31,7 @@ pub(crate) enum CancellationEvent {
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use approval_modal_view::ApprovalModalView;
|
||||
use status_indicator_view::StatusIndicatorView;
|
||||
|
||||
@@ -46,6 +48,19 @@ pub(crate) struct BottomPane<'a> {
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
|
||||
/// Optional live, multi‑line status/"live cell" rendered directly above
|
||||
/// the composer while a task is running. Unlike `active_view`, this does
|
||||
/// not replace the composer; it augments it.
|
||||
live_status: Option<StatusIndicatorWidget>,
|
||||
|
||||
/// Optional transient ring shown above the composer. This is a rendering-only
|
||||
/// container used during development before we wire it to ChatWidget events.
|
||||
live_ring: Option<live_ring_widget::LiveRingWidget>,
|
||||
|
||||
/// True if the active view is the StatusIndicatorView that replaces the
|
||||
/// composer during a running task.
|
||||
status_view_active: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -55,6 +70,7 @@ pub(crate) struct BottomPaneParams {
|
||||
}
|
||||
|
||||
impl BottomPane<'_> {
|
||||
const BOTTOM_PAD_LINES: u16 = 2;
|
||||
pub fn new(params: BottomPaneParams) -> Self {
|
||||
let enhanced_keys_supported = params.enhanced_keys_supported;
|
||||
Self {
|
||||
@@ -68,14 +84,40 @@ impl BottomPane<'_> {
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
live_status: None,
|
||||
live_ring: None,
|
||||
status_view_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.active_view
|
||||
let overlay_status_h = self
|
||||
.live_status
|
||||
.as_ref()
|
||||
.map(|v| v.desired_height(width))
|
||||
.unwrap_or(self.composer.desired_height(width))
|
||||
.map(|s| s.desired_height(width))
|
||||
.unwrap_or(0);
|
||||
let ring_h = self
|
||||
.live_ring
|
||||
.as_ref()
|
||||
.map(|r| r.desired_height(width))
|
||||
.unwrap_or(0);
|
||||
|
||||
let view_height = if let Some(view) = self.active_view.as_ref() {
|
||||
// Add a single blank spacer line between live ring and status view when active.
|
||||
let spacer = if self.live_ring.is_some() && self.status_view_active {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
spacer + view.desired_height(width)
|
||||
} else {
|
||||
self.composer.desired_height(width)
|
||||
};
|
||||
|
||||
overlay_status_h
|
||||
.saturating_add(ring_h)
|
||||
.saturating_add(view_height)
|
||||
.saturating_add(Self::BOTTOM_PAD_LINES)
|
||||
}
|
||||
|
||||
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
@@ -96,10 +138,6 @@ impl BottomPane<'_> {
|
||||
view.handle_key_event(self, key_event);
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
)));
|
||||
}
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
@@ -125,10 +163,6 @@ impl BottomPane<'_> {
|
||||
CancellationEvent::Handled => {
|
||||
if !view.is_complete() {
|
||||
self.active_view = Some(view);
|
||||
} else if self.is_task_running {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
)));
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
}
|
||||
@@ -148,19 +182,37 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
||||
/// active).
|
||||
/// Update the status indicator text. Prefer replacing the composer with
|
||||
/// the StatusIndicatorView so the input pane shows a single-line status
|
||||
/// like: `▌ Working waiting for model`.
|
||||
pub(crate) fn update_status_text(&mut self, text: String) {
|
||||
if let Some(view) = &mut self.active_view {
|
||||
match view.update_status_text(text) {
|
||||
ConditionalUpdate::NeedsRedraw => {
|
||||
self.request_redraw();
|
||||
}
|
||||
ConditionalUpdate::NoRedraw => {
|
||||
// No redraw needed.
|
||||
}
|
||||
let mut handled_by_view = false;
|
||||
if let Some(view) = self.active_view.as_mut() {
|
||||
if matches!(
|
||||
view.update_status_text(text.clone()),
|
||||
bottom_pane_view::ConditionalUpdate::NeedsRedraw
|
||||
) {
|
||||
handled_by_view = true;
|
||||
}
|
||||
} else {
|
||||
let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||||
v.update_text(text.clone());
|
||||
self.active_view = Some(Box::new(v));
|
||||
self.status_view_active = true;
|
||||
handled_by_view = true;
|
||||
}
|
||||
|
||||
// Fallback: if the current active view did not consume status updates,
|
||||
// present an overlay above the composer.
|
||||
if !handled_by_view {
|
||||
if self.live_status.is_none() {
|
||||
self.live_status = Some(StatusIndicatorWidget::new(self.app_event_tx.clone()));
|
||||
}
|
||||
if let Some(status) = &mut self.live_status {
|
||||
status.update_text(text);
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
@@ -186,27 +238,23 @@ impl BottomPane<'_> {
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
|
||||
match (running, self.active_view.is_some()) {
|
||||
(true, false) => {
|
||||
// Show status indicator overlay.
|
||||
if running {
|
||||
if self.active_view.is_none() {
|
||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||
self.app_event_tx.clone(),
|
||||
)));
|
||||
self.request_redraw();
|
||||
self.status_view_active = true;
|
||||
}
|
||||
(false, true) => {
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
if view.should_hide_when_task_is_done() {
|
||||
// Leave self.active_view as None.
|
||||
self.request_redraw();
|
||||
} else {
|
||||
// Preserve the view.
|
||||
self.active_view = Some(view);
|
||||
}
|
||||
self.request_redraw();
|
||||
} else {
|
||||
self.live_status = None;
|
||||
// Drop the status view when a task completes, but keep other
|
||||
// modal views (e.g. approval dialogs).
|
||||
if let Some(mut view) = self.active_view.take() {
|
||||
if !view.should_hide_when_task_is_done() {
|
||||
self.active_view = Some(view);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No change.
|
||||
self.status_view_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +296,7 @@ impl BottomPane<'_> {
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
|
||||
self.active_view = Some(Box::new(modal));
|
||||
self.status_view_active = false;
|
||||
self.request_redraw()
|
||||
}
|
||||
|
||||
@@ -281,15 +330,80 @@ impl BottomPane<'_> {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Set the rows and cap for the transient live ring overlay.
|
||||
pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec<Line<'static>>) {
|
||||
let mut w = live_ring_widget::LiveRingWidget::new();
|
||||
w.set_max_rows(max_rows);
|
||||
w.set_rows(rows);
|
||||
self.live_ring = Some(w);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_live_ring(&mut self) {
|
||||
self.live_ring = None;
|
||||
}
|
||||
|
||||
// Removed restart_live_status_with_text – no longer used by the current streaming UI.
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Show BottomPaneView if present.
|
||||
if let Some(ov) = &self.active_view {
|
||||
ov.render(area, buf);
|
||||
} else {
|
||||
(&self.composer).render_ref(area, buf);
|
||||
let mut y_offset = 0u16;
|
||||
if let Some(ring) = &self.live_ring {
|
||||
let live_h = ring.desired_height(area.width).min(area.height);
|
||||
if live_h > 0 {
|
||||
let live_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: live_h,
|
||||
};
|
||||
ring.render_ref(live_rect, buf);
|
||||
y_offset = live_h;
|
||||
}
|
||||
}
|
||||
// Spacer between live ring and status view when active
|
||||
if self.live_ring.is_some() && self.status_view_active && y_offset < area.height {
|
||||
// Leave one empty line
|
||||
y_offset = y_offset.saturating_add(1);
|
||||
}
|
||||
if let Some(status) = &self.live_status {
|
||||
let live_h = status.desired_height(area.width).min(area.height);
|
||||
if live_h > 0 {
|
||||
let live_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: live_h,
|
||||
};
|
||||
status.render_ref(live_rect, buf);
|
||||
y_offset = live_h;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(view) = &self.active_view {
|
||||
if y_offset < area.height {
|
||||
// Reserve bottom padding lines; keep at least 1 line for the view.
|
||||
let avail = area.height - y_offset;
|
||||
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
|
||||
let view_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
height: avail - pad,
|
||||
};
|
||||
view.render(view_rect, buf);
|
||||
}
|
||||
} else if y_offset < area.height {
|
||||
let composer_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
// Reserve bottom padding
|
||||
height: (area.height - y_offset)
|
||||
- BottomPane::BOTTOM_PAD_LINES.min((area.height - y_offset).saturating_sub(1)),
|
||||
};
|
||||
(&self.composer).render_ref(composer_rect, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,6 +412,9 @@ impl WidgetRef for &BottomPane<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
@@ -324,4 +441,200 @@ mod tests {
|
||||
assert!(pane.ctrl_c_quit_hint_visible());
|
||||
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ring_renders_above_composer() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
});
|
||||
|
||||
// Provide 4 rows with max_rows=3; only the last 3 should be visible.
|
||||
pane.set_live_ring_rows(
|
||||
3,
|
||||
vec![
|
||||
Line::from("one".to_string()),
|
||||
Line::from("two".to_string()),
|
||||
Line::from("three".to_string()),
|
||||
Line::from("four".to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let area = Rect::new(0, 0, 10, 5);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
// Extract the first 3 rows and assert they contain the last three lines.
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for y in 0..3 {
|
||||
let mut s = String::new();
|
||||
for x in 0..area.width {
|
||||
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
lines.push(s.trim_end().to_string());
|
||||
}
|
||||
assert_eq!(lines, vec!["two", "three", "four"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_indicator_visible_with_live_ring() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
});
|
||||
|
||||
// Simulate task running which replaces composer with the status indicator.
|
||||
pane.set_task_running(true);
|
||||
pane.update_status_text("waiting for model".to_string());
|
||||
|
||||
// Provide 2 rows in the live ring (e.g., streaming CoT) and ensure the
|
||||
// status indicator remains visible below them.
|
||||
pane.set_live_ring_rows(
|
||||
2,
|
||||
vec![
|
||||
Line::from("cot1".to_string()),
|
||||
Line::from("cot2".to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
// Allow some frames so the dot animation is present.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
|
||||
// Height should include both ring rows, 1 spacer, and the 1-line status.
|
||||
let area = Rect::new(0, 0, 30, 4);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
// Top two rows are the live ring.
|
||||
let mut r0 = String::new();
|
||||
let mut r1 = String::new();
|
||||
for x in 0..area.width {
|
||||
r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
r1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(r0.contains("cot1"), "expected first live row: {r0:?}");
|
||||
assert!(r1.contains("cot2"), "expected second live row: {r1:?}");
|
||||
|
||||
// Row 2 is the spacer (blank)
|
||||
let mut r2 = String::new();
|
||||
for x in 0..area.width {
|
||||
r2.push(buf[(x, 2)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(r2.trim().is_empty(), "expected blank spacer line: {r2:?}");
|
||||
|
||||
// Bottom row is the status line; it should contain the left bar and "Working".
|
||||
let mut r3 = String::new();
|
||||
for x in 0..area.width {
|
||||
r3.push(buf[(x, 3)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert_eq!(buf[(0, 3)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert!(
|
||||
r3.contains("Working"),
|
||||
"expected Working header in status line: {r3:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_present_for_status_view() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
});
|
||||
|
||||
// Activate spinner (status view replaces composer) with no live ring.
|
||||
pane.set_task_running(true);
|
||||
pane.update_status_text("waiting for model".to_string());
|
||||
|
||||
// Use height == desired_height; expect 1 status row at top and 2 bottom padding rows.
|
||||
let height = pane.desired_height(30);
|
||||
assert!(
|
||||
height >= 3,
|
||||
"expected at least 3 rows with bottom padding; got {height}"
|
||||
);
|
||||
let area = Rect::new(0, 0, 30, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(&pane).render_ref(area, &mut buf);
|
||||
|
||||
// Top row contains the status header
|
||||
let mut top = String::new();
|
||||
for x in 0..area.width {
|
||||
top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
|
||||
assert!(
|
||||
top.contains("Working"),
|
||||
"expected Working header on top row: {top:?}"
|
||||
);
|
||||
|
||||
// Bottom two rows are blank padding
|
||||
let mut r_last = String::new();
|
||||
let mut r_last2 = String::new();
|
||||
for x in 0..area.width {
|
||||
r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' '));
|
||||
r_last2.push(buf[(x, height - 2)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
r_last.trim().is_empty(),
|
||||
"expected last row blank: {r_last:?}"
|
||||
);
|
||||
assert!(
|
||||
r_last2.trim().is_empty(),
|
||||
"expected second-to-last row blank: {r_last2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_padding_shrinks_when_tiny() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.update_status_text("waiting for model".to_string());
|
||||
|
||||
// Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner.
|
||||
let area2 = Rect::new(0, 0, 20, 2);
|
||||
let mut buf2 = Buffer::empty(area2);
|
||||
(&pane).render_ref(area2, &mut buf2);
|
||||
let mut row0 = String::new();
|
||||
let mut row1 = String::new();
|
||||
for x in 0..area2.width {
|
||||
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
row0.contains("Working"),
|
||||
"expected Working header on row 0: {row0:?}"
|
||||
);
|
||||
assert!(
|
||||
row1.trim().is_empty(),
|
||||
"expected bottom padding on row 1: {row1:?}"
|
||||
);
|
||||
|
||||
// Height=1 → no padding; single row is the spinner.
|
||||
let area1 = Rect::new(0, 0, 20, 1);
|
||||
let mut buf1 = Buffer::empty(area1);
|
||||
(&pane).render_ref(area1, &mut buf1);
|
||||
let mut only = String::new();
|
||||
for x in 0..area1.width {
|
||||
only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(
|
||||
only.contains("Working"),
|
||||
"expected Working header with no padding: {only:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,10 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::live_wrap::RowBuilder;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_file_search::FileMatch;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
struct RunningCommand {
|
||||
command: Vec<String>,
|
||||
@@ -64,6 +66,10 @@ pub(crate) struct ChatWidget<'a> {
|
||||
// at once into scrollback so the history contains a single message.
|
||||
answer_buffer: String,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
live_builder: RowBuilder,
|
||||
current_stream: Option<StreamKind>,
|
||||
stream_header_emitted: bool,
|
||||
live_max_rows: u16,
|
||||
}
|
||||
|
||||
struct UserMessage {
|
||||
@@ -71,6 +77,12 @@ struct UserMessage {
|
||||
image_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum StreamKind {
|
||||
Answer,
|
||||
Reasoning,
|
||||
}
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
@@ -151,6 +163,10 @@ impl ChatWidget<'_> {
|
||||
reasoning_buffer: String::new(),
|
||||
answer_buffer: String::new(),
|
||||
running_commands: HashMap::new(),
|
||||
live_builder: RowBuilder::new(80),
|
||||
current_stream: None,
|
||||
stream_header_emitted: false,
|
||||
live_max_rows: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,58 +250,45 @@ impl ChatWidget<'_> {
|
||||
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
// Final assistant answer. Prefer the fully provided message
|
||||
// from the event; if it is empty fall back to any accumulated
|
||||
// delta buffer (some providers may only stream deltas and send
|
||||
// an empty final message).
|
||||
let full = if message.is_empty() {
|
||||
std::mem::take(&mut self.answer_buffer)
|
||||
} else {
|
||||
self.answer_buffer.clear();
|
||||
message
|
||||
};
|
||||
if !full.is_empty() {
|
||||
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message: _ }) => {
|
||||
// Final assistant answer: commit all remaining rows and close with
|
||||
// a blank line. Use the final text if provided, otherwise rely on
|
||||
// streamed deltas already in the builder.
|
||||
self.finalize_stream(StreamKind::Answer);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||||
// Buffer only – do not emit partial lines. This avoids cases
|
||||
// where long responses appear truncated if the terminal
|
||||
// wrapped early. The full message is emitted on
|
||||
// AgentMessage.
|
||||
self.begin_stream(StreamKind::Answer);
|
||||
self.answer_buffer.push_str(&delta);
|
||||
self.stream_push_and_maybe_commit(&delta);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||||
// Buffer only – disable incremental reasoning streaming so we
|
||||
// avoid truncated intermediate lines. Full text emitted on
|
||||
// AgentReasoning.
|
||||
// Stream CoT into the live pane; keep input visible and commit
|
||||
// overflow rows incrementally to scrollback.
|
||||
self.begin_stream(StreamKind::Reasoning);
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
self.stream_push_and_maybe_commit(&delta);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||||
// Emit full reasoning text once. Some providers might send
|
||||
// final event with empty text if only deltas were used.
|
||||
let full = if text.is_empty() {
|
||||
std::mem::take(&mut self.reasoning_buffer)
|
||||
} else {
|
||||
self.reasoning_buffer.clear();
|
||||
text
|
||||
};
|
||||
if !full.is_empty() {
|
||||
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
|
||||
}
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text: _ }) => {
|
||||
// Final reasoning: commit remaining rows and close with a blank.
|
||||
self.finalize_stream(StreamKind::Reasoning);
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
// Replace composer with single-line spinner while waiting.
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for model".to_string());
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TaskComplete(TaskCompleteEvent {
|
||||
last_agent_message: _,
|
||||
}) => {
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.bottom_pane.clear_live_ring();
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::TokenCount(token_usage) => {
|
||||
@@ -298,8 +301,8 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.set_task_running(false);
|
||||
}
|
||||
EventMsg::PlanUpdate(update) => {
|
||||
// Commit plan updates directly to history (no status-line preview).
|
||||
self.add_to_history(HistoryCell::new_plan_update(update));
|
||||
self.request_redraw();
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id: _,
|
||||
@@ -307,8 +310,7 @@ impl ChatWidget<'_> {
|
||||
cwd,
|
||||
reason,
|
||||
}) => {
|
||||
// Print the command to the history so it is visible in the
|
||||
// transcript *before* the modal asks for approval.
|
||||
// Log a background summary immediately so the history is chronological.
|
||||
let cmdline = strip_bash_lc_and_escape(&command);
|
||||
let text = format!(
|
||||
"command requires approval:\n$ {cmdline}{reason}",
|
||||
@@ -344,7 +346,6 @@ impl ChatWidget<'_> {
|
||||
// approval dialog) and avoids surprising the user with a modal
|
||||
// prompt before they have seen *what* is being requested.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
self.add_to_history(HistoryCell::new_patch_event(
|
||||
PatchEventType::ApprovalRequest,
|
||||
changes,
|
||||
@@ -379,8 +380,6 @@ impl ChatWidget<'_> {
|
||||
auto_approved,
|
||||
changes,
|
||||
}) => {
|
||||
// Even when a patch is auto‑approved we still display the
|
||||
// summary so the user can follow along.
|
||||
self.add_to_history(HistoryCell::new_patch_event(
|
||||
PatchEventType::ApplyBegin { auto_approved },
|
||||
changes,
|
||||
@@ -393,6 +392,7 @@ impl ChatWidget<'_> {
|
||||
stdout,
|
||||
stderr,
|
||||
}) => {
|
||||
// Compute summary before moving stdout into the history cell.
|
||||
let cmd = self.running_commands.remove(&call_id);
|
||||
self.add_to_history(HistoryCell::new_completed_exec_command(
|
||||
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
|
||||
@@ -442,14 +442,15 @@ impl ChatWidget<'_> {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
event => {
|
||||
self.add_to_history(HistoryCell::new_background_event(format!("{event:?}")));
|
||||
let text = format!("{event:?}");
|
||||
self.add_to_history(HistoryCell::new_background_event(text.clone()));
|
||||
self.update_latest_log(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the live log preview while a task is running.
|
||||
pub(crate) fn update_latest_log(&mut self, line: String) {
|
||||
// Forward only if we are currently showing the status indicator.
|
||||
self.bottom_pane.update_status_text(line);
|
||||
}
|
||||
|
||||
@@ -515,6 +516,97 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
fn begin_stream(&mut self, kind: StreamKind) {
|
||||
if self.current_stream != Some(kind) {
|
||||
self.current_stream = Some(kind);
|
||||
self.stream_header_emitted = false;
|
||||
// Clear any previous live content; we're starting a new stream.
|
||||
self.live_builder = RowBuilder::new(self.live_builder.width());
|
||||
// Ensure the waiting status is visible (composer replaced).
|
||||
self.bottom_pane
|
||||
.update_status_text("waiting for model".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_push_and_maybe_commit(&mut self, delta: &str) {
|
||||
self.live_builder.push_fragment(delta);
|
||||
|
||||
// Commit overflow rows (small batches) while keeping the last N rows visible.
|
||||
let drained = self
|
||||
.live_builder
|
||||
.drain_commit_ready(self.live_max_rows as usize);
|
||||
if !drained.is_empty() {
|
||||
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
|
||||
if !self.stream_header_emitted {
|
||||
match self.current_stream {
|
||||
Some(StreamKind::Reasoning) => {
|
||||
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
|
||||
}
|
||||
Some(StreamKind::Answer) => {
|
||||
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.stream_header_emitted = true;
|
||||
}
|
||||
for r in drained {
|
||||
lines.push(ratatui::text::Line::from(r.text));
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
}
|
||||
|
||||
// Update the live ring overlay lines (text-only, newest at bottom).
|
||||
let rows = self
|
||||
.live_builder
|
||||
.display_rows()
|
||||
.into_iter()
|
||||
.map(|r| ratatui::text::Line::from(r.text))
|
||||
.collect::<Vec<_>>();
|
||||
self.bottom_pane
|
||||
.set_live_ring_rows(self.live_max_rows, rows);
|
||||
}
|
||||
|
||||
fn finalize_stream(&mut self, kind: StreamKind) {
|
||||
if self.current_stream != Some(kind) {
|
||||
// Nothing to do; either already finalized or not the active stream.
|
||||
return;
|
||||
}
|
||||
// Flush any partial line as a full row, then drain all remaining rows.
|
||||
self.live_builder.end_line();
|
||||
let remaining = self.live_builder.drain_rows();
|
||||
// TODO: Re-add markdown rendering for assistant answers and reasoning.
|
||||
// When finalizing, pass the accumulated text through `markdown::append_markdown`
|
||||
// to build styled `Line<'static>` entries instead of raw plain text lines.
|
||||
if !remaining.is_empty() || !self.stream_header_emitted {
|
||||
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
|
||||
if !self.stream_header_emitted {
|
||||
match kind {
|
||||
StreamKind::Reasoning => {
|
||||
lines.push(ratatui::text::Line::from("thinking".magenta().italic()));
|
||||
}
|
||||
StreamKind::Answer => {
|
||||
lines.push(ratatui::text::Line::from("codex".magenta().bold()));
|
||||
}
|
||||
}
|
||||
self.stream_header_emitted = true;
|
||||
}
|
||||
for r in remaining {
|
||||
lines.push(ratatui::text::Line::from(r.text));
|
||||
}
|
||||
// Close the block with a blank line for readability.
|
||||
lines.push(ratatui::text::Line::from(""));
|
||||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
}
|
||||
|
||||
// Clear the live overlay and reset state for the next stream.
|
||||
self.live_builder = RowBuilder::new(self.live_builder.width());
|
||||
self.bottom_pane.clear_live_ring();
|
||||
self.current_stream = None;
|
||||
self.stream_header_emitted = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// In the hybrid inline viewport mode we only draw the interactive
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::text_block::TextBlock;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use base64::Engine;
|
||||
@@ -68,12 +67,7 @@ pub(crate) enum HistoryCell {
|
||||
/// Message from the user.
|
||||
UserPrompt { view: TextBlock },
|
||||
|
||||
/// Message from the agent.
|
||||
AgentMessage { view: TextBlock },
|
||||
|
||||
/// Reasoning event from the agent.
|
||||
AgentReasoning { view: TextBlock },
|
||||
|
||||
// AgentMessage and AgentReasoning variants were unused and have been removed.
|
||||
/// An exec tool call that has not finished yet.
|
||||
ActiveExecCommand { view: TextBlock },
|
||||
|
||||
@@ -128,8 +122,6 @@ impl HistoryCell {
|
||||
match self {
|
||||
HistoryCell::WelcomeMessage { view }
|
||||
| HistoryCell::UserPrompt { view }
|
||||
| HistoryCell::AgentMessage { view }
|
||||
| HistoryCell::AgentReasoning { view }
|
||||
| HistoryCell::BackgroundEvent { view }
|
||||
| HistoryCell::GitDiffOutput { view }
|
||||
| HistoryCell::ErrorEvent { view }
|
||||
@@ -231,28 +223,6 @@ impl HistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_agent_message(config: &Config, message: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("codex".magenta().bold()));
|
||||
append_markdown(&message, &mut lines, config);
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::AgentMessage {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("thinking".magenta().italic()));
|
||||
append_markdown(&text, &mut lines, config);
|
||||
lines.push(Line::from(""));
|
||||
|
||||
HistoryCell::AgentReasoning {
|
||||
view: TextBlock::new(lines),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
|
||||
let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
@@ -22,6 +21,20 @@ use ratatui::text::Span;
|
||||
|
||||
/// Insert `lines` above the viewport.
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
let mut out = std::io::stdout();
|
||||
insert_history_lines_to_writer(terminal, &mut out, lines);
|
||||
}
|
||||
|
||||
/// Like `insert_history_lines`, but writes ANSI to the provided writer. This
|
||||
/// is intended for testing where a capture buffer is used instead of stdout.
|
||||
pub fn insert_history_lines_to_writer<B, W>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
writer: &mut W,
|
||||
lines: Vec<Line>,
|
||||
) where
|
||||
B: ratatui::backend::Backend,
|
||||
W: Write,
|
||||
{
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
let cursor_pos = terminal.get_cursor_position().ok();
|
||||
|
||||
@@ -32,10 +45,22 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
// Don't scroll it past the bottom of the screen.
|
||||
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_down(area.top()..screen_size.height, scroll_amount)
|
||||
.ok();
|
||||
|
||||
// Emit ANSI to scroll the lower region (from the top of the viewport to the bottom
|
||||
// of the screen) downward by `scroll_amount` lines. We do this by:
|
||||
// 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds)
|
||||
// 2) Placing the cursor at the top margin of that region
|
||||
// 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times
|
||||
// 4) Resetting the scroll region back to full screen
|
||||
let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM
|
||||
queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok();
|
||||
queue!(writer, MoveTo(0, area.top())).ok();
|
||||
for _ in 0..scroll_amount {
|
||||
// Reverse Index (RI): ESC M
|
||||
queue!(writer, Print("\x1bM")).ok();
|
||||
}
|
||||
queue!(writer, ResetScrollRegion).ok();
|
||||
|
||||
let cursor_top = area.top().saturating_sub(1);
|
||||
area.y += scroll_amount;
|
||||
terminal.set_viewport_area(area);
|
||||
@@ -59,23 +84,23 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
|
||||
// ││ ││
|
||||
// │╰────────────────────────────╯│
|
||||
// └──────────────────────────────┘
|
||||
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
|
||||
queue!(writer, SetScrollRegion(1..area.top())).ok();
|
||||
|
||||
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
||||
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(std::io::stdout(), MoveTo(0, cursor_top)).ok();
|
||||
queue!(writer, MoveTo(0, cursor_top)).ok();
|
||||
|
||||
for line in lines {
|
||||
queue!(std::io::stdout(), Print("\r\n")).ok();
|
||||
write_spans(&mut std::io::stdout(), line.iter()).ok();
|
||||
queue!(writer, Print("\r\n")).ok();
|
||||
write_spans(writer, line.iter()).ok();
|
||||
}
|
||||
|
||||
queue!(std::io::stdout(), ResetScrollRegion).ok();
|
||||
queue!(writer, ResetScrollRegion).ok();
|
||||
|
||||
// Restore the cursor position to where it was before we started.
|
||||
if let Some(cursor_pos) = cursor_pos {
|
||||
queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok();
|
||||
queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,19 +113,25 @@ fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
|
||||
}
|
||||
|
||||
fn line_height(line: &Line, width: u16) -> u16 {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
// get the total display width of the line, accounting for double-width chars
|
||||
let total_width = line
|
||||
// Use the same visible-width slicing semantics as the live row builder so
|
||||
// our pre-scroll estimation matches how rows will actually wrap.
|
||||
let w = width.max(1) as usize;
|
||||
let mut rows = 0u16;
|
||||
let mut remaining = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.width())
|
||||
.sum::<usize>();
|
||||
// divide by width to get the number of lines, rounding up
|
||||
if width == 0 {
|
||||
1
|
||||
} else {
|
||||
(total_width as u16).div_ceil(width).max(1)
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
while !remaining.is_empty() {
|
||||
let (_prefix, suffix, taken) = crate::live_wrap::take_prefix_by_width(&remaining, w);
|
||||
rows = rows.saturating_add(1);
|
||||
if taken >= remaining.len() {
|
||||
break;
|
||||
}
|
||||
remaining = suffix.to_string();
|
||||
}
|
||||
rows.max(1)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -283,4 +314,12 @@ mod tests {
|
||||
String::from_utf8(expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_height_counts_double_width_emoji() {
|
||||
let line = Line::from("😀😀😀"); // each emoji ~ width 2
|
||||
assert_eq!(line_height(&line, 4), 2);
|
||||
assert_eq!(line_height(&line, 2), 3);
|
||||
assert_eq!(line_height(&line, 6), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
mod custom_terminal;
|
||||
pub mod custom_terminal;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
mod git_warning_screen;
|
||||
mod history_cell;
|
||||
mod insert_history;
|
||||
pub mod insert_history;
|
||||
pub mod live_wrap;
|
||||
mod log_layer;
|
||||
mod markdown;
|
||||
mod slash_command;
|
||||
|
||||
290
codex-rs/tui/src/live_wrap.rs
Normal file
290
codex-rs/tui/src/live_wrap.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// A single visual row produced by RowBuilder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
pub text: String,
|
||||
/// True if this row ends with an explicit line break (as opposed to a hard wrap).
|
||||
pub explicit_break: bool,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn width(&self) -> usize {
|
||||
self.text.width()
|
||||
}
|
||||
}
|
||||
|
||||
/// Incrementally wraps input text into visual rows of at most `width` cells.
|
||||
///
|
||||
/// Step 1: plain-text only. ANSI-carry and styled spans will be added later.
|
||||
pub struct RowBuilder {
|
||||
target_width: usize,
|
||||
/// Buffer for the current logical line (until a '\n' is seen).
|
||||
current_line: String,
|
||||
/// Output rows built so far for the current logical line and previous ones.
|
||||
rows: Vec<Row>,
|
||||
}
|
||||
|
||||
impl RowBuilder {
|
||||
pub fn new(target_width: usize) -> Self {
|
||||
Self {
|
||||
target_width: target_width.max(1),
|
||||
current_line: String::new(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> usize {
|
||||
self.target_width
|
||||
}
|
||||
|
||||
pub fn set_width(&mut self, width: usize) {
|
||||
self.target_width = width.max(1);
|
||||
// Rewrap everything we have (simple approach for Step 1).
|
||||
let mut all = String::new();
|
||||
for row in self.rows.drain(..) {
|
||||
all.push_str(&row.text);
|
||||
if row.explicit_break {
|
||||
all.push('\n');
|
||||
}
|
||||
}
|
||||
all.push_str(&self.current_line);
|
||||
self.current_line.clear();
|
||||
self.push_fragment(&all);
|
||||
}
|
||||
|
||||
/// Push an input fragment. May contain newlines.
|
||||
pub fn push_fragment(&mut self, fragment: &str) {
|
||||
if fragment.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut start = 0usize;
|
||||
for (i, ch) in fragment.char_indices() {
|
||||
if ch == '\n' {
|
||||
// Flush anything pending before the newline.
|
||||
if start < i {
|
||||
self.current_line.push_str(&fragment[start..i]);
|
||||
}
|
||||
self.flush_current_line(true);
|
||||
start = i + ch.len_utf8();
|
||||
}
|
||||
}
|
||||
if start < fragment.len() {
|
||||
self.current_line.push_str(&fragment[start..]);
|
||||
self.wrap_current_line();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the end of the current logical line (equivalent to pushing a '\n').
|
||||
pub fn end_line(&mut self) {
|
||||
self.flush_current_line(true);
|
||||
}
|
||||
|
||||
/// Drain and return all produced rows.
|
||||
pub fn drain_rows(&mut self) -> Vec<Row> {
|
||||
std::mem::take(&mut self.rows)
|
||||
}
|
||||
|
||||
/// Return a snapshot of produced rows (non-draining).
|
||||
pub fn rows(&self) -> &[Row] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
/// Rows suitable for display, including the current partial line if any.
|
||||
pub fn display_rows(&self) -> Vec<Row> {
|
||||
let mut out = self.rows.clone();
|
||||
if !self.current_line.is_empty() {
|
||||
out.push(Row {
|
||||
text: self.current_line.clone(),
|
||||
explicit_break: false,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Drain the oldest rows that exceed `max_keep` display rows (including the
|
||||
/// current partial line, if any). Returns the drained rows in order.
|
||||
pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec<Row> {
|
||||
let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 };
|
||||
if display_count <= max_keep {
|
||||
return Vec::new();
|
||||
}
|
||||
let to_commit = display_count - max_keep;
|
||||
let commit_count = to_commit.min(self.rows.len());
|
||||
let mut drained = Vec::with_capacity(commit_count);
|
||||
for _ in 0..commit_count {
|
||||
drained.push(self.rows.remove(0));
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
fn flush_current_line(&mut self, explicit_break: bool) {
|
||||
// Wrap any remaining content in the current line and then finalize with explicit_break.
|
||||
self.wrap_current_line();
|
||||
// If the current line ended exactly on a width boundary and is non-empty, represent
|
||||
// the explicit break as an empty explicit row so that fragmentation invariance holds.
|
||||
if explicit_break {
|
||||
if self.current_line.is_empty() {
|
||||
// We ended on a boundary previously; add an empty explicit row.
|
||||
self.rows.push(Row {
|
||||
text: String::new(),
|
||||
explicit_break: true,
|
||||
});
|
||||
} else {
|
||||
// There is leftover content that did not wrap yet; push it now with the explicit flag.
|
||||
let mut s = String::new();
|
||||
std::mem::swap(&mut s, &mut self.current_line);
|
||||
self.rows.push(Row {
|
||||
text: s,
|
||||
explicit_break: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reset current line buffer for next logical line.
|
||||
self.current_line.clear();
|
||||
}
|
||||
|
||||
fn wrap_current_line(&mut self) {
|
||||
// While the current_line exceeds width, cut a prefix.
|
||||
loop {
|
||||
if self.current_line.is_empty() {
|
||||
break;
|
||||
}
|
||||
let (prefix, suffix, taken) =
|
||||
take_prefix_by_width(&self.current_line, self.target_width);
|
||||
if taken == 0 {
|
||||
// Avoid infinite loop on pathological inputs; take one scalar and continue.
|
||||
if let Some((i, ch)) = self.current_line.char_indices().next() {
|
||||
let len = i + ch.len_utf8();
|
||||
let p = self.current_line[..len].to_string();
|
||||
self.rows.push(Row {
|
||||
text: p,
|
||||
explicit_break: false,
|
||||
});
|
||||
self.current_line = self.current_line[len..].to_string();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if suffix.is_empty() {
|
||||
// Fits entirely; keep in buffer (do not push yet) so we can append more later.
|
||||
break;
|
||||
} else {
|
||||
// Emit wrapped prefix as a non-explicit row and continue with the remainder.
|
||||
self.rows.push(Row {
|
||||
text: prefix,
|
||||
explicit_break: false,
|
||||
});
|
||||
self.current_line = suffix.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a prefix of `text` whose visible width is at most `max_cols`.
|
||||
/// Returns (prefix, suffix, prefix_width).
|
||||
pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize) {
|
||||
if max_cols == 0 || text.is_empty() {
|
||||
return (String::new(), text, 0);
|
||||
}
|
||||
let mut cols = 0usize;
|
||||
let mut end_idx = 0usize;
|
||||
for (i, ch) in text.char_indices() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if cols.saturating_add(ch_width) > max_cols {
|
||||
break;
|
||||
}
|
||||
cols += ch_width;
|
||||
end_idx = i + ch.len_utf8();
|
||||
if cols == max_cols {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let prefix = text[..end_idx].to_string();
|
||||
let suffix = &text[end_idx..];
|
||||
(prefix, suffix, cols)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn rows_do_not_exceed_width_ascii() {
|
||||
let mut rb = RowBuilder::new(10);
|
||||
rb.push_fragment("hello whirl this is a test");
|
||||
let rows = rb.rows().to_vec();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
Row {
|
||||
text: "hello whir".to_string(),
|
||||
explicit_break: false
|
||||
},
|
||||
Row {
|
||||
text: "l this is ".to_string(),
|
||||
explicit_break: false
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_do_not_exceed_width_emoji_cjk() {
|
||||
// 😀 is width 2; 你/好 are width 2.
|
||||
let mut rb = RowBuilder::new(6);
|
||||
rb.push_fragment("😀😀 你好");
|
||||
let rows = rb.rows().to_vec();
|
||||
// At width 6, we expect the first row to fit exactly two emojis and a space
|
||||
// (2 + 2 + 1 = 5) plus one more column for the first CJK char (2 would overflow),
|
||||
// so only the two emojis and the space fit; the rest remains buffered.
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![Row {
|
||||
text: "😀😀 ".to_string(),
|
||||
explicit_break: false
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmentation_invariance_long_token() {
|
||||
let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 26 chars
|
||||
let mut rb_all = RowBuilder::new(7);
|
||||
rb_all.push_fragment(s);
|
||||
let all_rows = rb_all.rows().to_vec();
|
||||
|
||||
let mut rb_chunks = RowBuilder::new(7);
|
||||
for i in (0..s.len()).step_by(3) {
|
||||
let end = (i + 3).min(s.len());
|
||||
rb_chunks.push_fragment(&s[i..end]);
|
||||
}
|
||||
let chunk_rows = rb_chunks.rows().to_vec();
|
||||
|
||||
assert_eq!(all_rows, chunk_rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newline_splits_rows() {
|
||||
let mut rb = RowBuilder::new(10);
|
||||
rb.push_fragment("hello\nworld");
|
||||
let rows = rb.display_rows();
|
||||
assert!(rows.iter().any(|r| r.explicit_break));
|
||||
assert_eq!(rows[0].text, "hello");
|
||||
// Second row should begin with 'world'
|
||||
assert!(rows.iter().any(|r| r.text.starts_with("world")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrap_on_width_change() {
|
||||
let mut rb = RowBuilder::new(10);
|
||||
rb.push_fragment("abcdefghijK");
|
||||
assert!(!rb.rows().is_empty());
|
||||
rb.set_width(5);
|
||||
for r in rb.rows() {
|
||||
assert!(r.width() <= 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::citation_regex::CITATION_REGEX;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config_types::UriBasedFileOpener;
|
||||
use ratatui::text::Line;
|
||||
@@ -5,8 +6,7 @@ use ratatui::text::Span;
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::citation_regex::CITATION_REGEX;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn append_markdown(
|
||||
markdown_source: &str,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
@@ -15,6 +15,7 @@ pub(crate) fn append_markdown(
|
||||
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn append_markdown_with_opener_and_cwd(
|
||||
markdown_source: &str,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
@@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd(
|
||||
/// ```text
|
||||
/// <scheme>://file<ABS_PATH>:<LINE>
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
fn rewrite_file_citations<'a>(
|
||||
src: &'a str,
|
||||
file_opener: UriBasedFileOpener,
|
||||
|
||||
@@ -9,24 +9,22 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Padding;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
// We render the live text using markdown so it visually matches the history
|
||||
// cells. Before rendering we strip any ANSI escape sequences to avoid writing
|
||||
// raw control bytes into the back buffer.
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
@@ -34,6 +32,14 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
/// time).
|
||||
text: String,
|
||||
|
||||
/// Animation state: reveal target `text` progressively like a typewriter.
|
||||
/// We compute the currently visible prefix length based on the current
|
||||
/// frame index and a constant typing speed. The `base_frame` and
|
||||
/// `reveal_len_at_base` form the anchor from which we advance.
|
||||
last_target_len: usize,
|
||||
base_frame: usize,
|
||||
reveal_len_at_base: usize,
|
||||
|
||||
frame_idx: Arc<AtomicUsize>,
|
||||
running: Arc<AtomicBool>,
|
||||
// Keep one sender alive to prevent the channel from closing while the
|
||||
@@ -66,9 +72,13 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
|
||||
Self {
|
||||
text: String::from("waiting for logs…"),
|
||||
text: String::from("waiting for model"),
|
||||
last_target_len: 0,
|
||||
base_frame: 0,
|
||||
reveal_len_at_base: 0,
|
||||
frame_idx,
|
||||
running,
|
||||
|
||||
_app_event_tx: app_event_tx,
|
||||
}
|
||||
}
|
||||
@@ -79,7 +89,67 @@ impl StatusIndicatorWidget {
|
||||
|
||||
/// Update the line that is displayed in the widget.
|
||||
pub(crate) fn update_text(&mut self, text: String) {
|
||||
self.text = text.replace(['\n', '\r'], " ");
|
||||
// If the text hasn't changed, don't reset the baseline; let the
|
||||
// animation continue advancing naturally.
|
||||
if text == self.text {
|
||||
return;
|
||||
}
|
||||
// Update the target text, preserving newlines so wrapping matches history cells.
|
||||
// Strip ANSI escapes for the character count so the typewriter animation speed is stable.
|
||||
let stripped = {
|
||||
let line = ansi_escape_line(&text);
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
let new_len = stripped.chars().count();
|
||||
|
||||
// Compute how many characters are currently revealed so we can carry
|
||||
// this forward as the new baseline when target text changes.
|
||||
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let shown_now = self.current_shown_len(current_frame);
|
||||
|
||||
self.text = text;
|
||||
self.last_target_len = new_len;
|
||||
self.base_frame = current_frame;
|
||||
self.reveal_len_at_base = shown_now.min(new_len);
|
||||
}
|
||||
|
||||
/// Reset the animation and start revealing `text` from the beginning.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn restart_with_text(&mut self, text: String) {
|
||||
let sanitized = text.replace(['\n', '\r'], " ");
|
||||
let stripped = {
|
||||
let line = ansi_escape_line(&sanitized);
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
|
||||
let new_len = stripped.chars().count();
|
||||
let current_frame = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
self.text = sanitized;
|
||||
self.last_target_len = new_len;
|
||||
self.base_frame = current_frame;
|
||||
// Start from zero revealed characters for a fresh typewriter cycle.
|
||||
self.reveal_len_at_base = 0;
|
||||
}
|
||||
|
||||
/// Calculate how many characters should currently be visible given the
|
||||
/// animation baseline and frame counter.
|
||||
fn current_shown_len(&self, current_frame: usize) -> usize {
|
||||
// Increase typewriter speed (~5x): reveal more characters per frame.
|
||||
const TYPING_CHARS_PER_FRAME: usize = 7;
|
||||
let frames = current_frame.saturating_sub(self.base_frame);
|
||||
let advanced = self
|
||||
.reveal_len_at_base
|
||||
.saturating_add(frames.saturating_mul(TYPING_CHARS_PER_FRAME));
|
||||
advanced.min(self.last_target_len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,26 +162,22 @@ impl Drop for StatusIndicatorWidget {
|
||||
|
||||
impl WidgetRef for StatusIndicatorWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let widget_style = Style::default();
|
||||
let block = Block::default()
|
||||
.padding(Padding::new(1, 0, 0, 0))
|
||||
.borders(Borders::LEFT)
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(widget_style.dim());
|
||||
// Ensure minimal height
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build animated gradient header for the word "Working".
|
||||
let idx = self.frame_idx.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let header_text = "Working";
|
||||
let header_chars: Vec<char> = header_text.chars().collect();
|
||||
|
||||
let padding = 4usize; // virtual padding around the word for smoother loop
|
||||
let period = header_chars.len() + padding * 2;
|
||||
let pos = idx % period;
|
||||
|
||||
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
.map(|level| level.has_16m)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Width of the bright band (in characters).
|
||||
let band_half_width = 2.0;
|
||||
let band_half_width = 2.0; // width of the bright band in characters
|
||||
|
||||
let mut header_spans: Vec<Span<'static>> = Vec::new();
|
||||
for (i, ch) in header_chars.iter().enumerate() {
|
||||
@@ -133,64 +199,46 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
.fg(Color::Rgb(level, level, level))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
// Bold makes dark gray and gray look the same, so don't use it
|
||||
// when true color is not supported.
|
||||
// Bold makes dark gray and gray look the same, so don't use it when true color is not supported.
|
||||
Style::default().fg(color_for_level(level))
|
||||
};
|
||||
|
||||
header_spans.push(Span::styled(ch.to_string(), style));
|
||||
}
|
||||
|
||||
header_spans.push(Span::styled(
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let inner_width = area.width as usize;
|
||||
|
||||
// Compose a single status line like: "▌ Working [•] waiting for model"
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
spans.push(Span::styled("▌ ", Style::default().fg(Color::Cyan)));
|
||||
// Gradient header
|
||||
spans.extend(header_spans);
|
||||
// Space after header
|
||||
spans.push(Span::styled(
|
||||
" ",
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
// Ensure we do not overflow width.
|
||||
let inner_width = block.inner(area).width as usize;
|
||||
|
||||
// Sanitize and colour‑strip the potentially colourful log text. This
|
||||
// ensures that **no** raw ANSI escape sequences leak into the
|
||||
// back‑buffer which would otherwise cause cursor jumps or stray
|
||||
// artefacts when the terminal is resized.
|
||||
let line = ansi_escape_line(&self.text);
|
||||
let mut sanitized_tail: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
// Truncate *after* stripping escape codes so width calculation is
|
||||
// accurate. See UTF‑8 boundary comments above.
|
||||
let header_len: usize = header_spans.iter().map(|s| s.content.len()).sum();
|
||||
|
||||
if header_len + sanitized_tail.len() > inner_width {
|
||||
let available_bytes = inner_width.saturating_sub(header_len);
|
||||
|
||||
if sanitized_tail.is_char_boundary(available_bytes) {
|
||||
sanitized_tail.truncate(available_bytes);
|
||||
// Truncate spans to fit the width.
|
||||
let mut acc: Vec<Span<'static>> = Vec::new();
|
||||
let mut used = 0usize;
|
||||
for s in spans {
|
||||
let w = s.content.width();
|
||||
if used + w <= inner_width {
|
||||
acc.push(s);
|
||||
used += w;
|
||||
} else {
|
||||
let mut idx = available_bytes;
|
||||
while idx < sanitized_tail.len() && !sanitized_tail.is_char_boundary(idx) {
|
||||
idx += 1;
|
||||
}
|
||||
sanitized_tail.truncate(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let lines = vec![Line::from(acc)];
|
||||
|
||||
let mut spans = header_spans;
|
||||
// No-op once full text is revealed; the app no longer reacts to a completion event.
|
||||
|
||||
// Re‑apply the DIM modifier so the tail appears visually subdued
|
||||
// irrespective of the colour information preserved by
|
||||
// `ansi_escape_line`.
|
||||
spans.push(Span::styled(sanitized_tail, Style::default().dim()));
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans))
|
||||
.block(block)
|
||||
.alignment(Alignment::Left);
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -204,3 +252,50 @@ fn color_for_level(level: u8) -> Color {
|
||||
Color::White
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn renders_without_left_border_or_padding() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx);
|
||||
w.restart_with_text("Hello".to_string());
|
||||
|
||||
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
|
||||
// Allow a short delay so the typewriter reveals the first character.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
w.render_ref(area, &mut buf);
|
||||
|
||||
// Leftmost column has the left bar
|
||||
let ch0 = buf[(0, 0)].symbol().chars().next().unwrap_or(' ');
|
||||
assert_eq!(ch0, '▌', "expected left bar at col 0: {ch0:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_header_is_present_on_last_line() {
|
||||
let (tx_raw, _rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx);
|
||||
w.restart_with_text("Hi".to_string());
|
||||
// Ensure some frames elapse so we get a stable state.
|
||||
std::thread::sleep(std::time::Duration::from_millis(120));
|
||||
|
||||
let area = ratatui::layout::Rect::new(0, 0, 30, 1);
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
w.render_ref(area, &mut buf);
|
||||
|
||||
// Single line; it should contain the animated "Working" header.
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
assert!(row.contains("Working"), "expected Working header: {row:?}");
|
||||
}
|
||||
}
|
||||
|
||||
214
codex-rs/tui/tests/vt100_history.rs
Normal file
214
codex-rs/tui/tests/vt100_history.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
#![cfg(feature = "vt100-tests")]
|
||||
#![expect(clippy::expect_used)]
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
// Small helper macro to assert a collection contains an item with a clearer
|
||||
// failure message.
|
||||
macro_rules! assert_contains {
|
||||
($collection:expr, $item:expr $(,)?) => {
|
||||
assert!(
|
||||
$collection.contains(&$item),
|
||||
"Expected {:?} to contain {:?}",
|
||||
$collection,
|
||||
$item
|
||||
);
|
||||
};
|
||||
($collection:expr, $item:expr, $($arg:tt)+) => {
|
||||
assert!($collection.contains(&$item), $($arg)+);
|
||||
};
|
||||
}
|
||||
|
||||
struct TestScenario {
|
||||
width: u16,
|
||||
height: u16,
|
||||
term: codex_tui::custom_terminal::Terminal<TestBackend>,
|
||||
}
|
||||
|
||||
impl TestScenario {
|
||||
fn new(width: u16, height: u16, viewport: Rect) -> Self {
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut term = codex_tui::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
term.set_viewport_area(viewport);
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
term,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_insert(&mut self, lines: Vec<Line<'static>>) -> Vec<u8> {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut self.term, &mut buf, lines);
|
||||
buf
|
||||
}
|
||||
|
||||
fn screen_rows_from_bytes(&self, bytes: &[u8]) -> Vec<String> {
|
||||
let mut parser = vt100::Parser::new(self.height, self.width, 0);
|
||||
parser.process(bytes);
|
||||
let screen = parser.screen();
|
||||
|
||||
let mut rows: Vec<String> = Vec::with_capacity(self.height as usize);
|
||||
for row in 0..self.height {
|
||||
let mut s = String::with_capacity(self.width as usize);
|
||||
for col in 0..self.width {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
rows.push(s.trim_end().to_string());
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_001_basic_insertion_no_wrap() {
|
||||
// Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec![Line::from("first"), Line::from("second")];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
assert_contains!(rows, String::from("first"));
|
||||
assert_contains!(rows, String::from("second"));
|
||||
let first_idx = rows
|
||||
.iter()
|
||||
.position(|r| r == "first")
|
||||
.expect("expected 'first' row to be present");
|
||||
let second_idx = rows
|
||||
.iter()
|
||||
.position(|r| r == "second")
|
||||
.expect("expected 'second' row to be present");
|
||||
assert_eq!(second_idx, first_idx + 1, "rows should be adjacent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_002_long_token_wraps() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let long = "A".repeat(45); // > 2 lines at width 20
|
||||
let lines = vec![Line::from(long.clone())];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let mut parser = vt100::Parser::new(6, 20, 0);
|
||||
parser.process(&buf);
|
||||
let screen = parser.screen();
|
||||
|
||||
// Count total A's on the screen
|
||||
let mut count_a = 0usize;
|
||||
for row in 0..6 {
|
||||
for col in 0..20 {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
if ch == 'A' {
|
||||
count_a += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
count_a,
|
||||
long.len(),
|
||||
"wrapped content did not preserve all characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_003_emoji_and_cjk() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let text = String::from("😀😀😀😀😀 你好世界");
|
||||
let lines = vec![Line::from(text.clone())];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
let reconstructed: String = rows.join("").chars().filter(|c| *c != ' ').collect();
|
||||
for ch in text.chars().filter(|c| !c.is_whitespace()) {
|
||||
assert!(
|
||||
reconstructed.contains(ch),
|
||||
"missing character {ch:?} in reconstructed screen"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_004_mixed_ansi_spans() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled("red", Style::default().fg(Color::Red)),
|
||||
Span::raw("+plain"),
|
||||
]);
|
||||
let buf = scenario.run_insert(vec![line]);
|
||||
let rows = scenario.screen_rows_from_bytes(&buf);
|
||||
assert_contains!(rows, String::from("red+plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_006_cursor_restoration() {
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec![Line::from("x")];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
// CUP to 1;1 (ANSI: ESC[1;1H)
|
||||
assert!(
|
||||
s.contains("\u{1b}[1;1H"),
|
||||
"expected final CUP to 1;1 in output, got: {s:?}"
|
||||
);
|
||||
// Reset scroll region
|
||||
assert!(
|
||||
s.contains("\u{1b}[r"),
|
||||
"expected reset scroll region in output, got: {s:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hist_005_pre_scroll_region_down() {
|
||||
// Viewport not at bottom: y=3 (0-based), height=1
|
||||
let area = Rect::new(0, 3, 20, 1);
|
||||
let mut scenario = TestScenario::new(20, 6, area);
|
||||
|
||||
let lines = vec![Line::from("first"), Line::from("second")];
|
||||
let buf = scenario.run_insert(lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
// Expect we limited scroll region to [top+1 .. screen_height] => [4 .. 6] (1-based)
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;6r"),
|
||||
"expected pre-scroll SetScrollRegion 4..6, got: {s:?}"
|
||||
);
|
||||
// Expect we moved cursor to top of that region: row 3 (0-based) => CUP 4;1H
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;1H"),
|
||||
"expected cursor at top of pre-scroll region, got: {s:?}"
|
||||
);
|
||||
// Expect at least two Reverse Index commands (ESC M) for two inserted lines
|
||||
let ri_count = s.matches("\u{1b}M").count();
|
||||
assert!(
|
||||
ri_count >= 1,
|
||||
"expected at least one RI (ESC M), got: {s:?}"
|
||||
);
|
||||
// After pre-scroll, we set insertion scroll region to [1 .. new_top] => [1 .. 5]
|
||||
assert!(
|
||||
s.contains("\u{1b}[1;5r"),
|
||||
"expected insertion SetScrollRegion 1..5, got: {s:?}"
|
||||
);
|
||||
}
|
||||
101
codex-rs/tui/tests/vt100_live_commit.rs
Normal file
101
codex-rs/tui/tests/vt100_live_commit.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
#![cfg(feature = "vt100-tests")]
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
#[test]
|
||||
fn live_001_commit_on_overflow() {
|
||||
let backend = TestBackend::new(20, 6);
|
||||
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("failed to construct terminal: {e}"),
|
||||
};
|
||||
let area = Rect::new(0, 5, 20, 1);
|
||||
term.set_viewport_area(area);
|
||||
|
||||
// Build 5 explicit rows at width 20.
|
||||
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
|
||||
rb.push_fragment("one\n");
|
||||
rb.push_fragment("two\n");
|
||||
rb.push_fragment("three\n");
|
||||
rb.push_fragment("four\n");
|
||||
rb.push_fragment("five\n");
|
||||
|
||||
// Keep the last 3 in the live ring; commit the first 2.
|
||||
let commit_rows = rb.drain_commit_ready(3);
|
||||
let lines: Vec<Line<'static>> = commit_rows
|
||||
.into_iter()
|
||||
.map(|r| Line::from(r.text))
|
||||
.collect();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||||
|
||||
let mut parser = vt100::Parser::new(6, 20, 0);
|
||||
parser.process(&buf);
|
||||
let screen = parser.screen();
|
||||
|
||||
// The words "one" and "two" should appear above the viewport.
|
||||
let mut joined = String::new();
|
||||
for row in 0..6 {
|
||||
for col in 0..20 {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
joined.push(ch);
|
||||
} else {
|
||||
joined.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
joined.push('\n');
|
||||
}
|
||||
assert!(
|
||||
joined.contains("one"),
|
||||
"expected committed 'one' to be visible\n{joined}"
|
||||
);
|
||||
assert!(
|
||||
joined.contains("two"),
|
||||
"expected committed 'two' to be visible\n{joined}"
|
||||
);
|
||||
// The last three (three,four,five) remain in the live ring, not committed here.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_002_pre_scroll_and_commit() {
|
||||
let backend = TestBackend::new(20, 6);
|
||||
let mut term = match codex_tui::custom_terminal::Terminal::with_options(backend) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("failed to construct terminal: {e}"),
|
||||
};
|
||||
// Viewport not at bottom: y=3
|
||||
let area = Rect::new(0, 3, 20, 1);
|
||||
term.set_viewport_area(area);
|
||||
|
||||
let mut rb = codex_tui::live_wrap::RowBuilder::new(20);
|
||||
rb.push_fragment("alpha\n");
|
||||
rb.push_fragment("beta\n");
|
||||
rb.push_fragment("gamma\n");
|
||||
rb.push_fragment("delta\n");
|
||||
|
||||
// Keep 3, commit 1.
|
||||
let commit_rows = rb.drain_commit_ready(3);
|
||||
let lines: Vec<Line<'static>> = commit_rows
|
||||
.into_iter()
|
||||
.map(|r| Line::from(r.text))
|
||||
.collect();
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||||
let s = String::from_utf8_lossy(&buf);
|
||||
|
||||
// Expect a SetScrollRegion to [area.top()+1 .. screen_height] and a cursor move to top of that region.
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;6r"),
|
||||
"expected pre-scroll region 4..6, got: {s:?}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("\u{1b}[4;1H"),
|
||||
"expected cursor CUP 4;1H, got: {s:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user