diff --git a/internal/display/hint_test.go b/internal/display/hint_test.go index 29b11a32..35c9a7d4 100644 --- a/internal/display/hint_test.go +++ b/internal/display/hint_test.go @@ -18,6 +18,10 @@ func rowIndex(screen, substr string) int { return -1 } +func compactScreen(screen string) string { + return strings.Join(strings.Fields(screen), "") +} + // TestHintProviderTracksLine checks that a registered passive hint provider is // re-evaluated as the input changes: the provided lane echoes the current line. func TestHintProviderTracksLine(t *testing.T) { diff --git a/internal/display/refresh.go b/internal/display/refresh.go index a1210053..cd09c483 100644 --- a/internal/display/refresh.go +++ b/internal/display/refresh.go @@ -271,11 +271,24 @@ func (e *Engine) displayLineRefactored() { if len(e.suggested) > e.line.Len() && e.opts.GetBool("history-autosuggest") { line += color.Dim + color.Fmt(color.Fg+"242") + string(e.suggested[e.line.Len():]) + color.Reset } - // Format tabs as spaces, for consistent display - line = strutil.FormatTabs(line) + term.ClearLineAfter + // Format tabs as spaces, for consistent display. When the rendered input + // lands exactly on the terminal edge, there is no current-row tail to clear; + // writing EL in the terminal's pending-wrap state can erase the edge glyph. + wrappedAtRightEdge := e.lineCol == 0 && len(line) > 0 + line = strutil.FormatTabs(line) + if !wrappedAtRightEdge { + line += term.ClearLineAfter + } // And display the line. e.suggested.Set([]rune(line)...) core.DisplayLine(&e.suggested, e.startCols) + + // If the rendered input lands exactly on the terminal edge, terminals keep + // the cursor in a pending-wrap state. Force the wrap before later clear and + // cursor movement sequences so redraws do not overwrite or scroll input. + if wrappedAtRightEdge { + term.WriteString(term.NewlineReturn) + } } func (e *Engine) renderMultilineIndicators() { diff --git a/internal/display/render_test.go b/internal/display/render_test.go index bf277029..cf7f36e6 100644 --- a/internal/display/render_test.go +++ b/internal/display/render_test.go @@ -73,6 +73,54 @@ func TestRenderWrappingAlignment(t *testing.T) { } } +// TestRenderRightEdgeWrapAtBottomKeepsInputVisiblePerKey guards the common +// shell position: the prompt is already at the bottom of the terminal, and the +// typed input soft-wraps at the right edge. Each keypress should keep the +// current input visible instead of erasing the edge character or scrolling one +// row per refresh. +func TestRenderRightEdgeWrapAtBottomKeepsInputVisiblePerKey(t *testing.T) { + const ( + cols = 20 + rows = 8 + ) + const prompt = "P> " + + c := startConsole(t, consoleConfig{ + prompt: prompt, + cols: cols, + rows: rows, + prefill: rows - 1, + }) + + typed := "abcdefghijklmnopqrstu" + firstWrappedInputLen := cols - len(prompt) + 1 + stableTopRow := -1 + + c.waitForScreen(prompt) + for idx, ch := range typed { + c.send(string(ch)) + want := typed[:idx+1] + + screen := c.waitUntil(func(screen string) bool { + return strings.Contains(compactScreen(screen), "P>"+want) + }) + + if idx+1 == firstWrappedInputLen { + stableTopRow = rowIndex(screen, prompt) + } + if idx+1 > firstWrappedInputLen { + if got := rowIndex(screen, prompt); got != stableTopRow { + t.Fatalf("prompt scrolled while typing within the same wrapped row after key %d (%q): prompt row %d, want %d\n%s", idx+1, ch, got, stableTopRow, screen) + } + } + } + + c.send("\r") + c.waitUntil(func(screen string) bool { + return strings.Contains(compactScreen(screen), "[LINE:"+typed+"]") + }) +} + // TestRenderMultilinePromptAtBottom guards the bottom-of-window case: a // multi-line primary prompt rendered on the last row of the terminal must keep // all of its lines (including the input line) instead of overlapping them.