feat(tui): codeblock background + syntax highlighting\n\n- Add code block background styling and improved syntax highlighting\n- Update , , streaming controller\n- Adjust colors/theme and diff rendering for consistency\n- Core: execution event plumbing tweaks\n- Docs: add markdown showcase

This commit is contained in:
James Peter
2025-08-28 16:57:54 +10:00
parent 7ae25a9490
commit bb29c300a3
22 changed files with 2674 additions and 281 deletions
+169 -2
View File
@@ -848,6 +848,7 @@ dependencies = [
"eventsource-stream",
"fs2",
"futures",
"htmd",
"image 0.25.6",
"img_hash",
"landlock",
@@ -882,6 +883,7 @@ dependencies = [
"tracing",
"tree-sitter",
"tree-sitter-bash",
"url",
"uuid",
"walkdir",
"which",
@@ -1114,6 +1116,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"syntect",
"tempfile",
"textwrap 0.16.2",
"tokio",
@@ -1916,6 +1919,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
@@ -2200,6 +2213,38 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "htmd"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be5de9d3e28558a12296e07d6cd7789c879417fbb3f07bdc77f1dd1df74ccbd8"
dependencies = [
"html-escape",
"html5ever",
"markup5ever_rcdom",
]
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]]
name = "html5ever"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]]
name = "http"
version = "1.3.1"
@@ -2958,12 +3003,52 @@ dependencies = [
"url",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e4cd8c02f18a011991a039855480c64d74291c5792fcc160d55d77dc4de4a39"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "markup5ever_rcdom"
version = "0.5.3-unofficial"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "853740b93240b82f68a23d8b296b2d19fc81521c298fcae44bf34bed6e445f00"
dependencies = [
"html5ever",
"markup5ever",
"tendril",
"xml5ever",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "matchers"
version = "0.1.0"
@@ -3492,6 +3577,35 @@ dependencies = [
"indexmap 2.11.0",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -4848,6 +4962,19 @@ dependencies = [
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
@@ -5013,6 +5140,17 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "term"
version = "0.7.0"
@@ -5589,9 +5727,9 @@ dependencies = [
[[package]]
name = "tui-markdown"
version = "0.3.5"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d10648c25931bfaaf5334ff4e7dc5f3d830e0c50d7b0119b1d5cfe771f540536"
checksum = "cf47229087fc49650d095a910a56aaf10c1c64181d042d2c2ba46fc3746ff534"
dependencies = [
"ansi-to-tui",
"itertools 0.14.0",
@@ -5710,6 +5848,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -5937,6 +6081,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webbrowser"
version = "1.0.5"
@@ -6529,6 +6685,17 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "xml5ever"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a91563ba5a5ab749488164063f1317e327ca1daa80f00e5bd1e670ad0d78154"
dependencies = [
"log",
"mac",
"markup5ever",
]
[[package]]
name = "yaml-rust"
version = "0.4.5"
+2
View File
@@ -40,6 +40,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
htmd = "0.2.2"
url = "2"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
+296 -3
View File
@@ -534,6 +534,24 @@ struct State {
/// model for a given batch when using `agent_wait` without `return_all`.
/// This enables sequential waiting behavior across multiple calls.
seen_completed_agents_by_batch: HashMap<String, HashSet<String>>,
/// Scratchpad that buffers streamed items/deltas for the current HTTP attempt
/// so we can seed retries without losing progress.
turn_scratchpad: Option<TurnScratchpad>,
}
/// Buffers partial turn progress produced during a single HTTP streaming attempt.
/// This is not recorded to persistent history. It is only used to seed retries
/// when the SSE stream disconnects mid‑turn.
#[derive(Default, Clone, Debug)]
struct TurnScratchpad {
/// Output items that reached `response.output_item.done` during this attempt
items: Vec<ResponseItem>,
/// Tool outputs we produced locally in reaction to output items
responses: Vec<ResponseInputItem>,
/// Last assistant text fragment received via deltas (not yet finalized)
partial_assistant_text: String,
/// Last reasoning summary fragment received via deltas (not yet finalized)
partial_reasoning_summary: String,
}
/// Context for an initialized model agent
@@ -607,6 +625,70 @@ impl Session {
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
}
// ────────────────────────────
// Scratchpad helpers
// ────────────────────────────
fn begin_attempt_scratchpad(&self) {
let mut state = self.state.lock().unwrap();
state.turn_scratchpad = Some(TurnScratchpad::default());
}
fn scratchpad_push(&self, item: &ResponseItem, response: &Option<ResponseInputItem>) {
let mut state = self.state.lock().unwrap();
if let Some(sp) = &mut state.turn_scratchpad {
sp.items.push(item.clone());
if let Some(r) = response {
sp.responses.push(r.clone());
}
}
}
fn scratchpad_add_text_delta(&self, delta: &str) {
let mut state = self.state.lock().unwrap();
if let Some(sp) = &mut state.turn_scratchpad {
sp.partial_assistant_text.push_str(delta);
// Keep memory bounded (ensure UTF-8 char boundary when trimming)
if sp.partial_assistant_text.len() > 4000 {
let mut drain_up_to = sp.partial_assistant_text.len() - 4000;
while !sp.partial_assistant_text.is_char_boundary(drain_up_to) {
drain_up_to -= 1;
}
sp.partial_assistant_text.drain(..drain_up_to);
}
}
}
fn scratchpad_add_reasoning_delta(&self, delta: &str) {
let mut state = self.state.lock().unwrap();
if let Some(sp) = &mut state.turn_scratchpad {
sp.partial_reasoning_summary.push_str(delta);
if sp.partial_reasoning_summary.len() > 4000 {
let mut drain_up_to = sp.partial_reasoning_summary.len() - 4000;
while !sp.partial_reasoning_summary.is_char_boundary(drain_up_to) {
drain_up_to -= 1;
}
sp.partial_reasoning_summary.drain(..drain_up_to);
}
}
}
fn scratchpad_clear_partial_message(&self) {
let mut state = self.state.lock().unwrap();
if let Some(sp) = &mut state.turn_scratchpad {
sp.partial_assistant_text.clear();
}
}
fn take_scratchpad(&self) -> Option<TurnScratchpad> {
let mut state = self.state.lock().unwrap();
state.turn_scratchpad.take()
}
fn clear_scratchpad(&self) {
let mut state = self.state.lock().unwrap();
state.turn_scratchpad = None;
}
}
impl Session {
@@ -1182,6 +1264,7 @@ impl Session {
let mut state = self.state.lock().unwrap();
state.pending_approvals.clear();
state.pending_input.clear();
state.turn_scratchpad = None;
if let Some(agent) = state.current_agent.take() {
agent.abort(TurnAbortReason::Interrupted);
}
@@ -1972,12 +2055,15 @@ async fn run_turn(
);
let mut retries = 0;
// Attempt input starts as the provided input, and may be augmented with
// items from a previous dropped stream attempt so we don't lose progress.
let mut attempt_input: Vec<ResponseItem> = input.clone();
loop {
// Build status items (screenshots, system status) fresh for each attempt
let status_items = build_turn_status_items(sess).await;
let prompt = Prompt {
input: input.clone(),
input: attempt_input.clone(),
user_instructions: sess.user_instructions.clone(),
store: !sess.disable_response_storage,
tools: tools.clone(),
@@ -1991,6 +2077,9 @@ async fn run_turn(
status_items, // Include status items with this request
};
// Start a new scratchpad for this HTTP attempt
sess.begin_attempt_scratchpad();
match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt).await {
Ok(output) => {
// Record status items to conversation history after successful turn
@@ -1998,6 +2087,8 @@ async fn run_turn(
if !prompt.status_items.is_empty() {
sess.record_conversation_items(&prompt.status_items).await;
}
// Commit successful attempt – scratchpad is no longer needed.
sess.clear_scratchpad();
return Ok(output);
}
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
@@ -2028,6 +2119,67 @@ async fn run_turn(
),
)
.await;
// Pull any partial progress from this attempt and append to
// the next request's input so we do not lose tool progress
// or already-finalized items.
if let Some(sp) = sess.take_scratchpad() {
// Build a set of call_ids we have already included to avoid duplicate calls
let mut seen_calls: std::collections::HashSet<String> = attempt_input
.iter()
.filter_map(|ri| match ri {
ResponseItem::FunctionCall { call_id, .. } => Some(call_id.clone()),
ResponseItem::LocalShellCall { call_id: Some(c), .. } => Some(c.clone()),
_ => None,
})
.collect();
// Append finalized function/local shell calls from the dropped attempt
for item in sp.items {
match &item {
ResponseItem::FunctionCall { call_id, .. } => {
if seen_calls.insert(call_id.clone()) {
attempt_input.push(item.clone());
}
}
ResponseItem::LocalShellCall { call_id: Some(c), .. } => {
if seen_calls.insert(c.clone()) {
attempt_input.push(item.clone());
}
}
_ => {
// Avoid injecting assistant/Reasoning messages on retry to reduce duplication.
}
}
}
// Append tool outputs produced during the dropped attempt
for resp in sp.responses {
attempt_input.push(ResponseItem::from(resp));
}
// If we have partial deltas, include a short ephemeral hint so the model can resume.
if !sp.partial_assistant_text.is_empty() || !sp.partial_reasoning_summary.is_empty() {
use codex_protocol::models::ContentItem;
let mut hint = String::from(
"[EPHEMERAL:RETRY_HINT]\nPrevious attempt aborted mid-stream. Continue without repeating.\n",
);
if !sp.partial_reasoning_summary.is_empty() {
let preview = &sp.partial_reasoning_summary;
let preview = if preview.len() > 800 { &preview[preview.len()-800..] } else { preview };
hint.push_str(&format!("Last reasoning summary fragment:\n{}\n\n", preview));
}
if !sp.partial_assistant_text.is_empty() {
let preview = &sp.partial_assistant_text;
let preview = if preview.len() > 800 { &preview[preview.len()-800..] } else { preview };
hint.push_str(&format!("Last assistant text fragment:\n{}\n", preview));
}
attempt_input.push(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: hint }],
});
}
}
tokio::time::sleep(delay).await;
} else {
@@ -2141,6 +2293,14 @@ async fn try_run_turn(
let response =
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone()).await?;
// Save into scratchpad so we can seed a retry if the stream drops later.
sess.scratchpad_push(&item, &response);
// If this was a finalized assistant message, clear partial text buffer
if let ResponseItem::Message { .. } = &item {
sess.scratchpad_clear_partial_message();
}
output.push(ProcessedResponseItem { item, response });
}
ResponseEvent::WebSearchCallBegin { call_id, query } => {
@@ -2197,18 +2357,26 @@ async fn try_run_turn(
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
let event = Event {
id: event_id,
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: delta.clone() }),
};
sess.tx_event.send(event).await.ok();
// Track partial assistant text in the scratchpad to help resume on retry.
// Only accumulate when we have an item context or a single active stream.
// We deliberately do not scope by item_id to keep implementation simple.
sess.scratchpad_add_text_delta(&delta);
}
ResponseEvent::ReasoningSummaryDelta { delta, item_id } => {
// Use the item_id if present, otherwise fall back to sub_id
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
let event = Event {
id: event_id,
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }),
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }),
};
sess.tx_event.send(event).await.ok();
// Buffer reasoning summary so we can include a hint on retry.
sess.scratchpad_add_reasoning_delta(&delta);
}
ResponseEvent::ReasoningSummaryPartAdded => {
let event = Event {
@@ -2230,6 +2398,7 @@ async fn try_run_turn(
sess.tx_event.send(event).await.ok();
}
}
// Note: ReasoningSummaryPartAdded handled above without scratchpad mutation.
}
}
}
@@ -2543,6 +2712,7 @@ async fn handle_function_call(
"browser_inspect" => handle_browser_inspect(sess, arguments, sub_id, call_id).await,
"browser_cdp" => handle_browser_cdp(sess, arguments, sub_id, call_id).await,
"browser_cleanup" => handle_browser_cleanup(sess, sub_id, call_id).await,
"web_fetch" => handle_web_fetch(sess, arguments, sub_id, call_id).await,
_ => {
match sess.mcp_connection_manager.parse_tool_name(&name) {
Some((server, tool_name)) => {
@@ -2603,6 +2773,129 @@ async fn handle_browser_cleanup(
).await
}
async fn handle_web_fetch(
sess: &Session,
arguments: String,
sub_id: String,
call_id: String,
) -> ResponseInputItem {
// Include raw params in begin event for observability
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
let call_id_clone = call_id.clone();
execute_custom_tool(
sess,
&sub_id,
call_id,
"web_fetch".to_string(),
params_for_event,
|| async move {
#[derive(serde::Deserialize)]
struct WebFetchParams {
url: String,
#[serde(default)]
timeout_ms: Option<u64>,
}
let parsed: Result<WebFetchParams, _> = serde_json::from_str(&arguments_clone);
let params = match parsed {
Ok(p) => p,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid web_fetch arguments: {e}"),
success: None,
},
};
}
};
let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(15000));
let user_agent = crate::user_agent::get_codex_user_agent(Some("web_fetch"));
let client = match reqwest::Client::builder()
.user_agent(user_agent)
.timeout(timeout)
.build()
{
Ok(c) => c,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to build HTTP client: {e}"),
success: Some(false),
},
};
}
};
let resp = match client.get(&params.url).send().await {
Ok(r) => r,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Request failed: {e}"),
success: Some(false),
},
};
}
};
let status = resp.status();
let text = match resp.text().await {
Ok(t) => t,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Failed to read response body: {e}"),
success: Some(false),
},
};
}
};
// Configure htmd with conservative, GFM‑like defaults
let options = htmd::options::Options {
heading_style: htmd::options::HeadingStyle::Atx,
code_block_style: htmd::options::CodeBlockStyle::Fenced,
link_style: htmd::options::LinkStyle::Inlined,
..Default::default()
};
let converter = htmd::HtmlToMarkdown::builder().options(options).build();
let markdown = match converter.convert(&text) {
Ok(m) => m,
Err(e) => {
return ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Markdown conversion failed: {e}"),
success: Some(false),
},
};
}
};
let body = serde_json::json!({
"url": params.url,
"status": status.as_u16(),
"markdown": markdown,
});
ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: body.to_string(),
success: Some(status.is_success()),
},
}
},
).await
}
fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams {
ExecParams {
command: params.command,
+37
View File
@@ -578,6 +578,9 @@ pub(crate) fn get_openai_tools(
tools.push(OpenAiTool::WebSearch {});
}
// Always include web_fetch tool
tools.push(create_web_fetch_tool());
if let Some(mcp_tools) = mcp_tools {
// Ensure deterministic ordering to maximize prompt cache hits.
// HashMap iteration order is non-deterministic, so sort by fully-qualified tool name.
@@ -660,6 +663,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
],
);
}
@@ -693,6 +697,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
],
);
}
@@ -762,6 +767,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
"test_server/do_something_cool",
],
);
@@ -875,6 +881,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
"dash/search",
],
);
@@ -948,6 +955,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
"dash/paginate",
],
);
@@ -1018,6 +1026,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
"dash/tags",
],
);
@@ -1091,6 +1100,7 @@ mod tests {
"agent_wait",
"agent_list",
"web_search",
"web_fetch",
"dash/value",
],
);
@@ -1460,3 +1470,30 @@ fn create_browser_cdp_tool() -> OpenAiTool {
},
})
}
fn create_web_fetch_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"url".to_string(),
JsonSchema::String {
description: Some("The URL to fetch (e.g., https://example.com)".to_string()),
},
);
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("Optional timeout in milliseconds for the HTTP request".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "web_fetch".to_string(),
description: "Fetches a webpage over HTTP(S) and converts the HTML to Markdown using htmd.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
},
})
}
+3 -1
View File
@@ -52,6 +52,7 @@ ratatui = { version = "0.29.0", features = [
"scrolling-regions",
"unstable-rendered-line-info",
"unstable-widget-ref",
"underline-color",
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
@@ -76,11 +77,12 @@ tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.14.0"
tui-markdown = "0.3.3"
tui-markdown = "=0.3.3"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
url = "2"
uuid = "1"
syntect = "5"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+5
View File
@@ -279,6 +279,10 @@ impl App<'_> {
AppState::Chat { widget } => widget.insert_history_lines_with_kind(kind, lines),
AppState::Onboarding { .. } => {}
},
AppEvent::InsertFinalAnswer { lines, source } => match &mut self.app_state {
AppState::Chat { widget } => widget.insert_final_answer(lines, source),
AppState::Onboarding { .. } => {}
},
AppEvent::RequestRedraw => {
self.schedule_redraw();
}
@@ -455,6 +459,7 @@ impl App<'_> {
widget.toggle_diffs_popup();
}
}
// (Ctrl+Y disabled): Previously cycled syntax themes; now intentionally no-op
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
+2
View File
@@ -86,6 +86,8 @@ pub(crate) enum AppEvent {
InsertHistory(Vec<Line<'static>>),
InsertHistoryWithKind { kind: StreamKind, lines: Vec<Line<'static>> },
/// Finalized assistant answer with raw markdown for re-rendering under theme changes.
InsertFinalAnswer { lines: Vec<Line<'static>>, source: String },
#[allow(dead_code)]
StartCommitAnimation,
+7 -1
View File
@@ -690,9 +690,15 @@ impl TextArea {
};
if needs_recalc {
let mut lines: Vec<Range<usize>> = Vec::new();
// Wrap one column earlier to avoid the caret visually overlapping
// the right border before a newline is committed. This makes the
// input feel more natural: when the cursor reaches the last
// visible column, the next character starts on the next line.
let effective_width = width.saturating_sub(1);
for line in textwrap::wrap(
&self.text,
Options::new(width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
Options::new(effective_width as usize)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
)
.iter()
{
+121 -43
View File
@@ -544,8 +544,16 @@ impl ChatWidget<'_> {
_ => {}
}
}
// Skip internal system status messages
// Show internal system status messages (rendered with markdown) so
// code blocks and formatting are consistent with assistant output.
if text.contains("== System Status ==") {
use ratatui::text::Line as RLine;
let mut lines: Vec<RLine<'static>> = Vec::new();
crate::markdown::append_markdown(&text, &mut lines, &self.config);
self.add_to_history(crate::history_cell::PlainHistoryCell {
lines,
kind: crate::history_cell::HistoryCellType::Notice,
});
return;
}
if role == "user" {
@@ -1059,23 +1067,35 @@ impl ChatWidget<'_> {
// Update the most recent patch cell header from "Updating..." to "Updated"
// without creating a new history section.
if let Some(last) = self.history_cells.iter_mut().rev().find(|c| {
matches!(c.kind(), crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::ApplyBegin } | crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::Proposed })
matches!(
c.kind(),
crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::ApplyBegin }
| crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::Proposed }
)
}) {
// Downcast to PlainHistoryCell to mutate its lines
// Case 1: Patch summary cell – update title/kind in-place
if let Some(summary) = last.as_any_mut().downcast_mut::<history_cell::PatchSummaryCell>() {
summary.title = "Updated".to_string();
summary.kind = history_cell::PatchKind::ApplySuccess;
self.request_redraw();
return;
}
// Case 2: Plain history cell fallback – adjust first span and kind
if let Some(plain) = last.as_any_mut().downcast_mut::<history_cell::PlainHistoryCell>() {
if let Some(first_line) = plain.lines.first_mut() {
// Replace the title span content with "Updated" and apply success style
if let Some(first_span) = first_line.spans.get_mut(0) {
first_span.content = "Updated".into();
first_span.style = Style::default().fg(crate::colors::success()).add_modifier(Modifier::BOLD);
first_span.style = Style::default()
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD);
}
}
// Update the kind so gutter color reflects success
plain.kind = history_cell::HistoryCellType::Patch { kind: history_cell::PatchKind::ApplySuccess };
plain.kind = history_cell::HistoryCellType::Patch {
kind: history_cell::PatchKind::ApplySuccess,
};
self.request_redraw();
return;
}
// Don't surface stdout on success – keep UI concise
self.request_redraw();
return;
}
// Fallback: if no prior cell found, do nothing (avoid extra section)
} else {
@@ -1323,26 +1343,7 @@ impl ChatWidget<'_> {
// add AnimatedWelcomeCell silently
history_cells.push(welcome_cell);
// Startup hint: show /resume tip if a recent session exists for this cwd (within 72h)
{
let cwd = config.cwd.clone();
let codex_home = config.codex_home.clone();
let recent = crate::resume::discovery::list_sessions_for_cwd(&cwd, &codex_home).into_iter().next();
if let Some(rec) = recent {
if let Some(ts) = rec.modified_ts.as_ref() {
use chrono::{DateTime, Utc};
if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
let now = Utc::now();
let age = now.signed_duration_since(dt.with_timezone(&Utc));
if age.num_hours() <= 72 {
history_cells.push(Box::new(history_cell::new_background_event(
"Tip: /resume to continue your last session".to_string(),
)));
}
}
}
}
}
// Removed startup tip for /resume; /resume is now shown under "Popular commands".
// Initialize image protocol for rendering screenshots
@@ -3510,6 +3511,15 @@ impl ChatWidget<'_> {
.show_theme_selection(self.config.tui.theme.name);
}
// Ctrl+Y syntax cycling disabled intentionally.
/// Show a brief debug notice in the footer.
#[allow(dead_code)]
pub(crate) fn debug_notice(&mut self, text: String) {
self.bottom_pane.flash_footer_notice(text);
self.request_redraw();
}
pub(crate) fn set_theme(&mut self, new_theme: codex_core::config_types::ThemeName) {
// Update the config
self.config.tui.theme.name = new_theme;
@@ -3559,6 +3569,9 @@ impl ChatWidget<'_> {
history_cell::retint_lines_in_place(&mut reason.lines, &old, &new);
} else if let Some(stream) = cell.as_any_mut().downcast_mut::<history_cell::StreamingContentCell>() {
history_cell::retint_lines_in_place(&mut stream.lines, &old, &new);
} else if let Some(assist) = cell.as_any_mut().downcast_mut::<history_cell::AssistantMarkdownCell>() {
// Fully rebuild from raw to apply new theme + syntax highlight
assist.rebuild(&self.config);
}
}
@@ -3751,6 +3764,7 @@ impl ChatWidget<'_> {
kind: StreamKind,
mut lines: Vec<ratatui::text::Line<'static>>,
) {
// No debug logging: we rely on preserving span modifiers end-to-end.
// Insert all lines as a single streaming content cell to preserve spacing
if lines.is_empty() { return; }
@@ -3824,6 +3838,7 @@ impl ChatWidget<'_> {
lines.remove(0);
}
}
// No debug logging in steady-state.
stream_cell.lines.extend(lines);
// Content height changed; clear memoized heights
self.invalidate_height_cache();
@@ -3858,6 +3873,36 @@ impl ChatWidget<'_> {
self.request_redraw();
}
/// Replace the in-progress streaming assistant cell with a final markdown cell that
/// stores raw markdown for future re-rendering.
pub(crate) fn insert_final_answer(
&mut self,
lines: Vec<ratatui::text::Line<'static>>,
source: String,
) {
// Ensure a hidden 'codex' header is present
let has_header = lines.first().map(|l| {
l.spans.iter().map(|s| s.content.as_ref()).collect::<String>().trim().eq_ignore_ascii_case("codex")
}).unwrap_or(false);
if !has_header {
// No need to mutate `lines` further since we rebuild from `source` below.
}
// If the last cell is a StreamingContentCell, replace it; else append
if let Some(last) = self.history_cells.last_mut() {
if last.as_any().downcast_ref::<history_cell::StreamingContentCell>().is_some() {
let cell = history_cell::AssistantMarkdownCell::new(source, &self.config);
*last = Box::new(cell);
self.invalidate_height_cache();
self.autoscroll_if_near_bottom();
self.request_redraw();
return;
}
}
let cell = history_cell::AssistantMarkdownCell::new(source, &self.config);
self.add_to_history(cell);
}
pub(crate) fn toggle_reasoning_visibility(&mut self) {
// Track whether any reasoning cells are found and their new state
let mut has_reasoning_cells = false;
@@ -6328,24 +6373,52 @@ impl WidgetRef for &ChatWidget<'_> {
height: visible_height,
};
// Render gutter symbol
// Paint gutter background. For Assistant, extend the assistant tint under the
// gutter and also one extra column to the left (so the • has color on both sides),
// without changing layout or symbol positions.
let is_assistant = matches!(item.kind(), crate::history_cell::HistoryCellType::Assistant);
let gutter_bg = if is_assistant {
crate::colors::assistant_bg()
} else {
crate::colors::background()
};
let gutter_bg_style = Style::default().bg(gutter_bg);
for y in gutter_area.y..gutter_area.y.saturating_add(gutter_area.height) {
// Gutter’s two columns (symbol + space)
if gutter_area.width > 0 {
buf[(gutter_area.x, y)].set_char(' ').set_style(gutter_bg_style);
}
if gutter_area.width > 1 {
buf[(gutter_area.x + 1, y)].set_char(' ').set_style(gutter_bg_style);
}
// One extra column to the left for Assistant only (no layout change)
if is_assistant && gutter_area.x > 0 {
buf[(gutter_area.x - 1, y)].set_char(' ').set_style(gutter_bg_style);
}
}
// Render gutter symbol if present
if let Some(symbol) = item.gutter_symbol() {
// Choose color based on symbol/type
let color = if symbol == "➤" {
let color = if symbol == "❯" {
// Executed arrow – color reflects exec state
if let Some(exec) = item.as_any().downcast_ref::<crate::history_cell::ExecCell>() {
match &exec.output {
None => crate::colors::primary(), // Running...
None => crate::colors::info(), // Running...
Some(o) if o.exit_code == 0 => crate::colors::text_bright(), // Ran
Some(_) => crate::colors::error(),
}
} else {
crate::colors::primary()
crate::colors::info()
}
} else if symbol == "↯" {
// Patch/Updated arrow color from explicit type
// Patch/Updated arrow color – match the header text color
match item.kind() {
crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::ApplySuccess } => crate::colors::success(),
crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::ApplyBegin } => crate::colors::success(),
crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::Proposed } => crate::colors::primary(),
crate::history_cell::HistoryCellType::Patch { kind: crate::history_cell::PatchKind::ApplyFailure } => crate::colors::error(),
_ => crate::colors::primary(),
}
} else {
@@ -6353,7 +6426,7 @@ impl WidgetRef for &ChatWidget<'_> {
"›" => crate::colors::text(), // user
"⋮" => crate::colors::primary(), // thinking
"•" => crate::colors::text_bright(), // codex/agent
"⚙" => crate::colors::primary(), // tool working
"⚙" => crate::colors::info(), // tool working
"✔" => crate::colors::success(), // tool complete
"✖" => crate::colors::error(), // error
"★" => crate::colors::text_bright(), // notice/popular
@@ -6361,14 +6434,19 @@ impl WidgetRef for &ChatWidget<'_> {
}
};
// Draw the symbol at the top of the visible portion of this cell
// so the gutter icon remains present even when scrolled.
// Draw the symbol aligned with the first visible content row.
// Assistant cells render with 1-row top padding; if not scrolled past it,
// place the dot on the second row to align with the text.
if gutter_area.width >= 2 {
// Choose color based on symbol/type
let symbol_style = Style::default()
.fg(color)
.bg(crate::colors::background());
buf.set_string(gutter_area.x, gutter_area.y, symbol, symbol_style);
let pad_adjust: u16 = match item.kind() {
crate::history_cell::HistoryCellType::Assistant => if skip_top == 0 { 1 } else { 0 },
_ => 0,
};
let symbol_y = gutter_area.y.saturating_add(pad_adjust);
if symbol_y < gutter_area.y.saturating_add(gutter_area.height) {
let symbol_style = Style::default().fg(color).bg(gutter_bg);
buf.set_string(gutter_area.x, symbol_y, symbol, symbol_style);
}
}
}
+46
View File
@@ -1191,3 +1191,49 @@ fn deltas_then_same_final_message_are_rendered_snapshot() {
.collect::<String>();
assert_snapshot!(combined);
}
#[test]
fn streaming_answer_then_finalize_does_not_truncate() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
// Stream an assistant message in chunks, without sending a final AgentMessage.
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Files changed\n".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "- codex-rs/tui/src/markdown.rs: Guard against list markers...\n".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "\nWhat to expect\n".into(),
}),
});
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "- Third-level bullets render correctly now.\n".into(),
}),
});
// Simulate lifecycle location that finalizes active stream (e.g., new event)
chat.finalize_active_stream();
// Drain and combine visible content
let cells = drain_insert_history(&rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert!(combined.contains("Files changed"), "missing header: {combined}");
assert!(combined.contains("What to expect"), "missing section header: {combined}");
assert!(combined.contains("Third-level bullets render correctly now"),
"missing tail content after finalize: {combined}");
}
+77
View File
@@ -69,6 +69,13 @@ pub(crate) fn text_bright() -> Color {
current_theme().text_bright
}
/// Midpoint color between `text` and `text_dim` for secondary list levels.
pub(crate) fn text_mid() -> Color {
let a = current_theme().text;
let b = current_theme().text_dim;
mix_toward(a, b, 0.5)
}
pub(crate) fn info() -> Color {
current_theme().info
}
@@ -121,6 +128,23 @@ fn color_to_rgb(c: Color) -> (u8, u8, u8) {
}
}
fn blend_rgb(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
let inv = 1.0 - t;
let r = (a.0 as f32 * inv + b.0 as f32 * t).round() as u8;
let g = (a.1 as f32 * inv + b.1 as f32 * t).round() as u8;
let bl = (a.2 as f32 * inv + b.2 as f32 * t).round() as u8;
(r, g, bl)
}
/// Blend `from` toward `to` by fraction `t` (0.0..=1.0) in RGB space.
#[allow(dead_code)]
pub(crate) fn mix_toward(from: Color, to: Color, t: f32) -> Color {
let a = color_to_rgb(from);
let b = color_to_rgb(to);
let (r, g, b) = blend_rgb(a, b, t.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
}
fn blend_with_black(rgb: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
// target = bg*(1-alpha) + black*alpha => bg*(1-alpha)
let inv = 1.0 - alpha;
@@ -135,6 +159,10 @@ fn is_light(rgb: (u8, u8, u8)) -> bool {
l >= 0.6
}
fn relative_luminance(rgb: (u8, u8, u8)) -> f32 {
(0.2126 * rgb.0 as f32 + 0.7152 * rgb.1 as f32 + 0.0722 * rgb.2 as f32) / 255.0
}
pub(crate) fn overlay_scrim() -> Color {
let bg = current_theme().background;
let rgb = color_to_rgb(bg);
@@ -143,3 +171,52 @@ pub(crate) fn overlay_scrim() -> Color {
let (r, g, b) = blend_with_black(rgb, alpha);
Color::Rgb(r, g, b)
}
/// Background for assistant messages: theme background moved 5% toward theme info.
pub(crate) fn assistant_bg() -> Color {
let bg = current_theme().background;
let info = current_theme().info;
mix_toward(bg, info, 0.05)
}
/// Background for multiline code blocks rendered in assistant markdown.
///
/// Requirements:
/// - Light themes: use plain white to create a crisp, paper-like block.
/// - Dark themes: use a plain dark background (near-black) for high contrast.
///
/// We intentionally avoid theme tints here to keep code blocks visually
/// distinct and consistent across themes, per request.
pub(crate) fn code_block_bg() -> Color {
let bg = current_theme().background;
let rgb = color_to_rgb(bg);
if is_light(rgb) {
Color::White
} else {
// Plain dark; choose true black for consistent appearance.
Color::Black
}
}
/// Color for horizontal rules inside assistant messages.
/// Defined as halfway from the theme background toward the assistant background tint.
/// This makes the rule more pronounced than the cell background while staying subtle.
pub(crate) fn assistant_hr() -> Color {
let bg = current_theme().background;
let info = current_theme().info;
let cell = assistant_bg();
// Start with a stronger mix toward the theme's info color than the cell bg.
// Cell is bg→info at 5%; pick ~15% as a baseline so it is darker than cell on light themes.
let candidate = mix_toward(bg, info, 0.15);
let cand_l = relative_luminance(color_to_rgb(candidate));
let cell_l = relative_luminance(color_to_rgb(cell));
if cand_l < cell_l {
// Already darker than the cell background.
candidate
} else {
// Ensure the rule is darker than the cell even on dark themes where info is brighter.
// Darken the cell bg by ~12% for a subtle, theme-respecting separator.
let (r, g, b) = blend_with_black(color_to_rgb(cell), 0.12);
Color::Rgb(r, g, b)
}
}
+6 -1
View File
@@ -211,6 +211,7 @@ fn render_patch_details_with_width(
full.saturating_sub(20).max(40)
};
let total_files = changes.len();
for (index, (path, change)) in changes.iter().enumerate() {
let is_first_file = index == 0;
// Add separator only between files (not at the very start)
@@ -303,7 +304,11 @@ fn render_patch_details_with_width(
}
}
out.push(RtLine::from(RtSpan::raw("")));
// Avoid trailing blank line at the very end; only add spacing
// when there are more files following.
if index + 1 < total_files {
out.push(RtLine::from(RtSpan::raw("")));
}
}
out
+445 -18
View File
@@ -132,7 +132,13 @@ pub(crate) trait HistoryCell {
// clear broader regions, custom widgets that shrink or scroll can otherwise
// leave residual glyphs to the right of shorter lines or from prior frames.
// We paint spaces with the current theme background to guarantee a clean slate.
let bg_style = Style::default().bg(crate::colors::background()).fg(crate::colors::text());
// Assistant messages use a subtly tinted background: theme background
// moved 5% toward the theme info color for a gentle distinction.
let cell_bg = match self.kind() {
HistoryCellType::Assistant => crate::colors::assistant_bg(),
_ => crate::colors::background(),
};
let bg_style = Style::default().bg(cell_bg).fg(crate::colors::text());
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
@@ -144,12 +150,12 @@ pub(crate) trait HistoryCell {
let lines = self.display_lines_trimmed();
let text = Text::from(lines);
let bg_block = Block::default().style(Style::default().bg(crate::colors::background()));
let bg_block = Block::default().style(Style::default().bg(cell_bg));
Paragraph::new(text)
.block(bg_block)
.wrap(Wrap { trim: false })
.scroll((skip_rows, 0))
.style(Style::default().bg(crate::colors::background()))
.style(Style::default().bg(cell_bg))
.render(area, buf);
}
@@ -203,6 +209,7 @@ pub(crate) trait HistoryCell {
match self.kind() {
HistoryCellType::Plain => None,
HistoryCellType::User => Some("›"),
// Restore assistant gutter icon
HistoryCellType::Assistant => Some("•"),
HistoryCellType::Reasoning => None,
HistoryCellType::Error => Some("✖"),
@@ -212,10 +219,10 @@ pub(crate) trait HistoryCell {
ToolStatus::Failed => "✖",
}),
HistoryCellType::Exec { kind, status } => {
// Show ➤ only for Run executions; hide for read/search/list summaries
// Show ❯ only for Run executions; hide for read/search/list summaries
match (kind, status) {
(ExecKind::Run, ExecStatus::Error) => Some("✖"),
(ExecKind::Run, _) => Some("➤"),
(ExecKind::Run, _) => Some("❯"),
_ => None,
}
}
@@ -336,6 +343,43 @@ pub(crate) struct ExecCell {
cached_out_lines: std::cell::RefCell<Option<Vec<Line<'static>>>>,
}
// ==================== AssistantMarkdownCell ====================
// Stores raw assistant markdown and rebuilds on demand (e.g., theme/syntax changes)
pub(crate) struct AssistantMarkdownCell {
pub(crate) raw: String,
pub(crate) lines: Vec<Line<'static>>, // includes hidden header "codex"
}
impl AssistantMarkdownCell {
pub(crate) fn new(raw: String, cfg: &codex_core::config::Config) -> Self {
let mut me = Self { raw, lines: Vec::new() };
me.rebuild(cfg);
me
}
pub(crate) fn rebuild(&mut self, cfg: &codex_core::config::Config) {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from("codex"));
crate::markdown::append_markdown_with_bold_first(&self.raw, &mut out, cfg);
// Apply bright text to body like streaming finalize
let bright = crate::colors::text_bright();
for line in out.iter_mut().skip(1) {
line.style = line.style.patch(Style::default().fg(bright));
}
self.lines = out;
}
}
impl HistoryCell for AssistantMarkdownCell {
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType { HistoryCellType::Assistant }
fn display_lines(&self) -> Vec<Line<'static>> {
// Hide the header line, mirroring PlainHistoryCell behavior for Assistant
if self.lines.len() > 1 { self.lines[1..].to_vec() } else { Vec::new() }
}
}
impl HistoryCell for ExecCell {
fn as_any(&self) -> &dyn std::any::Any { self }
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
@@ -594,7 +638,7 @@ fn exec_render_parts_generic(
let header_line = match output {
None => {
let duration_str = if let Some(start) = start_time { let elapsed = start.elapsed(); format!(" ({})", format_duration(elapsed)) } else { String::new() };
Line::styled("Running...".to_string() + &duration_str, Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD))
Line::styled("Running...".to_string() + &duration_str, Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD))
}
Some(o) if o.exit_code == 0 => Line::styled("Ran", Style::default().fg(crate::colors::text_bright()).add_modifier(Modifier::BOLD)),
Some(_) => Line::styled("Ran", Style::default().fg(crate::colors::text_bright()).add_modifier(Modifier::BOLD)),
@@ -633,9 +677,9 @@ fn exec_render_parts_parsed(
_ => match &ctx_path { Some(p) => format!("Running... in {}", p), None => "Running...".to_string() },
};
if matches!(action, "read" | "search" | "list") {
Line::styled(header + &duration_str, Style::default().fg(crate::colors::primary()))
Line::styled(header + &duration_str, Style::default().fg(crate::colors::info()))
} else {
Line::styled(header + &duration_str, Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD))
Line::styled(header + &duration_str, Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD))
}
}
Some(o) if o.exit_code == 0 => {
@@ -993,7 +1037,7 @@ impl HistoryCell for RunningToolCallCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::styled(
format!("{} ({})", self.title, format_duration(elapsed)),
Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD),
Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD),
));
lines.extend(self.arg_lines.clone());
lines.push(Line::from(""));
@@ -1264,6 +1308,100 @@ pub(crate) struct StreamingContentCell {
impl HistoryCell for StreamingContentCell {
fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
fn kind(&self) -> HistoryCellType { HistoryCellType::Assistant }
fn has_custom_render(&self) -> bool { true }
fn desired_height(&self, width: u16) -> u16 {
// Reserve one row of padding above and below the assistant content.
// Also reserve 1 col of left padding and 2 cols of right padding.
let content = Text::from(self.display_lines_trimmed());
let content_rows: u16 = Paragraph::new(content)
.wrap(Wrap { trim: false })
.line_count(width.saturating_sub(2))
.try_into()
.unwrap_or(0);
content_rows.saturating_add(2)
}
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, skip_rows: u16) {
// Render with a 1-row top and bottom padding, all using the assistant bg tint.
let cell_bg = crate::colors::assistant_bg();
// Do not set a widget-level FG here; it can mask per-span modifiers like BOLD on some terminals.
// Keep only BG tint so span-level styles remain authoritative.
// (A debug flag still exists, but this is now the default behavior.)
let bg_style = Style::default().bg(cell_bg);
// Hard clear area with assistant background
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
buf[(x, y)].set_char(' ').set_style(bg_style);
}
}
// Prepare lines with bullet hanging-indents (first-sentence styling is now handled in the markdown renderer).
let mut content_lines = self.display_lines_trimmed();
let wrap_width = area.width.saturating_sub(2); // keep 2-col right padding in layout
if wrap_width > 4 {
let mut transformed: Vec<Line<'static>> = Vec::new();
for line in content_lines.into_iter() {
// Horizontal rule: lines of only --- *** or ___ -> draw across width
if is_horizontal_rule_line(&line) {
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(wrap_width as usize).collect::<String>(),
Style::default().fg(crate::colors::assistant_hr()),
));
transformed.push(hr);
continue;
}
if let Some((indent_spaces, bullet_char)) = detect_bullet_prefix(&line) {
transformed.extend(wrap_bullet_line(line, indent_spaces, &bullet_char, wrap_width));
} else {
transformed.push(line);
}
}
content_lines = transformed;
}
let content_text = Text::from(content_lines);
let content_total: u16 = Paragraph::new(content_text.clone())
.wrap(Wrap { trim: false })
.line_count(area.width.saturating_sub(2))
.try_into()
.unwrap_or(0);
let mut remaining_skip = skip_rows;
let mut cur_y = area.y;
let end_y = area.y.saturating_add(area.height);
// Top padding (1 row)
if remaining_skip == 0 && cur_y < end_y {
// already cleared with bg; nothing else to draw
cur_y = cur_y.saturating_add(1);
}
remaining_skip = remaining_skip.saturating_sub(1);
// Content block
if cur_y < end_y {
let content_skip = remaining_skip.min(content_total);
let avail = end_y.saturating_sub(cur_y);
let draw_h = (content_total.saturating_sub(content_skip)).min(avail);
if draw_h > 0 {
// Render content flush to the gutter (no extra left pad) and keep 2 cols right pad
let content_area = Rect { x: area.x, y: cur_y, width: area.width.saturating_sub(2), height: draw_h };
let block = Block::default().style(bg_style);
Paragraph::new(content_text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((content_skip, 0))
.style(bg_style)
.render(content_area, buf);
cur_y = cur_y.saturating_add(draw_h);
}
}
// Bottom padding (1 row)
if cur_y < end_y {
// one row already cleared with bg; nothing else to draw
// when remaining_skip > 0, the single bottom pad row is entirely skipped.
// if remaining_skip > 0, the single bottom pad row is entirely skipped
}
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Hide the header line (e.g., "codex") when using a gutter symbol
if self.gutter_symbol().is_some() {
@@ -1280,6 +1418,290 @@ impl HistoryCell for StreamingContentCell {
}
}
// Detect lines that start with a markdown bullet produced by our renderer and return (indent, bullet)
fn detect_bullet_prefix(line: &ratatui::text::Line<'_>) -> Option<(usize, String)> {
let bullets = ["•", "◦", "·", "∘", "⋅"];
let spans = &line.spans;
if spans.is_empty() { return None; }
// First span may be leading spaces
let mut idx = 0;
let mut indent = 0usize;
if let Some(s) = spans.get(0) {
let t = s.content.as_ref();
if !t.is_empty() && t.chars().all(|c| c == ' ') {
indent = t.chars().count();
idx = 1;
}
}
// Next must be bullet glyph
let bullet_span = spans.get(idx)?;
let bullet = bullet_span.content.as_ref();
if !bullets.contains(&bullet) { return None; }
// Next must be a single space span (renderer adds one)
let space_ok = spans.get(idx + 1).map(|s| s.content.as_ref() == " ").unwrap_or(false);
if !space_ok { return None; }
Some((indent, bullet.to_string()))
}
// Wrap a bullet line with a hanging indent so wrapped lines align under the content start.
fn wrap_bullet_line(mut line: ratatui::text::Line<'static>, indent_spaces: usize, bullet: &str, width: u16) -> Vec<ratatui::text::Line<'static>> {
use ratatui::style::Style;
use ratatui::text::Span;
let width = width as usize;
let mut spans = std::mem::take(&mut line.spans);
// If the line contains OSC 8 hyperlinks (ESC ]8), avoid character-level
// rewrapping to prevent breaking escape sequences. Fall back to default
// Paragraph wrapping for this line by returning it unchanged.
if spans.iter().any(|s| s.content.as_ref().contains('\u{1b}')) {
line.spans = spans;
return vec![line];
}
let mut i = 0usize;
// Consume leading spaces span
if i < spans.len() {
let t = spans[i].content.as_ref();
if t.chars().all(|c| c == ' ') { i += 1; }
}
// Consume bullet span and following single-space span
let bullet_style = if i < spans.len() { spans[i].style } else { Style::default() };
i += 1; // bullet
i += 1; // space after bullet
// Remaining spans comprise the content
let rest_spans = spans.drain(i..).collect::<Vec<_>>();
let mut chars: Vec<(char, Style)> = Vec::new();
for sp in &rest_spans {
let st = sp.style;
for ch in sp.content.as_ref().chars() { chars.push((ch, st)); }
}
// Prefix widths
let first_prefix = indent_spaces + 1 /*bullet*/ + 1 /*space*/;
let cont_prefix = indent_spaces + 2; // spaces equal to bullet+space
let mut out: Vec<ratatui::text::Line<'static>> = Vec::new();
let mut pos = 0usize;
let mut first = true;
while pos < chars.len() {
let avail = if first { width.saturating_sub(first_prefix) } else { width.saturating_sub(cont_prefix) };
let avail = avail.max(1);
let mut take = 0usize;
let mut col = 0usize;
while pos + take < chars.len() && col < avail {
col += 1;
take += 1;
}
let slice = &chars[pos..pos + take];
let mut seg_spans: Vec<Span<'static>> = Vec::new();
// Build prefix spans
if first {
if indent_spaces > 0 { seg_spans.push(Span::raw(" ".repeat(indent_spaces))); }
seg_spans.push(Span::styled(bullet.to_string(), bullet_style));
seg_spans.push(Span::raw(" "));
} else {
seg_spans.push(Span::raw(" ".repeat(cont_prefix)));
}
// Build content spans coalescing same-style runs
let mut cur_style = None::<Style>;
let mut buf = String::new();
for (ch, st) in slice.iter().copied() {
if cur_style.map(|cs| cs == st).unwrap_or(false) {
buf.push(ch);
} else {
if !buf.is_empty() {
seg_spans.push(Span::styled(std::mem::take(&mut buf), cur_style.unwrap()));
}
cur_style = Some(st);
buf.push(ch);
}
}
if !buf.is_empty() {
seg_spans.push(Span::styled(buf, cur_style.unwrap()));
}
out.push(ratatui::text::Line::from(seg_spans));
pos += take;
first = false;
}
if out.is_empty() {
// Ensure at least prefix-only line (edge case empty content)
let mut seg_spans: Vec<Span<'static>> = Vec::new();
if indent_spaces > 0 { seg_spans.push(Span::raw(" ".repeat(indent_spaces))); }
seg_spans.push(Span::styled(bullet.to_string(), bullet_style));
out.push(ratatui::text::Line::from(seg_spans));
}
out
}
fn is_horizontal_rule_line(line: &ratatui::text::Line<'_>) -> bool {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let t = text.trim();
if t.is_empty() { return false; }
let chars: Vec<char> = t.chars().collect();
// Allow optional spaces between characters
let only = |ch: char| chars.iter().all(|c| *c == ch || c.is_whitespace());
(only('-') && chars.iter().filter(|c| **c=='-').count()>=3)
|| (only('*') && chars.iter().filter(|c| **c=='*').count()>=3)
|| (only('_') && chars.iter().filter(|c| **c=='_').count()>=3)
}
// Bold the first sentence (up to the first '.', '!' or '?' in the first non-empty line),
// or the entire first non-empty line if no terminator is present. Newlines already split lines.
// removed bold_first_sentence; renderer handles first sentence styling
/*
fn bold_first_sentence(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
use ratatui::text::Span;
use ratatui::style::Modifier;
// Find the first non-empty line index
let first_idx = match lines.iter().position(|l| {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
!s.trim().is_empty()
}) {
Some(i) => i,
None => return lines,
};
// Build the plain text of that line
let line_text: String = lines[first_idx]
.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect();
// If the first non-space character is a bullet (•), do not bold.
if line_text.chars().skip_while(|c| c.is_whitespace()).next() == Some('•') {
return lines;
}
// Heuristic: pick first sentence terminator that is not part of a filename or common
// abbreviation (e.g., "e.g.", "i.e."). Treat '.', '!' or '?' as terminators when
// followed by whitespace/end or a closing quote then whitespace/end. Skip when the
// next character is a letter/number (e.g., within filenames like example.sh).
let mut boundary: Option<usize> = None; // char index inclusive
let chars: Vec<char> = line_text.chars().collect();
let len_chars = chars.len();
for i in 0..len_chars {
let ch = chars[i];
if ch == '.' || ch == '!' || ch == '?' || ch == ':' {
let next = chars.get(i + 1).copied();
// Skip if next is alphanumeric (likely filename/identifier like example.sh)
if matches!(next, Some(c) if c.is_ascii_alphanumeric()) { continue; }
// Skip common abbreviation endings like "e.g." or "i.e." (match last 4 chars)
if i >= 3 {
let tail: String = chars[i - 3..=i].iter().collect::<String>().to_lowercase();
if tail == "e.g." || tail == "i.e." { continue; }
}
// Accept if end of line,
// or next is whitespace,
// or next is quote then whitespace/end
let ok = match next {
None => true,
Some(c) if c.is_whitespace() => true,
Some('"') | Some('\'') => {
let n2 = chars.get(i + 2).copied();
n2.is_none() || n2.map(|c| c.is_whitespace()).unwrap_or(false)
}
_ => false,
};
if ok { boundary = Some(i); break; }
}
}
// Bold up to and including the terminator.
let bold_upto = boundary.map(|i| i + 1);
// If there's no terminator or there's no additional content after it in the message,
// do not bold (single-sentence message).
if let Some(limit) = bold_upto {
let mut has_more_in_line = false;
// allow trailing quote right after terminator
let mut idx = limit;
if let Some('"') | Some('\'') = chars.get(idx) { idx += 1; }
if idx < len_chars {
has_more_in_line = chars[idx..].iter().any(|c| !c.is_whitespace());
}
let has_more_below = if !has_more_in_line {
lines.iter().skip(first_idx + 1).any(|l| {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
!s.trim().is_empty()
})
} else { true };
if !has_more_below {
return lines; // single-sentence message: leave as-is
}
} else {
// No terminator at all → treat as single sentence; leave as-is
return lines;
}
// Rebuild spans for the line with bold applied up to bold_upto (in chars)
let mut new_spans: Vec<Span<'static>> = Vec::new();
let mut consumed_chars: usize = 0;
for sp in lines[first_idx].spans.drain(..) {
let content = sp.content.into_owned();
let len = content.chars().count();
if bold_upto.is_none() {
// Entire line bold
let mut st = sp.style;
st.add_modifier.insert(Modifier::BOLD);
st.fg = Some(crate::colors::text_bright());
new_spans.push(Span::styled(content, st));
consumed_chars += len;
continue;
}
let limit = bold_upto.unwrap();
if consumed_chars >= limit {
// After bold range, preserve original styling (do not strip bold)
new_spans.push(Span::styled(content, sp.style));
consumed_chars += len;
} else if consumed_chars + len <= limit {
// Entire span within bold range
let mut st = sp.style;
st.add_modifier.insert(Modifier::BOLD);
st.fg = Some(crate::colors::text_bright());
new_spans.push(Span::styled(content, st));
consumed_chars += len;
} else {
// Split this span at the boundary
let split_at = limit - consumed_chars; // chars into this span
let mut iter = content.chars();
let bold_part: String = iter.by_ref().take(split_at).collect();
let rest_part: String = iter.collect();
let mut bold_style = sp.style;
bold_style.add_modifier.insert(Modifier::BOLD);
bold_style.fg = Some(crate::colors::text_bright());
if !bold_part.is_empty() { new_spans.push(Span::styled(bold_part, bold_style)); }
if !rest_part.is_empty() { new_spans.push(Span::styled(rest_part, sp.style)); }
consumed_chars += len;
}
}
lines[first_idx].spans = new_spans;
// Recolor markdown bullet glyphs inside assistant content to text_dim.
// Applies to common unordered bullets produced by our renderer: •, ◦, ·, ∘, ⋅
let bullet_set: [&str; 5] = ["•", "◦", "·", "∘", "⋅"];
for line in lines.iter_mut() {
let mut updated: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
for sp in line.spans.drain(..) {
let content_ref = sp.content.as_ref();
if bullet_set.contains(&content_ref) {
let mut st = sp.style;
st.fg = Some(crate::colors::text_dim());
updated.push(Span::styled(sp.content, st));
} else {
updated.push(sp);
}
}
line.spans = updated;
}
lines
}
*/
// ==================== Helper Functions ====================
// Unified preview format: show first 2 and last 5 non-empty lines with an ellipsis between.
@@ -1535,6 +1957,11 @@ pub(crate) fn new_session_info(
Span::from(" - "),
Span::from(SlashCommand::Reasoning.description()).style(Style::default().add_modifier(Modifier::DIM)),
]),
Line::from(vec![
Span::styled("/resume", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Resume.description()).style(Style::default().add_modifier(Modifier::DIM)),
]),
];
PlainHistoryCell { lines, kind: HistoryCellType::Notice }
} else if config.model == model {
@@ -1740,17 +2167,17 @@ fn new_parsed_command(
None => "Running...".to_string(),
},
};
// Use non-bold styling for informational actions; keep primary color
// Use non-bold styling for informational actions; use info color
if matches!(action, "read" | "search" | "list") {
Line::styled(
format!("{}{}", header, duration_str),
Style::default().fg(crate::colors::primary()),
format!("{header}{duration_str}"),
Style::default().fg(crate::colors::info()),
)
} else {
Line::styled(
format!("{}{}", header, duration_str),
format!("{header}{duration_str}"),
Style::default()
.fg(crate::colors::primary())
.fg(crate::colors::info())
.add_modifier(Modifier::BOLD),
)
}
@@ -2013,8 +2440,8 @@ fn new_exec_command_generic(
String::new()
};
Line::styled(
format!("Running...{}", duration_str),
Style::default().fg(crate::colors::primary()).add_modifier(Modifier::BOLD),
format!("Running...{duration_str}"),
Style::default().fg(crate::colors::info()).add_modifier(Modifier::BOLD),
)
}
Some(o) if o.exit_code == 0 => Line::styled(
@@ -2062,7 +2489,7 @@ fn new_exec_command_generic(
#[allow(dead_code)]
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> ToolCallCell {
let title_line = Line::styled("Working", Style::default().fg(crate::colors::primary()));
let title_line = Line::styled("Working", Style::default().fg(crate::colors::info()));
let lines: Vec<Line> = vec![
title_line,
format_mcp_invocation(invocation),
@@ -2073,7 +2500,7 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> ToolCallCel
#[allow(dead_code)]
pub(crate) fn new_active_custom_tool_call(tool_name: String, args: Option<String>) -> ToolCallCell {
let title_line = Line::styled("Working", Style::default().fg(crate::colors::primary()));
let title_line = Line::styled("Working", Style::default().fg(crate::colors::info()));
let invocation_str = if let Some(args) = args {
format!("{}({})", tool_name, args)
} else {
+24
View File
@@ -330,6 +330,30 @@ fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![to_owned_line(line)];
}
// Horizontal rule detection: lines consisting of --- *** or ___ (3+)
let flat_trim: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
.trim()
.to_string();
if !flat_trim.is_empty() {
let chars: Vec<char> = flat_trim.chars().collect();
let only = |ch: char| chars.iter().all(|c| *c == ch || c.is_whitespace());
let count = |ch: char| chars.iter().filter(|c| **c == ch).count();
if (only('-') && count('-') >= 3)
|| (only('*') && count('*') >= 3)
|| (only('_') && count('_') >= 3)
{
let hr = Line::from(Span::styled(
std::iter::repeat('─').take(width).collect::<String>(),
ratatui::style::Style::default().fg(crate::colors::assistant_hr()),
));
return vec![hr];
}
}
// Concatenate content and keep span boundaries for later re-slicing.
let mut flat = String::new();
let mut span_bounds = Vec::new(); // (start_byte, end_byte, style)
+1
View File
@@ -42,6 +42,7 @@ pub mod live_wrap;
mod markdown;
mod markdown_renderer;
mod markdown_stream;
mod syntax_highlight;
pub mod onboarding;
mod render;
// mod scroll_view; // Orphaned after trait-based HistoryCell migration
+120 -18
View File
@@ -3,10 +3,11 @@
use crate::citation_regex::CITATION_REGEX;
use crate::markdown_renderer::MarkdownRenderer;
use codex_core::config::Config;
use codex_core::config_types::UriBasedFileOpener;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
use codex_core::config_types::UriBasedFileOpener;
use ratatui::text::Line;
use std::borrow::Cow;
use std::path::Path;
@@ -63,22 +64,38 @@ fn append_markdown_with_opener_and_cwd_and_bold(
};
lines.extend(rendered);
}
Segment::Code { content, .. } => {
// Emit the code content exactly as-is, line by line.
// We don't attempt syntax highlighting to avoid whitespace bugs.
for line in content.split_inclusive('\n') {
// split_inclusive keeps the trailing \n; we want lines without it.
let line = if let Some(stripped) = line.strip_suffix('\n') {
stripped
} else {
line
};
// Style code blocks using the theme's function color for visibility.
let code_style = Style::default().fg(crate::colors::function());
let owned_line: Line<'static> =
Line::from(Span::styled(line.to_string(), code_style));
lines.push(owned_line);
Segment::Code { _lang, content } => {
// Use syntect-based syntax highlighting when available, preserving exact text.
let lang = _lang.as_deref();
// Apply a solid background and pad trailing spaces so the block forms
// a rectangle up to the longest line in this code section.
let code_bg = crate::colors::code_block_bg();
let mut highlighted = crate::syntax_highlight::highlight_code_block(&content, lang);
// Compute max display width (in terminal cells) across lines
let max_w: usize = highlighted
.iter()
.map(|l| l.spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum::<usize>())
.max()
.unwrap_or(0);
for l in highlighted.iter_mut() {
l.style = l.style.bg(code_bg);
let w: usize = l
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if max_w > w {
let pad = " ".repeat(max_w - w);
l.spans.push(Span::styled(pad, Style::default().bg(code_bg)));
} else if w == 0 {
// Defensive: if this code block segment happens to have
// max_w == 0 (e.g., a split artifact), still paint the
// row with at least one space so the code block
// background shows consistently.
l.spans.push(Span::styled(" ", Style::default().bg(code_bg)));
}
}
lines.extend(highlighted);
}
}
}
@@ -207,7 +224,37 @@ fn split_text_and_fences(src: &str) -> Vec<Segment> {
// Consider any line that begins with >=4 spaces or a tab to start an
// indented code block. This favors preserving indentation even when a
// preceding blank line is omitted (common in streamed model output).
let starts_indented_code = (leading_spaces >= 4) || starts_with_tab;
//
// However, do NOT treat indented list items as code. Nested markdown lists
// are commonly indented by 2+ spaces, and third-level bullets often cross
// the 4‑space threshold. If the text after the indentation begins with a
// list marker ("- ", "* ", "+ ", or an ordered list like "1. "), we
// should render it as a list, not as an indented code block.
let after_indent = if starts_with_tab {
&raw_line[1..]
} else {
&raw_line[leading_spaces..]
};
let is_ordered_list = {
// digits+". " or digits+") " are common ordered list markers
let mut chars = after_indent.chars();
let mut saw_digit = false;
while let Some(c) = chars.next() {
if c.is_ascii_digit() { saw_digit = true; continue; }
if (c == '.' || c == ')') && chars.next().is_some_and(|n| n == ' ') {
break;
}
// Not an ordered list pattern
saw_digit = false; // ensure false when we break early
break;
}
saw_digit
};
let is_unordered_list = after_indent.starts_with("- ")
|| after_indent.starts_with("* ")
|| after_indent.starts_with("+ ");
let looks_like_list = is_unordered_list || is_ordered_list;
let starts_indented_code = ((leading_spaces >= 4) || starts_with_tab) && !looks_like_list;
if starts_indented_code {
// Flush pending text and begin an indented code block.
if !curr_text.is_empty() {
@@ -466,4 +513,59 @@ mod tests {
"Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?"
);
}
#[test]
fn fenced_code_block_with_internal_blank_line_is_one_contiguous_block() {
// Repro from user: single fenced block with a blank line between two functions.
// The blank line must render as part of the code block (with spaces), not as
// a separate plain-text gap that would visually split the block.
let src = " ```rust\n // Rust example\n fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n }\n\n fn main() {\n println!(\"{}\", greet(\"Codex\"));\n }\n ```\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
// Expect the lines to be exactly the code lines (no fence markers), including
// one line that contains only spaces for the internal blank.
// Extract plain strings for inspection.
let rendered: Vec<String> = out
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
// There should be at least 7 lines: comment, fn greet header, format!, closing },
// the internal blank (spaces), fn main header, println!, closing }.
assert!(rendered.len() >= 7, "unexpected line count: {:?}", rendered);
// Find the internal blank: it should be a line consisting only of spaces
// (inserted by padding logic inside code block), not an actually empty string
// produced by the non-code renderer.
assert!(rendered.iter().any(|s| !s.is_empty() && s.trim().is_empty()),
"expected a space-padded blank line inside the code block, got: {:?}", rendered);
}
#[test]
fn nested_list_items_not_treated_as_code_blocks() {
// Repro: deeply indented third-level bullets starting with "- " should render
// as list items, not as indented code blocks with a background.
let src = "- What I changed\n - data/model_data.ts\n - Added model id gemini-2.5\n - input_per_million: 0.30\n";
let cwd = Path::new("/");
let mut out = Vec::new();
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
// Convert to plain strings for inspection
let rendered: Vec<String> = out
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect();
// Expect at least three lines rendered
assert!(rendered.len() >= 3, "unexpected rendered lines: {:?}", rendered);
// The third-level bullets (indented by 6 spaces before "- ") should render
// with a bullet glyph (level 4 uses '⋅') rather than the literal "- ".
assert!(rendered.iter().any(|s| s.contains('⋅') && s.contains("Added model id")),
"expected a rendered bullet glyph for third-level list item: {:?}", rendered);
assert!(rendered.iter().any(|s| s.contains('⋅') && s.contains("input_per_million")),
"expected a rendered bullet glyph for third-level list item: {:?}", rendered);
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -157,6 +157,17 @@ impl MarkdownStreamCollector {
self.clear();
out
}
/// Return the full source that would be rendered at finalize time without mutating state.
pub fn full_render_source_preview(&self) -> String {
let mut source: String = self.buffer.clone();
if !source.ends_with('\n') {
source.push('\n');
}
let source = unwrap_markdown_language_fence_if_enabled(source);
let source = strip_empty_fenced_code_blocks(&source);
source
}
}
/// fence helpers are provided by `crate::render::markdown_utils`
+56 -84
View File
@@ -11,6 +11,7 @@ use super::StreamState;
pub(crate) trait HistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>);
fn insert_history_with_kind(&self, kind: StreamKind, lines: Vec<Line<'static>>);
fn insert_final_answer(&self, lines: Vec<Line<'static>>, full_markdown_source: String);
fn start_commit_animation(&self);
fn stop_commit_animation(&self);
}
@@ -37,6 +38,11 @@ impl HistorySink for AppEventHistorySink {
self.0
.send(crate::app_event::AppEvent::InsertHistoryWithKind { kind, lines })
}
fn insert_final_answer(&self, lines: Vec<Line<'static>>, full_markdown_source: String) {
tracing::debug!("insert_final_answer {} lines ({} bytes source)", lines.len(), full_markdown_source.len());
self.0
.send(crate::app_event::AppEvent::InsertFinalAnswer { lines, source: full_markdown_source })
}
fn start_commit_animation(&self) {
self.0
.send(crate::app_event::AppEvent::StartCommitAnimation)
@@ -197,31 +203,17 @@ impl StreamController {
}
}
if !newly_completed.is_empty() {
// Color reasoning as text_dim and answers as text_bright, preserving span modifiers
// IMPORTANT: Do not recolor entire Answer lines. We only dim Reasoning lines.
// Recoloring the whole Answer line can mask per-span BOLD styling on some
// terminals. See regression: inline bold appeared normal due to line FG.
let color = match kind {
StreamKind::Reasoning => crate::colors::text_dim(),
StreamKind::Answer => crate::colors::text_bright(),
StreamKind::Reasoning => Some(crate::colors::text_dim()),
StreamKind::Answer => Some(crate::colors::text_bright()),
};
let mut styled: Vec<Line<'static>> = Vec::with_capacity(newly_completed.len());
for mut line in newly_completed {
line.style = line
.style
.patch(ratatui::style::Style::default().fg(color));
if matches!(kind, StreamKind::Answer) {
// Force bold spans in assistant output to use bright text
let spans: Vec<ratatui::text::Span<'static>> = line
.spans
.into_iter()
.map(|s| {
if s.style.add_modifier.contains(ratatui::style::Modifier::BOLD) {
s.style(ratatui::style::Style::default().fg(crate::colors::text_bright()))
} else {
s
}
})
.collect();
line.spans = spans;
}
if let Some(c) = color { line.style = line.style.patch(ratatui::style::Style::default().fg(c)); }
// No per-span overrides needed for Answer: line FG is already bright.
styled.push(line);
}
state.enqueue(styled);
@@ -285,7 +277,13 @@ impl StreamController {
return false;
}
let cfg = self.config.clone();
// Finalize collector first.
// Capture the full render source BEFORE draining/clearing the collector so
// we can rebuild the final Assistant cell without losing any content.
let full_source_before_drain = {
let state = self.state(kind);
state.collector.full_render_source_preview()
};
// Finalize collector (this clears internal buffers).
let remaining = {
let state = self.state_mut(kind);
state.collector.finalize_and_drain(&cfg)
@@ -301,51 +299,41 @@ impl StreamController {
let step = state.drain_all();
out_lines.extend(step.history);
}
if !out_lines.is_empty() {
let mut lines_with_header: Lines = Vec::new();
let _emitted_header = self.emit_header_if_needed(kind, &mut lines_with_header);
// Reduce leading blanks to at most one
let mut skip_count = 0;
while skip_count < out_lines.len()
&& crate::render::line_utils::is_blank_line_trim(&out_lines[skip_count]) {
skip_count += 1;
// Build output regardless of whether out_lines is empty so we can still
// replace the streaming cell with a re-renderable final cell.
let mut lines_with_header: Lines = Vec::new();
let _emitted_header = self.emit_header_if_needed(kind, &mut lines_with_header);
// Reduce leading blanks to at most one
let mut skip_count = 0;
while skip_count < out_lines.len()
&& crate::render::line_utils::is_blank_line_trim(&out_lines[skip_count]) {
skip_count += 1;
}
if skip_count > 1 {
for _ in 0..(skip_count - 1) {
out_lines.remove(0);
}
if skip_count > 1 {
for _ in 0..(skip_count - 1) {
out_lines.remove(0);
}
}
// Apply stream-specific color to body lines
let color = match kind {
StreamKind::Reasoning => crate::colors::text_dim(),
StreamKind::Answer => crate::colors::text_bright(),
};
let out_lines: Vec<Line<'static>> = out_lines
.into_iter()
.map(|mut line| {
line.style = line
.style
.patch(ratatui::style::Style::default().fg(color));
if matches!(kind, StreamKind::Answer) {
let spans: Vec<ratatui::text::Span<'static>> = line
.spans
.into_iter()
.map(|s| {
if s.style.add_modifier.contains(ratatui::style::Modifier::BOLD) {
s.style(ratatui::style::Style::default().fg(crate::colors::text_bright()))
} else {
s
}
})
.collect();
line.spans = spans;
}
line
})
.collect();
}
// Apply stream-specific color to body lines
let color = match kind {
StreamKind::Reasoning => Some(crate::colors::text_dim()),
StreamKind::Answer => Some(crate::colors::text_bright()),
};
let out_lines: Vec<Line<'static>> = out_lines
.into_iter()
.map(|mut line| {
if let Some(c) = color { line.style = line.style.patch(ratatui::style::Style::default().fg(c)); }
line
})
.collect();
lines_with_header.extend(out_lines);
// Don't add extra blank line - markdown renderer handles spacing
lines_with_header.extend(out_lines);
// Don't add extra blank line - markdown renderer handles spacing
if matches!(kind, StreamKind::Answer) {
// Use the source captured before draining so we don't lose content
// when the collector was cleared by finalize_and_drain.
sink.insert_final_answer(lines_with_header, full_source_before_drain);
} else if !lines_with_header.is_empty() {
sink.insert_history_with_kind(kind, lines_with_header);
}
@@ -401,29 +389,13 @@ impl StreamController {
}
// Apply stream-specific color to body lines while preserving modifiers
let color = match kind {
StreamKind::Reasoning => crate::colors::text_dim(),
StreamKind::Answer => crate::colors::text_bright(),
StreamKind::Reasoning => Some(crate::colors::text_dim()),
StreamKind::Answer => Some(crate::colors::text_bright()),
};
let history: Vec<Line<'static>> = history
.into_iter()
.map(|mut line| {
line.style = line
.style
.patch(ratatui::style::Style::default().fg(color));
if matches!(kind, StreamKind::Answer) {
let spans: Vec<ratatui::text::Span<'static>> = line
.spans
.into_iter()
.map(|s| {
if s.style.add_modifier.contains(ratatui::style::Modifier::BOLD) {
s.style(ratatui::style::Style::default().fg(crate::colors::text_bright()))
} else {
s
}
})
.collect();
line.spans = spans;
}
if let Some(c) = color { line.style = line.style.patch(ratatui::style::Style::default().fg(c)); }
line
})
.collect();
+247
View File
@@ -0,0 +1,247 @@
use once_cell::sync::OnceCell;
use ratatui::text::{Line, Span};
// syntect imports
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style as SynStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;
// Convert a ratatui Color to RGB tuple; mirrored locally to avoid exposing internals.
fn color_to_rgb(c: ratatui::style::Color) -> (u8, u8, u8) {
use ratatui::style::Color;
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0, 0, 0),
Color::White => (255, 255, 255),
Color::Gray => (192, 192, 192),
Color::DarkGray => (128, 128, 128),
Color::Red => (205, 49, 49),
Color::Green => (13, 188, 121),
Color::Yellow => (229, 229, 16),
Color::Blue => (36, 114, 200),
Color::Magenta => (188, 63, 188),
Color::Cyan => (17, 168, 205),
Color::LightRed => (255, 102, 102),
Color::LightGreen => (102, 255, 178),
Color::LightYellow => (255, 255, 102),
Color::LightBlue => (102, 153, 255),
Color::LightMagenta => (255, 102, 255),
Color::LightCyan => (102, 255, 255),
Color::Indexed(i) => (i, i, i),
Color::Reset => (255, 255, 255),
}
}
fn relative_luminance(rgb: (u8, u8, u8)) -> f32 {
(0.2126 * rgb.0 as f32 + 0.7152 * rgb.1 as f32 + 0.0722 * rgb.2 as f32) / 255.0
}
fn is_light_bg() -> bool {
let bg = crate::colors::background();
let lum = relative_luminance(color_to_rgb(bg));
lum >= 0.6
}
static PS: OnceCell<SyntaxSet> = OnceCell::new();
static THEMES: OnceCell<ThemeSet> = OnceCell::new();
fn syntax_set() -> &'static SyntaxSet {
PS.get_or_init(|| SyntaxSet::load_defaults_newlines())
}
fn themes() -> &'static ThemeSet {
THEMES.get_or_init(ThemeSet::load_defaults)
}
fn current_theme_name<'a>(_ts: &'a ThemeSet) -> &'a str {
// Restrict to Solarized only; pick based on current UI background brightness.
if is_light_bg() { "Solarized (light)" } else { "Solarized (dark)" }
}
fn blending_enabled() -> bool { false }
fn default_theme<'a>() -> &'a Theme {
// Use the currently selected theme (rotatable via Ctrl+Y)
let ts = themes();
let name = current_theme_name(ts);
// Prefer the Solarized themes; fall back to first available if missing.
ts.themes
.get(name)
.or_else(|| ts.themes.get(if is_light_bg() { "Solarized (light)" } else { "Solarized (dark)" }))
.unwrap_or_else(|| ts.themes.values().next().expect("at least one syntect theme"))
}
fn syntax_for_lang<'a>(ps: &'a SyntaxSet, lang: &str) -> &'a SyntaxReference {
// Try token, then extension, then name; fall back to plain text.
let lang = normalize_lang(lang);
ps.find_syntax_by_token(lang)
.or_else(|| ps.find_syntax_by_extension(lang))
.or_else(|| ps.find_syntax_by_name(lang))
.unwrap_or_else(|| ps.find_syntax_plain_text())
}
fn span_from_syn((SynStyle { foreground, font_style, .. }, text): (SynStyle, &str)) -> Span<'static> {
use ratatui::style::{Color, Modifier, Style};
// Map syntect Style to ratatui Style
let fg = adjust_color(Color::Rgb(foreground.r, foreground.g, foreground.b));
let mut style = Style::default().fg(fg);
if font_style.contains(syntect::highlighting::FontStyle::BOLD) {
style = style.add_modifier(Modifier::BOLD);
}
if font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
style = style.add_modifier(Modifier::ITALIC);
}
if font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
style = style.add_modifier(Modifier::UNDERLINED);
}
// Strip a single trailing newline from syntect's line so our Line has no '\n'
let content = if let Some(stripped) = text.strip_suffix('\n') { stripped } else { text };
Span::styled(content.to_string(), style)
}
/// Highlight a code block into ratatui Lines while preserving exact text.
pub(crate) fn highlight_code_block(content: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let ps = syntax_set();
let theme = default_theme();
// Resolve language if provided
let syntax = lang
.map(|l| syntax_for_lang(ps, l))
.unwrap_or_else(|| ps.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, theme);
let mut out: Vec<Line<'static>> = Vec::new();
for line in LinesWithEndings::from(content) {
// syntect returns (Style, &str) pairs; convert to ratatui Spans
let ranges = highlighter.highlight_line(line, ps).unwrap_or_else(|_| vec![(SynStyle::default(), line)]);
let spans: Vec<Span<'static>> = ranges.into_iter().map(span_from_syn).collect();
out.push(Line::from(spans));
}
// When `content` ends with a trailing newline, syntect yields an extra
// empty "line" at the end. Historically our renderer did not emit that
// final empty line, which also avoids introducing a stray blank row
// outside the code block background. Trim a single trailing empty Line.
if content.ends_with('\n') {
if let Some(last) = out.last() {
let is_empty = last.spans.is_empty() || last.spans.iter().all(|s| s.content.is_empty());
if is_empty { out.pop(); }
}
}
out
}
// --- Color adaptation helpers ---
use ratatui::style::Color;
fn mix_rgb(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
let t = t.clamp(0.0, 1.0);
let inv = 1.0 - t;
let r = (a.0 as f32 * inv + b.0 as f32 * t).round() as u8;
let g = (a.1 as f32 * inv + b.1 as f32 * t).round() as u8;
let bl = (a.2 as f32 * inv + b.2 as f32 * t).round() as u8;
(r, g, bl)
}
fn contrast_ratio(l1: f32, l2: f32) -> f32 {
let (a, b) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
(a + 0.05) / (b + 0.05)
}
fn adjust_color(c: Color) -> Color {
// Blend syntect foreground toward our theme text color to reduce mismatch,
// then ensure a minimum contrast vs our background.
if !blending_enabled() {
return c; // raw syntect color
}
let text = crate::colors::text();
let bg = crate::colors::background();
let (cr, cg, cb) = color_to_rgb(c);
let (tr, tg, tb) = color_to_rgb(text);
let (br, bgc, bb) = color_to_rgb(bg);
let base_mix = if is_light_bg() { 0.35 } else { 0.25 };
let (mut r, mut g, mut b) = mix_rgb((cr, cg, cb), (tr, tg, tb), base_mix);
// Enforce a modest contrast so colors don't look washed out.
let mut l_fg = relative_luminance((r, g, b));
let l_bg = relative_luminance((br, bgc, bb));
let mut ratio = contrast_ratio(l_fg, l_bg);
let target = if is_light_bg() { 3.0 } else { 2.5 };
if ratio < target {
// Move further toward text color until ratio is met or we cap iterations.
let mut t = base_mix;
for _ in 0..4 {
t = (t + 0.20).min(0.80);
let mixed = mix_rgb((cr, cg, cb), (tr, tg, tb), t);
l_fg = relative_luminance(mixed);
ratio = contrast_ratio(l_fg, l_bg);
r = mixed.0; g = mixed.1; b = mixed.2;
if ratio >= target { break; }
}
}
Color::Rgb(r, g, b)
}
// --- Language aliasing ---
fn normalize_lang(lang: &str) -> &str {
let l = lang.trim().trim_matches(|c: char| c == '.' || c == '#').to_ascii_lowercase();
match l.as_str() {
// Shells
"sh" | "bash" | "zsh" | "shell" | "console" | "shellsession" => "bash",
// Web
"html" | "htm" | "xhtml" => "html",
"xml" => "xml",
"css" => "css",
"scss" | "sass" => "scss",
// JS/TS
"js" | "javascript" => "javascript",
"mjs" | "cjs" => "javascript",
"jsx" => "jsx",
"ts" | "typescript" => "typescript",
"tsx" => "tsx",
// Data/config
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"ini" | "cfg" | "conf" | "dotenv" | ".env" => "ini",
// Rust and friends
"rs" | "rust" => "rust",
// Python
"py" | "python" | "py3" => "python",
// C-family
"c" => "c",
"h" => "c",
"cpp" | "c++" | "cxx" | "cc" | "hpp" | "hh" => "cpp",
"objc" | "objective-c" | "m" | "mm" => "objective-c",
"cs" | "csharp" => "cs",
// Other popular
"go" => "go",
"rb" | "ruby" => "ruby",
"java" => "java",
"scala" => "scala",
"kt" | "kts" | "kotlin" => "kotlin",
"swift" => "swift",
"php" => "php",
"dart" => "dart",
"lua" => "lua",
"r" => "r",
"hs" | "haskell" => "haskell",
// Infra / devops
"docker" | "dockerfile" => "docker",
"make" | "makefile" | "mk" => "make",
"cmake" | "cmakelists.txt" => "cmake",
"nix" => "nix",
"tf" | "terraform" => "terraform",
"hcl" => "hcl",
// Markup / docs
"md" | "markdown" => "markdown",
// Data formats and misc languages
"sql" => "sql",
"proto" | "protobuf" => "protobuf",
"graphql" | "gql" => "graphql",
"plantuml" | "puml" => "plantuml",
// Fallback
other => Box::leak(other.to_string().into_boxed_str()),
}
}
// Theme cycling removed; we always use Solarized matching UI brightness.
+1 -1
View File
@@ -434,7 +434,7 @@ fn get_predefined_theme(name: ThemeName) -> Theme {
info: Color::Rgb(9, 105, 218), // #0969DA
text: Color::Rgb(79, 91, 106), // #4f5b6a
text_dim: Color::Rgb(171, 180, 199), // #abb4c7
text_bright: Color::Black, // #000000
text_bright: Color::Rgb(0, 0, 20), // #000014
keyword: Color::Rgb(110, 89, 203), // #6E59CB
string: Color::Rgb(11, 125, 105), // #0B7D69
comment: Color::Rgb(100, 115, 132), // #647384
+251
View File
@@ -0,0 +1,251 @@
# Markdown Showcase
A quick tour of many common Markdown elements so you can verify rendering.
---
## Headings
# H1
## H2
### H3
#### H4
##### H5
###### H6
---
## Emphasis & Inline
- Plain: text
- Italic: _text_ or *text*
- Bold: __text__ or **text**
- Bold + Italic: **_text_**
- Strikethrough: ~~text~~
- Code: `let answer = 42;`
- Escaping: \*literal asterisks\*, \_underscores\_, \`backticks\`
---
## Blockquotes
> Note: This is a single-level quote.
>
> - It can include lists
> - And `inline code`
>
> > Nested quote level two.
> >
> > - Deep thought here
---
## Lists
- Unordered list item
- Nested item
- Third level
- Another item
1. Ordered list item
1. Nested item
2. Another nested item
2. Second item
- Mixed list
1. Ordered inside unordered
2. Still works
---
## Task Lists
- [x] Render checkboxes
- [ ] Support unchecked state
- [ ] Handle nested tasks
- [x] Child complete
- [ ] Child pending
---
## Links
- Inline: [OpenAI](https://openai.com)
- Title: [Rust](https://www.rust-lang.org "Rust language")
- Reference: [Codex CLI][codex] and [CommonMark][cm]
[codex]: https://github.com/openai/codex
[cm]: https://commonmark.org
---
## Images
![Sample Banner](https://via.placeholder.com/600x150?text=Sample+Banner "Placeholder banner")
_Caption: Placeholder banner to test image rendering._
[![Clickable Image](https://via.placeholder.com/200x80?text=Click+Me)](https://example.com)
---
## Code Blocks
```rust
// Rust example
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
fn main() {
println!("{}", greet("Codex"));
}
```
```bash
# Shell example
set -euo pipefail
./build-fast.sh
echo "Build finished."
```
```json
{
"name": "codex-demo",
"version": "1.0.0",
"private": true
}
```
```toml
# Cargo-like TOML
[package]
name = "codex-demo"
version = "0.1.0"
edition = "2021"
```
```diff
diff --git a/hello.txt b/hello.txt
--- a/hello.txt
+++ b/hello.txt
@@
-Hello Wordl
+Hello World
```
---
## Simple Tables
| First Header | Second Header |
| ------------- | ------------- |
| Content Cell | Content Cell |
| Content Cell | Content Cell |
| Command | Description |
| --- | --- |
| git status | List all new or modified files |
| git diff | Show file differences that haven't been staged |
## Complex Table
| Name | Type | Stars | Notes |
|:------------|:--------|------:|:-----------------------|
| codex-core | library | 4,512| Core execution logic |
| codex-tui | app | 1,203| Terminal UI |
| codex-web | app | 987| Browser front-end |
Alignment: left, center, right verified above.
| Command | Description |
| --- | --- |
| `git status` | List all *new or modified* files |
| `git diff` | Show file differences that **haven't been** staged |
Contains markdown
---
## Footnotes
Here’s a sentence with a footnote marker.[^1] And another one right here.[^ref]
[^1]: This is the first footnote with more detail.
[^ref]: A second footnote to test multiple references.
---
## Horizontal Rules
---
***
___
---
## Collapsible
<details>
<summary>Click to expand</summary>
- This content is hidden by default.
- It can include lists and code:
```text
Hidden block content.
```
</details>
---
## Definition List
Term
: The thing being defined.
Another Term
: A second definition with more words for testing.
---
## Callouts (Quote-style)
> [!NOTE]
> This is a helpful note for readers.
> [!TIP]
> Use format!("{value}") to inline variables.
> [!WARNING]
> Treat all warnings as failures during builds.
> [!IMPORTANT]
> Never modify sandbox env var handling in code.
---
## Mermaid (Diagrams)
```mermaid
flowchart TD
A[Start] --> B{Build?}
B -- yes --> C[Run ./build-fast.sh]
B -- no --> D[Edit code]
C --> E[Success]
D --> B
```
---
## Mixed Content
- Emoji: ✅ 🚀 💡
- Math (literal text): `E = mc^2`
- Monospace paths: `/usr/local/bin`, `src/main.rs`
- HTML inline: <sup>sup</sup> and <sub>sub</sub>
---
If you want more of any specific element (e.g., longer tables, nested lists, or additional code languages), tell me what to stress-test next.