This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Commands and keys
papote # ordinary run: the conversation is saved
papote --private # nothing is written down, and /resume is off
papote --system "Tu es concis" # start with a system prompt in place
Everything else is configured in the TOML files. See Private mode.
--system sets the system prompt before the first
message, exactly as /system would — useful for a shell alias, or for
telling a container what it is. It belongs to that run and is not written
anywhere: /system replaces it, /clear keeps it, and /resume puts the
saved conversation's own prompt in its place, since that one travelled with
it.
Commands
| Command | Effect |
|---|---|
/resume |
carry on with a saved conversation — off under --private |
/image <path> |
attach an image to your next message |
/system <instructions> |
set the system prompt |
/system |
drop it |
/rename <name> |
name this conversation |
/rename |
let the model name it |
/model |
pick another model on the same provider |
/provider |
switch provider, or add one |
/clear |
start a new conversation, keep the model |
/help |
list the commands |
/quit |
exit |
A named conversation shows its name on the top border of the input box, and
/resume lists it by that name instead of by its first message. See
Saved conversations.
/model and /provider fetch the model list live and write your pick back to
config.toml, so it is still there next time. Esc leaves everything as it
was.
Completion
Tab completes a command, and the path /image takes:
❯ /sy⇥ → ❯ /system
❯ /image ~/Ima⇥ → ❯ /image ~/Images/
Every press does something. Tab first grows the line by as much as it can — the whole match when there is only one, otherwise as far as the candidates agree — and once nothing more can be added without choosing, it starts putting the candidates themselves in the line, one per press:
❯ /image ~/Images/moi⇥ ❯ /image ~/Images/moi-chauve.jpg
[moi-chauve.jpg] moi-circle.png moi.jpg moi.png …
⇥ again → ❯ /image ~/Images/moi-circle.png
moi-chauve.jpg [moi-circle.png] moi.jpg moi.png …
The bottom line is the choice while you are walking it, and the candidate
now in the input line is the one highlighted there — colour it with
selection in theme.toml. Past the last one Tab
starts again at the first, Shift-Tab walks back, and a list too long for
the bar scrolls rather than being cut: the highlight is always visible.
Any other key settles whichever candidate is in the line and ends the walk, so you carry on typing from it. To go into a directory you have just cycled onto, press → and then Tab.
A directory is completed with its slash and no space, so Tab goes deeper
into it. Hidden entries stay out of the way until you type the dot
yourself, and the ~ is left exactly as you wrote it rather than expanded
into the line.
Anywhere else — in the middle of a message, or with the cursor back inside the line — Tab is still a tab.
Images
/image hangs a picture on the input line, for you to ask your question
beside it:
❯ /image ~/Images/moi.jpg
Nothing is sent yet. A terminal cannot draw a JPEG, so the image becomes a chip at the head of the line, and the cursor waits after it:
┌────────────────────────────────────────────────────────────┐
│❯ [image/jpeg, 120 kB] Combien de personnes sur cette photo…│
└────────────────────────────────────────────────────────────┘
image/jpeg, 120 kB attached — write your message, or Del to drop it
Enter sends the image and the message together:
❯ [image/jpeg, 120 kB] Combien de personnes sur cette photo, et que
porte-t-elle ? Deux phrases.
Il y a une seule personne sur cette photo. Elle porte des lunettes et une
veste en cuir noir.
Enter on a line that holds nothing but the chip sends the image on its own — and either way the picture stays in front of the model for the questions that follow.
Del takes the image back off the line, which is what it does when there
is no text to its right; Backspace does the same at the very start of
the line, so an image can be dropped without losing what you have typed.
Ctrl-C clears the text first and the image next, and /clear drops both
with the conversation.
The path may be absolute, relative to where papote was started, or start
with ~ — no shell has been through it, so papote expands that itself.
png, jpeg, gif and webp are taken; anything else is refused by name,
and the type comes from the file's first bytes rather than its extension.
Pasting one
A picture copied in a file manager can be pasted straight in: the clipboard
carries its file:// address, the terminal hands that over as text, and a
paste that is exactly one path attaches the image it points at. A paste with
anything else in it — a sentence that merely mentions a path — is typed in
as text.
Ctrl-V attaches a picture that is on the clipboard as an image rather than as a file, which is what a screenshot tool leaves there. papote has to ask the clipboard for that itself: a terminal only ever pastes text, and a clipboard holding a screenshot offers no text at all, so the paste would arrive empty. With text on the clipboard, Ctrl-V types it in as any paste would.
A screenshot is sent as a JPEG, its transparent corners flattened onto white.
The formats
A WebP is converted to JPEG before it goes, in memory — the file on disk
is untouched. llama-server decodes images with stb_image, which knows
nothing of WebP and answers 400 Failed to load image or audio file, so a
.webp sent as it came would simply never arrive. That is why the chip
reads image/jpeg for a picture you gave as .webp: it describes what is
actually sent. A transparent WebP is flattened onto white, since JPEG has no
alpha.
An image is saved with the conversation like anything else, which means a
conversation with a photograph in it is a file with that photograph in it,
base64 and all. --private sends one without writing any of that down.
Which models can see is in the /model list, from what the endpoint reports:
┌ Models on ai.example.com ────────────────────────────────┐
│ gemma-4:26B-A4B:256K unloaded vision │
│❯ gemma-4:26B-A4B:32K loaded vision │
└──────────────────────────────────────────────────────────┘
/image does not refuse to send when the model is not marked vision: an
endpoint that says nothing is not the same as a no, and a model that cannot
see says so itself — its answer lands on the bar.
Thinking
Some models reason before they answer. That reasoning is streamed and shown
as it arrives, unless thinking = false under [model] in
config.toml:
❯ Un train part a 14h12 et roule 2h47. Il arrive a quelle heure ?
╭─ thinking ──────────────────────────────────────────────────╮
│ * Departure time: 14h12 (2:12 PM). │
│ * Start with the hours: 14h + 2h = 16h. │
│ * Start with the minutes: 12m + 47m = 59m. │
╰─────────────────────────────────────────────────────────────╯
Le train arrivera à 16h59.
It goes in a box of its own, the way a code block does, with what it is
where the language would be — an aside rather than the answer. It stays
there afterwards, saved with the conversation and back on /resume,
because it is part of what the model said.
The reasoning is markdown too, and is shown as such: a model writes headings, lists, tables and code in it whether or not you asked, and those are how the reasoning is laid out. The text is muted, but whatever the renderer colours keeps its colour — the box is what tells you this is an aside, so nothing inside it has to be dulled to say so a second time.
Those tokens count. The bar's figures include them and its clock starts at the first of them, so a model that reasons for two hundred tokens before its first word shows a real count and a real rate throughout rather than an empty bar and then a jump. It costs nothing to keep: quoting a past turn's thinking back to the server does not grow the prompt — the template leaves it out when it renders the conversation.
Thinking is on by default. Turn it off for a small context, where a model can spend its whole token budget reasoning before it answers — that one is a decision per endpoint rather than one papote can make for you.
Tools
A model cannot know what time it is or what is on your disk: it was trained months ago and has no clock and no filesystem. A tool is how it asks. papote says what it can do in every request, and the model may answer with a call rather than an answer.
| Tool | What it does | Asks first |
|---|---|---|
get_datetime |
the local time, in ISO 8601, with the zone's name | no |
list_files |
the entries of a directory | yes |
read_file |
a text file, or a range of its lines | yes |
write_file |
writes a text file, replacing it whole | yes |
edit_file |
replaces one exact piece of a file | yes |
grep |
the lines matching a pattern, and where they are | yes |
run_shell |
runs a shell command | yes |
web_search |
searches the web through your searxng | yes |
read_url |
fetches a page and returns it as markdown | yes |
The last two are only offered once you have pointed papote at a searxng instance; see Configuration. Without one they are not in the request at all, rather than being there to fail.
write_file replaces an existing file entirely rather than appending, and
will not create missing parent directories — a mistyped directory should be
a mistake the model hits, not a tree it builds. It says which of the two it
did, because afterwards there is no telling.
edit_file is the one to reach for on a file that already exists. It takes
the text to replace and the text to put there, and leaves the rest alone —
so changing three lines of four hundred costs three lines rather than four
hundred, which is a difference in time, in context, and in the chance of
something being dropped on the way.
The match is literal and has to be unique, and both refusals are the point rather than a limitation:
- not found — the text has to match character for character, indentation included. A single space out and the edit does not happen, which is how it should be: a fuzzy match would be a second, worse regular-expression engine, guessing at what you meant on a file it is about to overwrite.
- found more than once — papote says how many and on which lines, and
does nothing. Picking one on the model's behalf would be a silent
corruption every time the guess was wrong. The way out is to give more of
the lines around the one you mean, or to set
replace_all, which changes every occurrence and says so in the question.
A file that is not UTF-8 text is refused rather than edited. read_file
will happily show you one, replacing the bytes it cannot read with ? —
writing those back over the original is how a binary gets destroyed.
grep searches a tree for a regular expression and hands back
path:line:text — grep's own shape, which is the one the model reads best.
It takes a path to search, a suffix to narrow it to .rs or
Cargo.toml, and Rust regex syntax, so (?i) at the front ignores case.
It is worth having rather than letting the model reach for the shell: it
only reads, so saying yes to it is a smaller thing; it answers the same way
whatever is installed on the machine; and it stops rather than filling the
context — 200 matches, 300 columns of any one line, 2 MB of any one file,
20 000 files walked, and it says so in the answer whenever one of those
bites. It does not read .gitignore — that is ripgrep's walker and
twenty-two crates — so hidden entries like .git are skipped but target/
is not. Give it the narrowest path you can.
run_shell runs a command line through sh -c, in the directory papote was
started in, and hands back both output streams and the exit status. Pipes,
globs and redirections all work. Its output appears as it is produced,
in the same box that will hold the finished result:
╭─ preparing run_shell ────────────────────────────────────────╮
│ for i in 1 2 3 4 5 6; do echo "ligne $i"; sleep 1; done │
╰──────────────────────────────────────────────────────────────╯
╭─ tool result ────────────────────────────────────────────────╮
│ ligne 1 │
│ ligne 2 │
│ ligne 3 │
╰──────────────────────────────────────────────────────────────╯
so a build is something you watch rather than something you wait for. The
last line to land is the — exit 0.
A command that draws a progress bar is shown at its last state rather
than as two hundred redraws: apt and curl rewrite one line over and over
with a carriage return, and papote applies those the way a terminal would.
Tabs line up to the next eight-column stop, and anything else that would
move the cursor — a bell, a form feed, an escape sequence — is dropped,
because a stray carriage return inside the box would send the cursor out
through its left wall. The model is told the same tidied text, which also
keeps a progress bar from eating its context. The model is told the whole thing in
one go when the command ends — a tool result is a single message on the
wire, and there is no streaming one — so what this buys is the watching. It is killed after two minutes — nothing
can interrupt a running tool, so it has to stop itself — and what it managed
to say before that is kept. There is a ceiling on the output, like the file
tools have, for the same reason: an answer that fills the context leaves no
room for the answer.
That tool can do anything the account running papote can do, which is why there is a container for it.
Searching the web
web_search asks your searxng instance and hands back the results as a
numbered list — a title, the URL and a sentence or two each. It takes a
query, an optional time_range of day, week, month or year, and a
page for when the first ten were not enough. When an engine produced an
infobox, that leads the answer, since it is frequently the whole of what the
question wanted.
papote talks to searxng's own JSON API, not to an MCP server. There is nothing to install beside the instance itself.
read_url fetches one page and hands back markdown, not flat text. That
is deliberate: markdown keeps the link URLs, and a page's links are most of
what lets the model carry on to the next page — flattened to text they are
gone. It keeps the headings and lists too, which is the structure the model
reads from. Scripts, styles and SVGs are dropped.
A long page comes back a window at a time, 20 000 characters each, and every
window ends by saying how much is left and what start to pass to carry on.
A page that is not text — a PDF, an image — is refused with a sentence saying
so rather than handed over as noise, and only http:// and https:// are
fetched at all.
Watching one being written
A call is generated a token at a time like any other answer, and a
write_file carrying a whole file takes just as long to arrive — so it is
shown as it is written, in a box that says what the tool is and what it is
about:
╭─ preparing write_file: /tmp/fact.rs ─────────────────────────╮
│ pub fn factorial(n: u64) -> u64 { │
│ if n == 0 { │
│ 1 │
│ } else { │
│ n * factorial(n - 1) │
│ } │
│ } │
╰──────────────────────────────────────────────────────────────╯
The file is shown as it will be written — line breaks and indentation, not the escaped JSON it travels in — and coloured from the extension in its name. Some models write the content before the path, and until the path has arrived there is no name to take a language from, so the colour turns up when it does. It is always there by the time you are asked.
An edit_file shows the change as a diff instead, red for what goes and
green for what comes, because that is what you are being asked about:
╭─ preparing edit_file: /home/thomas/essai.py ─────────────────╮
│ - print("Hello, " + name) │
│ + print(f"Hello, {name}") │
╰──────────────────────────────────────────────────────────────╯
It fills in the same way the call arrives: the line that goes shows first,
on its own, and the line that replaces it appears underneath as the model
writes it. When the call sets replace_all, the label says
(every occurrence) beside the path — agreeing to one replacement and
agreeing to all of them are not the same answer.
Being asked
Anything that touches your disk stops and asks, and the question takes the input line's place rather than covering the box above — what you are agreeing to is up there:
┌ Run this tool? ──────────────────────────────────────────────┐
│ ❯ yes │
│ yes, and don't ask again for write_file │
│ no │
└──────────────────────────────────────────────────────────────┘
↑ and ↓ walk the answers, Enter takes the one under the cursor, and every other key does nothing at all. The list wraps, so one ↑ puts you on no. Esc is an answer too — it means no — rather than a way of leaving the question alone, because the model is waiting on it.
Saying something with it
Tab beside yes or no opens a message on that line, to send along with the answer:
┌ Run this tool? ──────────────────────────────────────────────┐
│ ❯ no, lis plutôt /etc/os-release │
│ yes, and don't ask again for read_file │
│ yes │
└──────────────────────────────────────────────────────────────┘
Enter then sends both: the tool's answer — or the refusal — and your message after it, as your own turn. That is where it belongs, since you wrote it knowing what the model had asked for. Tab or Esc closes the message again without answering, so Esc only refuses once there is nothing to back out of first.
yes, and don't ask again … takes no message: that answer is about the tool rather than about this call.
yes, and don't ask again … writes <tool> = "allow" under [tools] in
config.toml, so that tool stops asking. There is
no answer that refuses for good: "deny" is a line to write there
deliberately, not somewhere to land by pressing Enter one row too far.
A refused call still gets an answer: the model is told
Tool execution was denied by the user. and carries on from there, usually
by saying so or trying something else. The bar says which, and says
<tool> is denied in config.toml rather than refused <tool> when it was
a deny line that did it — a tool that stops working with no explanation
looks like a bug rather than like a setting. A turn asking for several tools asks
about each in turn, and the title says which of how many.
Edit [tools] by hand to set it up in advance:
[tools]
get_datetime = "allow"
list_files = "allow"
read_file = "ask"
What it looks like
❯ Quelle heure est-il ?
╭─ preparing tool call ───────────────────────────────────────╮
│ get_datetime() │
╰─────────────────────────────────────────────────────────────╯
╭─ tool result ───────────────────────────────────────────────╮
│ {"result":"2026-09-11T09:46:18+02:00","timezone":"Europe/…"} │
╰─────────────────────────────────────────────────────────────╯
Il est 09h46.
The call and what papote answered each get a box, like the thinking. They are
shown rather than hidden — a reply that knows the time out of nowhere is a
mystery worth a couple of lines — and they are part of the conversation,
saved with it and back on /resume.
Tools run on a thread of their own, so the screen keeps going while one does: a directory on a network mount would otherwise freeze papote until it answered. There is no stopping one once it is away, so the two that read the disk have a ceiling on what they hand back — a file of a million lines would fill the context and leave no room for the answer.
When to stop
There is no limit on how many rounds of tool calls one question may take.
A model working through a task calls tool after tool, and any number picked
here would be one you hit doing something reasonable. Set one under
[tools] if you want it:
[tools]
max_rounds = -1 # the default: as many as it takes
It is signed because no number means "no ceiling", so the sign says it: any negative is unlimited, and zero refuses the first call.
Ctrl-C is the brake. It stops the whole turn, not just the answer being written: a cancelled answer that had asked for a tool does not run it, a confirmation waiting for you goes away, and anything queued behind it is dropped. A tool already running cannot be interrupted — it finishes, and its answer goes nowhere.
Turn the whole thing off with tools = false under [model] in
config.toml. It is worth knowing that
llama-server needs --jinja to accept a request carrying tools at all: an
endpoint without it refuses every message rather than ignoring the field.
The system prompt
/system sets the instructions the model is given before anything else —
what it is, how to answer, in which language:
❯ /system Tu es concis. Tu réponds en français.
It shows at the top of the conversation, muted and labelled, so what is in force is never a guess:
system prompt
Tu es concis. Tu réponds en français.
Setting one again replaces it; /system with nothing after it drops it.
For a prompt of several lines, type /system, then Ctrl-Enter (or Alt-Enter)
for each new line, and Enter to send the lot — the whole thing becomes the
prompt.
It is part of the conversation as far as the API is concerned, so it is
saved with it and comes back with /resume. /clear keeps
it: it says how you want to be answered, which is not something a new
conversation should lose.
A system prompt on its own is never saved — set one, say nothing, and nothing is written.
Keys
| Key | Effect |
|---|---|
| Tab | complete a command or a path, then walk the candidates |
| Shift-Tab | walk them the other way |
| Enter | send the message |
| Ctrl-Enter, Alt-Enter, numpad Enter | new line in the message |
| Ctrl-C | stop the answer, or clear the input line |
| Ctrl-V | paste an image from the clipboard, or the text on it |
| Del, Backspace | drop an attached image when they have nothing else to do |
| ↑ ↓ Enter | answer a tool confirmation |
| Ctrl-D | exit |
| Esc | back out of a picker |
| wheel, PgUp / PgDn, Ctrl-↑ / ↓ | scroll the conversation |
| ← → ↑ ↓ | move in the message |
| Home / End, Ctrl-A / Ctrl-E | start and end of the line |
| Ctrl-W | delete the word before the cursor |
| Ctrl-U / Ctrl-K | cut to the start / end of the line |
Enter while an answer is still coming does not send: the bar says so, and
what you typed stays in the line, ready for when the answer is over or
you stop it. Commands still run — /help, /clear and the rest do not wait
on the model.
Ctrl-C stops a running answer and keeps whatever already arrived, so a reply that has gone off the rails costs nothing to abandon. On an empty prompt with nothing running, it only reminds you that Ctrl-D leaves.
Ctrl-Enter needs a terminal that speaks the keyboard-enhancement protocol — see Limitations. Alt-Enter works everywhere.
The numpad's Enter opens a line too, which is the point of having it under your hand while you write a paragraph. Plain Enter still sends. It is bound twice on purpose: a terminal speaking the protocol sends the key itself, and Alacritty sends a bare line feed, which is indistinguishable from Ctrl-J — so Ctrl-J opens a line as well.
The input box grows as the message gains lines, up to half the screen, then scrolls to follow the cursor. There is no input history: a chat prompt is prose, not commands worth recalling.
The bottom bar
gemma-4:26B-A4B:32K on ai.example.com ████████░░░░░░░░░░░░░░░░ 11k tok 103 tok/s /help
└── the gauge fills the second half of the window ──┘
The model and the provider on the left; on the right, what the server reported about the last exchange.
████████░░░░░░░░is how full the context is. The light part is what the conversation has taken, the dark part what is left. It starts at the middle of the window and runs to the figures, so it grows when you make the terminal wider.11k tokis the whole conversation — prompt and answers together. It climbs as the conversation goes on.103 tok/sis generation speed alone. Prompt processing is measured separately by the server and not shown.
The gauge only appears when papote knows how big the window is, which it
learns from meta.n_ctx on the endpoint's model list — and only a loaded
model reports that, so papote asks at startup and again after each answer
until it gets an answer. In practice it is there from the first reply. An
endpoint that never says leaves the gauge out rather than guessing. On a
window under about seventy columns the figures have taken the whole half
already, and it is dropped rather than squeezed. Both halves are the same
block in two greys, so what you read is where the light stops; the colours
are
gauge_used and gauge_free in
theme.toml.
A conversation can outgrow the window: llama.cpp shifts the context along rather than refusing, and the count keeps climbing. The gauge sits full at that point, which is the honest picture.
Both update live while an answer comes in: papote counts the tokens as they arrive rather than waiting for the server, which only reports once the answer is over. The two agree to about a percent.
The count is short by the tokens of your own question, which papote cannot measure, so it catches up by that much when the answer lands — always upwards. The number never goes backwards, and never changes width: both figures sit in a fixed-width field, or the whole right-hand side would shuffle sideways every time a digit appeared.
Both come from the answer itself, at no extra cost. A server that reports neither simply leaves the right-hand side empty — see How it works.
Scrolling
At the bottom, the conversation follows along: new lines push the older ones up and you always see the latest.
Scroll up and it stops. An answer arriving while you are reading further up
does not move what you are looking at — it goes on being written below,
out of sight, and the bar's ↑n counts up as it does. Scroll back to the
bottom and papote follows again; sending a message does the same, since you
asked for the answer you are about to get.
One thing does take the view back: a tool confirmation. What you are being asked to agree to is the box just above the question, so papote returns to the bottom to put it there — a question about something off the screen is not one anybody can answer.
The bar also carries transient messages, and the scroll position when you have scrolled up. A message takes the place of the model and the provider until the next one, and its colour says how much attention it wants:
| Colour | What it means |
|---|---|
| grey | something happened — a conversation resumed, an image attached, an answer stopped |
| amber | something did not happen — a command refused, a key with nothing to do |
| red | something failed — the endpoint, the disk, a file that is not there |
Only the message is coloured; the model, the numbers and /help stay grey
whatever it says. The two loud colours are warning and error in
theme.toml.
The conversation is kept between messages — it is a real multi-turn chat.
/clear starts a fresh one without changing the model.
papote
Getting started
Using it
Under the hood