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
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,15 @@ feat(tui): compact tool steps with Ctrl+E details toggle
alongside it. `msg.content` stays the "\n\n"-joined blob of all reply
segments (appendReply maintains it) for export, stats, and hand-built
messages; turn markers (`**Cancelled.**` etc.) attach to the last reply.
Render-only layers (intent rail, always-on step peek, turn receipt, live
The live intent rail holds completed sentences (or a short frozen stem)
so a fast `thinking_delta` stream cannot ticker the excerpt; sealed and
finalized blocks still show the last two sentences. The status line
stays a quiet `reasoning` / `composing` label while those surfaces own
the words; live tool steps use a static `▸` (one spinner: the status
line). `beginWireTurn` must not `GotoBottom` — `refresh()` already
sticks when the reader is at the bottom, and a yank fights the
"↓ new output" contract. Render-only layers
(intent rail, always-on step peek, turn receipt, live
swarm band, sub-agent chip strip, swarm receipt rail) must not reorder
`items[]` — the parallel-tool swarm is a consecutive overlay on unfinished
parent steps and dissolves when one leftover remains. Sub-agent children
Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ own front-end settings are separate; see [Configuration](#configuration).
- **Turn cards** — telemetry rides the turn head, a coding receipt
(`touched 4 · +82 −19 · tests ✓`) scans what the turn changed, `^F`
folds noisy turns to that receipt, `alt+↑`/`alt+↓` jump turn-to-turn,
reasoning renders as an intent rail (last sentences, `beat N/M`), and
reasoning renders as an intent rail (held sentences while live, `beat N/M`), and
`^E` expands every tool step's full details.
- **Typed tool renderers** — diffs tint with a `+N −M` chip, file reads get
line numbers, JSON pretty-prints, and step lines earn typed chips from
Expand All @@ -165,12 +165,13 @@ own front-end settings are separate; see [Configuration](#configuration).
- **Streaming answers** rendered as Markdown
([glamour](https://github.com/charmbracelet/glamour)).
- **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph
per tool, a spinner, and a result peek (`⎿`, first 1–2 typed-renderer
beats) so a finished step is scannable without `^E`. Running steps speak
the same progress copy as the status line (`🧪 running tests`) and tick
their own elapsed clock. Two or more in-flight calls wrap in a parallel
swarm band that shrinks as members finish and dissolves on the last
leftover. Full output stays behind expand.
per tool, a static live mark (`▸`), and a result peek (`⎿`, first 1–2
typed-renderer beats) so a finished step is scannable without `^E`. The
status line is the only spinner. Running steps speak the same progress
copy as the status line (`🧪 running tests`) and tick their own elapsed
clock. Two or more in-flight calls wrap in a parallel swarm band that
shrinks as members finish and dissolves on the last leftover. Full
output stays behind expand.
- **Fluent by default** — gradient wordmark, smooth braille spinner, smart
autoscroll that never yanks you while you read history, and a
scroll-position indicator.
Expand All @@ -182,15 +183,18 @@ own front-end settings are separate; see [Configuration](#configuration).
### Working with the agent

- **Live reasoning** — the model's pre-tool thinking streams as an intent
rail (last two sentences, never flattened) with elapsed time. The clock
freezes when that think cycle yields (a tool or the reply). A turn that
rail (finished sentences, held until the next one lands — never a token
ticker) with elapsed time. The clock freezes when that think cycle yields
(a tool or the reply). A turn that
thinks more than once labels each block `beat 2/3` — one beat is one
think→act cycle. Tab / `^E` still unfolds the stored full block. Long
turns keep every think→reply pair intact: each reasoning block is
followed by its own answer card, in arrival order.
- **Context-aware progress** — while the agent works, a status line right
below your last message shows what it's actually doing (`🧪 running
tests`, `📖 reading client.go`, `🚀 pushing`) with a live elapsed timer.
Reasoning and composing stay quiet labels — the intent rail and the
answer card own those words.
- **Sub-agents** — a delegation paints an always-on chip strip under the
parent step (`⟳ SA1 explore · ✓ SA2 lint · ✗ SA3 types`), so you can
see who is running or who failed without expanding. Click a chip or
Expand Down Expand Up @@ -236,9 +240,10 @@ own front-end settings are separate; see [Configuration](#configuration).
trims, tool execution times) stays silent. Info traces fade after 3s;
errors, warnings, and disconnect notes autoclose after 10s.
- **Just-in-time hints** — the first time a state appears (a held prompt,
a sub-agent swarm, a multi-step turn), a one-time 💡 tip teaches its key,
then stays silent for the run. Features surface the moment they matter;
no keybinding table required.
a sub-agent swarm, a multi-step turn), a one-time 💡 tip teaches its key
and dwells 8s (5s longer than info traces), then stays silent for the
run. Features surface the moment they matter; no keybinding table
required.
- **Session home dashboard** — after `/clear`, the home card orients: the
last prompt and receipt, the context gauge, up to three recent sessions
(titles sanitized), and one action line pointing at the `^K` hub.
Expand Down
8 changes: 4 additions & 4 deletions internal/tui/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ func TestStatusBadgeStates(t *testing.T) {
t.Error("responding status line missing")
}
m.status = "thinking"
if plain(m.statusLine()) == "" {
t.Error("thinking status line empty")
if got := plain(m.statusLine()); !strings.Contains(got, "reasoning") {
t.Errorf("thinking status line = %q, want reasoning", got)
}

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

view := plain(m.View())
marker := strings.Index(view, "unique-marker prompt")
thinking := strings.Index(view, "🧠 thinking")
thinking := strings.Index(view, "reasoning")
if thinking < 0 {
t.Fatal("busy view missing the thinking status line")
t.Fatal("busy view missing the reasoning status line")
}
if marker < 0 || thinking < marker {
t.Error("status line must render below the last user message")
Expand Down
9 changes: 7 additions & 2 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import (
// subagent) stay on screen before fading out.
const noticeTTL = 3 * time.Second

// hintTTL is how long just-in-time teaching tips dwell. Five seconds
// longer than info traces so a chord has time to land.
const hintTTL = noticeTTL + 5*time.Second

// alertTTL is how long alert-tier notices (errors, warnings, disconnects,
// shutdown / upgrade hints) dwell before fading — longer than the info
// traces so a glance away doesn't miss them, but bounded like everything
Expand Down Expand Up @@ -504,6 +508,8 @@ func (m *Model) ensureWireTurn() {
// beginWireTurn appends the streaming card and arms the busy turn state
// shared by the stamped-frame path (openWakeTurn) and the lazy path
// (ensureWireTurn). wake decides the systemWake marker and status line.
// The viewport is left alone: refresh() already sticks when the reader
// is at the bottom; a forced GotoBottom would yank scrollback.
func (m *Model) beginWireTurn(wake bool) {
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake})
m.curIdx = len(m.msgs) - 1
Expand All @@ -521,8 +527,7 @@ func (m *Model) beginWireTurn(wake bool) {
m.sessionStart = m.runStart
}
m.relayout() // the busy status line claims a row above the input
m.refresh()
m.vp.GotoBottom() // new activity: show it even when reading scrollback
m.refresh() // sticks only when already at the bottom — leave scrollback
}

// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/hints.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ func (m *Model) teach(key, text string) {
return
}
m.hintsShown[key] = true
m.pushNote("💡 "+text, time.Now().Add(noticeTTL))
m.pushNote("💡 "+text, time.Now().Add(hintTTL))
}
16 changes: 16 additions & 0 deletions internal/tui/hints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tui
import (
"strings"
"testing"
"time"

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

func TestHintDwellsLongerThanInfoTraces(t *testing.T) {
m := newTestModel()
m.teach(hintQueue, "tip: dwell check")
if len(m.noticeExp) != 1 {
t.Fatalf("teach should post one note, got %d", len(m.noticeExp))
}
dwell := time.Until(m.noticeExp[0])
if dwell <= noticeTTL || dwell > hintTTL {
t.Errorf("hint dwell = %v, want (%v, %v]", dwell, noticeTTL, hintTTL)
}
if hintTTL != noticeTTL+5*time.Second {
t.Errorf("hintTTL = %v, want noticeTTL+5s (%v)", hintTTL, noticeTTL+5*time.Second)
}
}

func TestQueueHintFiresOnce(t *testing.T) {
m := newTestModel()
m.busy = true
Expand Down
87 changes: 70 additions & 17 deletions internal/tui/narrative.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,20 @@ import (

// ── thinking intent rail ────────────────────────────────────────────────────

const thinkingExcerptSentences = 2
const (
thinkingExcerptSentences = 2
thinkingLiveStem = 72 // frozen opening clause until a sentence lands
)

// lastSentences returns the last n sentences of s, preserving internal
// newlines inside each sentence. A sentence ends at `.!?` followed by
// whitespace or EOF. Fewer than n sentences returns s trimmed.
func lastSentences(s string, n int) string {
s = strings.TrimSpace(s)
if s == "" || n <= 0 {
return s
}
type sentSpan struct {
start, end int
complete bool // ended on .!? ; false = still-growing tail
}

// sentenceSpans splits s on `.!?` followed by whitespace or EOF.
func sentenceSpans(s string) []sentSpan {
rs := []rune(s)
type span struct{ start, end int }
var spans []span
var spans []sentSpan
start := 0
for i := 0; i < len(rs); i++ {
if rs[i] != '.' && rs[i] != '!' && rs[i] != '?' {
Expand All @@ -31,28 +32,80 @@ func lastSentences(s string, n int) string {
if i+1 < len(rs) && !unicode.IsSpace(rs[i+1]) {
continue // abbreviation / decimal
}
spans = append(spans, span{start, i + 1})
spans = append(spans, sentSpan{start, i + 1, true})
start = i + 1
}
if start < len(rs) {
if tail := strings.TrimSpace(string(rs[start:])); tail != "" {
spans = append(spans, span{start, len(rs)})
spans = append(spans, sentSpan{start, len(rs), false})
}
}
return spans
}

func joinSpans(s string, spans []sentSpan) string {
if len(spans) == 0 {
return ""
}
rs := []rune(s)
return strings.TrimSpace(string(rs[spans[0].start:spans[len(spans)-1].end]))
}

// lastSentences returns the last n sentences of s, preserving internal
// newlines inside each sentence. A sentence ends at `.!?` followed by
// whitespace or EOF. Fewer than n sentences returns s trimmed.
func lastSentences(s string, n int) string {
s = strings.TrimSpace(s)
if s == "" || n <= 0 {
return s
}
spans := sentenceSpans(s)
if len(spans) <= n {
return s
}
kept := spans[len(spans)-n:]
return strings.TrimSpace(string(rs[kept[0].start:kept[len(kept)-1].end]))
return joinSpans(s, spans[len(spans)-n:])
}

// lastCompleteSentences is lastSentences without the unfinished tail — the
// live rail holds these so a fast model cannot turn the excerpt into a ticker.
func lastCompleteSentences(s string, n int) string {
s = strings.TrimSpace(s)
if s == "" || n <= 0 {
return ""
}
var done []sentSpan
for _, sp := range sentenceSpans(s) {
if sp.complete {
done = append(done, sp)
}
}
if len(done) == 0 {
return ""
}
if len(done) > n {
done = done[len(done)-n:]
}
return joinSpans(s, done)
}

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

// thinkingExcerptLive is the collapsed rail while a think cycle is still
// streaming: finished sentences only, held until the next one lands. Before
// the first period, a short opening stem freezes once it fills so the
// transcript does not chase tokens.
func thinkingExcerptLive(s string) string {
if held := lastCompleteSentences(s, thinkingExcerptSentences); held != "" {
return capThinkingTail(held, maxThinkingLen)
}
return capThinkingText(strings.TrimSpace(s), thinkingLiveStem)
}

// capThinkingTail keeps the last n runes of s, snapping forward to a
// whitespace so the visible excerpt starts on a word boundary.
func capThinkingTail(s string, n int) string {
Expand Down
34 changes: 34 additions & 0 deletions internal/tui/narrative_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ import (
"github.com/BackendStack21/bodek/internal/client"
)

func TestLastCompleteSentences(t *testing.T) {
in := "First beat. Second beat. Growing tail"
if got := lastCompleteSentences(in, 2); !strings.Contains(got, "First") || !strings.Contains(got, "Second") || strings.Contains(got, "Growing") {
t.Errorf("complete last 2 = %q", got)
}
if got := lastCompleteSentences("no period yet at all", 2); got != "" {
t.Errorf("unfinished only = %q, want empty", got)
}
if got := lastCompleteSentences("Done.", 2); got != "Done." {
t.Errorf("single complete = %q", got)
}
}

func TestThinkingExcerptLiveHoldsSentences(t *testing.T) {
held := thinkingExcerptLive("I will read the file. Then I patch it. And now I am mid")
if !strings.Contains(held, "I will read") || !strings.Contains(held, "patch") {
t.Errorf("live excerpt dropped a finished sentence: %q", held)
}
if strings.Contains(held, "mid") {
t.Errorf("live excerpt chased the unfinished tail: %q", held)
}

stem := thinkingExcerptLive("opening clause without a stop " + strings.Repeat("word ", 40))
if n := len([]rune(stem)); n > thinkingLiveStem {
t.Errorf("live stem exceeded the freeze cap (%d > %d): %q", n, thinkingLiveStem, stem)
}
if !strings.Contains(stem, "opening") {
t.Errorf("live stem lost the opening: %q", stem)
}
}

func TestLastSentences(t *testing.T) {
in := "First beat. Second beat.\nThird beat!"
if got := lastSentences(in, 2); !strings.Contains(got, "Second") || !strings.Contains(got, "Third") || strings.Contains(got, "First") {
Expand Down Expand Up @@ -100,6 +131,9 @@ func TestRunningStepLiveProgress(t *testing.T) {
if !strings.Contains(got, "reading") {
t.Errorf("running step missing progress copy: %q", got)
}
if !strings.Contains(got, "▸") {
t.Errorf("running step must use the static live glyph, not a spinner: %q", got)
}
if !strings.Contains(got, "2.0s") && !strings.Contains(got, "1.9s") && !strings.Contains(got, "2.1s") {
// 2s ± a tick — don't assert an exact tenth.
if !strings.Contains(got, "s") {
Expand Down
30 changes: 30 additions & 0 deletions internal/tui/scroll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ func TestBusyRefreshKeepsScrollback(t *testing.T) {
}
}

// TestWireTurnKeepsScrollback: a server-opened card (wake / remote /
// lazy ensureWireTurn) must not yank a reader who is up in history.
// refresh() already sticks when AtBottom(); beginWireTurn must not
// override that with GotoBottom.
func TestWireTurnKeepsScrollback(t *testing.T) {
m := newTestModel()
tallTranscript(m)
m.vp.GotoTop()
if m.vp.AtBottom() {
t.Fatal("precondition: scrolled off the bottom")
}

m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_wake", Initiated: "system"})
if m.vp.AtBottom() {
t.Error("wire turn yanked scrollback to the bottom")
}
if !m.busy || m.cur() < 0 {
t.Fatal("turn_started should still open the streaming card")
}
if foot := plain(m.footer()); !strings.Contains(foot, "new output") {
t.Errorf("scrollback should advertise new output, footer=%q", foot)
}

m.vp.GotoBottom()
m.handleEvent(client.Event{Type: "done"})
if !m.vp.AtBottom() {
t.Error("at-bottom reader should stay pinned after the wire turn")
}
}

// TestTranscriptPrefixCached verifies the finalized transcript prefix renders
// once and is reused across streaming ticks, and that the cache invalidates on
// finalize, resize, and wholesale transcript replacement (session resume).
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,9 @@ func TestRenderStepsSubagentAndError(t *testing.T) {
t.Errorf("peek should not expand nested sub-agent logs:\n%s", plainOut)
}

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