From 9a674f553e4c6c89acd1f1b275195810a61660b9 Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Tue, 10 Mar 2026 16:13:11 -0700 Subject: [PATCH] feat: handle insert mode Handle ANSI terminal mode `4` as terminal state, so printable input inserts into the active row instead of always overwriting content. ANSI `CSI 4 h` enables insert mode, and `CSI 4 l` restores normal overwrite mode. If a program prints: echo -ne 'abcde\r\033[3C\033[4h \033[4l\n' # \r = CR, move to column 0 # \033[3C = CSI 3 C, move right 3 columns # \033[4h = CSI 4 h, enable insert mode # " " = insert a space at the cursor, # shifting existing text right # \033[4l = CSI 4 l, disable insert mode # \n = LF, move to the next line a terminal should show: abc de The space is inserted at column 3, and the existing `de` shifts one cell to the right. Before this change, midterm painted the space as a normal overwrite, so it effectively read the sequence as: abc e The bug showed up in Bubble Tea redraws that briefly enabled insert mode around ordinary glyphs during prompt updates. midterm moved the cursor correctly and printed the glyphs, but it did not interpret `CSI 4 h` when handling ordinary printable input. That meant inserted glyphs overwrote the cell under the cursor instead of shifting the remainder of the row right. Fix printable input to honor insert mode by shifting the active row at the cursor before painting the rune, and clear that state on reset so overwrite mode is restored predictably. (Also delete the unused 'eachFrame' function from terminal_test.go.) --- handler.go | 4 +++ terminal.go | 8 ++++++ terminal_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/handler.go b/handler.go index 699e815..bd1e2f3 100644 --- a/handler.go +++ b/handler.go @@ -391,6 +391,8 @@ func (v *Terminal) SetMode(mode ansicode.TerminalMode) { switch mode { case ansicode.TerminalModeCursorKeys: forward = true + case ansicode.TerminalModeInsert: + v.insertMode = true case ansicode.TerminalModeLineWrap: case ansicode.TerminalModeBlinkingCursor: epoch := time.Now() @@ -627,6 +629,8 @@ func (v *Terminal) UnsetMode(mode ansicode.TerminalMode) { switch mode { case ansicode.TerminalModeCursorKeys: forward = true + case ansicode.TerminalModeInsert: + v.insertMode = false case ansicode.TerminalModeLineWrap: case ansicode.TerminalModeBlinkingCursor: v.CursorBlinkEpoch = nil diff --git a/terminal.go b/terminal.go index 5e55bdf..9155ea0 100644 --- a/terminal.go +++ b/terminal.go @@ -52,6 +52,10 @@ type Terminal struct { // to the next line if another character is printed. wrap bool + // insertMode indicates whether printable input + // should shift row contents right. + insertMode bool + *ansicode.Decoder // onResize is a hook called every time the terminal resizes. @@ -147,6 +151,7 @@ func (v *Terminal) Reset() { v.mut.Lock() defer v.mut.Unlock() v.reset() + v.insertMode = false } func (v *Terminal) UsedHeight() int { @@ -247,6 +252,9 @@ func (v *Terminal) put(r rune) { v.wrap = false } x, y, f := v.Cursor.X, v.Cursor.Y, v.Cursor.F + if v.insertMode { + v.insertCharacters(1) + } v.paint(y, x, f, r) if y > v.MaxY { v.MaxY = y diff --git a/terminal_test.go b/terminal_test.go index aa1fe91..9a7d978 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -202,8 +202,70 @@ func TestResizeGrowingHeightThenShrinkWidth(t *testing.T) { require.NoError(t, err) } -func eachFrame(r io.Reader, callback func(frame int, segment []byte)) { - eachNthFrame(r, 1, callback) +func TestInsertModePreservesShiftedContentAcrossLines(t *testing.T) { + term := midterm.NewTerminal(24, 80) + term.Raw = true + + // Seed the two-line prompt before the redraw: + // + // Please answer: first + // (▼ for other options) + _, err := io.WriteString(term, "\r\x1b[JPlease answer: first \r\n(▼ for other options)") + require.NoError(t, err) + + // Replace "first" with "second", + // and insert "▲" and "▼" on the next line + // so the existing space shifts: + // + // Please answer: second + // (▲▼ for other options) + _, err = io.WriteString(term, "\x1b[A\x1b[6Dsecond\x1b[4h \x1b[4l\r\n\x1b[C▲\x1b[4h▼\x1b[4l") + require.NoError(t, err) + + require.Equal(t, "Please answer: second", strings.TrimRight(string(term.Content[0]), " ")) + require.Equal(t, "(▲▼ for other options)", strings.TrimRight(string(term.Content[1]), " ")) +} + +func TestInsertModeShiftsSingleLineContent(t *testing.T) { + term := midterm.NewTerminal(1, 8) + term.Raw = true + + // Start with the cursor after the final "e": + // + // abcde^ + _, err := io.WriteString(term, "abcde") + require.NoError(t, err) + + // Return to column 3, enable insert mode, and insert a space: + // + // abc^de + // abc ^de + _, err = io.WriteString(term, "\r\x1b[3C\x1b[4h \x1b[4l") + require.NoError(t, err) + + require.Equal(t, "abc de", strings.TrimRight(string(term.Content[0]), " ")) +} + +func TestUnsetInsertModeRestoresReplaceMode(t *testing.T) { + term := midterm.NewTerminal(1, 8) + term.Raw = true + + // Start with the cursor after the final "e": + // + // abcde^ + _, err := io.WriteString(term, "abcde") + require.NoError(t, err) + + // Insert a space at column 3, disable insert mode, then overwrite the "d" + // with "Z" at the next cursor position: + // + // abc^de + // abc ^de + // abc Z^e + _, err = io.WriteString(term, "\r\x1b[3C\x1b[4h \x1b[4lZ") + require.NoError(t, err) + + require.Equal(t, "abc Ze", strings.TrimRight(string(term.Content[0]), " ")) } func eachNthFrame(r io.Reader, n int, callback func(frame int, segment []byte)) {