mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
Generated
+169
-2
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(¶ms.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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
..
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
_Caption: Placeholder banner to test image rendering._
|
||||
|
||||
[](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.
|
||||
|
||||
Reference in New Issue
Block a user