You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Normalize control characters before measuring and writing prompt text - #431
OffscreenBuffer measured control characters as zero width (EastAsianWidth treats Cc/Cf as width 0), but wrote them to the console unchanged. The line count and cursor position it computed then disagreed with what the terminal showed:
Text
Terminal
Counted as
Effect
\n, \r in a message, item, or error
Line break / carriage return
Width 0, no new line
Redraw clears too few lines and leaves the old prompt behind
\t
Next tab stop
Width 0
Cursor ends up left of where it should be
ESC[31m
No width (color)
Width 4 ([31m)
Cursor ends up to the right
Cursor movement, clear screen, OSC
Moves the cursor or changes state
—
Corrupts the prompt
\b, \0, …
Moves left or prints nothing
Width 0
Items from external data are hard to tell apart
Typed keys were already safe, because FormBase rejects control characters. Messages, items (TextSelector), answers, default values (Tab), and exception messages can still carry them from external data.
Rules
OffscreenBuffer.Write, which every piece of prompt text goes through, now normalizes text with a new ConsoleText helper. Both the width calculation and the output use the normalized text:
Line breaks (CRLF, LF, CR, NEL, LS, PS) become lines of the buffer, so multi-line messages and error messages render on several lines and are counted correctly. Cursor restoration sums the physical rows of the lines before the cursor, so a wrapped line before the input row doesn't misplace it.
Tabs expand with spaces to the next multiple of 8 columns, counted from the start of the line. The column is accumulated incrementally, not remeasured.
SGR sequences (ESC [ … m whose parameters are only digits, ; and :) are kept and take no width, so colored messages still work. A style ends with its line: each line is written separately and the console driver resets colors after every write, so the next line starts with the prompt's own colors.
Other escape sequences are removed, because they can move the cursor or change terminal state:
other CSI sequences, including private ones such as ESC[>4;2m
OSC/DCS/SOS/PM/APC strings
two-character ESC sequences
Other control characters are shown as Control Pictures (␀–␟, ␡); C1 controls are shown as U+FFFD.
Text being edited (for example, a default value inserted with Tab) shows ESC as ␛ instead of applying sequences. The text before and after the cursor is written separately and must not split a sequence, and the cursor should move over every character.
No configuration was added. Only the display changes: Select/MultiSelect return the original items, and Input returns the text as entered. Plain text, which is almost all text, takes a fast path that returns the original string without allocating.
The docs (en/ja) describe the rules in a new "Control Characters" section under "Unicode Support".
Notes for reviewers
SGR across lines: an earlier revision restored the effective SGR state at the start of each continuation line. Matching the terminal kept needing special cases: private parameters, default colors and resets against the driver's color, grouped resets, unknown codes, zero padding, and oversized parameters. We chose to let styles end with their line instead. Before this PR, a line break inside a message corrupted the redraw anyway.
Line breaks are honored rather than flattened in every context, including items. Long items already wrap across rows today, so the same layout behavior applies.
Grapheme-aware width (CJK, emoji ZWJ sequences, flags, combining marks) is unchanged. Existing EastAsianWidthTests and new cases cover it.
Testing
ConsoleTextTests:
line-break variants
tab stops: after CJK text, emoji, and SGR, from a start column, after a line break, and 10,000 tabs
escape-sequence shapes: SGR, including the colon form, is kept with zero width; private CSI, other CSI, OSC including hyperlinks, two-char ESC, and unterminated and lone ESC are handled
SGR ending with its line
visible symbols for C0, DEL, and C1
unchanged printable text
Form regressions:
multi-line message and multi-line error redraws
a wrapped line before the input row places the cursor on the input row
Select items with control characters still return the original value
escape sequences in edited input are shown literally
Each form regression fails without its fix.
dotnet build, dotnet test (1344 passed), and dotnet format --verify-no-changes.
Native AOT verification (win-x64): 65/65 for net8.0 and net10.0.
OffscreenBuffer measured text with control characters as zero width but
wrote them to the console as is, so the line count and cursor position
did not match the output: a line break in a message or error was not
counted and the redraw left lines behind, a tab moved the cursor further
than counted, and an SGR sequence such as ESC[31m was measured as four
columns. Other escape sequences in external data could move the cursor or
clear the screen.
OffscreenBuffer.Write now normalizes text through ConsoleText, and both
the width and the output use the result:
- Line breaks (CRLF, LF, CR, NEL, LS, PS) become lines of the buffer.
- Tabs expand with spaces to the next multiple of 8 columns.
- SGR sequences are kept and take no width; other escape sequences are
removed.
- Other control characters are shown as Control Pictures (or U+FFFD for
C1).
Only the display changes; returned values are unchanged. Plain text takes
a fast path without allocation. Docs (en/ja) describe the rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e cursor after wrapped lines
- A final m does not make a CSI an SGR: ESC[>4;2m enables xterm's
modifyOtherKeys, and it was kept. SGR now requires parameters of only
digits, semicolons and colons; private parameters (<, =, >, ?) are
dropped like other commands.
- Lines are written separately and the console driver resets colors after
each write, so a style that spanned a line break was lost on the next
line. The SGR sequences active at a line break are now replayed at the
start of the next line, until a full reset.
- The pushed cursor's line index was used as a physical row, so after a
line that wraps (for example, a long first line of a multi-line
message) the cursor landed one row too high. The rows of the preceding
lines are now summed with the current buffer width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Avoid quadratic tab normalization during prompt redraws
src/Sharprompt/Internal/ConsoleText.cs:59
Each tab copies and remeasures the entire expanded line prefix, making normalization quadratic for tab-heavy external text. For example, 10,000 tabs produce 80,000 spaces but require scanning nearly 400 million characters. This work repeats on every prompt redraw and can stall interactive input. Track the display column incrementally and measure only newly appended text runs, preserving grapheme-aware widths and zero-width SGR handling.
Normalize complete input before splitting around the cursor
src/Sharprompt/Internal/OffscreenBuffer.cs:67
OffscreenBufferExtensions.WriteInput writes the raw text before and after the cursor separately, so normalizing each write can split an escape sequence. With DefaultValue = "\x1b[31mred\x1b[0m", pressing Tab then LeftArrow changes the visible input from red to redm: the first write drops the incomplete reset, while the second prints its final m. Normalize the complete input and map the raw cursor position to its displayed position before splitting the output. Add a regression covering Tab followed by cursor movement within an escape sequence.
…rally in edited text
- Continuation lines replayed every SGR sequence since the last full
reset, so a line that restored its color with ESC[39m still grew the
history, and repeated style changes made the output quadratic. SGR is
now kept as effective per-attribute state, including selective resets
(22, 23, 39, 49, ...), and restored with one compact sequence.
- Each tab remeasured the whole line so far, which was quadratic for
tab-heavy text. The column is now accumulated, measuring only the text
appended since the previous tab; a control character always ends a
grapheme cluster.
- Input renders the text before and after the cursor separately, which
split an escape sequence the cursor was inside (a default value
ESC[31mred ESC[0m showed "redm" after Tab and LeftArrow). Text being
edited now shows ESC as U+241B, so every character stays visible and the
cursor moves over each one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unknown SGR codes cause quadratic continuation output
src/Sharprompt/Internal/ConsoleText.cs:324
The fallback retains every distinct unrecognized SGR code, and each newline replays all accumulated entries. For 1,024 lines containing codes 1000–2023 (ESC[1000mx\n, etc.), 9,216 input characters produce 2,634,240 normalized characters. The compact-state change therefore still permits quadratic allocation and terminal output for external multiline text. Track supported attributes explicitly and exclude unknown codes from continuation state; add a regression using distinct unknown codes.
… SGR state bounded
- The console driver applies its own foreground color to every line, so
an explicit reset to the terminal default (0, 39, 49) is now kept as
state and restored first. Before, it was dropped, and a continuation
line took the driver's color, such as ColorSchema.Error.
- Resets that cover several attributes clear them instead of being
stored as attributes of their own, which replayed them after the
enable: 54 (frame and encircle), 75 (superscript and subscript), 65
(ideogram), 23 (italic and fraktur), 50 (proportional spacing), and 10
(fonts).
- Unknown codes are still written where they appear but are no longer
carried over, so distinct unknown codes cannot grow every continuation
line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Oversized color tokens cause quadratic continuation expansion
src/Sharprompt/Internal/ConsoleText.cs:373
The number of stored attributes is bounded, but each value can still contain an arbitrarily long raw token. For example, ESC[38:2:: followed by 2,048 zeroes, 255:0:0m, and 2,048 newlines expands 4,112 input characters into 4,229,136 normalized characters because the entire color token is replayed on every line. External text can therefore cause quadratic allocation and terminal output on each redraw, even with just one known attribute. Canonicalize supported parameters or bound the stored representation without truncating escape sequences, and add a regression for one oversized known attribute followed by many newlines.
- Extended color modes were compared as strings, so ESC[38;05;196m lost
its color on continuation lines and ESC[38;02;255;0;0m restored a reset
instead of red. Modes and components are now parsed as numbers, and
both semicolon and colon forms are recognized.
- Stored attributes kept the raw parameter, so an oversized one (such as
thousands of leading zeros) was replayed in full on every line. Each
attribute is now stored in canonical form, which bounds the restoring
sequence regardless of the input. Parameters that are not numbers
leave the attribute out of the state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Restoring the effective SGR state at the start of each continuation line
kept needing special cases to match the terminal: private parameters,
default colors and full resets against the driver's color, grouped
resets, unknown codes, zero padding, and oversized parameters. SGR
sequences are still written where they appear and take no width, but a
style now ends with its line: each line is written separately and the
console driver resets colors after every write, so the next line starts
with the prompt's own colors. The docs (en/ja) say so and suggest
repeating the sequence on each line.
This removes SgrState and its tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Retained SGR attributes need explicit resetting to prevent styles such as conceal from affecting subsequent input.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Fragments may leak SGR attributes across lines
src/Sharprompt/Internal/OffscreenBuffer.cs:78
These fragments rely on DefaultConsoleDriver.Write calling Console.ResetColor() to end SGR styles. That restores colors, but does not guarantee resetting attributes such as underline or conceal. On terminals whose color reset leaves those attributes active, ESC[8mfirst\nsecond can therefore conceal the second line and subsequent input. Append an explicit full SGR reset to each fragment containing retained SGR sequences, and add a rendering-level regression that checks the reset is emitted before the next line.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #411.
OffscreenBuffermeasured control characters as zero width (EastAsianWidthtreatsCc/Cfas width 0), but wrote them to the console unchanged. The line count and cursor position it computed then disagreed with what the terminal showed:\n,\rin a message, item, or error\tESC[31m[31m)\b,\0, …Typed keys were already safe, because
FormBaserejects control characters. Messages, items (TextSelector), answers, default values (Tab), and exception messages can still carry them from external data.Rules
OffscreenBuffer.Write, which every piece of prompt text goes through, now normalizes text with a newConsoleTexthelper. Both the width calculation and the output use the normalized text:ESC [ … mwhose parameters are only digits,;and:) are kept and take no width, so colored messages still work. A style ends with its line: each line is written separately and the console driver resets colors after every write, so the next line starts with the prompt's own colors.ESC[>4;2mESCsequences␀–␟,␡); C1 controls are shown asU+FFFD.ESCas␛instead of applying sequences. The text before and after the cursor is written separately and must not split a sequence, and the cursor should move over every character.No configuration was added. Only the display changes:
Select/MultiSelectreturn the original items, andInputreturns the text as entered. Plain text, which is almost all text, takes a fast path that returns the original string without allocating.The docs (en/ja) describe the rules in a new "Control Characters" section under "Unicode Support".
Notes for reviewers
EastAsianWidthTestsand new cases cover it.Testing
ConsoleTextTests:dotnet build,dotnet test(1344 passed), anddotnet format --verify-no-changes.🤖 Generated with Claude Code