A minimal, cross-platform, personal-journal TUI with a month-calendar picker, markdown-highlighted editor, and auto-managed git history. Written in Rust.
Source of truth for v1 scope. Push back on anything here before implementation begins.
- Single-user, single-journal. Personal use. May be synced across the user's own machines via git, but no collaborators.
- One file, one repo. The journal is a single
journal.mdfile. The app auto-manages a git repo around it. - Very simple. Minimal features for v1. Everything listed under Out of scope is intentionally deferred.
- Cross-platform. Linux, macOS, Windows (via
crossterm). - Modern terminals only. No 80-col optimization; assume ≥100 cols wide.
| Invocation | Behavior |
|---|---|
journaltui init <dir> |
Create a new journal in <dir>. See §4. |
journaltui <path> |
Open an existing journal at <path> (a .md file). |
journaltui |
Open the last-used journal (recorded in config). If no config, fall back to the default path. |
Default path: ~/journal/journal.md.
Error cases:
journaltui <path>where<path>doesn't exist → error:no journal at <path>. Run 'journaltui init <dir>' to create one.journaltui init <dir>where<dir>exists and is non-empty → error.- Config-recorded last-used path missing → fall back to default; if default also missing, error.
Location: ~/.config/journaltui/config.toml (honor $XDG_CONFIG_HOME if set).
Minimum v1 schema:
last_path = "/home/andy/journal/journal.md"The file is rewritten (atomically) whenever a journal is successfully opened.
journal.md is a markdown file of entries, newest first, separated by ## <date> headers.
## Tuesday March 31st, 2026
Body text for this entry. Freeform markdown.
## Saturday January 31st, 2026
Body text for the previous entry.
Format: ## <Weekday> <Month> <Day><suffix>, <Year>
- Weekday: full name (
Monday…Sunday). Derivable from date; the app generates it on save and validates it on load. - Month: full name (
January…December). - Day: 1–31 with English ordinal suffix (
1st,2nd,3rd,4th…21st…31st). - Year: 4-digit.
The header is app-managed: the user never edits it in the buffer. It is generated deterministically from the entry's date.
- One entry per date. Enforced; there is no way to have two entries on the same date.
- Body is everything between a date header and the next one (or EOF), trimmed of leading/trailing blank lines internally but canonically re-emitted with one blank line before and after the body.
- Body can be empty. An entry with an empty body is allowed if created via
:won an empty buffer. (Though normally, saving a buffer that's been emptied deletes the entry — see §11.)
Entries in the file are sorted reverse-chronological (newest at the top), matching the existing file format.
If loading the file encounters a ## ... header that cannot be parsed as a valid date (unknown month, bad suffix, non-existent date like Feb 30, weekday mismatch), the app refuses to open and prints a clear error with the line number. The user fixes the file and retries.
A
--forceflag to load permissively may be added later if needed.
Every write to journal.md goes through: write to journal.md.tmp → fsync → rename → fsync parent dir. No partial writes.
No .bak files; git is the backup.
journaltui init <dir>:
- If
<dir>does not exist, create it. - If
<dir>exists and is non-empty, error. - Create:
<dir>/journal.md— empty.<dir>/.gitignore— excludes swap and temp files (*.swp,*.tmp,.DS_Store,journal.md.tmp).
- Run
git initinside<dir>. - Stage
journal.mdand.gitignore; create initial commit with messageinit. Message must not match^\d{4}-\d{2}-\d{2}$so it never collides with entry commits. - Write
<dir>/journal.mdas thelast_pathin config. - Print:
initialized journal at <dir> run 'journaltui' to open it - Exit cleanly. Do not auto-launch the TUI. Do not configure a remote. (User runs
git remote add origin <url>themselves if they want one.)
If journal.md's containing directory (or any ancestor) is a git repo, the app auto-manages git. If not, git features are disabled silently.
Shell out to the system git CLI via subprocess. Uses the user's existing git config, SSH keys, HTTPS credential helpers, GPG signing, hooks, etc.
Not using git2/libgit2.
- One commit per entry, ever. The repo never contains two commits for the same date.
- Commit message = ISO date, e.g.
2026-03-31. - Commit order = entry-date order. Oldest entry at the bottom of
git log; newest at the top.git log --onelinereads like a timeline of the user's life.
On every :w, the app regenerates git history from scratch based on the current in-memory set of entries:
- Determine the canonical chronological sequence of entries (oldest to newest by date).
git reset --hard <orphan commit>(or equivalent — detach from current tip, reset to init commit parent).- For each entry in chronological order: write
journal.mdreflecting all entries up to and including this one →git add journal.md→git commit -m YYYY-MM-DD. - Push is scheduled (see Sync timing).
This avoids any rebase/cherry-pick conflict handling. It is cheap because entry count is small (hundreds, not thousands).
Pull:
- On startup, before the TUI renders. Non-blocking if it fails (warn + continue).
- Manual
:pull. - No mid-session auto-pull.
Push:
- Debounced after saves: a few seconds (e.g. 3s) after the last
:w, if no further saves arrive, push in the background. Rapid saves collapse into one push. - On clean exit (
:qwith nothing dirty,:wq, lastCtrl-Cconfirmation). - Manual
:push. - Always
git push --force-with-lease(history rewrites make this necessary; personal repo makes it safe).
- All local edits and commits succeed.
- Pull/push failures (network, auth, DNS) surface as status-line errors; do not block editing.
- Pushes queue implicitly — the next successful push covers all prior commits.
Inherited from the user's git config. The app does nothing special.
Two-pane horizontal split, plus a single status line.
┌─ Calendar ──────────────────┐ ┌─ Entry ─────────────────────────────────┐
│ │ │ ## Tuesday March 31st, 2026 │
│ ‹‹ ‹ March 2026 › ›› │ │ │
│ │ │ <body — editable, markdown-highlighted> │
│ Su Mo Tu We Th Fr Sa│ │ │
│ │ │ │
│ 1 2 3 4 5 6 7│ │ │
│ │ │ │
│ 8 9 10 11 12 13 14│ │ │
│ │ │ │
│ 15 16 17 18 19 20 21│ │ │
│ │ │ │
│ 22 23 24 25 26 27 28│ │ │
│ │ │ │
│ 29 30 31 │ │ │
│ │ │ │
└─────────────────────────────┘ └─────────────────────────────────────────┘
CALENDAR · Mar 31, 2026 · 2 unsaved · synced 14:22
- Calendar pane: fixed width (~32 cols). Left.
- Editor pane: fills the remaining width. Right.
- Status line: one row at the bottom.
- Command bar (when
:is typed from Calendar or Browse): replaces the status line; hides on submit/cancel.
No outer frame around the whole app.
No mouse support.
Three states. Transitions shown below.
| State | What's active | Arrow keys | Typing | Enter via | Esc |
|---|---|---|---|---|---|
| Calendar | Left pane. Default on launch. | Move selected date | — | — | — (or layered dismiss, see below) |
| Browse | Cursor in right pane; read-only. | Move cursor in entry | inert (no insert) | Enter from Calendar |
→ Calendar |
| Edit | Cursor in right pane; editing. | Move cursor | inserts text | i from Calendar or Browse |
→ Calendar |
- Entering Browse from Calendar positions the cursor at the top of the entry.
- Entering Edit from Browse preserves the cursor position.
- Entering Edit from Calendar positions the cursor at the end of the entry (ready to append). For an empty entry, at position 0.
- Navigating to a different date from any state switches the right pane to that date's buffer (browse/edit state is retained — you're now cursor-ing in the new buffer).
When Esc is pressed, dismiss in this order:
- Command bar, if open.
- Persistent error banner, if shown.
- If in Browse/Edit, switch focus to Calendar.
- Otherwise, no-op.
| Key | Effect |
|---|---|
← / → |
prev/next day |
↑ / ↓ |
prev/next week |
PgUp / PgDn |
prev/next month |
Shift+PgUp / Shift+PgDn |
prev/next year |
Home / End |
first / last day of current month |
Enter |
enter Browse (on empty date: no-op) |
i |
enter Edit (on empty date: create new empty buffer, then Edit) |
: |
open command bar |
Ctrl-S |
:w |
Ctrl-C |
:q |
| Key | Effect |
|---|---|
← / → / ↑ / ↓ |
move cursor |
PgUp / PgDn |
scroll page |
Home / End |
line start / end |
Ctrl+Home / Ctrl+End |
buffer start / end |
Ctrl+← / Ctrl+→ |
word-jump (if terminal sends them) |
i |
enter Edit at cursor |
Esc |
→ Calendar |
: |
open command bar |
Ctrl-S |
:w |
Ctrl-C |
:q |
Same navigation as Browse, plus:
| Key | Effect |
|---|---|
| (any printable key) | insert at cursor |
Enter |
newline |
Backspace / Delete |
delete previous / next char |
Tab |
insert literal tab (or 2 spaces — TBD; default: literal \t) |
: |
insert literal : (does not open command bar) |
Esc |
→ Calendar |
Ctrl-S |
:w (command bar does not appear; :w runs silently) |
Ctrl-C |
:q |
| Key | Effect |
|---|---|
| (typing) | edit the command |
Enter |
submit |
Esc |
cancel and close |
All invoked from the command bar (: from Calendar or Browse).
| Command | Effect |
|---|---|
:w |
Save all dirty buffers: write file, regenerate git history, schedule debounced push |
:w! |
Same, ignoring the "file changed on disk" guard |
:q |
Quit. Refuses if any buffer is dirty: N unsaved entries — use :wq or :q! |
:q! |
Force quit, discard unsaved buffers |
:wq |
:w then :q. Triggers push on exit |
:push |
Manual push now (force-with-lease) |
:pull |
Manual pull now |
:today |
Jump calendar to today's date |
:date YYYY-MM-DD |
Jump calendar to specific date (ISO only) |
:reload |
Re-read journal.md from disk. Warns if any buffer dirty. |
:messages |
Show scrollback of recent notifications (overlay or side-pane, TBD — v1 lean: a modal list dismissed with Esc) |
:help |
Show keybinding / command reference |
Aliases / shortcuts:
Ctrl-S=:wCtrl-C=:q
‹‹ ‹ March 2026 › ››
‹‹/››= prev / next year (maps toShift+PgUp/Shift+PgDn)‹/›= prev / next month (maps toPgUp/PgDn)- Purely visual cues. Not clickable (no mouse in v1).
- Sunday-first week. Weekday row:
Su Mo Tu We Th Fr Sa. - Dates right-aligned, two digits wide.
- Blank row between weekday header and the date grid.
- Blank row between each week (breathing room).
- Padding inside the pane border.
State-based coloring (no bullet glyph):
| Condition | Style |
|---|---|
| No entry | dim gray |
| Has saved entry | bold, normal foreground |
| Dirty buffer (edited, not yet saved) | bold, yellow/orange |
| Today | underlined (overlays other styles) |
| Selected | inverse video / accent background (overlays all others) |
Always today.
- Top line: the
## <date>header, styled and non-editable. Not part of the buffer. - Body below: the editable buffer.
- Pane title: minimal — e.g.
Entry— since the date header itself anchors the content.
Each date the user visits gets an in-memory buffer. Key properties:
- Opening a date the first time: read content from
journal.md. If no entry exists, buffer is empty. - Navigating to other dates does not discard unsaved changes.
- A buffer is dirty if its current text differs from what was last read/written for that date.
:wwrites all dirty buffers in one transaction (file write + git history rebuild + scheduled push).- Dirty buffers are visible at a glance via the calendar (yellow style) and the status line count.
For a date with no entry in the file:
- Right pane shows placeholder text:
(no entry — press i to create)in a muted style. Enterfrom Calendar: no-op.ifrom Calendar: create a new empty buffer for that date and enter Edit mode.
For an empty new buffer, before any keystrokes, the editor shows a journaling prompt as dim-italic placeholder text. Examples (user does not see the full list in advance):
- "Why will today matter later?"
- "What's something you want to remember about how today felt?"
- …and so on.
The full prompt list is hardcoded (v1) at 36 prompts, chosen randomly per empty-buffer open. The prompt disappears on the first keystroke. Saving an empty buffer (no body) deletes the entry and its commit.
Live per keystroke, in both Browse and Edit. Syntax characters remain visible (we style, not hide). Scope (Standard):
| Element | Style |
|---|---|
# H1, ## H2, ### H3 |
bold, color per level |
**bold** |
bold |
*italic* / _italic_ |
italic |
~~strike~~ |
strikethrough |
`code` |
accent color |
- item / * item |
marker highlighted |
1. item |
marker highlighted |
> quote |
muted color + dim left bar |
[text](url) |
underline + accent |
--- |
dim horizontal rule |
Not in v1: fenced code blocks, tables.
Implementation is simple per-line tokenization (no multi-line state tracking).
One row at the bottom:
CALENDAR · Mar 31, 2026 · 2 unsaved · synced 14:22
Fields (separated by ·):
- Focus state —
CALENDAR,BROWSE, orEDIT. - Selected date — short form, e.g.
Mar 31, 2026. - Unsaved count —
N unsaved(omitted if zero). - Sync status —
synced HH:MM,offline,pushing…,pulling…, orpush failed.
After any action (save, push, pull, command), the status line temporarily overlays with a colored message:
- Green
✓ saved 2 entries · pushed - Yellow
⚠ push queued (offline) - Red
[!] file changed on disk — use :reload or :w!
Success/warning overlays fade back to persistent state after ~3 seconds.
Errors prefixed [!] persist on the status line until dismissed with Esc or superseded by another action.
Shows a scrollback of recent notifications (last ~20). Dismiss with Esc.
All feedback is via the status line and :messages. No blocking dialogs.
- No file watcher.
- mtime check on save: before writing
journal.md, compare its current mtime against what the app recorded on load/last-write. If it changed, refuse to save and show[!] file changed on disk — use :reload to re-read or :w! to overwrite. :reload— re-readjournal.mdfrom disk. Warns if any buffer is dirty (offers:reload!to discard).:w!— overwrite disk regardless of mtime change.
Manual git pull while the app is running is the main real-world case; this covers it.
Hardcoded for v1. A single dark-terminal-friendly palette. Configurable theming is out of scope for v1.
Rough palette (refined during implementation):
- Background: terminal default (transparent)
- Foreground: terminal default
- Dim: gray
- Accent: cyan
- Headers (H1/H2/H3): magenta variants
- Saved entry (calendar): bold normal
- Dirty entry (calendar): bold yellow
- Today (calendar): underline
- Selected date: reversed on accent
- Error: red
- Warning: yellow
- Success: green
- Link: blue underline
- Code: yellow-accent
Intentionally deferred:
- Mouse support.
- Fenced code blocks and tables in markdown highlighting.
- Configurable themes.
- Configurable journaling prompt list.
- Configurable new-entry template.
- Multiple journals / multi-repo.
- Full vim motions:
hjkl, word/line operators,dd,yy,p, counts, marks, registers, visual mode,/search. - Window splits beyond the fixed two-pane layout.
- File watchers / live reload on external change.
.bakfiles — git is the backup.- Collaborative / multi-user usage.
- Remote auto-configuration in
init. - Permissive parse on malformed entry headers (the
--forceflag). - Entry search (full-text across all entries).
- Last-selected-date persistence between runs (startup is always today).
- Rust (edition 2021).
- ratatui — TUI framework.
- crossterm — terminal backend (cross-platform).
- chrono — date handling.
- regex — markdown tokenization, date header parsing.
- serde + toml — config file.
- anyhow — error handling.
- dirs — resolve XDG paths.
- Plus the system
gitCLI at runtime.
Small details I'll decide at the keyboard unless you call them out:
- Exact timing of push debounce (probably 3s).
- Exact fade time for transient status overlays (probably 3s).
:messagesUI: modal list vs. expanded status pane (lean: modal list).Tabin Edit mode: literal\tvs. 2 spaces (lean: literal\t).- Calendar pane exact width (lean: 32 cols).
- Prompt rotation: pure random vs. shuffle-without-repeat-until-exhausted (lean: shuffle-without-repeat).