mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
4441 lines
156 KiB
Markdown
4441 lines
156 KiB
Markdown
# PR #1810: Stream model responses
|
||
|
||
- URL: https://github.com/openai/codex/pull/1810
|
||
- Author: easong-openai
|
||
- Created: 2025-08-03 07:00:46 UTC
|
||
- Updated: 2025-08-05 04:23:29 UTC
|
||
- Changes: +1616/-234, Files changed: 17, Commits: 20
|
||
|
||
## Description
|
||
|
||
Stream models thoughts and responses instead of waiting for the whole thing to come through. Very rough right now.
|
||
|
||
## Full Diff
|
||
|
||
```diff
|
||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||
index 4daae977b0..2e20a7d624 100644
|
||
--- a/codex-rs/Cargo.lock
|
||
+++ b/codex-rs/Cargo.lock
|
||
@@ -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]]
|
||
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
|
||
index b5ade23b9d..e1804b191e 100644
|
||
--- a/codex-rs/core/src/chat_completions.rs
|
||
+++ b/codex-rs/core/src/chat_completions.rs
|
||
@@ -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))));
|
||
}
|
||
}
|
||
}
|
||
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
|
||
index 568d87c4a8..8d24356460 100644
|
||
--- a/codex-rs/core/src/codex.rs
|
||
+++ b/codex-rs/core/src/codex.rs
|
||
@@ -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;
|
||
|
||
diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs
|
||
index f5254f339e..1d55b125bc 100644
|
||
--- a/codex-rs/core/src/conversation_history.rs
|
||
+++ b/codex-rs/core/src/conversation_history.rs
|
||
@@ -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()
|
||
+ }]
|
||
+ }
|
||
+ ]
|
||
+ );
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
|
||
index 166404915a..91bfb3bc8c 100644
|
||
--- a/codex-rs/core/src/models.rs
|
||
+++ b/codex-rs/core/src/models.rs
|
||
@@ -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>,
|
||
}
|
||
|
||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||
index a571b32c8d..60af056a2d 100644
|
||
--- a/codex-rs/tui/Cargo.toml
|
||
+++ b/codex-rs/tui/Cargo.toml
|
||
@@ -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"
|
||
diff --git a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
|
||
new file mode 100644
|
||
index 0000000000..13f91acc5d
|
||
--- /dev/null
|
||
+++ b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs
|
||
@@ -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);
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||
index cab78bbe3f..fde0b3bde8 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||
@@ -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:?}"
|
||
+ );
|
||
+ }
|
||
}
|
||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||
index e5ebf58a07..f63810b62a 100644
|
||
--- a/codex-rs/tui/src/chatwidget.rs
|
||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||
@@ -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
|
||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||
index c2aafdd522..17f0e683c0 100644
|
||
--- a/codex-rs/tui/src/history_cell.rs
|
||
+++ b/codex-rs/tui/src/history_cell.rs
|
||
@@ -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);
|
||
|
||
diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs
|
||
index 87d88b7f03..5c316637b1 100644
|
||
--- a/codex-rs/tui/src/insert_history.rs
|
||
+++ b/codex-rs/tui/src/insert_history.rs
|
||
@@ -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);
|
||
+ }
|
||
}
|
||
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
|
||
index 0ec9be6153..c619ce8ff0 100644
|
||
--- a/codex-rs/tui/src/lib.rs
|
||
+++ b/codex-rs/tui/src/lib.rs
|
||
@@ -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;
|
||
diff --git a/codex-rs/tui/src/live_wrap.rs b/codex-rs/tui/src/live_wrap.rs
|
||
new file mode 100644
|
||
index 0000000000..e78710dc6c
|
||
--- /dev/null
|
||
+++ b/codex-rs/tui/src/live_wrap.rs
|
||
@@ -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);
|
||
+ }
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs
|
||
index ab20138298..910a6869ec 100644
|
||
--- a/codex-rs/tui/src/markdown.rs
|
||
+++ b/codex-rs/tui/src/markdown.rs
|
||
@@ -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,
|
||
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
|
||
index aa18ac6fa5..fad7e41a39 100644
|
||
--- a/codex-rs/tui/src/status_indicator_widget.rs
|
||
+++ b/codex-rs/tui/src/status_indicator_widget.rs
|
||
@@ -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;
|
||
-
|
||
- // 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()));
|
||
+ // No-op once full text is revealed; the app no longer reacts to a completion event.
|
||
|
||
- 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:?}");
|
||
+ }
|
||
+}
|
||
diff --git a/codex-rs/tui/tests/vt100_history.rs b/codex-rs/tui/tests/vt100_history.rs
|
||
new file mode 100644
|
||
index 0000000000..11ee044041
|
||
--- /dev/null
|
||
+++ b/codex-rs/tui/tests/vt100_history.rs
|
||
@@ -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:?}"
|
||
+ );
|
||
+}
|
||
diff --git a/codex-rs/tui/tests/vt100_live_commit.rs b/codex-rs/tui/tests/vt100_live_commit.rs
|
||
new file mode 100644
|
||
index 0000000000..c0cfb3211a
|
||
--- /dev/null
|
||
+++ b/codex-rs/tui/tests/vt100_live_commit.rs
|
||
@@ -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:?}"
|
||
+ );
|
||
+}
|
||
```
|
||
|
||
## Review Comments
|
||
|
||
### codex-rs/core/src/codex.rs
|
||
|
||
- Created: 2025-08-04 20:36:55 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252533557
|
||
|
||
```diff
|
||
@@ -122,8 +122,17 @@ impl Codex {
|
||
// experimental resume path (undocumented)
|
||
let resume_path = config.experimental_resume.clone();
|
||
info!("resume_path: {resume_path:?}");
|
||
+ // Use a bounded channel for submissions to retain backpressure on the
|
||
```
|
||
|
||
> decide whether to keep this?
|
||
|
||
- Created: 2025-08-04 20:38:18 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252535763
|
||
|
||
```diff
|
||
@@ -1374,6 +1383,13 @@ async fn try_run_turn(
|
||
return Ok(output);
|
||
}
|
||
ResponseEvent::OutputTextDelta(delta) => {
|
||
+ // Stream assistant text into in-memory conversation history so
|
||
```
|
||
|
||
> ?
|
||
|
||
### codex-rs/core/src/conversation_history.rs
|
||
|
||
- Created: 2025-08-04 20:40:02 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252538612
|
||
|
||
```diff
|
||
@@ -24,9 +24,53 @@ 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);
|
||
+ }
|
||
+ _ => {
|
||
+ // Note agent-loop.ts also does filtering on some of the fields.
|
||
```
|
||
|
||
> TS ref?
|
||
|
||
- Created: 2025-08-04 20:42:24 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252543585
|
||
|
||
```diff
|
||
@@ -24,9 +24,53 @@ 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 needs a test.
|
||
|
||
- Created: 2025-08-05 01:08:45 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252876934
|
||
|
||
```diff
|
||
@@ -72,3 +115,131 @@ 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.len(), 1, "adjacent assistant messages should merge");
|
||
+ if let ResponseItem::Message { role, content, .. } = &items[0] {
|
||
+ assert_eq!(role, "assistant");
|
||
+ let text = match &content[0] {
|
||
+ ContentItem::OutputText { text } => text,
|
||
+ _ => panic!("expected OutputText"),
|
||
+ };
|
||
+ assert_eq!(text, "Hello, world!");
|
||
+ } else {
|
||
+ panic!("expected Message");
|
||
+ }
|
||
```
|
||
|
||
> Can you just make this:
|
||
>
|
||
> ```rust
|
||
> assert_eq!(items, vec![...]);
|
||
> ```
|
||
>
|
||
> we should know the exact output we are getting, correct?
|
||
>
|
||
> Same for the other tests, as well.
|
||
|
||
### codex-rs/tui/Cargo.toml
|
||
|
||
- Created: 2025-08-04 20:42:58 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252545139
|
||
|
||
```diff
|
||
@@ -65,6 +69,7 @@ tui-markdown = "0.3.3"
|
||
unicode-segmentation = "1.12.0"
|
||
unicode-width = "0.1"
|
||
uuid = "1"
|
||
+vt100 = { version = "0.16.2", optional = true }
|
||
```
|
||
|
||
> `[dev-dependencies]`
|
||
|
||
### codex-rs/tui/src/app.rs
|
||
|
||
- Created: 2025-08-04 20:43:44 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252546948
|
||
|
||
```diff
|
||
@@ -220,6 +220,7 @@ impl App<'_> {
|
||
AppEvent::Redraw => {
|
||
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
|
||
}
|
||
+ AppEvent::LiveStatusRevealComplete => {}
|
||
```
|
||
|
||
> unused?
|
||
|
||
### codex-rs/tui/src/bottom_pane/mod.rs
|
||
|
||
- Created: 2025-08-04 21:06:11 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252581513
|
||
|
||
```diff
|
||
@@ -148,19 +182,38 @@ 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 => {
|
||
+ if let Some(view) = self.active_view.as_mut() {
|
||
+ match view.update_status_text(text.clone()) {
|
||
+ bottom_pane_view::ConditionalUpdate::NeedsRedraw => {
|
||
self.request_redraw();
|
||
+ return;
|
||
}
|
||
- ConditionalUpdate::NoRedraw => {
|
||
- // No redraw needed.
|
||
- }
|
||
+ bottom_pane_view::ConditionalUpdate::NoRedraw => {}
|
||
```
|
||
|
||
> This is the only case in which we don't `return;`, so maybe move the logic from "Fallback" into here and then eliminate the `return;` statements?
|
||
|
||
- Created: 2025-08-04 21:06:34 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252582093
|
||
|
||
```diff
|
||
@@ -148,19 +182,38 @@ 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 => {
|
||
+ if let Some(view) = self.active_view.as_mut() {
|
||
+ match view.update_status_text(text.clone()) {
|
||
+ bottom_pane_view::ConditionalUpdate::NeedsRedraw => {
|
||
self.request_redraw();
|
||
+ return;
|
||
}
|
||
- ConditionalUpdate::NoRedraw => {
|
||
- // No redraw needed.
|
||
- }
|
||
+ bottom_pane_view::ConditionalUpdate::NoRedraw => {}
|
||
}
|
||
+ } else {
|
||
+ let mut v = StatusIndicatorView::new(self.app_event_tx.clone());
|
||
+ v.update_text(text);
|
||
+ self.active_view = Some(Box::new(v));
|
||
+ self.status_view_active = true;
|
||
+ self.request_redraw();
|
||
+ return;
|
||
+ }
|
||
+
|
||
+ // Fallback: if the current active view does not consume status updates,
|
||
+ // present an overlay above the composer.
|
||
+ if self.live_status.is_none() {
|
||
+ self.live_status = Some(crate::status_indicator_widget::StatusIndicatorWidget::new(
|
||
+ self.app_event_tx.clone(),
|
||
+ ));
|
||
}
|
||
+ if let Some(status) = &mut self.live_status {
|
||
+ status.update_text(text);
|
||
+ }
|
||
+ self.request_redraw();
|
||
```
|
||
|
||
> It looks like all code paths result in `self.request_redraw()`.
|
||
|
||
- Created: 2025-08-05 01:14:42 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252882838
|
||
|
||
```diff
|
||
@@ -68,14 +82,42 @@ 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);
|
||
+
|
||
+ 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
|
||
+ };
|
||
+ overlay_status_h
|
||
+ .saturating_add(ring_h)
|
||
+ .saturating_add(spacer)
|
||
+ .saturating_add(view.desired_height(width))
|
||
+ .saturating_add(Self::BOTTOM_PAD_LINES)
|
||
+ } else {
|
||
+ overlay_status_h
|
||
+ .saturating_add(ring_h)
|
||
+ .saturating_add(self.composer.desired_height(width))
|
||
+ .saturating_add(Self::BOTTOM_PAD_LINES)
|
||
+ }
|
||
```
|
||
|
||
> Up to you:
|
||
>
|
||
> ```suggestion
|
||
> 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)
|
||
> ```
|
||
|
||
- Created: 2025-08-05 01:16:43 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252884475
|
||
|
||
```diff
|
||
@@ -148,19 +182,40 @@ 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(crate::status_indicator_widget::StatusIndicatorWidget::new(
|
||
```
|
||
|
||
> I would favor declaring types like these as top-level import statements.
|
||
|
||
- Created: 2025-08-05 01:18:10 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252886630
|
||
|
||
```diff
|
||
@@ -186,28 +241,26 @@ 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);
|
||
+ self.status_view_active = false;
|
||
+ } else {
|
||
+ self.status_view_active = false;
|
||
```
|
||
|
||
> `self.status_view_active = false` in both cases, right?
|
||
>
|
||
> ```suggestion
|
||
> if !view.should_hide_when_task_is_done() {
|
||
> self.active_view = Some(view);
|
||
> }
|
||
> self.status_view_active = false;
|
||
> ```
|
||
|
||
- Created: 2025-08-05 01:18:51 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252887221
|
||
|
||
```diff
|
||
@@ -281,15 +335,84 @@ 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<ratatui::text::Line<'static>>,
|
||
```
|
||
|
||
> `use ratatui::text::Line` up top?
|
||
>
|
||
> ```suggestion
|
||
> rows: Vec<Line<'static>>,
|
||
> ```
|
||
|
||
- Created: 2025-08-05 01:20:43 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252888903
|
||
|
||
```diff
|
||
@@ -281,15 +335,84 @@ 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<ratatui::text::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.
|
||
+ 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(ov) = &self.active_view {
|
||
- ov.render(area, buf);
|
||
- } else {
|
||
- (&self.composer).render_ref(area, buf);
|
||
+ if y_offset < area.height {
|
||
```
|
||
|
||
> What is `ov` supposed to stand for? I haven't noted it across the PR, but we generally avoid single character variable names in this codebase.
|
||
|
||
- Created: 2025-08-05 01:22:59 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252891013
|
||
|
||
```diff
|
||
@@ -324,4 +450,211 @@ 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!(
|
||
```
|
||
|
||
> Can we not `assert_eq!()`? What is the exact contents here?
|
||
|
||
### codex-rs/tui/src/chatwidget.rs
|
||
|
||
- Created: 2025-08-05 01:30:32 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252897663
|
||
|
||
```diff
|
||
@@ -235,57 +251,46 @@ 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));
|
||
- }
|
||
+ // 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.
|
||
+ let _ = message; // Already streamed via deltas in most providers.
|
||
```
|
||
|
||
> Maybe this above as a different way to ignore `message`:
|
||
>
|
||
> ```
|
||
> AgentMessageEvent {
|
||
> // Already streamed via deltas in most providers.
|
||
> message: _
|
||
> }
|
||
> ```
|
||
|
||
- Created: 2025-08-05 01:31:24 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252898479
|
||
|
||
```diff
|
||
@@ -235,57 +251,46 @@ 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));
|
||
- }
|
||
+ // 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.
|
||
+ let _ = message; // Already streamed via deltas in most providers.
|
||
+ 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));
|
||
- }
|
||
+ // Final reasoning: commit remaining rows and close with a blank.
|
||
+ let _ = text; // Deltas carried the content; finalize below.
|
||
```
|
||
|
||
> Same as above.
|
||
|
||
- Created: 2025-08-05 01:34:28 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252901037
|
||
|
||
```diff
|
||
@@ -515,6 +518,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(String::new()));
|
||
```
|
||
|
||
> Does `& 'static str` work instead of `String`?
|
||
>
|
||
> ```suggestion
|
||
> lines.push(ratatui::text::Line::from(""));
|
||
> ```
|
||
|
||
### codex-rs/tui/src/history_cell.rs
|
||
|
||
- Created: 2025-08-05 01:34:56 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252901872
|
||
|
||
```diff
|
||
@@ -231,27 +223,7 @@ 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),
|
||
- }
|
||
- }
|
||
+ // Removed unused new_agent_message and new_agent_reasoning constructors.
|
||
```
|
||
|
||
> delete comment?
|
||
|
||
### codex-rs/tui/src/insert_history.rs
|
||
|
||
- Created: 2025-08-05 01:35:27 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252902949
|
||
|
||
```diff
|
||
@@ -14,14 +14,27 @@ 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;
|
||
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();
|
||
```
|
||
|
||
> Does doing the write directly (not through Ratatui) cause any issues?
|
||
|
||
- Created: 2025-08-05 01:35:56 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252903918
|
||
|
||
```diff
|
||
@@ -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();
|
||
```
|
||
|
||
> Oh, I guess we were already doing this?
|
||
|
||
### codex-rs/tui/src/lib.rs
|
||
|
||
- Created: 2025-08-05 01:37:12 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252907180
|
||
|
||
```diff
|
||
@@ -25,13 +25,20 @@ mod bottom_pane;
|
||
mod chatwidget;
|
||
mod citation_regex;
|
||
mod cli;
|
||
+#[cfg(feature = "vt100-tests")]
|
||
```
|
||
|
||
> Is this less work to just always make it `pub`? This is only within our own workspace, anyway.
|
||
|
||
### codex-rs/tui/src/live_wrap.rs
|
||
|
||
- Created: 2025-08-05 01:38:09 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252908829
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+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 `s` whose visible width is at most `max_cols`.
|
||
```
|
||
|
||
> Why not name it `prefix` instead of `s`?
|
||
|
||
- Created: 2025-08-05 01:39:05 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252909570
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+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 `s` whose visible width is at most `max_cols`.
|
||
+/// Returns (prefix, suffix, prefix_width).
|
||
+pub fn take_prefix_by_width(s: &str, max_cols: usize) -> (String, &str, usize) {
|
||
+ if max_cols == 0 || s.is_empty() {
|
||
+ return (String::new(), s, 0);
|
||
+ }
|
||
+ let mut cols = 0usize;
|
||
+ let mut end_idx = 0usize;
|
||
+ for (i, ch) in s.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 = s[..end_idx].to_string();
|
||
+ let suffix = &s[end_idx..];
|
||
+ (prefix, suffix, cols)
|
||
+}
|
||
+
|
||
+#[cfg(test)]
|
||
+mod tests {
|
||
+ use super::*;
|
||
+
|
||
+ #[test]
|
||
+ fn rows_do_not_exceed_width_ascii() {
|
||
+ let mut rb = RowBuilder::new(10);
|
||
+ rb.push_fragment("hello world this is a test");
|
||
+ let rows = rb.rows();
|
||
+ assert!(!rows.is_empty());
|
||
```
|
||
|
||
> Again, please just `assert_eq!()` throughout. I would also make liberal use of `use pretty_assertions::assert_eq;` in tests.
|
||
|
||
### codex-rs/tui/src/markdown.rs
|
||
|
||
- Created: 2025-08-05 01:39:31 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252909911
|
||
|
||
```diff
|
||
@@ -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)]
|
||
```
|
||
|
||
> Is this used in an upcoming PR?
|
||
|
||
- Created: 2025-08-05 01:39:36 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252909971
|
||
|
||
```diff
|
||
@@ -60,6 +61,7 @@ fn append_markdown_with_opener_and_cwd(
|
||
/// ```text
|
||
/// <scheme>://file<ABS_PATH>:<LINE>
|
||
/// ```
|
||
+#[allow(dead_code)]
|
||
```
|
||
|
||
> Same question.
|
||
|
||
### codex-rs/tui/tests/vt100_history.rs
|
||
|
||
- Created: 2025-08-04 21:11:49 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252589276
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
+///
|
||
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
|
||
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
|
||
+/// required). It feeds the bytes into a vt100 parser and asserts that the
|
||
+/// inserted lines are visible near the bottom of the screen.
|
||
+#[test]
|
||
+fn hist_001_basic_insertion_no_wrap() {
|
||
+ // Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||
+ 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}"),
|
||
```
|
||
|
||
> FYI, I would favor putting this at the top of an integration test:
|
||
>
|
||
> ```
|
||
> #![expect(clippy::expect_used)]
|
||
> ```
|
||
>
|
||
> and then use `expect()` liberally with an expression that evaluates to `Result` so you don't have to do a `match` across multiple lines.
|
||
|
||
- Created: 2025-08-04 21:12:31 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252590363
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
+///
|
||
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
|
||
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
|
||
+/// required). It feeds the bytes into a vt100 parser and asserts that the
|
||
+/// inserted lines are visible near the bottom of the screen.
|
||
+#[test]
|
||
+fn hist_001_basic_insertion_no_wrap() {
|
||
+ // Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||
+ 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}"),
|
||
+ };
|
||
+
|
||
+ // Place the viewport at the bottom row
|
||
+ let area = Rect::new(0, 5, 20, 1);
|
||
+ term.set_viewport_area(area);
|
||
+
|
||
+ let lines = vec![Line::from("first"), Line::from("second")];
|
||
+ let mut buf: Vec<u8> = Vec::new();
|
||
+
|
||
+ codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||
+
|
||
+ // Feed captured bytes into vt100 emulator
|
||
+ let mut parser = vt100::Parser::new(6, 20, 0);
|
||
+ parser.process(&buf);
|
||
+ let screen = parser.screen();
|
||
+
|
||
+ // Gather visible rows as strings
|
||
```
|
||
|
||
> Seems like a common operation for these tests that should be a helper function?
|
||
|
||
- Created: 2025-08-04 21:16:32 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252595880
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
+///
|
||
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
|
||
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
|
||
+/// required). It feeds the bytes into a vt100 parser and asserts that the
|
||
+/// inserted lines are visible near the bottom of the screen.
|
||
+#[test]
|
||
+fn hist_001_basic_insertion_no_wrap() {
|
||
+ // Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||
+ 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}"),
|
||
+ };
|
||
+
|
||
+ // Place the viewport at the bottom row
|
||
+ let area = Rect::new(0, 5, 20, 1);
|
||
+ term.set_viewport_area(area);
|
||
+
|
||
+ let lines = vec![Line::from("first"), Line::from("second")];
|
||
+ let mut buf: Vec<u8> = Vec::new();
|
||
+
|
||
+ codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||
+
|
||
+ // Feed captured bytes into vt100 emulator
|
||
+ let mut parser = vt100::Parser::new(6, 20, 0);
|
||
+ parser.process(&buf);
|
||
+ let screen = parser.screen();
|
||
+
|
||
+ // Gather visible rows as strings
|
||
+ let mut rows: Vec<String> = Vec::new();
|
||
+ for row in 0..6 {
|
||
+ let mut s = String::new();
|
||
+ for col in 0..20 {
|
||
+ if let Some(cell) = screen.cell(row, col) {
|
||
+ let cont = cell.contents();
|
||
+ if let Some(ch) = cont.chars().next() {
|
||
+ s.push(ch);
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ }
|
||
+ rows.push(s);
|
||
+ }
|
||
+
|
||
+ // The inserted lines should appear somewhere above the viewport; in this
|
||
+ // simple case, they will occupy the two rows immediately above the final
|
||
+ // row of the scroll region.
|
||
+ let joined = rows.join("\n");
|
||
```
|
||
|
||
> Should this be true:
|
||
>
|
||
> ```
|
||
> assert_eq!(vec!["", "", "", "", "first", "second"], rows);
|
||
> ```
|
||
|
||
- Created: 2025-08-04 21:16:58 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252596506
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
+///
|
||
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
|
||
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
|
||
+/// required). It feeds the bytes into a vt100 parser and asserts that the
|
||
+/// inserted lines are visible near the bottom of the screen.
|
||
+#[test]
|
||
+fn hist_001_basic_insertion_no_wrap() {
|
||
+ // Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||
+ 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}"),
|
||
+ };
|
||
+
|
||
+ // Place the viewport at the bottom row
|
||
+ let area = Rect::new(0, 5, 20, 1);
|
||
+ term.set_viewport_area(area);
|
||
+
|
||
+ let lines = vec![Line::from("first"), Line::from("second")];
|
||
+ let mut buf: Vec<u8> = Vec::new();
|
||
+
|
||
+ codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||
+
|
||
+ // Feed captured bytes into vt100 emulator
|
||
+ let mut parser = vt100::Parser::new(6, 20, 0);
|
||
+ parser.process(&buf);
|
||
+ let screen = parser.screen();
|
||
+
|
||
+ // Gather visible rows as strings
|
||
+ let mut rows: Vec<String> = Vec::new();
|
||
+ for row in 0..6 {
|
||
+ let mut s = String::new();
|
||
+ for col in 0..20 {
|
||
+ if let Some(cell) = screen.cell(row, col) {
|
||
+ let cont = cell.contents();
|
||
+ if let Some(ch) = cont.chars().next() {
|
||
+ s.push(ch);
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ }
|
||
+ rows.push(s);
|
||
+ }
|
||
+
|
||
+ // The inserted lines should appear somewhere above the viewport; in this
|
||
+ // simple case, they will occupy the two rows immediately above the final
|
||
+ // row of the scroll region.
|
||
+ let joined = rows.join("\n");
|
||
+ assert!(
|
||
```
|
||
|
||
> If you want to get fancy, chat suggested:
|
||
>
|
||
> ```rust
|
||
> 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)+
|
||
> );
|
||
> };
|
||
> }
|
||
> ```
|
||
|
||
- Created: 2025-08-04 21:17:28 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252597237
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
```
|
||
|
||
> What's the significance of the id `HIST-001`?
|
||
|
||
- Created: 2025-08-04 21:22:58 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252605645
|
||
|
||
```diff
|
||
@@ -0,0 +1,272 @@
|
||
+#![cfg(feature = "vt100-tests")]
|
||
+
|
||
+use ratatui::backend::TestBackend;
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::style::Color;
|
||
+use ratatui::style::Style;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+
|
||
+/// HIST-001: Basic insertion at bottom, no wrap.
|
||
+///
|
||
+/// This test captures the ANSI bytes produced by `insert_history_lines_to_writer`
|
||
+/// when the viewport is at the bottom of the screen (so no pre-scroll is
|
||
+/// required). It feeds the bytes into a vt100 parser and asserts that the
|
||
+/// inserted lines are visible near the bottom of the screen.
|
||
+#[test]
|
||
+fn hist_001_basic_insertion_no_wrap() {
|
||
+ // Screen of 20x6; viewport is the last row (height=1 at y=5)
|
||
+ 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}"),
|
||
+ };
|
||
+
|
||
+ // Place the viewport at the bottom row
|
||
+ let area = Rect::new(0, 5, 20, 1);
|
||
+ term.set_viewport_area(area);
|
||
+
|
||
+ let lines = vec![Line::from("first"), Line::from("second")];
|
||
+ let mut buf: Vec<u8> = Vec::new();
|
||
+
|
||
+ codex_tui::insert_history::insert_history_lines_to_writer(&mut term, &mut buf, lines);
|
||
+
|
||
+ // Feed captured bytes into vt100 emulator
|
||
+ let mut parser = vt100::Parser::new(6, 20, 0);
|
||
+ parser.process(&buf);
|
||
+ let screen = parser.screen();
|
||
+
|
||
+ // Gather visible rows as strings
|
||
+ let mut rows: Vec<String> = Vec::new();
|
||
+ for row in 0..6 {
|
||
+ let mut s = String::new();
|
||
+ for col in 0..20 {
|
||
+ if let Some(cell) = screen.cell(row, col) {
|
||
+ let cont = cell.contents();
|
||
+ if let Some(ch) = cont.chars().next() {
|
||
+ s.push(ch);
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ } else {
|
||
+ s.push(' ');
|
||
+ }
|
||
+ }
|
||
+ rows.push(s);
|
||
+ }
|
||
+
|
||
+ // The inserted lines should appear somewhere above the viewport; in this
|
||
+ // simple case, they will occupy the two rows immediately above the final
|
||
+ // row of the scroll region.
|
||
+ let joined = rows.join("\n");
|
||
+ assert!(
|
||
+ joined.contains("first"),
|
||
+ "screen did not contain 'first'\n{joined}"
|
||
+ );
|
||
+ assert!(
|
||
+ joined.contains("second"),
|
||
+ "screen did not contain 'second'\n{joined}"
|
||
+ );
|
||
+}
|
||
+
|
||
+/// HIST-002: Long token wraps across rows within the scroll region.
|
||
+#[test]
|
||
+fn hist_002_long_token_wraps() {
|
||
+ 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);
|
||
```
|
||
|
||
> Much of this setup seems repeated across tests, so you may want to create a setup function that returns something like:
|
||
>
|
||
> ```rust
|
||
> struct TestScenario {
|
||
> backend: TestBackend,
|
||
> term: Terminal,
|
||
> }
|
||
> ```
|
||
>
|
||
> and you could add methods like `get_screen_contents(buf: &Vec<u8>) -> String` perhaps?
|
||
|
||
- Created: 2025-08-05 01:42:00 UTC | Link: https://github.com/openai/codex/pull/1810#discussion_r2252912425
|
||
|
||
```diff
|
||
@@ -0,0 +1,231 @@
|
||
+#![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 mut parser = vt100::Parser::new(6, 20, 0);
|
||
+ parser.process(&buf);
|
||
+ let screen = parser.screen();
|
||
+
|
||
+ // Reconstruct string by concatenating non-space cells; ensure all emojis and CJK are present.
|
||
+ let mut reconstructed = String::new();
|
||
```
|
||
|
||
> Why not `screen_rows_from_bytes` |