reedi is a review editor
Current implementation status is described in
implementation_status.md
- It is a TUI tool to review unpushed commits.
- Commits are displayed (with their diff) in reverse topological order (oldest first).
- The general interface is the same as
delta, but commit messages, author, date and hunks are editable. - All hunks have syntax and diff-aware coloration (including word-level diff)
- When saving all changes are applied, as-if an interactive rebase was done (note: maybe using
jjinstead ofgit rebasewould lead to a better UX). - In review mode the closest editable area is highlighted and the cursor visible (if the cursor is on an editable area, this one, otherwise the next one)
- In visual mode (selection) the current selection is highlighted
- If the terminal size changes, the UI is updated automatically
- If available, the kitty terminal protocol is used
- Like less, it uses the alternate screen
- Only supports utf-8, if data is invalid an error message describing the issue is shown
- Non-latin scripts are expected to be properly supported (best effort, I donβt read them, especially right-to-left scripts, but itβs a goal). For RTL scripts, it is expected that a terminal that supports bidirectional algorithm is used (like GNOME VTE or Konsole).
- Supports
.gitattributes - Binary files only show a message if they differ. Non-valid utf8 files are treated the same.
- Likewise commit message, author name and other metadata must be valid utf8 or are replaced by an error message.
- Read git configuration, and re-use its settings
Performance:
- should target for 10 commits with 1000 lines diff (insertion + deletions) each as reported by
git log --stat:- 500 MB max memory usage
- 3-5 second max to open, ideally <1s
- line-diff (move excluded, so only removed/added) doesnβt have timeout, but is expected to be fast and is computed first
- move detection + word diff have a common timeout of 1s max per commit
- if move detection hit timeout, removed/added is used as fallback
- if word-diff hit timeout, the whole line will use the same result as the line-diff
- a full-file syntax highlight limit of 500ms per file before fallback (per hunk syntax color, with a 50ms timeout per hunk, then no syntax highlight)
$hash $commit_subject $author $date ($tags, $branches)
$commit_message_body
$file
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
$file
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
$hash $commit_subject $author $date ($tags, $branches)
$commit_message_body
$file
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
β $line β $before β $line β $after
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
$status_line
For exact template and colors, inspiration should be taken from delta (except for hunk header @@ -a,b +c,d @@ which is not shown).
-
$hash(short form) is non-editable -
$commit_subjectis editable -
$commit_message_bodyis editable -
$author(author name) and$date(author date, using current git date format) are editable -
$lineis non-editable and automatically updated both in the before (left) and after (right). The width of this field fits the largest number of that hunk. -
The general idea is to have a navigation mode, in which you can read the diffs, execute commands and navigate between the editable areas.
-
Editable areas can either be modified using the embedded editor that can use vim (and ideally CUA (
ctrl+c,ctrl+z,ctrl+a, β¦)/emacs/helix) style keybinding, or using an external editor ($VISUAL, orEDITOR as fallback). -
The diffs splits the screen 50/50 (right bigger if odd)
-
A status bar displays the current mode and each mode has an accent color for the background of the name of the current mode (
Review: blue,Edit: light green,Search: honey,Comment: dark green,Command: mango,Selection: purple,Move: dark red) -
After the mode, the status bar displays in gray
- default:
commit $x/$y: $short_hash $summary(x: index of the commit the cursor is on,y: number of commits displayed) - while a number is pending:
The next command will be repeated <n> times
- default:
-
In light mode, the background of the status bar (but the mode) is light gray
The software is mainly a lib, with a slim binary which only reads the config and command line options.
Saving and applying modifications:
- When starting
reedithe current git state is saved - Each time the
savecommand is executed, this new state is saved - It is always possible to undo all changes and restore the last saved state
- All modifications are immediately applied when leaving an editable area (but this state is not saved)
- When exiting
reedi, if the saved state and the current git state donβt match, there is a confirmation dialog - The default mode is the review mode
Cursor:
- When starting the application, the cursor is on the first row
- The cursor moves row by row: a line displayed on several rows (see "Line wrapping") counts as several rows
- The position of the cursor is made visible by highlighting the background of the current row (with a dim gray color)
$commit_subject,$authorand$dateare on the same line.rightandleftis the only way to edit$authorand$date, since the cursor is row-based.right/leftcan therefore select which of those 3 fields is active, and is highlighted when the cursor is on those lines.j/kdo not stop on$dateand$author(unless there is line wrapping) and reset to$commit_subjecteach time they leave and go back the line containing the subject.- When resizing, the cursor screen position stays the same. The cursor goes on the row displaying the character that was the first of its row: the one of the right half of a diff if this half displayed text on that row, otherwise the one of the left half. For example if a line was displayed on one row and is now split in 2, the cursor stays on the first one, and if a line was displayed on 2 rows, now merged, and the cursor was on the second one, the merged row goes at the position of the cursor.
Review mode:
-
Invisible characters are displayed with a replacement character (trailing whitespace are always displayed, even if the line is wrapped)
-
TODO: how is non-linear history displayed
-
j/korup/downmove the cursor one row. The screen automatically move so that there is always 5 extra rows on top/bottom (unless the beginning/end of the file is reached) -
tab/shift+taborleft/rightmove the cursor to the next/previous editable area (this does includecommit_subject,authoranddatethat are on the same line) -
g/Gmove the cursor to first/last row -
J/Korctrl+down/ctrl+upjumps to the next/previous commit -
[/]jumps to the next/previous merge conflict -
ctrl+e/ctrl+ymove the screen one line down/up (and move the cursor if it would have gone closer to the top/bottom of the screen than the scrolloff margin) -
d/uorctrl+d/ctrl+umove half a screen down/up: both the screen, likectrl+e/ctrl+y, and the cursor, likej/k, move by that amount. Nothing moves anymore when the last/first row is displayed -
numbers before a move repeat that move by this amount
-
${number}gor${number}Gmove the cursor to thenumberth row from the top (like${number}Gin vim) -
enteredit the current editable area (or the next one if on a line with only non-editable elements) -
ctrl+enter(oralt+enter) edit the current editable area (or next if on a non-editable area) with$VISUAL(or$EDITORif unset) -
centers comment mode /Cwith the external editor -
/starts search mode -
?starts search mode in reverse order -
n/pjumps to the next/previous item matching the search pattern -
ctrl+lclear the screen including all search results -
ctrl+zput the application in the background -
q/ctrl+cquit after a confirmation dialog if the currently saved state doesnβt match the current state -
Qundo all changes (after a confirmation dialog) and quit -
worctrl+ssave the current git state (by default the state when openingreedi) -
ctrl+rundo all changes and restore the saved git state after a confirmation dialog -
vstarts visual mode (available only on a hunk diff) -
maintaining
shiftand pressingup/downorj/kselect those lines (like what the visual mode does), and whenshiftis released comment mode is opened on those lines. Same behavior with click+drag with the mouse -
mstarts move mode (available only on a hunk diff). The moved span is all modified lines of the current hunk -
In visual mode,
mstarts move mode, and the span is the selected lines -
b/eadds lines at the beginning/end of the current hunk (possibly merging it with adjacent hunks) -
spacetoggle between expanding all hunks of the current file and restoring their default state -
!enters command mode
Selection (visual mode):
- selection can only be done in a single file, with a continuous range of lines. TODO: Until
b/eare implemented, it is therefore limited to a single hunk. Once those will be implemented, trying to extend a range outside a hunk expand the hunk alongside the selection. up/downorj/kextend the selection in that directionc/Candenter/ctrl+enter(oralt+enter) enters comment mode with the embedded/external editorescapereturn to review mode. This also applies toshift-selection and drag-selectionoswitch to the other side of the selection (ie. nextup/downwill expand/shrink from the other side)
Comment mode:
- Comment mode open the embedded (or external) editor.
- Each comment is associated with a span. If comment mode was opened with
c/Cit is the whole highlighted area. If it was opened after a selection, the span is the span of the selection - A non-empty comment creates a new editable area, clearing all its content removes it
- When exiting comment mode the comment file is updated. TODO: specify the format, but something
like
file:begin-end\nindented multi-line commentshould work, more probably a.typfile - When exiting
reedior saving, the human-readable comment file is updated. It should probably be a pdf generated with typst, that embeds the before/after state of the code + a few (3?) lines of context + the comment - comments can also be sent to a MCP client (with a disabled-by-default cargo feature)
Search mode:
-
Itβs a one-line editable area inside the status bar
-
It uses perl regex, in smartcase by default
-
an initial
\Iforces to ignore case,\Cto match the case,\Sto use smartcase -
an initial
\Rforces regex use,\La literal search -
both of the above settings can be combined. If the first character of the search pattern is a
\it should be doubled -
search is incremental, highlight all results, and automatically move the screen to the next/previous match (if it was started with
/or?) -
Search ignores the replacements of the invisible characters
-
entersave the current search pattern and return to review mode -
escapedoesnβt save the current search pattern, move back the cursor and screen position to what it was before the search -
up/downrestore the previous/next search pattern -
default emacs style edition works:
left/right,ctrl+w/alt+d,ctrl+b/ctrl+f,alt+b/alt+f,ctrl+d/ctrl+h, β¦ -
alt+eopen the search pattern in the external editor
Command mode:
- The interface is the same as the search mode
enterwill run$commandat the current commit (as ifexec $commandhad been used ingit rebase --interactiveafter the current commit).- The output of the command is displayed on the full screen, usual pager command (
j/k,ctrl+d/ctrl+e, β¦) should work andqorenterclose it. - If running that command modifies the working directory, the view is refreshed exactly as-if those modifications had been made using the editor (and the undo tree of the modified files is updated accordingly with a single new entry per area)
Edit mode (embedded editor):
- Invisible characters are displayed normally (no replacements character)
- Right-to-left text is reordered and joined by the terminal. The cursor moves in memory order, and is displayed on the character it is on
- Copy interacts with the system clipboard. In non-vim mode,
ctrl+ccopy - Each editable area has a separate undo tree
- This undo tree is associated with the patch id of the associated commit, and thus re-loaded automatically between sessions
ctrl+escape(oralt+escape, orescape,escapefor old terminals) exit edit mode (reset to review mode)- If the current edition mode is using vim-style keybinds, the vim mode is displayed in the status bar
authoranddateare validated. During edition, if invalid, with a red background, when exiting edit mode, if still invalid, they are reset to the value before edition (e.g. multiline or invalid date format).- When modified,
date(if valid) is re-formatted using the current git date format preference - When editing the
authorfieldauthor <email>should be used (with autocompletion for the list of recent authors), but only the author name is displayed. When entering edit mode, the author name is replaced byauthor <email> - when entering edit mode, the cursor is put at the beginning of the line the cursor was in review mode. When exiting edit mode, the cursor stays on the same line.
Merge conflicts:
- Merge conflicts are displayed with the
<<</===/>>>hunks, and are expected to be fixed - A merge conflict indicator is displayed in the status bar
[/]jumps to the next/previous merge conflict- TODO: maybe using
jjwould help for merge conflicts
Move mode:
k/jandup/downmove the current span to the previous/next commit- in move mode, all files/hunks are hidden, except for the current span and its surrounding context
Terminal protocol:
- Use kitty protocol by default, with a fallback for non-kitty-aware terminals
- A left-to-right mark (U+200E) is put after the first character of each row and after the last character of each column. Terminals reordering bidirectional text do it for the whole row, so without it, the punctuation and numbers next to a right-to-left text (like Persian) would be moved with it, even from another column
The text area of a row is the columns of that row in which the text of a line is displayed. For the
header, the commit message and the file names, it is the whole row. In a diff, there is one text
area per half: the columns after its line number column, up to (excluding) the column reserved for
the β΅ marker.
A line too wide for its text area is displayed on several rows. A continuation row is a row displaying the part of a line that did not fit on the previous row.
- Lines are broken at the break opportunities of the Unicode line breaking algorithm (UAX #14), as
late as possible: for example at spaces and after a hyphen, but not between
.and a letter. A word (the text between two break opportunities) wider than the text area is split after its last grapheme cluster that fits. - Break opportunities are computed on the original text, not on the displayed text (in which the invisible characters are replaced). The replacement character of the last character before a break is displayed at the end of the row before the break if it fits.
- Widths are display widths: wide characters (like CJK and emoji) count for 2 columns. A wide character that does not fit at the end of a row goes to the next row, and the rest of the row is left blank.
- Whitespaces that break a line:
- the spaces and U+2800 around a break are the ones ending the row before it and starting the row after it. If there is only one, it is not displayed (its column is left blank), unless it is a trailing space
- otherwise, spaces before a break are displayed with the replacement character. Spaces that do not fit the row are moved to the next row (and are displayed as plain space like usual unless they are also trailing spaces)
- U+2800 (braille pattern blank) can be part of the trailing whitespace, like spaces. When it is not, it is displayed with the replacement character when it is at the end or at the beginning of a row because of a wrap (unless it is the only space or U+2800 around that break), and displayed normally elsewhere
- tabs that are the last character before the break, are displayed as
β₯alone (without spaces up to the next tab stop). After a break they are displayed with the replacement character as usual (in all caseβ₯is displayed) - the replacement characters of non-trailing spaces, U+2800 and tabs use the dimmed whitespace color, and those of trailing spaces, U+2800 and tabs always use the trailing whitespace color (including before a break)
- other whitespaces are replaced and highlighted normally
- Tab stops are counted from the start of the text area
- Continuation rows start at the first column of the text area: the indentation of the line is not repeated.
- In a diff:
- the last column of each half is always reserved for the
β΅marker, which is displayed on each row followed by a continuation row of the same line - continuation rows display the line number column without number (
β β, as wide as the line number column of the hunk) - if one side of a line wraps on more rows than the other, the shorter side is padded with rows displaying only the line number column without number
- the last column of each half is always reserved for the
- The header, the commit message and the file names are wrapped the same way, but without
β΅marker. The header can be broken inside any field (like the subject, or betweentag:and the tag name), each part keeping the color of its field. The spaces between the fields are never replaced (nor colored as dimmed or trailing whitespace), even next to a break. - Right-to-left text is broken in memory order, like any other text, and then reordered by the terminal (see the beginning of this document). The right-to-left part of a text area can thus be displayed on either side of its left-to-right part. Rows are still left-aligned.
- A text area too narrow to display a grapheme cluster of its line (0 column, or 1 column for a wide
character) cannot be wrapped. In a diff, a clear error message is displayed in place of the
hunk. Elsewhere, or if this error message cannot be displayed either,
reediexits with a clear error message.
-
All settings can be configured in
$XDG_CONFIG_HOME/reedi/config.toml(if unset:~/.config/reedi/config.toml) -
A missing file, or a file without the preference key, means the preference is unset
-
An unreadable file, an invalid TOML file, or an invalid value makes
reediexit with a clear error message -
A warning is displayed if an unknown key is used (displaying both the faulty key and its value)
-
There is a named action for each shortcut, and all of them can be remapped (and unmapped).
-
Incremental search can be toggled off
-
Scrolloff (by default 5) can be changed
-
external editor (by default
$VISUAL, then$EDITOR) can be changed -
search case setting can be set to smartcase (default), ignorecase, and noignorecase
-
search pattern setting can be set to perl regex (default) or literal
-
embedded editor style can be set to vim (default) / emacs / CUA / helix
-
tab width is 4 spaces by default
-
theme has light, dark, and monochrome variant (terminal theme by default, dark otherwise)
-
each invisible character replacements can be configured. Default:
- tab:
β₯, followed by spaces up to the next tab stop - space, when trailing or before a wrapping break:
β - U+2800, braille pattern blank, when trailing or before or after a wrapping break:
β - U+2800, other position: no replacement
- note: unless it is trailing, the only space or U+2800 around a wrapping break is not displayed (its column is left blank), whatever its replacement
- non-ASCII spaces (U+00A0, U+1680, U+2000βU+200A, U+202F, U+205F, U+3000):
β½ - control characters (U+0000βU+001F), except tab and line feed: their control picture, from
βtoβ(U+2400 + their code) - carriage return:
β, only if a version of a file mixes line endings, or if the before and after versions of a file use different line endings - delete (U+007F):
β‘ - line separators (U+2028, U+0085) and paragraph separator (U+2029):
β€ - bidirectional text controls (U+202AβU+202E, U+2066βU+2069, U+200E, U+200F, U+061C):
β - zero-width characters (U+200B, U+200D, U+2060, U+FEFF), soft hyphen (U+00AD), U+180E, U+034F,
variation selectors (U+FE00βU+FE0F, U+E0100βU+E01EF) and tag characters (U+E0000βU+E007F):
β. The zero-width joiner (U+200D), variation selectors and tag characters are kept when they are inside a grapheme cluster (what is displayed as a single character) that starts with an emoji. The zero-width non-joiner (U+200C) is never replaced - any other invisible character (control characters U+0080βU+009F, Hangul fillers U+115F,
U+1160, U+3164 and U+FFA0, and the other characters with the Unicode property
Default_Ignorable_Code_Point):β°
- tab:
-
color of each invisible character replacements category:
- dimmed whitespace: dim gray
- trailing whitespace: bold red
- all other invisible characters: bold red
-
error color: bold bright red
-
current mode accent color:
- Review: blue
- Edit: light green
- Search: honey
- Comment: dark green
- Command: mango
- Selection: purple
- Move: dark red
-
number of context lines: 3 by default
-
Themes are the same as
deltaβs default:- dark:
Monokai Extended - light:
GitHub - monochrome: black and white only
- dark:
-
matching pair similarity:
- within a file: 0.6
- within a commit: 0.5
-
embedded bidi support for RTL language on terminal that do not support it: auto (default), enable or disabled (using
unicode-bidi+ terminal detection for auto (and config suggestion for proper RTL support if auto is used and RTL languages are used))
A hunk is a group of modified lines of a file, with the unchanged lines around them (the context),
like the @@ sections of git diff. Changes separated by few enough unchanged lines are in the
same hunk.
All the diff machinery is available as a standalone internal library: reediif
- TODO: validate public interface (as long as this note is here, there is strictly ZERO backward compatibility promise, the goal is to refine the interface)
A hunk is a list of lines, each one being either unchanged, removed, added or moved, with the line number of its first line in the before and after version of the file matching the color of the background of that line.
Word-level diff:
- All words of a non-matching pair (so pure deletion, and pure addition) have word-level diff coloration
Moved lines:
- leading and trailing whitespace is ignored when computing move (but are still displayed as word-level diff)
- Is a best effort, it is always possible that if a line is moved (with or without modifications inside the line itself), that the old line will be shown as removed and the new as added
- Moved lines are tracked across files within a single commit
- Inside a file, if the 4 lines "abcd" become "acbd":
- either b or c will be the moved line and the other unmodified
- if the inside of b is modified, and not c, then c will always be seen as unmodified and vice-versa
- Two lines are considered for move-detection only if:
- it's in a block of at least 20 alphanumeric characters of consecutive lines (like git)
- either:
- both lines are in the same file
- their respective lines immediately above or below are either:
- an exact match
- also a moved line pair
- said otherwise, if left side have lines "abcdefg", and right side "xBCdEFy", where B,C,E and F are lines with minor difference compared to b,c,e and f respectively, then d will be found by exact match, then C and E by being close enough and with an exact match neighbor, and finally B and F by being close enough with a moved-pair neighbor (cC and eE respectively).
- Moved lines are numbered using a 26-based alphabetic number (in order of appearance on the left side). Consecutive moved lines share the same letter if they are also consecutive moved lines on the other side. That letter is displayed on the line-number column of the other side.
- For a line L (on the left) matched with R (on the right):
- the right side of L and the left side of R are empty, and contain the letter of the block
- if L and R are identical (ignoring whitespace), their respective other sides are empty
- if they differ, the other side contains a copy of R and L respectively, with word diff, using the move-copy colorscheme
Duplicated moved lines:
- If moved lines have multiple valid matches, this means that there is a duplication (or deduplication, or both)
- For each line, on the copy side, each matching moved line is displayed (in the order of the original), and only the first one gets its letter
- The copy side is deduplicated
- On the original line, each word that differs from at least one of the lines of the set of the other side is a diff
- For each line of the copy side, only words that do not match the original are a diff
- Examples, with the line 5 on the left matched with the lines 10 and 20 on the right (
x5,x10andx20being their content,(copy)a line displayed with the move-copy colorscheme, andathe letter of the block).- if all three differ:
β 5 β x5 β a β x10 (copy) β β β β x20 (copy) β¦ β a β x5 (copy) β 10 β x10 β¦ β a β x5 (copy) β 20 β x20 - if
x5andx10are identical (the copy ofx10is still displayed on line 5, because the set also containsx20, which differs):β 5 β x5 β a β x10 (copy) β β β β x20 (copy) β¦ β a β β 10 β x10 β¦ β a β x5 (copy) β 20 β x20 - if
x10andx20are identical (the copy side of line 5 is deduplicated):β 5 β x5 β a β x10 (copy) β¦ β a β x5 (copy) β 10 β x10 β¦ β a β x5 (copy) β 20 β x20 - word-level diff, with
[β¦]marking a diff:β 5 β [aaa] [bbb] ccc β a β [AAA] bbb ccc (copy) β β β β aaa [BBB] ccc (copy)
- if all three differ:
- Choice between matches:
- every valid match is kept (as a duplication), so the result does not depend on the order in
which lines are processed, except when only one pair can be displayed side by side:
- several candidates in the same group of removed/added lines for the same line
- crossing pairs (removed
p,qand addedQ',P')
- in those cases, the most similar pair is displayed side by side (ties are broken from top to bottom on the left side), and the other one is displayed as moved
- every valid match is kept (as a duplication), so the result does not depend on the order in
which lines are processed, except when only one pair can be displayed side by side:
- The colorscheme for syntax highlighting follows the current git colorscheme used by delta.
- The line between commits is red (TODO: make it configurable, ideally re-use something from git colorscheme that would make sense)
$hash,$author,$date,$tagsand$branchesuse git coloration$fileand the line just below share the same color (blue)- The
β΅marker of wrapped lines has the same color as$line(blue) - Replacement characters of invisible characters have 3 categories:
- dimmed whitespace: non-trailing spaces before a line wrap, non-trailing U+2800 at the end or at the beginning of a row because of a line wrap (except the only space or U+2800 around a line wrap, which is not displayed), and non-trailing tabs (including before a line wrap)
- trailing whitespace (including U+2800, whitespace-only lines, and before a line wrap)
- all other invisible characters
Line-level diff:
- change the background color of the whole affected line (including leading and trailing whitespace)
- moves are blue, move-copy are italic + dim blue, deletion red, addition green
- monochrome uses inverted display for all, move-copy are italic
Word-level diff:
- light/dark themes use bold + another shade of the same background color
- monochrome uses underline
Syntax highlighting:
- per language syntax highlight
- The language is detected from the file name like
delta: if the path has an extension or the file name is longer than 4 bytes, the whole file name then the extension are looked up as an extension. The content of the file is never inspected. If nothing matches, the file is plain text (no highlighting).