fix(terminal): stabilize agent formula rendering

This commit is contained in:
liu-ws committed 2026-08-25 17:41:23 +08:00
1 parent 8dc9f2ba3b
commit d33bc666b2
5 files changed
+494 -709

No files matched your search

+5 -17
View File
@@ -8550,8 +8550,6 @@ impl Display {
// splits, where panes occupy different vertical bands of the window.
self.renderer.set_window_height(self.size_info.height());
pane_state.terminal_math.observe_program(pane_state.running_program.as_deref());
// Collect renderable content before the terminal is dropped.
let custom_background = self.nebula_background;
let clickable_matches = hint::visible_clickable_matches(&terminal, config);
@@ -8609,19 +8607,13 @@ impl Display {
// 打字(含 IME 组词)不影响网格内容,扫描保持开启;一旦这里随
// preedit 关断,中文输入的每次拼音组合都会让全部公式闪回原文。
//
// 扫描本身不按 AI CLI 探测门控:$$/\[ \]/\( \) 定界符加上
// plausible_math_source 已经足够抗误报,而 WSL/SSH 里跑的 AI CLI
// 在本机进程树上只留下 wsl.exe/ssh.exe,按进程门控会把远程会话的
// 公式全部关掉。裸 `$…$` 中包含明确 TeX 命令时也可安全渲染;只
// 有 `$x$` 这类紧凑形式才仍要求 AI CLI 上下文。
let allow_inline_dollar = pane_state.terminal_math.inline_dollar_enabled();
// opencode 等 AI TUI 使用备用屏幕;在这里排除 alt_screen 会让其中
// 的公式永远到不了扫描器。Vi、搜索和有效选区仍由终端自身接管。
// 扫描不按 AI CLI 进程名门控:WSL/SSH 中只能看到 wsl.exe/ssh.exe。
// 四类标准定界符使用同一内容判定;Vi、搜索和选区仍由终端接管。
let terminal_math_overlays =
if !vi_mode && search_state.regex().is_none() && selection_range.is_none() {
// In a regular shell the line containing the cursor is live input,
// so the scanner must leave it alone. Full-screen TUIs (opencode,
// codex, etc.) instead frequently leave the terminal cursor on the
// so the scanner must leave it alone. Full-screen terminal apps
// instead frequently leave the terminal cursor on the
// last rendered message. Treating that row as editable makes its
// formula disappear exactly while it is visible.
let visible_cursor = (!alt_screen)
@@ -8635,7 +8627,6 @@ impl Display {
&terminal,
&view,
&grid_cells,
allow_inline_dollar,
alt_screen,
visible_cursor,
foreground_color,
@@ -8828,10 +8819,7 @@ impl Display {
let cells = grid_cells.into_iter().filter_map(|mut cell| {
let source_point = cell.point;
// Hide formula source glyphs while retaining each terminal
// cell's resolved background. Full-screen TUIs such as
// opencode paint their own surface with ANSI backgrounds;
// dropping the entire cell exposes Nebula's card underneath
// and creates a differently coloured horizontal strip.
// cell's resolved background.
let formula_source =
!math_coverage.is_empty() && math_coverage.covers(source_point);
if formula_source {
File diff suppressed because it is too large. Load diff
@@ -237,13 +237,6 @@ impl Element for TerminalElement {
let Some(term) = view.session.as_ref().map(|session| session.term.clone()) else {
return super::math_overlay::MathFrame::default();
};
// CommandStart / NEBULA| / hook 都写入 running_program;回合
// 结束后 hook 身份仍可能留在 ai_session.source(旧壳同事实源)。
view.math.observe_program(
view.running_program
.as_deref()
.or(view.ai_session.as_ref().map(|session| session.source.as_str())),
);
let size_info =
super::math_overlay::grid_size_info(layout.cols, layout.rows, cell_w, line_h);
let term = term.lock();
@@ -6,7 +6,7 @@
//! `math_view` 的位图管线上屏。
//!
//! 与旧壳 `draw_pane` 相同的门控:vi 模式 / 存在选区时整帧不覆盖(源码可
//! 正常选择复制)。备用屏幕中的 AI TUI 会把光标留在回答末行,因此不能把
//! 正常选择复制)。备用屏幕程序可能把光标留在静态内容末行,因此不能把
//! 那一行当作正在编辑而排除。围栏过滤与前景色走同一条
//! `scan_visible(..., rendered_cells)`
//! 合同:从 term 网格构造等价 cell 列表(`bg_alpha` = 非默认 ANSI 背景或
@@ -61,16 +61,6 @@ impl MathFrame {
}
impl MathOverlay {
/// shell 集成上报的前台程序(AI CLI 名单解锁行内 `$…$`,与旧壳同门)。
///
/// 身份先经 `extract_program` + `AgentKind` 归一成 slug(`claude-code` →
/// `claude`),再交给共享的 `is_ai_cli`。CommandStart / `NEBULA|` / hook
/// 三条路只要把同一串交给这里即可。
pub fn observe_program(&mut self, program: Option<&str>) {
let normalized = program.map(normalize_ai_program);
self.state.observe_program(normalized.as_deref());
}
/// 每帧的扫描 + 预编 + 几何计划。必须在 term 锁内调用。
///
/// `size` 以网格左上角为原点(padding = 0),行列必须与可见网格一致,
@@ -83,8 +73,7 @@ impl MathOverlay {
font_pixel_size: f32,
pixels_per_point: f32,
) -> MathFrame {
// Vi 模式与有效选择由终端自身接管,避免覆盖层遮住光标或选区。备用
// 屏幕不能一概排除:opencode 等 AI TUI 在其中输出 TeX 公式。
// Vi 模式与有效选择由终端自身接管,避免覆盖层遮住光标或选区。
// 空选区 `to_range` 为 None,不会因为鼠标按下残留的零宽 Selection
// 把公式关掉。
if term.mode().intersects(TermMode::VI)
@@ -93,10 +82,9 @@ impl MathOverlay {
return MathFrame::default();
}
let allow_inline_dollar = self.state.inline_dollar_enabled();
let origin = term.viewport_origin_for(size.screen_lines());
// In a normal shell, the cursor row is active input and must keep its
// literal source. Full-screen AI TUIs commonly park that cursor on the
// literal source. Full-screen terminal apps commonly park that cursor on the
// final answer row; passing it through would filter the very formula
// we need to draw.
let cursor = (!term.mode().contains(TermMode::ALT_SCREEN))
@@ -111,7 +99,6 @@ impl MathOverlay {
term,
size,
&rendered_cells,
allow_inline_dollar,
term.mode().contains(TermMode::ALT_SCREEN),
cursor,
default_foreground,
@@ -174,14 +161,6 @@ pub(crate) fn grid_size_info(
)
}
fn normalize_ai_program(program: &str) -> String {
let extracted =
crate::display::extract_program(program).unwrap_or_else(|| program.trim().to_owned());
crate::ai_agents::AgentKind::parse(&extracted)
.map(|agent| agent.slug().to_owned())
.unwrap_or(extracted)
}
/// 旧壳 `RenderableContent` 迭代器的 GPUI 等价物:给 `scan_visible` 填
/// fallback / `bg_alpha` / 前景。`bg_alpha` 对齐 `compute_bg_alpha`(无
/// transparent_background_colors):默认 Named 背景为 0,反色或其它 ANSI
@@ -345,7 +324,7 @@ mod tests {
use nebula_terminal::vte::ansi::Color;
use nebula_terminal::{Term, index::Side};
use super::{MathOverlay, grid_size_info, normalize_ai_program, scan_cells_from_term};
use super::{MathOverlay, grid_size_info, scan_cells_from_term};
use crate::display::color::Rgb;
struct TestSize {
@@ -405,13 +384,6 @@ mod tests {
assert_eq!(size.padding_y(), 0.0);
}
#[test]
fn normalize_ai_program_maps_claude_code_alias_to_is_ai_cli_slug() {
assert_eq!(normalize_ai_program("claude-code"), "claude");
assert_eq!(normalize_ai_program(r"D:\tools\Claude.EXE"), "claude");
assert_eq!(normalize_ai_program("codex-cli"), "codex");
}
#[test]
fn scan_cells_keep_backslash_and_skip_default_background_spaces() {
let term = term_with(16, 3, &[r"\[x\]", "tail"]);
@@ -440,46 +412,42 @@ mod tests {
let mut overlay = MathOverlay::default();
let term = term_with(16, 4, &[r"\(x\)", "", "prompt"]);
let frame = plan(&mut overlay, &term, 16, 4);
assert!(!frame.is_empty(), "\\(x\\) is inline and does not need inline_dollar");
assert!(!frame.is_empty(), "\\(x\\) is standard inline math");
assert!(frame.covers(0, 0));
assert_eq!(frame.formulas[0].source.as_ref(), "x");
assert!(!frame.formulas[0].plan.display_style);
}
#[test]
fn inline_dollar_needs_ai_cli_identity() {
fn inline_dollar_uses_the_standard_formula_rule() {
let mut overlay = MathOverlay::default();
let term = term_with(16, 4, &["$x$", "", "prompt"]);
let frame = plan(&mut overlay, &term, 16, 4);
assert!(frame.is_empty(), "bare $x$ must stay literal without AI CLI");
overlay.observe_program(Some("claude-code"));
let frame = plan(&mut overlay, &term, 16, 4);
assert!(!frame.is_empty(), "claude-code must normalize to claude and unlock $");
assert!(!frame.is_empty(), "$x$ is standard inline math without AI context");
assert_eq!(frame.formulas[0].source.as_ref(), "x");
assert!(!frame.formulas[0].plan.display_style);
}
#[test]
fn confirmed_display_formula_unlocks_inline_dollar() {
fn ansi_background_keeps_formula_source_literal() {
let mut overlay = MathOverlay::default();
let display = term_with(16, 4, &[r"\[x\]", "", "prompt"]);
let _ = plan(&mut overlay, &display, 16, 4);
let inline = term_with(16, 4, &["$x$", "", "prompt"]);
let frame = plan(&mut overlay, &inline, 16, 4);
assert!(!frame.is_empty(), "a seen \\[ must unlock inline dollar like the old shell");
}
#[test]
fn ansi_background_fence_keeps_formula_source() {
let mut overlay = MathOverlay::default();
let mut term = term_with(16, 4, &[r"\[x\]", "", "prompt"]);
for col in 0..5 {
let mut term = term_with(16, 4, &["$x$", "", "prompt"]);
for col in 0..3 {
term.grid_mut()[Line(0)][Column(col)].bg = Color::Indexed(4);
}
let frame = plan(&mut overlay, &term, 16, 4);
assert!(frame.is_empty(), "fenced ANSI background must not overlay, matching bg_alpha>0");
assert!(frame.is_empty(), "TUI- or code-owned backgrounds stay untouched");
}
#[test]
fn markdown_stripped_bare_delimiter_still_needs_ansi_context() {
let mut overlay = MathOverlay::default();
let mut term = term_with(24, 4, &[r"(\sqrt{x})", "", "prompt"]);
for col in 0..10 {
term.grid_mut()[Line(0)][Column(col)].bg = Color::Indexed(4);
}
let frame = plan(&mut overlay, &term, 24, 4);
assert!(frame.is_empty(), "bare parentheses remain ambiguous on an ANSI code surface");
}
#[test]
@@ -503,7 +471,7 @@ mod tests {
assert!(!plan(&mut overlay, &term, 96, 4).is_empty());
term.swap_alt();
let frame = plan(&mut overlay, &term, 96, 4);
assert!(!frame.is_empty(), "alternate-screen AI TUI output must render TeX");
assert!(!frame.is_empty(), "alternate-screen output may use the common TeX path");
assert_eq!(
frame.formulas[0].source.as_ref(),
r"n! \approx \sqrt{2\pi n}\left(\dfrac{n}{e}\right)^n"
+139
View File
@@ -60,6 +60,12 @@ pub(super) fn normalize_formula_source<'a>(
if let Some(text) = repair_collapsed_row_breaks(normalized.as_ref()) {
normalized = Cow::Owned(text);
}
for _ in 0..MAX_ENTITY_DECODE_PASSES {
let Some(text) = brace_unbraced_fraction_arguments(normalized.as_ref()) else {
break;
};
normalized = Cow::Owned(text);
}
if normalized.len() > limits.max_source_bytes {
return Err(MathError::new(MathErrorKind::SourceTooLong, limits.max_source_bytes));
@@ -67,6 +73,124 @@ pub(super) fn normalize_formula_source<'a>(
Ok(normalized)
}
/// `pulldown-latex` requires braces around `\frac` arguments, while TeX also
/// accepts a single token (`\frac13`, `\frac\pi2`). Canonicalize that standard
/// shorthand before parsing without changing already-grouped arguments.
fn brace_unbraced_fraction_arguments(source: &str) -> Option<String> {
let mut normalized = String::with_capacity(source.len());
let mut copied_until = 0usize;
let mut offset = 0usize;
let mut changed = false;
while offset < source.len() {
if source.as_bytes()[offset] != b'\\' {
offset += source[offset..].chars().next()?.len_utf8();
continue;
}
let command_start = offset + 1;
let mut command_end = command_start;
while source.as_bytes().get(command_end).is_some_and(u8::is_ascii_alphabetic) {
command_end += 1;
}
if !matches!(&source[command_start..command_end], "frac" | "dfrac" | "tfrac") {
offset = command_end.max(offset + 1);
continue;
}
let Some((first, second)) = two_tex_arguments(source, command_end) else {
offset = command_end;
continue;
};
if first.2 && second.2 {
// Keep scanning inside grouped arguments: they may contain another
// shorthand fraction even though this outer command is canonical.
offset = command_end;
continue;
}
normalized.push_str(&source[copied_until..command_end]);
let mut cursor = command_end;
for (start, end, grouped) in [first, second] {
normalized.push_str(&source[cursor..start]);
if grouped {
normalized.push_str(&source[start..end]);
} else {
normalized.push('{');
normalized.push_str(&source[start..end]);
normalized.push('}');
}
cursor = end;
}
copied_until = cursor;
offset = cursor;
changed = true;
}
changed.then(|| {
normalized.push_str(&source[copied_until..]);
normalized
})
}
fn two_tex_arguments(
source: &str,
command_end: usize,
) -> Option<((usize, usize, bool), (usize, usize, bool))> {
let first = tex_argument(source, command_end)?;
let second = tex_argument(source, first.1)?;
Some((first, second))
}
fn tex_argument(source: &str, mut offset: usize) -> Option<(usize, usize, bool)> {
while let Some(character) = source[offset..].chars().next() {
if !character.is_whitespace() {
break;
}
offset += character.len_utf8();
}
let first = source[offset..].chars().next()?;
if first == '{' {
return grouped_argument_end(source, offset).map(|end| (offset, end, true));
}
if first != '\\' {
return Some((offset, offset + first.len_utf8(), false));
}
let mut end = offset + 1;
let command_start = end;
while source.as_bytes().get(end).is_some_and(u8::is_ascii_alphabetic) {
end += 1;
}
if end == command_start {
end += source[end..].chars().next()?.len_utf8();
}
Some((offset, end, false))
}
fn grouped_argument_end(source: &str, start: usize) -> Option<usize> {
let mut depth = 0usize;
let mut escaped = false;
for (relative, character) in source[start..].char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' => escaped = true,
'{' => depth += 1,
'}' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(start + relative + character.len_utf8());
}
},
_ => {},
}
}
None
}
fn decode_entities_once(source: &str) -> Option<String> {
match html_escape::decode_html_entities(source) {
Cow::Borrowed(_) => None,
@@ -241,6 +365,21 @@ mod tests {
assert!(layout.text.iter().all(|operation| operation.character != '&'));
}
#[test]
fn unbraced_fraction_arguments_are_canonicalized_before_parsing() {
let source = r"\displaystyle \int_0^1 x^2\,dx=\frac13+\frac\pi2+\dfrac1{n}";
let normalized = normalize_formula_source(source, DEFAULT_LIMITS).unwrap();
assert_eq!(
normalized,
r"\displaystyle \int_0^1 x^2\,dx=\frac{1}{3}+\frac{\pi}{2}+\dfrac{1}{n}"
);
compile_formula(source, false, 18.0, 1.0, DEFAULT_LIMITS).unwrap();
let nested = normalize_formula_source(r"\frac{\frac12}{3}", DEFAULT_LIMITS).unwrap();
assert_eq!(nested, r"\frac{\frac{1}{2}}{3}");
}
/// 光学补偿必须真正到达 metrics:Latin Modern 的 x-height 是 0.431 em,
/// 名义 20px 下补偿后应为 0.431 × 20 × [`super::super::OPTICAL_SCALE`]。
/// 补偿只在 compile_formula 单点生效;缓存键、fit、draw 都持名义字号。