Stabilize chat rendering: remove forced full repaints, fix scroll/diff edge cases
- commit
- c01e026961fce5f2764136930fbc63d5d34d6f67
- author
- patriceckhart · · 2026-05-10 08:36 UTC
- parent
- c98e701
- signature
- unsigned
3 files · +66 -95
M internal/agent/modes/interactive.go +11 -49
| @@ -727,21 +727,13 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 727 | 727 | } | |
| 728 | 728 | } | |
| 729 | 729 | i.view.Err = i.statusErr | |
| 730 | - | i.liveBlock = i.view.BuildLive(cols) | |
| 731 | - | ||
| 732 | - | streamingActive := i.view.StreamingActive | |
| 733 | - | streaming := i.view.Streaming | |
| 734 | - | toolCalls := i.view.ToolCalls | |
| 735 | - | errLine := i.view.Err | |
| 736 | - | i.view.StreamingActive = false | |
| 737 | - | i.view.Streaming = "" | |
| 738 | - | i.view.ToolCalls = nil | |
| 739 | - | i.view.Err = "" | |
| 730 | + | // Live streaming/tool rows are appended to the chat buffer (not | |
| 731 | + | // hoisted into a separate live block above the editor). That keeps | |
| 732 | + | // the renderer's diff view append-only: when a tool finishes the | |
| 733 | + | // rows update in place at the end of the buffer, instead of the | |
| 734 | + | // whole bottom band shrinking and shifting chat lines around. | |
| 735 | + | i.liveBlock = nil | |
| 740 | 736 | chat := i.view.Build(cols) | |
| 741 | - | i.view.StreamingActive = streamingActive | |
| 742 | - | i.view.Streaming = streaming | |
| 743 | - | i.view.ToolCalls = toolCalls | |
| 744 | - | i.view.Err = errLine | |
| 745 | 737 | ||
| 746 | 738 | // Welcome banner: shown at the top of the chat area when there is | |
| 747 | 739 | // no transcript yet. Disappears after the first message is sent. | |
| @@ -1015,15 +1007,7 @@ func (i *Interactive) redraw() { | |||
| 1015 | 1007 | // content. The status block and editor get their own dedicated | |
| 1016 | 1008 | // blanks so spacing stays consistent whether or not a dialog or | |
| 1017 | 1009 | // popup is showing. | |
| 1018 | - | bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9) | |
| 1019 | - | if len(i.liveBlock) > 0 { | |
| 1020 | - | // Live streaming/tool rows are rendered outside the immutable | |
| 1021 | - | // transcript so native scrollback stays stable while the agent | |
| 1022 | - | // works. Add the same breathing row the finalized transcript path | |
| 1023 | - | // gets between adjacent messages. | |
| 1024 | - | bottom = append(bottom, "") | |
| 1025 | - | bottom = append(bottom, i.liveBlock...) | |
| 1026 | - | } | |
| 1010 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9) | |
| 1027 | 1011 | if len(dialog) > 0 { | |
| 1028 | 1012 | bottom = append(bottom, "") | |
| 1029 | 1013 | } | |
| @@ -1151,29 +1135,23 @@ func (i *Interactive) redraw() { | |||
| 1151 | 1135 | // statusLines and edLines (input breathing room). Without | |
| 1152 | 1136 | // these the rendered cursor would land on a blank instead of | |
| 1153 | 1137 | // inside the editor row. | |
| 1154 | - | liveRows := len(i.liveBlock) | |
| 1155 | - | if liveRows > 0 { | |
| 1156 | - | // Account for the leading separator row inserted before live | |
| 1157 | - | // streaming/tool content in the bottom block. | |
| 1158 | - | liveRows++ | |
| 1159 | - | } | |
| 1160 | - | cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |
| 1138 | + | cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |
| 1161 | 1139 | cursorCol := curC | |
| 1162 | 1140 | if i.btwDialog.Active() { | |
| 1163 | 1141 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | |
| 1164 | - | cursorRow = liveRows + dialogLead + r | |
| 1142 | + | cursorRow = dialogLead + r | |
| 1165 | 1143 | cursorCol = c | |
| 1166 | 1144 | } | |
| 1167 | 1145 | } | |
| 1168 | 1146 | if i.dialog.Active() { | |
| 1169 | 1147 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | |
| 1170 | - | cursorRow = liveRows + dialogLead + r | |
| 1148 | + | cursorRow = dialogLead + r | |
| 1171 | 1149 | cursorCol = c | |
| 1172 | 1150 | } | |
| 1173 | 1151 | } | |
| 1174 | 1152 | if i.sessionDialog.Active() { | |
| 1175 | 1153 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 1176 | - | cursorRow = liveRows + dialogLead + r | |
| 1154 | + | cursorRow = dialogLead + r | |
| 1177 | 1155 | cursorCol = c | |
| 1178 | 1156 | } | |
| 1179 | 1157 | } | |
| @@ -3437,10 +3415,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 3437 | 3415 | return | |
| 3438 | 3416 | } | |
| 3439 | 3417 | i.resetStreamingStateLocked() | |
| 3440 | - | // Force full repaint: assistant text committed, layout shifts. | |
| 3441 | - | if i.rend != nil { | |
| 3442 | - | i.rend.Invalidate() | |
| 3443 | - | } | |
| 3444 | 3418 | case core.EvToolUseStart: | |
| 3445 | 3419 | // Live streaming: pre-create the view so the user sees the | |
| 3446 | 3420 | // tool call being composed in real time. Any subsequent | |
| @@ -3500,12 +3474,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 3500 | 3474 | } | |
| 3501 | 3475 | tc.Result = text.String() | |
| 3502 | 3476 | } | |
| 3503 | - | // Force full repaint: tool completion shifts the layout | |
| 3504 | - | // (collapsed body replaces streaming) and the diff renderer | |
| 3505 | - | // may leave stale code fragments on status bar rows. | |
| 3506 | - | if i.rend != nil { | |
| 3507 | - | i.rend.Invalidate() | |
| 3508 | - | } | |
| 3509 | 3477 | if i.cfg.OnToolResult != nil { | |
| 3510 | 3478 | i.cfg.OnToolResult(e.ID, e.Result) | |
| 3511 | 3479 | } | |
| @@ -3515,12 +3483,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 3515 | 3483 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | |
| 3516 | 3484 | } | |
| 3517 | 3485 | case core.EvTurnEnd: | |
| 3518 | - | // Force full repaint: the turn ending shifts layout (streaming | |
| 3519 | - | // buffer flushed, status bar updates) and the diff renderer | |
| 3520 | - | // may leave stale content on reused rows. | |
| 3521 | - | if i.rend != nil { | |
| 3522 | - | i.rend.Invalidate() | |
| 3523 | - | } | |
| 3524 | 3486 | if e.Stop == provider.StopAborted { | |
| 3525 | 3487 | i.resetStreamingStateLocked() | |
| 3526 | 3488 | i.statusErr = "" | |
| @@ -727,21 +727,13 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 727 | } | 727 | } | ||
| 728 | } | 728 | } | ||
| 729 | i.view.Err = i.statusErr | 729 | i.view.Err = i.statusErr | ||
| 730 | - | i.liveBlock = i.view.BuildLive(cols) | 730 | + | // Live streaming/tool rows are appended to the chat buffer (not |
| 731 | - | 731 | + | // hoisted into a separate live block above the editor). That keeps | |
| 732 | - | streamingActive := i.view.StreamingActive | 732 | + | // the renderer's diff view append-only: when a tool finishes the |
| 733 | - | streaming := i.view.Streaming | 733 | + | // rows update in place at the end of the buffer, instead of the |
| 734 | - | toolCalls := i.view.ToolCalls | 734 | + | // whole bottom band shrinking and shifting chat lines around. |
| 735 | - | errLine := i.view.Err | 735 | + | i.liveBlock = nil |
| 736 | - | i.view.StreamingActive = false | |||
| 737 | - | i.view.Streaming = "" | |||
| 738 | - | i.view.ToolCalls = nil | |||
| 739 | - | i.view.Err = "" | |||
| 740 | chat := i.view.Build(cols) | 736 | chat := i.view.Build(cols) | ||
| 741 | - | i.view.StreamingActive = streamingActive | |||
| 742 | - | i.view.Streaming = streaming | |||
| 743 | - | i.view.ToolCalls = toolCalls | |||
| 744 | - | i.view.Err = errLine | |||
| 745 | 737 | ||||
| 746 | // Welcome banner: shown at the top of the chat area when there is | 738 | // Welcome banner: shown at the top of the chat area when there is | ||
| 747 | // no transcript yet. Disappears after the first message is sent. | 739 | // no transcript yet. Disappears after the first message is sent. | ||
| @@ -1015,15 +1007,7 @@ func (i *Interactive) redraw() { | |||||
| 1015 | // content. The status block and editor get their own dedicated | 1007 | // content. The status block and editor get their own dedicated | ||
| 1016 | // blanks so spacing stays consistent whether or not a dialog or | 1008 | // blanks so spacing stays consistent whether or not a dialog or | ||
| 1017 | // popup is showing. | 1009 | // popup is showing. | ||
| 1018 | - | bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9) | 1010 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+9) |
| 1019 | - | if len(i.liveBlock) > 0 { | |||
| 1020 | - | // Live streaming/tool rows are rendered outside the immutable | |||
| 1021 | - | // transcript so native scrollback stays stable while the agent | |||
| 1022 | - | // works. Add the same breathing row the finalized transcript path | |||
| 1023 | - | // gets between adjacent messages. | |||
| 1024 | - | bottom = append(bottom, "") | |||
| 1025 | - | bottom = append(bottom, i.liveBlock...) | |||
| 1026 | - | } | |||
| 1027 | if len(dialog) > 0 { | 1011 | if len(dialog) > 0 { | ||
| 1028 | bottom = append(bottom, "") | 1012 | bottom = append(bottom, "") | ||
| 1029 | } | 1013 | } | ||
| @@ -1151,29 +1135,23 @@ func (i *Interactive) redraw() { | |||||
| 1151 | // statusLines and edLines (input breathing room). Without | 1135 | // statusLines and edLines (input breathing room). Without | ||
| 1152 | // these the rendered cursor would land on a blank instead of | 1136 | // these the rendered cursor would land on a blank instead of | ||
| 1153 | // inside the editor row. | 1137 | // inside the editor row. | ||
| 1154 | - | liveRows := len(i.liveBlock) | 1138 | + | cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR |
| 1155 | - | if liveRows > 0 { | |||
| 1156 | - | // Account for the leading separator row inserted before live | |||
| 1157 | - | // streaming/tool content in the bottom block. | |||
| 1158 | - | liveRows++ | |||
| 1159 | - | } | |||
| 1160 | - | cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR | |||
| 1161 | cursorCol := curC | 1139 | cursorCol := curC | ||
| 1162 | if i.btwDialog.Active() { | 1140 | if i.btwDialog.Active() { | ||
| 1163 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | 1141 | if r, c := i.btwDialog.CursorPos(cols); r >= 0 { | ||
| 1164 | - | cursorRow = liveRows + dialogLead + r | 1142 | + | cursorRow = dialogLead + r |
| 1165 | cursorCol = c | 1143 | cursorCol = c | ||
| 1166 | } | 1144 | } | ||
| 1167 | } | 1145 | } | ||
| 1168 | if i.dialog.Active() { | 1146 | if i.dialog.Active() { | ||
| 1169 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | 1147 | if r, c := i.dialog.CursorPos(cols); r >= 0 { | ||
| 1170 | - | cursorRow = liveRows + dialogLead + r | 1148 | + | cursorRow = dialogLead + r |
| 1171 | cursorCol = c | 1149 | cursorCol = c | ||
| 1172 | } | 1150 | } | ||
| 1173 | } | 1151 | } | ||
| 1174 | if i.sessionDialog.Active() { | 1152 | if i.sessionDialog.Active() { | ||
| 1175 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | 1153 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | ||
| 1176 | - | cursorRow = liveRows + dialogLead + r | 1154 | + | cursorRow = dialogLead + r |
| 1177 | cursorCol = c | 1155 | cursorCol = c | ||
| 1178 | } | 1156 | } | ||
| 1179 | } | 1157 | } | ||
| @@ -3437,10 +3415,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 3437 | return | 3415 | return | ||
| 3438 | } | 3416 | } | ||
| 3439 | i.resetStreamingStateLocked() | 3417 | i.resetStreamingStateLocked() | ||
| 3440 | - | // Force full repaint: assistant text committed, layout shifts. | |||
| 3441 | - | if i.rend != nil { | |||
| 3442 | - | i.rend.Invalidate() | |||
| 3443 | - | } | |||
| 3444 | case core.EvToolUseStart: | 3418 | case core.EvToolUseStart: | ||
| 3445 | // Live streaming: pre-create the view so the user sees the | 3419 | // Live streaming: pre-create the view so the user sees the | ||
| 3446 | // tool call being composed in real time. Any subsequent | 3420 | // tool call being composed in real time. Any subsequent | ||
| @@ -3500,12 +3474,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 3500 | } | 3474 | } | ||
| 3501 | tc.Result = text.String() | 3475 | tc.Result = text.String() | ||
| 3502 | } | 3476 | } | ||
| 3503 | - | // Force full repaint: tool completion shifts the layout | |||
| 3504 | - | // (collapsed body replaces streaming) and the diff renderer | |||
| 3505 | - | // may leave stale code fragments on status bar rows. | |||
| 3506 | - | if i.rend != nil { | |||
| 3507 | - | i.rend.Invalidate() | |||
| 3508 | - | } | |||
| 3509 | if i.cfg.OnToolResult != nil { | 3477 | if i.cfg.OnToolResult != nil { | ||
| 3510 | i.cfg.OnToolResult(e.ID, e.Result) | 3478 | i.cfg.OnToolResult(e.ID, e.Result) | ||
| 3511 | } | 3479 | } | ||
| @@ -3515,12 +3483,6 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 3515 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | 3483 | i.lastCtxInput = e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens | ||
| 3516 | } | 3484 | } | ||
| 3517 | case core.EvTurnEnd: | 3485 | case core.EvTurnEnd: | ||
| 3518 | - | // Force full repaint: the turn ending shifts layout (streaming | |||
| 3519 | - | // buffer flushed, status bar updates) and the diff renderer | |||
| 3520 | - | // may leave stale content on reused rows. | |||
| 3521 | - | if i.rend != nil { | |||
| 3522 | - | i.rend.Invalidate() | |||
| 3523 | - | } | |||
| 3524 | if e.Stop == provider.StopAborted { | 3486 | if e.Stop == provider.StopAborted { | ||
| 3525 | i.resetStreamingStateLocked() | 3487 | i.resetStreamingStateLocked() | ||
| 3526 | i.statusErr = "" | 3488 | i.statusErr = "" | ||
M internal/tui/render.go +47 -46
| @@ -58,6 +58,12 @@ func NewRenderer(out io.Writer) *Renderer { | |||
| 58 | 58 | // starts from a blank slate. Without the clear, characters from the | |
| 59 | 59 | // old (wider) layout linger past the new right edge and rows from | |
| 60 | 60 | // before the new bottom hang around as garbage. | |
| 61 | + | func (r *Renderer) ResetScrollRegion() { | |
| 62 | + | if r.out != nil { | |
| 63 | + | _, _ = io.WriteString(r.out, SeqResetScrollRegion) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 61 | 67 | func (r *Renderer) Resize(cols, rows int) { | |
| 62 | 68 | if cols != r.cols || rows != r.rows { | |
| 63 | 69 | r.cols = cols | |
| @@ -398,36 +404,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 398 | 404 | w.WriteString(SeqShowCursor) | |
| 399 | 405 | } | |
| 400 | 406 | ||
| 401 | - | // Selection-highlight workaround: VS Code's terminal (and some | |
| 402 | - | // others) don't reliably clear background colors when a row is | |
| 403 | - | // overwritten. When the current OR previous bottom block contains | |
| 404 | - | // selection-highlight escapes (dialogs with a cursor row), wipe | |
| 405 | - | // the cached bottom rows so the diff rewrites every one of them. | |
| 406 | - | // This mirrors the hasSelection guard in Draw(). | |
| 407 | - | if r.logInit && len(r.logLines) > 0 { | |
| 408 | - | botHasHL := func(block []string) bool { | |
| 409 | - | for _, l := range block { | |
| 410 | - | if strings.Contains(l, "\x1b[48;5;") { | |
| 411 | - | return true | |
| 412 | - | } | |
| 413 | - | } | |
| 414 | - | return false | |
| 415 | - | } | |
| 416 | - | if botHasHL(bottomFrame) || botHasHL(r.logBottom) { | |
| 417 | - | // Invalidate only the bottom portion of the cached lines | |
| 418 | - | // so the chat region (terminal scrollback) is still | |
| 419 | - | // diffed cheaply. We do this by replacing the old bottom | |
| 420 | - | // rows with empty strings so every bottom row looks | |
| 421 | - | // "changed" to the diff below. | |
| 422 | - | botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows | |
| 423 | - | if botStart < 0 { | |
| 424 | - | botStart = 0 | |
| 425 | - | } | |
| 426 | - | for idx := botStart; idx < len(r.logLines); idx++ { | |
| 427 | - | r.logLines[idx] = "\x00" // sentinel that won't match any real line | |
| 428 | - | } | |
| 429 | - | } | |
| 430 | - | } | |
| 407 | + | // Selection-highlight workaround removed: it could mis-invalidate | |
| 408 | + | // user-bubble padding rows whose colored bg made botHasHL trip, | |
| 409 | + | // causing the next diff pass to leave those rows visually thinned | |
| 410 | + | // because the cached entry was the \x00 sentinel rather than the | |
| 411 | + | // real previous bg-colored row. | |
| 431 | 412 | ||
| 432 | 413 | full := !r.logInit || len(r.logLines) == 0 | |
| 433 | 414 | if full { | |
| @@ -456,14 +437,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 456 | 437 | lastChanged = idx | |
| 457 | 438 | } | |
| 458 | 439 | } | |
| 440 | + | // Buffer grew but the appended rows were empty (or otherwise | |
| 441 | + | // equal to the implicit "" past the old end). The diff above | |
| 442 | + | // won't flag those rows, yet the renderer still needs to | |
| 443 | + | // advance its hardware cursor / viewport tracking past them so | |
| 444 | + | // the next render starts from the correct position. Treat the | |
| 445 | + | // extension as changed. | |
| 446 | + | if len(lines) > len(r.logLines) { | |
| 447 | + | if firstChanged == -1 { | |
| 448 | + | firstChanged = len(r.logLines) | |
| 449 | + | } | |
| 450 | + | if lastChanged < len(lines)-1 { | |
| 451 | + | lastChanged = len(lines) - 1 | |
| 452 | + | } | |
| 453 | + | } | |
| 459 | 454 | ||
| 460 | 455 | if firstChanged == -1 { | |
| 461 | 456 | // No content changes; the final cursor positioning below may still | |
| 462 | 457 | // move the hardware cursor if the editor cursor changed. | |
| 463 | - | } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop { | |
| 464 | - | // Shrinks and changes above the visible viewport cannot be safely | |
| 465 | - | // patched in terminal scrollback. Replay the current logical buffer | |
| 466 | - | // instead of risking stale duplicated rows. | |
| 458 | + | } else if firstChanged < r.logViewportTop { | |
| 459 | + | // Changes above the visible viewport cannot be patched safely. | |
| 467 | 460 | writeFull(true) | |
| 468 | 461 | } else { | |
| 469 | 462 | prevViewportTop := r.logViewportTop | |
| @@ -523,7 +516,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 523 | 516 | w.WriteString(SeqClearLine) | |
| 524 | 517 | w.WriteString(lines[idx]) | |
| 525 | 518 | } | |
| 526 | - | r.logHardwareRow = renderEnd | |
| 519 | + | finalRow := renderEnd | |
| 520 | + | if len(r.logLines) > len(lines) { | |
| 521 | + | extra := len(r.logLines) - len(lines) | |
| 522 | + | if extra > r.rows { | |
| 523 | + | writeFull(true) | |
| 524 | + | } else { | |
| 525 | + | for e := 0; e < extra; e++ { | |
| 526 | + | w.WriteString("\x1b[1B") | |
| 527 | + | w.WriteString("\r") | |
| 528 | + | w.WriteString("\x1b[0m") | |
| 529 | + | w.WriteString(SeqClearLine) | |
| 530 | + | finalRow++ | |
| 531 | + | } | |
| 532 | + | if extra > 0 { | |
| 533 | + | w.WriteString("\x1b[" + itoa(extra) + "A") | |
| 534 | + | finalRow -= extra | |
| 535 | + | } | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | r.logHardwareRow = finalRow | |
| 527 | 539 | r.logViewportTop = viewportTop | |
| 528 | 540 | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | |
| 529 | 541 | r.logViewportTop = minTop | |
| @@ -534,17 +546,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 534 | 546 | } | |
| 535 | 547 | } | |
| 536 | 548 | ||
| 537 | - | // Re-anchor the terminal viewport at the true end of the logical | |
| 538 | - | // buffer on every frame. During busy turns most redraws only mutate | |
| 539 | - | // spinner/live rows above the editor; without touching the final margin | |
| 540 | - | // row, the terminal can visually lose the bottom padding even though it | |
| 541 | - | // still exists in r.logLines. | |
| 542 | - | if len(lines) > 0 { | |
| 543 | - | moveToLogicalRow(len(lines) - 1) | |
| 544 | - | w.WriteString("\r") | |
| 545 | - | w.WriteString("\x1b[0m") | |
| 546 | - | w.WriteString(SeqClearLine) | |
| 547 | - | } | |
| 548 | 549 | positionCursor() | |
| 549 | 550 | w.WriteString(SeqSynchronizedOff) | |
| 550 | 551 | _, _ = io.WriteString(r.out, w.String()) | |
| @@ -58,6 +58,12 @@ func NewRenderer(out io.Writer) *Renderer { | |||||
| 58 | // starts from a blank slate. Without the clear, characters from the | 58 | // starts from a blank slate. Without the clear, characters from the | ||
| 59 | // old (wider) layout linger past the new right edge and rows from | 59 | // old (wider) layout linger past the new right edge and rows from | ||
| 60 | // before the new bottom hang around as garbage. | 60 | // before the new bottom hang around as garbage. | ||
| 61 | + | func (r *Renderer) ResetScrollRegion() { | |||
| 62 | + | if r.out != nil { | |||
| 63 | + | _, _ = io.WriteString(r.out, SeqResetScrollRegion) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 61 | func (r *Renderer) Resize(cols, rows int) { | 67 | func (r *Renderer) Resize(cols, rows int) { | ||
| 62 | if cols != r.cols || rows != r.rows { | 68 | if cols != r.cols || rows != r.rows { | ||
| 63 | r.cols = cols | 69 | r.cols = cols | ||
| @@ -398,36 +404,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 398 | w.WriteString(SeqShowCursor) | 404 | w.WriteString(SeqShowCursor) | ||
| 399 | } | 405 | } | ||
| 400 | 406 | ||||
| 401 | - | // Selection-highlight workaround: VS Code's terminal (and some | 407 | + | // Selection-highlight workaround removed: it could mis-invalidate |
| 402 | - | // others) don't reliably clear background colors when a row is | 408 | + | // user-bubble padding rows whose colored bg made botHasHL trip, |
| 403 | - | // overwritten. When the current OR previous bottom block contains | 409 | + | // causing the next diff pass to leave those rows visually thinned |
| 404 | - | // selection-highlight escapes (dialogs with a cursor row), wipe | 410 | + | // because the cached entry was the \x00 sentinel rather than the |
| 405 | - | // the cached bottom rows so the diff rewrites every one of them. | 411 | + | // real previous bg-colored row. |
| 406 | - | // This mirrors the hasSelection guard in Draw(). | |||
| 407 | - | if r.logInit && len(r.logLines) > 0 { | |||
| 408 | - | botHasHL := func(block []string) bool { | |||
| 409 | - | for _, l := range block { | |||
| 410 | - | if strings.Contains(l, "\x1b[48;5;") { | |||
| 411 | - | return true | |||
| 412 | - | } | |||
| 413 | - | } | |||
| 414 | - | return false | |||
| 415 | - | } | |||
| 416 | - | if botHasHL(bottomFrame) || botHasHL(r.logBottom) { | |||
| 417 | - | // Invalidate only the bottom portion of the cached lines | |||
| 418 | - | // so the chat region (terminal scrollback) is still | |||
| 419 | - | // diffed cheaply. We do this by replacing the old bottom | |||
| 420 | - | // rows with empty strings so every bottom row looks | |||
| 421 | - | // "changed" to the diff below. | |||
| 422 | - | botStart := len(r.logLines) - len(r.logBottom) - bottomMarginRows | |||
| 423 | - | if botStart < 0 { | |||
| 424 | - | botStart = 0 | |||
| 425 | - | } | |||
| 426 | - | for idx := botStart; idx < len(r.logLines); idx++ { | |||
| 427 | - | r.logLines[idx] = "\x00" // sentinel that won't match any real line | |||
| 428 | - | } | |||
| 429 | - | } | |||
| 430 | - | } | |||
| 431 | 412 | ||||
| 432 | full := !r.logInit || len(r.logLines) == 0 | 413 | full := !r.logInit || len(r.logLines) == 0 | ||
| 433 | if full { | 414 | if full { | ||
| @@ -456,14 +437,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 456 | lastChanged = idx | 437 | lastChanged = idx | ||
| 457 | } | 438 | } | ||
| 458 | } | 439 | } | ||
| 440 | + | // Buffer grew but the appended rows were empty (or otherwise | |||
| 441 | + | // equal to the implicit "" past the old end). The diff above | |||
| 442 | + | // won't flag those rows, yet the renderer still needs to | |||
| 443 | + | // advance its hardware cursor / viewport tracking past them so | |||
| 444 | + | // the next render starts from the correct position. Treat the | |||
| 445 | + | // extension as changed. | |||
| 446 | + | if len(lines) > len(r.logLines) { | |||
| 447 | + | if firstChanged == -1 { | |||
| 448 | + | firstChanged = len(r.logLines) | |||
| 449 | + | } | |||
| 450 | + | if lastChanged < len(lines)-1 { | |||
| 451 | + | lastChanged = len(lines) - 1 | |||
| 452 | + | } | |||
| 453 | + | } | |||
| 459 | 454 | ||||
| 460 | if firstChanged == -1 { | 455 | if firstChanged == -1 { | ||
| 461 | // No content changes; the final cursor positioning below may still | 456 | // No content changes; the final cursor positioning below may still | ||
| 462 | // move the hardware cursor if the editor cursor changed. | 457 | // move the hardware cursor if the editor cursor changed. | ||
| 463 | - | } else if len(lines) < len(r.logLines) || firstChanged < r.logViewportTop { | 458 | + | } else if firstChanged < r.logViewportTop { |
| 464 | - | // Shrinks and changes above the visible viewport cannot be safely | 459 | + | // Changes above the visible viewport cannot be patched safely. |
| 465 | - | // patched in terminal scrollback. Replay the current logical buffer | |||
| 466 | - | // instead of risking stale duplicated rows. | |||
| 467 | writeFull(true) | 460 | writeFull(true) | ||
| 468 | } else { | 461 | } else { | ||
| 469 | prevViewportTop := r.logViewportTop | 462 | prevViewportTop := r.logViewportTop | ||
| @@ -523,7 +516,26 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 523 | w.WriteString(SeqClearLine) | 516 | w.WriteString(SeqClearLine) | ||
| 524 | w.WriteString(lines[idx]) | 517 | w.WriteString(lines[idx]) | ||
| 525 | } | 518 | } | ||
| 526 | - | r.logHardwareRow = renderEnd | 519 | + | finalRow := renderEnd |
| 520 | + | if len(r.logLines) > len(lines) { | |||
| 521 | + | extra := len(r.logLines) - len(lines) | |||
| 522 | + | if extra > r.rows { | |||
| 523 | + | writeFull(true) | |||
| 524 | + | } else { | |||
| 525 | + | for e := 0; e < extra; e++ { | |||
| 526 | + | w.WriteString("\x1b[1B") | |||
| 527 | + | w.WriteString("\r") | |||
| 528 | + | w.WriteString("\x1b[0m") | |||
| 529 | + | w.WriteString(SeqClearLine) | |||
| 530 | + | finalRow++ | |||
| 531 | + | } | |||
| 532 | + | if extra > 0 { | |||
| 533 | + | w.WriteString("\x1b[" + itoa(extra) + "A") | |||
| 534 | + | finalRow -= extra | |||
| 535 | + | } | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | r.logHardwareRow = finalRow | |||
| 527 | r.logViewportTop = viewportTop | 539 | r.logViewportTop = viewportTop | ||
| 528 | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | 540 | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | ||
| 529 | r.logViewportTop = minTop | 541 | r.logViewportTop = minTop | ||
| @@ -534,17 +546,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 534 | } | 546 | } | ||
| 535 | } | 547 | } | ||
| 536 | 548 | ||||
| 537 | - | // Re-anchor the terminal viewport at the true end of the logical | |||
| 538 | - | // buffer on every frame. During busy turns most redraws only mutate | |||
| 539 | - | // spinner/live rows above the editor; without touching the final margin | |||
| 540 | - | // row, the terminal can visually lose the bottom padding even though it | |||
| 541 | - | // still exists in r.logLines. | |||
| 542 | - | if len(lines) > 0 { | |||
| 543 | - | moveToLogicalRow(len(lines) - 1) | |||
| 544 | - | w.WriteString("\r") | |||
| 545 | - | w.WriteString("\x1b[0m") | |||
| 546 | - | w.WriteString(SeqClearLine) | |||
| 547 | - | } | |||
| 548 | positionCursor() | 549 | positionCursor() | ||
| 549 | w.WriteString(SeqSynchronizedOff) | 550 | w.WriteString(SeqSynchronizedOff) | ||
| 550 | _, _ = io.WriteString(r.out, w.String()) | 551 | _, _ = io.WriteString(r.out, w.String()) | ||
M internal/tui/terminal.go +8 -0
| @@ -111,6 +111,14 @@ const ( | |||
| 111 | 111 | SeqEraseToEnd = "\x1b[J" | |
| 112 | 112 | ) | |
| 113 | 113 | ||
| 114 | + | // SetScrollRegion returns the DECSTBM sequence to set the terminal's | |
| 115 | + | // scroll region (1-indexed, inclusive). Lines that scroll out of this | |
| 116 | + | // region still flow into the terminal's scrollback, but content above | |
| 117 | + | // or below the region is not affected by scroll-induced movement. | |
| 118 | + | func SetScrollRegion(top, bottom int) string { | |
| 119 | + | return "\x1b[" + itoa(top) + ";" + itoa(bottom) + "r" | |
| 120 | + | } | |
| 121 | + | ||
| 114 | 122 | // MoveTo moves the cursor to 1-indexed (row, col). | |
| 115 | 123 | func MoveTo(row, col int) string { | |
| 116 | 124 | return "\x1b[" + itoa(row) + ";" + itoa(col) + "H" | |
| @@ -111,6 +111,14 @@ const ( | |||||
| 111 | SeqEraseToEnd = "\x1b[J" | 111 | SeqEraseToEnd = "\x1b[J" | ||
| 112 | ) | 112 | ) | ||
| 113 | 113 | ||||
| 114 | + | // SetScrollRegion returns the DECSTBM sequence to set the terminal's | |||
| 115 | + | // scroll region (1-indexed, inclusive). Lines that scroll out of this | |||
| 116 | + | // region still flow into the terminal's scrollback, but content above | |||
| 117 | + | // or below the region is not affected by scroll-induced movement. | |||
| 118 | + | func SetScrollRegion(top, bottom int) string { | |||
| 119 | + | return "\x1b[" + itoa(top) + ";" + itoa(bottom) + "r" | |||
| 120 | + | } | |||
| 121 | + | ||||
| 114 | // MoveTo moves the cursor to 1-indexed (row, col). | 122 | // MoveTo moves the cursor to 1-indexed (row, col). | ||
| 115 | func MoveTo(row, col int) string { | 123 | func MoveTo(row, col int) string { | ||
| 116 | return "\x1b[" + itoa(row) + ";" + itoa(col) + "H" | 124 | return "\x1b[" + itoa(row) + ";" + itoa(col) + "H" | ||