Skip to content

Commit 01e6503

Browse files
wrap markdown at render time (#4506)
This results in correctly indenting list items with long lines. <img width="1006" height="251" alt="Screenshot 2025-09-30 at 10 00 48 AM" src="https://github.com/user-attachments/assets/0a076cf6-ca3c-4efb-b3af-dc07617cdb6f" />
1 parent 9c25973 commit 01e6503

File tree

8 files changed

+345
-101
lines changed

8 files changed

+345
-101
lines changed

codex-rs/tui/src/chatwidget.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ pub(crate) struct ChatWidget {
256256
ghost_snapshots_disabled: bool,
257257
// Whether to add a final message separator after the last message
258258
needs_final_message_separator: bool,
259+
260+
last_rendered_width: std::cell::Cell<Option<usize>>,
259261
}
260262

261263
struct UserMessage {
@@ -658,7 +660,10 @@ impl ChatWidget {
658660
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
659661
self.needs_final_message_separator = false;
660662
}
661-
self.stream_controller = Some(StreamController::new(self.config.clone()));
663+
self.stream_controller = Some(StreamController::new(
664+
self.config.clone(),
665+
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
666+
));
662667
}
663668
if let Some(controller) = self.stream_controller.as_mut()
664669
&& controller.push(&delta)
@@ -912,6 +917,7 @@ impl ChatWidget {
912917
ghost_snapshots: Vec::new(),
913918
ghost_snapshots_disabled: true,
914919
needs_final_message_separator: false,
920+
last_rendered_width: std::cell::Cell::new(None),
915921
}
916922
}
917923

@@ -974,6 +980,7 @@ impl ChatWidget {
974980
ghost_snapshots: Vec::new(),
975981
ghost_snapshots_disabled: true,
976982
needs_final_message_separator: false,
983+
last_rendered_width: std::cell::Cell::new(None),
977984
}
978985
}
979986

@@ -1447,7 +1454,7 @@ impl ChatWidget {
14471454
} else {
14481455
// Show explanation when there are no structured findings.
14491456
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
1450-
append_markdown(&explanation, &mut rendered, &self.config);
1457+
append_markdown(&explanation, None, &mut rendered, &self.config);
14511458
let body_cell = AgentMessageCell::new(rendered, false);
14521459
self.app_event_tx
14531460
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
@@ -1456,7 +1463,7 @@ impl ChatWidget {
14561463
let message_text =
14571464
codex_core::review_format::format_review_findings_block(&output.findings, None);
14581465
let mut message_lines: Vec<ratatui::text::Line<'static>> = Vec::new();
1459-
append_markdown(&message_text, &mut message_lines, &self.config);
1466+
append_markdown(&message_text, None, &mut message_lines, &self.config);
14601467
let body_cell = AgentMessageCell::new(message_lines, true);
14611468
self.app_event_tx
14621469
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
@@ -1998,6 +2005,7 @@ impl WidgetRef for &ChatWidget {
19982005
tool.render_ref(area, buf);
19992006
}
20002007
}
2008+
self.last_rendered_width.set(Some(area.width as usize));
20012009
}
20022010
}
20032011

codex-rs/tui/src/chatwidget/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ fn make_chatwidget_manual() -> (
341341
ghost_snapshots: Vec::new(),
342342
ghost_snapshots_disabled: false,
343343
needs_final_message_separator: false,
344+
last_rendered_width: std::cell::Cell::new(None),
344345
};
345346
(widget, rx, op_rx)
346347
}

codex-rs/tui/src/history_cell.rs

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::exec_cell::output_lines;
66
use crate::exec_cell::spinner;
77
use crate::exec_command::relativize_to_home;
88
use crate::exec_command::strip_bash_lc_and_escape;
9+
use crate::markdown::MarkdownCitationContext;
910
use crate::markdown::append_markdown;
1011
use crate::render::line_utils::line_to_static;
1112
use crate::render::line_utils::prefix_lines;
@@ -128,38 +129,44 @@ impl HistoryCell for UserHistoryCell {
128129

129130
#[derive(Debug)]
130131
pub(crate) struct ReasoningSummaryCell {
131-
_header: Vec<Line<'static>>,
132-
content: Vec<Line<'static>>,
132+
_header: String,
133+
content: String,
134+
citation_context: MarkdownCitationContext,
133135
}
134136

135137
impl ReasoningSummaryCell {
136-
pub(crate) fn new(header: Vec<Line<'static>>, content: Vec<Line<'static>>) -> Self {
138+
pub(crate) fn new(
139+
header: String,
140+
content: String,
141+
citation_context: MarkdownCitationContext,
142+
) -> Self {
137143
Self {
138144
_header: header,
139145
content,
146+
citation_context,
140147
}
141148
}
142149
}
143150

144151
impl HistoryCell for ReasoningSummaryCell {
145152
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
146-
let summary_lines = self
147-
.content
148-
.iter()
149-
.map(|line| {
150-
Line::from(
151-
line.spans
152-
.iter()
153-
.map(|span| {
154-
Span::styled(
155-
span.content.clone().into_owned(),
156-
span.style
157-
.add_modifier(Modifier::ITALIC)
158-
.add_modifier(Modifier::DIM),
159-
)
160-
})
161-
.collect::<Vec<_>>(),
162-
)
153+
let mut lines: Vec<Line<'static>> = Vec::new();
154+
append_markdown(
155+
&self.content,
156+
Some((width as usize).saturating_sub(2)),
157+
&mut lines,
158+
self.citation_context.clone(),
159+
);
160+
let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
161+
let summary_lines = lines
162+
.into_iter()
163+
.map(|mut line| {
164+
line.spans = line
165+
.spans
166+
.into_iter()
167+
.map(|span| span.patch_style(summary_style))
168+
.collect();
169+
line
163170
})
164171
.collect::<Vec<_>>();
165172

@@ -174,7 +181,14 @@ impl HistoryCell for ReasoningSummaryCell {
174181
fn transcript_lines(&self) -> Vec<Line<'static>> {
175182
let mut out: Vec<Line<'static>> = Vec::new();
176183
out.push("thinking".magenta().bold().into());
177-
out.extend(self.content.clone());
184+
let mut lines = Vec::new();
185+
append_markdown(
186+
&self.content,
187+
None,
188+
&mut lines,
189+
self.citation_context.clone(),
190+
);
191+
out.extend(lines);
178192
out
179193
}
180194
}
@@ -1065,7 +1079,7 @@ pub(crate) fn new_reasoning_block(
10651079
) -> TranscriptOnlyHistoryCell {
10661080
let mut lines: Vec<Line<'static>> = Vec::new();
10671081
lines.push(Line::from("thinking".magenta().italic()));
1068-
append_markdown(&full_reasoning_buffer, &mut lines, config);
1082+
append_markdown(&full_reasoning_buffer, None, &mut lines, config);
10691083
TranscriptOnlyHistoryCell { lines }
10701084
}
10711085

@@ -1089,14 +1103,12 @@ pub(crate) fn new_reasoning_summary_block(
10891103
// then we don't have a summary to inject into history
10901104
if after_close_idx < full_reasoning_buffer.len() {
10911105
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
1092-
let mut header_lines = Vec::new();
1093-
append_markdown(&header_buffer, &mut header_lines, config);
1094-
10951106
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
1096-
let mut summary_lines = Vec::new();
1097-
append_markdown(&summary_buffer, &mut summary_lines, config);
1098-
1099-
return Box::new(ReasoningSummaryCell::new(header_lines, summary_lines));
1107+
return Box::new(ReasoningSummaryCell::new(
1108+
header_buffer,
1109+
summary_buffer,
1110+
config.into(),
1111+
));
11001112
}
11011113
}
11021114
}

codex-rs/tui/src/markdown.rs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,55 @@ use codex_core::config::Config;
22
use codex_core::config_types::UriBasedFileOpener;
33
use ratatui::text::Line;
44
use std::path::Path;
5+
use std::path::PathBuf;
56

6-
pub(crate) fn append_markdown(
7+
#[derive(Clone, Debug)]
8+
pub struct MarkdownCitationContext {
9+
file_opener: UriBasedFileOpener,
10+
cwd: PathBuf,
11+
}
12+
13+
impl MarkdownCitationContext {
14+
pub(crate) fn new(file_opener: UriBasedFileOpener, cwd: PathBuf) -> Self {
15+
Self { file_opener, cwd }
16+
}
17+
}
18+
19+
impl From<&Config> for MarkdownCitationContext {
20+
fn from(config: &Config) -> Self {
21+
MarkdownCitationContext::new(config.file_opener, config.cwd.clone())
22+
}
23+
}
24+
25+
pub(crate) fn append_markdown<C>(
726
markdown_source: &str,
27+
width: Option<usize>,
828
lines: &mut Vec<Line<'static>>,
9-
config: &Config,
10-
) {
11-
append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd);
29+
citation_context: C,
30+
) where
31+
C: Into<MarkdownCitationContext>,
32+
{
33+
let citation_context: MarkdownCitationContext = citation_context.into();
34+
append_markdown_with_opener_and_cwd(
35+
markdown_source,
36+
width,
37+
lines,
38+
citation_context.file_opener,
39+
&citation_context.cwd,
40+
);
1241
}
1342

14-
fn append_markdown_with_opener_and_cwd(
43+
pub(crate) fn append_markdown_with_opener_and_cwd(
1544
markdown_source: &str,
45+
width: Option<usize>,
1646
lines: &mut Vec<Line<'static>>,
1747
file_opener: UriBasedFileOpener,
1848
cwd: &Path,
1949
) {
2050
// Render via pulldown-cmark and rewrite citations during traversal (outside code blocks).
2151
let rendered = crate::markdown_render::render_markdown_text_with_citations(
2252
markdown_source,
53+
width,
2354
file_opener.get_scheme(),
2455
cwd,
2556
);
@@ -36,7 +67,7 @@ mod tests {
3667
let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n";
3768
let cwd = Path::new("/");
3869
let mut out = Vec::new();
39-
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
70+
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd);
4071
let rendered: Vec<String> = out
4172
.iter()
4273
.map(|l| {
@@ -69,7 +100,7 @@ mod tests {
69100
let src = "Before\n\n code 1\n\nAfter\n";
70101
let cwd = Path::new("/");
71102
let mut out = Vec::new();
72-
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
103+
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
73104
let lines: Vec<String> = out
74105
.iter()
75106
.map(|l| {
@@ -87,7 +118,7 @@ mod tests {
87118
let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n";
88119
let cwd = Path::new("/");
89120
let mut out = Vec::new();
90-
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd);
121+
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd);
91122
let rendered: Vec<String> = out
92123
.iter()
93124
.map(|l| {
@@ -117,7 +148,7 @@ mod tests {
117148
let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n";
118149
let cwd = Path::new("/");
119150
let mut out = Vec::new();
120-
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
151+
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
121152
assert_eq!(
122153
out.len(),
123154
1,
@@ -143,6 +174,7 @@ mod tests {
143174
let mut out = Vec::new();
144175
append_markdown_with_opener_and_cwd(
145176
"1. Tight item\n",
177+
None,
146178
&mut out,
147179
UriBasedFileOpener::None,
148180
cwd,
@@ -166,7 +198,7 @@ mod tests {
166198
let src = "Loose vs. tight list items:\n1. Tight item\n";
167199
let cwd = Path::new("/");
168200
let mut out = Vec::new();
169-
append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd);
201+
append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd);
170202

171203
let lines: Vec<String> = out
172204
.iter()

0 commit comments

Comments
 (0)