Skip to content

Latest commit

 

History

History
535 lines (375 loc) · 19.4 KB

File metadata and controls

535 lines (375 loc) · 19.4 KB

journaltui — v1 spec

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.


1. Scope & guiding principles

  • 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.md file. 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.

2. CLI

Invocation

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.

Config file

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.


3. File format

Structure

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.

Date header

Format: ## <Weekday> <Month> <Day><suffix>, <Year>

  • Weekday: full name (MondaySunday). Derivable from date; the app generates it on save and validates it on load.
  • Month: full name (JanuaryDecember).
  • Day: 1–31 with English ordinal suffix (1st, 2nd, 3rd, 4th21st31st).
  • 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.

Entries

  • 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 :w on an empty buffer. (Though normally, saving a buffer that's been emptied deletes the entry — see §11.)

File ordering

Entries in the file are sorted reverse-chronological (newest at the top), matching the existing file format.

Parse errors

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 --force flag to load permissively may be added later if needed.

Atomic writes

Every write to journal.md goes through: write to journal.md.tmpfsyncrenamefsync parent dir. No partial writes.

No .bak files; git is the backup.


4. init behavior

journaltui init <dir>:

  1. If <dir> does not exist, create it.
  2. If <dir> exists and is non-empty, error.
  3. Create:
    • <dir>/journal.md — empty.
    • <dir>/.gitignore — excludes swap and temp files (*.swp, *.tmp, .DS_Store, journal.md.tmp).
  4. Run git init inside <dir>.
  5. Stage journal.md and .gitignore; create initial commit with message init. Message must not match ^\d{4}-\d{2}-\d{2}$ so it never collides with entry commits.
  6. Write <dir>/journal.md as the last_path in config.
  7. Print:
    initialized journal at <dir>
    run 'journaltui' to open it
    
  8. 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.)

5. Git integration

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.

Backend

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.

Commit model

  • 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 --oneline reads like a timeline of the user's life.

History rewrite

On every :w, the app regenerates git history from scratch based on the current in-memory set of entries:

  1. Determine the canonical chronological sequence of entries (oldest to newest by date).
  2. git reset --hard <orphan commit> (or equivalent — detach from current tip, reset to init commit parent).
  3. For each entry in chronological order: write journal.md reflecting all entries up to and including this one → git add journal.mdgit commit -m YYYY-MM-DD.
  4. 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).

Sync timing

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 (:q with nothing dirty, :wq, last Ctrl-C confirmation).
  • Manual :push.
  • Always git push --force-with-lease (history rewrites make this necessary; personal repo makes it safe).

Offline behavior

  • 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.

Identity / GPG

Inherited from the user's git config. The app does nothing special.


6. Layout

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.


7. Focus states

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).

Esc precedence

When Esc is pressed, dismiss in this order:

  1. Command bar, if open.
  2. Persistent error banner, if shown.
  3. If in Browse/Edit, switch focus to Calendar.
  4. Otherwise, no-op.

8. Keybindings

Calendar focus

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

Browse focus

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

Edit focus

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

Command bar (open)

Key Effect
(typing) edit the command
Enter submit
Esc cancel and close

9. Commands

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 = :w
  • Ctrl-C = :q

10. Calendar widget

Header

‹‹   ‹   March 2026   ›   ››
  • ‹‹ / ›› = prev / next year (maps to Shift+PgUp / Shift+PgDn)
  • / = prev / next month (maps to PgUp / PgDn)
  • Purely visual cues. Not clickable (no mouse in v1).

Grid

  • 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.

Date cell styling

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)

Startup date

Always today.


11. Editor pane

Content

  • 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.

Buffers

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.
  • :w writes 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.

Empty-entry behavior

For a date with no entry in the file:

  • Right pane shows placeholder text: (no entry — press i to create) in a muted style.
  • Enter from Calendar: no-op.
  • i from 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.

Markdown highlighting

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).


12. Status line & notifications

Persistent state (default)

One row at the bottom:

  CALENDAR · Mar 31, 2026 · 2 unsaved · synced 14:22

Fields (separated by ·):

  1. Focus stateCALENDAR, BROWSE, or EDIT.
  2. Selected date — short form, e.g. Mar 31, 2026.
  3. Unsaved countN unsaved (omitted if zero).
  4. Sync statussynced HH:MM, offline, pushing…, pulling…, or push failed.

Transient feedback

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.

Persistent errors

Errors prefixed [!] persist on the status line until dismissed with Esc or superseded by another action.

:messages

Shows a scrollback of recent notifications (last ~20). Dismiss with Esc.

No modal popups

All feedback is via the status line and :messages. No blocking dialogs.


13. External edits & reload

  • 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-read journal.md from 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.


14. Visuals

Theme

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

15. Out of scope (v1)

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.
  • .bak files — git is the backup.
  • Collaborative / multi-user usage.
  • Remote auto-configuration in init.
  • Permissive parse on malformed entry headers (the --force flag).
  • Entry search (full-text across all entries).
  • Last-selected-date persistence between runs (startup is always today).

16. Tech stack

  • 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 git CLI at runtime.

17. Open questions / TBD during implementation

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).
  • :messages UI: modal list vs. expanded status pane (lean: modal list).
  • Tab in Edit mode: literal \t vs. 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).