From e0308bd54fc2ce2921b1e797f034db439a0d62b6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:34:32 +0800 Subject: [PATCH] fix(terminal): keep output without a trailing newline through a resize zsh's PROMPT_SP pads dangling output until it wraps onto the prompt's row. The grid took that wrap for the prompt's own first row, so the next resize at the prompt cleared the output and widening joined it onto the prompt. At the prompt marks, a padded wrap after dangling output is turned back into a hard break. Also: editor breadcrumbs, breadcrumb reveal and Problems labels match files against the project root's real path, so a project under a symlink (macOS /tmp) no longer shows the absolute path; the terminal font size setting says points, which is what its stepper shows. --- src/terminal/mod.rs | 2 +- src/terminal/prompt_reflow.rs | 200 +++++++++++++++++++++++++++++++-- src/terminal/remote.rs | 18 ++- src/ui/code_editor/nav.rs | 5 +- src/ui/code_editor/problems.rs | 9 +- src/ui/file_tree.rs | 43 ++++++- src/ui/i18n/en.rs | 2 +- src/ui/i18n/ja.rs | 2 +- src/ui/i18n/zh.rs | 2 +- 9 files changed, 258 insertions(+), 25 deletions(-) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0cc3a7ea..8f3f01bf 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,7 +19,7 @@ mod loopback; pub mod palette; pub(crate) mod pane_liveness; pub(crate) mod parked_cursor; -mod prompt_reflow; +pub(crate) mod prompt_reflow; mod remote; mod reverse_search; pub(crate) mod scrollbar; diff --git a/src/terminal/prompt_reflow.rs b/src/terminal/prompt_reflow.rs index 2a326e7e..56504ec2 100644 --- a/src/terminal/prompt_reflow.rs +++ b/src/terminal/prompt_reflow.rs @@ -48,17 +48,9 @@ pub(super) fn resize(term: &mut Term, size: TermSize, shell } let cursor = term.grid().cursor.point; - let last = Column(term.columns() - 1); // Stops at the top of the screen: the shell's cursor-up stops there too, // so rows already in scrollback are not part of what it will move over. - let mut start = cursor.line; - while start > Line(0) - && term.grid()[Line(start.0 - 1)][last] - .flags - .contains(Flags::WRAPLINE) - { - start -= 1; - } + let start = wrapped_line_start(term, cursor.line); let rows_up = (cursor.line.0 - start.0) as usize; term.grid_mut().cursor.point = Point::new(start, Column(0)); @@ -75,6 +67,114 @@ pub(super) fn resize(term: &mut Term, size: TermSize, shell term.grid_mut().cursor.point.column = cursor.column.min(last); } +/// The two prompt marks the shell brackets its prompt with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptMark { + /// OSC 133;A — a fresh prompt is about to be drawn. + Opened, + /// OSC 133;B — the prompt is drawn and input begins here. + Ready, +} + +impl PromptMark { + /// Reads an OSC payload (`133;A`, `133;B`, …). + pub fn parse(payload: &[u8]) -> Option { + let mark = payload.strip_prefix(b"133;")?; + let (&kind, rest) = mark.split_first()?; + if !(rest.is_empty() || rest.first() == Some(&b';')) { + return None; + } + match kind { + b'A' => Some(Self::Opened), + b'B' => Some(Self::Ready), + _ => None, + } + } +} + +/// Puts the line break back between output that did not end in a newline and +/// the prompt after it. +/// +/// zsh's `PROMPT_SP` moves such a prompt onto a line of its own by writing +/// the `%` end-of-line mark and then padding it with spaces until the cursor +/// *wraps*. To the grid that is one wrapped line: `printf xyz` and the prompt +/// under it read as a single line of text, so the reflow above took the +/// output for the prompt's own first row and erased it on the next resize, +/// and widening the pane joined the two onto one row. +/// +/// The marks say where that happened. `A` comes from precmd, before +/// `PROMPT_SP`: a cursor off column 0 there is output left dangling. By `B` +/// the prompt is drawn, and if the row that output ended on is now padding +/// all the way to its last column and wraps into the rows below, that wrap is +/// the padding's, not the text's — so it becomes a hard break. A prompt that +/// simply carried on from the output (bash has no `PROMPT_SP`) leaves its own +/// text on that row rather than blanks, and is left alone. +#[derive(Default)] +pub struct PromptBreak { + /// Where `A` found a dangling line: how many rows into its wrapped line + /// the cursor was, the column, and the character it had just written — + /// the last to recognize the row by again at `B`, which the prompt may + /// have scrolled. + dangling: Option<(usize, Column, char)>, +} + +impl PromptBreak { + pub fn apply(&mut self, term: &mut Term, mark: PromptMark) { + match mark { + PromptMark::Opened => { + let cursor = term.grid().cursor.point; + // A full row leaves the cursor parked on its last cell: the + // text ends there, one column short of where it stands. + let end = match term.grid().cursor.input_needs_wrap { + true => cursor.column + 1, + false => cursor.column, + }; + self.dangling = (end.0 > 0).then(|| { + let into = (cursor.line.0 - wrapped_line_start(term, cursor.line).0) as usize; + let last = term.grid()[cursor.line][end - 1].c; + (into, end, last) + }); + } + PromptMark::Ready => { + let Some((into, end, last)) = self.dangling.take() else { + return; + }; + let cursor = term.grid().cursor.point.line; + let row = wrapped_line_start(term, cursor) + into; + if row >= cursor { + return; + } + let cols = term.columns(); + let grid = term.grid(); + let padded = end.0 < cols + && grid[row][end - 1].c == last + && (end.0 + 1..cols).all(|col| grid[row][Column(col)].c == ' ') + && grid[row][Column(cols - 1)].flags.contains(Flags::WRAPLINE); + if padded { + term.grid_mut()[row][Column(cols - 1)] + .flags + .remove(Flags::WRAPLINE); + } + } + } + } +} + +/// The first row of the wrapped line `line` is part of, stopping at the top of +/// the screen as the shell's own cursor-up does. +fn wrapped_line_start(term: &Term, line: Line) -> Line { + let last = Column(term.columns() - 1); + let mut start = line; + while start > Line(0) + && term.grid()[Line(start.0 - 1)][last] + .flags + .contains(Flags::WRAPLINE) + { + start -= 1; + } + start +} + #[cfg(test)] mod tests { use super::*; @@ -287,4 +387,86 @@ mod tests { resize(&mut term, TermSize::new(98, 12), true); assert_eq!(text(&term), ["status bar"]); } + + /// What zsh 5.9 writes between precmd's `A` and the prompt when the last + /// output did not end in a newline, as captured off a real pty: the + /// standout `%` end-of-line mark, `cols - 1` spaces to force a wrap, then + /// back to column 0 and clear below. + fn prompt_sp(cols: usize) -> Vec { + let mut out = b"\x1b[1m\x1b[7m%\x1b[27m\x1b[1m\x1b[0m".to_vec(); + out.extend(std::iter::repeat_n(b' ', cols - 1)); + out.extend_from_slice(b"\r \r\r\x1b[0m\x1b[27m\x1b[24m\x1b[J"); + out + } + + /// `printf xyz` at a zsh prompt, through a stream the reader has cut at + /// the prompt marks, then drawn out at `cols`. + fn dangling_output(cols: usize, guarded: bool) -> (Term, Processor) { + let (mut term, mut parser) = pane(cols); + let mut marks = PromptBreak::default(); + parser.advance(&mut term, b"xyz"); + if guarded { + marks.apply(&mut term, PromptMark::Opened); + } + parser.advance(&mut term, &prompt_sp(cols)); + parser.advance(&mut term, &zsh_redraw(cols, 0, "")); + if guarded { + marks.apply(&mut term, PromptMark::Ready); + } + (term, parser) + } + + /// `PROMPT_SP` wraps the dangling output's row into the prompt's. Taken + /// for the prompt's own first row, that output was cleared by the next + /// resize at the prompt — `printf xyz` simply vanished when a panel + /// opened. Measured against the stream without the marks' fix, so the + /// test also proves the scenario loses the output there. + #[test] + fn output_without_a_newline_survives_a_resize_at_the_prompt() { + let narrow = |guarded: bool| { + let (mut term, mut parser) = dangling_output(98, guarded); + resize(&mut term, TermSize::new(60, 12), true); + parser.advance(&mut term, &zsh_redraw(60, 0, "")); + text(&term) + }; + assert!( + !narrow(false).iter().any(|row| row.starts_with("xyz")), + "the scenario no longer loses the output without the fix" + ); + assert_eq!(narrow(true), ["echo hi", "hi", "xyz%", &prompt_row(60)]); + } + + /// Widening joins wrapped rows back together, and the padding's wrap + /// joined the output and the prompt onto one row. + #[test] + fn widening_keeps_dangling_output_off_the_prompt_row() { + let (mut term, mut parser) = dangling_output(60, true); + resize(&mut term, TermSize::new(98, 12), true); + parser.advance(&mut term, &zsh_redraw(98, 0, "")); + assert_eq!(text(&term), ["echo hi", "hi", "xyz%", &prompt_row(98)]); + } + + /// A prompt that carries straight on from the output — bash has no + /// `PROMPT_SP` — wraps on its own text, not on padding, and that wrap is + /// the shell's to count. + #[test] + fn a_prompt_continuing_the_output_row_keeps_its_wrap() { + let (mut term, mut parser) = pane(20); + let mut marks = PromptBreak::default(); + parser.advance(&mut term, b"xyz"); + marks.apply(&mut term, PromptMark::Opened); + parser.advance(&mut term, b"user@host:~/some/long/path$ "); + marks.apply(&mut term, PromptMark::Ready); + let row: Line = term.grid().cursor.point.line - 1; + assert!(term.grid()[row][Column(19)].flags.contains(Flags::WRAPLINE)); + } + + #[test] + fn prompt_marks_parse() { + assert_eq!(PromptMark::parse(b"133;A"), Some(PromptMark::Opened)); + assert_eq!(PromptMark::parse(b"133;B"), Some(PromptMark::Ready)); + assert_eq!(PromptMark::parse(b"133;A;k=s"), Some(PromptMark::Opened)); + assert_eq!(PromptMark::parse(b"133;C"), None); + assert_eq!(PromptMark::parse(b"133;AB"), None); + } } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index bf212a9f..336d6503 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -14,6 +14,7 @@ use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle}; use crate::terminal::command_cursor::{CommandCursorStyle, CommandMark}; use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner}; +use crate::terminal::prompt_reflow::{PromptBreak, PromptMark}; use std::collections::VecDeque; @@ -1239,6 +1240,7 @@ impl RemoteTerminal { // against the bytes before its `D`, not the whole batch. let mut command_tok = OscTokenizer::new(&[b"133"]); let mut command_cursor = CommandCursorStyle::default(); + let mut prompt_break = PromptBreak::default(); let mut pending: Vec = buffered; // Kitty-graphics decode runs on its own thread with newest-frame // coalescing (issue #213): inflating a full-window browser frame @@ -1335,6 +1337,9 @@ impl RemoteTerminal { ReaderCut::Command(mark) => { command_cursor.apply(term, mark) } + ReaderCut::Prompt(mark) => { + prompt_break.apply(term, mark) + } }, ) else { return; @@ -1472,8 +1477,14 @@ impl RemoteTerminal { command_cuts(&mut command_tok, &bytes, &mut cuts); let fed = feed_grid(&term, &mut processor, &bytes, cuts, &quit, |term, cut| { - if let ReaderCut::Command(mark) = cut { - command_cursor.apply(term, mark); + match cut { + ReaderCut::Command(mark) => { + command_cursor.apply(term, mark) + } + ReaderCut::Prompt(mark) => { + prompt_break.apply(term, mark) + } + ReaderCut::Parked(_) => {} } }); if fed.is_none() { @@ -3501,6 +3512,7 @@ mod chunking_tests { enum ReaderCut { Parked(CursorCut), Command(CommandMark), + Prompt(PromptMark), } /// Adds the batch's command marks to `cuts`, keeping them in stream order @@ -3510,6 +3522,8 @@ fn command_cuts(tok: &mut OscTokenizer, bytes: &[u8], cuts: &mut Vec<(usize, Rea tok.feed_at(bytes, |off, payload| { if let Some(mark) = CommandMark::parse(payload) { cuts.push((off, ReaderCut::Command(mark))); + } else if let Some(mark) = PromptMark::parse(payload) { + cuts.push((off, ReaderCut::Prompt(mark))); } }); if before > 0 && cuts.len() > before { diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index 8ccd30b8..73237a25 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -754,7 +754,10 @@ impl Tty7App { } else { let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); let local = f.host.id() == self.spawn_host(cx); - path_segments(&f.path, if local { roots } else { &[] }) + match local { + true => path_segments(&self.project_spelling(&f.path, cx), roots), + false => path_segments(&f.path, &[]), + } }; let file = (f.untitled.is_none()).then(|| f.path.clone()); // A rendered Markdown file keeps the path; its symbols are the source's. diff --git a/src/ui/code_editor/problems.rs b/src/ui/code_editor/problems.rs index 31c1b21b..a28b93bb 100644 --- a/src/ui/code_editor/problems.rs +++ b/src/ui/code_editor/problems.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use gpui::prelude::*; -use gpui::{AnyElement, Context, ScrollHandle, Window, div, px}; +use gpui::{AnyElement, App, Context, ScrollHandle, Window, div, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use lsp_types::DiagnosticSeverity; @@ -108,12 +108,13 @@ impl Tty7App { /// Where a file's name sits in the list: relative to the project the /// tab shows, when it is inside it. - fn problems_label(&self, path: &Path) -> (String, String) { + fn problems_label(&self, path: &Path, cx: &App) -> (String, String) { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.display().to_string()); - let dir = path.parent().map(|parent| { + let spelled = self.project_spelling(path, cx); + let dir = spelled.parent().map(|parent| { self.tab_code() .and_then(|c| { c.roots @@ -192,7 +193,7 @@ impl Tty7App { left += file.problems.len(); continue; } - let (name, dir) = self.problems_label(&file.path); + let (name, dir) = self.problems_label(&file.path, cx); rows.push( h_flex() .id(("editor-problems-file", file_ix)) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index aa3da09e..b09cc70c 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -205,6 +205,11 @@ pub(crate) struct FileTreeState { unreadable: HashSet, stale: HashSet, repo_roots: ByHost, + /// A project root's real path, where it differs from the root as spelled + /// (`/tmp/x` → `/private/tmp/x`, a symlinked checkout). The editor opens + /// files by their real path, so this is how a file finds the root it is + /// under — see [`Tty7App::project_spelling`]. + root_aliases: ByHost, repo_root_loads: InFlight, search: SearchState, /// Every file under the project, for the search's Files tab @@ -252,6 +257,7 @@ impl FileTreeState { unreadable: HashSet::new(), stale: HashSet::new(), repo_roots: ByHost::default(), + root_aliases: ByHost::default(), repo_root_loads: InFlight::default(), search: SearchState::default(), quick_open: Default::default(), @@ -683,6 +689,7 @@ impl FileTreeState { fn invalidate_repo_roots(&mut self) -> bool { let had = !self.repo_roots.is_empty() || !self.repo_root_loads.is_empty(); self.repo_roots.clear(); + self.root_aliases.clear(); self.repo_root_loads.invalidate_all(); had } @@ -857,13 +864,18 @@ impl Tty7App { cx, { let cwd = cwd.clone(); - move |h| h.repo_root(&cwd).ok().flatten() + move |h| { + let root = h.repo_root(&cwd).ok().flatten().unwrap_or(cwd); + let real = h.canonicalize(&root).ok().filter(|real| *real != root); + (root, real) + } }, - move |app, root, cx| { + move |app, (root, real), cx| { if app.file_tree.repo_root_loads.finish(&key) { - app.file_tree - .repo_roots - .insert(id, cwd.clone(), root.unwrap_or(cwd)); + if let Some(real) = real { + app.file_tree.root_aliases.insert(id, root.clone(), real); + } + app.file_tree.repo_roots.insert(id, cwd, root); } cx.notify(); }, @@ -1034,7 +1046,28 @@ impl Tty7App { /// no row to scroll to. An empty root list is not that: it means the panel /// has never drawn and does not know its roots yet, and the request /// outlives the render that fills them in. + /// `path` as the tree spells it: under the project root as the tab names + /// it, when `path` is that root's real path spelled out. The editor keeps + /// files by their real path (`load_file` canonicalizes), the tree by the + /// pane's — on macOS every project under `/tmp` differs that way, as does + /// any checkout reached through a symlink. Anything else comes back as is. + pub(crate) fn project_spelling(&self, path: &Path, cx: &App) -> PathBuf { + let host = self.spawn_host(cx); + let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); + if roots.iter().any(|root| path.starts_with(root)) { + return path.to_path_buf(); + } + roots + .iter() + .find_map(|root| { + let real = self.file_tree.root_aliases.get(host, root)?; + Some(root.join(path.strip_prefix(real).ok()?)) + }) + .unwrap_or_else(|| path.to_path_buf()) + } + pub(crate) fn file_tree_reveal_path(&mut self, path: &Path, cx: &mut Context) -> bool { + let path = &self.project_spelling(path, cx); let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default(); if !roots.is_empty() && !roots.iter().any(|root| path.starts_with(root)) { return false; diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ef4ea82d..c4372950 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -275,7 +275,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsThemeIntroDesc => "Each theme sets its own light or dark look.", L10nKey::SettingsTypography => "Typography", L10nKey::SettingsFontSize => "Terminal font size", - L10nKey::SettingsFontSizeDesc => "Terminal text size in pixels.", + L10nKey::SettingsFontSizeDesc => "Terminal text size in points.", L10nKey::SettingsUiFontSize => "Interface font size", L10nKey::SettingsUiFontSizeDesc => "Text size for tabs, panels and settings.", L10nKey::SettingsUiFontFamily => "Interface font", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4498b6fc..b868ffc4 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -292,7 +292,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsThemeIntroDesc => "テーマごとにライトかダークが決まります", L10nKey::SettingsTypography => "タイポグラフィ", L10nKey::SettingsFontSize => "ターミナルの文字サイズ", - L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ピクセル)", + L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ポイント)", L10nKey::SettingsUiFontSize => "画面の文字サイズ", L10nKey::SettingsUiFontSizeDesc => "タブ・パネル・設定などの文字サイズ", L10nKey::SettingsUiFontFamily => "画面のフォント", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 47ecea52..ea0bee8a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -248,7 +248,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsThemeIntroDesc => "每个主题自带浅色或深色外观。", L10nKey::SettingsTypography => "字体排版", L10nKey::SettingsFontSize => "终端字号", - L10nKey::SettingsFontSizeDesc => "终端文字大小(像素)。", + L10nKey::SettingsFontSizeDesc => "终端文字大小(磅)。", L10nKey::SettingsUiFontSize => "界面字号", L10nKey::SettingsUiFontSizeDesc => "标签页、面板、设置等非终端文字的大小。", L10nKey::SettingsUiFontFamily => "界面字体",