feat: add configurable pane screen and scrollback clearing (#4383)

This commit is contained in:
Can Celik
2026-09-19 14:47:18 +03:00
committed by GitHub
parent 3f2a6e743f
commit cc7c696cd0
27 changed files with 403 additions and 1 deletions
+16
View File
@@ -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": {
@@ -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",
+1
View File
@@ -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(_)
+2
View File
@@ -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")]
+1
View File
@@ -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",
+1
View File
@@ -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);
}
+36
View File
@@ -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(&params.pane_id) else {
return pane_not_found(id, &params.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();
+3
View File
@@ -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?,
})),
@@ -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()));
+3
View File
@@ -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);
+5
View File
@@ -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<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
edit_scrollback: Option<BindingConfig>,
clear_pane: Option<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
copy_mode: Option<BindingConfig>,
#[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"),
+4
View File
@@ -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(
+4
View File
@@ -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,
+1
View File
@@ -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"),
+36
View File
@@ -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 {
+1
View File
@@ -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"
+64
View File
@@ -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);
+19
View File
@@ -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)]
+6 -1
View File
@@ -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")
+1
View File
@@ -262,6 +262,7 @@ impl HeadlessServer {
| Method::PaneRename(_)
| Method::PaneResize(_)
| Method::PaneScroll(_)
| Method::PaneClear(_)
| Method::PaneSplit(_)
| Method::PaneSwap(_)
| Method::PaneZoom(_)
+4
View File
@@ -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);
}
+38
View File
@@ -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
```
+8
View File
@@ -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.
*
+1
View File
@@ -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" });
+1
View File
@@ -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;
+34
View File
@@ -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;
@@ -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;