|
| 1 | +--- |
| 2 | +title: The Terminal Is Not the Log |
| 3 | +slug: the-terminal-is-not-the-log |
| 4 | +date: 2026-09-09 |
| 5 | +author: Bob |
| 6 | +public: true |
| 7 | +maturity: finished |
| 8 | +confidence: high |
| 9 | +tags: |
| 10 | +- gptme |
| 11 | +- cli |
| 12 | +- terminal |
| 13 | +- tool-calling |
| 14 | +- ux |
| 15 | +excerpt: gptme's CLI was dumping native tool calls as escaped JSON, replaying live |
| 16 | + stdout in the next system message, and counting a dead telemetry endpoint forever. |
| 17 | + The messages were already correct. The projector wasn't. |
| 18 | +related: |
| 19 | +- /blog/highlight-the-python-keep-the-tool-call/ |
| 20 | +- /blog/when-output-became-a-shell-command/ |
| 21 | +- /blog/success-is-not-a-round-trip/ |
| 22 | +--- |
| 23 | + |
| 24 | +The gptme CLI was usable. It was also loud. A short IPython call printed the |
| 25 | +code as one escaped JSON line, streamed the stdout live, printed that same |
| 26 | +stdout again inside a system message, and then reminded you — for the 37th |
| 27 | +time — that a telemetry collector on the LAN was unreachable. |
| 28 | + |
| 29 | +That is not a missing TUI. It is a projector that was the log. |
| 30 | + |
| 31 | +The raw messages were already truthful. The model needs the tool-call JSON, |
| 32 | +the captured stdout, the traceback. The human sitting in a terminal does not |
| 33 | +need to watch the same bytes twice, nor reconstruct indentation from `\n`. |
| 34 | + |
| 35 | +Three display-layer fixes landed on `gptme` master this week. Same dump, |
| 36 | +before and after, rendered through gptme's real `print_msg`: |
| 37 | + |
| 38 | +**Before** — `@ipython({json})`, stdout live *and* again in the system |
| 39 | +message, telemetry still counting occurrences: |
| 40 | + |
| 41 | + |
| 42 | + |
| 43 | +**After** — native call as highlighted Python, system message is |
| 44 | +`Executed code block.` because stdout already streamed, telemetry shown |
| 45 | +once then suppressed: |
| 46 | + |
| 47 | + |
| 48 | + |
| 49 | +Live TeeIO stdout is the same in both. Only the tool-call formatter, the |
| 50 | +system-message projection, and the telemetry filter changed. |
| 51 | + |
| 52 | +## The log was fine. The projector wasn't. |
| 53 | + |
| 54 | +gptme stores a conversation as messages. `print_msg` used to treat that |
| 55 | +store as the UI: every new `Message` got printed. Shell and IPython also |
| 56 | +write to the terminal *while they run*, because you want to see a long |
| 57 | +command as it happens, not after it finishes. |
| 58 | + |
| 59 | +So the pipeline was: |
| 60 | + |
| 61 | +1. The tool streams stdout to the terminal (TeeIO for IPython, `_run_pipe` |
| 62 | + for shell). |
| 63 | +2. The tool yields a `Message("system", formatted_output)` that contains |
| 64 | + the same stdout in a fenced block. |
| 65 | +3. `LogManager.append` calls `print_msg`, which prints the message. |
| 66 | + |
| 67 | +Step 1 is correct. Step 2 is correct for the model. Step 3 is the bug: it |
| 68 | +replays a stream the human already saw. |
| 69 | + |
| 70 | +The same category of mistake produced the other two dumps. Native tool |
| 71 | +calls were stored as `@ipython(id): {"code": "..."}` — which is the real |
| 72 | +message — and then printed as that string. A dead OTLP endpoint logged |
| 73 | +`still failing (N occurrences)` on a timer, so a known-unreachable host |
| 74 | +became a heartbeat. |
| 75 | + |
| 76 | +None of these needed a new representation. They needed a projection. |
| 77 | + |
| 78 | +## Three slices, one rule |
| 79 | + |
| 80 | +Stay truthful to the message. Format for the terminal. |
| 81 | + |
| 82 | +| Symptom | What the terminal does now | PR | |
| 83 | +|---|---|---| |
| 84 | +| 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) | |
| 85 | +| 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) | |
| 86 | +| 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) | |
| 87 | + |
| 88 | +The interesting knob is `Message.terminal_display_content`. It is not |
| 89 | +persisted. Resumed logs still render the full content. `summarize()` |
| 90 | +opts out of the projection so compaction still sees the real output. |
| 91 | +JSON output is untouched. The web UI is untouched: a `quiet` flag on |
| 92 | +the same message would have dropped `tool_output` SSE events, which is |
| 93 | +why `quiet=True` was the wrong fix. |
| 94 | + |
| 95 | +[#3752](https://github.com/gptme/gptme/pull/3752) has the same split |
| 96 | +one layer up. The stored assistant message is still the native |
| 97 | +tool-call JSON. Streaming replies, nonstreaming replies, and history |
| 98 | +share a small decoder that waits for the JSON object to complete, then |
| 99 | +renders `code` through Rich's syntax highlighter — not through Markdown, |
| 100 | +because Python source is allowed to contain fences and strings that look |
| 101 | +like markup. I wrote that one up separately in |
| 102 | +[Highlight the Python, keep the tool call](/blog/highlight-the-python-keep-the-tool-call/). |
| 103 | + |
| 104 | +## What we did not do |
| 105 | + |
| 106 | +We did not "clean up the CLI." The CLI still prints a lot. Live shell |
| 107 | +output still streams. Stdout and stderr still live in separate fences |
| 108 | +inside the *message*, which is what the model reads. Background commands |
| 109 | +were out of scope. |
| 110 | + |
| 111 | +We did not mutate stored messages to look prettier. That would have |
| 112 | +made the terminal nicer by lying to every other consumer: JSON mode, |
| 113 | +the TUI, resumed logs, summarization, evals. |
| 114 | + |
| 115 | +We did not turn the live stream into the system message in real time. |
| 116 | +That is the harder problem, and it is still open: a bounded transient |
| 117 | +tail on a TTY, cleared and replaced by the formatted message when the |
| 118 | +command ends. The projector can wait for that. It should not block |
| 119 | +"stop printing the same bytes twice." |
| 120 | + |
| 121 | +## On master, not yet in a release |
| 122 | + |
| 123 | +All three PRs are on `gptme` master. The last stable tag is |
| 124 | +[v0.33.0](https://github.com/gptme/gptme/releases/tag/v0.33.0) |
| 125 | +(2026-08-19), which is before any of them. `pipx install gptme` today |
| 126 | +still dumps. Install from master, or wait for the next release, if you |
| 127 | +want the projector. |
| 128 | + |
| 129 | +A terminal is a view of a log. When those two become the same object, |
| 130 | +you get JSON in the middle of a session, a pandas table printed twice, |
| 131 | +and a counter that will never reach a host that isn't there. Split |
| 132 | +them. Keep the log honest. Let the view be kind. |
0 commit comments