13 Development
Thomas Forgione edited this page 2026-09-11 16:45:02 +02:00

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.