Skip to content
Open
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
92 changes: 74 additions & 18 deletions internal/cli/chat_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ type chatTUI struct {
// window). Reset when Ctrl+C clears non-empty input instead.
lastCtrlCAt time.Time

// statusLineCount is the number of terminal rows the status block occupies
// (wrapped status line + wrapped data line). Updated each frame so bottomRows
// can reserve the correct height; starts at 2 (unwrapped) until first render.
statusLineCount int

// host is the running MCP servers (nil when no plugins). The TUI reads
// prompts (slash commands), resources (@-references), and server status
// (/mcp) from it.
Expand Down Expand Up @@ -444,6 +449,7 @@ func newChatTUI(ctrl *control.Controller, missing string, eventCh chan event.Eve
commands: ctrl.Commands(),
skills: ctrl.Skills(),
viewport: viewport.New(viewport.WithWidth(termW)),
statusLineCount: 2,
}
}

Expand Down Expand Up @@ -527,6 +533,10 @@ func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if contentW < 1 {
contentW = 1
}
// Recompute the wrapped status-line count so bottomRows reserves the right
// height for the viewport. The data-line tags (model, git, effort, context,
// cache, jobs, balance) are the ones most likely to wrap on a narrow terminal.
cm.statusLineCount = cm.computeStatusLineCount(contentW)
cm.viewport.SetWidth(contentW)
cm.viewport.SetHeight(cm.transcriptHeight())
// Re-feed only when the content grew or the width changed (re-wrapping is
Expand Down Expand Up @@ -1174,6 +1184,9 @@ func (m chatTUI) bottomRows() int {
if !m.hideComposer() {
rows += m.input.Height() + 2
}
if m.statusLineCount > 0 {
return rows + m.statusLineCount
}
return rows + 2
}

Expand Down Expand Up @@ -1682,10 +1695,10 @@ func (m chatTUI) View() tea.View {
}
}
// Second status row: the live data (model, git, effort, context gauge, cache
// rates, jobs, balance). It lives on its own fixed row so it's always shown in
// full rather than being truncated off the end of the status line. Two rows is
// a fixed height, so unlike a wrap-when-long status it doesn't reintroduce
// resize ghosting.
// rates, jobs, balance). It lives on its own row so it's always visible; if it
// exceeds the terminal width it wraps to additional rows instead of being
// truncated. Wrapping is safe in the alt-screen view — there's no scrollback
// to strand — and computeStatusLineCount reserves the correct height.
var data []string
if mt := m.modelTag(); mt != "" {
data = append(data, mt)
Expand Down Expand Up @@ -1745,17 +1758,17 @@ func (m chatTUI) View() tea.View {
}
// Layout: the working spinner (when running), then the composer when visible,
// then the two status rows (line 1 = mode + shortcuts/state, line 2 = live data).
// Each row is clamped to width independently so neither wraps; padding to full
// width keeps a short row from leaving stale cells from the prior frame.
// Each row is wrapped to width so long data lines flow onto additional rows
// instead of being truncated. Padding to full width prevents stale cells.
if working != "" {
parts = append(parts, workingStyle.Width(boxW).MaxWidth(boxW).Render(clampStatusLine(working, boxW)))
parts = append(parts, workingStyle.Width(boxW).MaxWidth(boxW).Render(wrapStatusLine(working, boxW)))
rowsAboveBox++
}
if footer := m.renderMainManagerFooter(); footer != "" {
parts = append(parts, footer)
rowsAboveBox += strings.Count(footer, "\n") + 1
}
statusBlock := clampStatusLine(status, boxW) + "\n" + clampStatusLine(dataLine, boxW)
statusBlock := wrapStatusLine(status, boxW) + "\n" + wrapStatusLine(dataLine, boxW)
if !hideComposer {
parts = append(parts, box)
}
Expand Down Expand Up @@ -2028,16 +2041,59 @@ func truncateSubject(s string, width int) string {
return ansi.Truncate(s, max, "…")
}

// clampStatusLine truncates a status line to `width` visible columns, ANSI-aware,
// appending an ellipsis and a reset. The bottom region must stay a fixed height —
// the non-alt-screen renderer commits scrollback by clearing the prior frame's
// lines, so a status that wraps to a second row strands input-box borders in
// history. Truncating (not wrapping) keeps it one row regardless of how many tags
// (ctx · cache · avg · jobs · balance) it carries on a narrow terminal.
func clampStatusLine(s string, width int) string {
// ansi.Truncate is ANSI-aware, counts wide chars, and appends the tail when
// it actually clips — one row regardless of how many tags the status carries.
return ansi.Truncate(s, width, "…")
// wrapStatusLine wraps a status line to `width` visible columns, ANSI-aware,
// so text that exceeds one row flows onto additional lines instead of being
// truncated with an ellipsis. Wrapping is permissive — spaces are preferred
// break points — and works within the alt-screen view so there is no scrollback
// artifact.
func wrapStatusLine(s string, width int) string {
if width <= 0 || s == "" {
return s
}
return ansi.Hardwrap(s, width, true)
}

// computeStatusLineCount returns the number of terminal rows the status block
// (first status line + data line) will occupy after wrapping to `width`. The
// mode-tag status line is usually one row; the data line (model · git · effort
// · context · cache · jobs · balance) wraps when the tags exceed the terminal
// width. A custom statusline command likewise may wrap.
func (m chatTUI) computeStatusLineCount(width int) int {
if m.ctrl == nil {
return 2 // safe default when called from tests without a real controller
}
lines := 1 // first status line (mode tag + state) — always fits one row

// Second status line — replicate the data-tag construction from View().
dims := []string{}
if mt := m.modelTag(); mt != "" {
dims = append(dims, mt)
}
if gt := m.gitTag(); gt != "" {
dims = append(dims, gt)
}
if et := m.effortTag(); et != "" {
dims = append(dims, et)
}
if ct := m.contextTag(); ct != "" {
dims = append(dims, ct)
}
if cache := m.cacheTag(); cache != "" {
dims = append(dims, cache)
}
if jt := m.jobsTag(); jt != "" {
dims = append(dims, jt)
}
if m.balance != "" {
dims = append(dims, m.balance)
}
dataLine := " " + strings.Join(dims, " · ")
if m.statuslineCmd != "" && m.statuslineOut != "" {
dataLine = " " + m.statuslineOut
}
wrapped := wrapStatusLine(dataLine, width)
lines += strings.Count(wrapped, "\n") + 1
return lines
}

// growInputToFit resizes the textarea to the number of lines its value spans,
Expand Down
Loading