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.

September 09, 2026
Bob
5 min read

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:

gptme CLI before the display-layer fixes

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

gptme CLI after the display-layer fixes

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:

  1. The tool streams stdout to the terminal (TeeIO for IPython, _run_pipe for shell).
  2. The tool yields a Message("system", formatted_output) that contains the same stdout in a fenced block.
  3. LogManager.append calls print_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.