From d78e92a839560e121709dc14cad934d9ab5a4a71 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 27 Jul 2026 15:15:48 +0200 Subject: [PATCH] fix(tui): drop duplicated in-transcript thinking placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a streamed assistant turn had no content yet, the transcript rendered a spinner + "thinking…" placeholder line that duplicated the top-bar progress signal (spinner + status + elapsed time). The placeholder is removed and an empty in-flight turn no longer renders a bare odek block at all — the top bar stays the single progress signal and the turn block appears atomically once the first token, reasoning chunk, or tool step arrives. --- internal/tui/model_smoke_test.go | 29 +++++++++++++++++++++++++++++ internal/tui/view.go | 25 ++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go index 4d1a7aa..8930e19 100644 --- a/internal/tui/model_smoke_test.go +++ b/internal/tui/model_smoke_test.go @@ -74,6 +74,35 @@ func TestRenderStreamingTurn(t *testing.T) { } } +// TestEmptyStreamingTurnHidden verifies that while a streamed assistant turn +// has no content yet, the transcript shows neither a "thinking…" placeholder +// nor a bare odek block — the top-bar spinner is the only progress signal. +// The turn block appears atomically once the first real content arrives. +func TestEmptyStreamingTurnHidden(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, + message{role: roleUser, content: "hi"}, + message{role: roleAsst, streaming: true}, + ) + m.curIdx = 1 + m.busy = true + + out := plain(m.conversation()) + if strings.Contains(out, "thinking…") { + t.Errorf("transcript shows duplicated thinking placeholder:\n%s", out) + } + if strings.Contains(out, "⬡ odek") { + t.Errorf("empty streaming turn renders a bare odek block:\n%s", out) + } + + // First reasoning chunk makes the turn block visible. + m.handleEvent(client.Event{Type: "thinking", Content: "hmm"}) + out = plain(m.conversation()) + if !strings.Contains(out, "⬡ odek") || !strings.Contains(out, "hmm") { + t.Errorf("turn block did not appear after first content:\n%s", out) + } +} + // TestApprovalAndAutocompleteRender ensures the approval panel and the @-popup // render at full and narrow widths without panicking. func TestApprovalAndAutocompleteRender(t *testing.T) { diff --git a/internal/tui/view.go b/internal/tui/view.go index 635dac6..4dedc37 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -283,6 +283,11 @@ func (m *Model) conversation() string { blocks = append(blocks, m.convPrefix) } for i := tail; i < len(m.msgs); i++ { + if emptyStreamingTurn(m.msgs[i]) { + // Nothing to show yet: the top-bar spinner is the sole + // progress signal — no bare odek block, no placeholder. + continue + } s, r := m.renderMessage(m.msgs[i], i, lineOffset) blocks = append(blocks, s) refs = append(refs, r...) @@ -297,6 +302,23 @@ func (m *Model) conversation() string { return strings.Join(blocks, "\n\n") } +// emptyStreamingTurn reports whether a message is an in-flight assistant +// turn without any visible content yet — no tokens, reasoning, or steps. +func emptyStreamingTurn(msg message) bool { + if msg.role != roleAsst || !msg.streaming { + return false + } + if strings.TrimSpace(msg.content) != "" || strings.TrimSpace(msg.thinking) != "" || len(msg.steps) > 0 { + return false + } + for _, it := range msg.items { + if !it.thinking || strings.TrimSpace(it.text) != "" { + return false + } + } + return true +} + func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []stepRef) { th := m.th // Pre-styled cards (e.g. /stats) render verbatim — no label, no bar, and @@ -320,9 +342,6 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st if !msg.streaming && msg.rendered != "" { content = msg.rendered } - if strings.TrimSpace(content) == "" && msg.streaming { - content = th.thinkStyle.Render(m.sp.View() + " thinking…") - } // Compose the turn body from the chronological timeline: reasoning // blocks and tool steps interleaved in arrival order. items := msg.items