Skip to content

Commit 5ba0bc3

Browse files
fix(tui): calm the live stream and hold teaching tips (#78)
* fix(tui): hold live thinking and calm the stream chrome Fast models were turning the intent rail into a token ticker and stacking extra motion on top (per-step spinners, a competing thinking label, and wire turns yanking scrollback). Hold finished sentences on the live rail, keep one spinner on the status line, and leave history alone until ^G. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tui): dwell JIT tips five seconds longer Three seconds is too short to read a chord tip while a turn is moving. Keep info traces at 3s; teaching notes now hold for 8s. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 320bff4 commit 5ba0bc3

12 files changed

Lines changed: 212 additions & 54 deletions

‎AGENTS.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,15 @@ feat(tui): compact tool steps with Ctrl+E details toggle
9797
alongside it. `msg.content` stays the "\n\n"-joined blob of all reply
9898
segments (appendReply maintains it) for export, stats, and hand-built
9999
messages; turn markers (`**Cancelled.**` etc.) attach to the last reply.
100-
Render-only layers (intent rail, always-on step peek, turn receipt, live
100+
The live intent rail holds completed sentences (or a short frozen stem)
101+
so a fast `thinking_delta` stream cannot ticker the excerpt; sealed and
102+
finalized blocks still show the last two sentences. The status line
103+
stays a quiet `reasoning` / `composing` label while those surfaces own
104+
the words; live tool steps use a static `▸` (one spinner: the status
105+
line). `beginWireTurn` must not `GotoBottom` — `refresh()` already
106+
sticks when the reader is at the bottom, and a yank fights the
107+
"↓ new output" contract. Render-only layers
108+
(intent rail, always-on step peek, turn receipt, live
101109
swarm band, sub-agent chip strip, swarm receipt rail) must not reorder
102110
`items[]` — the parallel-tool swarm is a consecutive overlay on unfinished
103111
parent steps and dissolves when one leftover remains. Sub-agent children

‎README.md‎

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ own front-end settings are separate; see [Configuration](#configuration).
154154
- **Turn cards** — telemetry rides the turn head, a coding receipt
155155
(`touched 4 · +82 −19 · tests ✓`) scans what the turn changed, `^F`
156156
folds noisy turns to that receipt, `alt+↑`/`alt+↓` jump turn-to-turn,
157-
reasoning renders as an intent rail (last sentences, `beat N/M`), and
157+
reasoning renders as an intent rail (held sentences while live, `beat N/M`), and
158158
`^E` expands every tool step's full details.
159159
- **Typed tool renderers** — diffs tint with a `+N −M` chip, file reads get
160160
line numbers, JSON pretty-prints, and step lines earn typed chips from
@@ -165,12 +165,13 @@ own front-end settings are separate; see [Configuration](#configuration).
165165
- **Streaming answers** rendered as Markdown
166166
([glamour](https://github.com/charmbracelet/glamour)).
167167
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
168-
per tool, a spinner, and a result peek (`⎿`, first 1–2 typed-renderer
169-
beats) so a finished step is scannable without `^E`. Running steps speak
170-
the same progress copy as the status line (`🧪 running tests`) and tick
171-
their own elapsed clock. Two or more in-flight calls wrap in a parallel
172-
swarm band that shrinks as members finish and dissolves on the last
173-
leftover. Full output stays behind expand.
168+
per tool, a static live mark (`▸`), and a result peek (`⎿`, first 1–2
169+
typed-renderer beats) so a finished step is scannable without `^E`. The
170+
status line is the only spinner. Running steps speak the same progress
171+
copy as the status line (`🧪 running tests`) and tick their own elapsed
172+
clock. Two or more in-flight calls wrap in a parallel swarm band that
173+
shrinks as members finish and dissolves on the last leftover. Full
174+
output stays behind expand.
174175
- **Fluent by default** — gradient wordmark, smooth braille spinner, smart
175176
autoscroll that never yanks you while you read history, and a
176177
scroll-position indicator.
@@ -182,15 +183,18 @@ own front-end settings are separate; see [Configuration](#configuration).
182183
### Working with the agent
183184

184185
- **Live reasoning** — the model's pre-tool thinking streams as an intent
185-
rail (last two sentences, never flattened) with elapsed time. The clock
186-
freezes when that think cycle yields (a tool or the reply). A turn that
186+
rail (finished sentences, held until the next one lands — never a token
187+
ticker) with elapsed time. The clock freezes when that think cycle yields
188+
(a tool or the reply). A turn that
187189
thinks more than once labels each block `beat 2/3` — one beat is one
188190
think→act cycle. Tab / `^E` still unfolds the stored full block. Long
189191
turns keep every think→reply pair intact: each reasoning block is
190192
followed by its own answer card, in arrival order.
191193
- **Context-aware progress** — while the agent works, a status line right
192194
below your last message shows what it's actually doing (`🧪 running
193195
tests`, `📖 reading client.go`, `🚀 pushing`) with a live elapsed timer.
196+
Reasoning and composing stay quiet labels — the intent rail and the
197+
answer card own those words.
194198
- **Sub-agents** — a delegation paints an always-on chip strip under the
195199
parent step (`⟳ SA1 explore · ✓ SA2 lint · ✗ SA3 types`), so you can
196200
see who is running or who failed without expanding. Click a chip or
@@ -236,9 +240,10 @@ own front-end settings are separate; see [Configuration](#configuration).
236240
trims, tool execution times) stays silent. Info traces fade after 3s;
237241
errors, warnings, and disconnect notes autoclose after 10s.
238242
- **Just-in-time hints** — the first time a state appears (a held prompt,
239-
a sub-agent swarm, a multi-step turn), a one-time 💡 tip teaches its key,
240-
then stays silent for the run. Features surface the moment they matter;
241-
no keybinding table required.
243+
a sub-agent swarm, a multi-step turn), a one-time 💡 tip teaches its key
244+
and dwells 8s (5s longer than info traces), then stays silent for the
245+
run. Features surface the moment they matter; no keybinding table
246+
required.
242247
- **Session home dashboard** — after `/clear`, the home card orients: the
243248
last prompt and receipt, the context gauge, up to three recent sessions
244249
(titles sanitized), and one action line pointing at the `^K` hub.

‎internal/tui/coverage_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ func TestStatusBadgeStates(t *testing.T) {
5353
t.Error("responding status line missing")
5454
}
5555
m.status = "thinking"
56-
if plain(m.statusLine()) == "" {
57-
t.Error("thinking status line empty")
56+
if got := plain(m.statusLine()); !strings.Contains(got, "reasoning") {
57+
t.Errorf("thinking status line = %q, want reasoning", got)
5858
}
5959

6060
// Approval arrives mid-turn: the panel owns the input area, so the badge
@@ -91,9 +91,9 @@ func TestStatusLinePlacement(t *testing.T) {
9191

9292
view := plain(m.View())
9393
marker := strings.Index(view, "unique-marker prompt")
94-
thinking := strings.Index(view, "🧠 thinking")
94+
thinking := strings.Index(view, "reasoning")
9595
if thinking < 0 {
96-
t.Fatal("busy view missing the thinking status line")
96+
t.Fatal("busy view missing the reasoning status line")
9797
}
9898
if marker < 0 || thinking < marker {
9999
t.Error("status line must render below the last user message")

‎internal/tui/events.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import (
1919
// subagent) stay on screen before fading out.
2020
const noticeTTL = 3 * time.Second
2121

22+
// hintTTL is how long just-in-time teaching tips dwell. Five seconds
23+
// longer than info traces so a chord has time to land.
24+
const hintTTL = noticeTTL + 5*time.Second
25+
2226
// alertTTL is how long alert-tier notices (errors, warnings, disconnects,
2327
// shutdown / upgrade hints) dwell before fading — longer than the info
2428
// traces so a glance away doesn't miss them, but bounded like everything
@@ -504,6 +508,8 @@ func (m *Model) ensureWireTurn() {
504508
// beginWireTurn appends the streaming card and arms the busy turn state
505509
// shared by the stamped-frame path (openWakeTurn) and the lazy path
506510
// (ensureWireTurn). wake decides the systemWake marker and status line.
511+
// The viewport is left alone: refresh() already sticks when the reader
512+
// is at the bottom; a forced GotoBottom would yank scrollback.
507513
func (m *Model) beginWireTurn(wake bool) {
508514
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake})
509515
m.curIdx = len(m.msgs) - 1
@@ -521,8 +527,7 @@ func (m *Model) beginWireTurn(wake bool) {
521527
m.sessionStart = m.runStart
522528
}
523529
m.relayout() // the busy status line claims a row above the input
524-
m.refresh()
525-
m.vp.GotoBottom() // new activity: show it even when reading scrollback
530+
m.refresh() // sticks only when already at the bottom — leave scrollback
526531
}
527532

528533
// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in

‎internal/tui/hints.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@ func (m *Model) teach(key, text string) {
3131
return
3232
}
3333
m.hintsShown[key] = true
34-
m.pushNote("💡 "+text, time.Now().Add(noticeTTL))
34+
m.pushNote("💡 "+text, time.Now().Add(hintTTL))
3535
}

‎internal/tui/hints_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tui
33
import (
44
"strings"
55
"testing"
6+
"time"
67

78
"github.com/BackendStack21/bodek/internal/client"
89
)
@@ -45,6 +46,21 @@ func runMiniTurn(t *testing.T, m *Model, tool, result string) {
4546
m.handleEvent(client.Event{Type: "tool_result", Name: tool, Data: result})
4647
}
4748

49+
func TestHintDwellsLongerThanInfoTraces(t *testing.T) {
50+
m := newTestModel()
51+
m.teach(hintQueue, "tip: dwell check")
52+
if len(m.noticeExp) != 1 {
53+
t.Fatalf("teach should post one note, got %d", len(m.noticeExp))
54+
}
55+
dwell := time.Until(m.noticeExp[0])
56+
if dwell <= noticeTTL || dwell > hintTTL {
57+
t.Errorf("hint dwell = %v, want (%v, %v]", dwell, noticeTTL, hintTTL)
58+
}
59+
if hintTTL != noticeTTL+5*time.Second {
60+
t.Errorf("hintTTL = %v, want noticeTTL+5s (%v)", hintTTL, noticeTTL+5*time.Second)
61+
}
62+
}
63+
4864
func TestQueueHintFiresOnce(t *testing.T) {
4965
m := newTestModel()
5066
m.busy = true

‎internal/tui/narrative.go‎

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,20 @@ import (
1010

1111
// ── thinking intent rail ────────────────────────────────────────────────────
1212

13-
const thinkingExcerptSentences = 2
13+
const (
14+
thinkingExcerptSentences = 2
15+
thinkingLiveStem = 72 // frozen opening clause until a sentence lands
16+
)
1417

15-
// lastSentences returns the last n sentences of s, preserving internal
16-
// newlines inside each sentence. A sentence ends at `.!?` followed by
17-
// whitespace or EOF. Fewer than n sentences returns s trimmed.
18-
func lastSentences(s string, n int) string {
19-
s = strings.TrimSpace(s)
20-
if s == "" || n <= 0 {
21-
return s
22-
}
18+
type sentSpan struct {
19+
start, end int
20+
complete bool // ended on .!? ; false = still-growing tail
21+
}
22+
23+
// sentenceSpans splits s on `.!?` followed by whitespace or EOF.
24+
func sentenceSpans(s string) []sentSpan {
2325
rs := []rune(s)
24-
type span struct{ start, end int }
25-
var spans []span
26+
var spans []sentSpan
2627
start := 0
2728
for i := 0; i < len(rs); i++ {
2829
if rs[i] != '.' && rs[i] != '!' && rs[i] != '?' {
@@ -31,28 +32,80 @@ func lastSentences(s string, n int) string {
3132
if i+1 < len(rs) && !unicode.IsSpace(rs[i+1]) {
3233
continue // abbreviation / decimal
3334
}
34-
spans = append(spans, span{start, i + 1})
35+
spans = append(spans, sentSpan{start, i + 1, true})
3536
start = i + 1
3637
}
3738
if start < len(rs) {
3839
if tail := strings.TrimSpace(string(rs[start:])); tail != "" {
39-
spans = append(spans, span{start, len(rs)})
40+
spans = append(spans, sentSpan{start, len(rs), false})
4041
}
4142
}
43+
return spans
44+
}
45+
46+
func joinSpans(s string, spans []sentSpan) string {
47+
if len(spans) == 0 {
48+
return ""
49+
}
50+
rs := []rune(s)
51+
return strings.TrimSpace(string(rs[spans[0].start:spans[len(spans)-1].end]))
52+
}
53+
54+
// lastSentences returns the last n sentences of s, preserving internal
55+
// newlines inside each sentence. A sentence ends at `.!?` followed by
56+
// whitespace or EOF. Fewer than n sentences returns s trimmed.
57+
func lastSentences(s string, n int) string {
58+
s = strings.TrimSpace(s)
59+
if s == "" || n <= 0 {
60+
return s
61+
}
62+
spans := sentenceSpans(s)
4263
if len(spans) <= n {
4364
return s
4465
}
45-
kept := spans[len(spans)-n:]
46-
return strings.TrimSpace(string(rs[kept[0].start:kept[len(kept)-1].end]))
66+
return joinSpans(s, spans[len(spans)-n:])
67+
}
68+
69+
// lastCompleteSentences is lastSentences without the unfinished tail — the
70+
// live rail holds these so a fast model cannot turn the excerpt into a ticker.
71+
func lastCompleteSentences(s string, n int) string {
72+
s = strings.TrimSpace(s)
73+
if s == "" || n <= 0 {
74+
return ""
75+
}
76+
var done []sentSpan
77+
for _, sp := range sentenceSpans(s) {
78+
if sp.complete {
79+
done = append(done, sp)
80+
}
81+
}
82+
if len(done) == 0 {
83+
return ""
84+
}
85+
if len(done) > n {
86+
done = done[len(done)-n:]
87+
}
88+
return joinSpans(s, done)
4789
}
4890

49-
// thinkingExcerpt is the collapsed intent-rail body: the last two sentences,
50-
// never flattened. A runaway sentence is tail-capped so the rail stays short.
91+
// thinkingExcerpt is the collapsed intent-rail body for a sealed or
92+
// finalized block: the last two sentences, never flattened.
5193
func thinkingExcerpt(s string) string {
5294
ex := lastSentences(s, thinkingExcerptSentences)
5395
return capThinkingTail(ex, maxThinkingLen)
5496
}
5597

98+
// thinkingExcerptLive is the collapsed rail while a think cycle is still
99+
// streaming: finished sentences only, held until the next one lands. Before
100+
// the first period, a short opening stem freezes once it fills so the
101+
// transcript does not chase tokens.
102+
func thinkingExcerptLive(s string) string {
103+
if held := lastCompleteSentences(s, thinkingExcerptSentences); held != "" {
104+
return capThinkingTail(held, maxThinkingLen)
105+
}
106+
return capThinkingText(strings.TrimSpace(s), thinkingLiveStem)
107+
}
108+
56109
// capThinkingTail keeps the last n runes of s, snapping forward to a
57110
// whitespace so the visible excerpt starts on a word boundary.
58111
func capThinkingTail(s string, n int) string {

‎internal/tui/narrative_test.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,37 @@ import (
88
"github.com/BackendStack21/bodek/internal/client"
99
)
1010

11+
func TestLastCompleteSentences(t *testing.T) {
12+
in := "First beat. Second beat. Growing tail"
13+
if got := lastCompleteSentences(in, 2); !strings.Contains(got, "First") || !strings.Contains(got, "Second") || strings.Contains(got, "Growing") {
14+
t.Errorf("complete last 2 = %q", got)
15+
}
16+
if got := lastCompleteSentences("no period yet at all", 2); got != "" {
17+
t.Errorf("unfinished only = %q, want empty", got)
18+
}
19+
if got := lastCompleteSentences("Done.", 2); got != "Done." {
20+
t.Errorf("single complete = %q", got)
21+
}
22+
}
23+
24+
func TestThinkingExcerptLiveHoldsSentences(t *testing.T) {
25+
held := thinkingExcerptLive("I will read the file. Then I patch it. And now I am mid")
26+
if !strings.Contains(held, "I will read") || !strings.Contains(held, "patch") {
27+
t.Errorf("live excerpt dropped a finished sentence: %q", held)
28+
}
29+
if strings.Contains(held, "mid") {
30+
t.Errorf("live excerpt chased the unfinished tail: %q", held)
31+
}
32+
33+
stem := thinkingExcerptLive("opening clause without a stop " + strings.Repeat("word ", 40))
34+
if n := len([]rune(stem)); n > thinkingLiveStem {
35+
t.Errorf("live stem exceeded the freeze cap (%d > %d): %q", n, thinkingLiveStem, stem)
36+
}
37+
if !strings.Contains(stem, "opening") {
38+
t.Errorf("live stem lost the opening: %q", stem)
39+
}
40+
}
41+
1142
func TestLastSentences(t *testing.T) {
1243
in := "First beat. Second beat.\nThird beat!"
1344
if got := lastSentences(in, 2); !strings.Contains(got, "Second") || !strings.Contains(got, "Third") || strings.Contains(got, "First") {
@@ -100,6 +131,9 @@ func TestRunningStepLiveProgress(t *testing.T) {
100131
if !strings.Contains(got, "reading") {
101132
t.Errorf("running step missing progress copy: %q", got)
102133
}
134+
if !strings.Contains(got, "▸") {
135+
t.Errorf("running step must use the static live glyph, not a spinner: %q", got)
136+
}
103137
if !strings.Contains(got, "2.0s") && !strings.Contains(got, "1.9s") && !strings.Contains(got, "2.1s") {
104138
// 2s ± a tick — don't assert an exact tenth.
105139
if !strings.Contains(got, "s") {

‎internal/tui/scroll_test.go‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ func TestBusyRefreshKeepsScrollback(t *testing.T) {
147147
}
148148
}
149149

150+
// TestWireTurnKeepsScrollback: a server-opened card (wake / remote /
151+
// lazy ensureWireTurn) must not yank a reader who is up in history.
152+
// refresh() already sticks when AtBottom(); beginWireTurn must not
153+
// override that with GotoBottom.
154+
func TestWireTurnKeepsScrollback(t *testing.T) {
155+
m := newTestModel()
156+
tallTranscript(m)
157+
m.vp.GotoTop()
158+
if m.vp.AtBottom() {
159+
t.Fatal("precondition: scrolled off the bottom")
160+
}
161+
162+
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_wake", Initiated: "system"})
163+
if m.vp.AtBottom() {
164+
t.Error("wire turn yanked scrollback to the bottom")
165+
}
166+
if !m.busy || m.cur() < 0 {
167+
t.Fatal("turn_started should still open the streaming card")
168+
}
169+
if foot := plain(m.footer()); !strings.Contains(foot, "new output") {
170+
t.Errorf("scrollback should advertise new output, footer=%q", foot)
171+
}
172+
173+
m.vp.GotoBottom()
174+
m.handleEvent(client.Event{Type: "done"})
175+
if !m.vp.AtBottom() {
176+
t.Error("at-bottom reader should stay pinned after the wire turn")
177+
}
178+
}
179+
150180
// TestTranscriptPrefixCached verifies the finalized transcript prefix renders
151181
// once and is reused across streaming ticks, and that the cache invalidates on
152182
// finalize, resize, and wholesale transcript replacement (session resume).

‎internal/tui/steps_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ func TestRenderStepsSubagentAndError(t *testing.T) {
232232
t.Errorf("peek should not expand nested sub-agent logs:\n%s", plainOut)
233233
}
234234

235-
// Streaming turn: a not-done step renders the live spinner; a not-done step
236-
// in a finalized turn renders the pending glyph. Also drive the narrow-width
237-
// budget floor.
235+
// Streaming turn: a not-done step uses the static live glyph (the
236+
// status line owns the spinner). A not-done step in a finalized turn
237+
// also renders ▸. Also drive the narrow-width budget floor.
238238
m.vp.Width = 8
239239
if s, _ := renderStepsForTest(m, message{streaming: true, steps: []step{{name: "read", arg: "x"}}}, 0, 0); s == "" {
240240
t.Error("streaming step rendered empty")

0 commit comments

Comments
 (0)