charmbracelet / charmbracelet/x
vt: DECSTBM/DECSLRM margins are not clamped to the screen; a scroll after a shrink panics in InsertLineArea
- Dominant language
- Go
- Stars
- 314
- Forks
- 94
- Avg merge
- 3d 22h
- Merged PRs (30d)
- 2
Description
### Summary
`vt` accepts DECSTBM and DECSLRM margins that lie past the screen. The scroll region then reaches beyond the buffer, and the next operation that scrolls inside it (a reverse index at the top of the region, for example) panics inside `ultraviolet.(*Buffer).InsertLineArea`.
This is easy to hit in practice with a resize: an application still drawing for the height it had a moment ago sends `ESC[1;29r` after the emulator has already been resized to 27 rows. xterm and other terminals clamp the bottom margin to the number of rows (and the right margin to the number of columns) and ignore the sequence when top >= bottom after clamping.
### Version
`github.com/charmbracelet/x/vt v0.0.0-20260901172002-a5dee49b2863`
`github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886`
### Repro
```go
package main
import "github.com/charmbracelet/x/vt"
func main() {
e := vt.NewEmulator(40, 27)
e.Write([]byte("\x1b[1;29r")) // bottom margin past the 27 rows
e.Write([]byte("top\x1b[H\x1bM")) // home, then reverse index: scroll down through the region
}
```
```
panic: runtime error: index out of range [28] with length 27
github.com/charmbracelet/ultraviolet.(*Buffer).InsertLineArea buffer.go:478
github.com/charmbracelet/ultraviolet.(*RenderBuffer).InsertLineArea buffer.go:736
github.com/charmbracelet/x/vt.(*Screen).InsertLine screen.go:334
github.com/charmbracelet/x/vt.(*Screen).ScrollDown screen.go:313
github.com/charmbracelet/x/vt.(*Emulator).reverseIndex cc.go:50
github.com/charmbracelet/x/vt.(*Emulator).handleEsc handlers.go:205
```
`ESC[?69h ESC[1;60s` on a 40-column screen has the same effect through the right margin.
### Where
`handlers.go`, the `'r'` CSI handler: `bottom` comes from the parameters and goes straight to `setVerticalMargins(top-1, bottom)` with no comparison against `e.Height()`. The `'s'` handler under the left-right margin mode does the same for the right margin against `e.Width()`. `Screen.Resize` resets the region to the bounds, so the bad region only appears when the margins are set after the shrink, which is exactly what a resizing application does.
### Suggested fix
Clamp `bottom` to `height` and `right` to `width` in the two handlers (or in `setVerticalMargins` / `setHorizontalMargins`), and return without changing the region when `top >= bottom` after clamping. `InsertLineArea` and `DeleteLineArea` could also intersect `area` with `b.Bounds()` as a second line of defence.
Happy to send a PR for either if you would like one.
Contributor guide
Research direction
Start in handlers.go at the CSI 'r' handler and the left-right margin 's' handler, then trace setVerticalMargins and setHorizontalMargins. Reproduce with the Go program in the issue and verify that margins beyond the emulator dimensions are clamped, invalid post-clamp ranges leave the region unchanged, and reverse-index scrolling no longer panics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100