mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
feat(tui): add OSC 8 web links to rich content (#24472)
## Why Wrapped URLs in rich TUI output, especially URLs rendered inside Markdown tables, are split across terminal rows. In terminals that support OSC 8 hyperlinks, treating each visible fragment as part of the complete destination enables reliable open-link and copy-link actions even after table layout wraps the URL. This addresses the semantic-link portion of #12200 and the behavior described in https://github.com/openai/codex/issues/12200#issuecomment-4535452980. It does not change ordinary drag-selection across bordered table rows. ## What Changed - Added shared TUI OSC 8 support that validates `http://` and `https://` destinations, sanitizes terminal payloads, and applies metadata separately from visible line width/layout. - Added semantic web-link annotations to assistant and proposed-plan Markdown, including explicit web links and bare web URLs in prose and table cells while excluding code and non-web Markdown destinations. - Preserved complete URL targets through table wrapping, narrow pipe fallback, streaming, transcript overlay rendering, history insertion, and resize replay. - Routed intentional Codex-owned links in notices, status/setup/app-link, feedback, onboarding, MCP/plugin help, memories, and update surfaces through the shared hyperlink handling. ## How to Test 1. Run Codex in a terminal with OSC 8 link support, such as Ghostty, and request an assistant response containing a Markdown table whose last column contains a long `https://` URL. 2. Make the terminal narrow enough for the URL to wrap across multiple bordered table rows. 3. Use the terminal's open-link or copy-link action on more than one wrapped URL fragment and confirm each fragment resolves to the complete original URL. 4. Resize the terminal after the table is rendered and repeat the link action to confirm the destination survives scrollback replay. 5. Open the transcript overlay while rich output is present and confirm web links remain interactive there. 6. As a regression check, render inline/fenced code containing URL text and a Markdown link such as `[https://example.com](mailto:support@example.com)`; confirm these do not acquire a web OSC 8 destination. Targeted automated coverage exercised Markdown links and exclusions, wrapped and pipe-fallback tables, streaming/transcript overlay propagation, status-link truncation, and rendered word-wrapping cell alignment. `just test -p codex-tui` was also run; it passed the hyperlink coverage and reproduced two unrelated existing guardian feature-flag test failures.
This commit is contained in:
@@ -473,7 +473,7 @@ struct SessionSummary {
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct InitialHistoryReplayBuffer {
|
||||
retained_lines: VecDeque<Line<'static>>,
|
||||
retained_lines: VecDeque<crate::terminal_hyperlinks::HyperlinkLine>,
|
||||
render_from_transcript_tail: bool,
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ pub(crate) struct App {
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
pub(crate) deferred_history_lines: Vec<Line<'static>>,
|
||||
pub(crate) deferred_history_lines: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
|
||||
has_emitted_history_lines: bool,
|
||||
transcript_reflow: TranscriptReflowState,
|
||||
initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>,
|
||||
|
||||
@@ -27,11 +27,12 @@ use super::InitialHistoryReplayBuffer;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
|
||||
use crate::tui;
|
||||
|
||||
struct ReflowCellDisplay {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ struct ReflowCellDisplay {
|
||||
/// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the
|
||||
/// rows here are a transient render product for a single terminal width.
|
||||
pub(super) struct ReflowRenderResult {
|
||||
pub(super) lines: Vec<Line<'static>>,
|
||||
pub(super) lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
pub(super) fn trailing_run_start<T: 'static>(transcript_cells: &[Arc<dyn HistoryCell>]) -> usize {
|
||||
@@ -75,12 +76,12 @@ impl App {
|
||||
&mut self,
|
||||
cell: &dyn HistoryCell,
|
||||
width: u16,
|
||||
) -> Vec<Line<'static>> {
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut display =
|
||||
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
if !display.is_empty() && !cell.is_stream_continuation() {
|
||||
if self.has_emitted_history_lines {
|
||||
display.insert(0, Line::from(""));
|
||||
display.insert(/*index*/ 0, HyperlinkLine::new(Line::from("")));
|
||||
} else {
|
||||
self.has_emitted_history_lines = true;
|
||||
}
|
||||
@@ -101,7 +102,10 @@ impl App {
|
||||
if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy());
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
display,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,14 +157,20 @@ impl App {
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines;
|
||||
if !reflowed_lines.is_empty() {
|
||||
tui.insert_history_lines(reflowed_lines);
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
reflowed_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let retained_lines = buffer.retained_lines.into_iter().collect::<Vec<_>>();
|
||||
tui.insert_history_lines_with_wrap_policy(retained_lines, self.history_line_wrap_policy());
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
retained_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn insert_history_cell_lines_with_initial_replay_buffer(
|
||||
@@ -190,7 +200,10 @@ impl App {
|
||||
} else if self.overlay.is_some() {
|
||||
self.deferred_history_lines.extend(display);
|
||||
} else {
|
||||
tui.insert_history_lines_with_wrap_policy(display, self.history_line_wrap_policy());
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
display,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,7 +223,7 @@ impl App {
|
||||
/// here would make copy, transcript overlay, and future replay paths disagree about history.
|
||||
pub(super) fn buffer_initial_history_replay_display_lines(
|
||||
buffer: &mut InitialHistoryReplayBuffer,
|
||||
display: Vec<Line<'static>>,
|
||||
display: Vec<HyperlinkLine>,
|
||||
max_rows: usize,
|
||||
) {
|
||||
buffer.retained_lines.extend(display);
|
||||
@@ -437,7 +450,7 @@ impl App {
|
||||
|
||||
self.deferred_history_lines.clear();
|
||||
if !reflowed_lines.is_empty() {
|
||||
tui.insert_history_lines_with_wrap_policy(
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
reflowed_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
@@ -462,7 +475,8 @@ impl App {
|
||||
while start > 0 {
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
let lines = cell.display_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
let lines = cell
|
||||
.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
rendered_rows += lines.len();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines,
|
||||
@@ -482,7 +496,10 @@ impl App {
|
||||
start -= 1;
|
||||
let cell = self.transcript_cells[start].clone();
|
||||
cell_displays.push_front(ReflowCellDisplay {
|
||||
lines: cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
|
||||
lines: cell.display_hyperlink_lines_for_mode(
|
||||
width,
|
||||
self.chat_widget.history_render_mode(),
|
||||
),
|
||||
is_stream_continuation: cell.is_stream_continuation(),
|
||||
});
|
||||
}
|
||||
@@ -492,7 +509,7 @@ impl App {
|
||||
for display in cell_displays {
|
||||
if !display.lines.is_empty() && !display.is_stream_continuation {
|
||||
if has_emitted_history_lines {
|
||||
reflowed_lines.push(Line::from(""));
|
||||
reflowed_lines.push(HyperlinkLine::new(Line::from("")));
|
||||
} else {
|
||||
has_emitted_history_lines = true;
|
||||
}
|
||||
|
||||
@@ -3906,8 +3906,9 @@ fn plain_line_cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
|
||||
}
|
||||
|
||||
fn rendered_line_text(line: &Line<'static>) -> String {
|
||||
line.spans
|
||||
fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> String {
|
||||
line.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
@@ -4019,7 +4020,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
|
||||
app.initial_history_replay_buffer
|
||||
.as_mut()
|
||||
.expect("initial replay buffer active"),
|
||||
vec![Line::from(format!("line {index}"))],
|
||||
vec![Line::from(format!("line {index}")).into()],
|
||||
/*max_rows*/ 3,
|
||||
);
|
||||
}
|
||||
@@ -4925,7 +4926,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
||||
app.transcript_cells.clone(),
|
||||
app.keymap.pager.clone(),
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
||||
app.backtrack.overlay_preview_active = true;
|
||||
app.backtrack.nth_user_message = 1;
|
||||
|
||||
@@ -5459,7 +5460,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
app.transcript_cells.clone(),
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line").into()];
|
||||
app.has_emitted_history_lines = true;
|
||||
app.backtrack.primed = true;
|
||||
app.backtrack.overlay_preview_active = true;
|
||||
|
||||
@@ -245,7 +245,10 @@ impl App {
|
||||
let was_backtrack = self.backtrack.overlay_preview_active;
|
||||
if !self.deferred_history_lines.is_empty() {
|
||||
let lines = std::mem::take(&mut self.deferred_history_lines);
|
||||
tui.insert_history_lines_with_wrap_policy(lines, self.history_line_wrap_policy());
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
self.overlay = None;
|
||||
self.backtrack.overlay_preview_active = false;
|
||||
@@ -263,8 +266,11 @@ impl App {
|
||||
.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width);
|
||||
for cell in &self.transcript_cells {
|
||||
tui.insert_history_lines_with_wrap_policy(
|
||||
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
|
||||
tui.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
cell.display_hyperlink_lines_for_mode(
|
||||
width,
|
||||
self.chat_widget.history_render_mode(),
|
||||
),
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
@@ -399,7 +405,7 @@ impl App {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
let width = frame.area().width.max(1);
|
||||
t.sync_live_tail(width, active_key, |w| {
|
||||
chat_widget.active_cell_transcript_lines(w)
|
||||
chat_widget.active_cell_transcript_hyperlink_lines(w)
|
||||
});
|
||||
t.render(frame.area(), frame.buffer);
|
||||
})?;
|
||||
|
||||
@@ -814,6 +814,7 @@ impl crate::render::renderable::Renderable for AppLinkView {
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(inner, buf);
|
||||
crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url);
|
||||
|
||||
if actions_area.height > 0 {
|
||||
let actions_area = Rect {
|
||||
@@ -1005,7 +1006,10 @@ mod tests {
|
||||
if symbol.is_empty() {
|
||||
' '
|
||||
} else {
|
||||
symbol.chars().next().unwrap_or(' ')
|
||||
crate::terminal_hyperlinks::strip_osc8(symbol)
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or(' ')
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
@@ -1325,7 +1329,10 @@ mod tests {
|
||||
if symbol.is_empty() {
|
||||
' '
|
||||
} else {
|
||||
symbol.chars().next().unwrap_or(' ')
|
||||
crate::terminal_hyperlinks::strip_osc8(symbol)
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or(' ')
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
|
||||
@@ -313,7 +313,7 @@ pub(crate) fn feedback_success_cell(
|
||||
include_logs: bool,
|
||||
thread_id: &str,
|
||||
feedback_audience: FeedbackAudience,
|
||||
) -> history_cell::PlainHistoryCell {
|
||||
) -> history_cell::WebHyperlinkHistoryCell {
|
||||
let prefix = if include_logs {
|
||||
"• Feedback uploaded."
|
||||
} else {
|
||||
@@ -359,7 +359,7 @@ pub(crate) fn feedback_success_cell(
|
||||
]);
|
||||
}
|
||||
}
|
||||
history_cell::PlainHistoryCell::new(lines)
|
||||
history_cell::WebHyperlinkHistoryCell::new(lines)
|
||||
}
|
||||
|
||||
fn issue_url_for_category(
|
||||
|
||||
@@ -421,6 +421,7 @@ impl Renderable for MemoriesSettingsView {
|
||||
}
|
||||
if self.reset_confirmation.is_none() {
|
||||
self.docs_link.clone().render(docs_area, buf);
|
||||
crate::terminal_hyperlinks::mark_url_hyperlink(buf, docs_area, MEMORIES_DOC_URL);
|
||||
}
|
||||
|
||||
let hint_area = Rect {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! visible immediately.
|
||||
//!
|
||||
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
|
||||
//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The
|
||||
//! during draws using `active_cell_transcript_key()` and
|
||||
//! `active_cell_transcript_hyperlink_lines()`. The
|
||||
//! cache key is designed to change when the active cell mutates in place or when its transcript
|
||||
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
|
||||
//! every draw.
|
||||
@@ -77,6 +78,7 @@ use crate::status::StatusHistoryHandle;
|
||||
use crate::status::format_directory_display;
|
||||
use crate::status::format_tokens_compact;
|
||||
use crate::status::rate_limit_snapshot_display_for_limit;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_title::SetTerminalTitleResult;
|
||||
use crate::terminal_title::clear_terminal_title;
|
||||
use crate::terminal_title::set_terminal_title;
|
||||
@@ -1824,28 +1826,37 @@ impl ChatWidget {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the active cell's transcript lines for a given terminal width.
|
||||
/// Returns the active cell's annotated transcript lines for a given terminal width.
|
||||
///
|
||||
/// This is a convenience for the transcript overlay live-tail path, and it intentionally
|
||||
/// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers
|
||||
/// should pass the same width the overlay uses; using a different width will cause wrapping
|
||||
/// mismatches between the main viewport and the transcript overlay.
|
||||
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
|
||||
pub(crate) fn active_cell_transcript_hyperlink_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
) -> Option<Vec<HyperlinkLine>> {
|
||||
let mut lines = Vec::new();
|
||||
if let Some(cell) = self.transcript.active_cell.as_ref() {
|
||||
lines.extend(cell.transcript_lines(width));
|
||||
lines.extend(cell.transcript_hyperlink_lines(width));
|
||||
}
|
||||
if let Some(hook_cell) = self.active_hook_cell.as_ref() {
|
||||
// Compute hook lines first so hidden hooks do not add a separator.
|
||||
let hook_lines = hook_cell.transcript_lines(width);
|
||||
let hook_lines = hook_cell.transcript_hyperlink_lines(width);
|
||||
if !hook_lines.is_empty() && !lines.is_empty() {
|
||||
lines.push("".into());
|
||||
lines.push(HyperlinkLine::from(""));
|
||||
}
|
||||
lines.extend(hook_lines);
|
||||
}
|
||||
(!lines.is_empty()).then_some(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
|
||||
self.active_cell_transcript_hyperlink_lines(width)
|
||||
.map(crate::terminal_hyperlinks::visible_lines)
|
||||
}
|
||||
|
||||
/// Return a reference to the widget's current config (includes any
|
||||
/// runtime overrides applied via TUI, e.g., model or approval policy).
|
||||
pub(crate) fn config_ref(&self) -> &Config {
|
||||
|
||||
@@ -2200,7 +2200,7 @@ async fn memories_settings_popup_snapshot() {
|
||||
|
||||
chat.open_memories_popup();
|
||||
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
let popup = strip_osc8_for_snapshot(&render_bottom_popup(&chat, /*width*/ 80));
|
||||
assert_chatwidget_snapshot!("memories_settings_popup", popup);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,35 @@ impl HistoryCell for PlainHistoryCell {
|
||||
plain_lines(self.lines.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebHyperlinkHistoryCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl WebHyperlinkHistoryCell {
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for WebHyperlinkHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone())
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PrefixedWrappedHistoryCell {
|
||||
text: Text<'static>,
|
||||
@@ -86,6 +115,38 @@ impl HistoryCell for CompositeHistoryCell {
|
||||
out
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let mut out = Vec::new();
|
||||
let mut first = true;
|
||||
for part in &self.parts {
|
||||
let mut lines = part.display_hyperlink_lines(width);
|
||||
if !lines.is_empty() {
|
||||
if !first {
|
||||
out.push(HyperlinkLine::from(""));
|
||||
}
|
||||
out.append(&mut lines);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let mut out = Vec::new();
|
||||
let mut first = true;
|
||||
for part in &self.parts {
|
||||
let mut lines = part.transcript_hyperlink_lines(width);
|
||||
if !lines.is_empty() {
|
||||
if !first {
|
||||
out.push(HyperlinkLine::from(""));
|
||||
}
|
||||
out.append(&mut lines);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
let mut first = true;
|
||||
|
||||
@@ -325,14 +325,17 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
||||
" • No MCP servers configured.".italic().into(),
|
||||
Line::from(vec![
|
||||
" See the ".into(),
|
||||
"\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}"
|
||||
.underlined(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://developers.openai.com/codex/mcp",
|
||||
"MCP docs",
|
||||
)
|
||||
.underlined(),
|
||||
" to configure them.".into(),
|
||||
])
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
];
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
PlainHistoryCell::new(lines)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -268,12 +268,20 @@ impl HistoryCell for ReasoningSummaryCell {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AgentMessageCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_first_line: bool,
|
||||
}
|
||||
|
||||
impl AgentMessageCell {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
||||
Self {
|
||||
lines: plain_hyperlink_lines(lines),
|
||||
is_first_line,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_hyperlink_lines(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
is_first_line,
|
||||
@@ -283,6 +291,10 @@ impl AgentMessageCell {
|
||||
|
||||
impl HistoryCell for AgentMessageCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let mut wrapped = Vec::new();
|
||||
for (index, line) in self.lines.iter().enumerate() {
|
||||
let initial_indent = if index == 0 && self.is_first_line {
|
||||
@@ -293,20 +305,23 @@ impl HistoryCell for AgentMessageCell {
|
||||
let mut subsequent_indent = Line::from(" ");
|
||||
subsequent_indent
|
||||
.spans
|
||||
.extend(crate::insert_history::leading_whitespace_prefix(line).spans);
|
||||
let line_wrapped = adaptive_wrap_line(
|
||||
line,
|
||||
.extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans);
|
||||
wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
|
||||
std::slice::from_ref(line),
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(initial_indent)
|
||||
.subsequent_indent(subsequent_indent),
|
||||
);
|
||||
push_owned_lines(&line_wrapped, &mut wrapped);
|
||||
));
|
||||
}
|
||||
wrapped
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
plain_lines(visible_lines(self.lines.clone()))
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
@@ -347,22 +362,32 @@ impl AgentMarkdownCell {
|
||||
|
||||
impl HistoryCell for AgentMarkdownCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let Some(wrap_width) =
|
||||
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
|
||||
else {
|
||||
return prefix_lines(vec![Line::default()], "• ".dim(), " ".into());
|
||||
return prefix_hyperlink_lines(
|
||||
vec![HyperlinkLine::new(Line::default())],
|
||||
"• ".dim(),
|
||||
" ".into(),
|
||||
);
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
// Re-render markdown from source at the current width. Reserve 2 columns for the "• " /
|
||||
// " " prefix prepended below.
|
||||
crate::markdown::append_markdown_agent_with_cwd(
|
||||
let lines = crate::markdown::render_markdown_agent_with_links_and_cwd(
|
||||
&self.markdown_source,
|
||||
Some(wrap_width),
|
||||
Some(self.cwd.as_path()),
|
||||
&mut lines,
|
||||
);
|
||||
prefix_lines(lines, "• ".dim(), " ".into())
|
||||
prefix_hyperlink_lines(lines, "• ".dim(), " ".into())
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
@@ -377,12 +402,12 @@ impl HistoryCell for AgentMarkdownCell {
|
||||
/// every delta and cleared when the stream finalizes.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StreamingAgentTailCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_first_line: bool,
|
||||
}
|
||||
|
||||
impl StreamingAgentTailCell {
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
||||
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
is_first_line,
|
||||
@@ -391,10 +416,14 @@ impl StreamingAgentTailCell {
|
||||
}
|
||||
|
||||
impl HistoryCell for StreamingAgentTailCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
// Tail lines are already rendered at the controller's current stream width.
|
||||
// Re-wrapping them here can split table borders and produce malformed in-flight rows.
|
||||
prefix_lines(
|
||||
prefix_hyperlink_lines(
|
||||
self.lines.clone(),
|
||||
if self.is_first_line {
|
||||
"• ".dim()
|
||||
@@ -405,8 +434,12 @@ impl HistoryCell for StreamingAgentTailCell {
|
||||
)
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
plain_lines(self.display_lines(/*width*/ u16::MAX))
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::live_wrap::take_prefix_by_width;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::markdown::append_markdown_agent_with_cwd;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
@@ -33,6 +32,11 @@ use crate::render::renderable::Renderable;
|
||||
use crate::session_state::ThreadSessionState;
|
||||
use crate::style::proposed_plan_style;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
#[cfg(test)]
|
||||
use crate::test_support::PathBufExt;
|
||||
#[cfg(test)]
|
||||
@@ -189,13 +193,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
/// Returns copy-friendly plain logical lines for raw scrollback mode.
|
||||
fn raw_lines(&self) -> Vec<Line<'static>>;
|
||||
|
||||
/// Returns rich visible lines plus terminal hyperlink metadata.
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
plain_hyperlink_lines(self.display_lines(width))
|
||||
}
|
||||
|
||||
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
|
||||
match mode {
|
||||
HistoryRenderMode::Rich => self.display_lines(width),
|
||||
HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)),
|
||||
HistoryRenderMode::Raw => self.raw_lines(),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines_for_mode(
|
||||
&self,
|
||||
width: u16,
|
||||
mode: HistoryRenderMode,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
match mode {
|
||||
HistoryRenderMode::Rich => self.display_hyperlink_lines(width),
|
||||
HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of viewport rows needed to render this cell.
|
||||
///
|
||||
/// The default delegates to `Paragraph::line_count` with
|
||||
@@ -224,13 +244,22 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
self.display_lines(width)
|
||||
}
|
||||
|
||||
/// Returns transcript-overlay lines plus terminal hyperlink metadata.
|
||||
///
|
||||
/// Defaults to the plain transcript representation because some cells render different
|
||||
/// display and transcript content. Rich cells whose transcript mirrors their display should
|
||||
/// delegate to `display_hyperlink_lines`.
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
plain_hyperlink_lines(self.transcript_lines(width))
|
||||
}
|
||||
|
||||
/// Returns the number of viewport rows for the transcript overlay.
|
||||
///
|
||||
/// Uses the same `Paragraph::line_count` measurement as
|
||||
/// `desired_height`. Contains a workaround for a ratatui bug where
|
||||
/// a single whitespace-only line reports 2 rows instead of 1.
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
let lines = self.transcript_lines(width);
|
||||
let lines = visible_lines(self.transcript_hyperlink_lines(width));
|
||||
// Workaround: ratatui's line_count returns 2 for a single
|
||||
// whitespace-only line. Clamp to 1 in that case.
|
||||
if let [line] = &lines[..]
|
||||
@@ -270,7 +299,8 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.display_lines(area.width);
|
||||
let hyperlink_lines = self.display_hyperlink_lines(area.width);
|
||||
let lines = visible_lines(hyperlink_lines.clone());
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
@@ -284,6 +314,7 @@ impl Renderable for Box<dyn HistoryCell> {
|
||||
// entire draw area first so stale glyphs from previous frames never linger.
|
||||
Clear.render(area, buf);
|
||||
paragraph.scroll((y, 0)).render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(y));
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
HistoryCell::desired_height(self.as_ref(), width)
|
||||
|
||||
@@ -71,6 +71,14 @@ impl HistoryCell for UpdateAvailableHistoryCell {
|
||||
Line::from("https://github.com/openai/codex/releases/latest"),
|
||||
]
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width))
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
}
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
@@ -129,6 +137,14 @@ impl HistoryCell for CyberPolicyNoticeCell {
|
||||
Line::from(TRUSTED_ACCESS_FOR_CYBER_URL),
|
||||
]
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
crate::terminal_hyperlinks::annotate_web_urls(self.display_lines(width))
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -9,12 +9,12 @@ use super::*;
|
||||
/// preview-only during streaming.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StreamingPlanTailCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
impl StreamingPlanTailCell {
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>, is_stream_continuation: bool) -> Self {
|
||||
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_stream_continuation: bool) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
is_stream_continuation,
|
||||
@@ -24,11 +24,19 @@ impl StreamingPlanTailCell {
|
||||
|
||||
impl HistoryCell for StreamingPlanTailCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
plain_lines(visible_lines(self.lines.clone()))
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
@@ -58,11 +66,11 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl
|
||||
/// Stream cells are display fragments, not source-backed history. They should be replaced by
|
||||
/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history.
|
||||
pub(crate) fn new_proposed_plan_stream(
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<impl Into<HyperlinkLine>>,
|
||||
is_stream_continuation: bool,
|
||||
) -> ProposedPlanStreamCell {
|
||||
ProposedPlanStreamCell {
|
||||
lines,
|
||||
lines: lines.into_iter().map(Into::into).collect(),
|
||||
is_stream_continuation,
|
||||
}
|
||||
}
|
||||
@@ -85,36 +93,43 @@ pub(crate) struct ProposedPlanCell {
|
||||
/// terminal resize.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProposedPlanStreamCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for ProposedPlanCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
|
||||
lines.push(Line::from(" "));
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
let mut plan_lines: Vec<Line<'static>> = vec![Line::from(" ")];
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let mut lines = vec![
|
||||
HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()),
|
||||
HyperlinkLine::new(Line::from(" ")),
|
||||
];
|
||||
|
||||
let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))];
|
||||
let plan_style = proposed_plan_style();
|
||||
let wrap_width = width.saturating_sub(4).max(1) as usize;
|
||||
let mut body: Vec<Line<'static>> = Vec::new();
|
||||
append_markdown_agent_with_cwd(
|
||||
let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd(
|
||||
&self.plan_markdown,
|
||||
Some(wrap_width),
|
||||
Some(self.cwd.as_path()),
|
||||
&mut body,
|
||||
);
|
||||
if body.is_empty() {
|
||||
body.push(Line::from("(empty)".dim().italic()));
|
||||
body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic())));
|
||||
}
|
||||
plan_lines.extend(prefix_lines(body, " ".into(), " ".into()));
|
||||
plan_lines.push(Line::from(" "));
|
||||
plan_lines.extend(prefix_hyperlink_lines(body, " ".into(), " ".into()));
|
||||
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
|
||||
|
||||
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
raw_lines_from_source(&self.plan_markdown)
|
||||
}
|
||||
@@ -122,11 +137,19 @@ impl HistoryCell for ProposedPlanCell {
|
||||
|
||||
impl HistoryCell for ProposedPlanStreamCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.lines.clone())
|
||||
plain_lines(visible_lines(self.lines.clone()))
|
||||
}
|
||||
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
|
||||
@@ -295,6 +295,47 @@ fn proposed_plan_cell_renders_markdown_table() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposed_plan_cell_preserves_wrapped_table_web_links() {
|
||||
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
||||
let plan = new_proposed_plan(
|
||||
format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n"),
|
||||
&test_cwd(),
|
||||
);
|
||||
|
||||
let lines = plan.display_hyperlink_lines(/*width*/ 32);
|
||||
let linked_rows = lines
|
||||
.iter()
|
||||
.filter(|line| !line.hyperlinks.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(linked_rows.len() > 1);
|
||||
assert!(linked_rows.iter().all(|line| {
|
||||
line.hyperlinks
|
||||
.iter()
|
||||
.all(|link| link.destination == destination)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_cell_preserves_child_web_links() {
|
||||
let destination = "https://chatgpt.com/codex/settings/usage";
|
||||
let cell = CompositeHistoryCell::new(vec![
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from("/status")])),
|
||||
Box::new(WebHyperlinkHistoryCell::new(vec![Line::from(destination)])),
|
||||
]);
|
||||
|
||||
let lines = cell.display_hyperlink_lines(/*width*/ 80);
|
||||
|
||||
assert_eq!(
|
||||
lines[2].hyperlinks,
|
||||
vec![crate::terminal_hyperlinks::TerminalHyperlink {
|
||||
columns: 0..destination.len(),
|
||||
destination: destination.to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proposed_plan_cell_unwraps_markdown_fenced_table() {
|
||||
let plan = new_proposed_plan(
|
||||
|
||||
@@ -7,6 +7,11 @@ use std::fmt;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::decorate_spans;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::remap_wrapped_line;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_line;
|
||||
use crate::wrapping::line_contains_url_like;
|
||||
@@ -85,6 +90,23 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy<B>(
|
||||
mode: InsertHistoryMode,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
plain_hyperlink_lines(lines.iter().map(line_to_static).collect()),
|
||||
mode,
|
||||
wrap_policy,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
mode: InsertHistoryMode,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
@@ -113,13 +135,21 @@ where
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) =>
|
||||
if line_contains_url_like(&line.line)
|
||||
&& !line_has_mixed_url_and_non_url_tokens(&line.line) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => adaptive_wrap_line(
|
||||
HistoryLineWrapPolicy::PreWrap => remap_wrapped_line(
|
||||
line,
|
||||
RtOptions::new(wrap_width).subsequent_indent(leading_whitespace_prefix(line)),
|
||||
adaptive_wrap_line(
|
||||
&line.line,
|
||||
RtOptions::new(wrap_width)
|
||||
.subsequent_indent(leading_whitespace_prefix(&line.line)),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
@@ -249,7 +279,11 @@ pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> {
|
||||
/// Render a single wrapped history line: clear continuation rows for wide lines,
|
||||
/// set foreground/background colors, and write styled spans. Caller is responsible
|
||||
/// for cursor positioning and any leading `\r\n`.
|
||||
fn write_history_line<W: Write>(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> {
|
||||
fn write_history_line<W: Write>(
|
||||
writer: &mut W,
|
||||
line: &HyperlinkLine,
|
||||
wrap_width: usize,
|
||||
) -> io::Result<()> {
|
||||
let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16;
|
||||
if physical_rows > 1 {
|
||||
queue!(writer, SavePosition)?;
|
||||
@@ -262,11 +296,13 @@ fn write_history_line<W: Write>(writer: &mut W, line: &Line, wrap_width: usize)
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(
|
||||
line.style
|
||||
line.line
|
||||
.style
|
||||
.fg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset),
|
||||
line.style
|
||||
line.line
|
||||
.style
|
||||
.bg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset)
|
||||
@@ -276,14 +312,20 @@ fn write_history_line<W: Write>(writer: &mut W, line: &Line, wrap_width: usize)
|
||||
// Merge line-level style into each span so that ANSI colors reflect
|
||||
// line styles (e.g., blockquotes with green fg).
|
||||
let merged_spans: Vec<Span> = line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style.patch(line.style),
|
||||
style: s.style.patch(line.line.style),
|
||||
content: s.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
write_spans(writer, merged_spans.iter())
|
||||
let merged_line = HyperlinkLine {
|
||||
line: Line::from(merged_spans),
|
||||
hyperlinks: line.hyperlinks.clone(),
|
||||
};
|
||||
let decorated = decorate_spans(&merged_line);
|
||||
write_spans(writer, decorated.iter())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -470,6 +512,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_semantic_web_link_without_changing_visible_text() {
|
||||
let destination = "https://example.com/long/path";
|
||||
let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination));
|
||||
let mut actual = Vec::new();
|
||||
|
||||
write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line");
|
||||
|
||||
let output = String::from_utf8(actual).expect("UTF-8 terminal output");
|
||||
assert!(output.contains("\x1b]8;;https://example.com/long/path\x07"));
|
||||
assert_eq!(line.line.spans[0].content, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_line_emits_green_fg() {
|
||||
// Set up a small off-screen terminal
|
||||
|
||||
@@ -177,6 +177,7 @@ mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod terminal_hyperlinks;
|
||||
mod terminal_palette;
|
||||
mod terminal_probe;
|
||||
mod terminal_title;
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::ops::Range;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::table_detect;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
|
||||
/// Render markdown source to styled ratatui lines and append them to `lines`.
|
||||
///
|
||||
@@ -56,20 +57,22 @@ pub(crate) fn append_markdown_agent(
|
||||
width: Option<usize>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
append_markdown_agent_with_cwd(markdown_source, width, /*cwd*/ None, lines);
|
||||
let normalized = unwrap_markdown_fences(markdown_source);
|
||||
let rendered = crate::markdown_render::render_markdown_text_with_width_and_cwd(
|
||||
&normalized,
|
||||
width,
|
||||
/*cwd*/ None,
|
||||
);
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
}
|
||||
|
||||
/// Render an agent message while resolving local file links relative to `cwd`.
|
||||
pub(crate) fn append_markdown_agent_with_cwd(
|
||||
pub(crate) fn render_markdown_agent_with_links_and_cwd(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
) {
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let normalized = unwrap_markdown_fences(markdown_source);
|
||||
let rendered =
|
||||
crate::markdown_render::render_markdown_text_with_width_and_cwd(&normalized, width, cwd);
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd)
|
||||
}
|
||||
|
||||
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
|
||||
|
||||
@@ -40,8 +40,12 @@
|
||||
use crate::render::highlight::foreground_style_for_scopes;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::style::table_separator_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::annotate_web_urls_in_line;
|
||||
use crate::terminal_hyperlinks::remap_wrapped_line;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::terminal_hyperlinks::web_destination;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_line;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
@@ -66,6 +70,7 @@ use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
@@ -136,7 +141,7 @@ impl IndentContext {
|
||||
/// `lines` are used for final rendering.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TableCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
// TableCell mutators inlined — called per-span during table event parsing.
|
||||
@@ -144,7 +149,7 @@ impl TableCell {
|
||||
#[inline]
|
||||
fn ensure_line(&mut self) {
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(Line::default());
|
||||
self.lines.push(HyperlinkLine::new(Line::default()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,13 +157,26 @@ impl TableCell {
|
||||
fn push_span(&mut self, span: Span<'static>) {
|
||||
self.ensure_line();
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
line.push_span(span);
|
||||
line.line.push_span(span);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
|
||||
self.ensure_line();
|
||||
if let Some(line) = self.lines.last_mut() {
|
||||
let shift = line.width();
|
||||
line.line.spans.append(&mut appended.line.spans);
|
||||
line.hyperlinks
|
||||
.extend(appended.hyperlinks.into_iter().map(|mut link| {
|
||||
link.columns = link.columns.start + shift..link.columns.end + shift;
|
||||
link
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn hard_break(&mut self) {
|
||||
self.lines.push(Line::default());
|
||||
self.lines.push(HyperlinkLine::new(Line::default()));
|
||||
}
|
||||
|
||||
fn plain_text(&self) -> String {
|
||||
@@ -168,7 +186,7 @@ impl TableCell {
|
||||
if i > 0 {
|
||||
buf.push(' ');
|
||||
}
|
||||
for span in &line.spans {
|
||||
for span in &line.line.spans {
|
||||
let _ = write!(buf, "{}", span.content);
|
||||
}
|
||||
}
|
||||
@@ -220,9 +238,9 @@ impl TableState {
|
||||
/// `spillover_lines` are prose rows extracted from parser artifacts and should
|
||||
/// be routed through normal wrapping.
|
||||
struct RenderedTableLines {
|
||||
table_lines: Vec<Line<'static>>,
|
||||
table_lines: Vec<HyperlinkLine>,
|
||||
table_lines_prewrapped: bool,
|
||||
spillover_lines: Vec<Line<'static>>,
|
||||
spillover_lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
/// Classification of a table column for width-allocation priority.
|
||||
@@ -287,6 +305,16 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Text<'static> {
|
||||
Text::from(visible_lines(render_markdown_lines_with_width_and_cwd(
|
||||
input, width, cwd,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_lines_with_width_and_cwd(
|
||||
input: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(Options::ENABLE_TABLES);
|
||||
@@ -338,7 +366,7 @@ where
|
||||
{
|
||||
input: &'a str,
|
||||
iter: I,
|
||||
text: Text<'static>,
|
||||
text: Vec<HyperlinkLine>,
|
||||
styles: MarkdownStyles,
|
||||
inline_styles: Vec<Style>,
|
||||
indent_stack: Vec<IndentContext>,
|
||||
@@ -356,7 +384,7 @@ where
|
||||
cwd: Option<PathBuf>,
|
||||
line_ends_with_local_link_target: bool,
|
||||
pending_local_link_soft_break: bool,
|
||||
current_line_content: Option<Line<'static>>,
|
||||
current_line_content: Option<HyperlinkLine>,
|
||||
current_initial_indent: Vec<Span<'static>>,
|
||||
current_subsequent_indent: Vec<Span<'static>>,
|
||||
current_line_style: Style,
|
||||
@@ -372,7 +400,7 @@ where
|
||||
Self {
|
||||
input,
|
||||
iter,
|
||||
text: Text::default(),
|
||||
text: Vec::new(),
|
||||
styles: MarkdownStyles::default(),
|
||||
inline_styles: Vec::new(),
|
||||
indent_stack: Vec::new(),
|
||||
@@ -417,7 +445,7 @@ where
|
||||
Event::HardBreak => self.hard_break(),
|
||||
Event::Rule => {
|
||||
self.flush_current_line();
|
||||
if !self.text.lines.is_empty() {
|
||||
if !self.text.is_empty() {
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.push_line(Line::from("———"));
|
||||
@@ -490,7 +518,7 @@ where
|
||||
TagEnd::Item => {
|
||||
self.flush_current_line();
|
||||
let start_line_count = self.list_item_start_line_counts.pop().unwrap_or_default();
|
||||
if self.text.lines.len().saturating_sub(start_line_count) > 1
|
||||
if self.text.len().saturating_sub(start_line_count) > 1
|
||||
&& let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
|
||||
{
|
||||
*needs_blank = true;
|
||||
@@ -613,12 +641,11 @@ where
|
||||
let has_content = self
|
||||
.current_line_content
|
||||
.as_ref()
|
||||
.map(|line| !line.spans.is_empty())
|
||||
.map(|line| !line.line.spans.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
self.text
|
||||
.lines
|
||||
.last()
|
||||
.map(|line| !line.spans.is_empty())
|
||||
.map(|line| !line.line.spans.is_empty())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if has_content {
|
||||
@@ -634,11 +661,8 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let content = line.to_string();
|
||||
let span = Span::styled(
|
||||
content,
|
||||
self.inline_styles.last().copied().unwrap_or_default(),
|
||||
);
|
||||
self.push_span(span);
|
||||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||||
self.push_text_spans(&content, style);
|
||||
}
|
||||
self.needs_newline = false;
|
||||
}
|
||||
@@ -748,7 +772,7 @@ where
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.flush_current_line();
|
||||
self.list_item_start_line_counts.push(self.text.lines.len());
|
||||
self.list_item_start_line_counts.push(self.text.len());
|
||||
self.pending_marker_line = true;
|
||||
let depth = self.list_indices.len();
|
||||
let is_ordered = self
|
||||
@@ -790,7 +814,7 @@ where
|
||||
|
||||
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
|
||||
self.flush_current_line();
|
||||
if !self.text.lines.is_empty() {
|
||||
if !self.text.is_empty() {
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.in_code_block = true;
|
||||
@@ -859,14 +883,14 @@ where
|
||||
if table_lines_prewrapped {
|
||||
self.push_prewrapped_line(line, pending_marker_line);
|
||||
} else {
|
||||
self.push_line(line);
|
||||
self.push_hyperlink_line(line);
|
||||
self.flush_current_line();
|
||||
}
|
||||
pending_marker_line = false;
|
||||
}
|
||||
self.pending_marker_line = false;
|
||||
for spillover_line in spillover_lines {
|
||||
self.push_line(spillover_line);
|
||||
self.push_hyperlink_line(spillover_line);
|
||||
self.flush_current_line();
|
||||
}
|
||||
self.needs_newline = true;
|
||||
@@ -986,7 +1010,29 @@ where
|
||||
if i > 0 {
|
||||
self.push_table_cell_hard_break();
|
||||
}
|
||||
self.push_span_to_table_cell(Span::styled(line.to_string(), style));
|
||||
self.push_text_spans_to_table_cell(line, style);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_text_spans_to_table_cell(&mut self, text: &str, style: Style) {
|
||||
let span = Span::styled(text.to_string(), style);
|
||||
let destination = self
|
||||
.link
|
||||
.as_ref()
|
||||
.and_then(|link| web_destination(&link.destination));
|
||||
let mut annotated = if let Some(destination) = destination {
|
||||
let mut annotated = HyperlinkLine::new(Line::default());
|
||||
annotated.push_span(span, Some(&destination));
|
||||
annotated
|
||||
} else if self.link.is_some() || self.in_code_block {
|
||||
HyperlinkLine::new(Line::from(span))
|
||||
} else {
|
||||
annotate_web_urls_in_line(Line::from(span))
|
||||
};
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_annotated(std::mem::take(&mut annotated));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1038,7 +1084,7 @@ where
|
||||
let available_width = self.available_table_width(column_count);
|
||||
let widths =
|
||||
self.compute_column_widths(&header, &rows, &table_state.alignments, available_width);
|
||||
let spillover_lines: Vec<Line<'static>> = spillover_rows
|
||||
let spillover_lines: Vec<HyperlinkLine> = spillover_rows
|
||||
.into_iter()
|
||||
.flat_map(|spillover| spillover.lines)
|
||||
.collect();
|
||||
@@ -1303,7 +1349,7 @@ where
|
||||
column_widths: &[usize],
|
||||
separator_char: char,
|
||||
style: Style,
|
||||
) -> Line<'static> {
|
||||
) -> HyperlinkLine {
|
||||
let segment_char = separator_char.to_string();
|
||||
let gap = " ".repeat(TABLE_COLUMN_GAP);
|
||||
let text = column_widths
|
||||
@@ -1311,7 +1357,7 @@ where
|
||||
.map(|width| segment_char.repeat(*width + (TABLE_CELL_PADDING * 2)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(&gap);
|
||||
Line::from(Span::styled(text, style))
|
||||
HyperlinkLine::new(Line::from(Span::styled(text, style)))
|
||||
}
|
||||
|
||||
fn render_table_row(
|
||||
@@ -1320,8 +1366,8 @@ where
|
||||
column_widths: &[usize],
|
||||
alignments: &[Alignment],
|
||||
row_style: Style,
|
||||
) -> Vec<Line<'static>> {
|
||||
let wrapped_cells: Vec<Vec<Line<'static>>> = row
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let wrapped_cells: Vec<Vec<HyperlinkLine>> = row
|
||||
.iter()
|
||||
.zip(column_widths)
|
||||
.map(|(cell, width)| self.wrap_cell(cell, *width))
|
||||
@@ -1333,9 +1379,9 @@ where
|
||||
let Some(last_visible_column) = wrapped_cells.iter().rposition(|lines| {
|
||||
lines
|
||||
.get(row_line)
|
||||
.is_some_and(|line| Self::line_display_width(line) > 0)
|
||||
.is_some_and(|line| Self::line_display_width(&line.line) > 0)
|
||||
}) else {
|
||||
out.push(Line::default().style(row_style));
|
||||
out.push(HyperlinkLine::new(Line::default().style(row_style)));
|
||||
continue;
|
||||
};
|
||||
let mut spans = Vec::new();
|
||||
@@ -1345,11 +1391,11 @@ where
|
||||
.take(last_visible_column + 1)
|
||||
{
|
||||
spans.push(Span::raw(" ".repeat(TABLE_CELL_PADDING)));
|
||||
let line = wrapped_cells[column]
|
||||
let mut line = wrapped_cells[column]
|
||||
.get(row_line)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let line_width = Self::line_display_width(&line);
|
||||
let line_width = Self::line_display_width(&line.line);
|
||||
let remaining = width.saturating_sub(line_width);
|
||||
let (left_padding, right_padding) = match alignments[column] {
|
||||
Alignment::Left | Alignment::None => (0, remaining),
|
||||
@@ -1359,7 +1405,7 @@ where
|
||||
if left_padding > 0 {
|
||||
spans.push(Span::raw(" ".repeat(left_padding)));
|
||||
}
|
||||
spans.extend(line.spans);
|
||||
spans.append(&mut line.line.spans);
|
||||
let is_last_column = column == last_visible_column;
|
||||
if right_padding > 0 && !is_last_column {
|
||||
spans.push(Span::raw(" ".repeat(right_padding)));
|
||||
@@ -1371,7 +1417,32 @@ where
|
||||
spans.push(Span::raw(" ".repeat(TABLE_COLUMN_GAP)));
|
||||
}
|
||||
}
|
||||
out.push(Line::from(spans).style(row_style));
|
||||
let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style));
|
||||
let mut column_start = 0usize;
|
||||
for (column, width) in column_widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(last_visible_column + 1)
|
||||
{
|
||||
column_start += TABLE_CELL_PADDING;
|
||||
if let Some(line) = wrapped_cells[column].get(row_line) {
|
||||
let remaining = width.saturating_sub(Self::line_display_width(&line.line));
|
||||
let left_padding = match alignments[column] {
|
||||
Alignment::Left | Alignment::None => 0,
|
||||
Alignment::Center => remaining / 2,
|
||||
Alignment::Right => remaining,
|
||||
};
|
||||
out_line
|
||||
.hyperlinks
|
||||
.extend(line.hyperlinks.iter().cloned().map(|mut link| {
|
||||
link.columns = link.columns.start + column_start + left_padding
|
||||
..link.columns.end + column_start + left_padding;
|
||||
link
|
||||
}));
|
||||
}
|
||||
column_start += *width + TABLE_CELL_PADDING + TABLE_COLUMN_GAP;
|
||||
}
|
||||
out.push(out_line);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1386,27 +1457,61 @@ where
|
||||
header: &[TableCell],
|
||||
rows: &[Vec<TableCell>],
|
||||
alignments: &[Alignment],
|
||||
) -> Vec<Line<'static>> {
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut out = Vec::new();
|
||||
out.push(Line::from(Self::row_to_pipe_string(header)));
|
||||
out.push(Line::from(Self::alignments_to_pipe_delimiter(alignments)));
|
||||
out.extend(
|
||||
rows.iter()
|
||||
.map(|row| Line::from(Self::row_to_pipe_string(row))),
|
||||
);
|
||||
out.push(Self::row_to_pipe_line(header));
|
||||
out.push(HyperlinkLine::new(Line::from(
|
||||
Self::alignments_to_pipe_delimiter(alignments),
|
||||
)));
|
||||
out.extend(rows.iter().map(|row| Self::row_to_pipe_line(row)));
|
||||
out
|
||||
}
|
||||
|
||||
fn row_to_pipe_string(row: &[TableCell]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push('|');
|
||||
fn row_to_pipe_line(row: &[TableCell]) -> HyperlinkLine {
|
||||
let mut out = HyperlinkLine::new(Line::default());
|
||||
out.push_span("|".into(), /*destination*/ None);
|
||||
for cell in row {
|
||||
out.push(' ');
|
||||
// Preserve literal `|` inside cell text in markdown fallback mode so
|
||||
// downstream markdown parsers keep the cell content intact.
|
||||
out.push_str(&cell.plain_text().replace('|', "\\|"));
|
||||
out.push(' ');
|
||||
out.push('|');
|
||||
out.push_span(" ".into(), /*destination*/ None);
|
||||
for (index, line) in cell.lines.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push_span(" ".into(), /*destination*/ None);
|
||||
}
|
||||
let text = line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
let mut column = 0usize;
|
||||
let mut current_destination = None;
|
||||
let mut current_text = String::new();
|
||||
let flush = |out: &mut HyperlinkLine,
|
||||
current_text: &mut String,
|
||||
destination: Option<&str>| {
|
||||
if !current_text.is_empty() {
|
||||
out.push_span(Span::raw(std::mem::take(current_text)), destination);
|
||||
}
|
||||
};
|
||||
for ch in text.chars() {
|
||||
let destination = line
|
||||
.hyperlinks
|
||||
.iter()
|
||||
.find(|link| link.columns.contains(&column))
|
||||
.map(|link| link.destination.as_str());
|
||||
if destination != current_destination {
|
||||
flush(&mut out, &mut current_text, current_destination);
|
||||
current_destination = destination;
|
||||
}
|
||||
if ch == '|' {
|
||||
current_text.push_str("\\|");
|
||||
} else {
|
||||
current_text.push(ch);
|
||||
}
|
||||
column += UnicodeWidthChar::width(ch).unwrap_or(/*default*/ 0);
|
||||
}
|
||||
flush(&mut out, &mut current_text, current_destination);
|
||||
}
|
||||
out.push_span(" |".into(), /*destination*/ None);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1433,21 +1538,25 @@ where
|
||||
/// Each logical line within the cell (separated by hard breaks) is wrapped
|
||||
/// independently. Empty cells produce a single blank line so the row grid
|
||||
/// stays aligned.
|
||||
fn wrap_cell(&self, cell: &TableCell, width: usize) -> Vec<Line<'static>> {
|
||||
fn wrap_cell(&self, cell: &TableCell, width: usize) -> Vec<HyperlinkLine> {
|
||||
if cell.lines.is_empty() {
|
||||
return vec![Line::default()];
|
||||
return vec![HyperlinkLine::new(Line::default())];
|
||||
}
|
||||
let mut wrapped = Vec::new();
|
||||
for source_line in &cell.lines {
|
||||
let rendered = word_wrap_line(source_line, RtOptions::new(width.max(1)));
|
||||
let rendered =
|
||||
word_wrap_line(&source_line.line, RtOptions::new(width.max(/*other*/ 1)))
|
||||
.into_iter()
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect::<Vec<_>>();
|
||||
if rendered.is_empty() {
|
||||
wrapped.push(Line::default());
|
||||
wrapped.push(HyperlinkLine::new(Line::default()));
|
||||
} else {
|
||||
push_owned_lines(&rendered, &mut wrapped);
|
||||
wrapped.extend(remap_wrapped_line(source_line, rendered));
|
||||
};
|
||||
}
|
||||
if wrapped.is_empty() {
|
||||
wrapped.push(Line::default());
|
||||
wrapped.push(HyperlinkLine::new(Line::default()));
|
||||
}
|
||||
wrapped
|
||||
}
|
||||
@@ -1559,7 +1668,7 @@ where
|
||||
fn cell_display_width(cell: &TableCell) -> usize {
|
||||
cell.lines
|
||||
.iter()
|
||||
.map(Self::line_display_width)
|
||||
.map(|line| Self::line_display_width(&line.line))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -1600,11 +1709,25 @@ where
|
||||
// line to avoid detached url lines.
|
||||
if self.in_table_cell() {
|
||||
self.push_span_to_table_cell(" (".into());
|
||||
self.push_span_to_table_cell(Span::styled(link.destination, self.styles.link));
|
||||
let mut destination = HyperlinkLine::new(Line::default());
|
||||
destination.push_span(
|
||||
Span::styled(link.destination.clone(), self.styles.link),
|
||||
web_destination(&link.destination).as_deref(),
|
||||
);
|
||||
if let Some(table_state) = self.table_state.as_mut()
|
||||
&& let Some(cell) = table_state.current_cell.as_mut()
|
||||
{
|
||||
cell.push_annotated(destination);
|
||||
}
|
||||
self.push_span_to_table_cell(")".into());
|
||||
} else {
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
let mut destination = HyperlinkLine::new(Line::default());
|
||||
destination.push_span(
|
||||
Span::styled(link.destination.clone(), self.styles.link),
|
||||
web_destination(&link.destination).as_deref(),
|
||||
);
|
||||
self.push_annotated(destination);
|
||||
self.push_span(")".into());
|
||||
}
|
||||
} else if let Some(local_target_display) = link.local_target_display {
|
||||
@@ -1638,7 +1761,7 @@ where
|
||||
}
|
||||
|
||||
fn flush_current_line(&mut self) {
|
||||
if let Some(line) = self.current_line_content.take() {
|
||||
if let Some(mut line) = self.current_line_content.take() {
|
||||
let style = self.current_line_style;
|
||||
// NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste.
|
||||
if !self.current_line_in_code_block
|
||||
@@ -1647,15 +1770,23 @@ where
|
||||
let opts = RtOptions::new(width)
|
||||
.initial_indent(self.current_initial_indent.clone().into())
|
||||
.subsequent_indent(self.current_subsequent_indent.clone().into());
|
||||
for wrapped in adaptive_wrap_line(&line, opts) {
|
||||
let owned = line_to_static(&wrapped).style(style);
|
||||
self.push_output_line(owned);
|
||||
let wrapped = adaptive_wrap_line(&line.line, opts)
|
||||
.into_iter()
|
||||
.map(|wrapped| line_to_static(&wrapped))
|
||||
.collect();
|
||||
for wrapped in remap_wrapped_line(&line, wrapped) {
|
||||
self.push_output_line(wrapped.style(style));
|
||||
}
|
||||
} else {
|
||||
let mut spans = self.current_initial_indent.clone();
|
||||
let mut line = line;
|
||||
spans.append(&mut line.spans);
|
||||
self.push_output_line(Line::from_iter(spans).style(style));
|
||||
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
|
||||
spans.append(&mut line.line.spans);
|
||||
for hyperlink in &mut line.hyperlinks {
|
||||
hyperlink.columns =
|
||||
hyperlink.columns.start + shift..hyperlink.columns.end + shift;
|
||||
}
|
||||
line.line = Line::from_iter(spans);
|
||||
self.push_output_line(line.style(style));
|
||||
}
|
||||
self.current_initial_indent.clear();
|
||||
self.current_subsequent_indent.clear();
|
||||
@@ -1670,25 +1801,30 @@ where
|
||||
/// Table lines are pre-formatted with exact column widths and separators.
|
||||
/// Passing them through `word_wrap_line` would break the layout at
|
||||
/// arbitrary positions. This method prepends the indent/blockquote prefix
|
||||
/// and pushes directly to `self.text.lines`.
|
||||
/// and pushes directly to `self.text`.
|
||||
fn is_blockquote_active(&self) -> bool {
|
||||
self.indent_stack
|
||||
.iter()
|
||||
.any(|ctx| ctx.prefix.iter().any(|p| p.content.contains('>')))
|
||||
}
|
||||
|
||||
fn push_prewrapped_line(&mut self, line: Line<'static>, pending_marker_line: bool) {
|
||||
fn push_prewrapped_line(&mut self, mut line: HyperlinkLine, pending_marker_line: bool) {
|
||||
self.flush_current_line();
|
||||
let blockquote_active = self.is_blockquote_active();
|
||||
let style = if blockquote_active {
|
||||
self.styles.blockquote.patch(line.style)
|
||||
self.styles.blockquote.patch(line.line.style)
|
||||
} else {
|
||||
line.style
|
||||
line.line.style
|
||||
};
|
||||
|
||||
let mut spans = self.prefix_spans(pending_marker_line);
|
||||
spans.extend(line.spans);
|
||||
self.push_output_line(Line::from(spans).style(style));
|
||||
let shift = spans.iter().map(|span| span.content.width()).sum::<usize>();
|
||||
spans.append(&mut line.line.spans);
|
||||
for hyperlink in &mut line.hyperlinks {
|
||||
hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
|
||||
}
|
||||
line.line = Line::from(spans);
|
||||
self.push_output_line(line.style(style));
|
||||
}
|
||||
|
||||
fn push_line(&mut self, line: Line<'static>) {
|
||||
@@ -1704,33 +1840,74 @@ where
|
||||
self.current_initial_indent = self.prefix_spans(was_pending);
|
||||
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
|
||||
self.current_line_style = style;
|
||||
self.current_line_content = Some(line);
|
||||
self.current_line_content = Some(HyperlinkLine::new(line));
|
||||
self.current_line_in_code_block = self.in_code_block;
|
||||
self.line_ends_with_local_link_target = false;
|
||||
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
|
||||
fn push_hyperlink_line(&mut self, line: HyperlinkLine) {
|
||||
let hyperlinks = line.hyperlinks;
|
||||
self.push_line(line.line);
|
||||
if let Some(current) = self.current_line_content.as_mut() {
|
||||
current.hyperlinks = hyperlinks;
|
||||
}
|
||||
}
|
||||
|
||||
fn push_span(&mut self, span: Span<'static>) {
|
||||
if let Some(line) = self.current_line_content.as_mut() {
|
||||
line.push_span(span);
|
||||
line.line.push_span(span);
|
||||
} else {
|
||||
self.push_line(Line::from(vec![span]));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_annotated(&mut self, mut appended: HyperlinkLine) {
|
||||
if self.current_line_content.is_none() {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
if let Some(line) = self.current_line_content.as_mut() {
|
||||
let shift = line.width();
|
||||
line.line.spans.append(&mut appended.line.spans);
|
||||
line.hyperlinks
|
||||
.extend(appended.hyperlinks.into_iter().map(|mut link| {
|
||||
link.columns = link.columns.start + shift..link.columns.end + shift;
|
||||
link
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_text_spans(&mut self, text: &str, style: Style) {
|
||||
let span = Span::styled(text.to_string(), style);
|
||||
let destination = self
|
||||
.link
|
||||
.as_ref()
|
||||
.and_then(|link| web_destination(&link.destination));
|
||||
let annotated = if let Some(destination) = destination {
|
||||
let mut annotated = HyperlinkLine::new(Line::default());
|
||||
annotated.push_span(span, Some(&destination));
|
||||
annotated
|
||||
} else if self.link.is_some() || self.in_code_block {
|
||||
HyperlinkLine::new(Line::from(span))
|
||||
} else {
|
||||
annotate_web_urls_in_line(Line::from(span))
|
||||
};
|
||||
self.push_annotated(annotated);
|
||||
}
|
||||
|
||||
fn push_blank_line(&mut self) {
|
||||
self.flush_current_line();
|
||||
if self.indent_stack.iter().all(|ctx| ctx.is_list) {
|
||||
self.push_output_line(Line::default());
|
||||
self.push_output_line(HyperlinkLine::new(Line::default()));
|
||||
} else {
|
||||
self.push_line(Line::default());
|
||||
self.flush_current_line();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_output_line(&mut self, line: Line<'static>) {
|
||||
self.text.lines.push(line);
|
||||
fn push_output_line(&mut self, line: HyperlinkLine) {
|
||||
self.text.push(line);
|
||||
}
|
||||
|
||||
fn prefix_spans(&self, pending_marker_line: bool) -> Vec<Span<'static>> {
|
||||
@@ -2187,7 +2364,8 @@ mod tests {
|
||||
let rendered = wrapped
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
line.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.clone())
|
||||
.collect::<String>()
|
||||
@@ -2444,4 +2622,85 @@ mod tests {
|
||||
);
|
||||
assert!(!W::is_spillover_row(&row, Some(&next)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotates_explicit_web_link_label_and_visible_destination() {
|
||||
let lines = render_markdown_lines_with_width_and_cwd(
|
||||
"See [docs](https://example.com/reference).",
|
||||
/*width*/ Some(80),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
let links = lines
|
||||
.iter()
|
||||
.flat_map(|line| line.hyperlinks.iter())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(links.len(), 2);
|
||||
assert!(
|
||||
links
|
||||
.iter()
|
||||
.all(|link| link.destination == "https://example.com/reference")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_table_url_fragments_keep_complete_web_destination() {
|
||||
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
||||
let markdown = format!("| Item | URL |\n| --- | --- |\n| report | {destination} |\n");
|
||||
let lines = render_markdown_lines_with_width_and_cwd(
|
||||
&markdown,
|
||||
/*width*/ Some(32),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
let linked_rows = lines
|
||||
.iter()
|
||||
.filter(|line| !line.hyperlinks.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
linked_rows.len() > 1,
|
||||
"expected a URL wrapped across table rows"
|
||||
);
|
||||
assert!(linked_rows.iter().all(|line| {
|
||||
line.hyperlinks
|
||||
.iter()
|
||||
.all(|link| link.destination == destination)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_annotate_code_or_non_web_markdown_links() {
|
||||
let markdown = "`https://example.com/inline`\n\n```text\nhttps://example.com/block\n```\n\n[mail](mailto:test@example.com)\n\n[https://example.com/label](mailto:test@example.com)\n\n| Target |\n| --- |\n| [https://example.com/table-label](mailto:test@example.com) |";
|
||||
let lines = render_markdown_lines_with_width_and_cwd(
|
||||
markdown,
|
||||
/*width*/ Some(80),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
|
||||
assert!(lines.iter().all(|line| line.hyperlinks.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_table_fallback_keeps_web_annotations() {
|
||||
let destination = "https://example.com/a/long/path";
|
||||
let target = "https://target.example/path";
|
||||
let code_url = "https://code.example/not-a-link";
|
||||
let markdown = format!(
|
||||
"| URL | Code | Label |\n| --- | --- | --- |\n| {destination} | `{code_url}` | [https://shown.example]({target}) |\n"
|
||||
);
|
||||
let lines = render_markdown_lines_with_width_and_cwd(
|
||||
&markdown,
|
||||
/*width*/ Some(5),
|
||||
/*cwd*/ None,
|
||||
);
|
||||
let destinations = lines
|
||||
.iter()
|
||||
.flat_map(|line| line.hyperlinks.iter().map(|link| link.destination.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(destinations.contains(&destination));
|
||||
assert!(destinations.contains(&target));
|
||||
assert!(!destinations.contains(&code_url));
|
||||
assert!(!destinations.contains(&"https://shown.example"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,48 +61,12 @@ use crate::tui::FrameRequester;
|
||||
/// row boundary, which breaks normal terminal URL detection for long URLs that
|
||||
/// wrap across multiple rows.
|
||||
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
mark_hyperlink_cells(buf, area, url, |cell| {
|
||||
cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
crate::terminal_hyperlinks::mark_url_hyperlink(buf, area, url);
|
||||
}
|
||||
|
||||
/// Marks any underlined buffer cells as an OSC 8 hyperlink.
|
||||
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) {
|
||||
mark_hyperlink_cells(buf, area, url, |cell| {
|
||||
cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_hyperlink_cells(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
url: &str,
|
||||
should_mark: impl Fn(&ratatui::buffer::Cell) -> bool,
|
||||
) {
|
||||
// Sanitize: strip any characters that could break out of the OSC 8
|
||||
// sequence (ESC or BEL) to prevent terminal escape injection from a
|
||||
// malformed or compromised upstream URL.
|
||||
let safe_url: String = url
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect();
|
||||
if safe_url.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
let cell = &mut buf[(x, y)];
|
||||
if !should_mark(cell) {
|
||||
continue;
|
||||
}
|
||||
let sym = cell.symbol().to_string();
|
||||
if sym.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07"));
|
||||
}
|
||||
}
|
||||
crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, url);
|
||||
}
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
@@ -580,24 +544,36 @@ impl AuthModeWidget {
|
||||
|
||||
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
"✓ Signed in with your ChatGPT account".fg(Color::Green).into(),
|
||||
"✓ Signed in with your ChatGPT account"
|
||||
.fg(Color::Green)
|
||||
.into(),
|
||||
"".into(),
|
||||
" Before you start:".into(),
|
||||
"".into(),
|
||||
" Decide how much autonomy you want to grant Codex".into(),
|
||||
Line::from(vec![
|
||||
" For more details see the ".into(),
|
||||
"\u{1b}]8;;https://developers.openai.com/codex/security\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://developers.openai.com/codex/security",
|
||||
"Codex docs",
|
||||
)
|
||||
.underlined(),
|
||||
])
|
||||
.dim(),
|
||||
"".into(),
|
||||
" Codex can make mistakes".into(),
|
||||
" Review the code it writes and commands it runs".dim().into(),
|
||||
" Review the code it writes and commands it runs"
|
||||
.dim()
|
||||
.into(),
|
||||
"".into(),
|
||||
" Powered by your ChatGPT account".into(),
|
||||
Line::from(vec![
|
||||
" Uses your plan's rate limits and ".into(),
|
||||
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(),
|
||||
crate::terminal_hyperlinks::osc8_hyperlink(
|
||||
"https://chatgpt.com/#settings",
|
||||
"training data preferences",
|
||||
)
|
||||
.underlined(),
|
||||
])
|
||||
.dim(),
|
||||
"".into(),
|
||||
|
||||
@@ -29,6 +29,9 @@ use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -395,10 +398,12 @@ struct CellRenderable {
|
||||
|
||||
impl Renderable for CellRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let p = Paragraph::new(Text::from(self.cell.transcript_lines(area.width)))
|
||||
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
|
||||
let p = Paragraph::new(Text::from(visible_lines(hyperlink_lines.clone())))
|
||||
.style(self.style)
|
||||
.wrap(Wrap { trim: false });
|
||||
p.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
@@ -406,6 +411,27 @@ impl Renderable for CellRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
struct HyperlinkLinesRenderable {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl Renderable for HyperlinkLinesRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &self.lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
/// Pager UI state and the renderables currently displayed.
|
||||
///
|
||||
@@ -612,7 +638,7 @@ impl TranscriptOverlay {
|
||||
&mut self,
|
||||
width: u16,
|
||||
active_key: Option<ActiveCellTranscriptKey>,
|
||||
compute_lines: impl FnOnce(u16) -> Option<Vec<Line<'static>>>,
|
||||
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
|
||||
) {
|
||||
let next_key = active_key.map(|key| LiveTailKey {
|
||||
width,
|
||||
@@ -678,12 +704,12 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
fn live_tail_renderable(
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
has_prior_cells: bool,
|
||||
is_stream_continuation: bool,
|
||||
) -> Box<dyn Renderable> {
|
||||
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
||||
let mut renderable: Box<dyn Renderable> = Box::new(CachedRenderable::new(paragraph));
|
||||
let mut renderable: Box<dyn Renderable> =
|
||||
Box::new(CachedRenderable::new(HyperlinkLinesRenderable { lines }));
|
||||
if has_prior_cells && !is_stream_continuation {
|
||||
renderable = Box::new(InsetRenderable::new(
|
||||
renderable,
|
||||
@@ -1041,6 +1067,27 @@ mod tests {
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_semantic_web_links() {
|
||||
let destination = "https://example.com/a/very/long/path";
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new(
|
||||
destination.to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
))]);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
assert!(area.positions().any(|position| {
|
||||
buf[position]
|
||||
.symbol()
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_renders_live_tail() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
@@ -1053,7 +1100,7 @@ mod tests {
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![Line::from("tail")]),
|
||||
|_| Some(vec![HyperlinkLine::from("tail")]),
|
||||
);
|
||||
|
||||
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
|
||||
@@ -1062,6 +1109,37 @@ mod tests {
|
||||
assert_snapshot!(term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_live_tail_preserves_semantic_web_links() {
|
||||
let destination = "https://example.com/a/streamed/path";
|
||||
let cell = history_cell::AgentMarkdownCell::new(
|
||||
destination.to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
);
|
||||
let mut overlay = transcript_overlay(Vec::new());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.sync_live_tail(
|
||||
area.width,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|width| Some(cell.transcript_hyperlink_lines(width)),
|
||||
);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
assert!(area.positions().any(|position| {
|
||||
buf[position]
|
||||
.symbol()
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
@@ -1077,11 +1155,11 @@ mod tests {
|
||||
|
||||
overlay.sync_live_tail(/*width*/ 40, Some(key), |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(vec![Line::from("tail")])
|
||||
Some(vec![HyperlinkLine::from("tail")])
|
||||
});
|
||||
overlay.sync_live_tail(/*width*/ 40, Some(key), |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(vec![Line::from("tail2")])
|
||||
Some(vec![HyperlinkLine::from("tail2")])
|
||||
});
|
||||
|
||||
assert_eq!(calls.get(), 1);
|
||||
|
||||
@@ -26,6 +26,7 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
@@ -52,6 +53,8 @@ use crate::wrapping::word_wrap_lines;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
const CHATGPT_USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StatusContextWindowData {
|
||||
percent_remaining: i64,
|
||||
@@ -763,9 +766,7 @@ impl HistoryCell for StatusHistoryCell {
|
||||
|
||||
let note_first_line = Line::from(vec![
|
||||
Span::from("Visit ").cyan(),
|
||||
"https://chatgpt.com/codex/settings/usage"
|
||||
.cyan()
|
||||
.underlined(),
|
||||
CHATGPT_USAGE_URL.cyan().underlined(),
|
||||
Span::from(" for up-to-date").cyan(),
|
||||
]);
|
||||
let note_second_line = Line::from(vec![
|
||||
@@ -861,6 +862,38 @@ impl HistoryCell for StatusHistoryCell {
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
|
||||
let mut lines =
|
||||
crate::terminal_hyperlinks::plain_hyperlink_lines(self.display_lines(width));
|
||||
for line in &mut lines {
|
||||
let visible = line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
if let Some(start_byte) = visible.find(CHATGPT_USAGE_URL) {
|
||||
let start = visible[..start_byte].width();
|
||||
line.hyperlinks
|
||||
.push(crate::terminal_hyperlinks::TerminalHyperlink {
|
||||
columns: start..start + CHATGPT_USAGE_URL.width(),
|
||||
destination: CHATGPT_USAGE_URL.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> Option<String> {
|
||||
|
||||
@@ -668,6 +668,25 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin
|
||||
rendered.contains("https://chatgpt.com/codex/settings/usage"),
|
||||
"expected /status to show ChatGPT usage link for OpenAI-auth proxy, got: {rendered}"
|
||||
);
|
||||
|
||||
let wide_destinations: Vec<String> = composite
|
||||
.display_hyperlink_lines(/*width*/ 120)
|
||||
.into_iter()
|
||||
.flat_map(|line| line.hyperlinks.into_iter())
|
||||
.map(|link| link.destination)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
wide_destinations,
|
||||
vec!["https://chatgpt.com/codex/settings/usage"]
|
||||
);
|
||||
|
||||
let narrow_destinations: Vec<String> = composite
|
||||
.display_hyperlink_lines(/*width*/ 24)
|
||||
.into_iter()
|
||||
.flat_map(|line| line.hyperlinks.into_iter())
|
||||
.map(|link| link.destination)
|
||||
.collect();
|
||||
assert_eq!(narrow_destinations, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -39,9 +39,11 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::raw_lines_from_source;
|
||||
use crate::history_cell::{self};
|
||||
use crate::markdown::append_markdown_agent_with_cwd;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::markdown::render_markdown_agent_with_links_and_cwd;
|
||||
use crate::style::proposed_plan_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
|
||||
use ratatui::prelude::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use std::path::Path;
|
||||
@@ -75,7 +77,7 @@ struct StreamCore {
|
||||
/// Accumulated raw markdown source for the current stream.
|
||||
raw_source: String,
|
||||
/// Full re-render of `raw_source` at `width`. Rebuilt on every committed delta.
|
||||
rendered_lines: Vec<Line<'static>>,
|
||||
rendered_lines: Vec<HyperlinkLine>,
|
||||
/// Lines enqueued into the commit-animation queue.
|
||||
enqueued_stable_len: usize,
|
||||
/// Lines actually emitted to scrollback.
|
||||
@@ -149,7 +151,7 @@ impl StreamCore {
|
||||
/// final render is the canonical transcript representation used for
|
||||
/// consolidation, so callers that skip `reset()` can accidentally replay a
|
||||
/// finished stream into the next answer.
|
||||
fn finalize_remaining(&mut self) -> Vec<Line<'static>> {
|
||||
fn finalize_remaining(&mut self) -> Vec<HyperlinkLine> {
|
||||
let remainder_source = self.state.collector.finalize_and_drain_source();
|
||||
if !remainder_source.is_empty() {
|
||||
self.raw_source.push_str(&remainder_source);
|
||||
@@ -164,14 +166,14 @@ impl StreamCore {
|
||||
}
|
||||
|
||||
/// Step animation: dequeue one line, update the emitted count.
|
||||
fn tick(&mut self) -> Vec<Line<'static>> {
|
||||
fn tick(&mut self) -> Vec<HyperlinkLine> {
|
||||
let step = self.state.step();
|
||||
self.emitted_stable_len += step.len();
|
||||
step
|
||||
}
|
||||
|
||||
/// Batch drain: dequeue up to `max_lines`, update the emitted count.
|
||||
fn tick_batch(&mut self, max_lines: usize) -> Vec<Line<'static>> {
|
||||
fn tick_batch(&mut self, max_lines: usize) -> Vec<HyperlinkLine> {
|
||||
if max_lines == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -209,7 +211,7 @@ impl StreamCore {
|
||||
/// `emitted_stable_len` instead, queued-but-not-yet-emitted lines could
|
||||
/// reappear in the active cell and duplicate content on screen.
|
||||
#[inline]
|
||||
fn current_tail_lines(&self) -> Vec<Line<'static>> {
|
||||
fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
||||
let start = self.enqueued_stable_len.min(self.rendered_lines.len());
|
||||
self.rendered_lines[start..].to_vec()
|
||||
}
|
||||
@@ -273,19 +275,14 @@ impl StreamCore {
|
||||
self.holdback_scanner.reset();
|
||||
}
|
||||
|
||||
fn render_source(&self, source: &str) -> Vec<Line<'static>> {
|
||||
fn render_source(&self, source: &str) -> Vec<HyperlinkLine> {
|
||||
match self.render_mode {
|
||||
HistoryRenderMode::Rich => {
|
||||
let mut rendered = Vec::new();
|
||||
append_markdown_agent_with_cwd(
|
||||
source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut rendered,
|
||||
);
|
||||
rendered
|
||||
}
|
||||
HistoryRenderMode::Raw => raw_lines_from_source(source),
|
||||
HistoryRenderMode::Rich => render_markdown_agent_with_links_and_cwd(
|
||||
source,
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
),
|
||||
HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,12 +434,10 @@ impl StreamCore {
|
||||
}
|
||||
|
||||
let render_start = Instant::now();
|
||||
let mut stable_prefix_render = Vec::new();
|
||||
append_markdown_agent_with_cwd(
|
||||
let stable_prefix_render = render_markdown_agent_with_links_and_cwd(
|
||||
&self.raw_source[..source_start.min(self.raw_source.len())],
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
&mut stable_prefix_render,
|
||||
);
|
||||
let stable_prefix_len = stable_prefix_render.len();
|
||||
tracing::trace!(
|
||||
@@ -528,7 +523,7 @@ impl StreamController {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn current_tail_lines(&self) -> Vec<Line<'static>> {
|
||||
pub(crate) fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
||||
self.core.current_tail_lines()
|
||||
}
|
||||
|
||||
@@ -555,15 +550,17 @@ impl StreamController {
|
||||
self.core.set_render_mode(render_mode);
|
||||
}
|
||||
|
||||
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
|
||||
fn emit(&mut self, lines: Vec<HyperlinkLine>) -> Option<Box<dyn HistoryCell>> {
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Box::new(history_cell::AgentMessageCell::new(lines, {
|
||||
let header_emitted = self.header_emitted;
|
||||
self.header_emitted = true;
|
||||
!header_emitted
|
||||
})))
|
||||
Some(Box::new(
|
||||
history_cell::AgentMessageCell::new_hyperlink_lines(lines, {
|
||||
let header_emitted = self.header_emitted;
|
||||
self.header_emitted = true;
|
||||
!header_emitted
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -644,7 +641,7 @@ impl PlanStreamController {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn current_tail_lines(&self) -> Vec<Line<'static>> {
|
||||
pub(crate) fn current_tail_lines(&self) -> Vec<HyperlinkLine> {
|
||||
self.core.current_tail_lines()
|
||||
}
|
||||
|
||||
@@ -653,7 +650,7 @@ impl PlanStreamController {
|
||||
!self.header_emitted && self.core.enqueued_stable_len == 0
|
||||
}
|
||||
|
||||
pub(crate) fn current_tail_display_lines(&self) -> Vec<Line<'static>> {
|
||||
pub(crate) fn current_tail_display_lines(&self) -> Vec<HyperlinkLine> {
|
||||
let lines = self.current_tail_lines();
|
||||
if lines.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -680,7 +677,7 @@ impl PlanStreamController {
|
||||
|
||||
fn emit(
|
||||
&mut self,
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
include_bottom_padding: bool,
|
||||
) -> Option<Box<dyn HistoryCell>> {
|
||||
if lines.is_empty() && !include_bottom_padding {
|
||||
@@ -700,26 +697,28 @@ impl PlanStreamController {
|
||||
|
||||
fn render_display_lines(
|
||||
&self,
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
include_bottom_padding: bool,
|
||||
) -> Vec<Line<'static>> {
|
||||
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(4);
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut out_lines: Vec<HyperlinkLine> = Vec::with_capacity(/*capacity*/ 4);
|
||||
if !self.header_emitted {
|
||||
out_lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
|
||||
out_lines.push(Line::from(" "));
|
||||
out_lines.push(HyperlinkLine::new(
|
||||
vec!["• ".dim(), "Proposed Plan".bold()].into(),
|
||||
));
|
||||
out_lines.push(HyperlinkLine::new(Line::from(" ")));
|
||||
}
|
||||
|
||||
let mut plan_lines: Vec<Line<'static>> = Vec::with_capacity(4);
|
||||
let mut plan_lines: Vec<HyperlinkLine> = Vec::with_capacity(/*capacity*/ 4);
|
||||
if !self.top_padding_emitted {
|
||||
plan_lines.push(Line::from(" "));
|
||||
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
|
||||
}
|
||||
plan_lines.extend(lines);
|
||||
if include_bottom_padding {
|
||||
plan_lines.push(Line::from(" "));
|
||||
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
|
||||
}
|
||||
|
||||
let plan_style = proposed_plan_style();
|
||||
let plan_lines = prefix_lines(plan_lines, " ".into(), " ".into())
|
||||
let plan_lines = prefix_hyperlink_lines(plan_lines, " ".into(), " ".into())
|
||||
.into_iter()
|
||||
.map(|line| line.style(plan_style))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -731,6 +730,7 @@ impl PlanStreamController {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -761,6 +761,10 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hyperlink_lines_to_plain_strings(lines: &[HyperlinkLine]) -> Vec<String> {
|
||||
lines_to_plain_strings(&visible_lines(lines.to_vec()))
|
||||
}
|
||||
|
||||
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
|
||||
let mut ctrl = stream_controller(width);
|
||||
let mut lines = Vec::new();
|
||||
@@ -862,7 +866,7 @@ mod tests {
|
||||
let mut ctrl = stream_controller(Some(80));
|
||||
assert!(!ctrl.has_live_tail());
|
||||
|
||||
ctrl.core.rendered_lines = vec![Line::from("tail line")];
|
||||
ctrl.core.rendered_lines = vec![Line::from("tail line").into()];
|
||||
ctrl.core.enqueued_stable_len = 0;
|
||||
assert!(ctrl.has_live_tail());
|
||||
|
||||
@@ -875,7 +879,7 @@ mod tests {
|
||||
let mut ctrl = plan_stream_controller(Some(80));
|
||||
assert!(!ctrl.has_live_tail());
|
||||
|
||||
ctrl.core.rendered_lines = vec![Line::from("tail line")];
|
||||
ctrl.core.rendered_lines = vec![Line::from("tail line").into()];
|
||||
ctrl.core.enqueued_stable_len = 0;
|
||||
assert!(ctrl.has_live_tail());
|
||||
|
||||
@@ -890,7 +894,7 @@ mod tests {
|
||||
ctrl.push("| --- | --- |\n");
|
||||
ctrl.push("| partial");
|
||||
|
||||
let tail = lines_to_plain_strings(&ctrl.current_tail_lines()).join("\n");
|
||||
let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines()).join("\n");
|
||||
assert!(
|
||||
!tail.contains("partial"),
|
||||
"expected live tail to remain newline-gated: {tail:?}",
|
||||
@@ -920,7 +924,7 @@ mod tests {
|
||||
|
||||
for width in [48, 104, 56] {
|
||||
ctrl.set_width(Some(width));
|
||||
let tail = lines_to_plain_strings(&ctrl.current_tail_lines());
|
||||
let tail = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines());
|
||||
|
||||
let mut expected = Vec::new();
|
||||
crate::markdown::append_markdown_agent(
|
||||
@@ -1033,7 +1037,7 @@ mod tests {
|
||||
|
||||
ctrl.set_width(Some(24));
|
||||
|
||||
let tail_after = lines_to_plain_strings(&ctrl.current_tail_lines());
|
||||
let tail_after = hyperlink_lines_to_plain_strings(&ctrl.current_tail_lines());
|
||||
assert!(
|
||||
!tail_after.is_empty(),
|
||||
"resize must keep mutable tail when queue is empty",
|
||||
@@ -1585,7 +1589,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let mut visible = emitted_lines.clone();
|
||||
visible.extend(ctrl.current_tail_lines());
|
||||
visible.extend(visible_lines(ctrl.current_tail_lines()));
|
||||
let visible_plain = lines_to_plain_strings(&visible);
|
||||
|
||||
let mut expected = Vec::new();
|
||||
@@ -1603,6 +1607,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalized_stream_table_preserves_semantic_url_fragments() {
|
||||
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
||||
let source = format!("| Item | URL |\n| --- | --- |\n| report | {destination} |\n");
|
||||
let mut ctrl = stream_controller(/*width*/ Some(32));
|
||||
ctrl.push(&source);
|
||||
|
||||
let (cell, _) = ctrl.finalize();
|
||||
let lines = cell
|
||||
.expect("final stream table cell")
|
||||
.display_hyperlink_lines(/*width*/ 32);
|
||||
let linked_rows = lines
|
||||
.iter()
|
||||
.filter(|line| !line.hyperlinks.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(linked_rows.len() > 1);
|
||||
assert!(linked_rows.iter().all(|line| {
|
||||
line.hyperlinks
|
||||
.iter()
|
||||
.all(|link| link.destination == destination)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_keeps_non_markdown_fenced_tables_as_code() {
|
||||
let source = "```sh\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n";
|
||||
@@ -1660,6 +1688,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalized_plan_stream_preserves_semantic_url_fragments() {
|
||||
let destination = "https://example.com/a/very/long/path/to/a/table/artifact";
|
||||
let source = format!("| Step | URL |\n| --- | --- |\n| Verify | {destination} |\n");
|
||||
let mut ctrl = PlanStreamController::new(
|
||||
/*width*/ Some(32),
|
||||
&test_cwd(),
|
||||
HistoryRenderMode::Rich,
|
||||
);
|
||||
ctrl.push(&source);
|
||||
|
||||
let (cell, _) = ctrl.finalize();
|
||||
let lines = cell
|
||||
.expect("final plan stream table cell")
|
||||
.display_hyperlink_lines(/*width*/ 32);
|
||||
let linked_rows = lines
|
||||
.iter()
|
||||
.filter(|line| !line.hyperlinks.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(linked_rows.len() > 1);
|
||||
assert!(linked_rows.iter().all(|line| {
|
||||
line.hyperlinks
|
||||
.iter()
|
||||
.all(|link| link.destination == destination)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_controller_streamed_markdown_fenced_table_matches_final_render() {
|
||||
let deltas = vec![
|
||||
|
||||
@@ -14,16 +14,15 @@ use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::text::Line;
|
||||
|
||||
use crate::markdown_stream::MarkdownStreamCollector;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
pub(crate) mod chunking;
|
||||
pub(crate) mod commit_tick;
|
||||
pub(crate) mod controller;
|
||||
mod table_holdback;
|
||||
|
||||
struct QueuedLine {
|
||||
line: Line<'static>,
|
||||
line: HyperlinkLine,
|
||||
enqueued_at: Instant,
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ impl StreamState {
|
||||
self.has_seen_delta = false;
|
||||
}
|
||||
/// Drains one queued line from the front of the queue.
|
||||
pub(crate) fn step(&mut self) -> Vec<Line<'static>> {
|
||||
pub(crate) fn step(&mut self) -> Vec<HyperlinkLine> {
|
||||
self.queued_lines
|
||||
.pop_front()
|
||||
.map(|queued| queued.line)
|
||||
@@ -64,7 +63,7 @@ impl StreamState {
|
||||
///
|
||||
/// Callers that pass very large values still get bounded behavior because this method clamps to
|
||||
/// the currently available queue length.
|
||||
pub(crate) fn drain_n(&mut self, max_lines: usize) -> Vec<Line<'static>> {
|
||||
pub(crate) fn drain_n(&mut self, max_lines: usize) -> Vec<HyperlinkLine> {
|
||||
let end = max_lines.min(self.queued_lines.len());
|
||||
self.queued_lines
|
||||
.drain(..end)
|
||||
@@ -90,7 +89,7 @@ impl StreamState {
|
||||
.map(|queued| now.saturating_duration_since(queued.enqueued_at))
|
||||
}
|
||||
/// Appends committed lines to the queue with a shared enqueue timestamp.
|
||||
pub(crate) fn enqueue(&mut self, lines: Vec<Line<'static>>) {
|
||||
pub(crate) fn enqueue(&mut self, lines: Vec<HyperlinkLine>) {
|
||||
let now = Instant::now();
|
||||
self.queued_lines
|
||||
.extend(lines.into_iter().map(|line| QueuedLine {
|
||||
@@ -104,6 +103,7 @@ impl StreamState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::text::Line;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_cwd() -> PathBuf {
|
||||
@@ -115,10 +115,10 @@ mod tests {
|
||||
#[test]
|
||||
fn drain_n_clamps_to_available_lines() {
|
||||
let mut state = StreamState::new(/*width*/ None, &test_cwd());
|
||||
state.enqueue(vec![Line::from("one")]);
|
||||
state.enqueue(vec![HyperlinkLine::new(Line::from("one"))]);
|
||||
|
||||
let drained = state.drain_n(/*max_lines*/ 8);
|
||||
assert_eq!(drained, vec![Line::from("one")]);
|
||||
assert_eq!(drained, vec![HyperlinkLine::new(Line::from("one"))]);
|
||||
assert!(state.is_idle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
//! Semantic terminal hyperlinks carried separately from visible TUI text.
|
||||
//!
|
||||
//! Layout code measures and wraps ordinary ratatui lines. Hyperlink annotations are applied only
|
||||
//! when text reaches a terminal buffer or scrollback writer so OSC 8 bytes never affect geometry.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_line;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct TerminalHyperlink {
|
||||
pub(crate) columns: Range<usize>,
|
||||
pub(crate) destination: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) struct HyperlinkLine {
|
||||
pub(crate) line: Line<'static>,
|
||||
pub(crate) hyperlinks: Vec<TerminalHyperlink>,
|
||||
}
|
||||
|
||||
impl HyperlinkLine {
|
||||
pub(crate) fn new(line: Line<'static>) -> Self {
|
||||
Self {
|
||||
line,
|
||||
hyperlinks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> usize {
|
||||
self.line.width()
|
||||
}
|
||||
|
||||
pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
|
||||
let start = self.width();
|
||||
let end = start + span.content.width();
|
||||
self.line.push_span(span);
|
||||
if end > start
|
||||
&& let Some(destination) = destination.and_then(web_destination)
|
||||
{
|
||||
self.hyperlinks.push(TerminalHyperlink {
|
||||
columns: start..end,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn style(mut self, style: ratatui::style::Style) -> Self {
|
||||
self.line = self.line.style(style);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Line<'static>> for HyperlinkLine {
|
||||
fn from(line: Line<'static>) -> Self {
|
||||
Self::new(line)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for HyperlinkLine {
|
||||
fn from(text: &'static str) -> Self {
|
||||
Self::new(Line::from(text))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for HyperlinkLine {
|
||||
fn from(text: String) -> Self {
|
||||
Self::new(Line::from(text))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn visible_lines(lines: Vec<HyperlinkLine>) -> Vec<Line<'static>> {
|
||||
lines.into_iter().map(|line| line.line).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn plain_hyperlink_lines(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
|
||||
lines.into_iter().map(HyperlinkLine::new).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn prefix_hyperlink_lines(
|
||||
lines: Vec<HyperlinkLine>,
|
||||
initial_prefix: Span<'static>,
|
||||
subsequent_prefix: Span<'static>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, mut line)| {
|
||||
let prefix = if index == 0 {
|
||||
initial_prefix.clone()
|
||||
} else {
|
||||
subsequent_prefix.clone()
|
||||
};
|
||||
let shift = prefix.content.width();
|
||||
let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
|
||||
spans.push(prefix);
|
||||
spans.extend(line.line.spans);
|
||||
line.line = Line::from(spans).style(line.line.style);
|
||||
for hyperlink in &mut line.hyperlinks {
|
||||
hyperlink.columns = hyperlink.columns.start + shift..hyperlink.columns.end + shift;
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn adaptive_wrap_hyperlink_lines(
|
||||
lines: &[HyperlinkLine],
|
||||
options: RtOptions<'static>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut out = Vec::new();
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let options = if index == 0 {
|
||||
options.clone()
|
||||
} else {
|
||||
options
|
||||
.clone()
|
||||
.initial_indent(options.subsequent_indent.clone())
|
||||
};
|
||||
out.extend(remap_wrapped_line(
|
||||
line,
|
||||
adaptive_wrap_line(&line.line, options)
|
||||
.into_iter()
|
||||
.map(|wrapped| line_to_static(&wrapped))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn annotate_web_urls(lines: Vec<Line<'static>>) -> Vec<HyperlinkLine> {
|
||||
lines.into_iter().map(annotate_web_urls_in_line).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine {
|
||||
let text = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
let mut out = HyperlinkLine::new(line);
|
||||
out.hyperlinks = web_links_in_text(&text);
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-attach source hyperlink ranges after visible-text wrapping has split a line.
|
||||
///
|
||||
/// Link text is matched in display order so a URL split across table rows retains the complete
|
||||
/// destination on every rendered fragment. Whitespace inserted or removed at line boundaries is
|
||||
/// ignored while matching; hyperlink destinations themselves are never reconstructed from output.
|
||||
pub(crate) fn remap_wrapped_line(
|
||||
source: &HyperlinkLine,
|
||||
wrapped: Vec<Line<'static>>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let mut out = plain_hyperlink_lines(wrapped);
|
||||
let source_text = line_text(&source.line);
|
||||
let mut source_byte = 0usize;
|
||||
let mut source_column = 0usize;
|
||||
for (index, line) in out.iter_mut().enumerate() {
|
||||
if index > 0 {
|
||||
let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
|
||||
let skipped = source_text[source_byte..].len() - trimmed.len();
|
||||
source_column += source_text[source_byte..source_byte + skipped].width();
|
||||
source_byte += skipped;
|
||||
}
|
||||
|
||||
let rendered = line_text(&line.line);
|
||||
let remaining = &source_text[source_byte..];
|
||||
let Some(rendered_start) = longest_suffix_matching_prefix(&rendered, remaining) else {
|
||||
continue;
|
||||
};
|
||||
let mapped = &rendered[rendered_start..];
|
||||
let mut output_column = rendered[..rendered_start].width();
|
||||
for ch in mapped.chars() {
|
||||
let width = ch.width().unwrap_or(/*default*/ 0);
|
||||
if let Some(link) = source
|
||||
.hyperlinks
|
||||
.iter()
|
||||
.find(|link| link.columns.contains(&source_column))
|
||||
{
|
||||
push_link_range(
|
||||
line,
|
||||
output_column..output_column + width,
|
||||
&link.destination,
|
||||
);
|
||||
}
|
||||
source_column += width;
|
||||
output_column += width;
|
||||
}
|
||||
source_byte += mapped.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn longest_suffix_matching_prefix(rendered: &str, source: &str) -> Option<usize> {
|
||||
rendered
|
||||
.char_indices()
|
||||
.map(|(index, _)| index)
|
||||
.chain(std::iter::once(rendered.len()))
|
||||
.find(|index| source.starts_with(&rendered[*index..]) && *index < rendered.len())
|
||||
}
|
||||
|
||||
fn push_link_range(line: &mut HyperlinkLine, range: Range<usize>, destination: &str) {
|
||||
if range.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(previous) = line.hyperlinks.last_mut()
|
||||
&& previous.destination == destination
|
||||
&& previous.columns.end == range.start
|
||||
{
|
||||
previous.columns.end = range.end;
|
||||
return;
|
||||
}
|
||||
line.hyperlinks.push(TerminalHyperlink {
|
||||
columns: range,
|
||||
destination: destination.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn web_links_in_text(text: &str) -> Vec<TerminalHyperlink> {
|
||||
let mut links = Vec::new();
|
||||
let mut search_from = 0usize;
|
||||
for raw_token in text.split_ascii_whitespace() {
|
||||
let Some(relative_start) = text[search_from..].find(raw_token) else {
|
||||
continue;
|
||||
};
|
||||
let raw_start = search_from + relative_start;
|
||||
search_from = raw_start + raw_token.len();
|
||||
let trimmed_start = raw_token
|
||||
.find(|ch: char| !is_leading_punctuation(ch))
|
||||
.unwrap_or(raw_token.len());
|
||||
let trimmed_end = trailing_url_end(&raw_token[trimmed_start..]) + trimmed_start;
|
||||
if trimmed_start >= trimmed_end {
|
||||
continue;
|
||||
}
|
||||
let candidate = &raw_token[trimmed_start..trimmed_end];
|
||||
let Some(destination) = web_destination(candidate) else {
|
||||
continue;
|
||||
};
|
||||
let start = text[..raw_start + trimmed_start].width();
|
||||
let end = start + candidate.width();
|
||||
links.push(TerminalHyperlink {
|
||||
columns: start..end,
|
||||
destination,
|
||||
});
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn is_leading_punctuation(ch: char) -> bool {
|
||||
matches!(
|
||||
ch,
|
||||
'(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' | ',' | '.' | ';' | '!' | '\'' | '"'
|
||||
)
|
||||
}
|
||||
|
||||
fn trailing_url_end(candidate: &str) -> usize {
|
||||
let mut end = candidate.len();
|
||||
while end > 0 {
|
||||
let remaining = &candidate[..end];
|
||||
let Some(ch) = remaining.chars().next_back() else {
|
||||
break;
|
||||
};
|
||||
let trim = matches!(ch, ',' | '.' | ';' | '!' | '\'' | '"')
|
||||
|| matches!(ch, ')' | ']' | '}' | '>')
|
||||
&& has_unmatched_closing_delimiter(remaining, ch);
|
||||
if !trim {
|
||||
break;
|
||||
}
|
||||
end -= ch.len_utf8();
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool {
|
||||
let opening = match closing {
|
||||
')' => '(',
|
||||
']' => '[',
|
||||
'}' => '{',
|
||||
'>' => '<',
|
||||
_ => return false,
|
||||
};
|
||||
candidate.chars().filter(|ch| *ch == closing).count()
|
||||
> candidate.chars().filter(|ch| *ch == opening).count()
|
||||
}
|
||||
|
||||
pub(crate) fn web_destination(destination: &str) -> Option<String> {
|
||||
let safe_destination = destination
|
||||
.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.collect::<String>();
|
||||
let parsed = Url::parse(&safe_destination).ok()?;
|
||||
matches!(parsed.scheme(), "http" | "https")
|
||||
.then(|| parsed.host_str())
|
||||
.flatten()?;
|
||||
Some(safe_destination)
|
||||
}
|
||||
|
||||
pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String {
|
||||
let Some(safe_destination) = web_destination(destination) else {
|
||||
return text.to_string();
|
||||
};
|
||||
format!("\x1b]8;;{safe_destination}\x07{text}\x1b]8;;\x07")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn strip_osc8(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut stripped = String::with_capacity(text.len());
|
||||
let mut index = 0usize;
|
||||
|
||||
while index < bytes.len() {
|
||||
if bytes[index..].starts_with(b"\x1b]8;;") {
|
||||
index += 5;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'\x07' {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if index + 1 < bytes.len() && bytes[index] == b'\x1b' && bytes[index + 1] == b'\\' {
|
||||
index += 2;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ch = text[index..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("current byte index starts a character");
|
||||
stripped.push(ch);
|
||||
index += ch.len_utf8();
|
||||
}
|
||||
|
||||
stripped
|
||||
}
|
||||
|
||||
pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
|
||||
if line.hyperlinks.is_empty() {
|
||||
return line.line.spans.clone();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut column = 0usize;
|
||||
let mut link_index = 0usize;
|
||||
let mut active_link_index = None;
|
||||
let mut active_destination: Option<String> = None;
|
||||
for span in &line.line.spans {
|
||||
for ch in span.content.chars() {
|
||||
let width = ch.width().unwrap_or(/*default*/ 0);
|
||||
while line
|
||||
.hyperlinks
|
||||
.get(link_index)
|
||||
.is_some_and(|link| link.columns.end <= column)
|
||||
{
|
||||
link_index += 1;
|
||||
}
|
||||
let selected_link_index = line
|
||||
.hyperlinks
|
||||
.get(link_index)
|
||||
.and_then(|link| link.columns.contains(&column).then_some(link_index));
|
||||
if active_link_index != selected_link_index {
|
||||
if active_destination.is_some() {
|
||||
append_to_last_span(&mut out, "\x1b]8;;\x07");
|
||||
}
|
||||
active_destination = selected_link_index
|
||||
.and_then(|index| web_destination(&line.hyperlinks[index].destination));
|
||||
if let Some(destination) = active_destination.as_ref() {
|
||||
push_styled_content(
|
||||
&mut out,
|
||||
&format!("\x1b]8;;{destination}\x07"),
|
||||
span.style,
|
||||
);
|
||||
}
|
||||
active_link_index = selected_link_index;
|
||||
}
|
||||
push_styled_content(&mut out, &ch.to_string(), span.style);
|
||||
column += width;
|
||||
}
|
||||
}
|
||||
if active_destination.is_some() {
|
||||
append_to_last_span(&mut out, "\x1b]8;;\x07");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_styled_content(out: &mut Vec<Span<'static>>, content: &str, style: ratatui::style::Style) {
|
||||
if let Some(last) = out.last_mut()
|
||||
&& last.style == style
|
||||
{
|
||||
last.content.to_mut().push_str(content);
|
||||
return;
|
||||
}
|
||||
out.push(Span::styled(content.to_string(), style));
|
||||
}
|
||||
|
||||
fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.content.to_mut().push_str(content);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_buffer_hyperlinks(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
lines: &[HyperlinkLine],
|
||||
scroll_rows: usize,
|
||||
) {
|
||||
if area.width == 0 {
|
||||
return;
|
||||
}
|
||||
let mut logical_row = 0usize;
|
||||
for line in lines {
|
||||
let paragraph = Paragraph::new(Text::from(line.line.clone())).wrap(Wrap { trim: false });
|
||||
let rendered_height = paragraph.line_count(area.width).max(/*other*/ 1);
|
||||
if line.hyperlinks.is_empty() {
|
||||
logical_row += rendered_height;
|
||||
continue;
|
||||
}
|
||||
|
||||
let layout_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
area.width,
|
||||
u16::try_from(rendered_height).unwrap_or(u16::MAX),
|
||||
);
|
||||
let mut layout = Buffer::empty(layout_area);
|
||||
paragraph.render(layout_area, &mut layout);
|
||||
let rendered_lines = (0..layout_area.height)
|
||||
.map(|row| {
|
||||
let text = (0..layout_area.width)
|
||||
.filter_map(|column| {
|
||||
let cell = &layout[(column, row)];
|
||||
(!cell.skip).then(|| cell.symbol())
|
||||
})
|
||||
.collect::<String>();
|
||||
Line::from(text.trim_end().to_string())
|
||||
})
|
||||
.collect();
|
||||
for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
|
||||
for link in &rendered.hyperlinks {
|
||||
for column in link.columns.clone() {
|
||||
let row = logical_row + row;
|
||||
if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
|
||||
continue;
|
||||
}
|
||||
let x = area.x + column as u16;
|
||||
let y = area.y + (row - scroll_rows) as u16;
|
||||
let cell = &mut buf[(x, y)];
|
||||
if cell.skip || cell.symbol().trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let symbol = osc8_hyperlink(&link.destination, cell.symbol());
|
||||
cell.set_symbol(&symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
logical_row += rendered_height;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
|
||||
mark_matching_cells(buf, area, destination, |cell| {
|
||||
cell.fg == Color::Cyan && cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, destination: &str) {
|
||||
mark_matching_cells(buf, area, destination, |cell| {
|
||||
cell.modifier.contains(Modifier::UNDERLINED)
|
||||
});
|
||||
}
|
||||
|
||||
fn mark_matching_cells(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
destination: &str,
|
||||
matches: impl Fn(&ratatui::buffer::Cell) -> bool,
|
||||
) {
|
||||
if web_destination(destination).is_none() {
|
||||
return;
|
||||
}
|
||||
for position in area.positions() {
|
||||
let cell = &mut buf[position];
|
||||
if !cell.skip && !cell.symbol().trim().is_empty() && matches(cell) {
|
||||
let symbol = osc8_hyperlink(destination, cell.symbol());
|
||||
cell.set_symbol(&symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn only_web_destinations_receive_osc8() {
|
||||
assert!(osc8_hyperlink("https://example.com/a", "a").contains("\x1b]8;;"));
|
||||
assert_eq!(osc8_hyperlink("mailto:a@example.com", "a"), "a");
|
||||
assert_eq!(
|
||||
osc8_hyperlink("https://example.com/\u{7}safe", "a"),
|
||||
"\x1b]8;;https://example.com/safe\x07a\x1b]8;;\x07"
|
||||
);
|
||||
assert_eq!(
|
||||
strip_osc8(&osc8_hyperlink("https://example.com/a", "visible")),
|
||||
"visible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_punctuated_web_url_columns() {
|
||||
assert_eq!(
|
||||
web_links_in_text("See (https://example.com/a)."),
|
||||
vec![TerminalHyperlink {
|
||||
columns: 5..26,
|
||||
destination: "https://example.com/a".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_balanced_parentheses_in_bare_web_urls() {
|
||||
let destination = "https://en.wikipedia.org/wiki/Function_(mathematics)";
|
||||
assert_eq!(
|
||||
web_links_in_text(&format!("See ({destination}).")),
|
||||
vec![TerminalHyperlink {
|
||||
columns: 5..5 + destination.width(),
|
||||
destination: destination.to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decorates_a_contiguous_web_link_with_one_osc8_pair() {
|
||||
let destination = "https://example.com/a/very/long/path";
|
||||
let line = HyperlinkLine {
|
||||
line: Line::from(destination),
|
||||
hyperlinks: vec![TerminalHyperlink {
|
||||
columns: 0..destination.width(),
|
||||
destination: destination.to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
decorate_spans(&line),
|
||||
vec![Span::from(osc8_hyperlink(destination, destination))]
|
||||
);
|
||||
assert_eq!(
|
||||
decorate_spans(&HyperlinkLine::new(Line::from("not linked"))),
|
||||
vec![Span::from("not linked")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_maps_repeated_link_labels_by_source_position() {
|
||||
let mut source = HyperlinkLine::new(Line::from("here here"));
|
||||
source.hyperlinks.push(TerminalHyperlink {
|
||||
columns: 5..9,
|
||||
destination: "https://example.com".to_string(),
|
||||
});
|
||||
|
||||
let wrapped = remap_wrapped_line(&source, vec![Line::from("here here")]);
|
||||
|
||||
assert_eq!(
|
||||
wrapped[0].hyperlinks,
|
||||
vec![TerminalHyperlink {
|
||||
columns: 5..9,
|
||||
destination: "https://example.com".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_hyperlinks_follow_word_wrapping() {
|
||||
let destination = "https://example.com/path";
|
||||
let mut line = HyperlinkLine::new(Line::from(format!("See {destination} now")));
|
||||
line.hyperlinks.push(TerminalHyperlink {
|
||||
columns: 4..4 + destination.width(),
|
||||
destination: destination.to_string(),
|
||||
});
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 18, /*height*/ 4,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
Paragraph::new(Text::from(line.line.clone()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, &mut buf);
|
||||
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
|
||||
|
||||
let linked_text = area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
symbol
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
.then(|| strip_osc8(symbol))
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, destination);
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -44,6 +44,8 @@ use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::notifications::DesktopNotificationBackend;
|
||||
use crate::notifications::detect_backend;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::tui::event_stream::EventBroker;
|
||||
use crate::tui::event_stream::TuiEventStream;
|
||||
#[cfg(unix)]
|
||||
@@ -510,7 +512,7 @@ pub struct Tui {
|
||||
}
|
||||
|
||||
struct PendingHistoryLines {
|
||||
lines: Vec<Line<'static>>,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
}
|
||||
|
||||
@@ -736,6 +738,17 @@ impl Tui {
|
||||
&mut self,
|
||||
lines: Vec<Line<'static>>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) {
|
||||
self.insert_history_hyperlink_lines_with_wrap_policy(
|
||||
plain_hyperlink_lines(lines),
|
||||
wrap_policy,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_history_hyperlink_lines_with_wrap_policy(
|
||||
&mut self,
|
||||
lines: Vec<HyperlinkLine>,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
@@ -814,7 +827,7 @@ impl Tui {
|
||||
} else {
|
||||
InsertHistoryMode::Standard
|
||||
};
|
||||
crate::insert_history::insert_history_lines_with_mode_and_wrap_policy(
|
||||
crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
batch.lines.clone(),
|
||||
mode,
|
||||
|
||||
@@ -27,6 +27,8 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
const RELEASE_NOTES_URL: &str = "https://github.com/openai/codex/releases/latest";
|
||||
|
||||
pub(crate) enum UpdatePromptOutcome {
|
||||
Continue,
|
||||
RunUpdate(UpdateAction),
|
||||
@@ -204,9 +206,7 @@ impl WidgetRef for &UpdatePromptScreen {
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Release notes: ".dim(),
|
||||
"https://github.com/openai/codex/releases/latest"
|
||||
.dim()
|
||||
.underlined(),
|
||||
RELEASE_NOTES_URL.dim().underlined(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
@@ -236,6 +236,7 @@ impl WidgetRef for &UpdatePromptScreen {
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.render(area, buf);
|
||||
crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, RELEASE_NOTES_URL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user