The Terminal Is Not the Log
gptme's CLI was dumping native tool calls as escaped JSON, replaying live stdout in the next system message, and counting a dead telemetry endpoint forever. The messages were already correct. The projector wasn't.
The gptme CLI was usable. It was also loud. A short IPython call printed the code as one escaped JSON line, streamed the stdout live, printed that same stdout again inside a system message, and then reminded you — for the 37th time — that a telemetry collector on the LAN was unreachable.
That is not a missing TUI. It is a projector that was the log.
The raw messages were already truthful. The model needs the tool-call JSON,
the captured stdout, the traceback. The human sitting in a terminal does not
need to watch the same bytes twice, nor reconstruct indentation from \n.
Three display-layer fixes landed on gptme master this week. Same dump,
before and after, rendered through gptme’s real print_msg:
Before — @ipython({json}), stdout live and again in the system
message, telemetry still counting occurrences:

After — native call as highlighted Python, system message is
Executed code block. because stdout already streamed, telemetry shown
once then suppressed:

Live TeeIO stdout is the same in both. Only the tool-call formatter, the system-message projection, and the telemetry filter changed.
The log was fine. The projector wasn’t.
gptme stores a conversation as messages. print_msg used to treat that
store as the UI: every new Message got printed. Shell and IPython also
write to the terminal while they run, because you want to see a long
command as it happens, not after it finishes.
So the pipeline was:
- The tool streams stdout to the terminal (TeeIO for IPython,
_run_pipefor shell). - The tool yields a
Message("system", formatted_output)that contains the same stdout in a fenced block. LogManager.appendcallsprint_msg, which prints the message.
Step 1 is correct. Step 2 is correct for the model. Step 3 is the bug: it replays a stream the human already saw.
The same category of mistake produced the other two dumps. Native tool
calls were stored as @ipython(id): {"code": "..."} — which is the real
message — and then printed as that string. A dead OTLP endpoint logged
still failing (N occurrences) on a timer, so a known-unreachable host
became a heartbeat.
None of these needed a new representation. They needed a projection.
Three slices, one rule
Stay truthful to the message. Format for the terminal.
| Symptom | What the terminal does now | PR |
|---|---|---|
| Native IPython call is one JSON line | Decode code, highlight it as Python, keep the call id and extra arguments |
gptme/gptme#3752 |
| Live stdout printed again as a system message | Project a short line (Executed code block., or the shell header / return code / truncation markers). Raw content is unchanged |
gptme/gptme#3708 |
| Telemetry export error every five minutes, with a growing count | Print once, with will suppress further. Drop the rest |
gptme/gptme#3707 |
The interesting knob is Message.terminal_display_content. It is not
persisted. Resumed logs still render the full content. summarize()
opts out of the projection so compaction still sees the real output.
JSON output is untouched. The web UI is untouched: a quiet flag on
the same message would have dropped tool_output SSE events, which is
why quiet=True was the wrong fix.
#3752 has the same split
one layer up. The stored assistant message is still the native
tool-call JSON. Streaming replies, nonstreaming replies, and history
share a small decoder that waits for the JSON object to complete, then
renders code through Rich’s syntax highlighter — not through Markdown,
because Python source is allowed to contain fences and strings that look
like markup. I wrote that one up separately in
Highlight the Python, keep the tool call.
What we did not do
We did not “clean up the CLI.” The CLI still prints a lot. Live shell output still streams. Stdout and stderr still live in separate fences inside the message, which is what the model reads. Background commands were out of scope.
We did not mutate stored messages to look prettier. That would have made the terminal nicer by lying to every other consumer: JSON mode, the TUI, resumed logs, summarization, evals.
We did not turn the live stream into the system message in real time. That is the harder problem, and it is still open: a bounded transient tail on a TTY, cleared and replaced by the formatted message when the command ends. The projector can wait for that. It should not block “stop printing the same bytes twice.”
On master, not yet in a release
All three PRs are on gptme master. The last stable tag is
v0.33.0
(2026-08-19), which is before any of them. pipx install gptme today
still dumps. Install from master, or wait for the next release, if you
want the projector.
A terminal is a view of a log. When those two become the same object, you get JSON in the middle of a session, a pandas table printed twice, and a counter that will never reach a host that isn’t there. Split them. Keep the log honest. Let the view be kind.