nib is a small, fast, distraction-free terminal text editor in the spirit of nano. Built first for its author's own notes and config editing; published for anyone who wants it. Reliability beats features: nib must never lose or corrupt a file.
Stack: Rust · crossterm (terminal) · ropey (buffer) · arboard (clipboard) · TOML config.
Platforms: Debian, Ubuntu, Fedora on x86_64 (primary terminal: Ghostty) · Windows 10/11 x64 (Windows Terminal and conhost).
Out of scope: plugins/extensions · LSP/autocomplete · split panes · file tree · config scripting · regex search · multiple buffers/tabs · block selection · multiple cursors · hard wrap · notes sync (parked as a separate future tool, not part of the editor).
nib 0.1.0 todo.md 2026-10-27 14:02
<text area>
┌───────────────┐
│ Saved todo.md │
└───────────────┘
<status line — prompts; blank otherwise>
- Title bar (nano layout):
nib <version>left · file name centre · state right. Right side shows exactly one of:- the file's last-modified time on disk (
YYYY-MM-DD HH:MM) when the buffer is clean; Modifiedfrom the first change until the next save;New Filefor a buffer never saved.[Read-only]is appended when the file is not writable.
- the file's last-modified time on disk (
- Text area: soft wrap on by default; line numbers off by default. Both toggle via palette and config. Line numbers sit in a gutter as wide as the last line number's digits plus one space, drawn right-aligned in dark grey on the first row of each line (wrapped continuation rows get a blank gutter); the text gets the rest of the width. A click in the gutter puts the cursor at the start of that row. Turning wrap on or off keeps the cursor on screen.
- Status line: empty by default; prompts appear here.
- Messages: every message nib shows (errors and confirmations alike; "says" and "status" in
this spec mean this) appears in a small box floating at the bottom right of the text area, on
the three rows just above the status line, its right edge one column in from the screen's. The
column on each side of the box is blanked on those rows, so a wide character cut by the box
never leaves half of itself on screen.
Box-drawing border in bright black, text in the default colour, one space of padding each side.
It fades 4 s after it appeared; a new message replaces the old one and restarts the 4 s; keys
do not dismiss it early. A message wider than the screen allows (a 1-column margin each side,
so
width - 6cells of text) is cut and ends in…. On a screen under 12 columns or 6 rows, the message goes on the status line instead. So does a message whose box would cover the text cursor, so the cursor and the text being typed are never hidden. A message that falls back while a prompt holds the status line is not shown (the prompt wins). The palette is drawn over the box.- Announced actions (nothing else, so typing and movement stay quiet):
Save / Save As
Saved <name>· OpenOpened <name>, orNew file <name>for a file not on disk yet (opening at startup says nothing) · Copy / Cut / PasteCopied <n>,Cut <n>,Pasted <n>, where<n>is1 line/<k> lineswhen the text holds a line break (a trailing partial line counts), else1 char/<k> chars(grapheme clusters) · Paste with an empty clipboardNothing to paste· Undo / RedoUndo/Redo, orNothing to undo/Nothing to redo· Toggle WrapWrap on/Wrap off· Toggle Line NumbersLine numbers on/Line numbers off· Select AllSelected all. Bracketed paste (the terminal's own paste) is typing and is not announced.
- Announced actions (nothing else, so typing and movement stay quiet):
Save / Save As
- No shortcut bar. The command palette replaces it.
- Colours: the terminal's 16 ANSI colours only, so nib follows the terminal theme.
| Invocation | Result |
|---|---|
nib |
Empty buffer. First save asks for a name. |
nib name (new) |
Buffer for name.txt. |
nib name.md / nib .rc / nib Makefile (new) |
Name kept as typed (has extension, starts with a dot, or is a known extensionless name). |
nib path (existing) |
Opens exactly that file; never renamed. |
nib path:42 |
Opens at line 42. Only a trailing :<digits> counts, and only when no file with the full name exists (keeps C:\x.md safe). |
nib --version / -V |
Print nib X.Y.Z and exit. |
nib --help / -h |
Print help and exit: usage, arguments, options, examples, the main keys as currently bound (from the user's config), and the config and recovery paths for this system. |
- The
.txtrule applies only to new files: no extension and not starting with.and not one ofMakefile,Dockerfile,LICENSE,README(matched case-insensitively). Only the final path component is checked, sonotes.d/todobecomesnotes.d/todo.txt. Same rule for the first-save name prompt. - New files are created on first save, never on launch.
- One file at a time. Opening another file (Ctrl+O) replaces the buffer; unsaved changes prompt
Save? (Y)es / (N)o / (C)ancel. Quit prompts the same way. Yes saves first (asking for a name when unnamed); if that save fails or is declined, the current buffer stays. - Open then asks
Open:. The name (trimmed; empty cancels) resolves like the command line,:Nsuffix included. A missing file gives an emptyNew Filebuffer, created on first save. Opening resets undo history, cursor, scroll, indent detection and any find. A file nib cannot edit leaves the current buffer untouched and says why:<name> is not a UTF-8 text file,<name> is a directory,<name> is not a regular file. A file over 50 MB asks first, as at startup; N or Esc keeps the current buffer. - Save As asks
Save as:prefilled with the current path (empty when unnamed), with the same name rules and<name> exists. Replace? (y/N)confirm as the first-save prompt; the buffer's own path needs no confirm. On success the buffer takes the new path and keeps its undo history. An existing target that is not writable is refused. - Go to line (Ctrl+G) asks
Line:; 1-based, surrounding spaces ignored, 0 and past-the-end clamp to the first/last line. Anything else saysNot a line numberand leaves the cursor. The cursor lands at the start of the line with no selection.
All bindings are remappable in the config. Defaults:
| Action | Key | Action | Key |
|---|---|---|---|
| Command palette | Ctrl+P (hidden backup: F1) | Undo / Redo | Ctrl+Z / Ctrl+Y, Ctrl+Shift+Z |
| Save | Ctrl+S | Copy / Cut / Paste | Ctrl+C / Ctrl+X / Ctrl+V |
| Save As | palette only | Cut line (no selection) | Ctrl+X |
| Open file | Ctrl+O | Select all | Ctrl+A |
| Quit | Ctrl+Q | Select | Shift+arrows, Shift+Home/End, mouse drag |
| Find; next / prev | Ctrl+F; Enter / Shift+Enter | Word move (select) | Ctrl+←/→ (+Shift) |
| Replace | Ctrl+R | File start / end | Ctrl+Home / Ctrl+End |
| Go to line | Ctrl+G | Delete word | Ctrl+Backspace / Ctrl+Delete |
| Toggle wrap / line numbers | palette only | Cancel prompt | Esc |
Rationale: Ctrl+Shift+P is taken by both Ghostty and Windows Terminal; Ctrl+P is free in both
(verified: Ghostty Config.zig defaults, Windows Terminal 1.24 defaults.json). Replace is Ctrl+R
because Ctrl+H is Backspace in legacy terminals. Save As has no key because Ctrl+Shift+S is
indistinguishable from Ctrl+S in some terminals. XON/XOFF flow control is disabled in raw mode
so Ctrl+S / Ctrl+Q reach nib.
- Selection: one linear selection, any number of lines, drawn in reverse video (a selected line end shows as one reversed space). Typing, Backspace, Delete and word-delete replace/remove exactly the selection. Plain Left/Right collapse to the selection's start/end. An empty selection counts as none.
- Mouse: click places cursor · drag selects · double-click (second press within 400 ms on the same cell) selects the word, punctuation or whitespace run under it · wheel scrolls 3 rows without moving the cursor. Clicks on the title or status row, other buttons and motion are ignored.
- Find: Ctrl+F opens
Find:; plain text, smart case (insensitive unless the query has a capital; a char whose lowercase is several chars, likeİ, matches only itself). Every edit of the query searches again from where the cursor was when Find opened (forward, wrapping) and selects the match; other matches on screen are underlined, the current one is reverse video. Enter selects the next match, Shift+Enter the previous (some legacy terminals cannot tell Shift+Enter from Enter); both wrap with statusSearch wrapped. No match: statusNot found, selection cleared, cursor back at the start. Esc closes and restores the cursor and selection. Any other key bound to a command (arrows, Ctrl+S, ...) closes Find, keeping the match selected, and then runs. An empty query highlights nothing. - Replace: Ctrl+R:
Find:thenReplace with:, then for each match from the cursor onward (wrapping, ending where it began):Replace? (R)eplace / (A)ll / (S)kip / Esc. R replaces and moves on (one undo step each), S skips, A replaces all remaining matches as one undo step, Esc stops and keeps what was replaced. The search resumes after the inserted text, so a replacement containing the query cannot loop. StatusReplaced Nwhen any were replaced. - Undo/redo: in memory per session; typing grouped by word/pause.
- Paste: bracketed paste; a paste is one undo step and bypasses auto-indent and list continuation. In a bracketed paste,
\r\nand lone\rboth become newlines (some terminals send\r). - Auto-indent: Enter copies the current line's leading whitespace, up to the cursor column (Enter inside the indentation does not duplicate it). With a selection, Enter replaces it and uses the line where the selection starts. Enter is one undo step.
- List continuation: Enter on
- x,* x,1. x,- [ ] xstarts the next item (numbers increment, checkboxes reset to[ ]); Markers are recognised after the indent;* [ ] xcounts too, and a checkbox[x]/[X]resets to[ ]. Numbers drop leading zeros (09.becomes10.). Continuation applies only when the cursor is at or after the end of the marker; Enter inside the marker is a plain Enter. Enter on an empty item (only indent and marker) clears the line and leaves the cursor at column 0 without adding a line (one undo step). - Tab: inserts spaces up to the next multiple of the indent width, measured in display columns (so tabs and wide characters count correctly). Width is detected once at load from the first 10,000 lines (most common step, 2 to 8, between consecutive non-blank lines (an unindented line counts as 0; a tab-indented line resets it)), else 4 (2 for
.json). Files with more tab-indented than space-indented lines, anyMakefile(any case) and*.mkfiles use a real tab. With a selection, Tab replaces it with one indent unit. - Syntax highlighting: small hand-written highlighters for Markdown and JSON only, picked by extension in any case:
.md/.markdownis Markdown,.jsonis JSON, anything else is plain.- Markdown: headings (1 to 6
#then a space or the line end, after up to 3 spaces; the whole line); emphasis*x*,**x**,_x_,__x__(the opener touches text on its right, the closer on its left;_inside a word, as insnake_case, is not emphasis); inline code (closed by a backtick run of the same length); fenced code blocks (a line starting with three backticks after up to 3 spaces opens or closes a block; fence lines and everything between are code); links[text](target); list markers-,*,1.after any indent (the marker, not its space). Inside code nothing else is recognised. - JSON: keys (strings followed by
:), other strings (an unclosed string runs to the line end), numbers,true/false/null, and the punctuation{}[]:,. - Colours: headings and literals magenta, emphasis and numbers yellow, code and strings green, links and keys blue, list markers cyan, punctuation bright black. Selected cells drop their colour (plain reverse video); underlined find matches keep it.
- Only the first 10,000 characters of a line are highlighted, so very long lines stay fast. Fence state for lines above the screen is cached per line and rescanned only from the first edited line.
- Markdown: headings (1 to 6
- Command palette: lists every command (all but the palette itself, movement, selection and editing included) with its current key; fuzzy search; driven by the same command registry as the keymap. A command run from the palette behaves exactly like its key, once the palette has closed.
- Box at the top centre under the title bar (60 columns at most, 10 rows at most), a
> queryline first; the selected row is reversed; each row shows the name and its current keys, right-aligned. Under 20 columns or 5 rows the border is dropped. Fuzzy search is a case-insensitive subsequence: +10 per consecutive character, +8 at a word start, -1 per skipped character; ties sort by name. Typing and Backspace edit the query; Up/Down (and Ctrl+P/Ctrl+N) move the selection with wrap-around; Enter runs the command (after closing the palette); Esc closes. Mouse and paste are ignored while it is open. - Find Next / Find Previous (palette only) repeat the last find query from the cursor, wrapping; they say
Not found, orNo previous searchwhen there is none.
- Box at the top centre under the title bar (60 columns at most, 10 rows at most), a
- Clipboard: system clipboard when a display exists (
DISPLAY/WAYLAND_DISPLAYset, or Windows); otherwise OSC 52 (SSH, headless, TTY). Copy with no selection does nothing and saysNothing selected. OSC 52 payloads over 100 KB of base64 are not sent (statusCopied (too large for terminal clipboard)); the copy stays inside nib. - Rendering: draw only the visible region; stays fast on very long lines and terminal resizes.
- Unicode: cursor and wrapping respect grapheme clusters and display width (CJK, emoji).
- Encoding: UTF-8 only; a UTF-8 BOM is preserved.
- Line endings: each file keeps its own (LF/CRLF); new files use LF on every OS (configurable).
- Final newline: preserved if present; new files get one.
- Atomic save: write temp file in the same directory →
fsync→ rename over the target. Symlinks are followed (the real file is replaced, the link stays); mode and owner are preserved. - Recovery file: written every few seconds while dirty; offered on next open after a crash.
It records the owning PID; a second nib on the same file offers read-only, or recovery if that PID is gone.
- Location:
%LOCALAPPDATA%\nib\recovery\(Windows),~/.local/share/nib/recovery/(Linux), created when first needed. A named file uses<hash>.rec, where the hash is FNV-1a 64 of the absolute path's bytes as 16 lowercase hex digits (not canonicalised: new files do not exist yet). An unnamed buffer usesuntitled-<pid>.rec, oruntitled-<pid>-<n>.rec(first free n) when a crashed nib with the same pid left one. - Format:
nib-recovery 1,pid <n>,path <path>, an empty line, then the exact bytes a save would write. A file that does not parse (or whose text is not UTF-8) counts as no recovery and is never deleted. nib only ever writes over or deletes a recovery or lock file that it wrote itself (header pid is its own); if a named file's.recalready exists and is not its own (e.g. Save As onto a path with older unsaved changes), no recovery is written for that buffer and the status saysRecovery file not written: <path> holds older unsaved changesonce. - Written through the atomic save path while dirty, at most every 3 s and only when the text changed since the last write. A failed write says
Recovery file not written: <error>once (again only after a write has worked) and editing goes on. - Deleted after a successful save, on quit, when undo returns to the saved state, and when Open replaces the buffer (after it was saved or the user chose not to). Kept after a failed save. If nib is about to exit on a terminal error with a dirty buffer, it writes the file first.
- Lock: while a named file is open, nib keeps
<hash>.lock(same header, no text) in the recovery directory, created only if absent (a lock of a nib that is gone, of this nib, or one that does not parse is replaced; a running nib's lock is never touched, and finding one makes the buffer a read-only copy); it goes on quit and on Open-away. A.lockor.recwhose PID is running and is not this nib's means the file is open elsewhere. PID check:/proc/<pid>on Linux,taskliston Windows; other systems count every PID as gone. - On open (startup and Open): if another nib has the file,
already open in another nib (pid N) — open read-only? (Y/n); Enter or Y opens it read-only ([Read-only], Save refused, Save As works); this nib takes no lock and writes no recovery for it, but does for a Save As target. N or Esc cancels the open: at startup nib exits (code 0, printing the reason to stderr if there was one), on Open the current buffer stays. - Otherwise, if a recovery file is left from a nib that is gone:
Recover unsaved changes from YYYY-MM-DD HH:MM? (Y/n)(the recovery file's time). Y loads its text as a modified buffer for the same path; the file on disk is untouched until a save, and undo cannot reach a state that counts as saved. N moves it to<hash>.rec.bak(replacing an older one), opens the file from disk and saysRecovery kept at <path>. Enter means Y. Esc cancels the open, as above; if the rename fails, the open is cancelled too, withRecovery file not moved: <error>. - Save As onto a file another running nib has open is refused with
<name> is open in another nib (pid N); the buffer stays modified and nothing is written. - These two prompts are the only choices where Enter means yes.
- Unnamed buffers are never offered at startup (there is no path to match); their
untitled-*.recfiles stay on disk after a crash for manual recovery.
- Location:
- External change: detected before save: if the file's modification time differs from when
it was read or last saved, Save asks
File changed on disk — overwrite? (y/N); Y writes, N, Esc or Enter sayNot savedand keep the buffer modified. Save As to another path does not ask. - Panic: a panic hook restores the terminal (raw mode off, main screen back) before exit.
| Situation | Behaviour |
|---|---|
| Binary or non-UTF-8 file | Message screen (at startup): <name> is not a UTF-8 text file, centred; exit (code 0) on Enter / Space / Esc. File untouched. |
| Path is a directory | Same as above, with <name> is a directory. |
| FIFO, device or socket | Same as above, with <name> is not a regular file; it is never read. |
| File > 50 MB | File is 2.1 GB — open anyway? (y/N) (1 decimal, KB/MB/GB/TB with 1024 base). N or Esc at startup exits (code 0). |
| Already open in another nib | already open in another nib (pid N) — open read-only? (Y/n); see Recovery file above. |
| No write permission | [Read-only] in title; editing allowed; Save says Cannot save: permission denied — use Save As; Save As works. Permission is checked again on Save, so a fixed file saves. No built-in sudo. |
| Disk full / write error | Original untouched; Save failed: <error>; buffer and recovery file kept. |
| File deleted while open | Save recreates it. |
| Broken config | Defaults used; warning shown as a message. |
TOML at ~/.config/nib/config.toml (Linux) or %APPDATA%\nib\config.toml (Windows).
Holds keymap overrides and settings: wrap, line numbers, tab width, new-file line ending.
Under sudo, root's config is used. Read once at startup. Every entry is optional; a missing
file means all defaults. Schema (values shown are the defaults):
wrap = true # soft wrap
line_numbers = false
tab_width = 4 # 1 to 8; unset by default. When set, replaces the detected indent
# width; files that indent with real tabs keep tabs, shown this wide
line_ending = "lf" # "lf" | "crlf"; for new files only (existing files keep theirs)
[keys]
save = "ctrl+s" # one chord
redo = ["ctrl+y", "ctrl+shift+z"] # or a list[keys]names are each command's config key:palette,save,save_as,open,quit,find,find_next,find_prev,replace,goto_line,undo,redo,copy,cut,paste,select_all,toggle_wrap,toggle_line_numbers,left,right,up,down,word_left,word_right,home,end,page_up,page_down,file_start,file_end,sel_left,sel_right,sel_up,sel_down,sel_word_left,sel_word_right,sel_home,sel_end,backspace,delete,delete_word_back,delete_word_fwd,newline,tab.- Chords are modifiers
ctrl,alt,shiftjoined with+to one key (any case): a character,f1–f12,enter,esc,tab,backspace,delete,insert,home,end,pageup,pagedown,up,down,left,right,space. An uppercase letter means Shift plus that letter. - An override replaces all of that command's default chords (hidden ones such as F1 included); an empty list leaves it palette-only. Each chord it names is removed from whichever command had it by default.
- Any error — invalid TOML, an unknown setting, command or key name, a value of the wrong type
or out of range, or one chord set for two commands — ignores the whole file: nib starts with
all defaults and shows
Config error: <msg> — using defaults.
- Versioning: semver from 0.1.0. Pushing a tag
vX.Y.Zbuilds a GitHub Release. - CI: GitHub Actions on Linux and Windows —
cargo fmt --check,cargo clippy -D warnings,cargo test. - Linux builds are static musl (
x86_64-unknown-linux-musl) so one binary runs on every supported distro. - Release assets:
nib_X.Y.Z_amd64.deb(cargo-deb)nib-X.Y.Z.x86_64.rpm(cargo-generate-rpm)nib-X.Y.Z-x86_64-linux.tar.gznib-X.Y.Z-x86_64.msi(cargo-wix; per-machine into Program Files; sets PATH; upgrades in place; listed in Add or remove programs)nib-X.Y.Z-x86_64-windows.zip(portable)SHA256SUMS
- Unsigned in v1. README shows SmartScreen's More info → Run anyway.
install.sh—curl -fsSL <url>/install.sh | sh- Reads
/etc/os-release; on Debian/Ubuntu installs the.debviaapt, on Fedora the.rpmviadnf. Binary lands in/usr/bin. --user(or another distro): tarball into~/.local/bin; adds an idempotent PATH line to~/.profile,~/.bashrc,~/.zshrc.- Verifies SHA-256, runs
nib --versionby full path, prints ahash -r/rehashhint, deletes its temp files. --uninstallremoves a--userinstall and its PATH lines;--purgealso removes config.- Re-running updates.
- Reads
install.ps1—irm <url>/install.ps1 | iex- Downloads and verifies the
.msi, runs it silently withmsiexec. Uninstall via Add or remove programs.
- Downloads and verifies the
- Package repositories (APT repo, COPR, official archives): not in v1.
Kept short. Pitch + screenshots · features (Markdown and JSON highlighting named) · supported
systems (Debian, Ubuntu, Fedora x86_64; Windows 10/11 x64) · install / update / uninstall per
method · SmartScreen note · build from source (and where the binary lands) · brief usage and main
keys · brief config with every [keys] name · links to docs/INTERNALS.md, this spec and the
changelog · license. No limitations section. No AI/tool credits.
docs/INTERNALS.md explains the code: architecture, each module, and how to add a command.