From ab22119e292fc582d65a4de66765e4dcd54e3e24 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 13:27:46 +0800 Subject: [PATCH] fix(terminal): align link decoration with grid cells and shell paths --- nebula_app/src/config/ui_config.rs | 3 + nebula_app/src/display/hint.rs | 132 +++++++++++++++++- nebula_app/src/gpui_shell/terminal/element.rs | 120 +++++++++------- .../terminal/element/color_tests.rs | 99 +++++++++++++ .../src/gpui_shell/terminal/link_underline.rs | 132 ++++++++++++++++++ nebula_app/src/gpui_shell/terminal/mod.rs | 1 + .../src/gpui_shell/terminal/osc_links.rs | 80 ++++++++++- .../src/gpui_shell/terminal/view/pointer.rs | 9 +- scripts/tests/user-feedback-regressions.md | 59 ++++++++ 9 files changed, 577 insertions(+), 58 deletions(-) create mode 100644 nebula_app/src/gpui_shell/terminal/element/color_tests.rs create mode 100644 nebula_app/src/gpui_shell/terminal/link_underline.rs create mode 100644 scripts/tests/user-feedback-regressions.md diff --git a/nebula_app/src/config/ui_config.rs b/nebula_app/src/config/ui_config.rs index c2c3578..0c06cd5 100644 --- a/nebula_app/src/config/ui_config.rs +++ b/nebula_app/src/config/ui_config.rs @@ -37,6 +37,9 @@ use crate::config::window::WindowConfig; /// Regex used for default terminal hints (Markdown links, URLs, and local paths). #[rustfmt::skip] pub const DEFAULT_HINT_REGEX: &str = "(?:\ + (?m:^[a-z0-9_.-]+@[a-z0-9_.-]+:/[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s`]*[$#])|\ + \"(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}\"\\r\\n]+\"|\ + '(?:[a-z]:[/\\\\]|\\\\\\\\|~[/\\\\])[^\u{0000}-\u{001F}\u{007F}-\u{009F}'\\r\\n]+'|\ \\[[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n\\]]+\\]\\((?:[^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]|\\([^\u{0000}-\u{001F}\u{007F}-\u{009F}\r\n()]*\\))+\\)|\ (ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`\\\\]+|\ (?:(?-u:\\b)[a-z]:[/\\\\]|\\\\\\\\)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`*?:,。;:!?、]+|\ diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index 7e3b6ae..719c4e5 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -585,9 +585,45 @@ impl<'a, T> HintPostProcessor<'a, T> { let mut iter = self.term.grid().iter_from(*regex_match.start()); let mut c = iter.cell().c; + let mut start = *regex_match.start(); + let end = *regex_match.end(); + if start == self.term.line_search_left(start) && c.is_ascii_alphanumeric() { + let mut prefix = self.term.grid().iter_from(start); + let mut at = false; + loop { + let ch = prefix.cell().c; + if ch == ':' && at { + if let Some(next) = prefix.next() + && next.c == '/' + { + start = next.point; + iter = self.term.grid().iter_from(start); + c = iter.cell().c; + } + break; + } + if !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '@')) + || prefix.point() >= end + { + break; + } + at |= ch == '@'; + if prefix.next().is_none() { + break; + } + } + } + // Quoted local paths have an explicit boundary and may contain spaces, + // punctuation and parentheses. Keep the target, not its shell quotes. + if matches!(c, '\'' | '"') && start < end && self.term.grid()[end].c == c { + let inner_start = start.add(self.term, Boundary::Grid, 1); + let inner_end = end.sub(self.term, Boundary::Grid, 1); + if inner_start <= inner_end { + return Some(inner_start..=inner_end); + } + } // Truncate uneven number of brackets. - let end = *regex_match.end(); let mut open_parents = 0; let mut open_brackets = 0; loop { @@ -624,7 +660,14 @@ impl<'a, T> HintPostProcessor<'a, T> { } // Truncate trailing characters which are likely to be delimiters. - let start = *regex_match.start(); + // A default Bash/WSL prompt has no space between its cwd and $/#. + // Only trim that suffix in a user@host:~/... prompt; '$' is otherwise + // legal in paths and URLs, and OSC 8 targets bypass these heuristics. + if matches!(c, '$' | '#') && self.is_shell_prompt_path(start, iter.point()) { + if let Some(indexed) = iter.prev() { + c = indexed.cell.c; + } + } while iter.point() != start { if !matches!( c, @@ -660,6 +703,32 @@ impl<'a, T> HintPostProcessor<'a, T> { if start > iter.point() { None } else { Some(start..=iter.point()) } } + fn is_shell_prompt_path(&self, start: Point, end: Point) -> bool { + if !matches!(self.term.grid()[start].c, '~' | '/') { + return false; + } + let line_start = self.term.line_search_left(start); + if start == line_start { + return false; + } + let mut after = self.term.grid().iter_from(end); + if after.next().is_some_and(|cell| !cell.c.is_whitespace()) { + return false; + } + let before = start.sub(self.term, Boundary::Grid, 1); + let prefix = self.term.bounds_to_string(line_start, before); + let Some(prefix) = prefix.split_whitespace().next_back() else { return false }; + let Some((user, host)) = prefix.strip_suffix(':').and_then(|s| s.rsplit_once('@')) else { + return false; + }; + !user.is_empty() + && !host.is_empty() + && user + .chars() + .chain(host.chars()) + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.')) + } + /// Loop over submatches until a non-empty post-processed match is found. fn next_processed_match(&mut self, mut regex_match: Match) { self.next_match = loop { @@ -705,6 +774,65 @@ mod tests { use super::*; + #[test] + fn shell_prompt_symbols_are_not_part_of_clickable_home_paths() { + for (text, expected) in [ + ("hello@userdk:/mnt/d/temp_build/nebula$ ", "/mnt/d/temp_build/nebula"), + ("root@host:/root# ", "/root"), + ("user@host:/# ", "/"), + ("hello@userdk:~/.codex$ ", "~/.codex"), + ("root@host:~/work# ", "~/work"), + ("hello@userdk:~/工作文档$ echo test", "~/工作文档"), + ("~/cost$ ", "~/cost$"), + ("~/repo# ", "~/repo#"), + ("https://example.com/cost$ ", "https://example.com/cost$"), + ("user@host:~/price$usd ", "~/price$usd"), + ] { + let term = mock_term(text); + let config = UiConfig::default(); + let matches = visible_clickable_matches(&term, &config); + assert_eq!(matches.len(), 1, "{text}: {matches:?}"); + assert_eq!(term.bounds_to_string(*matches[0].start(), *matches[0].end()), expected); + let end = *matches[0].end(); + let hit = highlighted_at(&term, &config, end, ModifiersState::CONTROL).unwrap(); + assert_eq!(hit.text(&term).unwrap().as_ref(), expected); + assert!( + highlighted_at( + &term, + &config, + end.add(&term, Boundary::Grid, 1), + ModifiersState::CONTROL + ) + .is_none() + ); + } + } + + #[test] + fn quoted_paths_keep_spaces_and_literal_punctuation_in_hover_and_click_targets() { + for (text, expected) in [ + ( + r#"& "C:\Program Files\Example Tools\probe.exe" pr view"#, + r"C:\Program Files\Example Tools\probe.exe", + ), + ("open 'D:/My Documents/开始 菜单 (1).md' now", "D:/My Documents/开始 菜单 (1).md"), + ("cat '~/cost$'", "~/cost$"), + (r#"open "\\server\my share\report.txt" now"#, r"\\server\my share\report.txt"), + ] { + let term = mock_term(text); + let config = UiConfig::default(); + let matches = visible_clickable_matches(&term, &config); + assert_eq!(matches.len(), 1, "{text}: {matches:?}"); + let start = *matches[0].start(); + let end = *matches[0].end(); + assert_eq!(term.bounds_to_string(start, end), expected); + for point in [start, end] { + let hit = highlighted_at(&term, &config, point, ModifiersState::CONTROL).unwrap(); + assert_eq!(hit.text(&term).unwrap().as_ref(), expected); + } + } + } + #[test] fn hint_label_generation() { let mut generator = HintLabels::new("0123", 0.5); diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 416c684..5a6e07a 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -9,8 +9,6 @@ //! 共同塑形,再由 `ligatures` 把字形簇映射回固定列号。其余字符逐格 //! 绘制,组合字符跟随基字;编辑、光标和选区不能改变无关格的原点。 -use std::collections::HashSet; - use gpui::{ App, Bounds, ContentMask, Corners, CursorStyle, DispatchPhase, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, LayoutId, MouseButton, @@ -28,6 +26,10 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; use super::colors::Palette; use super::view::TerminalView; +#[cfg(test)] +#[path = "element/color_tests.rs"] +mod color_tests; + pub struct TerminalElement { view: gpui::Entity, } @@ -84,7 +86,8 @@ impl TerminalElement { rows: usize, cols: usize, cx: &App, - ) -> Option<(RenderSnapshot, Option, usize, HashSet<(u16, u16)>, usize, i64)> { + ) -> Option<(RenderSnapshot, Option, usize, super::osc_links::LinkCells, usize, i64)> + { let view = self.view.read(cx); let session = view.session.as_ref()?; let hint_config = view.hint_config.clone(); @@ -139,12 +142,23 @@ impl TerminalElement { fn resolve_app_colors( &self, snap: &mut RenderSnapshot, + links: &mut super::osc_links::LinkCells, theme: &Palette, overrides: &Colors, cx: &mut App, ) { self.view.update(cx, |view, _| { resolve_app_colors_into(snap, theme, overrides, &mut view.color_resolver); + for cell in links.values_mut() { + cell.fg = resolve_text_foreground( + cell.fg, + cell.bg, + cell.bold, + theme, + overrides, + &mut view.color_resolver, + ); + } }); } } @@ -281,7 +295,7 @@ impl Element for TerminalElement { let focused = focus_handle.is_focused(window); // 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。 let cursor_visible = self.view.read(cx).cursor_visible(); - let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) = + let Some((mut snap, prompt_line, history, mut dashed, scrollback_floor, viewport_top_abs)) = self.snapshot(layout.rows, layout.cols, cx) else { return; @@ -295,7 +309,7 @@ impl Element for TerminalElement { view.drive_pending_remote_dir(cx); }); let overrides = snap.color_overrides; - self.resolve_app_colors(&mut snap, &theme, &overrides, cx); + self.resolve_app_colors(&mut snap, &mut dashed, &theme, &overrides, cx); let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx); let host_cursor_follows_theme = is_default_host_cursor(&theme); let app_cursor = snap.cursor.as_ref().filter(|cursor| { @@ -606,7 +620,7 @@ impl Element for TerminalElement { } else { theme.resolve(cell.fg, &overrides, cell.bold).into() }; - let dashed_link = dashed.contains(&(seg.row, cell.col)); + let dashed_link = dashed.contains_key(&(seg.row, cell.col)); let underline = (cell.underline && !dashed_link).then(|| UnderlineStyle { thickness: px(1.0), color: Some(fg), @@ -639,7 +653,7 @@ impl Element for TerminalElement { == cursor_inverts(seg.row, cell.col) && selected_foreground(seg.row, next.col) == selected_foreground(seg.row, cell.col) - && !dashed.contains(&(seg.row, next.col)) + && !dashed.contains_key(&(seg.row, next.col)) }) } else { 1 @@ -674,18 +688,35 @@ impl Element for TerminalElement { layout.line_height, ); } - if dashed_link { - paint_dashed_underline( - window, - origin, - layout.cell_width, - layout.line_height, - fg, - ); - } } } + // Link decoration follows grid columns, including wide-character spacer + // cells and spaces omitted by text shaping. Every cell shares one dash + // phase, so font fallback and ASCII/CJK boundaries cannot restart it. + for (&(row, col), cell) in &dashed { + if math_frame.covers(row as usize, col as usize) { + continue; + } + let Some(visual_col) = math_frame.project_cell(row as usize, col as usize, layout.cols) + else { + continue; + }; + let color = if cursor_inverts(row, col) { + theme.cursor_text.unwrap_or(theme.background) + } else if let Some(foreground) = selected_foreground(row, col) { + foreground + } else { + theme.resolve(cell.fg, &overrides, cell.bold) + }; + super::link_underline::paint( + window, + cell_rect(row as usize, visual_col, 1), + bounds.origin.x, + color.into(), + ); + } + // 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前, // 与旧壳 draw_rects → draw_overlays 的次序一致。 if !math_frame.is_empty() { @@ -945,27 +976,6 @@ impl Element for TerminalElement { } } -fn paint_dashed_underline( - window: &mut Window, - origin: gpui::Point, - cell_width: Pixels, - line_height: Pixels, - color: Hsla, -) { - let y = origin.y + line_height - px(1.0); - let end: f32 = origin.x.as_f32() + cell_width.as_f32(); - let mut x: f32 = origin.x.as_f32(); - let dash: f32 = 3.0; - let gap: f32 = 2.0; - while x < end { - let width = f32::min(dash, end - x); - if width > 0.0 { - window.paint_quad(fill(Bounds::new(point(px(x), y), size(px(width), px(1.0))), color)); - } - x += dash + gap; - } -} - /// Align geometry-only terminal primitives to the device pixel grid. Cell /// widths/heights are already measured in device pixels and converted back to /// logical units, but a pane origin can still be fractional after a split or @@ -1642,9 +1652,6 @@ fn resolve_app_colors_into( use crate::display::content::is_terminal_graphic; use crate::display::terminal_color::is_fixed_color; - let theme_fg = rgb_from_rgba(theme.foreground); - let theme_bg = rgb_from_rgba(theme.background); - for run in &mut snap.bg_runs { let base = rgb_from_rgba(theme.resolve(run.color, overrides, false)); let resolved = resolver.resolve_background(base, is_fixed_color(run.color, overrides)); @@ -1662,18 +1669,33 @@ fn resolve_app_colors_into( // 对比度是一对颜色的属性:这个前景可不可读,取决于它**这一格**底下是 // 什么,而不是主题底色。默认底色的格子没有 bg run,所以 `SnapCell::bg` // 单独带着这个值。 - let bg_base = rgb_from_rgba(theme.resolve(cell.bg, overrides, false)); - let bg = resolver.resolve_background(bg_base, is_fixed_color(cell.bg, overrides)); - // bold 提亮(0-7 → 8-15)必须发生在矫正**之前**,否则写回的 `Spec` 会把 - // 提亮吃掉。 - let base = rgb_from_rgba(theme.resolve(cell.fg, overrides, cell.bold)); - let resolved = resolver.resolve_foreground(base, bg, true, theme_fg, theme_bg); - if resolved != base { - cell.fg = Color::Spec(resolved.0); - } + cell.fg = resolve_text_foreground(cell.fg, cell.bg, cell.bold, theme, overrides, resolver); } } +fn resolve_text_foreground( + fg: Color, + bg: Color, + bold: bool, + theme: &Palette, + overrides: &Colors, + resolver: &mut crate::display::terminal_color::TerminalColorResolver, +) -> Color { + use crate::display::terminal_color::is_fixed_color; + let bg_base = rgb_from_rgba(theme.resolve(bg, overrides, false)); + let bg = resolver.resolve_background(bg_base, is_fixed_color(bg, overrides)); + // Resolve bold before contrast adjustment; Spec must retain that brightening. + let base = rgb_from_rgba(theme.resolve(fg, overrides, bold)); + let resolved = resolver.resolve_foreground( + base, + bg, + true, + rgb_from_rgba(theme.foreground), + rgb_from_rgba(theme.background), + ); + if resolved != base { Color::Spec(resolved.0) } else { fg } +} + pub(super) fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba { Rgba { r: f32::from(color.r) / 255.0, diff --git a/nebula_app/src/gpui_shell/terminal/element/color_tests.rs b/nebula_app/src/gpui_shell/terminal/element/color_tests.rs new file mode 100644 index 0000000..1278277 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/element/color_tests.rs @@ -0,0 +1,99 @@ +//! Replay the terminal features required by Codex message surfaces and stars. +//! Fixture colors follow codex rust-v0.154.0 tui/src/style.rs and +//! bottom_pane/chat_composer/sparkle.rs; this does not emulate a live Codex session. + +use nebula_terminal::event::VoidListener; +use nebula_terminal::render::{RenderSnapshot, SnapshotConfig}; +use nebula_terminal::term::color::Colors; +use nebula_terminal::term::test::TermSize; +use nebula_terminal::term::{Config, Term}; +use nebula_terminal::vte::ansi::{self, Color, NamedColor, Rgb}; + +use super::{Palette, resolve_app_colors_into, rgb_from_rgba}; +use crate::display::terminal_color::TerminalColorResolver; +use crate::gpui_shell::terminal::colors::from_ansi_rgb; +use crate::gpui_shell::theme::ResolvedTheme; + +fn palette(name: nebula_settings::ThemeName) -> Palette { + let resolved = ResolvedTheme::builtin(name, None); + let color = |[r, g, b]: [u8; 3]| from_ansi_rgb(Rgb { r, g, b }); + Palette { + background: color(resolved.terminal_background()), + foreground: color(resolved.terminal_foreground()), + ..Palette::default() + } +} + +fn capture(palette: &Palette) -> (RenderSnapshot, Rgb) { + let bg = palette.query_reply(NamedColor::Background as usize, &Colors::default()); + let fg = palette.query_reply(NamedColor::Foreground as usize, &Colors::default()); + let (top, alpha) = if palette.is_dark() { (255.0, 0.12) } else { (0.0, 0.04) }; + let blend = |channel: u8| (channel as f32 * (1.0 - alpha) + top * alpha).round() as u8; + let user = Rgb { r: blend(bg.r), g: blend(bg.g), b: blend(bg.b) }; + let bytes = format!( + "\x1b[48;2;{};{};{}m\x1b[38;2;{};{};{}m用户 message\x1b[K\r\n⠁⠂⠄⠈⠐⠠⡀⢀\x1b[K\r\n\x1b[0mAI reply", + user.r, user.g, user.b, fg.r, fg.g, fg.b, + ); + let mut term = Term::new(Config::default(), &TermSize::new(40, 4), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance(&mut term, bytes.as_bytes()); + (RenderSnapshot::capture(&term, &SnapshotConfig { rows: 4, cols: 40 }), user) +} + +#[test] +fn every_builtin_theme_keeps_message_surfaces_and_braille_stars() { + for name in nebula_settings::ThemeName::BUILTIN { + let palette = palette(name); + let (mut snapshot, user) = capture(&palette); + assert_ne!(from_ansi_rgb(user), palette.background, "{name:?}"); + let star_colors: Vec<_> = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.fg)) + .collect(); + let mut resolver = TerminalColorResolver::default(); + resolve_app_colors_into(&mut snapshot, &palette, &Colors::default(), &mut resolver); + for row in [0, 1] { + let run = snapshot.bg_runs.iter().find(|run| run.row == row).unwrap(); + assert_eq!((run.start, run.end, run.color), (0, 40, Color::Spec(user)), "{name:?}"); + } + assert!( + snapshot.bg_runs.iter().all(|run| run.row != 2), + "{name:?}: AI reply acquired a message background" + ); + let stars: String = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.text.as_str())) + .collect(); + assert_eq!(stars, "⠁⠂⠄⠈⠐⠠⡀⢀"); + let after: Vec<_> = snapshot + .segments + .iter() + .filter(|seg| seg.row == 1) + .flat_map(|seg| seg.cells.iter().map(|cell| cell.fg)) + .collect(); + assert_eq!(star_colors, after, "{name:?}: star colors were changed"); + } +} + +#[test] +fn message_background_remains_distinct_after_every_builtin_theme_switch() { + for from in nebula_settings::ThemeName::BUILTIN { + for to in nebula_settings::ThemeName::BUILTIN { + let original = palette(from); + let next = palette(to); + let (mut snapshot, _) = capture(&original); + let mut resolver = TerminalColorResolver::default(); + resolver + .theme_changed(rgb_from_rgba(original.background), rgb_from_rgba(next.background)); + resolve_app_colors_into(&mut snapshot, &next, &Colors::default(), &mut resolver); + let user = snapshot.bg_runs.iter().find(|run| run.row == 0).unwrap(); + let background = next.resolve(user.color, &Colors::default(), false); + assert_ne!(background, next.background, "{from:?} -> {to:?}"); + assert!(snapshot.bg_runs.iter().all(|run| run.row != 2)); + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/link_underline.rs b/nebula_app/src/gpui_shell/terminal/link_underline.rs new file mode 100644 index 0000000..6cecc43 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/link_underline.rs @@ -0,0 +1,132 @@ +//! Link dashes in physical grid coordinates, independent of glyph width/shaping. + +use gpui::{Bounds, Hsla, Pixels, Window, fill, point, px, size}; + +pub(super) fn paint(window: &mut Window, cell: Bounds, grid_left: Pixels, color: Hsla) { + let scale = window.scale_factor().max(0.5); + let y = (cell.bottom().as_f32() * scale).round(); + let thickness = scale.round().max(1.0); + for (left, right) in + dash_segments(cell.left().as_f32(), cell.right().as_f32(), grid_left.as_f32(), scale) + { + window.paint_quad(fill( + Bounds::new( + point(px(left / scale), px((y - thickness) / scale)), + size(px((right - left) / scale), px(thickness / scale)), + ), + color, + )); + } +} + +fn dash_segments( + left: f32, + right: f32, + anchor: f32, + scale: f32, +) -> impl Iterator { + let left = (left * scale).round(); + let right = (right * scale).round(); + let anchor = (anchor * scale).round(); + let dash = (3.0 * scale).round().max(1.0); + let period = dash + (2.0 * scale).round().max(1.0); + let mut x = anchor + ((left - anchor) / period).floor() * period; + std::iter::from_fn(move || { + while x < right { + let start = x.max(left); + let end = (x + dash).min(right); + x += period; + if start < end { + return Some((start, end)); + } + } + None + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::config::UiConfig; + use crate::gpui_shell::terminal::osc_links::dashed_cells; + use nebula_terminal::event::VoidListener; + use nebula_terminal::term::test::TermSize; + use nebula_terminal::term::{Config, Term}; + use nebula_terminal::vte::ansi; + + #[test] + #[ignore = "informational hot-path measurement; no machine-specific timing gate"] + fn measure_visible_link_decoration_work() { + for (name, linked, text) in + [("plain", false, "x"), ("ascii", true, "x"), ("cjk", true, "中")] + { + let mut term = Term::new(Config::default(), &TermSize::new(120, 40), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + let text = text.repeat(if name == "cjk" { 50 } else { 100 }); + let row = if linked { + format!("\x1b]8;;file:///fixture\x1b\\{text}\x1b]8;;\x1b\\\r\n") + } else { + format!("{text}\r\n") + }; + parser.advance(&mut term, row.repeat(35).as_bytes()); + let config = UiConfig::default(); + let started = std::time::Instant::now(); + let mut last_cells = 0; + for _ in 0..200 { + let cells = dashed_cells(&term, &config, 40, 120); + last_cells = cells.len(); + let segments: usize = cells + .keys() + .map(|&(_, col)| { + dash_segments( + 2.3 + f32::from(col) * 8.4, + 2.3 + f32::from(col + 1) * 8.4, + 2.3, + 1.25, + ) + .count() + }) + .sum(); + std::hint::black_box((cells, segments)); + } + use std::io::Write as _; + let _ = writeln!( + std::io::stderr().lock(), + "{name}: 120x40 grid, {last_cells} linked columns, {:.1} us/pass (capture + dash geometry, no GPU)", + started.elapsed().as_secs_f64() * 1_000_000.0 / 200.0 + ); + } + } + + #[test] + fn real_osc8_grid_covers_cjk_spacers_and_spaces_with_one_dash_phase() { + let mut term = Term::new(Config::default(), &TermSize::new(40, 2), VoidListener); + let mut parser: ansi::Processor = ansi::Processor::new(); + parser.advance( + &mut term, + "\x1b]8;;file:///tmp/example\x1b\\A开始 菜单Z\x1b]8;;\x1b\\".as_bytes(), + ); + let cells = dashed_cells(&term, &UiConfig::default(), 2, 40); + assert_eq!(cells.len(), 11); + assert!((0..11).all(|col| cells.contains_key(&(0, col)))); + for scale in [1.0, 1.25, 1.5, 2.0] { + for width in [7.0, 8.4, 9.5, 12.0] { + let anchor = 2.3; + let pixels = |left, right| { + dash_segments(left, right, anchor, scale) + .flat_map(|(left, right)| left as i32..right as i32) + }; + let actual: BTreeSet<_> = cells + .keys() + .flat_map(|&(_, col)| { + pixels(anchor + f32::from(col) * width, anchor + f32::from(col + 1) * width) + }) + .collect(); + let expected: BTreeSet<_> = pixels(anchor, anchor + 11.0 * width).collect(); + assert_eq!(actual, expected, "scale={scale}, width={width}"); + } + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index 6bf0f85..012f095 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -12,6 +12,7 @@ mod event_mailbox; mod inline_image; pub mod keymap; mod ligatures; +mod link_underline; pub mod math_overlay; mod molecule_overlay; pub mod mouse_protocol; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 94a3912..d825407 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -3,13 +3,15 @@ //! 匹配与动作全部复用旧壳 `display::hint` + `file_uri` + `daemon`,这里只做 //! 视口坐标、GPUI 修饰键和打开入口。 -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::Arc; use gpui::{App, ClipboardItem, Window}; use nebula_terminal::event::EventListener; use nebula_terminal::index::Point; +use nebula_terminal::term::cell::Flags; use nebula_terminal::term::{Term, point_to_viewport_from}; +use nebula_terminal::vte::ansi::Color; use unicode_width::UnicodeWidthChar; use winit::keyboard::ModifiersState; @@ -48,25 +50,42 @@ pub(super) fn winit_mouse_mods(mods: &gpui::Modifiers) -> ModifiersState { } /// 可见可点范围(OSC 8 + 正则 URL)映射到当前视口格子,供虚线下划线使用。 +pub(super) struct LinkCell { + pub fg: Color, + pub bg: Color, + pub bold: bool, +} + +pub(super) type LinkCells = HashMap<(u16, u16), LinkCell>; + pub(super) fn dashed_cells( term: &Term, config: &UiConfig, rows: usize, cols: usize, -) -> HashSet<(u16, u16)> { +) -> LinkCells { let matches = hint::visible_clickable_matches(term, config); if matches.is_empty() { - return HashSet::new(); + return HashMap::new(); } let origin = term.viewport_origin_for(rows); - let mut cells = HashSet::new(); + let mut cells = HashMap::new(); for indexed in term.grid().display_iter() { - if !matches.iter().any(|bounds| bounds.contains(&indexed.point)) { + if indexed.flags.intersects(Flags::HIDDEN | Flags::LEADING_WIDE_CHAR_SPACER) + || !matches.iter().any(|bounds| bounds.contains(&indexed.point)) + { continue; } let Some(vp) = point_to_viewport_from(origin, indexed.point) else { continue }; if vp.line < rows && vp.column.0 < cols { - cells.insert((vp.line as u16, vp.column.0 as u16)); + let (mut fg, mut bg) = (indexed.fg, indexed.bg); + if indexed.flags.contains(Flags::INVERSE) { + std::mem::swap(&mut fg, &mut bg); + } + cells.insert( + (vp.line as u16, vp.column.0 as u16), + LinkCell { fg, bg, bold: indexed.flags.contains(Flags::BOLD) }, + ); } } cells @@ -110,12 +129,61 @@ pub(super) fn open_hint_match( hint: &HintMatch, text: &str, cwd: Option<&std::path::Path>, + launch: &crate::session::LaunchSession, window: &mut Window, cx: &mut App, ) { + let target = (hint.hyperlink().is_none() + && hint.action() == &HintAction::Command(default_hint_command())) + .then(|| wsl_absolute_target(text, launch)) + .flatten(); + let text = target.as_deref().unwrap_or(text); dispatch_hint_action(hint.action(), hint.hyperlink().is_some(), text, cwd, window, cx); } +fn wsl_absolute_target(text: &str, launch: &crate::session::LaunchSession) -> Option { + if !text.starts_with('/') || text.starts_with("//") { + return None; + } + let (program, args) = match launch { + crate::session::LaunchSession::Shell { program, args, .. } => (program, args), + crate::session::LaunchSession::Profile { command, args, .. } => (command, args), + _ => return None, + }; + let distro = crate::shell_detect::wsl_launch_distro(program, args)?; + Some(crate::shell_detect::wsl_unc_path(distro, text).to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod path_tests { + use super::*; + + #[test] + fn absolute_prompt_paths_use_the_owning_wsl_distribution() { + let launch = crate::session::LaunchSession::Shell { + name: "Debian".into(), + program: "wsl.exe".into(), + args: vec!["-d".into(), "Debian".into()], + }; + assert_eq!( + wsl_absolute_target("/mnt/d/project", &launch).as_deref(), + Some(r"\\wsl.localhost\Debian\mnt\d\project") + ); + assert!(wsl_absolute_target("//example.com/file", &launch).is_none()); + assert!(wsl_absolute_target("https://example.com", &launch).is_none()); + assert!( + wsl_absolute_target( + "/home/user", + &crate::session::LaunchSession::Ssh { host: "remote".into() } + ) + .is_none() + ); + assert!( + wsl_absolute_target("/home/user", &crate::session::LaunchSession::Default).is_none() + ); + } +} + fn dispatch_hint_action( action: &HintAction, hyperlink: bool, diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 25367d1..4a6bcf3 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -178,7 +178,14 @@ impl TerminalView { }; let Some(text) = text else { return }; let cwd = self.local_cwd(); - super::super::osc_links::open_hint_match(&hover.hint, &text, cwd.as_deref(), window, cx); + super::super::osc_links::open_hint_match( + &hover.hint, + &text, + cwd.as_deref(), + &self.session_launch, + window, + cx, + ); } /// 应用是否接管了鼠标(vim/htop 等)。Shift 按住时强制旁路——这是 diff --git a/scripts/tests/user-feedback-regressions.md b/scripts/tests/user-feedback-regressions.md new file mode 100644 index 0000000..fbf13ac --- /dev/null +++ b/scripts/tests/user-feedback-regressions.md @@ -0,0 +1,59 @@ +# 用户反馈回归测试清单 + +本清单覆盖 2026-09-23 汇总的 Hook、更新、窗口材质、链接、Codex 显示与 +Windows 右键菜单反馈。它是验收合同,不表示下列功能已经全部修复。 +`待验证`、`等待日志`、`部分覆盖` 均不得作为 Issue 已解决的依据。 + +## 执行与证据 + +- 每轮记录源码提交、未提交补丁、产品版本、Windows build、DPI、主题、字体、 + Shell 与 Codex 版本。记录实际命令、通过/失败数量及截图或日志位置。 +- 自动回归使用实际生产函数、真实终端网格或进程夹具;不能只检查源码中出现 + 某个字符串。原生截图不替代行为测试,编译成功也不替代视觉验收。 +- 对修复先验证旧实现失败,再验证新实现通过;保留相邻功能的反例。 +- 浅色/深色、100%/125%/150%/200% DPI、宽/窄窗口、分屏、滚动和主题热切换 + 按条目覆盖。平台条件导致无法执行的项必须注明,不计入通过数量。 +- 实机操作使用隔离配置与测试窗口。安装/注册表夹具只修改其临时测试根, + 不关闭用户会话,不覆盖正式安装,不触碰用户 Hook、凭据和发行版配置。 +- 仅关闭已有代码与验收证据覆盖的问题单;部分修复说明剩余范围。 + 发布版本尚未包含修复时必须说明交付状态,不把本地通过写成已发布。 + +## 回归项目 + +| ID | 问题与来源 | 自动回归 / 通过条件 | 实机验收 / 反例 | 当前证据 | +| --- | --- | --- | --- | --- | +| HOOK-255 | [#255](https://github.com/Kuddev/pebrel/issues/255):Windows Codex Hook 被 PowerShell 解析为字符串而未执行 | 生成命令实际执行;`codex`、`--hooks=full/turns` 和 stdin JSON 完整传入;带空格、单引号、`$`、反引号的路径保持字面值 | Windows PowerShell 5.1 与 PowerShell 7;Codex 0.155.1/0.156.0;新会话、工具调用、提问、停止都无 ParserError | Windows 5.1 进程夹具通过;完整 Codex/PowerShell 7 会话待验收 | +| HOOK-LIFETIME | #258 前置故障:关闭 Pebrel 后 Hook 仍占用安装文件 | 保持 stdin 打开、连接后不读取命名管道时,真实 Hook 子进程仍在期限内以 0 退出;Cursor 继续允许提交;用户原有 notifier 仍执行 | 已运行的旧版 Hook 不会自动获得新期限;分别验证新启动的 native Hook 与旧进程恢复步骤 | 7 项单元 + 5 项真实进程回归通过 | +| HOOK-MIGRATE | 更新后旧 `hooks.json` / 归属标记仍含失效命令 | 旧受管命令迁移;重复安装幂等;保留 notify、第三方 Hook、显式禁用及用户编辑;卸载只移除自有条目 | 重启 Pebrel 后自动修复;用户修改过的冲突条目不能静默覆盖 | `ai_hook::` 120 通过、1 项既有忽略;后续改动需重跑相关项 | +| UPDATE-403 | 手动检查更新返回 HTTP 403 | 公共 Release 无需用户 token;覆盖 403/429、正常 JSON、其他错误、代理、受限响应;下载仍须官方 URL、精确资产名和 SHA-256 | 同一网络直连/代理、启动检查/手动检查;离线不得假报最新或成功 | **等待日志,按用户要求暂停**;未验收、不结项 | +| UPDATE-EXIT5 | 安装器 `exit code 5`,当前/最新版本显示相同 | 失败不得标成功;保留原快照与安装日志;已退出进程、安装失败、部分替换、相同版本、校验失败分别验证 | 基于反馈者 `installer.log` 重现;核对原版本、目标版本、实际 EXE;不能把 Inno 退出码 5 当作 Win32 拒绝访问码 | 后续 #258 已提供修复安装成功日志;无限等待的 Hook 已补期限,真实锁定故障仍需与旧版本进程区分 | +| UPDATE-258 | [#258](https://github.com/Kuddev/pebrel/issues/258):同版本修复成功却因主程序哈希不变报错 | 同版本主程序字节不变、Hook 文件修复后应成功;安装器失败或目标版本仍错误必须失败 | 同版本重装、首次升级部分成功后的修复;日志、实际版本和结果提示一致;原会话快照保留 | 10 项原生 handoff 场景通过,包含 `repair-identical` 与 `upgrade-noop` 正反例 | +| BLUR-236-A | [#236](https://github.com/Kuddev/pebrel/issues/236):Acrylic 在屏幕边缘透出背景内容 | 切换材质/透明度不重复创建资源;新窗口、关闭、失焦、最大化/还原、队列退出及运行库失败路径 | 0% 与非零不透明度;明显文字/色块背景;左右/上下屏幕边缘、最大化、任务栏恢复、混合 DPI;失焦不出现不透明灰底 | [PR #250](https://github.com/Kuddev/pebrel/pull/250) 尚未合并;其跨平台 CI 通过不等于本轮实机验收 | +| BLUR-236-B | #236 的 Aero 与 Acrylic 兼容回退范围 | Windows 10、Win11 22H2+、缺失/不匹配运行库与失败挂接均可正常启动/切换/退出 | Aero 需单独验收;缺少受支持 Windows App Runtime 时旧边缘现象不能标为已解决 | #250 仅覆盖受支持 Acrylic;**Aero 和回退范围仍开放** | +| LINK-CJK | OSC 8 中文/全角字符的虚线只画半字、错位 | 从真实 VT 输出构建网格,中文两格、空格、ASCII、组合字符均按列覆盖;逐格绘制与连续区间的物理像素集合相同 | `A开始 菜单Z`、纯中文、混排;DPI/分屏/窄窗/换行;不随字体回补或字形分段重置虚线 | 真实 OSC 8 网格与 16 种 DPI/格宽组合像素覆盖回归通过;原生 Nord / Paper 混排截图已核对 | +| LINK-PATH | 英文路径下划线断开,带空格路径只匹配前半段 | 连续虚线相位;引号包围的盘符/UNC/`~/` 路径完整匹配;hover、点击使用同一边界;普通正文不误连 | `"C:\Program Files\Example Tools\probe.exe"`、中文与括号目录、相邻命令参数、路径换行 | 8 项 hint 测试与 5 项配置解析测试通过;原生带空格路径截图已核对 | +| LINK-PROMPT | WSL `user@host:~/.codex$` 吞入 `$`;绝对路径提示符没有下划线 | `~/` 与 `/mnt/d/...` 提示符中的 `$`/`#` 不可点;真实文件名 `~/cost$`、URL 尾部 `$`、路径内部 `$` 保留;下划线与命中边界一致 | Bash/WSL 彩色提示符、root 提示符、输入命令后、换行;OSC 8 显式 URI 不被启发式改写 | 正反匹配/命中回归通过;绝对路径打开应使用所属 WSL 发行版,不得套用宿主目录或 SSH 目标 | +| CODEX-SPARKLE | 对照终端 中可见的 Codex 星点在 Pebrel 不显示 | 核对真彩色能力、OSC 10/11 回答、盲文点字与前/背景色;真实 PTY 录制/回放;关闭动画时不强制产生动画 | 相同 Codex/模型/设置,对照 WT;启动、输入、弹窗遮挡、焦点、主题切换;不能伪造 `WT_SESSION` 来冒充 WT | 完整 Windows 子环境漏真彩色声明已修复;8 项环境回归及 15 主题 VT 回放通过;真实产品星点字形/背景截图已核对;真实 Codex 动画仍需验收 | +| CODEX-MESSAGE | **所有主题**的 Codex 用户消息与 AI 回复背景缺少区分 | 保留/正确映射应用给出的用户消息背景;未提供背景时不得凭屏幕文字猜测角色;消息文本对比度不退化 | 遍历主题注册表中的全部内置主题;输入框与已发送的单/多行、中英混排消息;深浅切换、滚动回看;用户消息应有可辨认浅色背景区 | 15 个内置主题 + 225 种主题切换的背景保留回归通过;真实产品 Nord / Paper 的背景和星点截图已核对;其余主题有自动回放覆盖,仍需原生视觉抽查 | +| SHELL-MENU | 多个 WSL 发行版把资源管理器右键菜单铺满 | 保留一个普通“在 Pebrel 中打开”;其余发行版集中到一个级联菜单;零/一/多个发行版、重复安装、移除发行版、卸载与旧项迁移 | 文件夹对象/文件夹空白处;中文/空格/盘根路径;点击普通项与每个子项验证 Shell 和 cwd;保留第三方/其他安装拥有的键 | 已实现;真实 Inno 夹具累计 117 项通过,完整安装器编译通过;正式 Explorer 点击验收仍需进行 | + +## 当前测试入口 + +```powershell +cargo test --locked -p nebula_hook +cargo test --locked -p nebula --bin pebrel --features gpui-shell platform::environment::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell ai_hook:: +cargo test --locked -p nebula --bin pebrel --features gpui-shell display::hint::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell config::ui_config::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::link_underline::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell gpui_shell::terminal::element::tests +cargo test --locked -p nebula --bin pebrel --features gpui-shell display::terminal_color::tests +``` + +安装器既有入口为 `scripts/tests/installer.tests.ps1`、 +`scripts/tests/installer-migration.tests.ps1`、`scripts/tests/test_update_handoff.ps1`。 +菜单夹具已扩展零/一/多个发行版及所有权保护;handoff 已扩展同版本修复正例与错误版本反例。它们不代替正式安装或 Explorer 点击验收。 + +修改完成后还需按 `CONTRIBUTING.md` 执行架构、格式、受影响模块与真实产品检查; +架构门禁使用实际目标分支基线。新增门禁或 GitHub 强制保护需单独验证, +本清单不宣称启用了服务端保护。