From 86055289603203ea40aa7b74334a8876609713b2 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 29 Apr 2026 16:23:39 -0600 Subject: [PATCH] feat(ux): batch UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverse-video caret in input row (was cyan █ block — collided with the cyan ❯ prompt and read as 'unfocused-pane cursor') - Synchronized-output brackets (DEC mode 2026) around each redraw — eliminates tearing on Kitty/WezTerm/foot/recent iTerm; ignored elsewhere - Two-tone hint row (keys at normal weight, labels dim) gives the eye a scan anchor - Dim header above preview pane (── ────) anchors the right column to the cursor row's revision - Light-terminal detection via COLORFGBG: switch cursor-row highlight from indexed-237 dark-gray bg to reverse-video on themes where the dark bg would be invisible - Shell wrappers echo the resolved 'jj …' command (dim '$' prefix) before running it, so it's visible in scrollback --- src/init/bash.sh | 1 + src/init/fish.fish | 1 + src/init/nu.nu | 1 + src/init/zsh.sh | 1 + src/tui.rs | 103 ++++++++++++++++++++++++++++++++++++--------- 5 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/init/bash.sh b/src/init/bash.sh index 7dfaf27..c4b4b13 100644 --- a/src/init/bash.sh +++ b/src/init/bash.sh @@ -24,5 +24,6 @@ jjf() { return $_jjf_rc fi history -s -- "$_jjf_cmd" + printf '\033[2m$\033[0m %s\n' "$_jjf_cmd" sh -c "$_jjf_cmd" } diff --git a/src/init/fish.fish b/src/init/fish.fish index 827b53a..96b0cc7 100644 --- a/src/init/fish.fish +++ b/src/init/fish.fish @@ -22,6 +22,7 @@ function jjf return 130 end builtin history append -- $_jjf_cmd + printf '\033[2m$\033[0m %s\n' $_jjf_cmd sh -c $_jjf_cmd end diff --git a/src/init/nu.nu b/src/init/nu.nu index f1b3b94..b739b41 100644 --- a/src/init/nu.nu +++ b/src/init/nu.nu @@ -12,5 +12,6 @@ def --env jjf [...args] { let cmd = ($result.stdout | str trim) if ($cmd | is-empty) { exit 130 } [{command: $cmd, start_timestamp: (date now | format date "%+")}] | history import + print $"(ansi grey)$(ansi reset) ($cmd)" ^sh -c $cmd } diff --git a/src/init/zsh.sh b/src/init/zsh.sh index 025a77a..997751a 100644 --- a/src/init/zsh.sh +++ b/src/init/zsh.sh @@ -12,6 +12,7 @@ jjf() { _jjf_cmd="$(command jjf --emit "$@")" || return $? [ -n "$_jjf_cmd" ] || return 130 print -s -- "$_jjf_cmd" + printf '\033[2m$\033[0m %s\n' "$_jjf_cmd" sh -c "$_jjf_cmd" } diff --git a/src/tui.rs b/src/tui.rs index e023c9c..1d29f22 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::Write; +use std::sync::OnceLock; use std::time::Duration; use anyhow::{Context, Result}; @@ -16,13 +17,42 @@ const VIEWPORT_MIN: u16 = 15; const SGR_RESET: &[u8] = b"\x1b[0m"; const SGR_RESET_SHORT: &[u8] = b"\x1b[m"; const SGR_DIM: &[u8] = b"\x1b[2m"; +const SGR_NORMAL_WEIGHT: &[u8] = b"\x1b[22m"; const SGR_BOLD: &[u8] = b"\x1b[1m"; const SGR_FG_CYAN: &[u8] = b"\x1b[36m"; const SGR_FG_YELLOW: &[u8] = b"\x1b[33m"; -const SGR_BG_CURSOR: &[u8] = b"\x1b[48;5;237m"; const ERASE_TO_EOL: &[u8] = b"\x1b[K"; const ERASE_TO_END: &[u8] = b"\x1b[J"; const SHOW_CURSOR: &[u8] = b"\x1b[?25h"; + +// Synchronized output (DEC Mode 2026): Kitty / WezTerm / foot / recent iTerm +// honor these brackets and present the buffered redraw atomically; other +// terminals ignore them. Eliminates tearing on supported terminals. +const SYNC_BEGIN: &[u8] = b"\x1b[?2026h"; +const SYNC_END: &[u8] = b"\x1b[?2026l"; + +/// The cursor-row "highlight" SGR. Dark gray bg on dark themes, reverse +/// video on light themes (since indexed-color 237 disappears on light). +fn cursor_bg() -> &'static [u8] { + static CACHE: OnceLock<&'static [u8]> = OnceLock::new(); + CACHE.get_or_init(|| { + if is_light_terminal() { + b"\x1b[7m" + } else { + b"\x1b[48;5;237m" + } + }) +} + +fn is_light_terminal() -> bool { + if let Ok(v) = std::env::var("COLORFGBG") + && let Some(bg_str) = v.split(';').next_back() + && let Ok(bg) = bg_str.parse::() + { + return bg == 7 || bg == 15; + } + false +} const HIDE_CURSOR: &[u8] = b"\x1b[?25l"; struct App { @@ -398,6 +428,7 @@ fn render( term_cols: u16, ) -> Result<()> { let mut buf: Vec = Vec::with_capacity(16 * 1024); + buf.extend_from_slice(SYNC_BEGIN); let log_height = app.last_height; let preview_enabled = term_cols >= PREVIEW_MIN_COLS; @@ -411,14 +442,26 @@ fn render( }; // Pre-compute preview lines (cached) for the cursor row, before borrowing app immutably. - let preview_lines: Vec> = if preview_enabled { + // The first preview row is a dim header anchoring the pane to the cursor's change ID. + let (preview_header, preview_lines): (Option>, Vec>) = if preview_enabled { if let Some(&row_idx) = app.filtered.get(app.cursor) { - split_lines(app.preview_for(row_idx)) + let cid = if app.rows[row_idx].change_id_prefix.is_empty() { + app.rows[row_idx].change_id_short.clone() + } else { + app.rows[row_idx].change_id_prefix.clone() + }; + let label = format!("── {cid} "); + let dashes = right_w.saturating_sub(label.chars().count()); + let header = format!( + "\x1b[2m{label}{}\x1b[0m", + "─".repeat(dashes) + ); + (Some(header.into_bytes()), split_lines(app.preview_for(row_idx))) } else { - Vec::new() + (None, Vec::new()) } } else { - Vec::new() + (None, Vec::new()) }; // Input row (left only). @@ -428,9 +471,9 @@ fn render( buf.extend_from_slice("❯ ".as_bytes()); buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(app.filter.as_bytes()); - buf.extend_from_slice(SGR_FG_CYAN); - buf.extend_from_slice("█".as_bytes()); - buf.extend_from_slice(SGR_RESET); + // Reverse-video caret on a single space — matches fzf/lazygit/telescope + // convention; survives any colorscheme without competing with the cyan ❯. + buf.extend_from_slice(b"\x1b[7m \x1b[27m"); buf.extend_from_slice(ERASE_TO_EOL); // Log rows. @@ -458,13 +501,19 @@ fn render( if preview_enabled { write!(buf, "\x1b[{};{}H", row_y, right_x + 1)?; buf.extend_from_slice(ERASE_TO_EOL); - buf.extend_from_slice(SGR_DIM); - buf.extend_from_slice("│ ".as_bytes()); - buf.extend_from_slice(SGR_RESET); - if let Some(line) = preview_lines.get(i) { - let clipped = truncate_ansi(line, right_w.saturating_sub(2)); - buf.extend_from_slice(&clipped); + if i == 0 { + if let Some(h) = &preview_header { + buf.extend_from_slice(h); + } + } else { + buf.extend_from_slice(SGR_DIM); + buf.extend_from_slice("│ ".as_bytes()); buf.extend_from_slice(SGR_RESET); + if let Some(line) = preview_lines.get(i - 1) { + let clipped = truncate_ansi(line, right_w.saturating_sub(2)); + buf.extend_from_slice(&clipped); + buf.extend_from_slice(SGR_RESET); + } } } } @@ -482,11 +531,16 @@ fn render( app.selected.len() ) }; + // Two-tone hint: keys at normal weight, labels dim. Gives the eye an anchor + // to scan by without removing any information. buf.extend_from_slice(SGR_DIM); buf.extend_from_slice(counts.as_bytes()); - buf.extend_from_slice( - "type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit".as_bytes(), - ); + buf.extend_from_slice(b"type filter"); + write_hint_pair(&mut buf, "↑↓/^N^P".as_bytes(), b"nav"); + write_hint_pair(&mut buf, b"tab", b"select"); + write_hint_pair(&mut buf, b"enter", b"run"); + write_hint_pair(&mut buf, b"^U", b"clear"); + write_hint_pair(&mut buf, b"esc", b"quit"); buf.extend_from_slice(SGR_RESET); buf.extend_from_slice(ERASE_TO_EOL); @@ -500,11 +554,21 @@ fn render( write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?; let _ = viewport_height; + buf.extend_from_slice(SYNC_END); tty.write_all(&buf)?; tty.flush()?; Ok(()) } +fn write_hint_pair(buf: &mut Vec, key: &[u8], label: &[u8]) { + buf.extend_from_slice(" · ".as_bytes()); + buf.extend_from_slice(SGR_NORMAL_WEIGHT); + buf.extend_from_slice(key); + buf.extend_from_slice(SGR_DIM); + buf.push(b' '); + buf.extend_from_slice(label); +} + fn render_log_row(buf: &mut Vec, row: &Row, is_cursor: bool, is_selected: bool, width: usize) { let gutter_w = 2; let content_w = width.saturating_sub(gutter_w); @@ -523,8 +587,9 @@ fn render_log_row(buf: &mut Vec, row: &Row, is_cursor: bool, is_selected: bo let used = visible_width(&truncated); if is_cursor { - buf.extend_from_slice(SGR_BG_CURSOR); - inject_bg_into(buf, &truncated, SGR_BG_CURSOR); + let bg = cursor_bg(); + buf.extend_from_slice(bg); + inject_bg_into(buf, &truncated, bg); let pad = content_w.saturating_sub(used); for _ in 0..pad { buf.push(b' ');