diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/pane/terminal.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/pane/terminal.rs')
| -rw-r--r-- | src/pane/terminal.rs | 499 |
1 files changed, 260 insertions, 239 deletions
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 37de333b..df84e5cb 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -69,6 +69,20 @@ pub(crate) struct TerminalTextMatch { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TerminalSearchDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TerminalSearchWindow { + pub matches: Vec<TerminalTextMatch>, + pub current: Option<usize>, + pub current_global: Option<usize>, + pub total: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TerminalWordMotion { NextStart, PreviousStart, @@ -241,69 +255,32 @@ impl PaneTerminal { self.ghostty.scroll_metrics() } - pub(crate) fn search_text_matches( + pub(crate) fn search_text_window( &self, query: &str, case_sensitive: bool, - ) -> Vec<TerminalTextMatch> { + direction: TerminalSearchDirection, + cursor: TerminalTextPoint, + previous: Option<(TerminalTextPoint, TerminalTextPoint)>, + limit: usize, + ) -> TerminalSearchWindow { let Some((buffer, active_screen)) = self.retained_text_buffer() else { - return Vec::new(); - }; - buffer.search(query, case_sensitive, active_screen) - } - - pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool { - self.text_matches_are_current(&[text_match]) - .first() - .copied() - .unwrap_or(false) - } - - pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec<bool> { - if text_matches.is_empty() { - return Vec::new(); - } - let Ok(core) = self.ghostty.core.lock() else { - return vec![false; text_matches.len()]; - }; - let Some(cols) = core.terminal.cols().ok() else { - return vec![false; text_matches.len()]; - }; - let Some(active_screen) = core.terminal.active_screen().ok() else { - return vec![false; text_matches.len()]; - }; - let row_range = text_matches - .iter() - .filter(|text_match| { - text_match.scan_cols == cols && text_match.scan_screen == active_screen - }) - .fold(None::<(u32, u32)>, |range, text_match| { - Some(match range { - Some((start_row, end_row)) => ( - start_row.min(text_match.start.row), - end_row.max(text_match.end.row), - ), - None => (text_match.start.row, text_match.end.row), - }) - }); - let Some((start_row, end_row)) = row_range else { - return vec![false; text_matches.len()]; - }; - let Ok(rows) = core - .terminal - .screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize) - else { - return vec![false; text_matches.len()]; + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; }; - let buffer = RetainedTextBuffer::new_search(cols, rows, start_row); - text_matches - .iter() - .map(|text_match| { - text_match.scan_cols == cols - && text_match.scan_screen == active_screen - && buffer.contains_match(*text_match) - }) - .collect() + buffer.search_window( + query, + case_sensitive, + active_screen, + direction, + cursor, + previous, + limit, + ) } pub(crate) fn word_motion_target( @@ -377,6 +354,53 @@ impl PaneTerminal { } } + pub(crate) fn dimensions(&self) -> Option<(u16, u16)> { + let core = self.ghostty.core.lock().ok()?; + Some((core.terminal.cols().ok()?, core.terminal.rows().ok()?)) + } + + pub(crate) fn paragraph_motion_target( + &self, + row: u32, + direction: i8, + ) -> Option<TerminalTextPoint> { + let core = self.ghostty.core.lock().ok()?; + let total_rows = core.terminal.total_rows().ok()?; + let current = usize::try_from(row).ok()?; + if current >= total_rows || direction == 0 { + return None; + } + let limit = total_rows.min(1000); + for distance in 1..limit { + let candidate = if direction < 0 { + current.checked_sub(distance)? + } else { + let candidate = current.saturating_add(distance); + if candidate >= total_rows { + return None; + } + candidate + }; + let rows = core + .terminal + .screen_text_rows_range(candidate, candidate.saturating_add(1)) + .ok()?; + let row = rows.first()?; + let is_blank = row.cells.iter().all(|cell| { + terminal_cell_text(&cell.graphemes) + .chars() + .all(char::is_whitespace) + }); + if is_blank { + return Some(TerminalTextPoint { + row: u32::try_from(candidate).ok()?, + col: 0, + }); + } + } + None + } + fn retained_text_buffer(&self) -> Option<(RetainedTextBuffer, crate::ghostty::ActiveScreen)> { let (cols, rows, active_screen) = { let core = self.ghostty.core.lock().ok()?; @@ -393,10 +417,6 @@ impl PaneTerminal { self.ghostty.input_state() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.ghostty.keyboard_report_all_requested() - } - pub fn bracketed_paste_enabled(&self) -> bool { self.ghostty.bracketed_paste_enabled() } @@ -409,6 +429,10 @@ impl PaneTerminal { self.ghostty.mouse_reporting_enabled() } + pub fn modify_other_keys_level(&self) -> u8 { + self.ghostty.modify_other_keys_level() + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.ghostty.sgr_pixel_mouse_enabled() } @@ -530,12 +554,10 @@ impl PaneTerminal { self.ghostty.terminal_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.ghostty.agent_osc_title() } - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.ghostty.agent_osc_progress() } @@ -742,70 +764,122 @@ impl RetainedTextBuffer { Self { cols, lines, atoms } } - fn search( + fn search_window( &self, query: &str, case_sensitive: bool, active_screen: crate::ghostty::ActiveScreen, - ) -> Vec<TerminalTextMatch> { - if query.is_empty() { - return Vec::new(); + direction: TerminalSearchDirection, + cursor: TerminalTextPoint, + previous: Option<(TerminalTextPoint, TerminalTextPoint)>, + limit: usize, + ) -> TerminalSearchWindow { + if query.is_empty() || limit == 0 { + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; } let Ok(regex) = regex::RegexBuilder::new(®ex::escape(query)) .case_insensitive(!case_sensitive) .build() else { - return Vec::new(); + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; + }; + let to_match = |line: &LogicalTextLine, found: regex::Match<'_>| { + let start_index = line + .spans + .binary_search_by_key(&found.start(), |span| span.byte_start) + .ok()?; + let end_index = line + .spans + .binary_search_by_key(&found.end(), |span| span.byte_end) + .ok()?; + let start_span = &line.spans[start_index]; + let end_span = &line.spans[end_index]; + Some(TerminalTextMatch { + start: start_span.start, + end: end_span.end, + source_fingerprint: text_fingerprint(found.as_str()), + scan_cols: self.cols, + scan_screen: active_screen, + }) }; - let mut matches = Vec::new(); + + let origin = match direction { + TerminalSearchDirection::Forward => previous.map_or(cursor, |(_, end)| end), + TerminalSearchDirection::Backward => previous.map_or(cursor, |(start, _)| start), + }; + let mut total = 0usize; + let mut target = None; for line in &self.lines { for found in regex.find_iter(&line.text) { - let Ok(start_index) = line - .spans - .binary_search_by_key(&found.start(), |span| span.byte_start) - else { + let Some(text_match) = to_match(line, found) else { continue; }; - let Ok(end_index) = line - .spans - .binary_search_by_key(&found.end(), |span| span.byte_end) - else { + match direction { + TerminalSearchDirection::Forward + if target.is_none() && text_match.start > origin => + { + target = Some(total); + } + TerminalSearchDirection::Backward if text_match.end < origin => { + target = Some(total); + } + _ => {} + } + total = total.saturating_add(1); + } + } + if total == 0 { + return TerminalSearchWindow { + matches: Vec::new(), + current: None, + current_global: None, + total: 0, + }; + } + let target = target.unwrap_or(match direction { + TerminalSearchDirection::Forward => 0, + TerminalSearchDirection::Backward => total - 1, + }); + let retained = limit.min(total); + let start = target + .saturating_sub(retained / 2) + .min(total.saturating_sub(retained)); + let end = start.saturating_add(retained); + let mut index = 0usize; + let mut matches = Vec::with_capacity(retained); + for line in &self.lines { + for found in regex.find_iter(&line.text) { + let Some(text_match) = to_match(line, found) else { continue; }; - let start_span = &line.spans[start_index]; - let end_span = &line.spans[end_index]; - matches.push(TerminalTextMatch { - start: start_span.start, - end: end_span.end, - source_fingerprint: text_fingerprint(found.as_str()), - scan_cols: self.cols, - scan_screen: active_screen, - }); + if index >= start && index < end { + matches.push(text_match); + } + index = index.saturating_add(1); + if index >= end { + break; + } + } + if index >= end { + break; } } - matches - } - - fn contains_match(&self, text_match: TerminalTextMatch) -> bool { - self.lines.iter().any(|line| { - let Ok(start_index) = line - .spans - .binary_search_by_key(&text_match.start, |span| span.start) - else { - return false; - }; - let Ok(end_index) = line - .spans - .binary_search_by_key(&text_match.end, |span| span.end) - else { - return false; - }; - let start_span = &line.spans[start_index]; - let end_span = &line.spans[end_index]; - start_span.byte_start <= end_span.byte_end - && text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end]) - == text_match.source_fingerprint - }) + TerminalSearchWindow { + matches, + current: Some(target - start), + current_global: Some(target), + total, + } } fn word_motion( @@ -1187,7 +1261,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 0/2 title retained for agent detection, or `""` /// if no title has been seen or the last update was an empty clear. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_title(&self) -> String { self.core .lock() @@ -1197,7 +1270,6 @@ impl GhosttyPaneTerminal { /// Returns the latest OSC 9 progress payload retained for agent detection, /// or `""` if none has been seen. - #[allow(dead_code)] // exposed for Stage C (detection loop wiring) pub fn agent_osc_progress(&self) -> String { self.core .lock() @@ -1449,7 +1521,6 @@ impl GhosttyPaneTerminal { let Ok(mut core) = self.core.lock() else { return; }; - #[cfg(windows)] core.kitty_keyboard.observe(ansi.as_bytes()); core.terminal.write(ansi.as_bytes()); #[cfg(windows)] @@ -1514,6 +1585,7 @@ impl GhosttyPaneTerminal { } if input_state.modify_other_keys { + core.kitty_keyboard.observe(b"\x1b[>4;2m"); core.terminal.write(b"\x1b[>4;2m"); } @@ -1675,17 +1747,6 @@ impl GhosttyPaneTerminal { core.kitty_keyboard.replay_ansi() } - pub fn keyboard_report_all_requested(&self) -> bool { - self.core.lock().is_ok_and(|core| { - let protocol = crate::input::KeyboardProtocol::from_kitty_flags( - core.terminal.kitty_keyboard_flags().unwrap_or(0) as u16, - ); - protocol.reports_all_keys() - || (protocol.reports_event_types() - && core.terminal.modify_other_keys_enabled().unwrap_or(false)) - }) - } - pub fn bracketed_paste_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_BRACKETED_PASTE) } @@ -1700,6 +1761,12 @@ impl GhosttyPaneTerminal { .is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false)) } + pub fn modify_other_keys_level(&self) -> u8 { + self.core + .lock() + .map_or(0, |core| core.kitty_keyboard.modify_other_keys_level()) + } + pub fn sgr_pixel_mouse_enabled(&self) -> bool { self.mode_enabled(crate::ghostty::MODE_MOUSE_SGR_PIXELS) } @@ -2363,12 +2430,16 @@ fn ghostty_collect_dirty_patch( if render_state.update(terminal).is_err() { fallback!("render_state_update_error"); } - match render_state.dirty() { + let collect_all_rows = match render_state.dirty() { Ok(crate::ghostty::Dirty::Clean) => finish!(TerminalDirtyPatchOutcome::Clean), - Ok(crate::ghostty::Dirty::Partial) => {} - Ok(crate::ghostty::Dirty::Full) => fallback!("dirty_full"), + Ok(crate::ghostty::Dirty::Partial) => false, + // A full dirty state means that every visible row may have changed. It + // is still safe to send this as a bounded patch: the client replaces + // only this pane's viewport, rather than falling back to the whole + // shell surface. + Ok(crate::ghostty::Dirty::Full) => true, Err(_) => fallback!("dirty_read_error"), - } + }; let colors = render_state.colors().ok(); let default_bg = colors @@ -2399,7 +2470,7 @@ fn ghostty_collect_dirty_patch( let Ok(dirty) = rows.dirty() else { fallback!("row_dirty_read_error"); }; - if dirty { + if collect_all_rows || dirty { match rows.selection() { Ok(None) => {} Ok(Some(_)) => fallback!("row_selection_present"), @@ -2448,6 +2519,9 @@ fn ghostty_collect_dirty_patch( y += 1; } + // Nothing above mutates dirty state. Only clear it after every row has + // been collected successfully, so a safety fallback leaves the next + // collection with the same information. let dirty_ys: std::collections::HashSet<u16> = patch_rows.iter().map(|(row, _)| *row).collect(); if !dirty_ys.is_empty() { let Ok(mut clear_row_iterator) = crate::ghostty::RowIterator::new() else { @@ -3347,6 +3421,30 @@ mod tests { } #[test] + fn dirty_full_collects_bounded_viewport_patch() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(4, 3, 200).unwrap(); + terminal.write(b"one\r\ntwo\r\nthree"); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + let patch = match pane.collect_dirty_patch(4, 3) { + TerminalDirtyPatchOutcome::Patch(patch) => patch, + outcome => panic!("expected viewport patch, got {outcome:?}"), + }; + + assert_eq!(patch.rows.len(), 3); + assert_eq!( + patch.rows.iter().map(|(row, _)| *row).collect::<Vec<_>>(), + vec![0, 1, 2] + ); + assert!(patch.rows.iter().all(|(_, cells)| cells.len() == 4)); + assert!(matches!( + pane.collect_dirty_patch(4, 3), + TerminalDirtyPatchOutcome::Clean + )); + } + + #[test] fn palette_overrides_are_none_without_an_osc4_write() { let default = [rgb(1, 2, 3); 256]; assert!(PaletteOverrides::new(&default, &default).is_none()); @@ -3419,7 +3517,17 @@ mod tests { query: &str, case_sensitive: bool, ) -> Vec<TerminalTextMatch> { - buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary) + buffer + .search_window( + query, + case_sensitive, + crate::ghostty::ActiveScreen::Primary, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + usize::MAX, + ) + .matches } fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { @@ -3652,56 +3760,6 @@ mod tests { } #[test] - fn live_terminal_match_validation_rejects_overwritten_text() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - assert!(pane.text_match_is_current(text_match)); - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\r\x1b[2Kalpha changed"); - assert!(!pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_handles_soft_wrapped_matches() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); - terminal.write(b"abcdef"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("def", true)[0]; - - assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 }); - assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 }); - assert!(pane.text_match_is_current(text_match)); - } - - #[test] - fn live_terminal_match_validation_rejects_an_active_screen_change() { - let (tx, _rx) = mpsc::channel(4); - let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); - terminal.write(b"alpha needle"); - let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - - let text_match = pane.search_text_matches("needle", true)[0]; - pane.ghostty - .core - .lock() - .unwrap() - .terminal - .write(b"\x1b[?1049hneedle"); - - assert!(!pane.text_match_is_current(text_match)); - } - - #[test] fn live_terminal_word_motion_expands_across_long_blank_history() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(10, 3, 200).unwrap(); @@ -3725,7 +3783,16 @@ mod tests { let word = "a".repeat(132); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; assert_eq!( pane.word_motion_target( @@ -3744,7 +3811,16 @@ mod tests { let word = "界".repeat(66); terminal.write(word.as_bytes()); let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); - let text_match = pane.search_text_matches(&word, true)[0]; + let text_match = pane + .search_text_window( + &word, + true, + TerminalSearchDirection::Forward, + TerminalTextPoint { row: 0, col: 0 }, + None, + 1, + ) + .matches[0]; // The word end sits on the head cell of the final wide glyph, past the // initial read window, so the window has to expand to reach it. @@ -4476,6 +4552,7 @@ mod tests { color_scheme_reporting: true, }) ); + assert_eq!(pane.modify_other_keys_level(), 2); let encoded = pane.encode_terminal_key( crate::input::TerminalKey::new( @@ -4693,6 +4770,7 @@ mod tests { let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); + assert_eq!(pane.modify_other_keys_level(), 1); let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); assert_eq!(encoded, b"\x1b[27;2;13~"); @@ -5053,35 +5131,6 @@ mod tests { } #[test] - fn dirty_patch_keeps_halfwidth_katakana_voiced_tail_empty() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 1, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 1); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 1), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write("ガZ".as_bytes()); - } - - let patch = match pane.collect_dirty_patch(20, 1) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - let row = &patch.rows[0].1; - - assert_eq!(row[0].symbol, "カ\u{ff9e}"); - assert_eq!( - row[1].symbol, "", - "wide spacer tail must stay empty in retained terminal patches" - ); - assert_eq!(row[2].symbol, "Z"); - } - - #[test] fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); @@ -5974,34 +6023,6 @@ mod tests { } #[test] - fn dirty_patch_preserves_curly_underline_style() { - let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let backend = ratatui::backend::TestBackend::new(20, 5); - let mut terminal = ratatui::Terminal::new(backend).unwrap(); - terminal - .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) - .unwrap(); - { - let mut core = pane.core.lock().unwrap(); - core.terminal.write(b"\x1b[4:3mU"); - } - - let patch = match pane.collect_dirty_patch(20, 5) { - TerminalDirtyPatchOutcome::Patch(patch) => patch, - other => panic!("expected dirty patch, got {other:?}"), - }; - - let cell = &patch.rows[0].1[0]; - assert_eq!(cell.symbol, "U"); - assert_eq!( - crate::protocol::underline_style_from_modifier(cell.modifier), - 3 - ); - } - - #[test] fn full_frame_preserves_curly_underline_style() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); |
