mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
tui: render file links from target paths
This commit is contained in:
@@ -2666,6 +2666,7 @@ impl ChatWidget {
|
||||
}
|
||||
self.stream_controller = Some(StreamController::new(
|
||||
self.last_rendered_width.get().map(|w| w.saturating_sub(2)),
|
||||
self.config.cwd.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(controller) = self.stream_controller.as_mut()
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
use ratatui::text::Line;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn append_markdown(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width(markdown_source, width);
|
||||
append_markdown_with_cwd(markdown_source, width, None, lines);
|
||||
}
|
||||
|
||||
pub(crate) fn append_markdown_with_cwd(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
||||
markdown_source,
|
||||
width,
|
||||
cwd,
|
||||
);
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -16,7 +17,10 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use regex_lite::Regex;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use url::Url;
|
||||
|
||||
struct MarkdownStyles {
|
||||
h1: Style,
|
||||
@@ -80,10 +84,18 @@ pub fn render_markdown_text(input: &str) -> Text<'static> {
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_cwd(input, width, None)
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Text<'static> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
let parser = Parser::new_ext(input, options);
|
||||
let mut w = Writer::new(parser, width);
|
||||
let mut w = Writer::new(parser, width, cwd);
|
||||
w.run();
|
||||
w.text
|
||||
}
|
||||
@@ -92,9 +104,7 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
|
||||
struct LinkState {
|
||||
destination: String,
|
||||
show_destination: bool,
|
||||
hidden_location_suffix: Option<String>,
|
||||
label_start_span_idx: usize,
|
||||
label_styled: bool,
|
||||
local_display_text: Option<String>,
|
||||
}
|
||||
|
||||
fn should_render_link_destination(dest_url: &str) -> bool {
|
||||
@@ -130,6 +140,49 @@ fn is_local_path_like_link(dest_url: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn local_link_display_text(dest_url: &str, cwd: Option<&Path>) -> Option<String> {
|
||||
if !is_local_path_like_link(dest_url) {
|
||||
return None;
|
||||
}
|
||||
let (path, location_suffix) = split_local_link_destination(dest_url)?;
|
||||
let display_path = cwd.map_or_else(
|
||||
|| path.display().to_string(),
|
||||
|cwd| display_path_for(&path, cwd),
|
||||
);
|
||||
Some(format!("{display_path}{location_suffix}"))
|
||||
}
|
||||
|
||||
fn split_local_link_destination(dest_url: &str) -> Option<(PathBuf, String)> {
|
||||
if dest_url.starts_with("file://") {
|
||||
let url = Url::parse(dest_url).ok()?;
|
||||
let path = url.to_file_path().ok()?;
|
||||
let location_suffix = url
|
||||
.fragment()
|
||||
.map(|fragment| format!("#{fragment}"))
|
||||
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
|
||||
.unwrap_or_default();
|
||||
return Some((path, location_suffix));
|
||||
}
|
||||
|
||||
if let Some((path_part, fragment)) = dest_url.rsplit_once('#')
|
||||
&& HASH_LOCATION_SUFFIX_RE.is_match(fragment)
|
||||
{
|
||||
let location_suffix =
|
||||
normalize_markdown_hash_location_suffix(&format!("#{fragment}")).unwrap_or_default();
|
||||
return Some((PathBuf::from(path_part), location_suffix));
|
||||
}
|
||||
|
||||
if let Some(location_match) = COLON_LOCATION_SUFFIX_RE.find(dest_url) {
|
||||
let path_part = &dest_url[..location_match.start()];
|
||||
return Some((
|
||||
PathBuf::from(path_part),
|
||||
location_match.as_str().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Some((PathBuf::from(dest_url), String::new()))
|
||||
}
|
||||
|
||||
struct Writer<'a, I>
|
||||
where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
@@ -153,13 +206,14 @@ where
|
||||
current_subsequent_indent: Vec<Span<'static>>,
|
||||
current_line_style: Style,
|
||||
current_line_in_code_block: bool,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl<'a, I> Writer<'a, I>
|
||||
where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
{
|
||||
fn new(iter: I, wrap_width: Option<usize>) -> Self {
|
||||
fn new(iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
|
||||
Self {
|
||||
iter,
|
||||
text: Text::default(),
|
||||
@@ -180,9 +234,17 @@ where
|
||||
current_subsequent_indent: Vec::new(),
|
||||
current_line_style: Style::default(),
|
||||
current_line_in_code_block: false,
|
||||
cwd: cwd.map(Path::to_path_buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn suppress_local_link_label(&self) -> bool {
|
||||
self.link
|
||||
.as_ref()
|
||||
.and_then(|link| link.local_display_text.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn run(&mut self) {
|
||||
while let Some(ev) = self.iter.next() {
|
||||
self.handle_event(ev);
|
||||
@@ -194,10 +256,26 @@ where
|
||||
match event {
|
||||
Event::Start(tag) => self.start_tag(tag),
|
||||
Event::End(tag) => self.end_tag(tag),
|
||||
Event::Text(text) => self.text(text),
|
||||
Event::Code(code) => self.code(code),
|
||||
Event::SoftBreak => self.soft_break(),
|
||||
Event::HardBreak => self.hard_break(),
|
||||
Event::Text(text) => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.text(text);
|
||||
}
|
||||
}
|
||||
Event::Code(code) => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.code(code);
|
||||
}
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.soft_break();
|
||||
}
|
||||
}
|
||||
Event::HardBreak => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.hard_break();
|
||||
}
|
||||
}
|
||||
Event::Rule => {
|
||||
self.flush_current_line();
|
||||
if !self.text.lines.is_empty() {
|
||||
@@ -206,8 +284,16 @@ where
|
||||
self.push_line(Line::from("———"));
|
||||
self.needs_newline = true;
|
||||
}
|
||||
Event::Html(html) => self.html(html, false),
|
||||
Event::InlineHtml(html) => self.html(html, true),
|
||||
Event::Html(html) => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.html(html, false);
|
||||
}
|
||||
}
|
||||
Event::InlineHtml(html) => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.html(html, true);
|
||||
}
|
||||
}
|
||||
Event::FootnoteReference(_) => {}
|
||||
Event::TaskListMarker(_) => {}
|
||||
}
|
||||
@@ -231,9 +317,21 @@ where
|
||||
}
|
||||
Tag::List(start) => self.start_list(start),
|
||||
Tag::Item => self.start_item(),
|
||||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||||
Tag::Emphasis => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.push_inline_style(self.styles.emphasis);
|
||||
}
|
||||
}
|
||||
Tag::Strong => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.push_inline_style(self.styles.strong);
|
||||
}
|
||||
}
|
||||
Tag::Strikethrough => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.push_inline_style(self.styles.strikethrough);
|
||||
}
|
||||
}
|
||||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
@@ -257,7 +355,11 @@ where
|
||||
self.indent_stack.pop();
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
|
||||
if !self.suppress_local_link_label() {
|
||||
self.pop_inline_style();
|
||||
}
|
||||
}
|
||||
TagEnd::Link => self.pop_link(),
|
||||
TagEnd::HtmlBlock
|
||||
| TagEnd::FootnoteDefinition
|
||||
@@ -513,36 +615,9 @@ where
|
||||
|
||||
fn push_link(&mut self, dest_url: String) {
|
||||
let show_destination = should_render_link_destination(&dest_url);
|
||||
let label_styled = !show_destination;
|
||||
let label_start_span_idx = self
|
||||
.current_line_content
|
||||
.as_ref()
|
||||
.map(|line| line.spans.len())
|
||||
.unwrap_or(0);
|
||||
if label_styled {
|
||||
self.push_inline_style(self.styles.code);
|
||||
}
|
||||
self.link = Some(LinkState {
|
||||
show_destination,
|
||||
hidden_location_suffix: if is_local_path_like_link(&dest_url) {
|
||||
dest_url
|
||||
.rsplit_once('#')
|
||||
.and_then(|(_, fragment)| {
|
||||
HASH_LOCATION_SUFFIX_RE
|
||||
.is_match(fragment)
|
||||
.then(|| format!("#{fragment}"))
|
||||
})
|
||||
.and_then(|suffix| normalize_markdown_hash_location_suffix(&suffix))
|
||||
.or_else(|| {
|
||||
COLON_LOCATION_SUFFIX_RE
|
||||
.find(&dest_url)
|
||||
.map(|m| m.as_str().to_string())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
label_start_span_idx,
|
||||
label_styled,
|
||||
local_display_text: local_link_display_text(&dest_url, self.cwd.as_deref()),
|
||||
destination: dest_url,
|
||||
});
|
||||
}
|
||||
@@ -550,39 +625,11 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
if link.label_styled {
|
||||
self.pop_inline_style();
|
||||
}
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
self.push_span(")".into());
|
||||
} else if let Some(location_suffix) = link.hidden_location_suffix.as_deref() {
|
||||
let label_text = self
|
||||
.current_line_content
|
||||
.as_ref()
|
||||
.and_then(|line| {
|
||||
line.spans.get(link.label_start_span_idx..).map(|spans| {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if label_text
|
||||
.rsplit_once('#')
|
||||
.is_some_and(|(_, fragment)| HASH_LOCATION_SUFFIX_RE.is_match(fragment))
|
||||
|| COLON_LOCATION_SUFFIX_RE.find(&label_text).is_some()
|
||||
{
|
||||
// The label already carries a location suffix; don't duplicate it.
|
||||
} else {
|
||||
self.push_span(Span::styled(location_suffix.to_string(), self.styles.code));
|
||||
}
|
||||
if link.label_styled {
|
||||
self.pop_inline_style();
|
||||
}
|
||||
} else if link.label_styled {
|
||||
self.pop_inline_style();
|
||||
} else if let Some(local_display_text) = link.local_display_text {
|
||||
self.push_span(Span::styled(local_display_text, self.styles.code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
|
||||
render_markdown_text_with_width_and_cwd(input, None, Some(cwd))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
assert_eq!(render_markdown_text(""), Text::default());
|
||||
@@ -661,106 +667,109 @@ fn load_location_suffix_regexes() {
|
||||
|
||||
#[test]
|
||||
fn file_link_hides_destination() {
|
||||
let text = render_markdown_text(
|
||||
"[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[ignored label](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_appends_line_number_when_label_lacks_it() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_uses_target_path_relative_to_cwd() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"markdown_render.rs".cyan(),
|
||||
":74".cyan(),
|
||||
]));
|
||||
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_uses_label_for_line_number() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_ignores_label_when_rendering_visible_target() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter(["markdown_render.rs:74".cyan()]));
|
||||
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_appends_hash_anchor_when_label_lacks_it() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_normalizes_hash_anchor_for_terminal_clickability() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"markdown_render.rs".cyan(),
|
||||
":74:3".cyan(),
|
||||
]));
|
||||
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_uses_label_for_hash_anchor() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_ignores_hash_anchor_label() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3".cyan()]));
|
||||
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_appends_range_when_label_lacks_it() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_shows_range_from_target() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"markdown_render.rs".cyan(),
|
||||
":74:3-76:9".cyan(),
|
||||
]));
|
||||
let expected =
|
||||
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_uses_label_for_range() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_ignores_range_label() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter(["markdown_render.rs:74:3-76:9".cyan()]));
|
||||
let expected =
|
||||
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_appends_hash_range_when_label_lacks_it() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_normalizes_hash_range() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"markdown_render.rs".cyan(),
|
||||
":74:3-76:9".cyan(),
|
||||
]));
|
||||
let expected =
|
||||
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_file_link_label_after_styled_prefix_does_not_panic() {
|
||||
let text = render_markdown_text(
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"**bold** plain [foo\nbar](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from_iter([
|
||||
Line::from_iter(["bold".bold(), " plain ".into(), "foo".cyan()]),
|
||||
Line::from_iter(["bar".cyan(), ":74:3".cyan()]),
|
||||
]);
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"bold".bold(),
|
||||
" plain ".into(),
|
||||
"codex-rs/tui/src/markdown_render.rs:74:3".cyan(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_link_uses_label_for_hash_range() {
|
||||
let text = render_markdown_text(
|
||||
fn file_link_ignores_hash_range_label() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3-L76C9".cyan()]));
|
||||
let expected =
|
||||
Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan()]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
@@ -778,8 +787,9 @@ fn url_link_shows_destination() {
|
||||
|
||||
#[test]
|
||||
fn markdown_render_file_link_snapshot() {
|
||||
let text = render_markdown_text(
|
||||
let text = render_markdown_text_for_cwd(
|
||||
"See [markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74).",
|
||||
Path::new("/Users/example/code/codex"),
|
||||
);
|
||||
let rendered = text
|
||||
.lines
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::markdown;
|
||||
|
||||
@@ -8,14 +9,16 @@ pub(crate) struct MarkdownStreamCollector {
|
||||
buffer: String,
|
||||
committed_line_count: usize,
|
||||
width: Option<usize>,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl MarkdownStreamCollector {
|
||||
pub fn new(width: Option<usize>) -> Self {
|
||||
pub fn new(width: Option<usize>, cwd: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
buffer: String::new(),
|
||||
committed_line_count: 0,
|
||||
width,
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +44,7 @@ impl MarkdownStreamCollector {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rendered: Vec<Line<'static>> = Vec::new();
|
||||
markdown::append_markdown(&source, self.width, &mut rendered);
|
||||
markdown::append_markdown_with_cwd(&source, self.width, self.cwd.as_deref(), &mut rendered);
|
||||
let mut complete_line_count = rendered.len();
|
||||
if complete_line_count > 0
|
||||
&& crate::render::line_utils::is_blank_line_spaces_only(
|
||||
@@ -82,7 +85,7 @@ impl MarkdownStreamCollector {
|
||||
tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
|
||||
|
||||
let mut rendered: Vec<Line<'static>> = Vec::new();
|
||||
markdown::append_markdown(&source, self.width, &mut rendered);
|
||||
markdown::append_markdown_with_cwd(&source, self.width, self.cwd.as_deref(), &mut rendered);
|
||||
|
||||
let out = if self.committed_line_count >= rendered.len() {
|
||||
Vec::new()
|
||||
@@ -101,7 +104,7 @@ pub(crate) fn simulate_stream_markdown_for_tests(
|
||||
deltas: &[&str],
|
||||
finalize: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut collector = MarkdownStreamCollector::new(None);
|
||||
let mut collector = MarkdownStreamCollector::new(None, None);
|
||||
let mut out = Vec::new();
|
||||
for d in deltas {
|
||||
collector.push_delta(d);
|
||||
@@ -122,7 +125,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_commit_until_newline() {
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
let mut c = super::MarkdownStreamCollector::new(None, None);
|
||||
c.push_delta("Hello, world");
|
||||
let out = c.commit_complete_lines();
|
||||
assert!(out.is_empty(), "should not commit without newline");
|
||||
@@ -133,7 +136,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_commits_partial_line() {
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
let mut c = super::MarkdownStreamCollector::new(None, None);
|
||||
c.push_delta("Line without newline");
|
||||
let out = c.finalize_and_drain();
|
||||
assert_eq!(out.len(), 1);
|
||||
@@ -253,7 +256,7 @@ mod tests {
|
||||
async fn heading_starts_on_new_line_when_following_paragraph() {
|
||||
// Stream a paragraph line, then a heading on the next line.
|
||||
// Expect two distinct rendered lines: "Hello." and "Heading".
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
let mut c = super::MarkdownStreamCollector::new(None, None);
|
||||
c.push_delta("Hello.\n");
|
||||
let out1 = c.commit_complete_lines();
|
||||
let s1: Vec<String> = out1
|
||||
@@ -309,7 +312,7 @@ mod tests {
|
||||
// Paragraph without trailing newline, then a chunk that starts with the newline
|
||||
// and the heading text, then a final newline. The collector should first commit
|
||||
// only the paragraph line, and later commit the heading as its own line.
|
||||
let mut c = super::MarkdownStreamCollector::new(None);
|
||||
let mut c = super::MarkdownStreamCollector::new(None, None);
|
||||
c.push_delta("Sounds good!");
|
||||
// No commit yet
|
||||
assert!(c.commit_complete_lines().is_empty());
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/markdown_render_tests.rs
|
||||
assertion_line: 714
|
||||
assertion_line: 776
|
||||
expression: rendered
|
||||
---
|
||||
See markdown_render.rs:74.
|
||||
See codex-rs/tui/src/markdown_render.rs:74.
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::render::line_utils::prefix_lines;
|
||||
use crate::style::proposed_plan_style;
|
||||
use ratatui::prelude::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -18,9 +19,9 @@ pub(crate) struct StreamController {
|
||||
}
|
||||
|
||||
impl StreamController {
|
||||
pub(crate) fn new(width: Option<usize>) -> Self {
|
||||
pub(crate) fn new(width: Option<usize>, cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width),
|
||||
state: StreamState::new(width, Some(cwd)),
|
||||
finishing_after_drain: false,
|
||||
header_emitted: false,
|
||||
}
|
||||
@@ -117,7 +118,7 @@ pub(crate) struct PlanStreamController {
|
||||
impl PlanStreamController {
|
||||
pub(crate) fn new(width: Option<usize>) -> Self {
|
||||
Self {
|
||||
state: StreamState::new(width),
|
||||
state: StreamState::new(width, None),
|
||||
header_emitted: false,
|
||||
top_padding_emitted: false,
|
||||
}
|
||||
@@ -248,7 +249,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
|
||||
let mut ctrl = StreamController::new(None);
|
||||
let mut ctrl = StreamController::new(None, PathBuf::from("/workspace"));
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Exact deltas from the session log (section: Loose vs. tight list items)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! arrival timestamp so policy code can reason about oldest queued age without peeking into text.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -34,9 +35,9 @@ pub(crate) struct StreamState {
|
||||
|
||||
impl StreamState {
|
||||
/// Creates an empty stream state with an optional target wrap width.
|
||||
pub(crate) fn new(width: Option<usize>) -> Self {
|
||||
pub(crate) fn new(width: Option<usize>, cwd: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
collector: MarkdownStreamCollector::new(width),
|
||||
collector: MarkdownStreamCollector::new(width, cwd),
|
||||
queued_lines: VecDeque::new(),
|
||||
has_seen_delta: false,
|
||||
}
|
||||
@@ -105,7 +106,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn drain_n_clamps_to_available_lines() {
|
||||
let mut state = StreamState::new(None);
|
||||
let mut state = StreamState::new(None, None);
|
||||
state.enqueue(vec![Line::from("one")]);
|
||||
|
||||
let drained = state.drain_n(8);
|
||||
|
||||
Reference in New Issue
Block a user