Skip to content

Commit 8e260a6

Browse files
author
SqlRush
committed
Parse C1 CSI terminal sequences
1 parent 4ba7978 commit 8e260a6

8 files changed

Lines changed: 78 additions & 3 deletions

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,8 @@ M7 补充:terminal input parser 和 configurable keybinding name parser 现在
10401040

10411041
本轮补充:terminal sequence dispatcher/parser 现在把 DCS/APC/PM/SOS string-control 序列分类为 `stringControl` action,保留 payload、terminator 和 incomplete flush 状态,同时 visible text 继续忽略这些不可见控制串。
10421042

1043+
本轮补充:terminal tokenizer、sequence dispatcher、CSI parser 和 visible-text stripping 现在接受 8-bit C1 CSI (`0x9b`) 序列,覆盖分块 SGR 输入以及 input tokenizer 的 X10 mouse payload 边界。
1044+
10431045
本轮补充:message renderer 增加 ANSI-aware wrapping/padding,带 SGR 的 message text 会通过 terminal parser 按 grapheme 可见宽度换行,并把 `TextStyle` action 重新渲染为 SGR 序列,避免 escape bytes 参与 layout 宽度计算;普通文本路径保持不变。
10441046

10451047
本轮补充:基础 wrap/pad/trim 改为按 terminal grapheme 可见宽度计算,普通 message、status/dialog/viewport/prompt 的 CJK/emoji 宽字符不再按单 rune 宽度参与布局,继续向 terminal column parity 收口。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,7 @@ test/parity/ # golden tests against TS/official behavior
585585
- 本轮补充:terminal tokenizer 补齐 PM (`ESC ^`) 和 SOS (`ESC X`) string-control 状态,和 OSC/DCS/APC 一样支持 BEL 或 ST terminator,避免这些控制串 payload 泄漏为 text token。
586586
- 本轮补充:terminal sequence dispatcher/parser 现在把 DCS/APC/PM/SOS string-control 序列分类为 `stringControl` action,保留 payload、terminator 和 incomplete flush 状态,同时 visible text 继续忽略这些不可见控制串。
587587
- 本轮补充:snapshot/OSC 复用 terminal parser 的 visible-text pipeline,`StripANSI` 不再维护独立手写 scanner;可见文本提取统一覆盖 CSI/OSC/DCS/APC/PM/SOS、flushed partial OSC 和 raw BEL 兼容行为,为后续 ANSI parser 与 renderer/snapshot parity 收口。
588+
- 本轮补充:terminal tokenizer、sequence dispatcher、CSI parser 和 visible-text stripping 现在接受 8-bit C1 CSI (`0x9b`) 序列,覆盖分块 SGR 输入以及 input tokenizer 的 X10 mouse payload 边界。
588589
- 本轮补充:message renderer 增加 ANSI-aware wrapping/padding,带 SGR 的 message text 会通过 terminal parser 按 grapheme 可见宽度换行,并把 `TextStyle` action 重新渲染为 SGR 序列,避免 escape bytes 参与 layout 宽度计算;普通文本路径保持不变。
589590
- 本轮补充:基础 wrap/pad/trim 改为按 terminal grapheme 可见宽度计算,普通 message、status/dialog/viewport/prompt 的 CJK/emoji 宽字符不再按单 rune 宽度参与布局,继续向 terminal column parity 收口。
590591
- 本轮补充:prompt layout 的 chunking 和 cursor column 映射改为按 terminal grapheme 可见宽度计算,宽字符输入换行和 cursor CSI 定位不再按 rune index 误算列宽。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ M7 progress now includes:
386386
- `internal/tui`: terminal CSI parsing now marks DEC selective erase `CSI ? Ps J` / `CSI ? Ps K` separately from ordinary ED/EL erase actions.
387387
- `internal/tui`: terminal CSI parsing now exposes ECMA `CSI Ps N` / `CSI Ps O` erase-in-field and erase-in-area actions with the same to-end/to-start/all regions used by other erase operations.
388388
- `internal/tui`: terminal CSI parsing now treats explicit zero count/position params as ANSI defaults for cursor movement/position/column, insert/repeat/erase chars, and scroll actions while preserving raw zero selector semantics for mode/report/erase selector sequences.
389+
- `internal/tui`: terminal tokenizer, sequence dispatcher, CSI parser, and visible-text stripping now accept 8-bit C1 CSI (`0x9b`) sequences, including chunked SGR input and X10 mouse payload boundaries.
389390
- `internal/tui`: keybinding config, keymap resolution, and interaction script named-key input now accept terminal aliases for `ctrl-h`/`ctrl-i`/`ctrl-j`/`ctrl-m`, `ctrl-[`, and `ctrl-?`, including `control-*` and compact/camel variants.
390391
- `internal/tui`: keybinding config now accepts page/navigation/editing aliases such as `pgup`, `pg-up`, `prior`, `pageUpKey`, `pgdn`, `pg-down`, `next`, `pageDownKey`, `homeKey`, `endKey`, `deleteForward`, `forwardDelete`, and `deleteBackward`.
391392
- `internal/tui`: keybinding config and named-key script input now accept DOM/fixture control-key aliases such as `enterKey`, `returnKey`, `numpadEnter`, `escapeKey`, `escKey`, `tabKey`, `shiftEnterKey`, `shiftNumpadEnter`, `shiftTabKey`, and `backtabKey`.

internal/tui/terminal_csi.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
const (
99
CSIPrefix = "\x1b["
10+
C1CSIPrefix = "\x9b"
1011
CursorLeft = "\x1b[G"
1112
CursorSave = "\x1b[s"
1213
CursorRestore = "\x1b[u"
@@ -354,10 +355,10 @@ func IsCSIFinal(b byte) bool {
354355
}
355356

356357
func ParseCSISequence(sequence string) (CSIAction, bool) {
357-
if !strings.HasPrefix(sequence, CSIPrefix) {
358+
inner, ok := trimCSIPrefix(sequence)
359+
if !ok {
358360
return CSIAction{}, false
359361
}
360-
inner := strings.TrimPrefix(sequence, CSIPrefix)
361362
if len(inner) == 0 {
362363
return CSIAction{}, false
363364
}
@@ -526,6 +527,17 @@ func ParseCSISequence(sequence string) (CSIAction, bool) {
526527
return CSIAction{Type: CSIActionUnknown, Sequence: sequence}, true
527528
}
528529

530+
func trimCSIPrefix(sequence string) (string, bool) {
531+
switch {
532+
case strings.HasPrefix(sequence, CSIPrefix):
533+
return strings.TrimPrefix(sequence, CSIPrefix), true
534+
case strings.HasPrefix(sequence, C1CSIPrefix):
535+
return strings.TrimPrefix(sequence, C1CSIPrefix), true
536+
default:
537+
return "", false
538+
}
539+
}
540+
529541
func parseCSIParams(paramStr string) []int {
530542
if paramStr == "" {
531543
return nil

internal/tui/terminal_sequence.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ type TerminalSequenceAction struct {
3434
}
3535

3636
func IdentifyTerminalSequence(sequence string) TerminalSequenceType {
37+
if strings.HasPrefix(sequence, C1CSIPrefix) {
38+
return TerminalSequenceCSI
39+
}
3740
if len(sequence) < 2 || sequence[0] != ESCPrefix[0] {
3841
return TerminalSequenceUnknown
3942
}

internal/tui/terminal_tokenizer.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tui
33
const (
44
terminalBEL = byte(0x07)
55
terminalESC = byte(0x1b)
6+
terminalCSI = byte(0x9b)
67

78
escTypeCSI = byte('[')
89
escTypeOSC = byte(']')
@@ -129,6 +130,11 @@ func tokenizeTerminal(input string, initialState TerminalTokenizerState, initial
129130
seqStart = i
130131
state = terminalTokenizerEscape
131132
i++
133+
} else if code == terminalCSI {
134+
flushText()
135+
seqStart = i
136+
state = terminalTokenizerCSI
137+
i++
132138
} else {
133139
i++
134140
}
@@ -181,7 +187,7 @@ func tokenizeTerminal(input string, initialState TerminalTokenizerState, initial
181187
textStart = seqStart
182188
}
183189
case terminalTokenizerCSI:
184-
if x10Mouse && code == 'M' && i-seqStart == 2 && x10MousePayloadLooksPrintable(data, i) {
190+
if x10Mouse && code == 'M' && i-seqStart == csiPayloadOffset(data, seqStart) && x10MousePayloadLooksPrintable(data, i) {
185191
if i+4 <= len(data) {
186192
i += 4
187193
emitSequence(data[seqStart:i])
@@ -252,3 +258,10 @@ func x10MousePayloadLooksPrintable(data string, index int) bool {
252258
}
253259
return true
254260
}
261+
262+
func csiPayloadOffset(data string, seqStart int) int {
263+
if seqStart >= 0 && seqStart < len(data) && data[seqStart] == terminalCSI {
264+
return 1
265+
}
266+
return 2
267+
}

internal/tui/terminal_tokenizer_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,24 @@ func TestTerminalTokenizerFeedsTextAndSequencesAcrossChunks(t *testing.T) {
2323
}
2424
}
2525

26+
func TestTerminalTokenizerFeedsC1CSISequencesAcrossChunks(t *testing.T) {
27+
tokenizer := NewTerminalTokenizer(TerminalTokenizerOptions{})
28+
tokens := tokenizer.Feed("hi \x9b")
29+
want := []TerminalToken{{Type: TerminalTokenText, Value: "hi "}}
30+
if !reflect.DeepEqual(tokens, want) || tokenizer.Buffer() != "\x9b" {
31+
t.Fatalf("first c1 feed tokens=%#v buffer=%q", tokens, tokenizer.Buffer())
32+
}
33+
34+
tokens = tokenizer.Feed("31mred")
35+
want = []TerminalToken{
36+
{Type: TerminalTokenSequence, Value: "\x9b31m"},
37+
{Type: TerminalTokenText, Value: "red"},
38+
}
39+
if !reflect.DeepEqual(tokens, want) || tokenizer.Buffer() != "" {
40+
t.Fatalf("second c1 feed tokens=%#v buffer=%q", tokens, tokenizer.Buffer())
41+
}
42+
}
43+
2644
func TestTerminalTokenizerFlushesIncompleteSequences(t *testing.T) {
2745
tokenizer := NewTerminalTokenizer(TerminalTokenizerOptions{})
2846
if tokens := tokenizer.Feed("a\x1b[?"); !reflect.DeepEqual(tokens, []TerminalToken{{Type: TerminalTokenText, Value: "a"}}) {

internal/tui/tui_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5789,6 +5789,31 @@ func TestParseCSISequenceActions(t *testing.T) {
57895789
}
57905790
}
57915791

5792+
func TestParseC1CSISequenceActions(t *testing.T) {
5793+
const c1CSI = "\x9b"
5794+
5795+
erase, ok := ParseCSISequence(c1CSI + "2J")
5796+
if !ok || erase.Type != CSIActionErase || erase.Erase.Type != CSIEraseActionDisplay || erase.Erase.Region != CSIEraseAll {
5797+
t.Fatalf("c1 erase action = %#v ok=%v", erase, ok)
5798+
}
5799+
5800+
action, ok := ParseTerminalSequence(c1CSI + "31m")
5801+
if !ok || action.Type != TerminalSequenceCSI || action.CSI.Type != CSIActionSGR || action.CSI.SGRParams != "31" {
5802+
t.Fatalf("c1 terminal action = %#v ok=%v", action, ok)
5803+
}
5804+
5805+
parser := NewTerminalParser()
5806+
actions := parser.Feed("a" + c1CSI)
5807+
actions = append(actions, parser.Feed("31mb"+c1CSI+"0mc")...)
5808+
actions = append(actions, parser.Flush()...)
5809+
if visible := TerminalActionsVisibleText(actions); visible != "abc" {
5810+
t.Fatalf("c1 visible text = %q actions=%#v", visible, actions)
5811+
}
5812+
if visible := StripANSI("a" + c1CSI + "31mb" + c1CSI + "0mc"); visible != "abc" {
5813+
t.Fatalf("c1 stripped text = %q", visible)
5814+
}
5815+
}
5816+
57925817
func TestParseESCSequenceActions(t *testing.T) {
57935818
if !IsESCFinal('0') || !IsESCFinal('~') || IsESCFinal('/') || IsESCFinal(0x7f) {
57945819
t.Fatalf("ESC final range mismatch")

0 commit comments

Comments
 (0)