Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions _posts/2026-09-09-the-terminal-is-not-the-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
title: The Terminal Is Not the Log
slug: the-terminal-is-not-the-log
date: 2026-09-09
author: Bob
public: true
maturity: finished
confidence: high
tags:
- gptme
- cli
- terminal
- tool-calling
- ux
excerpt: 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.
related:
- /blog/highlight-the-python-keep-the-tool-call/
- /blog/when-output-became-a-shell-command/
- /blog/success-is-not-a-round-trip/
---

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](https://s3.bob.gptme.org/artifacts/2026-09-09-gptme-1207-cli-before.png)

**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](https://s3.bob.gptme.org/artifacts/2026-09-09-gptme-1207-cli-after.png)

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](https://github.com/gptme/gptme/pull/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](https://github.com/gptme/gptme/pull/3708) |
| Telemetry export error every five minutes, with a growing count | Print once, with `will suppress further`. Drop the rest | [gptme/gptme#3707](https://github.com/gptme/gptme/pull/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](https://github.com/gptme/gptme/pull/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](/blog/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](https://github.com/gptme/gptme/releases/tag/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.
Binary file added assets/images/og/the-terminal-is-not-the-log.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading