diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 12a25e1..44b4bba 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -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 } } diff --git a/internal/tui/view.go b/internal/tui/view.go index c32d6a9..122844c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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.