diff --git a/codex-rs/tui/src/app/agent_message_consolidation.rs b/codex-rs/tui/src/app/agent_message_consolidation.rs index 31ba087e39..e3486c46e9 100644 --- a/codex-rs/tui/src/app/agent_message_consolidation.rs +++ b/codex-rs/tui/src/app/agent_message_consolidation.rs @@ -17,6 +17,7 @@ use super::resize_reflow::trailing_run_start; use crate::app_event::ConsolidationScrollbackReflow; use crate::history_cell; use crate::history_cell::HistoryCell; +use crate::inline_visualization::InlineVisualizationContext; use crate::pager_overlay::Overlay; use crate::tui; @@ -26,6 +27,7 @@ impl App { tui: &mut tui::Tui, source: String, cwd: PathBuf, + inline_visualization_context: Option, scrollback_reflow: ConsolidationScrollbackReflow, deferred_history_cell: Option>, ) -> Result<()> { @@ -52,8 +54,13 @@ impl App { tracing::debug!( "ConsolidateAgentMessage: replacing cells [{start}..{end}] with AgentMarkdownCell" ); - let consolidated: Arc = - Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd)); + let consolidated: Arc = Arc::new( + history_cell::AgentMarkdownCell::new_with_inline_visualizations( + source, + &cwd, + inline_visualization_context, + ), + ); self.transcript_cells .splice(start..end, std::iter::once(consolidated.clone())); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 570f0edb24..4a75263e1f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -317,6 +317,7 @@ impl App { AppEvent::ConsolidateAgentMessage { source, cwd, + inline_visualization_context, scrollback_reflow, deferred_history_cell, } => { @@ -324,6 +325,7 @@ impl App { tui, source, cwd, + inline_visualization_context, scrollback_reflow, deferred_history_cell, )?; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 58acb6bfe8..ab0359f136 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5036,6 +5036,7 @@ async fn required_stream_reflow_during_capped_initial_replay_survives_transcript "Final answer:\n\n| Pattern | Outcome |\n| --- | --- |\n| Table tail | Preserved |" .to_string(), PathBuf::from("/tmp"), + /*inline_visualization_context*/ None, ConsolidationScrollbackReflow::Required, /*deferred_history_cell*/ None, )?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index dcfebfb42d..0bb4990ff2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use crate::inline_visualization::InlineVisualizationContext; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; @@ -734,6 +735,7 @@ pub(crate) enum AppEvent { ConsolidateAgentMessage { source: String, cwd: PathBuf, + inline_visualization_context: Option, scrollback_reflow: ConsolidationScrollbackReflow, deferred_history_cell: Option>, }, diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index d90c10c1c9..f64bab22fd 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -45,10 +45,17 @@ impl ChatWidget { if let Some(source) = source { let source = parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown; + let inline_visualization_context = self.thread_id.and_then(|thread_id| { + crate::inline_visualization::InlineVisualizationContext::from_config( + &self.config, + thread_id, + ) + }); self.note_stream_consolidation_queued(); self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), + inline_visualization_context, scrollback_reflow, deferred_history_cell, }); @@ -408,10 +415,17 @@ impl ChatWidget { // Reset the flag even if we don't show separator (no work was done) self.transcript.needs_final_message_separator = false; } - self.stream_controller = Some(StreamController::new( + let inline_visualization_context = self.thread_id.and_then(|thread_id| { + crate::inline_visualization::InlineVisualizationContext::from_config( + &self.config, + thread_id, + ) + }); + self.stream_controller = Some(StreamController::new_with_inline_visualizations( self.current_stream_width(/*reserved_cols*/ 2), &self.config.cwd, self.history_render_mode(), + inline_visualization_context, )); } if let Some(controller) = self.stream_controller.as_mut() diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 60cadf4341..fda91de4f2 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -365,6 +365,7 @@ impl HistoryCell for AgentMessageCell { pub(crate) struct AgentMarkdownCell { markdown_source: String, cwd: PathBuf, + inline_visualization_context: Option, } impl AgentMarkdownCell { @@ -373,10 +374,26 @@ impl AgentMarkdownCell { /// `markdown_source` must be the raw source accumulated by the stream controller, not already /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve /// stale wrapping instead of repairing it. + #[cfg(test)] pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { + Self::new_with_inline_visualizations( + markdown_source, + cwd, + /*inline_visualization_context*/ None, + ) + } + + pub(crate) fn new_with_inline_visualizations( + markdown_source: String, + cwd: &Path, + inline_visualization_context: Option< + crate::inline_visualization::InlineVisualizationContext, + >, + ) -> Self { Self { markdown_source, cwd: cwd.to_path_buf(), + inline_visualization_context, } } } @@ -399,10 +416,11 @@ impl HistoryCell for AgentMarkdownCell { // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. - let lines = crate::markdown::render_markdown_agent_with_links_and_cwd( + let lines = crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), + self.inline_visualization_context.as_ref(), ); prefix_hyperlink_lines(lines, "• ".dim(), " ".into()) } diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index c7a4ca801f..157761d4f6 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -349,10 +349,10 @@ fn composite_cell_preserves_child_web_links() { assert_eq!( lines[2].hyperlinks, - vec![crate::terminal_hyperlinks::TerminalHyperlink { - columns: 0..destination.len(), - destination: destination.to_string(), - }] + vec![crate::terminal_hyperlinks::TerminalHyperlink::web( + /*columns*/ 0..destination.len(), + destination.to_string(), + )] ); } diff --git a/codex-rs/tui/src/inline_visualization.rs b/codex-rs/tui/src/inline_visualization.rs new file mode 100644 index 0000000000..cbe07eebc7 --- /dev/null +++ b/codex-rs/tui/src/inline_visualization.rs @@ -0,0 +1,257 @@ +//! Terminal fallback for assistant-authored inline visualization directives. + +mod viewer; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::DateTime; +use codex_protocol::ThreadId; +use pulldown_cmark::Event; +use pulldown_cmark::Options; +use pulldown_cmark::Parser; +use pulldown_cmark::Tag; +use pulldown_cmark::TagEnd; +use rand::RngCore as _; +use std::borrow::Cow; +use std::collections::HashMap; +use std::fs; +use std::ops::Range; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use url::Url; +use uuid::Uuid; + +use self::viewer::materialize_document; + +const DIRECTIVE_PREFIX: &str = "::codex-inline-vis{"; +const MAX_FRAGMENT_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug)] +pub(crate) struct InlineVisualizationContext { + visualizations_dir: PathBuf, + thread_dir: PathBuf, +} + +impl InlineVisualizationContext { + pub(crate) fn new(codex_home: &Path, thread_id: ThreadId) -> Option { + Self::new_with_writable_roots(codex_home, thread_id, std::iter::empty()) + } + + pub(crate) fn from_config( + config: &crate::legacy_core::config::Config, + thread_id: ThreadId, + ) -> Option { + let writable_roots = config + .permissions + .file_system_sandbox_policy() + .get_writable_roots_with_cwd(config.cwd.as_path()); + Self::new_with_writable_roots( + config.codex_home.as_path(), + thread_id, + writable_roots.iter().map(|root| root.root.as_path()), + ) + } + + fn new_with_writable_roots<'a>( + codex_home: &Path, + thread_id: ThreadId, + writable_roots: impl IntoIterator, + ) -> Option { + let thread_id = thread_id.to_string(); + let uuid = Uuid::parse_str(&thread_id).ok()?; + let timestamp = uuid.get_timestamp()?; + let (seconds, nanos) = timestamp.to_unix(); + let created_at = DateTime::from_timestamp(i64::try_from(seconds).ok()?, nanos)?; + let visualizations_dir = codex_home.join("visualizations"); + let granted_thread_dirs = writable_roots + .into_iter() + .filter(|root| is_visualization_thread_dir(&visualizations_dir, root)) + .collect::>(); + let thread_dir = match granted_thread_dirs.as_slice() { + [thread_dir] => (*thread_dir).to_path_buf(), + _ => visualizations_dir + .join(created_at.format("%Y/%m/%d").to_string()) + .join(thread_id), + }; + Some(Self { + visualizations_dir, + thread_dir, + }) + } + + fn link_for(&self, file: &str) -> Option { + let relative = Path::new(file); + if relative + .extension() + .and_then(|extension| extension.to_str()) + != Some("html") + || !matches!( + relative.components().collect::>().as_slice(), + [Component::Normal(_)] + ) + { + return None; + } + + let visualizations_dir = fs::canonicalize(&self.visualizations_dir).ok()?; + let thread_dir = fs::canonicalize(&self.thread_dir).ok()?; + if !thread_dir.starts_with(&visualizations_dir) { + return None; + } + let fragment_path = fs::canonicalize(thread_dir.join(relative)).ok()?; + if !fragment_path.starts_with(&thread_dir) { + return None; + } + let viewer_path = materialize_document(&fragment_path, &thread_dir).ok()?; + Url::from_file_path(viewer_path).ok() + } +} + +fn is_visualization_thread_dir(visualizations_dir: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(visualizations_dir) else { + return false; + }; + let components = relative.components().collect::>(); + matches!( + components.as_slice(), + [ + Component::Normal(_), + Component::Normal(_), + Component::Normal(_), + Component::Normal(thread_id) + ] if Uuid::parse_str(&thread_id.to_string_lossy()).is_ok() + ) +} + +pub(crate) struct InlineVisualizationRewrite<'a> { + pub(crate) markdown: Cow<'a, str>, + // Markdown rendering only recognizes web links. Random placeholders let the renderer build the + // link ranges normally, then allow the caller to retarget only links created from directives. + pub(crate) trusted_file_links: HashMap, +} + +pub(crate) struct TrustedFileLink { + pub(crate) destination: Url, + pub(crate) markdown_label: String, + pub(crate) display_label: String, + pub(crate) markdown_destination_label: String, +} + +pub(crate) fn rewrite_inline_visualizations<'a>( + markdown: &'a str, + context: Option<&InlineVisualizationContext>, +) -> InlineVisualizationRewrite<'a> { + if !markdown.contains(DIRECTIVE_PREFIX) { + return InlineVisualizationRewrite { + markdown: Cow::Borrowed(markdown), + trusted_file_links: HashMap::new(), + }; + } + + let mut code_block_ranges = Vec::>::new(); + let mut code_block_start = None; + for (event, range) in Parser::new_ext(markdown, Options::empty()).into_offset_iter() { + match event { + Event::Start(Tag::CodeBlock(_)) => code_block_start = Some(range.start), + Event::End(TagEnd::CodeBlock) => { + if let Some(start) = code_block_start.take() { + code_block_ranges.push(start..range.end); + } + } + _ => {} + } + } + if let Some(start) = code_block_start { + code_block_ranges.push(start..markdown.len()); + } + + let mut rewritten = String::with_capacity(markdown.len()); + let mut trusted_file_links = HashMap::new(); + let mut source_offset = 0; + for source_line in markdown.split_inclusive('\n') { + let line_start = source_offset; + source_offset += source_line.len(); + let (line, newline) = source_line + .strip_suffix('\n') + .map_or((source_line, ""), |line| (line, "\n")); + let trimmed = line.trim(); + let is_code = code_block_ranges + .iter() + .any(|range| range.start < source_offset && line_start < range.end); + if is_code || !trimmed.starts_with(DIRECTIVE_PREFIX) { + rewritten.push_str(line); + } else if let Some(file) = parse_directive_file(trimmed) { + if let Some(destination) = context.and_then(|context| context.link_for(file)) { + let placeholder = link_placeholder(); + let (markdown_label, display_label) = visualization_link_labels(file); + let markdown_destination_label = escape_markdown_label(destination.as_str()); + rewritten.push_str(&format!( + "{markdown_label} \n[{markdown_destination_label}]({placeholder})" + )); + trusted_file_links.insert( + placeholder, + TrustedFileLink { + destination, + markdown_label, + display_label, + markdown_destination_label, + }, + ); + } else { + rewritten.push_str("_Visualization unavailable on this device._"); + } + } else if trimmed.ends_with('}') { + rewritten.push_str("_Visualization unavailable on this device._"); + } + rewritten.push_str(newline); + } + InlineVisualizationRewrite { + markdown: Cow::Owned(rewritten), + trusted_file_links, + } +} + +fn visualization_link_labels(file: &str) -> (String, String) { + let name = Path::new(file) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("generated"); + let escaped_name = escape_markdown_label(name); + ( + format!("Open {escaped_name} visualization in the browser"), + format!("Open {name} visualization in the browser"), + ) +} + +fn escape_markdown_label(label: &str) -> String { + let mut escaped = String::with_capacity(label.len()); + for character in label.chars() { + if character.is_ascii_punctuation() { + escaped.push('\\'); + } + escaped.push(character); + } + escaped +} + +fn link_placeholder() -> String { + let mut bytes = [0_u8; 24]; + rand::rng().fill_bytes(&mut bytes); + let token = URL_SAFE_NO_PAD.encode(bytes); + format!("https://codex.invalid/inline-visualization/{token}") +} + +fn parse_directive_file(directive: &str) -> Option<&str> { + let attributes = directive + .strip_prefix(DIRECTIVE_PREFIX)? + .strip_suffix('}')? + .trim(); + let value = attributes.strip_prefix("file=\"")?.strip_suffix('"')?; + (!value.is_empty() && !value.contains('"')).then_some(value) +} + +#[cfg(test)] +#[path = "inline_visualization_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/inline_visualization/assets/visualize.css b/codex-rs/tui/src/inline_visualization/assets/visualize.css new file mode 100644 index 0000000000..d0073ca311 --- /dev/null +++ b/codex-rs/tui/src/inline_visualization/assets/visualize.css @@ -0,0 +1,792 @@ +:root { + color-scheme: light dark; + background-color: var(--background) !important; + + /* Agent-facing contract; keep in sync with SKILL.md. */ + --background: light-dark(rgb(255 255 255), rgb(24 24 24)); + --foreground: light-dark(rgb(26 28 31), rgb(255 255 255)); + --card: color-mix(in oklab, var(--foreground) 5%, transparent); + --card-foreground: var(--foreground); + --popover: light-dark(rgb(255 255 255), rgb(45 45 45)); + --popover-foreground: var(--foreground); + --primary: light-dark(rgb(51 156 255), rgb(131 195 255)); + --primary-foreground: light-dark(rgb(255 255 255), rgb(13 13 13)); + --secondary: light-dark(rgb(255 255 255 / 96%), rgb(54 54 54 / 96%)); + --secondary-foreground: var(--foreground); + --muted: color-mix(in srgb, var(--foreground) 10%, transparent); + --muted-foreground: light-dark( + rgb(26 28 31 / 49.4%), + rgb(255 255 255 / 49.8%) + ); + --accent: light-dark(rgb(229 242 255), rgb(13 39 63)); + --accent-foreground: var(--primary); + --destructive: light-dark(rgb(226 85 7), rgb(255 133 73)); + --border: light-dark(rgb(26 28 31 / 8%), rgb(255 255 255 / 8.2%)); + --input: light-dark( + rgb(26 28 31 / 11.8%), + color-mix(in oklab, rgb(0 0 0) 10%, transparent) + ); + --ring: light-dark(rgb(51 156 255), rgb(131 195 255 / 76%)); + --font-size-base: 14px; + --viz-series-1: var(--primary); + --viz-series-2: light-dark(rgb(243 136 59), rgb(245 154 86)); + --viz-series-3: light-dark(rgb(93 201 119), rgb(116 213 139)); + --viz-series-4: light-dark(rgb(235 119 177), rgb(240 143 192)); + --viz-series-5: light-dark(rgb(155 121 236), rgb(170 145 239)); + --viz-series-6: light-dark(rgb(58 185 177), rgb(90 203 194)); + + /* Internal implementation variables; not part of the agent contract. */ + --font-sans: -apple-system, system-ui, "Segoe UI", sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; + --font-size-normal: max(11px, var(--font-size-base)); + --font-size-tooltip: calc(var(--font-size-base) - 1px); + --font-size-small: max(11px, calc(var(--font-size-base) - 2px)); + --font-size-h1: calc(var(--font-size-normal) * 1.7142857143); + --font-size-h2: calc(var(--font-size-normal) * 1.4285714286); + --font-size-h3: calc(var(--font-size-normal) * 1.2857142857); + --font-weight-normal: 430; + --font-weight-medium: 500; + --line-height-normal: calc(var(--font-size-normal) * 1.5); + --line-height-tooltip: calc(var(--font-size-tooltip) * 1.4285714286); + --line-height-small: calc(var(--font-size-small) + 4px); + --radius: 12.5px; + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-2xl: calc(var(--radius) * 1.6); + --radius-full: 9999px; + --shadow-sm: 0 1px 2px -1px rgb(0 0 0 / 8%); + --checkmark-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 17 17'%3E%3Cpath d='M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z'/%3E%3C/svg%3E"); + + /* Legacy aliases; not part of the current agent contract. */ + --viz-bg: transparent; + --viz-panel: var(--card); + --viz-border: var(--border); + --viz-text: var(--foreground); + --viz-muted: var(--muted-foreground); + --viz-accent: var(--primary); + --viz-accent-text: var(--primary-foreground); + --viz-accent-bg: var(--accent); + --viz-font-size: var(--font-size-base); + --viz-warning: var(--destructive); + --color-background-primary: var(--background); + --color-text-primary: var(--foreground); + --color-border-secondary: var(--border); +} + +:root[data-theme="light"] { + color-scheme: light; +} + +:root[data-theme="dark"] { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html > body { + margin: 0; + padding: 5px; + color: var(--foreground); + background: transparent !important; + font-family: var(--font-sans); + font-size: var(--font-size-normal); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); +} + +a { + color: color-mix(in srgb, var(--viz-accent) 80%, var(--viz-text) 20%); + cursor: pointer; + font-weight: var(--viz-font-weight-medium); + text-decoration: none; + text-underline-offset: 2px; +} + +a:is(:hover, :focus-visible) { + text-decoration-line: underline; + text-decoration-style: dashed; + text-decoration-thickness: 0.5px; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + margin-block: 0; +} + +h1 { + font-size: var(--font-size-h1); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +h2 { + font-size: var(--font-size-h2); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +h3, +h4, +h5, +h6 { + font-size: var(--font-size-h3); + font-weight: var(--font-weight-medium); + line-height: 1.3; +} + +b, +strong, +th { + font-weight: var(--font-weight-medium); +} + +code:not(pre code) { + display: inline; + padding: 1px 6px; + border-radius: var(--radius-sm); + corner-shape: superellipse(1.5); + background: var(--muted); + font-family: var(--font-mono); + font-size: 0.92em; + overflow-wrap: anywhere; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + word-break: break-word; +} + +.table-responsive { + width: 100%; + overflow-x: auto; + scrollbar-width: thin; +} + +.table { + width: 100%; + border-collapse: collapse; + color: var(--foreground); + font: inherit; + text-align: start; +} + +.table :is(th, td) { + padding-block: 10px; + padding-inline: 0 24px; + overflow-wrap: anywhere; + border-bottom: 1px solid var(--border); + text-align: start; + vertical-align: top; +} + +.table-responsive > .table :is(th, td) { + overflow-wrap: break-word; +} + +.table :is(th, td):last-child { + padding-inline-end: 0; +} + +.table :is(caption, thead th) { + font-weight: 600; +} + +.table thead th { + padding-block: 8px; + border-bottom-color: color-mix(in srgb, var(--foreground) 16%, transparent); +} + +.table tbody tr:last-child :is(th, td) { + border-bottom: 0; +} + +.table.table-sm :is(th, td) { + padding-block: 6px; +} + +.table.table-sm :is(th, td):not(:last-child) { + padding-inline-end: 16px; +} + +.table :is(.text-end, [align="right"]) { + text-align: end; + font-variant-numeric: tabular-nums; +} + +.table :is(.text-center, [align="center"]) { + text-align: center; +} + +.table .text-nowrap { + white-space: nowrap; +} + +#widget { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 0; + background: transparent !important; +} + +.card { + min-width: 0; + padding: 12px; + overflow: hidden; + overflow-wrap: break-word; + border-radius: var(--radius-2xl); + corner-shape: superellipse(1.5); + color: var(--card-foreground); + background: var(--card); +} + +#widget > :not(.card) { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.tooltip { + position: fixed; + z-index: 50; + top: 0; + left: 0; + width: max-content; + max-width: min( + 20rem, + var(--tooltip-available-width, calc(100vw - 10px)), + calc(100vw - 10px) + ); + max-height: min( + var(--tooltip-available-height, calc(100vh - 10px)), + calc(100vh - 10px) + ); + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--popover-foreground); + background: var(--popover); + box-shadow: none; + font-size: var(--font-size-tooltip); + line-height: var(--line-height-tooltip); + overflow-wrap: break-word; + white-space: normal; + pointer-events: none; + user-select: none; +} + +.viz-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(max(180px, 24%), 1fr)); + gap: 10px; +} + +.viz-stat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.viz-stat-value { + font-size: var(--font-size-h2); + font-weight: var(--font-weight-medium); + line-height: 1.25; +} + +.viz-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.viz-badge { + padding: 3px 8px; + border-radius: var(--radius-full); + color: var(--accent-foreground); + background: var(--accent); + font-weight: var(--font-weight-medium); +} + +small, +.text-small, +.viz-badge { + font-size: var(--font-size-small); + line-height: var(--line-height-small); +} + +.text-muted { + color: var(--muted-foreground); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.viz-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.viz-controls > .form-label { + display: grid; + min-width: min(100%, 260px); + flex: 1 1 280px; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 2px 12px; + margin-bottom: 0; +} + +.viz-controls > .form-label > :is(.form-control, .form-range, .form-select) { + grid-column: 1 / -1; +} + +.btn, +.form-check-input, +.form-control, +.form-range, +.form-select { + font: inherit; +} + +.btn { + appearance: button; + display: inline-flex; + inline-size: fit-content; + max-inline-size: 100%; + min-height: 28px; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0; + padding: 0 8px; + -webkit-app-region: no-drag; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--secondary-foreground); + background: var(--secondary); + cursor: var(--cursor-interaction, pointer); + text-align: center; + text-decoration: none; + white-space: nowrap; + user-select: none; + -webkit-font-smoothing: antialiased; +} + +.btn:is(.btn-block, .viz-tile) { + inline-size: 100%; +} + +.btn.viz-tile { + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +a.btn { + cursor: pointer; + text-decoration: none; +} + +.btn:not(:disabled):hover { + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.btn-primary { + border-color: transparent; + color: var(--primary-foreground); + background: var(--foreground); + background-clip: padding-box; +} + +.btn-primary .text-muted { + color: color-mix(in srgb, var(--primary-foreground) 50%, transparent); +} + +.btn-primary:not(:disabled):hover { + background: color-mix(in srgb, var(--foreground) 80%, transparent); + background-clip: padding-box; +} + +.btn-ghost { + border-color: transparent; + color: var(--muted-foreground); + background: transparent; +} + +.btn-ghost:not(:disabled):hover { + color: var(--foreground); + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.btn:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +[data-lucide] { + stroke-width: 1.6; +} + +.form-label { + display: block; + margin-bottom: 6px; + color: var(--foreground); +} + +.form-control { + display: block; + width: 100%; + min-height: 28px; + padding: 0 8px; + outline: none; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--foreground); + background: var(--secondary); +} + +.form-control::placeholder { + color: var(--muted-foreground); +} + +.form-control[type="file"] { + padding: 0; + overflow: hidden; + cursor: var(--cursor-interaction, pointer); +} + +.form-control[type="file"]::file-selector-button { + min-height: 26px; + margin-right: 8px; + padding: 0 8px; + border: 0; + border-right: 1px solid var(--input); + color: var(--secondary-foreground); + background: var(--secondary); + cursor: inherit; + font: inherit; +} + +.form-control[type="file"]:not(:disabled):hover::file-selector-button { + background: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.form-control-color[type="color"] { + width: 40px; + height: 28px; + padding: 3px; + cursor: var(--cursor-interaction, pointer); +} + +.form-control-color[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +.form-control-color[type="color"]::-webkit-color-swatch { + border: 0; + border-radius: calc(var(--radius-lg) - 4px); + corner-shape: superellipse(1.5); +} + +textarea.form-control { + height: auto; + min-height: 72px; + padding: 8px 10px; + resize: vertical; +} + +.form-control:focus-visible { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-control:disabled, +.form-select:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.form-select { + appearance: none; + display: block; + width: 100%; + min-height: 28px; + margin: 0; + padding: 0 32px 0 8px; + outline: none; + border: 1px solid var(--input); + border-radius: var(--radius-lg); + corner-shape: superellipse(1.5); + color: var(--foreground); + background-color: var(--secondary); + background-image: + linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%), + linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%); + background-position: + calc(100% - 14px) 50%, + calc(100% - 10px) 50%; + background-repeat: no-repeat; + background-size: 4px 4px; + cursor: var(--cursor-interaction, default); +} + +.form-select:not(:disabled):hover { + background-color: color-mix(in srgb, var(--foreground) 6%, var(--secondary)); +} + +.form-select:focus-visible { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-check { + display: flex; + min-height: 20px; + align-items: center; + gap: 6px; +} + +.form-check-input { + appearance: none; + width: 14px; + height: 14px; + flex: 0 0 auto; + margin: 0; + border: 1px solid var(--input); + color: var(--primary-foreground); + background-color: transparent; + cursor: var(--cursor-interaction, default); + transition: + background-color 150ms, + border-color 150ms, + box-shadow 150ms; +} + +.form-check:not(.form-switch) .form-check-input[type="checkbox"] { + border-color: var(--input); + border-radius: var(--radius-sm); + corner-shape: superellipse(1.5); + background-color: var(--secondary); + box-shadow: var(--shadow-sm); +} + +.form-check:not(.form-switch) + .form-check-input:not(:disabled):not(:checked):hover { + background-color: var(--card); +} + +.form-check:not(.form-switch) .form-check-input[type="checkbox"]:checked { + border-color: var(--primary); + background-color: var(--primary); +} + +.form-check:not(.form-switch) + .form-check-input[type="checkbox"]:checked::before { + display: block; + width: 100%; + height: 100%; + background: var(--primary-foreground); + content: ""; + mask: var(--checkmark-image) center / 12px 12px no-repeat; +} + +.form-check-input[type="radio"] { + width: 14px; + height: 14px; + border-radius: var(--radius-full); +} + +.form-check-input[type="radio"]:checked { + border: 2px solid var(--primary); + background: + radial-gradient(circle, var(--primary-foreground) 0 2.5px, transparent 3px), + var(--primary); +} + +.form-check:not(.form-switch) .form-check-input:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.form-check:not(.form-switch) .form-check-input:disabled { + cursor: not-allowed; + pointer-events: none; +} + +.form-check:not(.form-switch) .form-check-input:disabled + .form-check-label { + cursor: not-allowed; +} + +.form-switch .form-check-input:disabled, +.form-switch .form-check-input:disabled + .form-check-label { + cursor: not-allowed; + opacity: 0.6; +} + +.form-check-label { + color: var(--foreground); + cursor: var(--cursor-interaction, default); +} + +.form-switch .form-check-input { + position: relative; + width: 32px; + height: 20px; + border: 0; + border-radius: var(--radius-full); + background: var(--muted); + box-shadow: none; + transition: background-color 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.form-switch .form-check-input::before { + position: absolute; + top: 50%; + left: 0; + width: 16px; + height: 16px; + box-sizing: border-box; + border: 1px solid light-dark(var(--primary-foreground), var(--foreground)); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); + box-shadow: var(--shadow-sm); + content: ""; + transform: translate(2px, -50%); + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); +} + +.form-switch .form-check-input:checked { + background: var(--primary); +} + +.form-switch .form-check-input:checked::before { + transform: translate(14px, -50%); +} + +.form-switch .form-check-input:focus-visible { + box-shadow: 0 0 0 2px var(--ring); +} + +.form-range { + appearance: none; + display: block; + width: 100%; + height: 28px; + flex: 1; + margin: 0; + padding: 0; + outline: none; + border: 0; + accent-color: var(--primary); + background: linear-gradient( + color-mix(in srgb, var(--foreground) 7%, transparent), + color-mix(in srgb, var(--foreground) 7%, transparent) + ) + center / 100% 2px no-repeat; +} + +.form-range::-webkit-slider-runnable-track { + height: 28px; + background: transparent; +} + +.form-range::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + margin-top: 4px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); +} + +.form-range:focus-visible::-webkit-slider-thumb { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-range::-moz-range-track { + height: 28px; + background: transparent; +} + +.form-range::-moz-range-thumb { + width: 20px; + height: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: light-dark(var(--primary-foreground), var(--foreground)); +} + +.form-range:focus-visible::-moz-range-thumb { + border-color: var(--ring); + box-shadow: inset 0 0 0 1px var(--ring); +} + +.form-range:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.btn:not(.btn-primary, .viz-tile):is( + [aria-pressed="true"], + [aria-selected="true"], + .is-selected + ) { + border-color: var(--primary); + color: var(--primary-foreground); + background: var(--primary); +} + +.btn.viz-tile:is([aria-pressed="true"], [aria-selected="true"], .is-selected) { + border-color: var(--primary); + box-shadow: inset 0 0 0 1px var(--primary); +} + +.btn:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +svg { + display: block; + max-width: 100%; + height: auto; +} + +#widget > svg { + width: 100%; +} + +.text-warning, +.text-destructive { + color: var(--destructive); +} diff --git a/codex-rs/tui/src/inline_visualization/assets/visualize.html b/codex-rs/tui/src/inline_visualization/assets/visualize.html new file mode 100644 index 0000000000..01ef405324 --- /dev/null +++ b/codex-rs/tui/src/inline_visualization/assets/visualize.html @@ -0,0 +1,239 @@ + + + + + + diff --git a/codex-rs/tui/src/inline_visualization/viewer.rs b/codex-rs/tui/src/inline_visualization/viewer.rs new file mode 100644 index 0000000000..d5335bce49 --- /dev/null +++ b/codex-rs/tui/src/inline_visualization/viewer.rs @@ -0,0 +1,81 @@ +use super::MAX_FRAGMENT_BYTES; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +const FRAGMENT_PLACEHOLDER: &str = ""; +const VIEWER_DIRECTORY_NAME: &str = ".codex-viewers"; + +// Keep these assets in sync with the bundled visualize skill's browser renderer. +const VIEWER_STYLESHEET: &str = include_str!("assets/visualize.css"); +const VIEWER_RUNTIME: &str = include_str!("assets/visualize.html"); + +const FRAME_CSP: &str = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; style-src 'unsafe-inline' blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; img-src blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; font-src blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; media-src blob: data:; worker-src blob:; connect-src blob: data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"; +const SHELL_STYLE: &str = ":root{color-scheme:light dark;background:light-dark(rgb(255 255 255), rgb(24 24 24))}html,body{margin:0}body{box-sizing:border-box;padding:1rem;background:inherit}iframe{display:block;width:100%;max-width:736px;height:calc(100vh - 2rem);margin:0 auto;border:0}"; + +pub(super) fn materialize_document(path: &Path, thread_dir: &Path) -> std::io::Result { + let metadata = path.metadata()?; + if !metadata.is_file() || metadata.len() > MAX_FRAGMENT_BYTES { + return Err(std::io::Error::other("invalid visualization fragment")); + } + + let fragment = fs::read_to_string(path)?; + let title = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("Visualization") + .replace('-', " "); + let document = render_fragment(&fragment, &title); + + let thread_dir = fs::canonicalize(thread_dir)?; + let viewer_dir = thread_dir.join(VIEWER_DIRECTORY_NAME); + fs::create_dir_all(&viewer_dir)?; + let viewer_dir = fs::canonicalize(viewer_dir)?; + if !viewer_dir.starts_with(&thread_dir) { + return Err(std::io::Error::other( + "visualization viewer directory escapes thread directory", + )); + } + let file_name = path + .file_name() + .ok_or_else(|| std::io::Error::other("visualization fragment has no file name"))?; + let viewer_path = viewer_dir.join(file_name); + if fs::read_to_string(&viewer_path).is_ok_and(|existing| existing == document) { + return Ok(viewer_path); + } + + let mut temporary = tempfile::NamedTempFile::new_in(&viewer_dir)?; + temporary.write_all(document.as_bytes())?; + temporary.flush()?; + temporary + .persist(&viewer_path) + .map_err(|error| error.error)?; + Ok(viewer_path) +} + +fn render_fragment(fragment: &str, title: &str) -> String { + let runtime = VIEWER_RUNTIME.replacen(FRAGMENT_PLACEHOLDER, fragment, /*count*/ 1); + let escaped_title = escape_html(title); + let frame = format!( + "{escaped_title}{runtime}" + ); + + // A srcdoc frame inherits its parent's CSP, so the shell must grant every + // resource type that the stricter frame CSP may use. The frame itself stays + // sandboxed without allow-same-origin. + let shell_csp = FRAME_CSP.replace("frame-src 'none'", "frame-src 'self'"); + format!( + "{escaped_title}", + escape_html(&frame) + ) +} + +fn escape_html(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/codex-rs/tui/src/inline_visualization_tests.rs b/codex-rs/tui/src/inline_visualization_tests.rs new file mode 100644 index 0000000000..a883e58b67 --- /dev/null +++ b/codex-rs/tui/src/inline_visualization_tests.rs @@ -0,0 +1,348 @@ +use super::*; +use crate::history_cell::AgentMarkdownCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::HistoryRenderMode; +use crate::streaming::controller::StreamController; +use pretty_assertions::assert_eq; +use ratatui::style::Style; +use ratatui::style::Stylize; +use tempfile::TempDir; + +fn context_with_fragment(fragment: &str) -> (TempDir, InlineVisualizationContext) { + let codex_home = tempfile::tempdir().expect("temp codex home"); + let thread_id = ThreadId::new(); + let context = InlineVisualizationContext::new(codex_home.path(), thread_id) + .expect("UUIDv7 thread id should provide a timestamp"); + fs::create_dir_all(&context.thread_dir).expect("create visualization directory"); + fs::write(context.thread_dir.join("chart.html"), fragment).expect("write fragment"); + (codex_home, context) +} + +#[test] +fn granted_visualization_root_overrides_thread_id_derived_root() { + let codex_home = tempfile::tempdir().expect("temp codex home"); + let granted_context = InlineVisualizationContext::new(codex_home.path(), ThreadId::new()) + .expect("granted context"); + fs::create_dir_all(&granted_context.thread_dir).expect("create granted directory"); + fs::write( + granted_context.thread_dir.join("chart.html"), + "
chart
", + ) + .expect("write fragment"); + + let context = InlineVisualizationContext::new_with_writable_roots( + codex_home.path(), + ThreadId::new(), + [granted_context.thread_dir.as_path()], + ) + .expect("context"); + + assert!(context.link_for("chart.html").is_some()); +} + +fn line_text(line: &ratatui::text::Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[test] +fn rewrites_complete_directive_to_trusted_static_file_placeholder() { + let (_codex_home, context) = context_with_fragment("
chart
"); + + let rewritten = rewrite_inline_visualizations( + "Before\n::codex-inline-vis{file=\"chart.html\"}\nAfter", + Some(&context), + ); + + assert!( + rewritten + .markdown + .starts_with("Before\nOpen chart visualization in the browser \n[") + ); + assert!(rewritten.markdown.ends_with(")\nAfter")); + assert_eq!(rewritten.trusted_file_links.len(), 1); + let destination = rewritten + .trusted_file_links + .values() + .next() + .expect("trusted destination"); + assert_eq!(destination.destination.scheme(), "file"); + assert!( + destination + .destination + .to_file_path() + .expect("file URL") + .is_file() + ); + assert_eq!( + destination.display_label, + "Open chart visualization in the browser" + ); + assert!(rewritten.markdown.contains(&format!( + " \n[{}](", + destination.markdown_destination_label + ))); +} + +#[test] +fn hides_incomplete_streaming_directive() { + let rewritten = rewrite_inline_visualizations( + "Before\n::codex-inline-vis{file=\"chart", + /*context*/ None, + ); + + assert_eq!(rewritten.markdown, "Before\n"); + assert!(rewritten.trusted_file_links.is_empty()); +} + +#[test] +fn unavailable_artifact_has_explicit_fallback() { + let codex_home = tempfile::tempdir().expect("temp codex home"); + let context = InlineVisualizationContext::new(codex_home.path(), ThreadId::new()) + .expect("UUIDv7 thread id should provide a timestamp"); + + assert_eq!( + rewrite_inline_visualizations("::codex-inline-vis{file=\"missing.html\"}", Some(&context),) + .markdown, + "_Visualization unavailable on this device._" + ); +} + +#[test] +fn rejects_parent_path_and_non_html_file() { + let (_codex_home, context) = context_with_fragment("
chart
"); + + for file in ["../chart.html", "chart.svg"] { + assert_eq!( + rewrite_inline_visualizations( + &format!("::codex-inline-vis{{file=\"{file}\"}}"), + Some(&context), + ) + .markdown, + "_Visualization unavailable on this device._" + ); + } +} + +#[test] +fn rejects_oversized_fragment() { + let (_codex_home, context) = context_with_fragment("
chart
"); + let fragment = fs::OpenOptions::new() + .write(true) + .open(context.thread_dir.join("chart.html")) + .expect("open fragment"); + fragment + .set_len(MAX_FRAGMENT_BYTES + 1) + .expect("enlarge fragment"); + + assert_eq!( + rewrite_inline_visualizations("::codex-inline-vis{file=\"chart.html\"}", Some(&context),) + .markdown, + "_Visualization unavailable on this device._" + ); +} + +#[test] +fn viewer_materializes_sandboxed_static_document() { + let (_codex_home, context) = context_with_fragment( + "
controls
", + ); + let url = context.link_for("chart.html").expect("visualization link"); + assert_eq!(url.scheme(), "file"); + let viewer_path = url.to_file_path().expect("viewer file path"); + assert_eq!( + viewer_path.parent().and_then(Path::file_name), + Some(std::ffi::OsStr::new(".codex-viewers")) + ); + let document = fs::read_to_string(viewer_path).expect("read static viewer"); + + assert!(document.contains("sandbox=\"allow-scripts\"")); + assert!(!document.contains("allow-same-origin")); + assert!(document.contains("script-src 'unsafe-inline' 'unsafe-eval'")); + assert!(document.contains(".viz-controls")); + assert!(document.contains("https://unpkg.com/@floating-ui/dom@1.7.4")); + assert!(document.contains("https://unpkg.com/lucide@1.17.0")); + assert!(document.contains("<canvas id="chart"></canvas>")); + assert!(document.contains("globalThis.chartRendered = true")); + assert!(document.contains("Content-Security-Policy")); + + let shell = document + .split_once(" srcdoc=") + .map(|(shell, _)| shell) + .expect("viewer shell"); + let contract = format!( + "{shell} srcdoc=\"[canonical visualization frame]\">\n\nembedded frame:\n- canonical control styles\n- Floating UI tooltip runtime\n- Lucide icon runtime\n- visualization fragment" + ); + insta::assert_snapshot!("viewer_document_contract", contract); +} + +#[test] +fn viewer_reuses_path_and_refreshes_static_document() { + let (_codex_home, context) = context_with_fragment("
first
"); + let first_url = context.link_for("chart.html").expect("first viewer link"); + let viewer_path = first_url.to_file_path().expect("viewer file path"); + assert!( + fs::read_to_string(&viewer_path) + .expect("read first viewer") + .contains("first") + ); + + fs::write(context.thread_dir.join("chart.html"), "
second
").expect("update fragment"); + let second_url = context.link_for("chart.html").expect("second viewer link"); + + assert_eq!(second_url, first_url); + let refreshed = fs::read_to_string(viewer_path).expect("read refreshed viewer"); + assert!(refreshed.contains("second")); + assert!(!refreshed.contains("first")); +} + +#[test] +fn finalized_agent_cell_replays_visualization_link() { + let (_codex_home, context) = context_with_fragment("
chart
"); + let cell = AgentMarkdownCell::new_with_inline_visualizations( + "Before\n\n::codex-inline-vis{file=\"chart.html\"}\n\nAfter".to_string(), + Path::new("/workspace"), + Some(context), + ); + + let lines = cell.display_hyperlink_lines(/*width*/ 80); + let text = lines + .iter() + .map(|line| line_text(&line.line)) + .collect::>() + .join("\n"); + let snapshot_text = text + .lines() + .map(|line| { + line.find("file://").map_or_else( + || line.to_string(), + |start| format!("{}file://", &line[..start]), + ) + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!("finalized_agent_cell_visualization_link", snapshot_text); + let title_span = lines + .iter() + .flat_map(|line| &line.line.spans) + .find(|span| span.content == "Open chart visualization in the browser") + .expect("visualization title span"); + assert_eq!(title_span.style, Style::new()); + let url_span = lines + .iter() + .flat_map(|line| &line.line.spans) + .find(|span| span.content.starts_with("file://")) + .expect("visualization URL span"); + assert_eq!(url_span.style, Style::new().cyan().underlined()); + let destinations = lines + .iter() + .flat_map(|line| &line.hyperlinks) + .map(|link| link.destination.as_str()) + .collect::>(); + assert_eq!(destinations.len(), 1); + assert!( + destinations + .iter() + .all(|destination| destination.starts_with("file://")) + ); +} + +#[test] +fn agent_code_blocks_preserve_visualization_directive_literals() { + let (_codex_home, context) = context_with_fragment("
chart
"); + let cell = AgentMarkdownCell::new_with_inline_visualizations( + "Fenced:\n\n```text\n::codex-inline-vis{file=\"chart.html\"}\n```\n\nIndented:\n\n ::codex-inline-vis{file=\"chart.html\"}" + .to_string(), + Path::new("/workspace"), + Some(context), + ); + + let text = cell + .display_hyperlink_lines(/*width*/ 80) + .iter() + .map(|line| line_text(&line.line)) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(text); +} + +#[test] +fn streaming_hides_partial_directive_and_renders_completed_link() { + let (_codex_home, context) = context_with_fragment("
chart
"); + let mut controller = StreamController::new_with_inline_visualizations( + /*width*/ Some(80), + Path::new("/workspace"), + HistoryRenderMode::Rich, + Some(context), + ); + + controller.push("Before\n::codex-inline-vis{file=\"chart"); + assert!( + controller + .current_tail_lines() + .iter() + .all(|line| !line_text(&line.line).contains(DIRECTIVE_PREFIX)) + ); + + controller.push(".html\"}"); + let (cell, source) = controller.finalize(); + assert_eq!( + source.as_deref(), + Some("Before\n::codex-inline-vis{file=\"chart.html\"}\n") + ); + let cell = cell.expect("final streamed cell"); + let lines = cell.display_hyperlink_lines(/*width*/ 80); + assert!( + lines.iter().any(|line| { + line_text(&line.line).contains("Open chart visualization in the browser") + }) + ); +} + +#[test] +fn visualization_link_uses_the_artifact_name() { + let (_codex_home, context) = context_with_fragment("
chart
"); + fs::rename( + context.thread_dir.join("chart.html"), + context.thread_dir.join("compound-interest-explorer.html"), + ) + .expect("rename fragment"); + + let rewritten = rewrite_inline_visualizations( + "::codex-inline-vis{file=\"compound-interest-explorer.html\"}", + Some(&context), + ); + + assert!( + rewritten + .markdown + .starts_with("Open compound\\-interest\\-explorer visualization in the browser \n[") + ); + assert_eq!( + rewritten + .trusted_file_links + .values() + .next() + .expect("trusted destination") + .display_label, + "Open compound-interest-explorer visualization in the browser" + ); +} + +#[test] +fn user_markdown_keeps_directive_literal() { + let rendered = + crate::markdown_render::render_markdown_text("::codex-inline-vis{file=\"chart.html\"}"); + let text = rendered + .lines + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(text, "::codex-inline-vis{file=\"chart.html\"}"); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 17492acdc2..2d0cb83873 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -127,6 +127,7 @@ mod goal_files; mod history_cell; mod hooks_rpc; mod ide_context; +mod inline_visualization; pub(crate) mod insert_history; pub use insert_history::insert_history_lines; mod key_hint; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 31b8d09a76..b244af83f3 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -24,6 +24,8 @@ use std::borrow::Cow; use std::ops::Range; use std::path::Path; +use crate::inline_visualization::InlineVisualizationContext; +use crate::inline_visualization::rewrite_inline_visualizations; use crate::table_detect; use crate::terminal_hyperlinks::HyperlinkLine; @@ -71,8 +73,37 @@ pub(crate) fn render_markdown_agent_with_links_and_cwd( width: Option, cwd: Option<&Path>, ) -> Vec { - let normalized = unwrap_markdown_fences(markdown_source); - crate::markdown_render::render_markdown_lines_with_width_and_cwd(&normalized, width, cwd) + render_markdown_agent_with_links_cwd_and_visualizations( + markdown_source, + width, + cwd, + /*inline_visualization_context*/ None, + ) +} + +pub(crate) fn render_markdown_agent_with_links_cwd_and_visualizations( + markdown_source: &str, + width: Option, + cwd: Option<&Path>, + inline_visualization_context: Option<&InlineVisualizationContext>, +) -> Vec { + let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context); + let normalized = unwrap_markdown_fences(&rewritten.markdown); + let is_hidden_link_destination = + |destination: &str| rewritten.trusted_file_links.contains_key(destination); + let mut lines = + crate::markdown_render::render_markdown_lines_with_width_cwd_and_hidden_link_destinations( + &normalized, + width, + cwd, + &is_hidden_link_destination, + ); + for hyperlink in lines.iter_mut().flat_map(|line| &mut line.hyperlinks) { + if let Some(link) = rewritten.trusted_file_links.get(&hyperlink.destination) { + hyperlink.retarget_to_trusted_file(&link.destination); + } + } + lines } /// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 52b603bce0..885e139811 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -319,12 +319,30 @@ pub(crate) fn render_markdown_lines_with_width_and_cwd( input: &str, width: Option, cwd: Option<&Path>, +) -> Vec { + render_markdown_lines_with_width_cwd_and_hidden_link_destinations( + input, + width, + cwd, + &never_hide_link_destination, + ) +} + +fn never_hide_link_destination(_: &str) -> bool { + false +} + +pub(crate) fn render_markdown_lines_with_width_cwd_and_hidden_link_destinations( + input: &str, + width: Option, + cwd: Option<&Path>, + is_hidden_link_destination: &dyn Fn(&str) -> bool, ) -> Vec { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter()); - let mut w = Writer::new(input, parser, width, cwd); + let mut w = Writer::new(input, parser, width, cwd, is_hidden_link_destination); w.run(); w.text } @@ -333,6 +351,7 @@ pub(crate) fn render_markdown_lines_with_width_and_cwd( struct LinkState { destination: String, show_destination: bool, + style_label: bool, /// Pre-rendered display text for local file links. /// /// When this is present, the markdown label is intentionally suppressed so the rendered @@ -365,7 +384,7 @@ static HASH_LOCATION_SUFFIX_RE: LazyLock = /// and an optional `TableState` for accumulating table events. The /// `wrap_width` field enables width-aware line wrapping and table column /// allocation; when `None`, lines keep their intrinsic width. -struct Writer<'a, I> +struct Writer<'a, 'policy, I> where I: Iterator, Range)>, { @@ -387,6 +406,7 @@ where code_block_buffer: String, wrap_width: Option, cwd: Option, + is_hidden_link_destination: &'policy dyn Fn(&str) -> bool, line_ends_with_local_link_target: bool, pending_local_link_soft_break: bool, current_line_content: Option, @@ -397,11 +417,17 @@ where table_state: Option, } -impl<'a, I> Writer<'a, I> +impl<'a, 'policy, I> Writer<'a, 'policy, I> where I: Iterator, Range)>, { - fn new(input: &'a str, iter: I, wrap_width: Option, cwd: Option<&Path>) -> Self { + fn new( + input: &'a str, + iter: I, + wrap_width: Option, + cwd: Option<&Path>, + is_hidden_link_destination: &'policy dyn Fn(&str) -> bool, + ) -> Self { Self { input, iter, @@ -421,6 +447,7 @@ where code_block_buffer: String::new(), wrap_width, cwd: cwd.map(Path::to_path_buf), + is_hidden_link_destination, line_ends_with_local_link_target: false, pending_local_link_soft_break: false, current_line_content: None, @@ -1734,9 +1761,14 @@ where } fn push_link(&mut self, dest_url: String) { - let show_destination = should_render_link_destination(&dest_url); + let style_label = (self.is_hidden_link_destination)(&dest_url); + if style_label { + self.push_inline_style(self.styles.link); + } + let show_destination = !style_label && should_render_link_destination(&dest_url); self.link = Some(LinkState { show_destination, + style_label, local_target_display: if is_local_path_like_link(&dest_url) { render_local_link_target(&dest_url, self.cwd.as_deref()) } else { @@ -1748,6 +1780,9 @@ where fn pop_link(&mut self) { if let Some(link) = self.link.take() { + if link.style_label { + self.pop_inline_style(); + } if link.show_destination { // Link destinations are rendered as " (url)" suffixes. When parsing table cells, // append the suffix into the active cell buffer rather than the outer paragraph @@ -2404,7 +2439,13 @@ mod tests { cell.hard_break(); cell.push_span("second line".into()); - let writer = W::new("", std::iter::empty(), Some(80), /*cwd*/ None); + let writer = W::new( + "", + std::iter::empty(), + /*wrap_width*/ Some(80), + /*cwd*/ None, + &never_hide_link_destination, + ); let wrapped = writer.wrap_cell(&cell, /*width*/ 40); let rendered = wrapped .iter() @@ -2426,7 +2467,7 @@ mod tests { // --------------------------------------------------------------- // Type alias for calling private associated functions on Writer. // --------------------------------------------------------------- - type W<'a> = Writer<'a, std::iter::Empty<(Event<'a>, Range)>>; + type W<'a> = Writer<'a, 'a, std::iter::Empty<(Event<'a>, Range)>>; /// Build a single-line `TableCell` from plain text. fn make_cell(text: &str) -> TableCell { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 98e770e045..69d908fb16 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -9,6 +9,7 @@ use crate::clipboard_paste::normalize_pasted_search_query; use crate::color::blend; use crate::color::is_light; use crate::git_action_directives::parse_assistant_markdown; +use crate::inline_visualization::InlineVisualizationContext; use crate::key_hint::KeyBindingListExt; use crate::key_hint::is_plain_text_key_event; use crate::keymap::ListKeymap; @@ -377,6 +378,7 @@ async fn run_resume_picker_with_launch_context( app_server, include_non_interactive, raw_reasoning_visibility(config), + (!uses_remote_workspace).then(|| config.codex_home.to_path_buf()), bg_tx, ), bg_rx, @@ -422,6 +424,7 @@ pub async fn run_fork_picker_with_app_server( app_server, /*include_non_interactive*/ false, raw_reasoning_visibility(config), + (!uses_remote_workspace).then(|| config.codex_home.to_path_buf()), bg_tx, ), bg_rx, @@ -551,6 +554,7 @@ fn spawn_app_server_page_loader( app_server: AppServerSession, include_non_interactive: bool, raw_reasoning_visibility: RawReasoningVisibility, + codex_home: Option, bg_tx: mpsc::UnboundedSender, ) -> PickerLoader { let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); @@ -577,7 +581,9 @@ fn spawn_app_server_page_loader( }); } PickerLoadRequest::Preview { thread_id } => { - let preview = load_transcript_preview(&mut app_server, thread_id).await; + let preview = + load_transcript_preview(&mut app_server, thread_id, codex_home.as_deref()) + .await; let _ = bg_tx.send(BackgroundEvent::Preview { thread_id, preview }); } PickerLoadRequest::Transcript { thread_id } => { @@ -585,6 +591,7 @@ fn spawn_app_server_page_loader( &mut app_server, thread_id, raw_reasoning_visibility, + codex_home.as_deref(), ) .await; let _ = bg_tx.send(BackgroundEvent::Transcript { @@ -765,6 +772,7 @@ async fn load_app_server_page( async fn load_transcript_preview( app_server: &mut AppServerSession, thread_id: ThreadId, + codex_home: Option<&Path>, ) -> std::io::Result> { const MAX_PREVIEW_LINES: usize = 6; @@ -773,6 +781,11 @@ async fn load_transcript_preview( .await .map_err(std::io::Error::other)?; let cwd = thread.cwd.as_path(); + let inline_visualization_context = codex_home.and_then(|codex_home| { + ThreadId::from_string(&thread.id) + .ok() + .and_then(|thread_id| InlineVisualizationContext::new(codex_home, thread_id)) + }); let mut lines = thread .turns .iter() @@ -791,10 +804,27 @@ async fn load_transcript_preview( .collect::>() .join(" "), }), - ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { - speaker: TranscriptPreviewSpeaker::Assistant, - text: parse_assistant_markdown(text, cwd).visible_markdown, - }), + ThreadItem::AgentMessage { text, .. } => { + let visible_markdown = parse_assistant_markdown(text, cwd).visible_markdown; + let rewritten = crate::inline_visualization::rewrite_inline_visualizations( + &visible_markdown, + inline_visualization_context.as_ref(), + ); + let mut text = rewritten.markdown.into_owned(); + for (placeholder, link) in &rewritten.trusted_file_links { + text = text.replace( + &format!( + "{} \n[{}]({placeholder})", + link.markdown_label, link.markdown_destination_label + ), + &format!("{} \n{}", link.display_label, link.destination), + ); + } + Some(TranscriptPreviewLine { + speaker: TranscriptPreviewSpeaker::Assistant, + text, + }) + } _ => None, }) .flat_map(|line| { @@ -5813,12 +5843,16 @@ session_picker_view = "dense" }], }; - let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) - .into_iter() - .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) - .map(|line| line.to_string()) - .collect::>() - .join("\n"); + let rendered = thread_to_transcript_cells( + &thread, + RawReasoningVisibility::Visible, + /*codex_home*/ None, + ) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); assert!(rendered.contains("hello from user")); assert!(rendered.contains("hello from assistant")); @@ -5871,18 +5905,26 @@ session_picker_view = "dense" }], }; - let hidden = thread_to_transcript_cells(&thread, RawReasoningVisibility::Hidden) - .into_iter() - .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - let visible = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) - .into_iter() - .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) - .map(|line| line.to_string()) - .collect::>() - .join("\n"); + let hidden = thread_to_transcript_cells( + &thread, + RawReasoningVisibility::Hidden, + /*codex_home*/ None, + ) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + let visible = thread_to_transcript_cells( + &thread, + RawReasoningVisibility::Visible, + /*codex_home*/ None, + ) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); assert!(!hidden.contains("private raw chain of thought")); assert!(visible.contains("private raw chain of thought")); @@ -5933,12 +5975,16 @@ session_picker_view = "dense" }], }; - let rendered = thread_to_transcript_cells(&thread, RawReasoningVisibility::Visible) - .into_iter() - .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) - .map(|line| line.to_string()) - .collect::>() - .join("\n"); + let rendered = thread_to_transcript_cells( + &thread, + RawReasoningVisibility::Visible, + /*codex_home*/ None, + ) + .into_iter() + .flat_map(|cell| cell.transcript_lines(/*width*/ 80)) + .map(|line| line.to_string()) + .collect::>() + .join("\n"); assert!(rendered.contains("raw reasoning content")); assert!(!rendered.contains("public summary")); diff --git a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__agent_code_blocks_preserve_visualization_directive_literals.snap b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__agent_code_blocks_preserve_visualization_directive_literals.snap new file mode 100644 index 0000000000..5f212bc63a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__agent_code_blocks_preserve_visualization_directive_literals.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/inline_visualization_tests.rs +expression: text +--- +• Fenced: + + ::codex-inline-vis{file="chart.html"} + + Indented: + + ::codex-inline-vis{file="chart.html"} diff --git a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__finalized_agent_cell_visualization_link.snap b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__finalized_agent_cell_visualization_link.snap new file mode 100644 index 0000000000..4ece4e88c0 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__finalized_agent_cell_visualization_link.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/inline_visualization_tests.rs +expression: snapshot_text +--- +• Before + + Open chart visualization in the browser + file:// + + After diff --git a/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__viewer_document_contract.snap b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__viewer_document_contract.snap new file mode 100644 index 0000000000..9316ebef47 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__inline_visualization__tests__viewer_document_contract.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/inline_visualization_tests.rs +expression: contract +--- +chart + +embedded frame: +- canonical control styles +- Floating UI tooltip runtime +- Lucide icon runtime +- visualization fragment diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 61ffd3da7d..91027f8103 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -893,10 +893,10 @@ impl HistoryCell for StatusHistoryCell { 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(), - }); + .push(crate::terminal_hyperlinks::TerminalHyperlink::web( + start..start + CHATGPT_USAGE_URL.width(), + CHATGPT_USAGE_URL.to_string(), + )); } } lines diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 95a3b72269..8bff317be6 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -39,7 +39,8 @@ 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::render_markdown_agent_with_links_and_cwd; +use crate::inline_visualization::InlineVisualizationContext; +use crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations; use crate::style::proposed_plan_style; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::plain_hyperlink_lines; @@ -84,6 +85,7 @@ struct StreamCore { emitted_stable_len: usize, /// Session cwd used to keep local file-link display stable during stream re-renders. cwd: PathBuf, + inline_visualization_context: Option, render_mode: HistoryRenderMode, /// Cached rendered line count for prefix-before-table keyed by source start and width. stable_prefix_len_cache: Option, @@ -104,7 +106,12 @@ struct StablePrefixLenCache { } impl StreamCore { - fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { + fn new( + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option, + ) -> Self { Self { state: StreamState::new(width, cwd), width, @@ -113,6 +120,7 @@ impl StreamCore { enqueued_stable_len: 0, emitted_stable_len: 0, cwd: cwd.to_path_buf(), + inline_visualization_context, render_mode, stable_prefix_len_cache: None, holdback_scanner: TableHoldbackScanner::new(), @@ -277,10 +285,11 @@ impl StreamCore { fn render_source(&self, source: &str) -> Vec { match self.render_mode { - HistoryRenderMode::Rich => render_markdown_agent_with_links_and_cwd( + HistoryRenderMode::Rich => render_markdown_agent_with_links_cwd_and_visualizations( source, self.width, Some(self.cwd.as_path()), + self.inline_visualization_context.as_ref(), ), HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)), } @@ -434,10 +443,11 @@ impl StreamCore { } let render_start = Instant::now(); - let stable_prefix_render = render_markdown_agent_with_links_and_cwd( + let stable_prefix_render = render_markdown_agent_with_links_cwd_and_visualizations( &self.raw_source[..source_start.min(self.raw_source.len())], self.width, Some(self.cwd.as_path()), + self.inline_visualization_context.as_ref(), ); let stable_prefix_len = stable_prefix_render.len(); tracing::trace!( @@ -470,9 +480,24 @@ impl StreamController { /// `width` is the content width available to markdown rendering, not necessarily the full /// terminal width. Passing a stale width after resize will keep queued live output wrapped for /// the old viewport until app-level reflow repairs the finalized transcript. + #[cfg(test)] pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { + Self::new_with_inline_visualizations( + width, + cwd, + render_mode, + /*inline_visualization_context*/ None, + ) + } + + pub(crate) fn new_with_inline_visualizations( + width: Option, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option, + ) -> Self { Self { - core: StreamCore::new(width, cwd, render_mode), + core: StreamCore::new(width, cwd, render_mode, inline_visualization_context), header_emitted: false, } } @@ -585,7 +610,12 @@ impl PlanStreamController { /// callers must update it when the terminal width changes. pub(crate) fn new(width: Option, cwd: &Path, render_mode: HistoryRenderMode) -> Self { Self { - core: StreamCore::new(width, cwd, render_mode), + core: StreamCore::new( + width, + cwd, + render_mode, + /*inline_visualization_context*/ None, + ), header_emitted: false, top_padding_emitted: false, } diff --git a/codex-rs/tui/src/terminal_hyperlinks.rs b/codex-rs/tui/src/terminal_hyperlinks.rs index 21d6b5d2e4..b89672c518 100644 --- a/codex-rs/tui/src/terminal_hyperlinks.rs +++ b/codex-rs/tui/src/terminal_hyperlinks.rs @@ -27,6 +27,46 @@ use crate::wrapping::adaptive_wrap_line; pub(crate) struct TerminalHyperlink { pub(crate) columns: Range, pub(crate) destination: String, + destination_kind: DestinationKind, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DestinationKind { + Web, + TrustedFile, +} + +impl TerminalHyperlink { + pub(crate) fn web(columns: Range, destination: String) -> Self { + Self { + columns, + destination, + destination_kind: DestinationKind::Web, + } + } + + pub(crate) fn retarget_to_trusted_file(&mut self, destination: &Url) { + // Keep file URLs out of the general Markdown link path. Only generated visualization links + // are promoted to this destination kind. + debug_assert_eq!(destination.scheme(), "file"); + self.destination = destination.to_string(); + self.destination_kind = DestinationKind::TrustedFile; + } + + fn with_columns(&self, columns: Range) -> Self { + Self { + columns, + destination: self.destination.clone(), + destination_kind: self.destination_kind, + } + } + + fn terminal_destination(&self) -> Option { + match self.destination_kind { + DestinationKind::Web => web_destination(&self.destination), + DestinationKind::TrustedFile => trusted_file_destination(&self.destination), + } + } } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -54,10 +94,8 @@ impl HyperlinkLine { if end > start && let Some(destination) = destination.and_then(web_destination) { - self.hyperlinks.push(TerminalHyperlink { - columns: start..end, - destination, - }); + self.hyperlinks + .push(TerminalHyperlink::web(start..end, destination)); } } @@ -194,11 +232,7 @@ pub(crate) fn remap_wrapped_line( .iter() .find(|link| link.columns.contains(&source_column)) { - push_link_range( - line, - output_column..output_column + width, - &link.destination, - ); + push_link_range(line, output_column..output_column + width, link); } source_column += width; output_column += width; @@ -223,21 +257,19 @@ fn longest_suffix_matching_prefix(rendered: &str, source: &str) -> Option .find(|index| source.starts_with(&rendered[*index..]) && *index < rendered.len()) } -fn push_link_range(line: &mut HyperlinkLine, range: Range, destination: &str) { +fn push_link_range(line: &mut HyperlinkLine, range: Range, link: &TerminalHyperlink) { if range.is_empty() { return; } if let Some(previous) = line.hyperlinks.last_mut() - && previous.destination == destination + && previous.destination == link.destination + && previous.destination_kind == link.destination_kind && previous.columns.end == range.start { previous.columns.end = range.end; return; } - line.hyperlinks.push(TerminalHyperlink { - columns: range, - destination: destination.to_string(), - }); + line.hyperlinks.push(link.with_columns(range)); } pub(crate) fn web_links_in_text(text: &str) -> Vec { @@ -262,10 +294,7 @@ pub(crate) fn web_links_in_text(text: &str) -> Vec { }; let start = text[..raw_start + trimmed_start].width(); let end = start + candidate.width(); - links.push(TerminalHyperlink { - columns: start..end, - destination, - }); + links.push(TerminalHyperlink::web(start..end, destination)); } links } @@ -308,10 +337,7 @@ fn has_unmatched_closing_delimiter(candidate: &str, closing: char) -> bool { } pub(crate) fn web_destination(destination: &str) -> Option { - let safe_destination = destination - .chars() - .filter(|ch| !ch.is_control()) - .collect::(); + let safe_destination = sanitized_destination(destination); let parsed = Url::parse(&safe_destination).ok()?; matches!(parsed.scheme(), "http" | "https") .then(|| parsed.host_str()) @@ -319,6 +345,16 @@ pub(crate) fn web_destination(destination: &str) -> Option { Some(safe_destination) } +fn trusted_file_destination(destination: &str) -> Option { + let safe_destination = sanitized_destination(destination); + let parsed = Url::parse(&safe_destination).ok()?; + (parsed.scheme() == "file" && parsed.to_file_path().is_ok()).then_some(safe_destination) +} + +fn sanitized_destination(destination: &str) -> String { + destination.chars().filter(|ch| !ch.is_control()).collect() +} + pub(crate) fn osc8_hyperlink(destination: &str, text: &str) -> String { let Some(safe_destination) = web_destination(destination) else { return text.to_string(); @@ -388,7 +424,7 @@ pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec> { append_to_last_span(&mut out, "\x1b]8;;\x07"); } active_destination = selected_link_index - .and_then(|index| web_destination(&line.hyperlinks[index].destination)); + .and_then(|index| line.hyperlinks[index].terminal_destination()); if let Some(destination) = active_destination.as_ref() { push_styled_content( &mut out, @@ -474,7 +510,12 @@ pub(crate) fn mark_buffer_hyperlinks( if cell.skip || cell.symbol().trim().is_empty() { continue; } - let symbol = osc8_hyperlink(&link.destination, cell.symbol()); + let symbol = link.terminal_destination().map_or_else( + || cell.symbol().to_string(), + |destination| { + format!("\x1b]8;;{destination}\x07{}\x1b]8;;\x07", cell.symbol()) + }, + ); cell.set_symbol(&symbol); } } @@ -536,10 +577,10 @@ mod tests { 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(), - }] + vec![TerminalHyperlink::web( + /*columns*/ 5..26, + "https://example.com/a".to_string(), + )] ); } @@ -548,10 +589,10 @@ mod tests { 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(), - }] + vec![TerminalHyperlink::web( + /*columns*/ 5..5 + destination.width(), + destination.to_string(), + )] ); } @@ -560,10 +601,10 @@ mod tests { 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(), - }], + hyperlinks: vec![TerminalHyperlink::web( + /*columns*/ 0..destination.width(), + destination.to_string(), + )], }; assert_eq!( @@ -579,19 +620,19 @@ mod tests { #[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(), - }); + source.hyperlinks.push(TerminalHyperlink::web( + /*columns*/ 5..9, + "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(), - }] + vec![TerminalHyperlink::web( + /*columns*/ 5..9, + "https://example.com".to_string(), + )] ); } @@ -599,10 +640,10 @@ mod tests { 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(), - }); + line.hyperlinks.push(TerminalHyperlink::web( + /*columns*/ 4..4 + destination.width(), + destination.to_string(), + )); let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 18, /*height*/ 4, ); @@ -624,4 +665,28 @@ mod tests { .collect::(); assert_eq!(linked_text, destination); } + + #[test] + fn trusted_file_destination_receives_osc8_without_enabling_plain_file_links() { + let temp_dir = tempfile::tempdir().expect("temp directory"); + let file_url = Url::from_file_path(temp_dir.path().join("viewer.html")) + .expect("test path should convert to file URL"); + let mut link = TerminalHyperlink::web( + /*columns*/ 0..4, + "https://codex.invalid/viewer".to_string(), + ); + link.retarget_to_trusted_file(&file_url); + let line = HyperlinkLine { + line: Line::from("view"), + hyperlinks: vec![link], + }; + + assert_eq!( + decorate_spans(&line), + vec![Span::from(format!( + "\x1b]8;;{file_url}\x07view\x1b]8;;\x07" + ))] + ); + assert_eq!(osc8_hyperlink(file_url.as_str(), "view"), "view"); + } } diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index b051d3ed2b..afb76e4b94 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -10,6 +10,7 @@ use crate::history_cell::PlainHistoryCell; use crate::history_cell::ReasoningSummaryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::split_reasoning_summary_parts; +use crate::inline_visualization::InlineVisualizationContext; use crate::multi_agents::sub_agent_activity_summary; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; @@ -31,6 +32,7 @@ pub(crate) async fn load_session_transcript( app_server: &mut AppServerSession, thread_id: ThreadId, raw_reasoning_visibility: RawReasoningVisibility, + codex_home: Option<&std::path::Path>, ) -> std::io::Result { let thread = app_server .thread_read(thread_id, /*include_turns*/ true) @@ -39,14 +41,21 @@ pub(crate) async fn load_session_transcript( Ok(thread_to_transcript_cells( &thread, raw_reasoning_visibility, + codex_home, )) } pub(crate) fn thread_to_transcript_cells( thread: &Thread, raw_reasoning_visibility: RawReasoningVisibility, + codex_home: Option<&std::path::Path>, ) -> TranscriptCells { let cwd = thread.cwd.as_path(); + let inline_visualization_context = codex_home.and_then(|codex_home| { + ThreadId::from_string(&thread.id) + .ok() + .and_then(|thread_id| InlineVisualizationContext::new(codex_home, thread_id)) + }); let mut cells: TranscriptCells = Vec::new(); for item in thread.turns.iter().flat_map(|turn| turn.items.iter()) { match item { @@ -85,9 +94,10 @@ pub(crate) fn thread_to_transcript_cells( ThreadItem::AgentMessage { text, .. } => { let parsed = parse_assistant_markdown(text, cwd); if !parsed.visible_markdown.trim().is_empty() { - cells.push(Arc::new(AgentMarkdownCell::new( + cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations( parsed.visible_markdown, cwd, + inline_visualization_context.clone(), ))); } }