mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
tui: keep live output outside scrollback
This commit is contained in:
parent
76ca012170
commit
aceaffdec2
2 changed files with 107 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue