feat(ux): batch UX improvements
- 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 (── <change_id> ────) 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
This commit is contained in:
5 files changed
+88
-19
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
+84
-19
@@ -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::<u8>()
|
||||
{
|
||||
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<u8> = 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<Vec<u8>> = 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<u8>>, Vec<Vec<u8>>) = 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<u8>, 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<u8>, 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<u8>, 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' ');
|
||||
|
||||
Reference in new issue
Block a user