feat(cli): port ghostty +action CLI commands

Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with
per-action --help, and the actions help, version, list-actions,
list-colors, list-keybinds, list-themes (plain listing plus an
interactive ratatui browser mirroring Ghostty's libvaxis UI),
show-config, validate-config, edit-config, crash-report (listing),
ssh-cache and boo (235 embedded frames).

Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout,
stderr and exit codes of most actions, incl. tty output in a pty at
widths 20-200. Known differences: +list-colors --plain prints all 782
colors (Ghostty truncates at 617 because it never flushes), +version
has placeholder build info until build_info::set is wired up.

Deferred: list-fonts, show-face (need font discovery integration) and
new-window (needs GTK IPC); they return NotYetAvailable for now.

Test Plan:
- cargo test -p ghostty-cli (86 tests)
- cargo run -p ghostty-cli --example cli -- +list-keybinds --plain
  and diff against `ghostty +list-keybinds --plain`
This commit is contained in:
ddidderr committed 2026-10-11 22:22:01 +02:00
1 parent 5462bd8e3e
commit f2c4b40062
46 files changed
+7135 -6

No files matched your search

+427
View File
@@ -0,0 +1,427 @@
//! Shared TUI helpers (ZIG/src/cli/tui.zig) plus a tiny re-implementation of
//! the part of libvaxis that Ghostty's "pretty" (non-interactive, tty)
//! listings use: composing a single row of styled cells in a window and
//! writing it with vaxis' `prettyPrint` escape sequences.
//!
//! The byte output was verified against Ghostty 1.3.1 in a pty.
use std::io::Write;
use unicode_width::UnicodeWidthChar;
/// Whether pretty printing is possible on this platform
/// (`tui.can_pretty_print`; false only on iOS/tvOS/watchOS in Zig).
pub const CAN_PRETTY_PRINT: bool = true;
/// `vaxis.ctlseqs.unicode_set`.
pub const UNICODE_SET: &str = "\x1b[?2027h";
/// `vaxis.ctlseqs.unicode_reset`.
pub const UNICODE_RESET: &str = "\x1b[?2027l";
/// A cell color (`vaxis.Color`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Color {
#[default]
Default,
Index(u8),
Rgb(u8, u8, u8),
}
/// A cell style (the subset of `vaxis.Style` that is used).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Style {
pub fg: Color,
pub bg: Color,
pub bold: bool,
pub dim: bool,
pub italic: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Cell {
/// Never written (`vaxis.Cell{}`): rendered by moving the cursor.
Unwritten,
/// The trailing half of a wide grapheme.
Continuation(Style),
Glyph(String, Style),
}
/// The result of printing a segment (`vaxis.Window.PrintResult`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PrintResult {
pub col: u16,
pub row: u16,
pub overflow: bool,
}
/// A vaxis window of `width` x `height` cells. The listings only ever use
/// row 0 explicitly, but text that does not fit wraps onto the following
/// rows (and is rendered there), like vaxis does.
#[derive(Debug, Clone)]
pub struct Row {
width: u16,
height: u16,
rows: Vec<Vec<Cell>>,
}
/// Display width of a string (`Window.gwidth` with unicode mode on).
#[must_use]
pub fn gwidth(s: &str) -> u16 {
let w: usize = s.chars().map(|c| c.width().unwrap_or(0)).sum();
u16::try_from(w).unwrap_or(u16::MAX)
}
impl Row {
#[must_use]
pub fn new(width: u16) -> Row {
Row::with_height(width, window_size().1)
}
#[must_use]
pub fn with_height(width: u16, height: u16) -> Row {
Row {
width,
height: height.max(1),
rows: vec![vec![Cell::Unwritten; usize::from(width)]],
}
}
#[must_use]
pub fn width(&self) -> u16 {
self.width
}
/// `Window.clear`.
pub fn clear(&mut self) {
self.rows = vec![vec![Cell::Unwritten; usize::from(self.width)]];
}
fn cell_mut(&mut self, row: u16, col: u16) -> &mut Cell {
while self.rows.len() <= usize::from(row) {
self.rows
.push(vec![Cell::Unwritten; usize::from(self.width)]);
}
&mut self.rows[usize::from(row)][usize::from(col)]
}
/// `Window.printSegment` starting on row 0 at `col_offset`; wraps onto
/// the next rows when the text does not fit.
pub fn print_segment(&mut self, text: &str, style: Style, col_offset: u16) -> PrintResult {
self.print_segment_at(text, style, 0, col_offset)
}
/// `Window.printSegment` with an explicit `row_offset`.
pub fn print_segment_at(
&mut self,
text: &str,
style: Style,
row_offset: u16,
col_offset: u16,
) -> PrintResult {
let mut col = col_offset;
let mut row = row_offset;
let mut overflow = false;
let mut last: Option<(u16, u16)> = None;
for ch in text.chars() {
let w = ch.width().unwrap_or(0);
if w == 0 {
// Combine with the previous glyph.
if let Some((r, c)) = last {
if let Cell::Glyph(g, _) = self.cell_mut(r, c) {
g.push(ch);
}
}
continue;
}
let w16 = u16::try_from(w).unwrap_or(1);
if col + w16 > self.width {
row += 1;
col = 0;
}
if row >= self.height || w16 > self.width {
overflow = true;
break;
}
*self.cell_mut(row, col) = Cell::Glyph(ch.to_string(), style);
for i in 1..w16 {
*self.cell_mut(row, col + i) = Cell::Continuation(style);
}
last = Some((row, col));
col += w16;
if col >= self.width {
row += 1;
col = 0;
}
}
PrintResult { col, row, overflow }
}
/// `Window.print`: print several segments one after another.
pub fn print(&mut self, segments: &[(&str, Style)], col_offset: u16) -> PrintResult {
let mut res = PrintResult {
col: col_offset,
row: 0,
overflow: false,
};
for (text, style) in segments {
res = self.print_segment_at(text, *style, res.row, res.col);
if res.overflow {
break;
}
}
res
}
/// Write this window like `vx.prettyPrint` does (the escape sequences
/// are identical to libvaxis': synchronized update, cursor hidden, only
/// the written part of each row, unwritten gaps skipped with CUF).
///
/// `first` must be true for the first row printed by a program (vaxis
/// then also returns to column 0 and erases below).
pub fn pretty_print(&self, w: &mut dyn Write, first: bool) -> std::io::Result<()> {
if first {
w.write_all(b"\r\x1b[m\x1b[J")?;
}
w.write_all(b"\x1b[?25l\x1b[?2026h\x1b[m")?;
// Rows after the last written row are not printed.
let used_rows = self
.rows
.iter()
.rposition(|r| r.iter().any(|c| !matches!(c, Cell::Unwritten)))
.map_or(1, |i| i + 1);
let mut cursor = Style::default();
for (ri, cells) in self.rows[..used_rows].iter().enumerate() {
if ri > 0 {
w.write_all(b"\n\r")?;
}
// Trailing unwritten cells are never emitted.
let end = cells
.iter()
.rposition(|c| !matches!(c, Cell::Unwritten))
.map_or(0, |i| i + 1);
let mut skip = 0usize;
for cell in &cells[..end] {
match cell {
Cell::Unwritten => skip += 1,
Cell::Continuation(_) => {}
Cell::Glyph(g, style) => {
if skip > 0 {
write!(w, "\x1b[{skip}C")?;
skip = 0;
}
write_style_change(w, &cursor, style)?;
cursor = *style;
w.write_all(g.as_bytes())?;
}
}
}
if skip > 0 {
write!(w, "\x1b[{skip}C")?;
}
}
w.write_all(b"\r\n\x1b[m\x1b[?2026l")
}
}
fn write_color(w: &mut dyn Write, c: Color, base: u8) -> std::io::Result<()> {
// base is 3 for foreground, 4 for background.
match c {
Color::Default => write!(w, "\x1b[{base}9m"),
Color::Index(i) if i < 8 => write!(w, "\x1b[{base}{i}m"),
Color::Index(i) if i < 16 => write!(w, "\x1b[{}{}m", base + 6, i - 8),
Color::Index(i) => write!(w, "\x1b[{base}8:5:{i}m"),
Color::Rgb(r, g, b) => write!(w, "\x1b[{base}8:2:{r}:{g}:{b}m"),
}
}
/// Emit the SGR changes to go from `cursor` to `next` in vaxis' order.
fn write_style_change(w: &mut dyn Write, cursor: &Style, next: &Style) -> std::io::Result<()> {
if cursor.fg != next.fg {
write_color(w, next.fg, 3)?;
}
if cursor.bg != next.bg {
write_color(w, next.bg, 4)?;
}
if cursor.bold != next.bold {
w.write_all(if next.bold { b"\x1b[1m" } else { b"\x1b[22m" })?;
if next.dim {
w.write_all(b"\x1b[2m")?;
}
}
if cursor.dim != next.dim {
w.write_all(if next.dim { b"\x1b[2m" } else { b"\x1b[22m" })?;
if next.bold {
w.write_all(b"\x1b[1m")?;
}
}
if cursor.italic != next.italic {
w.write_all(if next.italic { b"\x1b[3m" } else { b"\x1b[23m" })?;
}
Ok(())
}
/// The terminal size in columns and rows (`vaxis.Tty.getWinsize`).
#[must_use]
pub fn window_size() -> (u16, u16) {
ratatui::crossterm::terminal::size().unwrap_or((80, 24))
}
/// Sequences vaxis writes when it is torn down (`vx.deinit`).
pub const VAXIS_DEINIT: &str = "\x1b[?25h\x1b[m\r\x1b[J";
/// A pretty-printing session on a tty, mirroring the
/// `vaxis.Tty.init` + `vaxis.init` + mode-2027 setup used by the listing
/// actions. Dropping it restores the terminal.
pub struct PrettySession<W: Write> {
out: W,
first: bool,
raw: bool,
pub width: u16,
pub height: u16,
}
impl<W: Write> PrettySession<W> {
pub fn new(mut out: W) -> std::io::Result<Self> {
let (width, height) = window_size();
// We know we are ghostty, so enable mode 2027.
out.write_all(UNICODE_SET.as_bytes())?;
Ok(PrettySession {
out,
first: true,
raw: false,
width,
height,
})
}
/// Like [`PrettySession::new`] but also puts the tty in raw mode, like
/// `vaxis.Tty.init` does (this matters for the bytes that reach the
/// terminal: with output processing off, `\r\n` stays `\r\n`).
pub fn new_raw(out: W) -> std::io::Result<Self> {
ratatui::crossterm::terminal::enable_raw_mode()?;
let mut s = Self::new(out)?;
s.raw = true;
Ok(s)
}
/// A new empty row of the window width.
#[must_use]
pub fn row(&self) -> Row {
Row::with_height(self.width, self.height)
}
/// Write a composed row.
pub fn print_row(&mut self, row: &Row) -> std::io::Result<()> {
row.pretty_print(&mut self.out, self.first)?;
self.first = false;
Ok(())
}
/// Restore the terminal state (also done on drop).
pub fn finish(&mut self) -> std::io::Result<()> {
let result = (|| {
self.out.write_all(UNICODE_RESET.as_bytes())?;
self.out.write_all(VAXIS_DEINIT.as_bytes())?;
self.out.flush()
})();
if self.raw {
self.raw = false;
ratatui::crossterm::terminal::disable_raw_mode()?;
}
result
}
}
impl<W: Write> Drop for PrettySession<W> {
fn drop(&mut self) {
if self.raw {
let _ = ratatui::crossterm::terminal::disable_raw_mode();
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn render(row: &Row, first: bool) -> String {
let mut v = Vec::new();
row.pretty_print(&mut v, first).unwrap();
String::from_utf8(v).unwrap()
}
#[test]
fn plain_text_and_skip() {
let mut row = Row::with_height(20, 5);
let r = row.print_segment("abc", Style::default(), 0);
assert_eq!(3, r.col);
row.print_segment("x", Style::default(), 7);
assert_eq!(
"\x1b[?25l\x1b[?2026h\x1b[mabc\x1b[4Cx\r\n\x1b[m\x1b[?2026l",
render(&row, false)
);
}
#[test]
fn first_row_prefix() {
let mut row = Row::with_height(5, 5);
row.print_segment("a", Style::default(), 0);
assert!(render(&row, true).starts_with("\r\x1b[m\x1b[J\x1b[?25l\x1b[?2026h\x1b[ma"));
}
#[test]
fn styles_match_vaxis_order() {
let mut row = Row::with_height(40, 5);
let red = Style {
fg: Color::Index(1),
..Style::default()
};
let dim = Style {
dim: true,
..Style::default()
};
let magenta = Style {
fg: Color::Index(5),
..Style::default()
};
let rgb = Style {
fg: Color::Rgb(1, 2, 3),
bg: Color::Rgb(4, 5, 6),
..Style::default()
};
let res = row.print(&[("su", red), (":", dim), ("x", magenta), ("c", rgb)], 0);
assert_eq!(5, res.col);
assert_eq!(
"\x1b[?25l\x1b[?2026h\x1b[m\x1b[31msu\x1b[39m\x1b[2m:\x1b[35m\x1b[22mx\x1b[38:2:1:2:3m\x1b[48:2:4:5:6mc\r\n\x1b[m\x1b[?2026l",
render(&row, false)
);
}
#[test]
fn wrapping_onto_following_rows() {
let mut row = Row::with_height(5, 3);
let r = row.print_segment("abcdefg", Style::default(), 2);
assert_eq!((4, 1), (r.col, r.row));
assert_eq!(
"\x1b[?25l\x1b[?2026h\x1b[m\x1b[2Cabc\n\rdefg\r\n\x1b[m\x1b[?2026l",
render(&row, false)
);
let mut row = Row::with_height(5, 1);
assert!(row.print_segment("abcdefg", Style::default(), 0).overflow);
}
#[test]
fn wide_chars_and_overflow() {
let mut row = Row::with_height(5, 5);
let r = row.print_segment("a\u{4e2d}b", Style::default(), 0);
assert_eq!(4, r.col);
assert_eq!(2, gwidth("\u{4e2d}"));
let r = row.print_segment("zz", Style::default(), 3);
assert!(!r.overflow);
}
}