Compare commits

...
5 Commits
Author SHA1 Message Date
swaits 3b63d67737 chore(release): 0.1.0
- Add LICENSE (MIT, copyright 2026 Stephen Waits)
- Add README.md reflecting the current TUI: two-column preview pane
  with jj show --summary, live command preview row, default-to-show
  behavior, full keybinding table, shell integration matrix, internals
  overview, attribution to oppi.li's original bash+fzf concept
- Add CHANGELOG.md (keep-a-changelog format) with 0.1.0 release notes
- Add .github/workflows/ci.yml — fmt + clippy + check on every push/PR;
  build matrix on stable + 1.85 (MSRV)
- Update Cargo.toml: refined description, repository/homepage/docs
  URLs, keywords + categories, rust-version=1.85, exclude .github and
  ux.txt from the published tarball
- Update .gitignore to also exclude ux.txt (scratch UX mockup)

Verified clean:
  cargo fmt --all -- --check
  cargo clippy --all-targets -- -D warnings
  cargo publish --dry-run --allow-dirty  (16 files, 20.6 KiB compressed)
2026-04-29 19:49:11 -06:00
swaits d2740008e9 feat(ux): more refinements
- Custom oneline template (drops timestamp + commit-id hash from the
  left pane); keeps change-id with shortest-unique-prefix highlighting,
  bookmarks, conflict/empty labels, and the description first-line.
  More room for the actually-useful columns.
- Drop the dim '── <change_id> ────' header above the preview; the
  cursor row tee (├) now carries the 'this is what you're looking at'
  signal.
- Box-char frame for the preview pane: top border (┌────) and left
  border (│), with ├ replacing │ on the cursor row to draw the eye
  from picker selection into preview content.
- Dynamic description clipping: the description fills whatever rows
  are left after the file list (ellipsis on overflow). Replaces the
  hard 3-line cap, which under-used vertical space for short file
  lists and was buggy (didn't count blank lines within description).
- Bump VIEWPORT_MIN from 15 to 20 so the preview pane has enough
  vertical room to be useful by default.
2026-04-29 18:12:40 -06:00
swaits 8605528960 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
2026-04-29 16:29:01 -06:00
swaits 5650a14d55 fix(history): drop jjf entry on cancel/error paths too
Previously, the original `jjf …` invocation only got dropped from
history on successful confirm. A cancelled or errored picker left a
useless `jjf …` entry behind. Now bash drops it unconditionally via
`_jjf_drop_last_history`; fish arms the postexec hook before the
`--emit` call so the deletion happens regardless of exit path.
2026-04-29 16:20:57 -06:00
swaits 6479867e61 feat: two-column layout with jj show preview pane
When term_cols >= 80, right side shows jj show --summary of the
currently highlighted revision (header stripped — just description and
the M/A/D file list, with description capped at 3 lines). Per-row
preview is cached, so navigating back to previously-viewed revs is
instant. Bumps the viewport floor to 15 rows (max = full terminal) so
the preview has room to be useful.
2026-04-29 16:20:57 -06:00
14 changed files with 888 additions and 98 deletions

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
check:
name: check / clippy / fmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets -- -D warnings
- run: cargo check --all-targets
build:
name: build (${{ matrix.toolchain }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
toolchain: [stable, "1.85"]
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.toolchain }}
- uses: Swatinem/rust-cache@v2
- run: cargo build --release
+2
View File
@@ -1 +1,3 @@
/target
/Cargo.lock.bak
ux.txt
+44
View File
@@ -0,0 +1,44 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] — 2026-04-29
Initial release.
### Added
- Fuzzy picker over `jj log` for any jj subcommand that accepts `-r`.
- Smart prefix matching: typing the bold-highlighted portion of a change
ID jumps to that revision instantly. Falls back to nucleo fuzzy
matching against description text.
- Multi-select via `Tab`; selections joined into `-r 'A|B|C'` revset
union.
- Two-column layout (when terminal ≥ 80 cols): picker on left, live
`jj show --summary` preview of the cursor row on right, cached per
revision.
- Live command preview row showing the exact `jj …` invocation that
Enter will run.
- Preflight check via `jj <sub> --help`: subcommands that don't accept
revisions (`status`, `log`, etc.) are rejected upfront with a
friendly message.
- `--ignore-working-copy` on the internal `jj log` call so the picker
doesn't race with prompt/editor auto-snapshot integrations.
- Shell integration via `jjf init <bash|zsh|fish|nu>`:
- rewrites shell history so up-arrow recalls the resolved `jj …`
command rather than the `jjf …` invocation,
- drops the `jjf …` entry on cancel/error so cancelled invocations
don't pollute history,
- defaults `jjf` (no args) to `jjf show`,
- passes meta-commands and bare flags through to the binary directly.
- Vim-style and emacs-style navigation: `↑↓`, `Ctrl-N/P`, `Ctrl-J/K`,
`PageUp/PageDown`, `Home/End`, `Ctrl-A/E`.
- Light-terminal detection via `COLORFGBG`: cursor-row highlight uses
reverse video on light themes (where the dark gray bg is invisible).
- Synchronized output (DEC mode 2026) around each redraw to eliminate
tearing on Kitty / WezTerm / foot / recent iTerm.
[0.1.0]: https://github.com/swaits/jjf/releases/tag/v0.1.0
+14
View File
@@ -2,6 +2,20 @@
name = "jjf"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
description = "Fuzzy revision picker for jujutsu (jj). Wraps any jj subcommand with an interactive picker over jj log, with a live jj show preview pane."
license = "MIT"
repository = "https://github.com/swaits/jjf"
homepage = "https://github.com/swaits/jjf"
documentation = "https://docs.rs/jjf"
readme = "README.md"
keywords = ["jj", "jujutsu", "vcs", "picker", "fuzzy-finder"]
categories = ["command-line-utilities", "development-tools"]
exclude = [
"/.github/",
"/ux.txt",
]
[dependencies]
crossterm = "0.29"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+166
View File
@@ -0,0 +1,166 @@
# jjf
A fuzzy revision picker for [jujutsu (jj)](https://github.com/jj-vcs/jj).
Wraps any jj subcommand with an interactive picker over `jj log`. Type to
filter, hit Enter, and `jjf` runs your command with `-r '<id>'` filled in
from the revision you picked. The right pane shows a live `jj show
--summary` of whatever's under the cursor; the bottom row shows the exact
command that's about to run.
```
❯ vx ┌────────────────────────────────
○ tovs chore(release): prepare 0.1.0 │ - Add LICENSE (MIT)
@ xnkx feat(ux): more refinements │ - Add README with install,
○ ssnu feat(ux): batch UX improvements ├──►│ usage, keybindings…
○ lyxo fix(history): drop jjf entry │
○ tqvr feat: two-column layout │ M Cargo.toml
○ yksz feat: default to 'show' │ A LICENSE
○ lsqz feat: broaden picker revset │ A README.md
○ nzwx feat: initial implementation
▶ jj describe -m 'fix typo' -r 'ss'
[8/8] type filter · ↑↓/^N^P nav · tab select · enter run · ^U clear · esc quit
```
## Install
```sh
cargo install jjf
```
Requires Rust 1.85+ (edition 2024) and `jj` 0.40+ on `PATH`.
## Usage
```sh
jjf <jj-subcommand> [args...]
```
Some examples:
```sh
jjf describe -m 'fix: validate input'
jjf abandon
jjf edit
jjf squash
jjf rebase -d main
```
`jjf` runs `jj log`, lets you fuzzy-pick a revision (or several), then
invokes the subcommand with `-r '<id>'` appended. Subcommands that don't
accept revisions (`status`, `log`, `git fetch`, etc.) are detected
upfront and rejected with a friendly message — no wasted picker cycle.
With shell integration installed (see below), `jjf` with no arguments
defaults to `jjf show` so it doubles as a quick rev browser.
## Smart prefix matching
Type the bold portion of any change ID shown in `jj log` and that
revision is highlighted instantly. Falls back to fuzzy-matching the
description text:
| Query matches | Score |
| ----------------------------------- | ------------ |
| Prefix of `change_id.shortest()` | 1,000,000 |
| Prefix of full short change ID (12) | 100,000 |
| Prefix of `commit_id.shortest()` | 10,000 |
| Prefix of full short commit ID (12) | 1,000 |
| Fuzzy match of description text | nucleo score |
Empty filter shows all revisions in original `jj log` order.
## Keybindings
| Key | Action |
| --------------------------------------------------- | ----------------------------------- |
| Any printable char | Append to filter |
| `↑` / `↓`, `Ctrl-N` / `Ctrl-P`, `Ctrl-J` / `Ctrl-K` | Move cursor |
| `PageUp` / `PageDown` | Page up / down |
| `Home` / `End`, `Ctrl-A` / `Ctrl-E` | Jump to first / last |
| `Tab` | Toggle selection on cursor row |
| `Enter` | Confirm: selected rows, else cursor |
| `Backspace`, `Ctrl-H` | Delete char from filter |
| `Ctrl-U` | Clear filter |
| `Ctrl-W` | Delete word from filter |
| `Esc`, `Ctrl-C` | Cancel |
Multi-select with `Tab` joins picks into a `-r 'A|B|C'` revset union.
Subcommands that require exactly one revision (`describe`, `edit`) will
surface jj's own error if multi-selected.
## Shell integration
Add to your shell rc:
```sh
# bash
eval "$(jjf init bash)"
# zsh
eval "$(jjf init zsh)"
# fish
jjf init fish | source
# nushell — save once, source from your config.nu
jjf init nu | save -f ~/.config/nushell/jjf.nu
# then add: source ~/.config/nushell/jjf.nu
```
With this in place:
- **History rewriting** — `jjf describe -m 'foo'` records the resolved
`jj describe -m 'foo' -r '<id>'` in your shell history rather than the
`jjf …` invocation. Up-arrow recalls the resolved command, ready to
re-run or edit.
- **`jjf` alone** runs `jjf show` so it's a quick rev browser.
- **`jjf init …`, `jjf -h`, `jjf --version`** bypass the picker and
pass through to the binary directly.
| Shell | History append | Original `jjf …` entry |
| ------- | ------------------------ | --------------------------------------------- |
| bash | `history -s` | deleted via `history -d` |
| zsh | `print -s` | suppressed via `zshaddhistory` hook |
| fish | `builtin history append` | deleted via `fish_postexec` hook |
| nushell | `history import` | remains (sqlite history; no clean delete API) |
Without shell integration, `jjf` still works — it just execs `jj`
directly and your shell history shows the original `jjf …` invocation.
## How it works
`jjf` invokes `jj log --ignore-working-copy --color=always -r 'all()'`
with a custom template that emits per-revision metadata (short change
ID, shortest unique change-ID prefix, short commit ID, shortest unique
commit-ID prefix) plus a custom oneline payload, separated by `\x1f`
sentinels.
The picker is a hand-rolled TUI rendered via direct ANSI to `/dev/tty`
(no `ratatui`, no `ansi-to-tui`). Two-column layout when the terminal
is at least 80 cols wide: picker on the left, `jj show --summary` of
the cursor row on the right (cached per revision). On Enter, the
resolved `jj <sub> [args] -r '<id>'` is either exec'd directly or
printed to stdout (in `--emit` mode, used by the shell wrappers).
The `--ignore-working-copy` flag avoids snapshotting during the picker
phase, which prevents races with prompt/editor integrations that
auto-snapshot (e.g. starship's `jj` module).
## Dependencies
Runtime crates: `crossterm`, `nucleo-matcher`, `anyhow`, `libc`. Total
transitive dep count is around 50; release binary is ~700 KB stripped.
## Acknowledgments
The idea for `jjf` came from a [bash + fzf script of the same
concept](https://oppi.li/posts/jjj/) (also discussed on
[lobste.rs](https://lobste.rs/s/exlogg/jjj)) by the author at oppi.li.
`jjf` is an independent Rust implementation — **none** of the original
script's code was viewed or used by the author — but the core insight (wrap any jj subcommand
with an fzf-style picker over `jj log`) is theirs and worth crediting.
## License
MIT — see [LICENSE](LICENSE).
+14 -7
View File
@@ -1,3 +1,9 @@
_jjf_drop_last_history() {
local _off
_off=$(history 1 2>/dev/null | awk '{print $1}')
[ -n "$_off" ] && history -d "$_off" 2>/dev/null || true
}
jjf() {
# No args → default to `show` so `jjf` alone is a rev browser.
if [ $# -eq 0 ]; then
@@ -9,14 +15,15 @@ jjf() {
return $?
fi
local _jjf_cmd
_jjf_cmd="$(command jjf --emit "$@")" || return $?
[ -n "$_jjf_cmd" ] || return 130
# Replace the original `jjf …` history entry with the resolved jj command.
local _jjf_last_offset
_jjf_last_offset=$(history 1 2>/dev/null | awk '{print $1}')
if [ -n "$_jjf_last_offset" ]; then
history -d "$_jjf_last_offset" 2>/dev/null || true
_jjf_cmd="$(command jjf --emit "$@")"
local _jjf_rc=$?
# Always drop the `jjf …` entry from history; on cancel/error there's
# nothing to replace it with, on success we add the resolved command below.
_jjf_drop_last_history
if [ $_jjf_rc -ne 0 ] || [ -z "$_jjf_cmd" ]; then
return $_jjf_rc
fi
history -s -- "$_jjf_cmd"
printf '\033[2m$\033[0m %s\n' "$_jjf_cmd"
sh -c "$_jjf_cmd"
}
+5 -3
View File
@@ -9,6 +9,10 @@ function jjf
command jjf $argv
return $status
end
# Arm the postexec hook *before* invoking the picker so the original
# `jjf …` entry is removed from history on any exit path (cancel, error,
# or success). Fish records function invocations *after* return.
set -g _jjf_pending_swap 1
set -l _jjf_cmd (command jjf --emit $argv)
set -l _jjf_status $status
if test $_jjf_status -ne 0
@@ -18,9 +22,7 @@ function jjf
return 130
end
builtin history append -- $_jjf_cmd
# Signal the postexec hook to remove the literal `jjf …` entry that fish
# is about to record (fish records function invocations *after* return).
set -g _jjf_pending_swap 1
printf '\033[2m$\033[0m %s\n' $_jjf_cmd
sh -c $_jjf_cmd
end
+1
View File
@@ -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
}
+1
View File
@@ -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"
}
+110 -1
View File
@@ -4,13 +4,23 @@ use anyhow::{Context, Result};
const SEP: u8 = 0x1f;
// Custom oneline template: drop the timestamp and commit id (rarely useful in
// the picker, and the commit-id hex steals real estate), keep change-id with
// shortest-unique-prefix highlighting, bookmarks, conflict/empty labels, and
// the first line of the description.
const TEMPLATE: &str = concat!(
"\"\\x1f\" ++ ",
"change_id.short() ++ \"\\x1f\" ++ ",
"change_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"commit_id.short() ++ \"\\x1f\" ++ ",
"commit_id.shortest().prefix() ++ \"\\x1f\" ++ ",
"builtin_log_oneline",
"(separate(\" \", \
format_short_change_id(self.change_id()), \
bookmarks, \
if(conflict, label(\"conflict\", \"conflict\")), \
if(empty, label(\"empty\", \"(empty)\")), \
description.first_line() \
))",
);
pub struct Row {
@@ -122,6 +132,105 @@ pub fn strip_ansi(bytes: &[u8]) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// Description and file-summary parts of `jj show --summary`, returned
/// separately so the renderer can fit them dynamically into the available
/// vertical space.
pub struct PreviewParts {
/// Description lines (still 4-space-indented as jj show emits them),
/// joined by `\n`. No trailing blank.
pub description: Vec<u8>,
/// File list (`M path`, `A path`, …) joined by `\n`. No leading blank.
pub files: Vec<u8>,
}
/// Run `jj show --summary` for a single revision and split the result into
/// description and file-list parts (stripping the `Commit ID:` / `Change ID:`
/// / `Author:` / `Committer:` header).
pub fn show_summary(change_id: &str) -> PreviewParts {
let out = Command::new("jj")
.args([
"--ignore-working-copy",
"show",
"--color=always",
"--summary",
"-r",
change_id,
])
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => parse_show_summary(&o.stdout),
_ => PreviewParts {
description: b"(preview unavailable)".to_vec(),
files: Vec::new(),
},
}
}
fn parse_show_summary(bytes: &[u8]) -> PreviewParts {
// Skip the metadata header — everything up to and including the first
// `\n\n` (blank line) is `Commit ID:` / `Change ID:` / `Author:` /
// `Committer:` and we don't want any of it.
let body: &[u8] = if let Some(pos) = bytes.windows(2).position(|w| w == b"\n\n") {
&bytes[pos + 2..]
} else {
bytes
};
let lines: Vec<&[u8]> = body.split(|&b| b == b'\n').collect();
// Description block: leading lines that are either 4-space-indented or
// blank. Stops at the first non-indented non-blank line, which is the
// first file entry (e.g. `M src/main.rs`).
let mut desc_end = 0;
for (i, line) in lines.iter().enumerate() {
if line.is_empty() || line.starts_with(b" ") {
desc_end = i + 1;
} else {
break;
}
}
// Trim trailing blank lines from the description block.
while desc_end > 0 && lines[desc_end - 1].is_empty() {
desc_end -= 1;
}
// Skip the first description line — it's the title, already shown on
// the left-pane oneline as `description.first_line()`. Also skip any
// blank lines immediately after it so the body starts cleanly.
let mut desc_start = if desc_end > 0 { 1 } else { 0 };
while desc_start < desc_end && lines[desc_start].is_empty() {
desc_start += 1;
}
let mut description = Vec::new();
for (i, line) in lines[desc_start..desc_end].iter().enumerate() {
if i > 0 {
description.push(b'\n');
}
// jj show indents description lines with 4 spaces — strip them so
// the preview pane doesn't waste a 4-char left margin (and so our
// wrapper has the full width to wrap into).
let stripped: &[u8] = line.strip_prefix(b" ").unwrap_or(line);
description.extend_from_slice(stripped);
}
let mut files = Vec::new();
let mut first = true;
for line in lines.iter().skip(desc_end) {
if line.is_empty() {
continue;
}
if !first {
files.push(b'\n');
}
files.extend_from_slice(line);
first = false;
}
PreviewParts { description, files }
}
pub fn supports_revisions(subcommand: &str) -> Result<bool> {
let output = Command::new("jj")
.arg(subcommand)
+1 -1
View File
@@ -89,7 +89,7 @@ fn run_pick(subcommand: &str, passthrough: &[String], emit: bool) -> Result<u8>
None
};
let tui_result = tui::run(rows);
let tui_result = tui::run(rows, subcommand, passthrough);
if let Some(saved) = saved_stdout {
restore_stdout(saved)?;
+460 -86
View File
@@ -1,25 +1,56 @@
use std::collections::HashSet;
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};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::jj::Row;
use crate::jj::{self, PreviewParts, Row};
const PREVIEW_MIN_COLS: u16 = 80;
// SGR constants
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 {
@@ -31,6 +62,7 @@ struct App {
filtered: Vec<usize>,
matcher: Matcher,
last_height: usize,
preview_cache: HashMap<usize, PreviewParts>,
}
impl App {
@@ -45,9 +77,18 @@ impl App {
filtered: (0..n).collect(),
matcher: Matcher::new(Config::DEFAULT),
last_height: 0,
preview_cache: HashMap::new(),
}
}
fn preview_for(&mut self, row_idx: usize) -> &PreviewParts {
if !self.preview_cache.contains_key(&row_idx) {
let cid = self.rows[row_idx].change_id_short.clone();
self.preview_cache.insert(row_idx, jj::show_summary(&cid));
}
self.preview_cache.get(&row_idx).unwrap()
}
fn refilter(&mut self) {
if self.filter.is_empty() {
self.filtered = (0..self.rows.len()).collect();
@@ -190,7 +231,11 @@ fn score_row(
if hit { Some(best) } else { None }
}
pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
pub fn run(
rows: Vec<Row>,
subcommand: &str,
passthrough: &[String],
) -> Result<Option<Vec<String>>> {
install_panic_hook();
let mut tty = OpenOptions::new()
.read(true)
@@ -198,53 +243,29 @@ pub fn run(rows: Vec<Row>) -> Result<Option<Vec<String>>> {
.open("/dev/tty")
.context("open /dev/tty")?;
crossterm::terminal::enable_raw_mode().context("enable raw mode")?;
// Full-screen alt-screen mode: saves the user's screen, gives us the
// whole terminal as a clean canvas, restores on exit. Resize behavior
// becomes trivial because we own the entire display.
tty.write_all(b"\x1b[?1049h").context("enter alt screen")?;
tty.write_all(HIDE_CURSOR).context("hide cursor")?;
tty.flush().ok();
let mut viewport_y: u16 = 0;
let result = (|| {
let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((80, 24));
let max_height = (term_rows / 2).clamp(8, 20);
let needed = (rows.len() as u16).saturating_add(2);
let viewport_height = needed.min(max_height).max(4);
let result = run_loop(&mut tty, rows, subcommand, passthrough);
viewport_y = allocate_viewport(&mut tty, viewport_height, term_rows)?;
tty.write_all(HIDE_CURSOR)?;
tty.flush()?;
run_loop(&mut tty, rows, viewport_y, viewport_height, term_cols)
})();
let _ = cleanup_at(&mut tty, viewport_y);
let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
let _ = tty.flush();
let _ = crossterm::terminal::disable_raw_mode();
result
}
fn allocate_viewport(tty: &mut File, viewport_height: u16, term_rows: u16) -> Result<u16> {
let (_, mut cursor_row) = crossterm::cursor::position().context("query cursor position")?;
let bottom_excl = cursor_row + viewport_height;
if bottom_excl > term_rows {
let scroll = bottom_excl - term_rows;
for _ in 0..scroll {
tty.write_all(b"\n")?;
}
tty.flush()?;
cursor_row = cursor_row.saturating_sub(scroll);
}
Ok(cursor_row)
}
fn cleanup_at(tty: &mut File, viewport_y: u16) -> std::io::Result<()> {
write!(tty, "\x1b[{};1H", viewport_y + 1)?;
tty.write_all(ERASE_TO_END)?;
tty.write_all(SHOW_CURSOR)?;
tty.flush()
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
if let Ok(mut tty) = OpenOptions::new().write(true).open("/dev/tty") {
let _ = tty.write_all(SHOW_CURSOR);
let _ = tty.write_all(b"\x1b[?1049l");
let _ = tty.flush();
}
original(info);
@@ -254,14 +275,15 @@ fn install_panic_hook() {
fn run_loop(
tty: &mut File,
rows: Vec<Row>,
viewport_y: u16,
viewport_height: u16,
term_cols: u16,
subcommand: &str,
passthrough: &[String],
) -> Result<Option<Vec<String>>> {
let mut app = App::new(rows);
app.last_height = (viewport_height as usize).saturating_sub(2); // input + hint
let (mut term_cols, mut term_rows) = crossterm::terminal::size().unwrap_or((80, 24));
// Chrome rows: input(1) + cmd_preview(1) + hint(1) = 3.
app.last_height = (term_rows as usize).saturating_sub(3);
loop {
render(tty, &app, viewport_y, viewport_height, term_cols)?;
render(tty, &mut app, term_rows, term_cols, subcommand, passthrough)?;
if !event::poll(Duration::from_millis(250)).context("event poll failed")? {
continue;
}
@@ -271,8 +293,13 @@ fn run_loop(
return Ok(action);
}
}
Event::Resize(_, _) => {
// Just redraw on next loop iteration; viewport stays the same.
Event::Resize(new_cols, new_rows) => {
// Alt-screen mode: trivial. Just update dims and redraw.
term_cols = new_cols;
term_rows = new_rows;
app.last_height = (term_rows as usize).saturating_sub(3);
let _ = write!(tty, "\x1b[2J");
let _ = tty.flush();
}
_ => {}
}
@@ -379,30 +406,91 @@ fn delete_word(s: &mut String) {
fn render(
tty: &mut File,
app: &App,
viewport_y: u16,
viewport_height: u16,
app: &mut App,
term_rows: u16,
term_cols: u16,
subcommand: &str,
passthrough: &[String],
) -> Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
let viewport_y: u16 = 0;
let viewport_height = term_rows;
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
buf.extend_from_slice(SYNC_BEGIN);
let log_height = app.last_height;
// Move to viewport top-left.
let preview_enabled = term_cols >= PREVIEW_MIN_COLS;
// Layout (1-indexed columns), with a 2-col separator between picker and box:
// cols 1..=left_w picker pane (text + padding for non-cursor;
// text only, no bg padding, for cursor)
// cols left_w+1 .. left_w+2 2-col separator
// col left_w+3 box left edge `│` (non-cursor)
// cols left_w+4 .. left_w+5 2-col gutter inside box
// cols left_w+6 .. term_cols preview content
//
// On the cursor row the picker text is NOT padded; instead a dynamic
// arrow fills from the text end through the separator and peeks into
// the box by exactly one column:
// col Y+1 ` ` (one space after text)
// col Y+2 `├` (arrow start)
// cols Y+3 .. left_w+3 `─` (variable-length body)
// col left_w+4 `►` (peeks 1 col into the box)
// col left_w+5 ` `
// col left_w+6 preview content (aligned with non-cursor)
let (left_w, right_x, right_w): (usize, u16, usize) = if preview_enabled {
let lw = (term_cols / 2).saturating_sub(3) as usize;
let rx = (lw + 2) as u16; // col where `│` lives (1-indexed): lw+3 = rx+1
let rw = (term_cols as usize).saturating_sub(lw + 2);
(lw, rx, rw)
} else {
(term_cols as usize, term_cols, 0)
};
// Preview pane layout: top border (`┌─────`) sits on the *input row*,
// not the first log row, so log rows align 1:1 with preview content
// rows. Each preview content row is `│ <line>` (3-char gutter) or
// `├─►<line>` on the cursor's row, drawing the eye from picker
// selection into preview content.
let preview_gutter_w = 3;
let preview_content_w = right_w.saturating_sub(preview_gutter_w);
let preview_rows = log_height; // top border is on input row, not log area
let preview_lines: Vec<Vec<u8>> = if preview_enabled {
if let Some(&row_idx) = app.filtered.get(app.cursor) {
let parts = app.preview_for(row_idx);
build_preview_lines(parts, preview_rows, preview_content_w)
} else {
Vec::new()
}
} else {
Vec::new()
};
// Input row (left only).
write!(buf, "\x1b[{};1H", viewport_y + 1)?;
// Input row.
buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_CYAN);
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);
buf.extend_from_slice(b"\r\n");
// Top border for the preview pane — drawn at the input row's vertical
// position so the box "starts" alongside the search field. Lives at
// col left_w+3 (= right_x + 1), past the 2-col separator from the picker.
if preview_enabled {
write!(buf, "\x1b[{};{}H", viewport_y + 1, right_x + 1)?;
buf.extend_from_slice(ERASE_TO_EOL);
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("┌".as_bytes());
for _ in 1..right_w {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
}
// Log rows.
let log_height = app.last_height;
let visible: Vec<(usize, usize)> = app
.filtered
.iter()
@@ -413,18 +501,80 @@ fn render(
.collect();
for i in 0..log_height {
if let Some(&(filt_idx, row_idx)) = visible.get(i) {
let row_y = viewport_y + 2 + i as u16; // input row + offset
write!(buf, "\x1b[{};1H", row_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let on_cursor = visible
.get(i)
.is_some_and(|&(filt_idx, _)| filt_idx == app.cursor);
let visible_used: usize = if let Some(&(filt_idx, row_idx)) = visible.get(i) {
let row = &app.rows[row_idx];
let is_cursor = filt_idx == app.cursor;
let is_selected = app.selected.contains(&row_idx);
render_row(&mut buf, row, is_cursor, is_selected, term_cols);
render_log_row(&mut buf, row, is_cursor, is_selected, left_w)
} else {
buf.extend_from_slice(ERASE_TO_EOL);
0
};
if preview_enabled {
if on_cursor {
// Dynamic arrow inline, starting at col visible_used+1:
// ` ` ├ ─×N ► ` ` then preview content at col left_w+6.
buf.push(b' ');
buf.extend_from_slice(SGR_DIM);
buf.extend_from_slice("├".as_bytes());
let dash_count = (left_w + 1).saturating_sub(visible_used);
for _ in 0..dash_count {
buf.extend_from_slice("─".as_bytes());
}
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(SGR_FG_CYAN);
buf.extend_from_slice("►".as_bytes());
buf.extend_from_slice(SGR_RESET);
buf.push(b' ');
} else {
// Position past the picker pane: 2-col sep, then `│ `, then content.
write!(buf, "\x1b[{};{}H", row_y, left_w as u16 + 1)?;
buf.push(b' ');
buf.push(b' ');
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) {
buf.extend_from_slice(line);
buf.extend_from_slice(SGR_RESET);
}
}
buf.extend_from_slice(b"\r\n");
}
// Hint row.
// Command preview row — `▶ jj describe -m 'foo' -r 'vx'` in bright green.
// Tells the user exactly what Enter will run, with the resolved `-r '<id>'`
// updated live as the cursor moves or selections toggle.
let cmd_y = viewport_y + 1 + log_height as u16 + 1;
write!(buf, "\x1b[{};1H", cmd_y)?;
buf.extend_from_slice(ERASE_TO_EOL);
let cmd = jj::command_line(subcommand, passthrough, &app.pending_ids());
let cmd_max = (term_cols as usize).saturating_sub(2); // for "▶ "
let cmd_visible: String = if cmd.chars().count() <= cmd_max {
cmd
} else if cmd_max == 0 {
String::new()
} else {
let mut s: String = cmd.chars().take(cmd_max.saturating_sub(1)).collect();
s.push('…');
s
};
buf.extend_from_slice(b"\x1b[1;32m"); // bold green
buf.extend_from_slice("▶ ".as_bytes());
buf.extend_from_slice(cmd_visible.as_bytes());
buf.extend_from_slice(SGR_RESET);
// Hint row (full width, below cmd preview).
let hint_y = cmd_y + 1;
write!(buf, "\x1b[{};1H", hint_y)?;
let counts = if app.selected.is_empty() {
format!("[{}/{}] ", app.filtered.len(), app.rows.len())
} else {
@@ -437,25 +587,179 @@ fn render(
};
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);
// Park cursor at end of input line so terminal doesn't blink in the log.
// Compute input column = column after "❯ " + filter chars + cursor block.
// Park cursor at end of input line.
let input_col = 2 + app.filter.chars().count() as u16 + 1;
write!(buf, "\x1b[{};{}H", viewport_y + 1, input_col + 1)?;
let _ = viewport_height; // currently unused; reserved for future use
let _ = viewport_height;
let _ = right_x;
buf.extend_from_slice(SYNC_END);
tty.write_all(&buf)?;
tty.flush()?;
Ok(())
}
fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool, width: u16) {
// Selected marker (always rendered at column 0, two cells)
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);
}
/// Pack description + files into `available_rows`. The file list always wins —
/// description is clipped (with a dim ellipsis appended to its last kept line)
/// to whatever room is left after the files (and a blank separator) are placed.
/// Long description/file lines are word-wrapped to `width` columns.
fn build_preview_lines(parts: &PreviewParts, available_rows: usize, width: usize) -> Vec<Vec<u8>> {
let mut desc_lines: Vec<Vec<u8>> = Vec::new();
if !parts.description.is_empty() {
for line in split_lines(&parts.description) {
desc_lines.extend(wrap_ansi(&line, width));
}
}
let mut file_lines: Vec<Vec<u8>> = Vec::new();
if !parts.files.is_empty() {
for line in split_lines(&parts.files) {
file_lines.extend(wrap_ansi(&line, width));
}
}
let blank_sep = if !desc_lines.is_empty() && !file_lines.is_empty() {
1
} else {
0
};
let max_desc = available_rows.saturating_sub(file_lines.len() + blank_sep);
let kept_desc = desc_lines.len().min(max_desc);
let clipped = desc_lines.len() > kept_desc;
let mut out: Vec<Vec<u8>> = Vec::with_capacity(kept_desc + blank_sep + file_lines.len());
for (i, line) in desc_lines.iter().take(kept_desc).enumerate() {
if clipped && i + 1 == kept_desc {
let mut last = line.clone();
last.extend_from_slice(b" \x1b[2m\xe2\x80\xa6\x1b[0m");
out.push(last);
} else {
out.push(line.clone());
}
}
if blank_sep == 1 && kept_desc > 0 {
out.push(Vec::new());
}
for line in file_lines {
out.push(line);
}
out
}
/// Word-wrap a line containing ANSI escapes to `width` visible columns.
/// Splits on spaces; a word longer than `width` overflows on its own line
/// rather than mid-word breaking. ANSI escape sequences carry through and
/// don't count toward visible width.
fn wrap_ansi(line: &[u8], width: usize) -> Vec<Vec<u8>> {
if width == 0 || visible_width(line) <= width {
return vec![line.to_vec()];
}
let mut out: Vec<Vec<u8>> = Vec::new();
let mut current: Vec<u8> = Vec::new();
let mut current_visible = 0usize;
let mut word: Vec<u8> = Vec::new();
let mut word_visible = 0usize;
let mut i = 0;
while i < line.len() {
if line[i] == 0x1b && line.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < line.len() && !line[i].is_ascii_alphabetic() {
i += 1;
}
if i < line.len() {
i += 1;
}
// Append ANSI to the in-progress word so styling travels with it.
word.extend_from_slice(&line[start..i]);
} else if line[i] == b' ' {
// End-of-word: try to fit `word` into `current`.
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
current_visible += 1;
}
current.extend_from_slice(&word);
current_visible += word_visible;
} else {
// Doesn't fit: flush current, start new line with the word.
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
current_visible = word_visible;
}
word.clear();
word_visible = 0;
i += 1;
} else {
let lead = line[i];
let len = utf8_char_len(lead);
let end = (i + len).min(line.len());
word.extend_from_slice(&line[i..end]);
word_visible += 1;
i = end;
}
}
// Final word
if word_visible > 0 {
let sep = if current_visible > 0 { 1 } else { 0 };
if current_visible + sep + word_visible <= width {
if sep == 1 {
current.push(b' ');
}
current.extend_from_slice(&word);
} else {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
current.extend_from_slice(&word);
}
}
if !current.is_empty() {
out.push(current);
}
out
}
/// Render the picker portion of one log row. Returns the 1-indexed column
/// after the rendered content (i.e. the col where the arrow's leading space
/// would be drawn for the cursor row).
///
/// Non-cursor rows are padded with normal spaces to fill `width`. Cursor rows
/// have bg highlight on actual text only and are NOT padded — the dynamic
/// arrow fills the rest.
fn render_log_row(
buf: &mut Vec<u8>,
row: &Row,
is_cursor: bool,
is_selected: bool,
width: usize,
) -> usize {
let gutter_w = 2;
let content_w = width.saturating_sub(gutter_w);
// Gutter: yellow `▎ ` if selected, else two spaces.
if is_selected {
buf.extend_from_slice(SGR_BOLD);
buf.extend_from_slice(SGR_FG_YELLOW);
@@ -465,24 +769,94 @@ fn render_row(buf: &mut Vec<u8>, row: &Row, is_cursor: bool, is_selected: bool,
buf.extend_from_slice(b" ");
}
let truncated = truncate_ansi(&row.styled, content_w);
let used = visible_width(&truncated);
if is_cursor {
buf.extend_from_slice(SGR_BG_CURSOR);
// Inject bg-restore after every embedded reset so the row's bg persists.
inject_bg_into(buf, &row.styled, SGR_BG_CURSOR);
// Pad to terminal width with bg.
let used_chars = 2 + crate::jj::strip_ansi(&row.styled).chars().count();
let width_us = width as usize;
if used_chars < width_us {
for _ in 0..(width_us - used_chars) {
buf.push(b' ');
}
}
let bg = cursor_bg();
buf.extend_from_slice(bg);
inject_bg_into(buf, &truncated, bg);
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
gutter_w + used
} else {
buf.extend_from_slice(&row.styled);
buf.extend_from_slice(&truncated);
buf.extend_from_slice(SGR_RESET);
buf.extend_from_slice(ERASE_TO_EOL);
let pad = content_w.saturating_sub(used);
for _ in 0..pad {
buf.push(b' ');
}
width
}
}
fn split_lines(bytes: &[u8]) -> Vec<Vec<u8>> {
bytes.split(|&b| b == b'\n').map(|l| l.to_vec()).collect()
}
/// Count visible chars (post-CSI-strip) in a UTF-8 byte slice.
fn visible_width(bytes: &[u8]) -> usize {
let mut w = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
let lead = bytes[i];
let len = utf8_char_len(lead);
i = (i + len).min(bytes.len());
w += 1;
}
}
w
}
/// Truncate `bytes` so visible (non-CSI, non-continuation) chars fit in `max`.
/// All CSI sequences are preserved; only printable chars are counted.
fn truncate_ansi(bytes: &[u8], max: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
let mut visible = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'[') {
let start = i;
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
out.extend_from_slice(&bytes[start..i]);
} else {
if visible >= max {
break;
}
let lead = bytes[i];
let len = utf8_char_len(lead);
let end = (i + len).min(bytes.len());
out.extend_from_slice(&bytes[i..end]);
visible += 1;
i = end;
}
}
out
}
fn utf8_char_len(lead: u8) -> usize {
if lead < 0x80 || (0x80..0xc0).contains(&lead) {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
}
}
+10
View File
@@ -0,0 +1,10 @@
❯ ┌──────────────────────────────────────────────────────────────
○ tovs chore(release): prepare 0.1.0 for crates.io │ Mirrors the original bash+fzf script's behavior — `jjf`
@ xnkx feat(ux): more refinements │ alone becomes
○ ssnu feat(ux): batch UX improvements │ a quick rev browser. Implemented in shell wrappers (so the
○ lyxo fix(history): drop jjf entry on cancel/error paths t │ --emit
○ tqvr feat: two-column layout with jj show preview pane │ history-rewrite path still applies); the binary itself still
○ yksz feat: default to 'show' subcommand when jjf is invok ├─► prints
○ lsqz feat: broaden picker revset to all() to expose every │ usage on bare invocation, for explicit invocations.
○ nzwx feat: initial implementation of jjf │
◆ zzzz (empty) │ M src/init/bash.sh