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
29 changes: 29 additions & 0 deletions internal/tui/model_smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 22 additions & 3 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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
Expand All @@ -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
Expand Down