From d33bc666b2eb2792fbbd6b580d4bc9713832f66a Mon Sep 17 00:00:00 2001 From: liu-ws Date: Tue, 25 Aug 2026 17:41:23 +0800 Subject: [PATCH] fix(terminal): stabilize agent formula rendering --- nebula_app/src/display/mod.rs | 22 +- nebula_app/src/display/terminal_math.rs | 957 ++++++------------ nebula_app/src/gpui_shell/terminal/element.rs | 7 - .../src/gpui_shell/terminal/math_overlay.rs | 78 +- nebula_app/src/math/compile.rs | 139 +++ 5 files changed, 494 insertions(+), 709 deletions(-) diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index fc9fefd..2282e82 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -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 { diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index a6af012..84dacfe 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -25,10 +25,6 @@ use crate::renderer::math::MathClip; use crate::renderer::{GlyphCache, Renderer}; const MAX_VISIBLE_FORMULAS: usize = 64; -/// A few terminal rows are enough for a TUI which separates a single-dollar -/// opener from its TeX body while it reflows a Markdown response. Keeping this -/// bounded prevents an unmatched `$` from consuming an arbitrary transcript. -const MAX_SPLIT_SINGLE_DOLLAR_ROWS: usize = 8; const MAX_PERSISTED_FORMULAS: usize = 2_048; const PERSISTED_FORMULA_BUDGET: usize = 1024 * 1024; const MAX_HISTORY_FORMULA_ROWS: usize = 512; @@ -40,10 +36,6 @@ const MAX_ABSORBED_BLANK_ROWS: usize = 2; /// Sliver of a lent blank row kept free, in rows, so the ink never reaches the /// row past it. const BLANK_ROW_MARGIN: f32 = 0.1; -/// Minimum horizontal gutter that separates a TUI's main content from a -/// right-hand status/sidebar region. Shorter runs are ordinary word spacing -/// and must not change block-math centring. -const MIN_PANE_GUTTER_COLUMNS: usize = 8; /// Neighbour state before [`apply_layout_hints`] has looked at the grid: the /// whole row counts as occupied, so an overlay that somehow skipped the scan /// gets the line gap and nothing more. @@ -72,7 +64,6 @@ const HEIGHT_OVERRUN_TOLERANCE: f32 = 0.12; pub(crate) struct TerminalMathState { cache: MathLayoutCache, projection: LineProjection, - ai_cli_seen: bool, formulas: BTreeMap, persisted_bytes: usize, max_formula_rows: usize, @@ -86,7 +77,6 @@ impl Default for TerminalMathState { Self { cache: MathLayoutCache::default(), projection: LineProjection::default(), - ai_cli_seen: false, formulas: BTreeMap::new(), persisted_bytes: 0, max_formula_rows: 0, @@ -101,7 +91,7 @@ impl Clone for TerminalMathState { fn clone(&self) -> Self { // Pane clones can be attached to a different PTY. Absolute grid anchors // must therefore be rebuilt from that pane instead of crossing sessions. - Self { ai_cli_seen: self.ai_cli_seen, ..Self::default() } + Self::default() } } @@ -109,7 +99,6 @@ impl fmt::Debug for TerminalMathState { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("TerminalMathState") - .field("ai_cli_seen", &self.ai_cli_seen) .field("projected_spans", &self.projection.spans.len()) .field("persisted_formulas", &self.formulas.len()) .field("persisted_bytes", &self.persisted_bytes) @@ -118,14 +107,6 @@ impl fmt::Debug for TerminalMathState { } impl TerminalMathState { - pub(crate) fn observe_program(&mut self, program: Option<&str>) { - self.ai_cli_seen |= program.is_some_and(is_ai_cli); - } - - pub(crate) fn inline_dollar_enabled(&self) -> bool { - self.ai_cli_seen - } - pub(crate) fn update_projection( &mut self, overlays: &[FormulaOverlay], @@ -194,11 +175,10 @@ impl TerminalMathState { fn scan_visible_grid( &mut self, grid: &TextGrid, - allow_inline_dollar: bool, active_edit_rows: Option<&std::ops::RangeInclusive>, ) -> Option { let mut completed_pending = false; - let scan = scan_grid_result(grid, allow_inline_dollar); + let scan = scan_grid_result(grid); for overlay in scan.overlays { // 当前光标所在的逻辑行仍由 CLI 编辑器拥有。这里必须在持久化 // 之前排除整段换行链,否则公式会先进入缓存,下一帧又覆盖输入。 @@ -261,16 +241,12 @@ impl TerminalMathState { } } - fn complete_pending_from_history( - &mut self, - history: &TextGrid, - allow_inline_dollar: bool, - ) -> bool { + fn complete_pending_from_history(&mut self, history: &TextGrid) -> bool { let Some(pending) = self.pending_display.take() else { return false; }; - for overlay in scan_grid_result(history, allow_inline_dollar).overlays { + for overlay in scan_grid_result(history).overlays { if !overlay.display { continue; } @@ -303,10 +279,6 @@ impl TerminalMathState { let Some(formula) = PersistedFormula::from_overlay(grid, overlay) else { return; }; - // 屏幕上确认出现过块级 TeX 的 pane 视同 AI CLI 在场,解锁行内 $。 - // WSL/SSH 里跑的 claude/codex 在本机进程树上只留下 wsl.exe/ssh.exe, - // 进程探测对它们永远失明;已渲染的 $$/\[ \] 块才是可靠信号。 - self.ai_cli_seen |= formula.display; let anchor = formula.anchor; if self.formulas.get(&anchor).is_some_and(|existing| existing.same_content(&formula)) { return; @@ -584,25 +556,6 @@ fn relative_row(row: usize, absolute_top: usize) -> i32 { } } -fn is_ai_cli(program: &str) -> bool { - matches!( - program, - "claude" - | "codex" - | "gemini" - | "copilot" - | "cursor" - | "cursor-agent" - | "aider" - | "goose" - | "crush" - | "opencode" - | "pi" - | "grok" - | "grok-cli" - ) -} - #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] enum DelimiterKind { DollarInline, @@ -658,27 +611,11 @@ pub(crate) struct FormulaOverlay { formula_neighbours_above: [bool; MAX_ABSORBED_BLANK_ROWS], formula_neighbours_below: [bool; MAX_ABSORBED_BLANK_ROWS], /// Right edge (in grid columns) available to a display formula whose rows - /// hold nothing after the source. This is normally the viewport edge, but - /// a split TUI may contribute a nearer main-pane boundary. + /// hold nothing after the source. widen_right_to: Option, } impl FormulaOverlay { - /// GPUI 壳的只读视图:该公式覆盖的 (行, 起列, 止列) 序列。行可为负 - /// (部分滚出视口的持久公式)。用于围栏背景过滤与源格跳过。 - pub(crate) fn covered_ranges(&self) -> impl Iterator + '_ { - self.spans.iter().map(|span| (span.row, span.start, span.end)) - } - - /// 归一化后的 TeX 源(GPUI 壳绘制/缓存键用)。 - pub(crate) fn source_arc(&self) -> Arc { - Arc::clone(&self.source) - } - - pub(crate) fn formula_id(&self) -> u64 { - self.formula_id - } - fn contains(&self, point: Point) -> bool { self.spans.iter().any(|span| { usize::try_from(span.row) == Ok(point.line) @@ -760,10 +697,6 @@ struct FormulaBounds { } impl FormulaBounds { - fn width(self) -> f32 { - self.right - self.left - } - fn height(self) -> f32 { self.bottom - self.top } @@ -909,14 +842,7 @@ impl TextGrid { } } - /// Inline TeX may cross a terminal's physical row only when that row was - /// produced by a soft wrap. A real newline still ends the inline span, so - /// separate Markdown lines cannot accidentally become one formula. - fn find_closing_soft_wrap( - &self, - mut position: GridPosition, - delimiter: &[char], - ) -> Option { + fn find_closing(&self, mut position: GridPosition, delimiter: &[char]) -> Option { loop { if position.row >= self.rows.len() { return None; @@ -924,28 +850,6 @@ impl TextGrid { if self.starts_with(position, delimiter) && !self.is_escaped(position) { return Some(position); } - let previous_row = position.row; - position = self.next(position)?; - if position.row != previous_row && !self.wrapped[previous_row] { - return None; - } - } - } - - fn find_closing( - &self, - mut position: GridPosition, - delimiter: &[char], - same_row: bool, - ) -> Option { - let initial_row = position.row; - loop { - if position.row >= self.rows.len() || (same_row && position.row != initial_row) { - return None; - } - if self.starts_with(position, delimiter) && !self.is_escaped(position) { - return Some(position); - } position = self.next(position)?; } } @@ -998,12 +902,6 @@ impl TextGrid { }) } - fn row_is_blank(&self, row: usize) -> bool { - self.rows - .get(row) - .is_some_and(|cells| cells.iter().all(|cell| cell.is_none_or(|c| c == ' '))) - } - /// Columns between the first and last non-blank cell of `row`, or `None` /// for a blank row. Interior gaps are counted as occupied: a formula that /// squeezes its ink between two words of the row above would read as an @@ -1016,44 +914,6 @@ impl TextGrid { Some((start, end)) } - /// Find the left edge of a visually separate right-hand pane. TUIs such as - /// opencode keep their answer and status sidebar in one terminal grid. A - /// display formula in the answer must be centred before that sidebar, not - /// across the combined grid width. - fn right_pane_boundary_after(&self, after: usize) -> Option { - let earliest_boundary = after.max(self.columns / 2); - let earliest_sidebar = self.columns.saturating_mul(2) / 3; - let is_blank = |cell: &Option| cell.is_none_or(|character| character == ' '); - - self.rows - .iter() - .filter_map(|cells| { - let mut column = earliest_boundary; - while column < cells.len() { - if !is_blank(&cells[column]) { - column += 1; - continue; - } - let gutter_start = column; - while column < cells.len() && is_blank(&cells[column]) { - column += 1; - } - let sidebar_start = column; - if gutter_start > 0 - && sidebar_start < cells.len() - && sidebar_start - gutter_start >= MIN_PANE_GUTTER_COLUMNS - && sidebar_start >= earliest_sidebar - && cells[..gutter_start].iter().any(|cell| !is_blank(cell)) - && cells[sidebar_start..].iter().any(|cell| !is_blank(cell)) - { - return Some(gutter_start); - } - } - None - }) - .min() - } - fn spans(&self, start: GridPosition, end: GridPosition) -> Vec { if start.row == end.row { return vec![RowSpan { row: start.row as i32, start: start.column, end: end.column }]; @@ -1088,7 +948,6 @@ pub(crate) fn scan_visible( terminal: &Term, size: &SizeInfo, rendered_cells: &[RenderableCell], - allow_inline_dollar: bool, filter_reasoning_style: bool, cursor: Option>, default_foreground: Rgb, @@ -1096,9 +955,7 @@ pub(crate) fn scan_visible( let grid = TextGrid::from_term(terminal, size); let active_edit_rows = cursor.and_then(|cursor| grid.logical_rows_containing(cursor.line)); state.synchronize_grid(&grid); - if let Some(anchor) = - state.scan_visible_grid(&grid, allow_inline_dollar, active_edit_rows.as_ref()) - { + if let Some(anchor) = state.scan_visible_grid(&grid, active_edit_rows.as_ref()) { // `extract` counts every terminal cell toward the 16 KiB parser limit. // Deriving the row cap from the current width keeps this rare temporary // grid near 64 KiB of cell data instead of scanning all scrollback. @@ -1117,7 +974,7 @@ pub(crate) fn scan_visible( }; if lookback <= source_rows { let history = TextGrid::from_term_with_lookback(terminal, size, lookback); - state.complete_pending_from_history(&history, allow_inline_dollar); + state.complete_pending_from_history(&history); } else { state.pending_display = None; } @@ -1136,19 +993,12 @@ pub(crate) fn scan_visible( .cloned() .collect(); - // ANSI backgrounds once meant “code fence”, but full-screen AI - // clients also use them for every answer card. A display delimiter - // carries enough intent on its own; inline math needs the AI - // context before it may paint over such a card. Ordinary terminal - // code blocks keep their literal source. + // A non-default background usually belongs to a code block or a + // TUI-owned surface. Painting a formula over it would require the + // renderer to understand that application's layout, so the common + // terminal path deliberately leaves the source untouched. let has_ansi_background = overlay.fallback.iter().any(|cell| cell.bg_alpha > 0.0); - // `\(E=mc^2\)` may reach the grid as the already-validated bare - // `(E=mc^2)` form when a WSL-hosted Codex process cannot be - // identified locally. It has the same unambiguous mathematical - // shape as the delimiter that admitted it, so retain it too. - let is_bare_equation = looks_like_bare_parenthesized_equation(&overlay.source); - if has_ansi_background && !overlay.display && !allow_inline_dollar && !is_bare_equation - { + if has_ansi_background { return None; } @@ -1165,8 +1015,7 @@ pub(crate) fn scan_visible( // like part of the answer. Restrict the heuristic to full-screen // TUI callers so a user-coloured formula in an ordinary shell is // never classified as reasoning. - if filter_reasoning_style && formula_uses_reasoning_style(&overlay, default_foreground) - { + if filter_reasoning_style && formula_uses_reasoning_style(&overlay) { return None; } @@ -1180,26 +1029,18 @@ pub(crate) fn scan_visible( /// Whether the source glyphs carry the presentation used by agent reasoning. /// -/// Codex emits SGR DIM. OpenCode's configurable `thinkingOpacity` is resolved -/// by its TUI into a foreground substantially closer to the cell background, -/// so it is detected by relative contrast. Requiring three quarters of the -/// source glyphs to agree avoids treating syntax-coloured final math as a -/// reasoning block because of one muted token. -fn formula_uses_reasoning_style(overlay: &FormulaOverlay, default_foreground: Rgb) -> bool { - let (source_cells, dim_cells, muted_cells) = overlay +/// Codex and Claude Code mark muted reasoning with SGR DIM. Requiring three +/// quarters of the source glyphs to agree avoids classifying a final answer as +/// reasoning because one token happens to be dimmed. +fn formula_uses_reasoning_style(overlay: &FormulaOverlay) -> bool { + let (source_cells, dim_cells) = overlay .fallback .iter() .filter(|cell| !cell.character.is_whitespace()) - .fold((0usize, 0usize, 0usize), |(total, dim, muted), cell| { - let normal = default_foreground.contrast(*cell.bg); - let is_muted = normal > 1.0 && cell.fg.contrast(*cell.bg) <= normal * 0.72; - ( - total + 1, - dim + usize::from(cell.flags.contains(Flags::DIM)), - muted + usize::from(is_muted), - ) + .fold((0usize, 0usize), |(total, dim), cell| { + (total + 1, dim + usize::from(cell.flags.contains(Flags::DIM))) }); - source_cells > 0 && (dim_cells * 4 >= source_cells * 3 || muted_cells * 4 >= source_cells * 3) + source_cells > 0 && dim_cells * 4 >= source_cells * 3 } /// Room the surrounding grid can lend an overlay, decided purely by what its @@ -1228,9 +1069,7 @@ fn apply_layout_hints(overlay: &mut FormulaOverlay, grid: &TextGrid) { Ok(row) if row < grid.rows.len() => grid.span_is_blank(row, span.end, grid.columns), _ => true, })) - .then(|| { - grid.right_pane_boundary_after(source_right).unwrap_or(grid.columns).max(source_right) - }); + .then_some(grid.columns.max(source_right)); } /// Mark rows occupied by a *different* formula after all visible overlays have @@ -1290,15 +1129,15 @@ fn neighbour_rows( } #[cfg(test)] -fn scan_grid(grid: &TextGrid, allow_inline_dollar: bool) -> Vec { - scan_grid_result(grid, allow_inline_dollar).overlays +fn scan_grid(grid: &TextGrid) -> Vec { + scan_grid_result(grid).overlays } /// Test-only mirror of the `scan_visible` tail: scan, then let the neighbours /// hand out the same layout budget the real pipeline would. #[cfg(test)] -fn scan_grid_with_hints(grid: &TextGrid, allow_inline_dollar: bool) -> Vec { - let mut overlays = scan_grid(grid, allow_inline_dollar); +fn scan_grid_with_hints(grid: &TextGrid) -> Vec { + let mut overlays = scan_grid(grid); for overlay in &mut overlays { apply_layout_hints(overlay, grid); } @@ -1311,7 +1150,7 @@ struct GridScanResult { unmatched_display: Option<(GridPosition, DisplayDelimiterKind)>, } -fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResult { +fn scan_grid_result(grid: &TextGrid) -> GridScanResult { let mut overlays = Vec::new(); let mut unmatched_display = None; let mut position = GridPosition { row: 0, column: 0 }; @@ -1325,7 +1164,6 @@ fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResul &['$', '$'], &['$', '$'], DelimiterKind::DollarDisplay, - false, ); (candidate, Some(DisplayDelimiterKind::Dollars)) } else if grid.starts_with(position, &['\\', '[']) && !grid.is_escaped(position) { @@ -1335,7 +1173,6 @@ fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResul &['\\', '['], &['\\', ']'], DelimiterKind::BracketDisplay, - false, ); (candidate, Some(DisplayDelimiterKind::Brackets)) } else if grid.starts_with(position, &['\\', ']']) && !grid.is_escaped(position) { @@ -1348,7 +1185,6 @@ fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResul &['\\', '('], &['\\', ')'], DelimiterKind::Parenthesized, - true, ), None, ) @@ -1357,7 +1193,7 @@ fn scan_grid_result(grid: &TextGrid, allow_inline_dollar: bool) -> GridScanResul } else if grid.character(position) == Some('(') && !grid.is_escaped(position) { (find_bare_paren_formula(grid, position), None) } else if grid.character(position) == Some('$') && !grid.is_escaped(position) { - (find_dollar_formula(grid, position, allow_inline_dollar), None) + (find_dollar_formula(grid, position), None) } else { (None, None) }; @@ -1387,14 +1223,13 @@ fn find_formula( opening: &[char], closing: &[char], kind: DelimiterKind, - same_row: bool, ) -> Option<(FormulaOverlay, GridPosition)> { let source_start = grid.after(open, opening.len()); - let close = grid.find_closing(source_start, closing, same_row)?; + let close = grid.find_closing(source_start, closing)?; let after = grid.after(close, closing.len()); let source = grid.extract(source_start, close)?; let display = kind.is_display(); - if !plausible_math_source(&source, display) { + if !standard_formula_source(&source, display) { return None; } Some((make_overlay(grid, open, after, source, kind), after)) @@ -1403,81 +1238,40 @@ fn find_formula( fn find_dollar_formula( grid: &TextGrid, open: GridPosition, - allow_simple_formula: bool, ) -> Option<(FormulaOverlay, GridPosition)> { let source_start = grid.after(open, 1); - let first = grid.character(source_start)?; - if first == '$' { + if grid.character(source_start)? == '$' { return None; } - // Some full-screen Markdown TUIs place the `$` just after the prose label - // and move the complete TeX source to the next terminal row. This is not - // regular inline Markdown, so only recognise it when both delimiters end - // their rows and the body supplies an explicit TeX command. - if first.is_whitespace() { - return find_split_dollar_display(grid, open); - } - - let mut search = source_start; - while let Some(close) = grid.find_closing_soft_wrap(search, &['$']) { - let previous = grid.previous(close).and_then(|position| grid.character(position)); - let after = grid.after(close, 1); - let next = grid.next(close).and_then(|position| grid.character(position)); - if previous.is_some_and(char::is_whitespace) - || next.is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') - { - search = grid.after(close, 1); - continue; - } - - let source = grid.extract(source_start, close)?; - // A recognised AI CLI (or an earlier display formula) may render compact - // `$x$`-style notation. In an ordinary shell, require an explicit TeX - // command so WSL process indirection cannot hide Agent output while - // currency and shell-like text stay literal. - if plausible_math_source(&source, false) - && (allow_simple_formula || has_known_tex_command(&source)) - { - return Some(( - make_overlay(grid, open, after, source, DelimiterKind::DollarInline), - after, - )); - } - return None; - } - None -} - -fn find_split_dollar_display( - grid: &TextGrid, - open: GridPosition, -) -> Option<(FormulaOverlay, GridPosition)> { - if !grid.span_is_blank(open.row, open.column + 1, grid.columns) { - return None; - } - let body_row = open.row.checked_add(1)?; - let source_start = GridPosition { row: body_row, column: 0 }; - let last_row = body_row - .saturating_add(MAX_SPLIT_SINGLE_DOLLAR_ROWS.saturating_sub(1)) - .min(grid.rows.len().checked_sub(1)?); - let mut search = source_start; - let close = loop { - let candidate = grid.find_closing(search, &['$'], false)?; - if candidate.row > last_row { - return None; - } - if grid.span_is_blank(candidate.row, candidate.column + 1, grid.columns) { - break candidate; - } - search = grid.after(candidate, 1); - }; + let close = find_inline_dollar_closing(grid, source_start)?; let after = grid.after(close, 1); let source = grid.extract(source_start, close)?; - if !has_known_tex_command(&source) || !plausible_math_source(&source, true) { + if !standard_formula_source(&source, false) { return None; } - Some((make_overlay(grid, open, after, source, DelimiterKind::DollarDisplay), after)) + Some((make_overlay(grid, open, after, source, DelimiterKind::DollarInline), after)) +} + +/// Find a single-dollar closer without borrowing either character from a +/// `$$` display delimiter. This remains true across terminal rows: an unmatched +/// shell/currency dollar above a display formula must not consume its opener. +fn find_inline_dollar_closing(grid: &TextGrid, mut position: GridPosition) -> Option { + loop { + if position.row >= grid.rows.len() { + return None; + } + if grid.character(position) == Some('$') && !grid.is_escaped(position) { + let previous_is_dollar = + grid.previous(position).and_then(|previous| grid.character(previous)) == Some('$'); + let next_is_dollar = + grid.next(position).and_then(|next| grid.character(next)) == Some('$'); + if !previous_is_dollar && !next_is_dollar { + return Some(position); + } + } + position = grid.next(position)?; + } } /// Markdown-unescaped display block: some AI CLIs run their answer through a @@ -1503,7 +1297,7 @@ fn find_bare_bracket_formula( // `[0, 1]` 区间的 `]` 不具备闭合资格,跳过继续找。 let mut search = source_start; loop { - let candidate = grid.find_closing(search, &[']'], false)?; + let candidate = grid.find_closing(search, &[']'])?; if grid.span_is_blank(candidate.row, 0, candidate.column) && grid.span_is_blank(candidate.row, candidate.column + 1, grid.columns) { @@ -1523,7 +1317,7 @@ fn find_bare_bracket_formula( }; let after = grid.after(close, 1); let source = grid.extract(source_start, close)?; - if !plausible_math_source(&source, true) || (!multiline && !has_known_tex_command(&source)) { + if !bare_formula_source(&source, true) { return None; } Some((make_overlay(grid, open, after, source, DelimiterKind::BareBracketDisplay), after)) @@ -1568,38 +1362,77 @@ fn find_bare_paren_formula( _ => {}, } position = grid.next(position)?; - // 与 `\(…\)` 的 same_row 合同一致:行内公式不跨物理行。 - if position.row != open.row { - return None; - } }; let after = grid.after(close, 1); let source = grid.extract(source_start, close)?; - let known_tex = has_known_tex_command(&source); - let bare_equation = looks_like_bare_parenthesized_equation(&source); - if (!known_tex && !bare_equation) || !plausible_math_source(&source, bare_equation) { + if !bare_formula_source(&source, false) { return None; } Some((make_overlay(grid, open, after, source, DelimiterKind::BareParenInline), after)) } -/// Strict fallback for Markdown renderers that turn `\(E=mc^2\)` into -/// `(E=mc^2)`. Requiring an equality plus an arithmetic/exponent marker avoids -/// rendering configuration prose such as `(key=value)`. -fn looks_like_bare_parenthesized_equation(source: &str) -> bool { +/// Strict compact-equation fallback for delimiters whose surrounding context +/// may not yet identify an AI client. Requiring an equality plus an +/// arithmetic/exponent marker or a short implicit product avoids rendering +/// configuration prose such as `key=value`. +fn looks_like_compact_equation(source: &str) -> bool { + let source = compact_equation_source(source); + let compact_syntax = source.chars().all(|character| { + character.is_ascii_alphanumeric() + || character.is_ascii_whitespace() + || matches!( + character, + '=' | '+' | '-' | '*' | '/' | '^' | '_' | '{' | '}' | '(' | ')' | '.' | ',' + ) + }); + if !compact_syntax { + return false; + } + + let Some((left, right)) = source.split_once('=') else { + return false; + }; + if right.contains('=') { + return false; + } + + source.chars().any(|character| { + character.is_ascii_digit() || matches!(character, '+' | '-' | '*' | '/' | '^' | '_') + }) || looks_like_implicit_product_equation(left, right) +} + +fn compact_equation_source(source: &str) -> &str { let source = source.trim(); - source.contains('=') - && source.chars().all(|character| { - character.is_ascii_alphanumeric() - || character.is_ascii_whitespace() - || matches!( - character, - '=' | '+' | '-' | '*' | '/' | '^' | '_' | '{' | '}' | '.' | ',' - ) - }) - && source.chars().any(|character| { - character.is_ascii_digit() || matches!(character, '+' | '-' | '*' | '/' | '^' | '_') - }) + // Presentation commands do not change whether the following source is a + // compact equation. Markdown renderers may leave this command inside bare + // parentheses after consuming the original `\(` / `\)` delimiters. + source.strip_prefix(r"\displaystyle").map(str::trim_start).unwrap_or(source) +} + +/// Recognize compact equations whose multiplication signs are conventionally +/// omitted, such as `F=ma` and `PV=nRT`. Requiring short identifiers plus a +/// single-symbol or uppercase variable keeps configuration prose like +/// `key=value` outside the formula path. +fn looks_like_implicit_product_equation(left: &str, right: &str) -> bool { + fn compact_identifier(source: &str) -> Option<(usize, bool)> { + let source = source.trim(); + let count = source.chars().count(); + if !(1..=3).contains(&count) + || !source.chars().all(|character| character.is_ascii_alphabetic()) + { + return None; + } + Some((count, source.chars().any(|character| character.is_ascii_uppercase()))) + } + + let Some((left_count, left_uppercase)) = compact_identifier(left) else { + return false; + }; + let Some((right_count, right_uppercase)) = compact_identifier(right) else { + return false; + }; + + left_count == 1 || right_count == 1 || left_uppercase || right_uppercase } /// Commands that justify treating bare delimiters as math. Deliberately a @@ -1800,142 +1633,20 @@ fn has_known_tex_command(source: &str) -> bool { false } -/// Delimiters state intent, content supplies evidence. Display blocks -/// (`$$…$$`, `\[…\]`) rarely occur outside real math, so lax evidence -/// suffices; bare `$…$` and `\(…\)` collide with currency, shell variables -/// and escaped prose, so their evidence must be structurally compact. -fn plausible_math_source(source: &str, display: bool) -> bool { - let source = source.trim(); - !obviously_non_math(source) && has_math_evidence(source, display) +/// Explicit TeX delimiters are the intent signal. Content validation belongs +/// to the shared compiler; the terminal scanner only rejects an empty span. +fn standard_formula_source(source: &str, _display: bool) -> bool { + !source.trim().is_empty() } -/// Terminal noise that must never render as math regardless of delimiter -/// strength: comments/URLs (`//`), Windows paths (`:\`), control bytes, -/// currency amounts, and ALL_CAPS shell variables. -fn obviously_non_math(source: &str) -> bool { - if source.is_empty() - || source.contains("//") - || source.contains(":\\") - || source - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\t')) - { - return true; - } - - // Currency and shell variables are the dominant terminal use of dollars. - let currency = source.chars().all(|character| { - character.is_ascii_digit() - || character.is_ascii_whitespace() - || ".,EURUSDCNYGBPJPY".contains(character) - }); - let shell_identifier = source.chars().all(|character| { - character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' - }); - currency || (shell_identifier && source.chars().count() > 1) -} - -/// At least one structural sign of mathematics. `lax` (display blocks) lifts -/// the compact-operand requirement on script bases so implicit products like -/// `mc^2` qualify; inline keeps it so `$foo^bar$` stays literal text. -fn has_math_evidence(source: &str, lax: bool) -> bool { - let chars: Vec<_> = source.chars().collect(); - let single_variable = chars.len() == 1 && chars[0].is_alphabetic(); - let tex_command = chars.windows(2).any(|pair| pair[0] == '\\' && pair[1].is_alphabetic()); - let script = source.find(['^', '_']).is_some_and(|index| { - let (base, suffix) = source.split_at(index); - let suffix = &suffix[1..]; - let base_qualifies = if lax { !base.trim().is_empty() } else { explicit_operand(base) }; - base_qualifies && !suffix.trim().is_empty() - }); - let relation = ["<=", ">=", "!=", "==", "=", "<", ">"].into_iter().any(|operator| { - source.find(operator).is_some_and(|index| { - relation_operand(&source[..index]) - && relation_operand(&source[index + operator.len()..]) - }) - }); - let structural = script - || relation - || source.chars().any(|character| { - matches!( - character, - '±' | '×' - | '÷' - | '√' - | '∑' - | '∏' - | '∫' - | '∞' - | '≈' - | '≠' - | '≤' - | '≥' - | '∂' - | '∇' - | '∈' - | '∉' - | '⊂' - | '⊆' - | '∪' - | '∩' - | '→' - | '↦' - ) - }); - let known_function = source.split(|character: char| !character.is_alphabetic()).any(|word| { - matches!( - word.to_ascii_lowercase().as_str(), - "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "det" | "max" | "min" - ) - }); - let function_application = source.find('(').is_some_and(|open| { - let name = source[..open].trim(); - let arguments = source[open + 1..].strip_suffix(')').unwrap_or("").trim(); - name.chars().count() == 1 && name.chars().all(char::is_alphabetic) && !arguments.is_empty() - }); - let parenthesized_variable = source - .strip_prefix('(') - .and_then(|source| source.strip_suffix(')')) - .is_some_and(explicit_operand); - let compact_operator = ['+', '-', '*', '/'].into_iter().any(|operator| { - source.find(operator).is_some_and(|index| { - let (left, right) = source.split_at(index); - let right = &right[operator.len_utf8()..]; - explicit_operand(left) && explicit_operand(right) - }) - }); - - single_variable - || tex_command - || structural - || known_function - || function_application - || parenthesized_variable - || compact_operator -} - -fn explicit_operand(operand: &str) -> bool { - let operand = operand.trim().trim_matches(['(', ')', '[', ']', '{', '}']); - if operand.is_empty() || operand.contains(char::is_whitespace) { - return false; - } - let alphabetic = operand.chars().filter(|character| character.is_alphabetic()).count(); - alphabetic <= 1 - && operand.chars().all(|character| { - character.is_alphanumeric() || matches!(character, '.' | ',' | '\\' | '^' | '_') - }) -} - -fn relation_operand(operand: &str) -> bool { - let operand = operand.trim(); - explicit_operand(operand) - || operand.find('(').is_some_and(|open| { - let name = operand[..open].trim(); - let arguments = operand[open + 1..].strip_suffix(')').unwrap_or("").trim(); - name.chars().count() == 1 - && name.chars().all(char::is_alphabetic) - && !arguments.is_empty() - }) +/// A bare bracket or parenthesis has no explicit TeX intent after Markdown has +/// consumed the delimiter backslashes. Recover it only when the remaining +/// source still carries TeX/equation evidence, and never reinterpret a Windows +/// path as a formula merely because it contains a whitelisted command name. +fn bare_formula_source(source: &str, display: bool) -> bool { + standard_formula_source(source, display) + && !source.contains(":\\") + && (has_known_tex_command(source) || looks_like_compact_equation(source)) } fn make_overlay( @@ -2579,7 +2290,7 @@ mod tests { use super::*; fn overlay_with_source_style(flags: Flags, foreground: Rgb, background: Rgb) -> FormulaOverlay { - let mut overlay = scan_grid(&TextGrid::from_rows(&[r"$$E=mc^2$$"]), true) + let mut overlay = scan_grid(&TextGrid::from_rows(&[r"$$E=mc^2$$"])) .into_iter() .next() .expect("test formula"); @@ -2612,8 +2323,8 @@ mod tests { }) } - fn sources(rows: &[&str], allow_inline: bool) -> Vec<(String, bool)> { - scan_grid(&TextGrid::from_rows(rows), allow_inline) + fn sources(rows: &[&str]) -> Vec<(String, bool)> { + scan_grid(&TextGrid::from_rows(rows)) .into_iter() .map(|formula| (formula.source.to_string(), formula.display)) .collect() @@ -2630,7 +2341,7 @@ mod tests { fn compact_projection_moves_only_render_coordinates() { let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); let original = grid.rows.clone(); - let overlays = scan_grid(&grid, true); + let overlays = scan_grid(&grid); assert_eq!(overlays.len(), 1); let span = overlays[0].spans[0]; let projection = LineProjection::build(&overlays, &[compact_prepared(2)]); @@ -2646,7 +2357,7 @@ mod tests { #[test] fn compact_projection_retains_background_under_visual_formula_box() { let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); - let overlays = scan_grid(&grid, true); + let overlays = scan_grid(&grid); let span = overlays[0].spans[0]; let projection = LineProjection::build(&overlays, &[compact_prepared(2)]); @@ -2667,9 +2378,9 @@ mod tests { } #[test] - fn alternate_screen_keeps_fixed_tui_columns() { + fn disabled_projection_keeps_fixed_columns() { let grid = TextGrid::from_rows(&["pre $x^2$ sidebar"]); - let overlays = scan_grid(&grid, true); + let overlays = scan_grid(&grid); let span = overlays[0].spans[0]; let prepared = [compact_prepared(2)]; let mut state = TerminalMathState::default(); @@ -2690,7 +2401,7 @@ mod tests { #[test] fn compact_projection_discards_overlapping_streaming_candidate() { let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); - let outer = scan_grid(&grid, true).remove(0); + let outer = scan_grid(&grid).remove(0); let outer_span = outer.spans[0]; let mut inner = outer.clone(); inner.spans[0].start += 1; @@ -2706,7 +2417,7 @@ mod tests { #[test] fn compact_projection_accumulates_multiple_formula_shifts() { let grid = TextGrid::from_rows(&["a $x^2$ b $y^2$ c"]); - let overlays = scan_grid(&grid, true); + let overlays = scan_grid(&grid); assert_eq!(overlays.len(), 2); let prepared = [compact_prepared(2), compact_prepared(2)]; let projection = LineProjection::build(&overlays, &prepared); @@ -2723,7 +2434,7 @@ mod tests { #[test] fn formula_hit_testing_returns_source_boundaries() { let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); - let overlays = scan_grid(&grid, true); + let overlays = scan_grid(&grid); let span = overlays[0].spans[0]; let projection = LineProjection::build(&overlays, &[compact_prepared(2)]); @@ -2737,7 +2448,7 @@ mod tests { fn remember_visible(state: &mut TerminalMathState, grid: &TextGrid) { state.synchronize_grid(grid); - for overlay in scan_grid(grid, true) { + for overlay in scan_grid(grid) { state.remember(grid, &overlay); } } @@ -2745,7 +2456,7 @@ mod tests { #[test] fn completed_formula_replaces_overlapping_streaming_candidate() { let grid = TextGrid::from_rows(&["pre $x^2$ suffix"]); - let outer = scan_grid(&grid, true).remove(0); + let outer = scan_grid(&grid).remove(0); let mut inner = outer.clone(); inner.spans[0].start += 1; inner.spans[0].end -= 1; @@ -2763,7 +2474,7 @@ mod tests { #[test] fn recognizes_cli_math_delimiters_and_utf8_prose() { assert_eq!( - sources(&[r"中文 \(x^2+y^2=z^2\) and $\alpha+1$"], true), + sources(&[r"中文 \(x^2+y^2=z^2\) and $\alpha+1$"]), vec![("x^2+y^2=z^2".into(), false), (r"\alpha+1".into(), false)] ); } @@ -2774,58 +2485,61 @@ mod tests { let background = Rgb::new(8, 8, 8); let codex_reasoning = overlay_with_source_style(Flags::DIM, normal, background); - assert!(formula_uses_reasoning_style(&codex_reasoning, normal)); - - let opencode_reasoning = - overlay_with_source_style(Flags::empty(), Rgb::new(128, 128, 128), background); - assert!(formula_uses_reasoning_style(&opencode_reasoning, normal)); + assert!(formula_uses_reasoning_style(&codex_reasoning)); let final_answer = overlay_with_source_style(Flags::empty(), normal, background); - assert!(!formula_uses_reasoning_style(&final_answer, normal)); + assert!(!formula_uses_reasoning_style(&final_answer)); } #[test] - fn tui_split_single_dollar_display_renders_when_tex_starts_on_next_row() { - assert_eq!( - sources( - &[ - "麦克斯韦方程组(高斯定律):$", - r"\nabla \cdot \mathbf{E} = \dfrac{\rho}{\varepsilon_0}$", - ], - true, - ), - vec![((r"\nabla \cdot \mathbf{E} = \dfrac{\rho}{\varepsilon_0}").into(), true)] - ); + fn explicit_inline_formulas_cross_terminal_rows() { + for mut grid in [ + TextGrid::from_rows(&["$e^ ", r"{i\pi}+1=0$"]), + TextGrid::from_rows(&[r"\(e^ ", r"{i\pi}+1=0\)"]), + ] { + for wrapped in [false, true] { + grid.wrapped[0] = wrapped; + let overlays = scan_grid(&grid); + assert_eq!(overlays.len(), 1); + assert!(overlays[0].source.contains("e^")); + assert!(overlays[0].source.contains(r"{i\pi}+1=0")); + } + } } #[test] - fn inline_formula_survives_soft_wrap_but_not_hard_newline() { - let mut wrapped = TextGrid::from_rows(&["$e^ ", r"{i\pi}+1=0$"]); - wrapped.wrapped[0] = true; - let overlays = scan_grid(&wrapped, true); - assert_eq!(overlays.len(), 1); - assert!(overlays[0].source.contains("e^")); - assert!(overlays[0].source.contains(r"{i\pi}+1=0")); - - let hard = TextGrid::from_rows(&["$e^ ", r"{i\pi}+1=0$"]); - assert!(scan_grid(&hard, true).is_empty()); + fn screenshot_inline_formulas_survive_agent_hard_wraps() { + for rows in [ + vec![r"• (\displaystyle \frac{d}{dx}\sin", r"x=\cos x),done"], + vec![r"• \(\displaystyle \frac{d}{dx}\sin", r"x=\cos x\),done"], + vec![r"• $\lim_{x\to0}\frac{\sin", r"x}{x}=1$,$PV=nRT$"], + ] { + let overlays = scan_grid(&TextGrid::from_rows(&rows)); + assert!(!overlays.is_empty(), "expected wrapped formula in {rows:?}"); + for overlay in overlays { + compile_formula(&overlay.source, false, 18.0, 1.0, DEFAULT_LIMITS).unwrap_or_else( + |error| panic!("wrapped formula failed: {:?}: {error:?}", overlay.source), + ); + } + } } #[test] fn display_math_can_cross_hard_terminal_rows() { assert_eq!( - sources(&["answer:", "$$", r"\frac{1}{2} + x^2", "$$", "done"], false), + sources(&["answer:", "$$", r"\frac{1}{2} + x^2", "$$", "done"]), vec![(r"\frac{1}{2} + x^2".into(), true)] ); - assert_eq!( - sources(&[r"\[\sum_{i=1}^n i\]"], false), - vec![(r"\sum_{i=1}^n i".into(), true)] - ); + assert_eq!(sources(&[r"\[\sum_{i=1}^n i\]"]), vec![(r"\sum_{i=1}^n i".into(), true)]); - let multiline = sources( - &["$$", r"\begin{aligned}", r"f(x) &= x^2 \\", r"g(x) &= x+1", r"\end{aligned}", "$$"], - false, - ); + let multiline = sources(&[ + "$$", + r"\begin{aligned}", + r"f(x) &= x^2 \\", + r"g(x) &= x+1", + r"\end{aligned}", + "$$", + ]); assert_eq!(multiline.len(), 1); assert!(multiline[0].0.contains('\n')); assert!(multiline[0].1); @@ -2843,7 +2557,7 @@ mod tests { let mut state = TerminalMathState::default(); state.synchronize_grid(&grid); - assert!(state.scan_visible_grid(&grid, true, Some(&active_rows)).is_none()); + assert!(state.scan_visible_grid(&grid, Some(&active_rows)).is_none()); let overlays = state.visible_overlays(&grid); assert_eq!(overlays.len(), 1); @@ -2857,30 +2571,31 @@ mod tests { let mut state = TerminalMathState::default(); state.synchronize_grid(&grid); - assert!(state.scan_visible_grid(&grid, true, Some(&active_rows)).is_none()); + assert!(state.scan_visible_grid(&grid, Some(&active_rows)).is_none()); assert!(state.pending_display.is_none()); } #[test] fn screenshot_cli_formulas_reach_the_shared_compiler() { let samples = [ - sources(&["$$", r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}", "$$"], false), - sources( - &["$$", r"f(x)=\begin{cases}", "x^2,&x\\geq 0\\", r"-x,&x<0", r"\end{cases}", "$$"], - false, - ), - sources( - &[ - "$$", - r"A=\begin{pmatrix}", - r"1&2&3\&nbsp;", - r"4&5&6\&#160;", - r"7&8&9", - r"\end{pmatrix}", - "$$", - ], - false, - ), + sources(&["$$", r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}", "$$"]), + sources(&[ + "$$", + r"f(x)=\begin{cases}", + "x^2,&x\\geq 0\\", + r"-x,&x<0", + r"\end{cases}", + "$$", + ]), + sources(&[ + "$$", + r"A=\begin{pmatrix}", + r"1&2&3\&nbsp;", + r"4&5&6\&#160;", + r"7&8&9", + r"\end{pmatrix}", + "$$", + ]), ]; for extracted in samples { @@ -2930,11 +2645,13 @@ d & -b \\ } #[test] - fn display_blocks_accept_implicit_products_that_inline_rejects() { - // `mc^2` 的上标基座是隐式乘积:inline 的紧凑操作数检查拒绝它 - // (`$foo^bar$` 在 shell 输出里太常见),块级定界已宣告意图,放行。 - assert_eq!(sources(&["$$", "E = mc^2", "$$"], false), vec![("E = mc^2".into(), true)]); - assert!(sources(&["$mc^2$"], true).is_empty()); + fn compact_equations_use_the_same_rule_for_every_standard_delimiter() { + let expected_inline = vec![("E = mc^2".into(), false)]; + let expected_display = vec![("E = mc^2".into(), true)]; + assert_eq!(sources(&["$E = mc^2$"]), expected_inline); + assert_eq!(sources(&[r"\(E = mc^2\)"]), expected_inline); + assert_eq!(sources(&["$$E = mc^2$$"]), expected_display); + assert_eq!(sources(&[r"\[E = mc^2\]"]), expected_display); } #[test] @@ -2943,23 +2660,23 @@ d & -b \\ // (它们是 markdown 标点转义),`\int`/`\frac` 不是转义所以幸存, // 屏幕上只剩裸 `[` 块——2026-08-17 截图的真实形态。 assert_eq!( - sources(&["[", r"\int_0^1 x^2,dx = \frac{1}{3}", "]"], false), + sources(&["[", r"\int_0^1 x^2,dx = \frac{1}{3}", "]"]), vec![(r"\int_0^1 x^2,dx = \frac{1}{3}".into(), true)] ); assert_eq!( - sources(&["[", r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}", "]"], false), + sources(&["[", r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}", "]"]), vec![(r"\sum_{i=1}^{n} i = \frac{n(n+1)}{2}".into(), true)] ); assert_eq!( - sources(&[r"[ \lim_{x\to 0}\frac{\sin x}{x}=1 ]"], false), + sources(&[r"[ \lim_{x\to 0}\frac{\sin x}{x}=1 ]"]), vec![(r"\lim_{x\to 0}\frac{\sin x}{x}=1".into(), true)] ); // Codex CLI under WSL renders `\[ E = mc^2 \]` as three plain rows // because its Markdown layer consumes the bracket escapes. - assert_eq!(sources(&["[", "E = mc^2", "]"], false), vec![("E = mc^2".into(), true)]); + assert_eq!(sources(&["[", "E = mc^2", "]"]), vec![("E = mc^2".into(), true)]); // Codex's list renderer keeps the bullet on the opening delimiter. assert_eq!( - sources(&["• [", r" \int_{-\infty}^{+\infty} e^{-x^2}\,dx=\sqrt{\pi}", " ]"], false), + sources(&["• [", r" \int_{-\infty}^{+\infty} e^{-x^2}\,dx=\sqrt{\pi}", " ]"]), vec![(r"\int_{-\infty}^{+\infty} e^{-x^2}\,dx=\sqrt{\pi}".into(), true)] ); } @@ -2967,79 +2684,101 @@ d & -b \\ #[test] fn bare_bracket_interval_inside_formula_does_not_close_early() { // 内容行里的 `[0, 1]` 区间:其 `]` 不独占一行,没有闭合资格。 - assert_eq!( - sources(&["[", r"x \in [0, 1]", "]"], false), - vec![(r"x \in [0, 1]".into(), true)] - ); + assert_eq!(sources(&["[", r"x \in [0, 1]", "]"]), vec![(r"x \in [0, 1]".into(), true)]); } #[test] fn bare_brackets_without_tex_evidence_stay_literal() { - assert!(sources(&["[", " 1, 2, 3,", "]"], false).is_empty()); - assert!(sources(&[r#"["alpha", "beta"]"#], false).is_empty()); - assert!(sources(&["[INFO] server started"], false).is_empty()); - assert!(sources(&["[x^2]"], false).is_empty()); - assert!(sources(&["result [ x^2 ] done"], false).is_empty()); - assert!(sources(&["[", r"C:\temp\integral.txt", "]"], false).is_empty()); + assert!(sources(&["[", " 1, 2, 3,", "]"]).is_empty()); + assert!(sources(&[r#"["alpha", "beta"]"#]).is_empty()); + assert!(sources(&["[INFO] server started"]).is_empty()); + assert!(sources(&["[x^2]"]).is_empty()); + assert!(sources(&["result [ x^2 ] done"]).is_empty()); + assert!(sources(&["[", r"C:\temp\integral.txt", "]"]).is_empty()); } #[test] fn markdown_unescaped_bare_parens_render_inline() { assert_eq!( - sources(&[r"也可以使用 (\sqrt{x^2+y^2}) 表示行内公式"], false), + sources(&[r"也可以使用 (\sqrt{x^2+y^2}) 表示行内公式"]), vec![(r"\sqrt{x^2+y^2}".into(), false)] ); - assert_eq!(sources(&["• 质能方程为 (E=mc^2)。"], false), vec![("E=mc^2".into(), false)]); + assert_eq!(sources(&["• 质能方程为 (E=mc^2)。"]), vec![("E=mc^2".into(), false)]); + let sequence = r"a_n=a_1+(n-1)d"; + assert_eq!(sources(&[&format!("• ({sequence}),done")]), vec![(sequence.into(), false)]); + compile_formula(sequence, false, 18.0, 1.0, DEFAULT_LIMITS) + .expect("the recovered sequence formula must compile"); // 括号深度配对:`\sin(x)` 的内层 `)` 不提前截断。 - assert_eq!(sources(&[r"值 (\sin(x)) 收敛"], false), vec![(r"\sin(x)".into(), false)]); + assert_eq!(sources(&[r"值 (\sin(x)) 收敛"]), vec![(r"\sin(x)".into(), false)]); + } + + #[test] + fn codex_line_with_multiple_inline_formulas_keeps_bare_paren_math() { + let source = r"$E = mc^2$,$a^2 + b^2 = c^2$,$e^{i\pi}+1=0$,(\displaystyle \int_0^1 x^2,dx=\frac13),$\sum_{n=1}^{\infty}\frac1{n^2}=\frac{\pi^2}{6}$"; + let formulas = sources(&[source]); + assert_eq!(formulas.len(), 5); + assert_eq!(formulas[3], (r"\displaystyle \int_0^1 x^2,dx=\frac13".into(), false)); + compile_formula(&formulas[3].0, false, 18.0, 1.0, DEFAULT_LIMITS) + .expect("Codex's unbraced fraction arguments must compile"); + } + + #[test] + fn implicit_product_equations_use_the_standard_delimiter_rule() { + let expected = vec![("F=ma".into(), false), ("PV=nRT".into(), false)]; + assert_eq!(sources(&[r"\(F=ma\), \(PV=nRT\)"]), expected); + assert_eq!(sources(&["$F=ma$, $PV=nRT$"]), expected); + assert_eq!( + sources(&[r"(\displaystyle F=ma), (\displaystyle PV=nRT)"]), + vec![(r"\displaystyle F=ma".into(), false), (r"\displaystyle PV=nRT".into(), false),] + ); + + assert_eq!(sources(&[r"\(key=value\)"]), vec![("key=value".into(), false)]); + assert_eq!(sources(&["$key=value$"]), vec![("key=value".into(), false)]); + assert!(sources(&["setting (key=value)"]).is_empty()); } #[test] fn bare_parens_reject_regex_prose_and_single_letter_escapes() { - assert!(sources(&[r"grep -E (\d+) input.txt"], false).is_empty()); - assert!(sources(&[r"match (\w*) here"], false).is_empty()); - assert!(sources(&["plain (normal prose) text"], false).is_empty()); - assert!(sources(&["setting (key=value)"], false).is_empty()); - assert!(sources(&[r"case (\n) newline"], false).is_empty()); - assert!(sources(&[r"path (C:\temp\frac) oops"], false).is_empty()); + assert!(sources(&[r"grep -E (\d+) input.txt"]).is_empty()); + assert!(sources(&[r"match (\w*) here"]).is_empty()); + assert!(sources(&["plain (normal prose) text"]).is_empty()); + assert!(sources(&["setting (key=value)"]).is_empty()); + assert!(sources(&[r"case (\n) newline"]).is_empty()); + assert!(sources(&[r"path (C:\temp\frac) oops"]).is_empty()); } #[test] - fn escaped_dollars_currency_and_shell_variables_stay_literal() { - assert!(sources(&[r"escaped \$x$"], true).is_empty()); - assert!(sources(&["price $5$ and $12.50$"], true).is_empty()); - assert!(sources(&["echo $HOME/$USER"], true).is_empty()); - assert!(sources(&["env $LONG_VARIABLE$"], true).is_empty()); - assert!(sources(&["quote $USD 20$ today"], true).is_empty()); - assert!(sources(&["literal $hello$ text"], true).is_empty()); - assert!(sources(&["path $foo/bar$"], true).is_empty()); - assert!(sources(&["total $10 USD$"], true).is_empty()); - assert!(sources(&["literal $hello_world$"], true).is_empty()); - assert!(sources(&["date $2026-07-19$"], true).is_empty()); - assert!(sources(&["config $PATH=/tmp$"], true).is_empty()); - assert!(sources(&["echo $$", "pid", "echo $$"], true).is_empty()); - assert!(sources(&[r"plain \(normal prose\)"], true).is_empty()); - assert!(sources(&["$$hello world$$"], true).is_empty()); + fn explicit_delimiters_accept_any_nonempty_source() { + assert!(sources(&[r"escaped \$x$"]).is_empty()); + assert_eq!(sources(&["price $5$ and $12.50$"]).len(), 2); + assert_eq!(sources(&["env $LONG_VARIABLE$"]), vec![("LONG_VARIABLE".into(), false)]); + assert_eq!(sources(&["quote $USD 20$ today"]), vec![("USD 20".into(), false)]); + assert_eq!(sources(&["literal $hello$ text"]), vec![("hello".into(), false)]); + assert_eq!(sources(&["path $foo/bar$"]), vec![("foo/bar".into(), false)]); + assert_eq!(sources(&["config $PATH=/tmp$"]), vec![("PATH=/tmp".into(), false)]); + assert_eq!(sources(&[r"plain \(normal prose\)"]), vec![("normal prose".into(), false)]); + assert_eq!(sources(&["$$hello world$$"]), vec![("hello world".into(), true)]); + assert!(sources(&["$ $", r"\( \)", "$$ $$"]).is_empty()); } #[test] fn single_dollar_accepts_only_explicit_math_shapes() { - assert_eq!(sources(&["$x$ $x_1$ $2+2$ $a/b$ $x=y$ $f(x)$ $f(x)=0$"], true).len(), 7); - assert_eq!(sources(&[r"$\frac{1}{2}$ $\sin x$ $x^2$"], true).len(), 3); + assert_eq!(sources(&["$x$ $x_1$ $2+2$ $a/b$ $x=y$ $f(x)$ $f(x)=0$"]).len(), 7); + assert_eq!(sources(&[r"$\frac{1}{2}$ $\sin x$ $x^2$"]).len(), 3); } #[test] - fn single_dollar_requires_context_only_for_simple_math() { - assert!(sources(&["plain $x$ text"], false).is_empty()); - assert_eq!(sources(&["plain $x$ text"], true), vec![("x".into(), false)]); + fn single_dollar_uses_the_standard_formula_rule_without_agent_context() { + assert_eq!(sources(&["plain $x$ text"]), vec![("x".into(), false)]); assert_eq!( - sources( - &[ - r"二次方程:$x = \dfrac{-b \pm \sqrt{b^2 - 4ac}}{2a}$", - r"斯特林公式:$n! \approx \sqrt{2\pi n}\left(\dfrac{n}{e}\right)^n$", - ], - false, - ), + sources(&["质能方程 $E = mc^2$,其中 c 为光速。"]), + vec![("E = mc^2".into(), false)] + ); + assert_eq!( + sources(&[ + r"二次方程:$x = \dfrac{-b \pm \sqrt{b^2 - 4ac}}{2a}$", + r"斯特林公式:$n! \approx \sqrt{2\pi n}\left(\dfrac{n}{e}\right)^n$", + ],), vec![ (r"x = \dfrac{-b \pm \sqrt{b^2 - 4ac}}{2a}".into(), false), (r"n! \approx \sqrt{2\pi n}\left(\dfrac{n}{e}\right)^n".into(), false), @@ -3047,28 +2786,6 @@ d & -b \\ ); } - #[test] - fn ai_context_survives_command_completion_and_cache_clone_resets_cleanly() { - let mut state = TerminalMathState::default(); - state.observe_program(Some("codex")); - state.observe_program(None); - assert!(state.inline_dollar_enabled()); - assert!(state.clone().inline_dollar_enabled()); - } - - #[test] - fn confirmed_display_formula_unlocks_inline_dollar_without_process_detection() { - // WSL/SSH 里跑的 AI CLI 在本机进程树上只留下 wsl.exe/ssh.exe, - // observe_program 永远不会命中;已确认的块级公式承担同一角色。 - let mut state = TerminalMathState::default(); - remember_visible(&mut state, &grid_at(&["中文 \\(x^2+y^2=z^2\\) 行内", "tail "], 40, 0)); - assert!(!state.inline_dollar_enabled(), "inline-only content must not unlock"); - - remember_visible(&mut state, &grid_at(&["$$ ", "x^2 ", "$$ ", "tail "], 44, 0)); - assert!(state.inline_dollar_enabled()); - assert!(state.clone().inline_dollar_enabled()); - } - #[test] fn persisted_formula_survives_partial_scroll_without_rescaling_its_bounds() { let mut state = TerminalMathState::default(); @@ -3078,7 +2795,7 @@ d & -b \\ let scrolled = grid_at(&["x^2 ", "$$ ", "tail ", "next "], 41, 0); state.synchronize_grid(&scrolled); - assert!(scan_grid(&scrolled, true).is_empty()); + assert!(scan_grid(&scrolled).is_empty()); let overlays = state.visible_overlays(&scrolled); assert_eq!(overlays.len(), 1); @@ -3101,7 +2818,7 @@ d & -b \\ 0, ); state.synchronize_grid(&opening); - assert!(state.scan_visible_grid(&opening, false, None).is_none()); + assert!(state.scan_visible_grid(&opening, None).is_none()); let visible_tail = grid_at( &[ @@ -3115,7 +2832,7 @@ d & -b \\ ); state.synchronize_grid(&visible_tail); let history_anchor = state - .scan_visible_grid(&visible_tail, false, None) + .scan_visible_grid(&visible_tail, None) .expect("closing delimiter requests the pending opening from history"); assert_eq!(history_anchor, FormulaAnchor { row: 40, column: 0 }); @@ -3133,7 +2850,7 @@ d & -b \\ 40, 0, ); - assert!(state.complete_pending_from_history(&history, false)); + assert!(state.complete_pending_from_history(&history)); let overlays = state.visible_overlays(&visible_tail); assert_eq!(overlays.len(), 1); @@ -3181,15 +2898,15 @@ d & -b \\ // visible (e.g. the persisted copy was evicted by a redraw glitch). let visible = grid_at(&["$$ ", "tail "], 45, 0); state.synchronize_grid(&visible); - assert!(state.scan_visible_grid(&visible, false, None).is_none()); + assert!(state.scan_visible_grid(&visible, None).is_none()); let anchor = state - .scan_visible_grid(&visible, false, None) + .scan_visible_grid(&visible, None) .expect("stable orphan delimiter requests one history pass"); assert_eq!(anchor, FormulaAnchor { row: 45, column: 0 }); let history = grid_at(&["$$ ", "x^2 ", "$$ ", "tail "], 43, 0); - assert!(state.complete_pending_from_history(&history, false)); + assert!(state.complete_pending_from_history(&history)); let overlays = state.visible_overlays(&visible); assert_eq!(overlays.len(), 1); @@ -3198,8 +2915,8 @@ d & -b \\ // The attempt is consumed: the same orphan never re-triggers, even // though the scanner keeps reporting it as unmatched every frame. - assert!(state.scan_visible_grid(&visible, false, None).is_none()); - assert!(state.scan_visible_grid(&visible, false, None).is_none()); + assert!(state.scan_visible_grid(&visible, None).is_none()); + assert!(state.scan_visible_grid(&visible, None).is_none()); } #[test] @@ -3234,9 +2951,9 @@ d & -b \\ /// Nominal terminal font size behind [`test_size`]. const TEST_FONT_PX: f32 = 20.0; - fn fitted_size(rows: &[&str], allow_inline: bool) -> f32 { + fn fitted_size(rows: &[&str]) -> f32 { let grid = TextGrid::from_rows(rows); - let overlays = scan_grid_with_hints(&grid, allow_inline); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1, "expected exactly one formula in {rows:?}"); let size = test_size(grid.columns as f32, grid.rows.len() as f32); let mut state = TerminalMathState::default(); @@ -3246,16 +2963,13 @@ d & -b \\ #[test] fn display_formula_with_blank_neighbours_keeps_the_terminal_font_size() { - let fitted = fitted_size( - &[ - " ", - "$$ ", - r"\sum_{i=1}^{n} i^2 ", - "$$ ", - " ", - ], - false, - ); + let fitted = fitted_size(&[ + " ", + "$$ ", + r"\sum_{i=1}^{n} i^2 ", + "$$ ", + " ", + ]); assert_eq!(fitted, TEST_FONT_PX, "blank-neighbour display math must not shrink"); } @@ -3263,24 +2977,18 @@ d & -b \\ /// whether the emitter put it on one line or spread it over three. #[test] fn display_math_size_does_not_depend_on_how_many_rows_the_source_used() { - let one_line = fitted_size( - &[ - " ", - r"$$\sum_{i=1}^{n} i^2$$ ", - " ", - ], - false, - ); - let three_lines = fitted_size( - &[ - " ", - "$$ ", - r"\sum_{i=1}^{n} i^2 ", - "$$ ", - " ", - ], - false, - ); + let one_line = fitted_size(&[ + " ", + r"$$\sum_{i=1}^{n} i^2$$ ", + " ", + ]); + let three_lines = fitted_size(&[ + " ", + "$$ ", + r"\sum_{i=1}^{n} i^2 ", + "$$ ", + " ", + ]); assert_eq!(one_line, three_lines); assert_eq!(one_line, TEST_FONT_PX); } @@ -3298,7 +3006,7 @@ d & -b \\ "prose below that runs under the formula", ]; let grid = TextGrid::from_rows(rows); - let overlays = scan_grid_with_hints(&grid, false); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1); let size = test_size(grid.columns as f32, 8.0); @@ -3351,14 +3059,14 @@ d & -b \\ #[test] fn display_math_uses_a_smaller_prose_bleed_than_inline_math() { let display_grid = TextGrid::from_rows(&["above", "$$x^2$$", "below"]); - let display_overlays = scan_grid_with_hints(&display_grid, false); + let display_overlays = scan_grid_with_hints(&display_grid); let display_size = test_size(display_grid.columns as f32, display_grid.rows.len() as f32); let display_overlay = &display_overlays[0]; let display_bounds = display_overlay.bounds(&display_size).expect("display bounds"); let display_bleed = display_overlay.vertical_bleed(&display_size, (0, 0)); let inline_grid = TextGrid::from_rows(&["value $x^2$ grows"]); - let inline_overlays = scan_grid_with_hints(&inline_grid, true); + let inline_overlays = scan_grid_with_hints(&inline_grid); let inline_size = test_size(inline_grid.columns as f32, inline_grid.rows.len() as f32); let inline_overlay = &inline_overlays[0]; let inline_bleed = inline_overlay.vertical_bleed(&inline_size, (0, 0)); @@ -3381,7 +3089,7 @@ d & -b \\ "下面继续说明。 ", ]; let grid = TextGrid::from_rows(rows); - let overlays = scan_grid_with_hints(&grid, false); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1); let mut state = TerminalMathState::default(); @@ -3394,7 +3102,7 @@ d & -b \\ #[test] fn inline_formula_between_prose_keeps_the_terminal_font_size() { - let fitted = fitted_size(&["value $x^2$ grows "], true); + let fitted = fitted_size(&["value $x^2$ grows "]); assert_eq!( fitted, TEST_FONT_PX, "a superscript must fit the line-gap budget without shrinking", @@ -3409,7 +3117,7 @@ d & -b \\ r"right $\frac{\sum_{i=1}^n x_i}{\sqrt{1+x_{j-1}^2}}+y_{m+1}$", ]; let grid = TextGrid::from_rows(&rows); - let overlays = scan_grid_with_hints(&grid, true); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 3); let size = test_size(grid.columns as f32, grid.rows.len() as f32); let mut state = TerminalMathState::default(); @@ -3478,7 +3186,7 @@ d & -b \\ r"行内的求和 $\sum_{i=1}^{n} i$ 和上标 $x^2$ 也在同一段里。 ", ]; let grid = TextGrid::from_rows(rows); - let overlays = scan_grid_with_hints(&grid, true); + let overlays = scan_grid_with_hints(&grid); let size = test_size(grid.columns as f32, grid.rows.len() as f32); let mut state = TerminalMathState::default(); let prepared = prepare_overlays(&mut state, &overlays, &size, TEST_FONT_PX, 1.0); @@ -3521,7 +3229,7 @@ d & -b \\ for source in sources { let row = format!("值 {source} 收敛 "); let grid = TextGrid::from_rows(&[&row]); - let overlays = scan_grid_with_hints(&grid, true); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1, "{source}"); let mut state = TerminalMathState::default(); @@ -3541,25 +3249,13 @@ d & -b \\ } } - #[test] - fn display_math_centres_before_a_tui_right_pane() { - let grid = TextGrid::from_rows(&[ - " explanation in the main pane $0.00 spent ", - " $$E = mc^2$$ ", - ]); - let overlays = scan_grid_with_hints(&grid, true); - - assert_eq!(overlays.len(), 1); - assert_eq!(overlays[0].widen_right_to, Some(42)); - } - #[test] fn display_math_mask_only_covers_the_formula_source() { let grid = TextGrid::from_rows(&[ " explanation in the main pane $0.00 spent ", " $$E = mc^2$$ ", ]); - let overlays = scan_grid_with_hints(&grid, true); + let overlays = scan_grid_with_hints(&grid); let coverage = CoverageMask::build(&overlays, &[Some(())]); assert!(coverage.covers(Point::new(1, Column(5))), "formula source is hidden"); @@ -3580,7 +3276,7 @@ d & -b \\ " ordinary prose across one terminal row ", " $$E = mc^2$$ ", ]); - let overlays = scan_grid_with_hints(&grid, true); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1); assert_eq!(overlays[0].widen_right_to, Some(grid.columns)); @@ -3674,11 +3370,12 @@ d & -b \\ "\n{:<22} {:>6} {:>6} {:>16} {:>16}", "场景", "比例", "样式", "墨迹 宽×高", "预算 宽×高" ); - for (name, rows, inline) in cases { + for (name, rows, expected_inline) in cases { let grid = TextGrid::from_rows(&rows); - let overlays = scan_grid_with_hints(&grid, inline); + let overlays = scan_grid_with_hints(&grid); assert_eq!(overlays.len(), 1, "{name}: expected one formula"); let overlay = &overlays[0]; + assert_eq!(!overlay.display, expected_inline, "{name}: unexpected formula style"); let size = test_size(grid.columns as f32, grid.rows.len() as f32); let mut state = TerminalMathState::default(); diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index afa4e01..49e2b4f 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -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(); diff --git a/nebula_app/src/gpui_shell/terminal/math_overlay.rs b/nebula_app/src/gpui_shell/terminal/math_overlay.rs index b34863e..77186c8 100644 --- a/nebula_app/src/gpui_shell/terminal/math_overlay.rs +++ b/nebula_app/src/gpui_shell/terminal/math_overlay.rs @@ -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" diff --git a/nebula_app/src/math/compile.rs b/nebula_app/src/math/compile.rs index 2a5295c..a375a25 100644 --- a/nebula_app/src/math/compile.rs +++ b/nebula_app/src/math/compile.rs @@ -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 { + 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 { + 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 { 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 都持名义字号。