Skip to content

Normalize control characters before measuring and writing prompt text - #431

Merged
shibayan merged 6 commits into
masterfrom
control-characters
Oct 21, 2026
Merged

shibayan merged 6 commits into
masterfrom
control-characters

Conversation

@shibayan

@shibayan shibayan commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

Summary

Closes #411.

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.
  • VitePress docs build.

🤖 Generated with Claude Code

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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 07:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Private escape commands bypass filtering, wrapped multiline prompts misplace the cursor, and multiline messages lose SGR formatting.

3 open issues
What changed in this PR

Normalizes prompt text before measurement and output to improve rendering consistency for external data, addressing #411.

Changes:

  • Adds handling for line breaks, tabs, escape sequences, and visible control symbols.
  • Integrates normalization into buffering and adds regression tests.
  • Documents display rules in English and Japanese.
File Description
tests/​Sharprompt.Tests/​Internal/​ConsoleTextTests.cs Tests normalization and display widths.
tests/​Sharprompt.Tests/​Forms/​FormInteractionTests.cs Tests multiline redraws and control-character rendering.
src/​Sharprompt/​Internal/​OffscreenBuffer.cs Normalizes buffered text before measurement.
src/​Sharprompt/​Internal/​ConsoleText.cs Implements normalization and escape-aware measurement.
docs/​ja/​advanced.md Documents control-character behavior in Japanese.
docs/​en/​advanced.md Documents control-character behavior in English.

Review effort: Balanced

Comment thread src/Sharprompt/Internal/ConsoleText.cs Resolved
…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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cursor-separated input can expose escape fragments, while tab measurement and SGR replay introduce quadratic processing or output growth.

1 open issue
2 previously missed issues

In code that hasn't changed since last review

Medium severity 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.

Medium severity 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.

3 resolved since last review

Review effort: Balanced

Comment thread src/Sharprompt/Internal/ConsoleText.cs Resolved
…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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Style continuation can restore incorrect formatting and generate quadratic output for distinct unsupported codes.

2 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity 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.

1 resolved since last review

Review effort: Balanced

Comment thread src/Sharprompt/Internal/ConsoleText.cs Resolved
Comment thread src/Sharprompt/Internal/ConsoleText.cs Resolved
… 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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Style continuation misparses zero-padded color modes and can cause quadratic output expansion.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity 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.

2 resolved since last review

Review effort: Balanced

Comment thread src/Sharprompt/Internal/ConsoleText.cs Resolved
- 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>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:36
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new escape parser and cross-line style restoration need final human validation against real cross-platform terminals.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 21, 2026 08:41
@shibayan
shibayan merged commit 28cbdbf into master Oct 21, 2026
10 checks passed
@shibayan
shibayan deleted the control-characters branch October 21, 2026 08:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium severity 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.

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Align control-character handling with display width calculation

2 participants