diff --git a/internal/tui/coverage_test.go b/internal/tui/coverage_test.go index cd4f95c..c9ce8ce 100644 --- a/internal/tui/coverage_test.go +++ b/internal/tui/coverage_test.go @@ -98,13 +98,17 @@ func TestStatusLinePlacement(t *testing.T) { if marker < 0 || thinking < marker { t.Error("status line must render below the last user message") } + if !strings.HasPrefix(m.statusLine(), "\n") { + t.Error("status line must lead with a blank separator row") + } if strings.Contains(plain(m.header()), "thinking") { t.Error("header must not carry the busy indicator anymore") } - // The row claims exactly one line of layout; idle releases it, and a - // pending approval (which owns the input area) never shows it. - if got, want := m.inputAreaHeight(), inputHeight+1; got != want { + // The indicator claims two rows of layout — a blank separator above it, + // then the status row itself; idle releases both, and a pending approval + // (which owns the input area) never shows it. + if got, want := m.inputAreaHeight(), inputHeight+2; got != want { t.Errorf("busy inputAreaHeight = %d, want %d", got, want) } m.busy = false diff --git a/internal/tui/input.go b/internal/tui/input.go index 89e38c1..960ea74 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -112,6 +112,7 @@ func (m *Model) submit() tea.Cmd { m.ta.Reset() m.closeAC() m.refresh() + m.vp.GotoBottom() // Enter means "show me the latest", even mid-turn return nil } m.ta.Reset() @@ -136,6 +137,10 @@ func (m *Model) sendPrompt(text string) tea.Cmd { } m.relayout() // the busy status line claims a row above the input m.refresh() + // Submitting is an explicit "show me the latest" signal — jump to the + // bottom even when the reader was up in the scrollback (refresh alone + // only sticks when already at the bottom). + m.vp.GotoBottom() thinking := "" if m.thinkOn { diff --git a/internal/tui/model.go b/internal/tui/model.go index f580b10..c9a6888 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -579,7 +579,7 @@ func (m *Model) inputAreaHeight() int { } h := inputHeight if m.statusLineVisible() { - h++ // busy status line above the input box + h += 2 // busy status line + blank separator row above the input box } if m.ac.open { h += m.ac.height() diff --git a/internal/tui/promptflow_test.go b/internal/tui/promptflow_test.go index 389eb21..48149b5 100644 --- a/internal/tui/promptflow_test.go +++ b/internal/tui/promptflow_test.go @@ -185,6 +185,34 @@ func TestQueuedPromptSendsOnDone(t *testing.T) { } } +// TestSubmitJumpsToBottom verifies ⏎ always returns the viewport to the +// latest output — both for a fresh prompt and when queueing mid-turn — even +// when the reader was up in the scrollback. +func TestSubmitJumpsToBottom(t *testing.T) { + m := newTestModel() + m.ta.Focus() + tallTranscript(m) + + // Queueing mid-turn: busy model, scrolled up, ⏎. + busyTurn(m) + m.vp.GotoTop() + m.ta.SetValue("follow up") + m.submit() + if !m.vp.AtBottom() { + t.Error("queueing a prompt should jump to the latest output") + } + + // Fresh submit once the turn ended (the returned send cmd is deliberately + // not executed — newTestModel has no client). + m.busy = false + m.vp.GotoTop() + m.ta.SetValue("next prompt") + m.submit() + if !m.vp.AtBottom() { + t.Error("submitting a prompt should jump to the latest output") + } +} + // tallTranscript loads a scrollable assistant message into the transcript. // (A markdown list survives glamour as one rendered line per item; a plain // "x\n" repeat would collapse into a single wrapped paragraph.) diff --git a/internal/tui/view.go b/internal/tui/view.go index d68e248..ffdcf71 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -221,7 +221,9 @@ func (m *Model) statusLine() string { if e := m.elapsed(); e != "" { el = th.headerMeta.Render(" · " + e) } - return th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el + // A blank row above separates the indicator from the transcript tail — + // inputAreaHeight accounts for it so the layout math stays exact. + return "\n" + th.spinner.Render(m.sp.View()) + " " + th.statusBusy.Render(label) + el } // statusLineVisible reports whether the status line occupies a row, keeping