fix: session dialog highlight doubling, row overflow, and resize flicker
- DrawLog: invalidate cached bottom rows when selection-highlight escapes are present so VS Code's terminal doesn't leave stale background colors on the previous cursor row - session dialog: hard-clamp row text to terminal width so long session summaries don't soft-wrap into adjacent rows - Resize: clear scrollback alongside screen so stale wider content doesn't bleed through when the terminal is narrowed
- commit
- facc7090604d99d817f4154f89cb2d5b969ca37e
- author
- patriceckhart · · 2026-05-09 20:55 UTC
- parent
- 9f0629b
- signature
- unsigned
2 files · +52 -9
M internal/agent/modes/session_dialog.go +17 -8
| @@ -172,7 +172,9 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||
| 172 | 172 | ||
| 173 | 173 | // formatSessionRowPlain returns the session row body without any ANSI | |
| 174 | 174 | // styling so the caller can wrap it in either a plain mute color or a | |
| 175 | - | // full-row selection highlight. | |
| 175 | + | // full-row selection highlight. The returned string is guaranteed to | |
| 176 | + | // fit within maxWidth visible characters so the terminal never soft- | |
| 177 | + | // wraps it into the next row. | |
| 176 | 178 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |
| 177 | 179 | when := formatRelative(s.Started) | |
| 178 | 180 | summary := strings.TrimSpace(s.Title) | |
| @@ -182,17 +184,24 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||
| 182 | 184 | if summary == "" { | |
| 183 | 185 | summary = "(empty)" | |
| 184 | 186 | } | |
| 187 | + | summary = strings.ReplaceAll(summary, "\n", " ") | |
| 185 | 188 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | |
| 186 | 189 | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | |
| 187 | - | room := maxWidth - len(left) | |
| 188 | - | if room < 10 { | |
| 189 | - | room = 10 | |
| 190 | + | room := maxWidth - len([]rune(left)) | |
| 191 | + | if room < 4 { | |
| 192 | + | room = 4 | |
| 190 | 193 | } | |
| 191 | - | if len(summary) > room { | |
| 192 | - | summary = summary[:room-1] + "…" | |
| 194 | + | runes := []rune(summary) | |
| 195 | + | if len(runes) > room { | |
| 196 | + | summary = string(runes[:room-1]) + "…" | |
| 193 | 197 | } | |
| 194 | - | summary = strings.ReplaceAll(summary, "\n", " ") | |
| 195 | - | return left + summary | |
| 198 | + | row := left + summary | |
| 199 | + | // Hard clamp: ensure the full row never exceeds maxWidth. | |
| 200 | + | rowRunes := []rune(row) | |
| 201 | + | if len(rowRunes) > maxWidth { | |
| 202 | + | row = string(rowRunes[:maxWidth-1]) + "…" | |
| 203 | + | } | |
| 204 | + | return row | |
| 196 | 205 | } | |
| 197 | 206 | ||
| 198 | 207 | func formatRelative(t time.Time) string { | |
| @@ -172,7 +172,9 @@ func clampViewTop(viewTop, cursor, window, total int) int { | |||||
| 172 | 172 | ||||
| 173 | // formatSessionRowPlain returns the session row body without any ANSI | 173 | // formatSessionRowPlain returns the session row body without any ANSI | ||
| 174 | // styling so the caller can wrap it in either a plain mute color or a | 174 | // styling so the caller can wrap it in either a plain mute color or a | ||
| 175 | - | // full-row selection highlight. | 175 | + | // full-row selection highlight. The returned string is guaranteed to |
| 176 | + | // fit within maxWidth visible characters so the terminal never soft- | |||
| 177 | + | // wraps it into the next row. | |||
| 176 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | 178 | func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | ||
| 177 | when := formatRelative(s.Started) | 179 | when := formatRelative(s.Started) | ||
| 178 | summary := strings.TrimSpace(s.Title) | 180 | summary := strings.TrimSpace(s.Title) | ||
| @@ -182,17 +184,24 @@ func formatSessionRowPlain(s core.SessionSummary, maxWidth int) string { | |||||
| 182 | if summary == "" { | 184 | if summary == "" { | ||
| 183 | summary = "(empty)" | 185 | summary = "(empty)" | ||
| 184 | } | 186 | } | ||
| 187 | + | summary = strings.ReplaceAll(summary, "\n", " ") | |||
| 185 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | 188 | left := fmt.Sprintf("%-14s %s/%s %d msgs $%.4f ", | ||
| 186 | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | 189 | when, s.Provider, s.Model, s.MessageCount, s.TotalCost) | ||
| 187 | - | room := maxWidth - len(left) | 190 | + | room := maxWidth - len([]rune(left)) |
| 188 | - | if room < 10 { | 191 | + | if room < 4 { |
| 189 | - | room = 10 | 192 | + | room = 4 |
| 190 | } | 193 | } | ||
| 191 | - | if len(summary) > room { | 194 | + | runes := []rune(summary) |
| 192 | - | summary = summary[:room-1] + "…" | 195 | + | if len(runes) > room { |
| 196 | + | summary = string(runes[:room-1]) + "…" | |||
| 193 | } | 197 | } | ||
| 194 | - | summary = strings.ReplaceAll(summary, "\n", " ") | 198 | + | row := left + summary |
| 195 | - | return left + summary | 199 | + | // Hard clamp: ensure the full row never exceeds maxWidth. |
| 200 | + | rowRunes := []rune(row) | |||
| 201 | + | if len(rowRunes) > maxWidth { | |||
| 202 | + | row = string(rowRunes[:maxWidth-1]) + "…" | |||
| 203 | + | } | |||
| 204 | + | return row | |||
| 196 | } | 205 | } | ||
| 197 | 206 | ||||
| 198 | func formatRelative(t time.Time) string { | 207 | func formatRelative(t time.Time) string { | ||
M internal/tui/render.go +35 -1
| @@ -70,7 +70,10 @@ func (r *Renderer) Resize(cols, rows int) { | |||
| 70 | 70 | r.logHardwareRow = 0 | |
| 71 | 71 | r.logInit = false | |
| 72 | 72 | if r.out != nil { | |
| 73 | - | _, _ = io.WriteString(r.out, SeqClearScreen) | |
| 73 | + | // Clear both screen and scrollback so stale content from | |
| 74 | + | // the old width doesn't bleed through. Move to (1,1) so | |
| 75 | + | // the next DrawLog/writeFull starts from a clean slate. | |
| 76 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | |
| 74 | 77 | } | |
| 75 | 78 | } | |
| 76 | 79 | } | |
| @@ -395,6 +398,37 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 395 | 398 | w.WriteString(SeqShowCursor) | |
| 396 | 399 | } | |
| 397 | 400 | ||
| 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 | + | } | |
| 431 | + | ||
| 398 | 432 | full := !r.logInit || len(r.logLines) == 0 | |
| 399 | 433 | if full { | |
| 400 | 434 | writeFull(true) | |
| @@ -70,7 +70,10 @@ func (r *Renderer) Resize(cols, rows int) { | |||||
| 70 | r.logHardwareRow = 0 | 70 | r.logHardwareRow = 0 | ||
| 71 | r.logInit = false | 71 | r.logInit = false | ||
| 72 | if r.out != nil { | 72 | if r.out != nil { | ||
| 73 | - | _, _ = io.WriteString(r.out, SeqClearScreen) | 73 | + | // Clear both screen and scrollback so stale content from |
| 74 | + | // the old width doesn't bleed through. Move to (1,1) so | |||
| 75 | + | // the next DrawLog/writeFull starts from a clean slate. | |||
| 76 | + | _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1)) | |||
| 74 | } | 77 | } | ||
| 75 | } | 78 | } | ||
| 76 | } | 79 | } | ||
| @@ -395,6 +398,37 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 395 | w.WriteString(SeqShowCursor) | 398 | w.WriteString(SeqShowCursor) | ||
| 396 | } | 399 | } | ||
| 397 | 400 | ||||
| 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 | + | } | |||
| 431 | + | ||||
| 398 | full := !r.logInit || len(r.logLines) == 0 | 432 | full := !r.logInit || len(r.logLines) == 0 | ||
| 399 | if full { | 433 | if full { | ||
| 400 | writeFull(true) | 434 | writeFull(true) | ||