Table of contents
Development
cargo test && cargo clippy --all-targets && cargo fmt
Tests live inside the modules (#[cfg(test)]), next to the code they cover.
The crate is a library with a one-line binary on top, so cargo doc --open
gives the whole thing; #![warn(missing_docs)] is on, and a new public item
without a doc comment warns.
Anything that touches the screen needs a pty to test. Driving one from Python
with pyte as a terminal emulator renders
the escape sequences papote emits into a grid you can assert on — the only
way to catch a display that corrupts rather than crashes.
Every display bug this project has had was found that way and would have passed a unit test: a resize mid-answer printing the same paragraph ten times over, two seconds of blank screen at startup, a cursor two columns off, a picker with ragged columns.
The files
| File | Role |
|---|---|
main.rs |
the binary, one line: it calls papote::main |
lib.rs |
startup: load the config, take the terminal, hand over to the app |
app/mod.rs |
event loop, keys, commands |
app/draw.rs |
the screen: conversation, input box, bar, popups |
app/editor.rs |
the input line |
app/setup.rs |
the wizard and the pickers, as state machines |
app/complete.rs |
Tab: a pure function from the line to what Tab makes of it |
chat/mod.rs |
HTTP client, SSE framing, conversation history |
chat/wire.rs |
the OpenAI protocol as Rust types, parsed and written by serde |
chat/tools/mod.rs |
the Tool trait, the registry, permissions, the thread |
chat/tools/clock.rs |
get_datetime |
chat/tools/files.rs |
list_files, read_file, write_file and edit_file |
chat/tools/search.rs |
grep |
chat/image.rs |
a path or the clipboard → the data: URL an image is sent as |
render/mod.rs |
block tree → ANSI lines (tables, boxes, lists) |
render/md.rs |
pulldown-cmark events → block tree |
render/highlight.rs |
syntax highlighting |
render/ansi.rs |
papote's own ANSI output back into ratatui spans |
render/live.rs |
the previous scrollback engine — unused, kept compiling |
config.rs |
the three TOML files, and where they live |
store.rs |
saved conversations: one JSON file each |
style.rs |
styles, spans, display width, wrapping |
tui.rs |
raw mode and the alternate screen, restored on panic |
Three groups and four primitives: app/ is the program, chat/ the
endpoint, render/ turns markdown into lines and knows nothing else. The
four loose files are what everything leans on — style.rs stays out of
render/ because half its users lay out a screen rather than a document.
There is no serde_json::Value in the program, bar the one schemars
generates. Every request is a struct filled in and every answer a struct
parsed, in chat/wire.rs, whose field names are the wire's own names — so a message in the history, a message on
the wire and a message in a saved conversation are one type, and a shape that
drifts is a compile error rather than a None nobody notices.
The fixed sets are enums for the same reason — a role, a model's state, what it can be sent. When an endpoint uses a word papote has no variant for, the answer still comes through and the word goes on the bar:
papote does not know model state `starting`, input modality `audio`
That is a bug report, not a warning about your setup: a variant is missing in
wire.rs, and the list you are looking at is right in every other respect.
live.rs is dead code kept on purpose: it was the trickiest thing in the
project, and a fossil that no longer builds would be worth nothing as a
reference.
Dependencies
Three carry the work: pulldown-cmark (markdown parsing, re-run on every
frame), unicode-width (true character widths, which is what makes table
alignment correct) and ratatui. The rest of the tree comes from reqwest,
toml, uuid, image, arboard, and chrono with iana-time-zone.
image is there for one job: re-encoding a WebP as a JPEG, because
llama-server cannot read WebP at all. It is taken with
default-features = false and only the webp and jpeg features, which
costs nine crates. image-webp + jpeg-encoder would have done the same
for four; the whole crate was chosen for the room it leaves to handle
another format later without a second decision.
arboard is the clipboard, for the Ctrl-V that attaches a screenshot. Its
wayland-data-control feature is not on by default and is what makes it
work on Wayland at all — without it, it quietly reads the X11 clipboard and
finds nothing. With the feature it costs twenty-nine crates;
wl-clipboard-rs would have done Wayland alone for seventeen, and a
wl-paste subprocess for none. It was taken for X11 and the other
platforms.
chrono and iana-time-zone are the two crates behind the get_datetime
tool, for the one thing the standard library cannot do: turn an instant into
a local date, and name the zone it is in. jiff would have cost the same two
crates.
regex-lite is the regular expressions behind grep, and it is one crate
with nothing underneath it. The full regex costs nine and buys speed and
Unicode character classes; ignore, ripgrep's walker and the only cheap way
to respect .gitignore, costs twenty-two. So papote walks the tree itself
and keeps a search bounded with ceilings instead.
schemars derives the JSON Schema a tool shows the model from the struct it
parses the arguments into, doc comments and all — six crates for not having
to declare every argument twice, once for serde and once for the model, and
watch the two drift. It is the one place a serde_json::Value survives
(schemars::Schema is one underneath), and it is allowed because nothing
reads meaning out of it: it is generated whole and shipped whole.
Measure the cost of a new one with cargo tree -e normal, not by counting
Cargo.lock — the lockfile is feature-blind and lists optional dependencies
whether or not their feature is on, which overstates things badly. ratatui
with only the crossterm backend really costs about 50 crates; the lockfile
would tell you 134.
reqwest's rustls feature is the heaviest single line. native-tls would
be lighter, at the price of linking the system OpenSSL — a deliberate call,
not an oversight.
papote
Getting started
Using it
Under the hood