tui: keep live output outside scrollback

This commit is contained in:
patriceckhart 2026-05-04 15:05:30 +02:00
parent 76ca012170
commit aceaffdec2
2 changed files with 107 additions and 10 deletions

View file

@ -198,6 +198,7 @@ type Interactive struct {
toolOrder []string
statusErr string
statusOK string
liveBlock []string // live streaming/tool progress rendered outside scrollback
helpBlock []string // rendered above the chat when /help was typed
cumUsage provider.Usage
lastCtxInput int // input_tokens of the most recent turn — approximates current context size
@ -686,8 +687,21 @@ func (i *Interactive) buildChatLocked(cols int) []string {
}
}
i.view.Err = i.statusErr
i.liveBlock = i.view.BuildLive(cols)
streamingActive := i.view.StreamingActive
streaming := i.view.Streaming
toolCalls := i.view.ToolCalls
errLine := i.view.Err
i.view.StreamingActive = false
i.view.Streaming = ""
i.view.ToolCalls = nil
i.view.Err = ""
chat := i.view.Build(cols)
i.view.StreamingActive = streamingActive
i.view.Streaming = streaming
i.view.ToolCalls = toolCalls
i.view.Err = errLine
// Welcome banner: shown at the top of the chat area when there is
// no transcript yet. Disappears after the first message is sent.
@ -950,7 +964,15 @@ func (i *Interactive) redraw() {
// content. The status block and editor get their own dedicated
// blanks so spacing stays consistent whether or not a dialog or
// popup is showing.
bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8)
bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
if len(i.liveBlock) > 0 {
// Live streaming/tool rows are rendered outside the immutable
// transcript so native scrollback stays stable while the agent
// works. Add the same breathing row the finalized transcript path
// gets between adjacent messages.
bottom = append(bottom, "")
bottom = append(bottom, i.liveBlock...)
}
if len(dialog) > 0 {
bottom = append(bottom, "")
}
@ -1079,23 +1101,29 @@ func (i *Interactive) redraw() {
// statusLines and edLines (input breathing room). Without
// these the rendered cursor would land on a blank instead of
// inside the editor row.
cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
liveRows := len(i.liveBlock)
if liveRows > 0 {
// Account for the leading separator row inserted before live
// streaming/tool content in the bottom block.
liveRows++
}
cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
cursorCol := curC
if i.btwDialog.Active() {
if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
cursorRow = dialogLead + r
cursorRow = liveRows + dialogLead + r
cursorCol = c
}
}
if i.dialog.Active() {
if r, c := i.dialog.CursorPos(cols); r >= 0 {
cursorRow = dialogLead + r
cursorRow = liveRows + dialogLead + r
cursorCol = c
}
}
if i.sessionDialog.Active() {
if r, c := i.sessionDialog.CursorPos(); r >= 0 {
cursorRow = dialogLead + r
cursorRow = liveRows + dialogLead + r
cursorCol = c
}
}

View file

@ -183,6 +183,47 @@ func (v *View) Build(width int) []string {
return lines
}
// BuildLive renders only in-flight assistant/tool/error state. Main-screen
// scrollback renderers can keep these rows outside the immutable transcript
// so native scrolling stays stable while a turn streams.
func (v *View) BuildLive(width int) []string {
var out []string
if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
const indent = " "
inner := assistantBodyWidth(width - len(indent))
md := RenderMarkdown(v.Streaming, v.Theme, inner)
for _, l := range strings.Split(md, "\n") {
for _, w := range wrapANSILine(l, inner) {
out = append(out, indent+w)
}
}
out = append(out, "")
}
finalised := map[string]bool{}
for _, m := range v.Messages {
for _, c := range m.Content {
switch b := c.(type) {
case provider.ToolCallBlock:
finalised[b.ID] = true
case provider.ToolResultBlock:
finalised[b.CallID] = true
}
}
}
for _, tc := range v.ToolCalls {
if finalised[tc.ID] {
continue
}
out = append(out, v.renderToolCall(tc, width)...)
out = append(out, "")
}
if v.Err != "" {
out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
out = append(out, "")
}
return out
}
// BuildWithAnchors is like Build but additionally reports the first
// row occupied by each message in v.Messages. Callers that need to
// scroll to a specific turn (the /jump dialog) use the anchor slice
@ -646,7 +687,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
if tc.Error {
color = v.Theme.Error
}
body := toolResultBlock(v.Theme, tc.Result, width, color)
body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
for _, l := range v.collapseToolBody(body, false) {
imgCells, stripped := parseImageFootprint(l)
if hasImageEscapeLine(stripped) {
@ -748,6 +789,7 @@ const toolBoxOuterMargin = 2
// of the line so the right corner sits at column width-1, matching the
// closing edge.
func toolBoxTop(th Theme, label string, width int) string {
label = oneLineToolLabel(label)
w := width - 2*toolBoxOuterMargin
if w < 12 {
w = 12
@ -782,6 +824,10 @@ func toolBoxTop(th Theme, label string, width int) string {
return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin
}
func oneLineToolLabel(label string) string {
return strings.Join(strings.Fields(label), " ")
}
func splitToolLabel(label string) (name, rest string) {
label = strings.TrimLeft(label, " ")
if label == "" {
@ -861,6 +907,25 @@ func parseImageFootprint(s string) (int, string) {
// drifting too far right.
const toolBoxBodyTrimLeft = 2
// toolBoxBodyRenderWidth is the width body renderers should target before
// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
// then adds the box edge/padding. Passing the full terminal width lets long
// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
// visually breaking the right edge. This returns the maximum renderer width
// whose post-trim visible width fits inside the box.
func toolBoxBodyRenderWidth(width int) int {
w := width - 2*toolBoxOuterMargin
if w < 12 {
w = 12
}
inner := w - 2 - 2*toolBoxInnerPad
if inner < 1 {
inner = 1
}
return inner + toolBoxBodyTrimLeft
}
// toolBoxSide wraps a single body line with vertical box edges:
//
// │ foo bar baz │
@ -982,7 +1047,8 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
for _, b := range blocks {
switch bb := b.(type) {
case provider.TextBlock:
body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
bodyWidth := toolBoxBodyRenderWidth(width)
body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
case provider.ImageBlock:
hasImage = true
body = append(body, v.renderImageBlock(bb, width)...)
@ -1399,7 +1465,9 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
}
case i == footerIdx:
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
for _, w := range wrapLine(l, width-4, " ") {
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
}
default:
for _, w := range wrapLine(l, width-4, " ") {
out = append(out, " "+v.Theme.FG256(defaultColor, w))
@ -1586,7 +1654,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
x, ok := v.(map[string]any)
if !ok {
b, _ := json.Marshal(v)
s := string(b)
s := oneLineToolLabel(string(b))
if len(s) > 60 {
s = s[:57] + "..."
}
@ -1601,12 +1669,13 @@ func ShortArgs(tool string, raw json.RawMessage) string {
}
if primary == "" {
b, _ := json.Marshal(v)
s := string(b)
s := oneLineToolLabel(string(b))
if len(s) > 60 {
s = s[:57] + "..."
}
return s
}
primary = oneLineToolLabel(primary)
// Tool-specific decoration. Only the read tool gets a range
// suffix for now; other tools just truncate the primary arg.