fix(terminal): stabilize agent formula rendering
This commit is contained in:
1 parent
8dc9f2ba3b
commit
d33bc666b2
5 files changed
+494
-709
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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 都持名义字号。
|
||||
|
||||
Reference in new issue
Block a user