diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index 26299e79..6f8b4f20 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -5707,6 +5707,22 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "pane.clear", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/PaneTarget" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index cf102f4b..ece9a833 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -697,6 +697,12 @@ "default": "\"prefix+e\"", "description": "Open the focused pane scrollback in $EDITOR." }, + { + "key": "keys.clear_pane", + "type": "keybinding", + "default": "unset", + "description": "Clear the focused pane screen and scrollback, keeping the current cursor line and its visible soft-wrapped rows. Sends no input to the running program and does nothing on the alternate screen (for example, Vim). Unset by default; for example, clear_pane = \"prefix+ctrl+k\"." + }, { "key": "keys.copy_mode", "type": "keybinding", diff --git a/src/api/mod.rs b/src/api/mod.rs index d28a82ae..bd2983e4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -59,6 +59,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneFocusDirection(_) | Method::PaneResize(_) | Method::PaneScroll(_) + | Method::PaneClear(_) | Method::PaneEditScrollback(_) | Method::PaneFocus(_) | Method::PaneInputSet(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 7c58fb69..e6c678dc 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -165,6 +165,8 @@ pub enum Method { PaneResize(PaneResizeParams), #[serde(rename = "pane.scroll")] PaneScroll(PaneScrollParams), + #[serde(rename = "pane.clear")] + PaneClear(PaneTarget), #[serde(rename = "pane.edit_scrollback")] PaneEditScrollback(PaneTarget), #[serde(rename = "pane.selection.read")] diff --git a/src/api/server.rs b/src/api/server.rs index 55571d75..03724813 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -457,6 +457,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneFocusDirection(_) => "pane.focus_direction", Method::PaneResize(_) => "pane.resize", Method::PaneScroll(_) => "pane.scroll", + Method::PaneClear(_) => "pane.clear", Method::PaneEditScrollback(_) => "pane.edit_scrollback", Method::PaneSelectionRead(_) => "pane.selection.read", Method::PaneCopyMotion(_) => "pane.copy_motion", diff --git a/src/app/api.rs b/src/app/api.rs index 1270feff..0900a09d 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1101,6 +1101,7 @@ impl App { } Method::PaneResize(params) => return self.handle_pane_resize(request.id, params), Method::PaneScroll(params) => return self.handle_pane_scroll(request.id, params), + Method::PaneClear(target) => return self.handle_pane_clear(request.id, target), Method::PaneEditScrollback(target) => { return self.handle_pane_edit_scrollback(request.id, target); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index a025f0b9..119d9298 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -167,6 +167,22 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_clear(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, &target.pane_id); + }; + match runtime.clear_screen() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "pane_clear_failed", err.to_string()), + } + } + pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); @@ -2349,6 +2365,26 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_clear_pane_mutates_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let request = crate::api::schema::Request { + id: "clear".into(), + method: crate::api::schema::Method::PaneClear(PaneTarget { + pane_id: public_pane_id, + }), + }; + assert!(crate::api::request_changes_ui(&request)); + let response = app.handle_api_request(request); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .unwrap(); + assert_eq!(runtime.scroll_metrics().unwrap().max_offset_from_bottom, 0); + } + #[tokio::test] async fn api_pane_get_exposes_scroll_metrics() { let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 12d8cc16..e3885626 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -1064,6 +1064,9 @@ impl ClientShellState { pane_id: focused_pane, mode: PaneZoomMode::Toggle, })), + KeybindAction::ClearPane => Some(Method::PaneClear(PaneTarget { + pane_id: focused_pane?, + })), KeybindAction::EditScrollback => Some(Method::PaneEditScrollback(PaneTarget { pane_id: focused_pane?, })), diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index 25d3b334..7f3ea137 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -884,6 +884,25 @@ fn pane_scrollbar_track_and_thumb_use_stable_endpoint_scroll_requests() { assert!(state.chrome_drag.is_none()); } +#[test] +fn clear_pane_binding_targets_the_focused_endpoint_pane() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + let mut input = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClearPane), + &mut input, + ); + assert!(input.requests.is_empty()); + assert!(matches!( + &input.actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneClear(target) + if target.pane_id == "pane_1") + )); +} + #[test] fn edit_scrollback_binding_targets_the_focused_endpoint_pane() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index bec1af66..5f4b46de 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -347,6 +347,7 @@ pub struct Keybinds { pub close_tab: ActionKeybinds, pub rename_pane: ActionKeybinds, pub edit_scrollback: ActionKeybinds, + pub clear_pane: ActionKeybinds, pub copy_mode: ActionKeybinds, pub focus_pane_left: ActionKeybinds, pub focus_pane_down: ActionKeybinds, @@ -515,6 +516,7 @@ impl Config { close_tab: empty_action!(), rename_pane: empty_action!(), edit_scrollback: empty_action!(), + clear_pane: empty_action!(), copy_mode: empty_action!(), focus_pane_left: empty_action!(), focus_pane_down: empty_action!(), @@ -662,6 +664,7 @@ impl Config { apply_action!(keybinds.close_tab, close_tab, source); apply_action!(keybinds.rename_pane, rename_pane, source); apply_action!(keybinds.edit_scrollback, edit_scrollback, source); + apply_action!(keybinds.clear_pane, clear_pane, source); apply_action!(keybinds.copy_mode, copy_mode, source); apply_action!(keybinds.focus_pane_left, focus_pane_left, source); apply_action!(keybinds.focus_pane_down, focus_pane_down, source); diff --git a/src/config/model.rs b/src/config/model.rs index 6ef6274d..930df026 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -405,6 +405,7 @@ pub struct KeysConfig { pub rename_pane: BindingConfig, /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e". pub edit_scrollback: BindingConfig, + pub clear_pane: BindingConfig, /// Enter keyboard copy mode for the focused pane. Default: "prefix+[". pub copy_mode: BindingConfig, /// Focus the pane to the left. Default: "prefix+h". @@ -536,6 +537,7 @@ pub(crate) struct KeysConfigOverlay { rename_pane: Option, #[serde(skip_serializing_if = "Option::is_none")] edit_scrollback: Option, + clear_pane: Option, #[serde(skip_serializing_if = "Option::is_none")] copy_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -646,6 +648,7 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(close_tab); apply_field!(rename_pane); apply_field!(edit_scrollback); + apply_field!(clear_pane); apply_field!(copy_mode); apply_field!(focus_pane_left); apply_field!(focus_pane_down); @@ -750,6 +753,7 @@ impl KeysConfig { copy_effective_action_field!(close_tab, keybinds.close_tab); copy_effective_action_field!(rename_pane, keybinds.rename_pane); copy_effective_action_field!(edit_scrollback, keybinds.edit_scrollback); + copy_effective_action_field!(clear_pane, keybinds.clear_pane); copy_effective_action_field!(copy_mode, keybinds.copy_mode); copy_effective_action_field!(focus_pane_left, keybinds.focus_pane_left); copy_effective_action_field!(focus_pane_down, keybinds.focus_pane_down); @@ -1118,6 +1122,7 @@ impl Default for KeysConfig { close_tab: BindingConfig::one("prefix+shift+x"), rename_pane: BindingConfig::one("prefix+shift+p"), edit_scrollback: BindingConfig::one("prefix+e"), + clear_pane: BindingConfig::default(), copy_mode: BindingConfig::one("prefix+["), focus_pane_left: BindingConfig::one("prefix+h"), focus_pane_down: BindingConfig::one("prefix+j"), diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index bfa71825..ceb2d699 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -3236,6 +3236,10 @@ unsafe extern "C" { #[doc = " Perform a full reset of the terminal (RIS).\n\n Resets all terminal state back to its initial configuration, including\n modes, scrollback, scrolling region, and screen contents. The terminal\n dimensions are preserved.\n\n @param terminal The terminal handle (may be NULL, in which case this is a no-op)\n\n @ingroup terminal"] pub fn ghostty_terminal_reset(terminal: GhosttyTerminal); } +unsafe extern "C" { + #[doc = " Clear screen and history, retaining the cursor's soft-wrapped active line.\n Does not alter the VT parser or write to the child process. Returns false\n without changing the terminal on the alternate screen or for a NULL handle.\n Otherwise returns true and moves the retained line to the top of the screen."] + pub fn ghostty_terminal_clear_screen(terminal: GhosttyTerminal) -> bool; +} unsafe extern "C" { #[doc = " Resize the terminal to the given dimensions.\n\n Changes the number of columns and rows in the terminal. The primary\n screen will reflow content if wraparound mode is enabled; the alternate\n screen does not reflow. If the dimensions are unchanged, this is a no-op.\n\n This also updates the terminal's pixel dimensions (used for image\n protocols and size reports), disables synchronized output mode (allowed\n by the spec so that resize results are shown immediately), and sends an\n in-band size report if mode 2048 is enabled.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param cols New width in cells (must be greater than zero)\n @param rows New height in cells (must be greater than zero)\n @param cell_width_px Width of a single cell in pixels\n @param cell_height_px Height of a single cell in pixels\n @return GHOSTTY_SUCCESS on success, or an error code on failure\n\n @ingroup terminal"] pub fn ghostty_terminal_resize( diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index d1748aad..82714118 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1619,6 +1619,10 @@ impl Terminal { Ok(text) } + pub fn clear_screen(&mut self) -> bool { + unsafe { ffi::ghostty_terminal_clear_screen(self.raw) } + } + pub fn scroll_viewport_bottom(&mut self) { let viewport = ffi::GhosttyTerminalScrollViewport { tag: ffi::GhosttyTerminalScrollViewportTag_GHOSTTY_SCROLL_VIEWPORT_BOTTOM, diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index e9462b9d..2f89b861 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -160,6 +160,7 @@ pub(crate) fn keybind_help_groups( entry(binding_label(&keybinds.close_pane), "close pane"), entry(binding_label(&keybinds.rename_pane), "rename pane"), entry(binding_label(&keybinds.edit_scrollback), "edit scrollback"), + entry(binding_label(&keybinds.clear_pane), "clear pane"), entry(binding_label(&keybinds.copy_mode), "copy mode"), entry(binding_label(&keybinds.zoom), "zoom pane"), entry(binding_label(&keybinds.resize_mode), "resize mode"), diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index d56de80c..9ffce2ee 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -52,6 +52,7 @@ pub(crate) enum KeybindAction { SplitHorizontal, ClosePane, EditScrollback, + ClearPane, CopyMode, Zoom, EnterResizeMode, @@ -120,6 +121,7 @@ pub(crate) fn resolve_non_indexed_action( (&keybinds.close_tab, KeybindAction::CloseTab), (&keybinds.rename_pane, KeybindAction::RenamePane), (&keybinds.edit_scrollback, KeybindAction::EditScrollback), + (&keybinds.clear_pane, KeybindAction::ClearPane), (&keybinds.copy_mode, KeybindAction::CopyMode), (&keybinds.focus_pane_left, KeybindAction::FocusPaneLeft), (&keybinds.focus_pane_down, KeybindAction::FocusPaneDown), @@ -258,6 +260,40 @@ mod tests { use super::*; + #[test] + fn clear_pane_is_unbound_by_default_and_configurable() { + assert!(crate::config::Config::default() + .keybinds() + .clear_pane + .bindings + .is_empty()); + let config: crate::config::Config = + toml::from_str("[keys]\nclear_pane = [\"super+k\", \"prefix+ctrl+k\"]").unwrap(); + assert!(config.collect_diagnostics().is_empty()); + let keybinds = config.keybinds(); + assert!(matches!( + resolve_direct_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SUPER) + ), + Some(KeybindMatch::Action(KeybindAction::ClearPane)) + )); + assert!(matches!( + resolve_prefix_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::CONTROL) + ), + Some(KeybindMatch::Action(KeybindAction::ClearPane)) + )); + assert!(matches!( + resolve_prefix_binding( + &keybinds, + &TerminalKey::new(KeyCode::Char('k'), KeyModifiers::SHIFT) + ), + Some(KeybindMatch::Action(KeybindAction::SwapPaneUp)) + )); + } + #[test] fn one_shared_resolver_handles_direct_prefix_and_indexed_bindings() { let keybinds = Keybinds { diff --git a/src/main.rs b/src/main.rs index 8cfe9b0b..5d7582cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,6 +172,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # close_tab = "prefix+shift+x" # rename_pane = "prefix+shift+p" # edit_scrollback = "prefix+e" +# clear_pane = "" # unbound; e.g. "prefix+ctrl+k" # focus_pane_left = "prefix+h" # focus_pane_down = "prefix+j" # focus_pane_up = "prefix+k" diff --git a/src/pane.rs b/src/pane.rs index 29cbad2a..3e1c52a4 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3081,6 +3081,20 @@ impl PaneRuntime { self.compression.wake(); } + pub fn clear_screen(&self) -> Result<(), String> { + let guard = match self.content_write_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + self.content_seq.fetch_add(1, Ordering::AcqRel); + let result = self.terminal.clear_screen(); + self.content_seq.fetch_add(1, Ordering::Release); + drop(guard); + self.compression.wake(); + mark_detection_content_changed(&self.detection_content_seq); + result + } + /// Reset scroll to live view (offset = 0). pub fn scroll_reset(&self) { self.terminal.scroll_reset(); @@ -3668,6 +3682,56 @@ impl PaneRuntime { mod tests { use super::*; + #[tokio::test] + async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() { + let (runtime, mut rx) = PaneRuntime::test_with_channel_and_scrollback_bytes( + 10, + 5, + 100_000, + b"old\r\nold\r\nold\r\nold\r\nold\r\n\x1b[32m$ abcdefghijklmnop\x1b[1A\x1b[4G\x1b[", + 4, + ); + let before = runtime.content_seq(); + runtime.scroll_up(1); + runtime.clear_screen().unwrap(); + let snapshot = runtime.collect_dirty_patch_snapshot(10, 5).unwrap(); + assert!(snapshot.content_revision > before); + assert!(!matches!(snapshot.patch, TerminalDirtyPatchOutcome::Clean)); + let metrics = runtime.scroll_metrics().unwrap(); + assert_eq!(metrics.max_offset_from_bottom, 0); + assert_eq!(metrics.offset_from_bottom, 0); + let text = runtime.recent_unwrapped_text_snapshot(100).text; + assert!(text.contains("$ abcdefghijklmnop"), "{text:?}"); + assert!(!text.contains("old"), "{text:?}"); + runtime.test_process_pty_bytes(b"5 q"); + assert!(!runtime.visible_text().contains("5 q")); + assert!(rx.try_recv().is_err(), "clear must not send child input"); + } + + #[tokio::test] + async fn clear_pane_preserves_alternate_screen_and_primary_history() { + let runtime = PaneRuntime::test_with_scrollback_bytes( + 20, + 4, + 100_000, + b"one\r\ntwo\r\nthree\r\nfour\r\nfive\x1b[?1049halt app", + ); + let before = runtime.visible_text(); + runtime.clear_screen().unwrap(); + assert_eq!(runtime.visible_text(), before); + runtime.test_process_pty_bytes(b"\x1b[?1049l"); + assert!(runtime + .recent_unwrapped_text_snapshot(100) + .text + .contains("one")); + runtime.clear_screen().unwrap(); + assert!(!runtime + .recent_unwrapped_text_snapshot(100) + .text + .contains("one")); + assert!(runtime.visible_text().contains("five")); + } + #[tokio::test] async fn dirty_patch_snapshot_keeps_clean_metadata_and_terminal_fallback() { let (runtime, _rx) = PaneRuntime::test_with_channel(20, 4); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 4a6bc173..4d7f07b1 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -258,6 +258,10 @@ impl PaneTerminal { self.ghostty.scroll_reset(); } + pub fn clear_screen(&self) -> Result<(), String> { + self.ghostty.clear_screen() + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { self.ghostty.set_scroll_offset_from_bottom(lines); } @@ -1773,6 +1777,21 @@ impl GhosttyPaneTerminal { } } + pub fn clear_screen(&self) -> Result<(), String> { + let mut core = self + .core + .lock() + .map_err(|_| "terminal lock poisoned".to_owned())?; + if core.terminal.clear_screen() { + #[cfg(windows)] + { + core.recent_fallback = windows_recent_fallback::Cache::default(); + windows_recent_fallback::update(&mut core); + } + } + Ok(()) + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { #[cfg(windows)] diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 4494f053..87824c85 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -18,6 +18,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "integration.install", "integration.list", "layout.set_split_ratio", + "pane.clear", "pane.close", "pane.copy_motion", "pane.copy_search", @@ -287,7 +288,11 @@ mod tests { ))) .expect("endpoint method shape fixture"); let mut actual = endpoint_method_shape_digests(); - // Freeze the additive method separately without rewriting the published fixture. + // Freeze additive methods separately without rewriting the published fixture. + assert_eq!( + actual.remove("pane.clear").as_deref(), + Some("0301d288ba198ddaa427dd7421c71911cccaf4ea03544531efa8b67ca21b08f6") + ); assert_eq!( actual.remove("pane.link.resolve").as_deref(), Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81") diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index 4e0bd2e2..62fd04e4 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -262,6 +262,7 @@ impl HeadlessServer { | Method::PaneRename(_) | Method::PaneResize(_) | Method::PaneScroll(_) + | Method::PaneClear(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneZoom(_) diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 46795791..a10bec78 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -270,6 +270,10 @@ impl TerminalRuntime { self.0.scroll_reset(); } + pub fn clear_screen(&self) -> Result<(), String> { + self.0.clear_screen() + } + pub fn set_scroll_offset_from_bottom(&self, lines: usize) { self.0.set_scroll_offset_from_bottom(lines); } diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 10596292..cd7f44f6 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -132,3 +132,41 @@ just test-one link_activation just test-one ctrl_click just check ``` + +## 0006 clear screen while preserving the cursor line + +status: active + +patch: `vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch` + +herdr issue: none; requested in https://github.com/herdrdev/herdr/discussions/545 + +upstream discussion: not opened + +upstream pr: not opened + +vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51` + +local files: + +- `vendor/libghostty-vt/include/ghostty/vt/terminal.h` +- `vendor/libghostty-vt/src/lib_vt.zig` +- `vendor/libghostty-vt/src/terminal/c/main.zig` +- `vendor/libghostty-vt/src/terminal/c/terminal.zig` + +reason: Herdr needs an explicit screen/history clear that preserves the cursor's +visible soft-wrapped line without writing to the child or interrupting a partial +VT sequence. The new C function operates directly on the screen, leaves alternate +screens untouched, clears image placements, and marks the result dirty. + +remove when: the vendored C API provides an equivalent parser-independent clear +operation preserving the visible cursor line, and Herdr passes the checks below +using it without this patch. + +verification: + +```sh +just test-one clear_pane +just maintenance-test +just check +``` diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index ad5eec54..81f9ebd0 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); */ GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); +/** + * Clear screen and history, retaining the cursor's soft-wrapped active line. + * Does not alter the VT parser or write to the child process. Returns false + * without changing the terminal on the alternate screen or for a NULL handle. + * Otherwise returns true and moves the retained line to the top of the screen. + */ +GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal); + /** * Resize the terminal to the given dimensions. * diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index 48536568..05bc0baa 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -317,6 +317,7 @@ comptime { @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); + @export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" }); @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 0ba1224a..5ac299ab 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -183,6 +183,7 @@ pub const sys_set = sys.set; pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; +pub const terminal_clear_screen = terminal.clearScreen; pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index 73097573..91bbe310 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { t.fullReset(); } +/// Clear output without feeding synthetic bytes into the child's VT stream. +pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool { + const t: *ZigTerminal = (terminal_ orelse return false).terminal; + if (t.screens.active_key == .alternate) return false; + const screen = t.screens.active; + + var first = screen.cursor.y; + while (first > 0) { + const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?; + if (!pin.rowAndCell().row.wrap_continuation) break; + first -= 1; + } + var last = screen.cursor.y; + while (last + 1 < t.rows) { + const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?; + if (!pin.rowAndCell().row.wrap) break; + last += 1; + } + + screen.clearSelection(); + if (last + 1 < t.rows) { + screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false); + } + screen.eraseHistory(null); + if (first > 0) screen.eraseActive(first - 1); + screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false; + screen.scroll(.active); + if (comptime build_options.kitty_graphics) { + screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true }); + } + t.flags.dirty.clear = true; + return true; +} + /// C: GhosttyKittyGraphics pub const KittyGraphics = kitty_gfx_c.KittyGraphics; diff --git a/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch b/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch new file mode 100644 index 00000000..86fcee9a --- /dev/null +++ b/vendor/patches/libghostty-vt/0006-clear-screen-preserving-cursor-line.patch @@ -0,0 +1,88 @@ +diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +index ad5eec54..81f9ebd0 100644 +--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h ++++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h +@@ -2007,6 +2007,14 @@ GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); + */ + GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); + ++/** ++ * Clear screen and history, retaining the cursor's soft-wrapped active line. ++ * Does not alter the VT parser or write to the child process. Returns false ++ * without changing the terminal on the alternate screen or for a NULL handle. ++ * Otherwise returns true and moves the retained line to the top of the screen. ++ */ ++GHOSTTY_API bool ghostty_terminal_clear_screen(GhosttyTerminal terminal); ++ + /** + * Resize the terminal to the given dimensions. + * +diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig +index 48536568..05bc0baa 100644 +--- a/vendor/libghostty-vt/src/lib_vt.zig ++++ b/vendor/libghostty-vt/src/lib_vt.zig +@@ -317,6 +317,7 @@ comptime { + @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); + @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); + @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); ++ @export(&c.terminal_clear_screen, .{ .name = "ghostty_terminal_clear_screen" }); + @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); + @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); + @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); +diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig +index 0ba1224a..5ac299ab 100644 +--- a/vendor/libghostty-vt/src/terminal/c/main.zig ++++ b/vendor/libghostty-vt/src/terminal/c/main.zig +@@ -183,6 +183,7 @@ pub const sys_set = sys.set; + pub const terminal_new = terminal.new; + pub const terminal_free = terminal.free; + pub const terminal_reset = terminal.reset; ++pub const terminal_clear_screen = terminal.clearScreen; + pub const terminal_resize = terminal.resize; + pub const terminal_set = terminal.set; + pub const terminal_vt_write = terminal.vt_write; +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 73097573..91bbe310 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -1504,6 +1504,40 @@ pub fn reset(terminal_: Terminal) callconv(lib.calling_conv) void { + t.fullReset(); + } + ++/// Clear output without feeding synthetic bytes into the child's VT stream. ++pub fn clearScreen(terminal_: Terminal) callconv(lib.calling_conv) bool { ++ const t: *ZigTerminal = (terminal_ orelse return false).terminal; ++ if (t.screens.active_key == .alternate) return false; ++ const screen = t.screens.active; ++ ++ var first = screen.cursor.y; ++ while (first > 0) { ++ const pin = screen.pages.pin(.{ .active = .{ .y = first } }).?; ++ if (!pin.rowAndCell().row.wrap_continuation) break; ++ first -= 1; ++ } ++ var last = screen.cursor.y; ++ while (last + 1 < t.rows) { ++ const pin = screen.pages.pin(.{ .active = .{ .y = last } }).?; ++ if (!pin.rowAndCell().row.wrap) break; ++ last += 1; ++ } ++ ++ screen.clearSelection(); ++ if (last + 1 < t.rows) { ++ screen.clearRows(.{ .active = .{ .y = last + 1 } }, null, false); ++ } ++ screen.eraseHistory(null); ++ if (first > 0) screen.eraseActive(first - 1); ++ screen.pages.pin(.{ .active = .{} }).?.rowAndCell().row.wrap_continuation = false; ++ screen.scroll(.active); ++ if (comptime build_options.kitty_graphics) { ++ screen.kitty_images.delete(t.io(), screen.alloc, t, .{ .all = true }); ++ } ++ t.flags.dirty.clear = true; ++ return true; ++} ++ + /// C: GhosttyKittyGraphics + pub const KittyGraphics = kitty_gfx_c.KittyGraphics; +