parent
f5f753c728
commit
648afa1fd5
10
AGENTS.md
10
AGENTS.md
|
|
@ -49,6 +49,16 @@ Run `just check` before committing unless Can explicitly accepts narrower valida
|
|||
|
||||
Unit tests live next to the code (`#[cfg(test)] mod tests`). New `AppState` or `Workspace` behavior should be testable with `AppState::test_new()` and `Workspace::test_new()` without PTYs.
|
||||
|
||||
## Vendored libghostty-vt
|
||||
|
||||
`vendor/libghostty-vt.vendor.json` records the upstream source commit currently vendored.
|
||||
|
||||
Local patches on top of the vendored source must be tracked in `vendor/libghostty-vt.patches.md` and stored as patch files under `vendor/patches/libghostty-vt/`. Each entry should say why the patch exists, the Herdr issue, upstream PR/discussion, vendored base commit, touched files, verification, and the exact removal condition.
|
||||
|
||||
When updating libghostty-vt, check every active patch in `vendor/libghostty-vt.patches.md`. If the new upstream commit contains the fix, remove the local patch and index entry, then rerun the listed verification. If not, reapply the patch on top of the new vendored source.
|
||||
|
||||
`just check` runs maintenance tests that verify local libghostty-vt patch files are listed in the index and reverse-apply cleanly against the vendored tree. Do not leave a patch file untracked or an indexed patch unapplied.
|
||||
|
||||
## Docs
|
||||
|
||||
Stable public docs live in `website/src/content/docs/`. They are the currently released herdr.dev docs. Do not document unreleased behavior there during normal feature or fix work.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- Resizing restored panes no longer aborts the server when libghostty-vt reflows a terminal whose pre-resize cursor row is past the new height. (#465)
|
||||
|
||||
## [0.6.8] - 2026-06-04
|
||||
|
||||
This is a hotfix release for v0.6.7, prioritizing a server-crash fix for panes that print complex Unicode or emoji output.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
|
@ -63,6 +64,43 @@ class VendorLibghosttyVtTests(unittest.TestCase):
|
|||
self.assertIn('"dist_archive"', text)
|
||||
self.assertIn('"extracted_dir"', text)
|
||||
|
||||
def test_local_vendor_patches_are_listed_in_patch_index(self) -> None:
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
index = project_root / "vendor" / "libghostty-vt.patches.md"
|
||||
patch_dir = project_root / "vendor" / "patches" / "libghostty-vt"
|
||||
patches = sorted(patch_dir.glob("*.patch"))
|
||||
|
||||
if not patches:
|
||||
return
|
||||
|
||||
self.assertTrue(index.exists())
|
||||
text = index.read_text()
|
||||
missing = [
|
||||
str(path.relative_to(project_root))
|
||||
for path in patches
|
||||
if str(path.relative_to(project_root)) not in text
|
||||
]
|
||||
self.assertEqual(missing, [])
|
||||
|
||||
def test_local_vendor_patches_are_applied_to_vendored_tree(self) -> None:
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
patch_dir = project_root / "vendor" / "patches" / "libghostty-vt"
|
||||
|
||||
for patch in sorted(patch_dir.glob("*.patch")):
|
||||
result = subprocess.run(
|
||||
["git", "apply", "--check", "--reverse", str(patch.relative_to(project_root))],
|
||||
cwd=project_root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
result.returncode,
|
||||
0,
|
||||
f"{patch.relative_to(project_root)} is not applied cleanly:\n"
|
||||
f"stdout:\n{result.stdout}\n"
|
||||
f"stderr:\n{result.stderr}",
|
||||
)
|
||||
|
||||
def test_embedded_libghostty_logging_is_silenced(self) -> None:
|
||||
root = Path(__file__).resolve().parent.parent / "vendor" / "libghostty-vt"
|
||||
lib_vt = root / "src" / "lib_vt.zig"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
# libghostty-vt local patches
|
||||
|
||||
This file tracks intentional local changes applied on top of the vendored
|
||||
`libghostty-vt` source. Remove a patch only when the vendored source commit
|
||||
contains the upstream fix and the listed verification still passes.
|
||||
|
||||
## 0001 backport resizeCols cursor subtraction saturation
|
||||
|
||||
status: active
|
||||
|
||||
patch: `vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch`
|
||||
|
||||
herdr issue: https://github.com/ogulcancelik/herdr/issues/465
|
||||
|
||||
upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905
|
||||
|
||||
upstream pr: https://github.com/ghostty-org/ghostty/pull/12907
|
||||
|
||||
introduced upstream: `c44afa625`
|
||||
|
||||
vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8`
|
||||
|
||||
local files:
|
||||
|
||||
- `vendor/libghostty-vt/src/terminal/PageList.zig`
|
||||
- `vendor/libghostty-vt/src/terminal/c/terminal.zig`
|
||||
|
||||
reason: shrinking rows and columns in one resize can leave the pre-resize
|
||||
cursor row past the new row count. `PageList.resizeCols` then computed rows
|
||||
below the cursor with checked unsigned subtraction and aborted in safety builds.
|
||||
|
||||
remove when: the vendored source commit contains upstream PR #12907 and the
|
||||
local ReleaseSafe resize regression tests pass without this patch.
|
||||
|
||||
verification:
|
||||
|
||||
```sh
|
||||
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="resize shrinks both axes with cursor at bottom"
|
||||
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="PageList resize less rows and cols cursor at bottom"
|
||||
```
|
||||
|
|
@ -1059,7 +1059,7 @@ fn resizeCols(
|
|||
break :cursor .{
|
||||
.tracked_pin = c.pin orelse try self.trackPin(p),
|
||||
.untrack = c.pin == null,
|
||||
.remaining_rows = self.rows - c.y - 1,
|
||||
.remaining_rows = self.rows -| (c.y + 1),
|
||||
.wrapped_rows = wrapped,
|
||||
};
|
||||
} else null;
|
||||
|
|
@ -1192,7 +1192,7 @@ fn resizeCols(
|
|||
break :wrapped wrapped;
|
||||
};
|
||||
|
||||
const current = self.rows - active_pt.active.y - 1;
|
||||
const current = self.rows -| (active_pt.active.y + 1);
|
||||
|
||||
var req_rows = c.remaining_rows;
|
||||
req_rows -|= wrapped -| c.wrapped_rows;
|
||||
|
|
@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" {
|
|||
}
|
||||
}
|
||||
|
||||
test "PageList resize less rows and cols cursor at bottom" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 80, 24, 0);
|
||||
defer s.deinit();
|
||||
|
||||
const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
|
||||
.x = 0,
|
||||
.y = s.rows - 1,
|
||||
} }).?);
|
||||
defer s.untrackPin(cursor_pin);
|
||||
|
||||
// Shrink both axes so the original cursor.y is strictly past the new row
|
||||
// count, after resizeWithoutReflow has already reduced self.rows.
|
||||
try s.resize(.{
|
||||
.cols = 79,
|
||||
.rows = 20,
|
||||
.reflow = true,
|
||||
.cursor = .{ .x = 0, .y = 23, .pin = cursor_pin },
|
||||
});
|
||||
try testing.expectEqual(@as(usize, 79), s.cols);
|
||||
try testing.expectEqual(@as(usize, 20), s.rows);
|
||||
|
||||
// remaining_rows saturates to 0, so the cursor lands on the new bottom row.
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = s.rows - 1,
|
||||
} }, s.pointFromPin(.active, cursor_pin.*).?);
|
||||
}
|
||||
|
||||
test "PageList resize (no reflow) more rows and less cols" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,29 @@ test "resize invalid value" {
|
|||
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
|
||||
}
|
||||
|
||||
test "resize shrinks both axes with cursor at bottom" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
// CSI 24;1H parks the cursor on the bottom row.
|
||||
const move = "\x1b[24;1H";
|
||||
vt_write(t, move, move.len);
|
||||
|
||||
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
|
||||
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
|
||||
try testing.expectEqual(79, t.?.terminal.cols);
|
||||
try testing.expectEqual(23, t.?.terminal.rows);
|
||||
}
|
||||
|
||||
test "mode_get and mode_set" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
|
|
|||
115
vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch
vendored
Normal file
115
vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: herdr maintainers <maintainers@herdr.dev>
|
||||
Date: Thu, 4 Jun 2026 00:00:00 +0000
|
||||
Subject: [PATCH] backport resizeCols cursor subtraction saturation
|
||||
|
||||
Backport ghostty-org/ghostty#12907 onto the vendored libghostty-vt source.
|
||||
Shrink-both resize can leave the old cursor row past the new row count after
|
||||
resizeWithoutReflow reduces self.rows. Saturating subtraction treats rows below
|
||||
that cursor as zero instead of trapping in safety builds.
|
||||
|
||||
Herdr issue: https://github.com/ogulcancelik/herdr/issues/465
|
||||
Upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905
|
||||
Upstream PR: https://github.com/ghostty-org/ghostty/pull/12907
|
||||
Vendored base: 0f7cd84b880b203c98683e520e84b9db0c5938d8
|
||||
---
|
||||
vendor/libghostty-vt/src/terminal/PageList.zig | 33 +++++++++++++++++++++--
|
||||
vendor/libghostty-vt/src/terminal/c/terminal.zig | 23 ++++++++++++++++
|
||||
2 files changed, 54 insertions(+), 2 deletions(-)
|
||||
|
||||
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
|
||||
index 0000000..0000000 100644
|
||||
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
|
||||
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
|
||||
@@ -1059,7 +1059,7 @@ fn resizeCols(
|
||||
break :cursor .{
|
||||
.tracked_pin = c.pin orelse try self.trackPin(p),
|
||||
.untrack = c.pin == null,
|
||||
- .remaining_rows = self.rows - c.y - 1,
|
||||
+ .remaining_rows = self.rows -| (c.y + 1),
|
||||
.wrapped_rows = wrapped,
|
||||
};
|
||||
} else null;
|
||||
@@ -1192,7 +1192,7 @@ fn resizeCols(
|
||||
break :wrapped wrapped;
|
||||
};
|
||||
|
||||
- const current = self.rows - active_pt.active.y - 1;
|
||||
+ const current = self.rows -| (active_pt.active.y + 1);
|
||||
|
||||
var req_rows = c.remaining_rows;
|
||||
req_rows -|= wrapped -| c.wrapped_rows;
|
||||
@@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" {
|
||||
}
|
||||
}
|
||||
|
||||
+test "PageList resize less rows and cols cursor at bottom" {
|
||||
+ const testing = std.testing;
|
||||
+ const alloc = testing.allocator;
|
||||
+
|
||||
+ var s = try init(alloc, 80, 24, 0);
|
||||
+ defer s.deinit();
|
||||
+
|
||||
+ const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
|
||||
+ .x = 0,
|
||||
+ .y = s.rows - 1,
|
||||
+ } }).?);
|
||||
+ defer s.untrackPin(cursor_pin);
|
||||
+
|
||||
+ // Shrink both axes so the original cursor.y is strictly past the new row
|
||||
+ // count, after resizeWithoutReflow has already reduced self.rows.
|
||||
+ try s.resize(.{
|
||||
+ .cols = 79,
|
||||
+ .rows = 20,
|
||||
+ .reflow = true,
|
||||
+ .cursor = .{ .x = 0, .y = 23, .pin = cursor_pin },
|
||||
+ });
|
||||
+ try testing.expectEqual(@as(usize, 79), s.cols);
|
||||
+ try testing.expectEqual(@as(usize, 20), s.rows);
|
||||
+
|
||||
+ // remaining_rows saturates to 0, so the cursor lands on the new bottom row.
|
||||
+ try testing.expectEqual(point.Point{ .active = .{
|
||||
+ .x = 0,
|
||||
+ .y = s.rows - 1,
|
||||
+ } }, s.pointFromPin(.active, cursor_pin.*).?);
|
||||
+}
|
||||
+
|
||||
test "PageList resize (no reflow) more rows and less cols" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
index 0000000..0000000 100644
|
||||
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
|
||||
@@ -1005,6 +1005,29 @@ test "resize invalid value" {
|
||||
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
|
||||
}
|
||||
|
||||
+test "resize shrinks both axes with cursor at bottom" {
|
||||
+ var t: Terminal = null;
|
||||
+ try testing.expectEqual(Result.success, new(
|
||||
+ &lib.alloc.test_allocator,
|
||||
+ &t,
|
||||
+ .{
|
||||
+ .cols = 80,
|
||||
+ .rows = 24,
|
||||
+ .max_scrollback = 0,
|
||||
+ },
|
||||
+ ));
|
||||
+ defer free(t);
|
||||
+
|
||||
+ // CSI 24;1H parks the cursor on the bottom row.
|
||||
+ const move = "\x1b[24;1H";
|
||||
+ vt_write(t, move, move.len);
|
||||
+
|
||||
+ // Shrink both axes; pre-resize cursor.y sits past the new bottom row.
|
||||
+ try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
|
||||
+ try testing.expectEqual(79, t.?.terminal.cols);
|
||||
+ try testing.expectEqual(23, t.?.terminal.rows);
|
||||
+}
|
||||
+
|
||||
test "mode_get and mode_set" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
--
|
||||
2.49.0
|
||||
Loading…
Reference in New Issue