Skip to content

SGR 2 (Faint) support#1

Open
fy0 wants to merge 7 commits intotuzig:masterfrom
fy0:master
Open

SGR 2 (Faint) support#1
fy0 wants to merge 7 commits intotuzig:masterfrom
fy0:master

Conversation

@fy0
Copy link
Copy Markdown

@fy0 fy0 commented Nov 29, 2025

https://xtermjs.org/docs/api/vtfeatures/> SGR 2: Faint, decreased intensity.

Made it for gray text from codex:
image

Summary by CodeRabbit

  • New Features

    • Better wide (2-cell) character handling and faint/dim text rendering; configurable xterm-style secondary device-attributes via a new option.
  • Improvements

    • Modernized platform build-tag handling, improved alternate-screen and cursor/scroll-region behavior, more accurate rune-width/layout and refined CSI/OSC reply/terminator behavior.
  • Tests

    • Expanded tests covering faint rendering, alt-screen behaviors, CSI/OSC parsing and DA replies.
  • API Changes

    • Exported attribute constants, a RawLines accessor, and a WithXtermStyle option added.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Nov 29, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Add modern //go:build directives; export and extend terminal attribute flags (faint, wide, wide-dummy); implement rune-width-aware insertion using github.com/rivo/uniseg; expand and correctly swap alternate-screen state; tighten CSI prefix parsing and DA responses; add OSC/STR terminator handling and multiple tests.

Changes

Cohort / File(s) Summary
Build constraint modernization
ioctl_other.go, ioctl_posix.go, vt_other.go, vt_posix.go
Add modern //go:build directives alongside existing legacy // +build lines; no runtime logic changes besides build gating and passing secondaryDAReply into newState.
State & attributes / alt-screen
state.go
Promote attr*Attr* (exported); add AttrFaint, AttrWide, AttrWideDummy; add RawLines() and accept secondaryDA in newState; add alternate-screen cursor/scroll/tab fields, reset/resize/clamp helpers, and update rendering to honor faint/wide semantics.
Parser & wide-rune handling
parse.go, str.go
Add github.com/rivo/uniseg for rune width; replace internal attr names with exported ones; implement width-aware insertion (wrap behavior, bounds checks, clearing overwritten wide cells, main+dummy cell writes with AttrWide/AttrWideDummy); update STR/OSC terminator handling and useST flag.
CSI parsing & DA behavior
csi.go, csi_test.go
Track CSI prefix byte (?, >, <, =); set priv only for ?; add handlePrefixedCSI() for supported prefixed sequences (private mode set/reset, DEC CPR reply, secondary DA reply using t.secondaryDA); tighten unknown/unsupported handling and logging.
Terminal options / DA config
vt.go, vt_posix.go, vt_other.go
Add TerminalInfo.secondaryDAReply with defaultSecondaryDAReply and xtermStyleSecondaryDAReply; add WithXtermStyle() TerminalOption; propagate secondaryDAReply into newState construction.
OSC/STR color handling
str.go, str_test.go
Add useST flag and terminator() for OSC/STR; centralize color resolution via dynamicColorValue/paletteColorValue; tighten color-slot validation; enable OSC cursor-color handling and ensure replies use correct terminator.
Tests — faint, CSI, alt-screen, OSC
vt_test.go, csi_test.go, alt_screen_test.go, str_test.go
Add/extend tests: SGR faint handling and dimmed color, prefixed CSI parsing and DA responses, private CPR/DSR behavior, multiple alt-screen reset/origin/round-trip cases, OSC color set/query and terminator correctness.
Module dependency
go.mod
Add github.com/rivo/uniseg v0.4.7.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble bytes and nibble lines,
I mark the wide where rune-width shines.
A faint new shade, a softened glow,
Alt screens swap and cursors go.
Hooray—I hop where tests now show!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'SGR 2 (Faint) support' accurately reflects the primary feature being added, as evidenced by new SGR 2 faint attribute handling across state.go, parse.go, and tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

  Previously, each rune was treated as occupying 1 column, causing
  incorrect cursor positioning for wide characters like CJK text.

  Changes:
  - Add go-runewidth dependency to calculate display width
  - Add AttrWide and AttrWideDummy flags for wide char tracking
  - Move cursor by actual character width (2 for wide chars)
  - Handle wide char wrapping at line end
  - Clear orphaned cells when overwriting partial wide chars
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
state.go (1)

292-300: Dimming by 50% may be too aggressive for faint text.

The dimColor function halves each RGB component to create a faint appearance. While this is simple and effective, dimming by 50% may make text too hard to read in some color schemes, especially for already-dark colors.

Consider:

  1. Using a less aggressive dimming factor (e.g., 33% or 25% reduction) for better readability.
  2. Making the dimming factor configurable.
  3. Testing with various color combinations to ensure faint text remains legible.

That said, 50% dimming matches the reference image mentioned in the PR description, so this may be intentional.

Please verify that the 50% dimming matches the intended visual appearance from the reference image and provides acceptable readability across different color schemes.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c2f2317 and 4bc538e.

📒 Files selected for processing (3)
  • go.mod
  • parse.go
  • state.go
🧰 Additional context used
🧬 Code graph analysis (2)
state.go (1)
color.go (3)
  • Color (35-35)
  • DefaultFG (27-27)
  • DefaultBG (28-28)
parse.go (1)
state.go (6)
  • State (86-107)
  • AttrGfx (17-17)
  • ModeWrap (37-37)
  • AttrWrap (20-20)
  • AttrWideDummy (23-23)
  • AttrWide (22-22)
🔇 Additional comments (9)
parse.go (4)

28-31: Treating zero-width as 1 may not handle combining characters correctly.

Zero-width characters (e.g., combining marks) are treated as width 1, which causes them to advance the cursor. In proper Unicode rendering, these should combine with the previous character without advancing the cursor.

Is combining character support required for this terminal emulator? If so, this logic should be enhanced to handle width==0 properly by combining with the previous cell rather than treating it as width 1.


34-43: Wide character silently dropped when wrapping is disabled.

When a wide character doesn't fit and ModeWrap is not enabled, the function returns early (line 41), silently dropping the character without writing anything.

Please confirm this is the intended behavior for the terminal emulator. Some terminals might instead truncate the wide character or write a replacement character.


45-86: Wide character handling logic is comprehensive and well-bounded.

The implementation correctly:

  • Clears overlapped wide characters in all scenarios (writing over dummy, main, or placing dummy over another wide char's main).
  • Performs proper bounds checking before all array accesses.
  • Sets appropriate flags (AttrWide, AttrWideDummy) for the main and dummy cells.
  • Moves the cursor by the actual character width.

The complexity is necessary to maintain correct rendering state when wide characters are overwritten or wrap across lines.


3-3: No action needed — the library version is valid and secure.

github.com/mattn/go-runewidth v0.0.19 is the latest available version with no known vulnerabilities or security issues.

go.mod (1)

5-5: Dependency addition aligns with wide-character support.

The addition of github.com/mattn/go-runewidth v0.0.19 is necessary for the wide-character handling implemented in parse.go. Version verification was requested in the parse.go review.

state.go (4)

14-24: Exported attribute constants enable external API usage.

The promotion of attribute constants to exported names (e.g., AttrReverse, AttrBold) and the addition of new constants (AttrFaint, AttrWide, AttrWideDummy) align well with the PR objectives:

  • AttrFaint implements SGR 2 (faint/decreased intensity) support.
  • AttrWide and AttrWideDummy enable correct wide-character (CJK) rendering.

The comments on lines 22-23 helpfully document the purpose of the wide-character flags.


162-166: Unsynchronized access via RawLines() could lead to data races.

The RawLines() method exposes the internal lines slice without acquiring the mutex, breaking the thread-safety guarantees typically provided by the State type. While the comment documents this, it places a significant burden on callers to ensure proper synchronization.

Please confirm:

  1. Is this API necessary, or could callers use the existing Cell(x, y) method with proper locking?
  2. Where will this be used, and have the call sites been audited to ensure they properly synchronize access?
  3. Consider making this a private method (e.g., rawLines()) if it's only needed internally.

Based on learnings, unsynchronized APIs in concurrent code require careful justification and documentation of the exact use cases.


280-285: Faint and bold attributes are mutually exclusive.

The implementation ensures that bold (line 280) and faint (line 283) are mutually exclusive:

  • Bold brightening (FG + 8) is only applied if NOT faint.
  • Faint dimming is only applied if NOT bold.

This is a reasonable design choice, as applying both simultaneously would be ambiguous. However, this behavior should be documented, as some terminal emulators may handle the combination differently.

Please confirm this mutual exclusion matches the intended terminal behavior and consider documenting it in code comments or external documentation.


636-660: SGR attribute handling correctly implements faint support.

The implementation properly handles SGR (Select Graphic Rendition) sequences:

  • SGR 0 (line 636): Resets all attributes including the new AttrFaint.
  • SGR 2 (lines 641-642): Sets AttrFaint as per the PR objective.
  • SGR 21/22 (line 652): Clears both AttrBold and AttrFaint, correctly implementing "normal intensity."

All changes use the exported attribute constants consistently.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
csi.go (1)

187-196: Minor redundancy in condition, but acceptable for clarity.

The condition c.priv || c.prefix != 0 is technically redundant since c.priv can only be true when c.prefix == '?' (which is non-zero). Simplifying to just c.prefix != 0 would suffice:

-if c.priv || c.prefix != 0 {
+if c.prefix != 0 {

However, keeping both checks is reasonable for clarity and defensive coding, especially if priv semantics might evolve independently.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@csi.go` around lines 187 - 196, The condition in the CSI handler for cases
's' and 'u' redundantly checks both c.priv and c.prefix != 0; simplify by
removing c.priv and only checking c.prefix != 0 before calling t.saveCursor()
and t.restoreCursor() (references: the case 's' and case 'u' branches, c.priv,
c.prefix, t.saveCursor, t.restoreCursor) so the guard becomes solely "if
c.prefix != 0 { goto unknown }".
csi_test.go (1)

46-55: Test covers the positive case; consider adding negative cases.

The test correctly verifies that ESC[c produces the expected DA response. For completeness, consider adding a test case verifying that prefixed variants like ESC[>c do not produce a response:

func TestSecondaryDANoResponse(t *testing.T) {
    var reply bytes.Buffer
    term := New(WithWriter(&reply))
    if _, err := term.Write([]byte("\033[>c")); err != nil {
        t.Fatal(err)
    }
    if reply.Len() != 0 {
        t.Fatalf("expected no response for secondary DA, got %q", reply.String())
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@csi_test.go` around lines 46 - 55, Add a negative test that verifies prefixed
DA requests don't elicit a response: create a TestSecondaryDANoResponse similar
to TestXtermStylePrimaryDAResponse, instantiate the terminal with
New(WithWriter(&reply)), write the sequence "\033[>c" and assert reply.Len() ==
0 (or reply.String() == "") so that secondary/extended DA (prefixed with '>')
does not produce any output; reference the existing
TestXtermStylePrimaryDAResponse, New, and WithWriter helpers to implement the
test.
state.go (1)

167-171: Consider the risk of exposing raw lines without synchronization.

The RawLines() method exposes internal state without locking, which could lead to data races if the caller doesn't properly synchronize. While the comment warns about this, an exported method like this may be misused.

Consider whether this method needs to be exported, or if callers should use the existing Cell() method within Lock()/Unlock() pairs instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state.go` around lines 167 - 171, The RawLines method exposes the internal
slice t.lines without synchronization which can cause data races; either make it
unexported (rename RawLines to rawLines) and restrict callers to use
State.Cell() within Lock()/Unlock() pairs, or implement safe access by acquiring
a read lock inside RawLines (e.g., t.mu.RLock(); defer t.mu.RUnlock()) and
returning a copy of the slice (append([]line(nil), t.lines...)) so callers
cannot mutate internal state; update callers accordingly to use the safe API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@csi_test.go`:
- Around line 46-55: Add a negative test that verifies prefixed DA requests
don't elicit a response: create a TestSecondaryDANoResponse similar to
TestXtermStylePrimaryDAResponse, instantiate the terminal with
New(WithWriter(&reply)), write the sequence "\033[>c" and assert reply.Len() ==
0 (or reply.String() == "") so that secondary/extended DA (prefixed with '>')
does not produce any output; reference the existing
TestXtermStylePrimaryDAResponse, New, and WithWriter helpers to implement the
test.

In `@csi.go`:
- Around line 187-196: The condition in the CSI handler for cases 's' and 'u'
redundantly checks both c.priv and c.prefix != 0; simplify by removing c.priv
and only checking c.prefix != 0 before calling t.saveCursor() and
t.restoreCursor() (references: the case 's' and case 'u' branches, c.priv,
c.prefix, t.saveCursor, t.restoreCursor) so the guard becomes solely "if
c.prefix != 0 { goto unknown }".

In `@state.go`:
- Around line 167-171: The RawLines method exposes the internal slice t.lines
without synchronization which can cause data races; either make it unexported
(rename RawLines to rawLines) and restrict callers to use State.Cell() within
Lock()/Unlock() pairs, or implement safe access by acquiring a read lock inside
RawLines (e.g., t.mu.RLock(); defer t.mu.RUnlock()) and returning a copy of the
slice (append([]line(nil), t.lines...)) so callers cannot mutate internal state;
update callers accordingly to use the safe API.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6cc6d30b-365d-4d51-a598-156c2c031437

📥 Commits

Reviewing files that changed from the base of the PR and between 051b8e0 and 7144fb8.

📒 Files selected for processing (4)
  • alt_screen_test.go
  • csi.go
  • csi_test.go
  • state.go

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
vt.go (1)

7-7: Consider updating deprecated ioutil usage.

ioutil.Discard has been deprecated since Go 1.16 in favor of io.Discard. While this is pre-existing code, consider updating it as part of this change.

♻️ Update deprecated import
 import (
 	"bufio"
 	"fmt"
 	"io"
-	"io/ioutil"
 )

And in New():

 	info := TerminalInfo{
-		w:                ioutil.Discard,
+		w:                io.Discard,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vt.go` at line 7, Replace deprecated ioutil usage: update the import of
"io/ioutil" to "io" and change any uses of ioutil.Discard to io.Discard (e.g.,
inside the New() function where ioutil.Discard is referenced). Ensure there are
no remaining references to ioutil.Discard and run `go vet`/`go build` to verify
imports and usage are correct.
str.go (1)

185-196: Redundant range check in validColorSlot.

The final between(j, 0, 1<<24-1) check (line 195) already includes the between(j, 0, 255) check (line 186), making the first check redundant. Consider simplifying:

♻️ Suggested simplification
 func (t *State) validColorSlot(j int) bool {
-	if between(j, 0, 255) {
-		return true
-	}
-
 	switch Color(j) {
 	case DefaultFG, DefaultBG, DefaultCursor:
 		return true
 	}
 
 	return between(j, 0, 1<<24-1)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@str.go` around lines 185 - 196, The initial between(j, 0, 255) check in
function validColorSlot is redundant because the later between(j, 0, 1<<24-1)
already covers that range; remove the first between call and simplify
validColorSlot to only check the special Color cases (DefaultFG, DefaultBG,
DefaultCursor) and then the broader between(j, 0, 1<<24-1) range check so the
logic remains correct and concise.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@str_test.go`:
- Around line 9-12: Tests in str_test.go call the helper writeAll but that
helper is defined in alt_screen_test.go, so add or centralize the helper: either
define a local writeAll(t *testing.T, term *Term, data ...string) function in
str_test.go matching the signature used in lines ~181-219, or move writeAll into
a shared test helper file (e.g., test_helpers_test.go) and import/keep it
package-private so both str_test.go and alt_screen_test.go can call it; ensure
the implementation uses the same behavior expected by the tests (writing the
provided strings to the terminal test harness) and reference the writeAll symbol
in your change so callers compile.

---

Nitpick comments:
In `@str.go`:
- Around line 185-196: The initial between(j, 0, 255) check in function
validColorSlot is redundant because the later between(j, 0, 1<<24-1) already
covers that range; remove the first between call and simplify validColorSlot to
only check the special Color cases (DefaultFG, DefaultBG, DefaultCursor) and
then the broader between(j, 0, 1<<24-1) range check so the logic remains correct
and concise.

In `@vt.go`:
- Line 7: Replace deprecated ioutil usage: update the import of "io/ioutil" to
"io" and change any uses of ioutil.Discard to io.Discard (e.g., inside the New()
function where ioutil.Discard is referenced). Ensure there are no remaining
references to ioutil.Discard and run `go vet`/`go build` to verify imports and
usage are correct.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 071fdbc6-c86b-47b4-99b4-675a13f5db52

📥 Commits

Reviewing files that changed from the base of the PR and between 7144fb8 and da1b8f9.

📒 Files selected for processing (11)
  • README.md
  • csi.go
  • csi_test.go
  • parse.go
  • state.go
  • str.go
  • str_test.go
  • vt.go
  • vt_other.go
  • vt_posix.go
  • vt_test.go
✅ Files skipped from review due to trivial changes (2)
  • vt_other.go
  • README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • vt_posix.go
  • parse.go
  • csi_test.go

Comment thread str_test.go
Comment on lines +9 to +12
func oscColorReply(num int, c Color, term string) string {
r, g, b := rgb(int(c))
return fmt.Sprintf("\033]%d;rgb:%02x%02x/%02x%02x/%02x%02x%s", num, r, r, g, g, b, b, term)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the writeAll helper function definition
ast-grep --pattern 'func writeAll($$$) {
  $$$
}'

Repository: tuzig/vt10x

Length of output: 343


🏁 Script executed:

#!/bin/bash
# Check if str_test.go exists and examine its structure
fd -t f "str_test.go" && wc -l str_test.go

Repository: tuzig/vt10x

Length of output: 82


🏁 Script executed:

#!/bin/bash
# Examine lines 181-219 where writeAll is likely being used
sed -n '181,219p' str_test.go

Repository: tuzig/vt10x

Length of output: 1195


🏁 Script executed:

#!/bin/bash
# Search for writeAll definition in str_test.go
rg "func writeAll" str_test.go

Repository: tuzig/vt10x

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Check if there's a common test utilities file
fd -t f "test" | grep -E "(util|helper|common)" | head -20

Repository: tuzig/vt10x

Length of output: 37


🏁 Script executed:

#!/bin/bash
# Search for all definitions of writeAll in the repository
rg "func writeAll" --type go

Repository: tuzig/vt10x

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Check what files import what in this repo to understand test structure
fd -t f "_test.go" | head -10

Repository: tuzig/vt10x

Length of output: 122


🏁 Script executed:

#!/bin/bash
# Check the full content of str_test.go to see if writeAll is defined
cat -n str_test.go | head -50

Repository: tuzig/vt10x

Length of output: 1236


🏁 Script executed:

#!/bin/bash
# Check if writeAll is referenced/imported in str_test.go
rg "writeAll" str_test.go -B2 -A2

Repository: tuzig/vt10x

Length of output: 625


Add writeAll helper function to str_test.go or refactor test utilities.

The tests at lines 181-219 use writeAll(t, term, ...) which is defined in alt_screen_test.go, not in this file. Either define writeAll locally in str_test.go or move shared test helpers to a dedicated test utilities file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@str_test.go` around lines 9 - 12, Tests in str_test.go call the helper
writeAll but that helper is defined in alt_screen_test.go, so add or centralize
the helper: either define a local writeAll(t *testing.T, term *Term, data
...string) function in str_test.go matching the signature used in lines
~181-219, or move writeAll into a shared test helper file (e.g.,
test_helpers_test.go) and import/keep it package-private so both str_test.go and
alt_screen_test.go can call it; ensure the implementation uses the same behavior
expected by the tests (writing the provided strings to the terminal test
harness) and reference the writeAll symbol in your change so callers compile.

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.

1 participant