vt100.js 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/package.json +26 -0
- package/src/index.ts +8 -0
- package/src/screen.ts +1202 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Beorn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# vt100.js
|
|
2
|
+
|
|
3
|
+
Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Full VT100/ANSI escape sequence parsing
|
|
8
|
+
- SGR attributes (bold, italic, underline, colors, etc.)
|
|
9
|
+
- 16-color, 256-color, and 24-bit truecolor support
|
|
10
|
+
- Cursor movement, save/restore (DECSC/DECRC)
|
|
11
|
+
- Screen modes (alternate screen, auto-wrap, origin mode, etc.)
|
|
12
|
+
- Scroll regions (DECSTBM) with content preservation
|
|
13
|
+
- Scrollback buffer with configurable limit
|
|
14
|
+
- Insert/delete characters and lines
|
|
15
|
+
- Wide character support (CJK, emoji)
|
|
16
|
+
- Zero dependencies -- works in Bun, Node.js, and browsers
|
|
17
|
+
|
|
18
|
+
## Comparison
|
|
19
|
+
|
|
20
|
+
| Package | Screen State | SGR Colors | Scrollback | Wide Chars | Zero Deps | Size |
|
|
21
|
+
|---------|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
22
|
+
| **vt100.js** | yes | 16/256/true | yes | yes | yes | ~30KB |
|
|
23
|
+
| `@xterm/headless` | yes | 16/256/true | yes | yes | no | ~500KB |
|
|
24
|
+
| `node-pty` + xterm | yes | 16/256/true | yes | yes | no | native build |
|
|
25
|
+
| `ansi-parser` | no | parse only | no | no | yes | ~5KB |
|
|
26
|
+
| `terminal-kit` | yes | 16/256/true | no | partial | no | ~2MB |
|
|
27
|
+
| `blessed` / `neo-blessed` | yes | 16/256 | no | no | no | ~1MB |
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install vt100.js
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { createVt100Screen } from "vt100.js"
|
|
39
|
+
|
|
40
|
+
const screen = createVt100Screen({ cols: 80, rows: 24 })
|
|
41
|
+
screen.process(new TextEncoder().encode("Hello, \x1b[1mBold\x1b[0m World!"))
|
|
42
|
+
|
|
43
|
+
// Read cell state
|
|
44
|
+
const cell = screen.getCell(0, 0)
|
|
45
|
+
console.log(cell.char) // "H"
|
|
46
|
+
|
|
47
|
+
// Read text
|
|
48
|
+
console.log(screen.getText()) // "Hello, Bold World!"
|
|
49
|
+
|
|
50
|
+
// Check cursor position
|
|
51
|
+
const pos = screen.getCursorPosition()
|
|
52
|
+
console.log(pos) // { x: 18, y: 0 }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## API
|
|
56
|
+
|
|
57
|
+
### `createVt100Screen(options)`
|
|
58
|
+
|
|
59
|
+
Create a new screen instance.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const screen = createVt100Screen({
|
|
63
|
+
cols: 80, // terminal width
|
|
64
|
+
rows: 24, // terminal height
|
|
65
|
+
scrollbackLimit: 1000 // max scrollback lines (default: 1000)
|
|
66
|
+
})
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Screen methods
|
|
70
|
+
|
|
71
|
+
| Method | Description |
|
|
72
|
+
|--------|-------------|
|
|
73
|
+
| `process(data: Uint8Array)` | Feed raw terminal data |
|
|
74
|
+
| `getText()` | Get all text (scrollback + screen) |
|
|
75
|
+
| `getTextRange(startRow, startCol, endRow, endCol)` | Get text in a range |
|
|
76
|
+
| `getLine(row)` | Get cells for a row |
|
|
77
|
+
| `getCell(row, col)` | Get a single cell (char, fg, bg, bold, etc.) |
|
|
78
|
+
| `getCursorPosition()` | Get cursor `{ x, y }` |
|
|
79
|
+
| `getCursorVisible()` | Check cursor visibility |
|
|
80
|
+
| `getMode(mode)` | Check terminal mode (`altScreen`, `bracketedPaste`, etc.) |
|
|
81
|
+
| `getTitle()` | Get window title (set via OSC 0/2) |
|
|
82
|
+
| `getScrollbackLength()` | Number of scrollback lines |
|
|
83
|
+
| `getViewportOffset()` | Current viewport scroll offset |
|
|
84
|
+
| `scrollViewport(delta)` | Scroll viewport by delta lines |
|
|
85
|
+
| `resize(cols, rows)` | Resize the terminal |
|
|
86
|
+
| `reset()` | Reset to initial state |
|
|
87
|
+
|
|
88
|
+
### Cell properties
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
interface ScreenCell {
|
|
92
|
+
char: string // character content
|
|
93
|
+
fg: CellColor | null // foreground color { r, g, b }
|
|
94
|
+
bg: CellColor | null // background color { r, g, b }
|
|
95
|
+
bold: boolean
|
|
96
|
+
faint: boolean
|
|
97
|
+
italic: boolean
|
|
98
|
+
underline: UnderlineStyle // "none" | "single" | "double" | "curly" | "dotted" | "dashed"
|
|
99
|
+
strikethrough: boolean
|
|
100
|
+
inverse: boolean
|
|
101
|
+
hidden: boolean
|
|
102
|
+
wide: boolean // true for CJK/emoji double-width chars
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Terminal modes
|
|
107
|
+
|
|
108
|
+
Queryable via `screen.getMode(mode)`:
|
|
109
|
+
|
|
110
|
+
- `altScreen` -- alternate screen buffer
|
|
111
|
+
- `bracketedPaste` -- bracketed paste mode
|
|
112
|
+
- `mouseTracking` -- mouse tracking
|
|
113
|
+
- `autoWrap` -- auto-wrap at right margin (default: on)
|
|
114
|
+
- `applicationCursor` -- application cursor keys
|
|
115
|
+
- `applicationKeypad` -- application keypad mode
|
|
116
|
+
- `originMode` -- origin mode
|
|
117
|
+
- `insertMode` -- insert mode
|
|
118
|
+
- `reverseVideo` -- reverse video
|
|
119
|
+
- `focusTracking` -- focus tracking
|
|
120
|
+
- `cursorVisible` -- cursor visibility
|
|
121
|
+
|
|
122
|
+
## Ecosystem
|
|
123
|
+
|
|
124
|
+
- [Termless](https://termless.dev) -- headless terminal testing (uses vt100.js as its default backend)
|
|
125
|
+
- [Terminfo.dev](https://terminfo.dev) -- terminal feature support tables
|
|
126
|
+
- [Silvery](https://silvery.dev) -- React TUI framework
|
|
127
|
+
- [@termless/vt100](https://github.com/beorn/termless) -- Termless backend wrapper for vt100.js
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vt100.js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pure TypeScript VT100 terminal emulator. Zero dependencies, headless, fast.",
|
|
5
|
+
"keywords": ["vt100", "terminal-emulator", "ansi", "typescript", "headless", "zero-dependency", "tui"],
|
|
6
|
+
"homepage": "https://github.com/beorn/vt100",
|
|
7
|
+
"bugs": "https://github.com/beorn/vt100/issues",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Beorn",
|
|
10
|
+
"repository": "github:beorn/vt100",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"module": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src"],
|
|
17
|
+
"publishConfig": { "access": "public" },
|
|
18
|
+
"engines": { "bun": ">=1.0.0", "node": ">=18" },
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"vitest": "^4.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
package/src/screen.ts
ADDED
|
@@ -0,0 +1,1202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure TypeScript VT100 terminal emulator.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by the Rust vt100 crate — parses a terminal byte stream and
|
|
5
|
+
* maintains an in-memory screen representation with per-cell attributes,
|
|
6
|
+
* cursor tracking, and mode state.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies. Handles: SGR (16/256/truecolor), cursor movement,
|
|
9
|
+
* erase, scroll regions, alternate screen, modes, OSC title, and more.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ═══════════════════════════════════════════════════════
|
|
13
|
+
// Internal cell representation
|
|
14
|
+
// ═══════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
export interface CellColor {
|
|
17
|
+
r: number
|
|
18
|
+
g: number
|
|
19
|
+
b: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type UnderlineStyle = "none" | "single" | "double" | "curly" | "dotted" | "dashed"
|
|
23
|
+
|
|
24
|
+
export interface ScreenCell {
|
|
25
|
+
char: string
|
|
26
|
+
fg: CellColor | null
|
|
27
|
+
bg: CellColor | null
|
|
28
|
+
bold: boolean
|
|
29
|
+
faint: boolean
|
|
30
|
+
italic: boolean
|
|
31
|
+
underline: UnderlineStyle
|
|
32
|
+
strikethrough: boolean
|
|
33
|
+
inverse: boolean
|
|
34
|
+
hidden: boolean
|
|
35
|
+
wide: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Frozen sentinel for unwritten cells — never mutate, copy-on-write in writeChar(). */
|
|
39
|
+
const EMPTY_CELL: ScreenCell = Object.freeze({
|
|
40
|
+
char: "",
|
|
41
|
+
fg: null,
|
|
42
|
+
bg: null,
|
|
43
|
+
bold: false,
|
|
44
|
+
faint: false,
|
|
45
|
+
italic: false,
|
|
46
|
+
underline: "none" as UnderlineStyle,
|
|
47
|
+
strikethrough: false,
|
|
48
|
+
inverse: false,
|
|
49
|
+
hidden: false,
|
|
50
|
+
wide: false,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
function emptyCell(): ScreenCell {
|
|
54
|
+
return { ...EMPTY_CELL }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ═══════════════════════════════════════════════════════
|
|
58
|
+
// ANSI 256-color palette
|
|
59
|
+
// ═══════════════════════════════════════════════════════
|
|
60
|
+
|
|
61
|
+
const ANSI_16: readonly CellColor[] = [
|
|
62
|
+
{ r: 0x00, g: 0x00, b: 0x00 }, // 0 Black
|
|
63
|
+
{ r: 0x80, g: 0x00, b: 0x00 }, // 1 Red
|
|
64
|
+
{ r: 0x00, g: 0x80, b: 0x00 }, // 2 Green
|
|
65
|
+
{ r: 0x80, g: 0x80, b: 0x00 }, // 3 Yellow
|
|
66
|
+
{ r: 0x00, g: 0x00, b: 0x80 }, // 4 Blue
|
|
67
|
+
{ r: 0x80, g: 0x00, b: 0x80 }, // 5 Magenta
|
|
68
|
+
{ r: 0x00, g: 0x80, b: 0x80 }, // 6 Cyan
|
|
69
|
+
{ r: 0xc0, g: 0xc0, b: 0xc0 }, // 7 White
|
|
70
|
+
{ r: 0x80, g: 0x80, b: 0x80 }, // 8 Bright Black
|
|
71
|
+
{ r: 0xff, g: 0x00, b: 0x00 }, // 9 Bright Red
|
|
72
|
+
{ r: 0x00, g: 0xff, b: 0x00 }, // 10 Bright Green
|
|
73
|
+
{ r: 0xff, g: 0xff, b: 0x00 }, // 11 Bright Yellow
|
|
74
|
+
{ r: 0x00, g: 0x00, b: 0xff }, // 12 Bright Blue
|
|
75
|
+
{ r: 0xff, g: 0x00, b: 0xff }, // 13 Bright Magenta
|
|
76
|
+
{ r: 0x00, g: 0xff, b: 0xff }, // 14 Bright Cyan
|
|
77
|
+
{ r: 0xff, g: 0xff, b: 0xff }, // 15 Bright White
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
function buildPalette256(): CellColor[] {
|
|
81
|
+
const palette: CellColor[] = [...ANSI_16]
|
|
82
|
+
const levels = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
|
|
83
|
+
for (let r = 0; r < 6; r++) {
|
|
84
|
+
for (let g = 0; g < 6; g++) {
|
|
85
|
+
for (let b = 0; b < 6; b++) {
|
|
86
|
+
palette.push({ r: levels[r]!, g: levels[g]!, b: levels[b]! })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < 24; i++) {
|
|
91
|
+
const v = 8 + i * 10
|
|
92
|
+
palette.push({ r: v, g: v, b: v })
|
|
93
|
+
}
|
|
94
|
+
return palette
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const PALETTE_256 = buildPalette256()
|
|
98
|
+
|
|
99
|
+
// ═══════════════════════════════════════════════════════
|
|
100
|
+
// Unicode width (simplified — CJK detection)
|
|
101
|
+
// ═══════════════════════════════════════════════════════
|
|
102
|
+
|
|
103
|
+
function isWide(codePoint: number): boolean {
|
|
104
|
+
// CJK Unified Ideographs, CJK Compatibility Ideographs, etc.
|
|
105
|
+
return (
|
|
106
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
107
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals
|
|
108
|
+
(codePoint >= 0x3041 && codePoint <= 0x33bf) || // Hiragana, Katakana, Bopomofo, etc.
|
|
109
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Extension A
|
|
110
|
+
(codePoint >= 0x4e00 && codePoint <= 0xa4cf) || // CJK Unified Ideographs
|
|
111
|
+
(codePoint >= 0xa960 && codePoint <= 0xa97c) || // Hangul Jamo Extended-A
|
|
112
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
|
|
113
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
|
|
114
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) || // Vertical Forms
|
|
115
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6b) || // CJK Compatibility Forms
|
|
116
|
+
(codePoint >= 0xff01 && codePoint <= 0xff60) || // Fullwidth Forms
|
|
117
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) || // Fullwidth Signs
|
|
118
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || // Misc Symbols/Emoticons
|
|
119
|
+
(codePoint >= 0x1fa00 && codePoint <= 0x1faff) || // Extended Symbols & Pictographs
|
|
120
|
+
(codePoint >= 0x20000 && codePoint <= 0x2fffd) || // CJK Extension B-F
|
|
121
|
+
(codePoint >= 0x30000 && codePoint <= 0x3fffd) // CJK Extension G+
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ═══════════════════════════════════════════════════════
|
|
126
|
+
// Screen
|
|
127
|
+
// ═══════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
export interface ScreenOptions {
|
|
130
|
+
cols: number
|
|
131
|
+
rows: number
|
|
132
|
+
scrollbackLimit?: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Attrs {
|
|
136
|
+
fg: CellColor | null
|
|
137
|
+
bg: CellColor | null
|
|
138
|
+
bold: boolean
|
|
139
|
+
faint: boolean
|
|
140
|
+
italic: boolean
|
|
141
|
+
underline: UnderlineStyle
|
|
142
|
+
strikethrough: boolean
|
|
143
|
+
inverse: boolean
|
|
144
|
+
hidden: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Screen {
|
|
148
|
+
readonly cols: number
|
|
149
|
+
readonly rows: number
|
|
150
|
+
process(data: Uint8Array): void
|
|
151
|
+
resize(cols: number, rows: number): void
|
|
152
|
+
reset(): void
|
|
153
|
+
getCell(row: number, col: number): ScreenCell
|
|
154
|
+
getLine(row: number): ScreenCell[]
|
|
155
|
+
getText(): string
|
|
156
|
+
getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string
|
|
157
|
+
getCursorPosition(): { x: number; y: number }
|
|
158
|
+
getCursorVisible(): boolean
|
|
159
|
+
getTitle(): string
|
|
160
|
+
getMode(mode: string): boolean
|
|
161
|
+
getScrollbackLength(): number
|
|
162
|
+
getViewportOffset(): number
|
|
163
|
+
scrollViewport(delta: number): void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createScreen(opts: ScreenOptions): Screen {
|
|
167
|
+
let cols = opts.cols
|
|
168
|
+
let rows = opts.rows
|
|
169
|
+
const scrollbackLimit = opts.scrollbackLimit ?? 1000
|
|
170
|
+
|
|
171
|
+
// Main and alternate screen buffers
|
|
172
|
+
let mainGrid: ScreenCell[][] = makeGrid(cols, rows)
|
|
173
|
+
let altGrid: ScreenCell[][] = makeGrid(cols, rows)
|
|
174
|
+
let grid = mainGrid
|
|
175
|
+
let scrollback: ScreenCell[][] = []
|
|
176
|
+
|
|
177
|
+
// Cursor
|
|
178
|
+
let curX = 0
|
|
179
|
+
let curY = 0
|
|
180
|
+
let curVisible = true
|
|
181
|
+
let savedCurX = 0
|
|
182
|
+
let savedCurY = 0
|
|
183
|
+
|
|
184
|
+
// DECSC/DECRC saved state (cursor + attrs + modes)
|
|
185
|
+
interface SavedState {
|
|
186
|
+
curX: number
|
|
187
|
+
curY: number
|
|
188
|
+
attrs: Attrs
|
|
189
|
+
originMode: boolean
|
|
190
|
+
autoWrap: boolean
|
|
191
|
+
}
|
|
192
|
+
let savedState: SavedState = {
|
|
193
|
+
curX: 0,
|
|
194
|
+
curY: 0,
|
|
195
|
+
attrs: resetAttrs(),
|
|
196
|
+
originMode: false,
|
|
197
|
+
autoWrap: true,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Current drawing attributes
|
|
201
|
+
let attrs: Attrs = resetAttrs()
|
|
202
|
+
|
|
203
|
+
// Terminal state
|
|
204
|
+
let title = ""
|
|
205
|
+
let useAltScreen = false
|
|
206
|
+
let bracketedPaste = false
|
|
207
|
+
let applicationCursor = false
|
|
208
|
+
let applicationKeypad = false
|
|
209
|
+
let autoWrap = true
|
|
210
|
+
let mouseTracking = false
|
|
211
|
+
let focusTracking = false
|
|
212
|
+
let originMode = false
|
|
213
|
+
let insertMode = false
|
|
214
|
+
let reverseVideo = false
|
|
215
|
+
|
|
216
|
+
// Scroll region (inclusive, 0-based)
|
|
217
|
+
let scrollTop = 0
|
|
218
|
+
let scrollBottom = rows - 1
|
|
219
|
+
|
|
220
|
+
// Viewport scroll offset for scrollViewport()
|
|
221
|
+
let viewportOffset = 0
|
|
222
|
+
|
|
223
|
+
// Parser state
|
|
224
|
+
let parserState: "ground" | "escape" | "csi" | "osc" | "dcs" | "oscString" = "ground"
|
|
225
|
+
let escBuf = ""
|
|
226
|
+
let oscBuf = ""
|
|
227
|
+
|
|
228
|
+
// Decoder for incoming bytes
|
|
229
|
+
const decoder = new TextDecoder()
|
|
230
|
+
|
|
231
|
+
function makeGrid(c: number, r: number): ScreenCell[][] {
|
|
232
|
+
const g: ScreenCell[][] = []
|
|
233
|
+
for (let row = 0; row < r; row++) {
|
|
234
|
+
g.push(makeRow(c))
|
|
235
|
+
}
|
|
236
|
+
return g
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makeRow(c: number): ScreenCell[] {
|
|
240
|
+
const row: ScreenCell[] = []
|
|
241
|
+
for (let col = 0; col < c; col++) {
|
|
242
|
+
row.push(EMPTY_CELL)
|
|
243
|
+
}
|
|
244
|
+
return row
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function resetAttrs(): Attrs {
|
|
248
|
+
return {
|
|
249
|
+
fg: null,
|
|
250
|
+
bg: null,
|
|
251
|
+
bold: false,
|
|
252
|
+
faint: false,
|
|
253
|
+
italic: false,
|
|
254
|
+
underline: "none",
|
|
255
|
+
strikethrough: false,
|
|
256
|
+
inverse: false,
|
|
257
|
+
hidden: false,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function clampCursor(): void {
|
|
262
|
+
if (curX < 0) curX = 0
|
|
263
|
+
if (curX >= cols) curX = cols - 1
|
|
264
|
+
if (curY < 0) curY = 0
|
|
265
|
+
if (curY >= rows) curY = rows - 1
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Scrolling ──
|
|
269
|
+
|
|
270
|
+
function scrollUp(top: number, bottom: number): void {
|
|
271
|
+
// Move top row to scrollback (only if main screen & top of screen)
|
|
272
|
+
if (grid === mainGrid && top === 0) {
|
|
273
|
+
scrollback.push(grid[0]!)
|
|
274
|
+
// Bulk trim when exceeding 2x limit to avoid O(n) shift() on every scroll
|
|
275
|
+
if (scrollback.length > scrollbackLimit * 2) {
|
|
276
|
+
scrollback.splice(0, scrollback.length - scrollbackLimit)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Shift rows up within the region
|
|
280
|
+
for (let i = top; i < bottom; i++) {
|
|
281
|
+
grid[i] = grid[i + 1]!
|
|
282
|
+
}
|
|
283
|
+
grid[bottom] = makeRow(cols)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function scrollDown(top: number, bottom: number): void {
|
|
287
|
+
for (let i = bottom; i > top; i--) {
|
|
288
|
+
grid[i] = grid[i - 1]!
|
|
289
|
+
}
|
|
290
|
+
grid[top] = makeRow(cols)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Character writing ──
|
|
294
|
+
|
|
295
|
+
function writeChar(ch: string): void {
|
|
296
|
+
const codePoint = ch.codePointAt(0) ?? 0
|
|
297
|
+
const wide = isWide(codePoint)
|
|
298
|
+
const charWidth = wide ? 2 : 1
|
|
299
|
+
|
|
300
|
+
// Handle autowrap at end of line
|
|
301
|
+
if (curX + charWidth > cols) {
|
|
302
|
+
if (autoWrap) {
|
|
303
|
+
curX = 0
|
|
304
|
+
curY++
|
|
305
|
+
if (curY > scrollBottom) {
|
|
306
|
+
curY = scrollBottom
|
|
307
|
+
scrollUp(scrollTop, scrollBottom)
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
curX = cols - charWidth
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Insert mode: shift existing characters right before writing
|
|
315
|
+
if (insertMode) {
|
|
316
|
+
const row = grid[curY]!
|
|
317
|
+
for (let i = 0; i < charWidth; i++) {
|
|
318
|
+
row.splice(curX, 0, EMPTY_CELL)
|
|
319
|
+
row.pop()
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Copy-on-write: if cell is the shared EMPTY_CELL sentinel, create a fresh object
|
|
324
|
+
const row = grid[curY]!
|
|
325
|
+
let cell = row[curX]!
|
|
326
|
+
if (cell === EMPTY_CELL) {
|
|
327
|
+
cell = { ...EMPTY_CELL }
|
|
328
|
+
row[curX] = cell
|
|
329
|
+
}
|
|
330
|
+
cell.char = ch
|
|
331
|
+
cell.fg = attrs.fg ? { ...attrs.fg } : null
|
|
332
|
+
cell.bg = attrs.bg ? { ...attrs.bg } : null
|
|
333
|
+
cell.bold = attrs.bold
|
|
334
|
+
cell.faint = attrs.faint
|
|
335
|
+
cell.italic = attrs.italic
|
|
336
|
+
cell.underline = attrs.underline
|
|
337
|
+
cell.strikethrough = attrs.strikethrough
|
|
338
|
+
cell.inverse = attrs.inverse
|
|
339
|
+
cell.hidden = attrs.hidden
|
|
340
|
+
cell.wide = wide
|
|
341
|
+
|
|
342
|
+
if (wide && curX + 1 < cols) {
|
|
343
|
+
// Spacer cell for wide character — always create fresh
|
|
344
|
+
let spacer = row[curX + 1]!
|
|
345
|
+
if (spacer === EMPTY_CELL) {
|
|
346
|
+
spacer = { ...EMPTY_CELL }
|
|
347
|
+
row[curX + 1] = spacer
|
|
348
|
+
}
|
|
349
|
+
spacer.char = ""
|
|
350
|
+
spacer.fg = null
|
|
351
|
+
spacer.bg = null
|
|
352
|
+
spacer.bold = false
|
|
353
|
+
spacer.faint = false
|
|
354
|
+
spacer.italic = false
|
|
355
|
+
spacer.underline = "none"
|
|
356
|
+
spacer.strikethrough = false
|
|
357
|
+
spacer.inverse = false
|
|
358
|
+
spacer.hidden = false
|
|
359
|
+
spacer.wide = false
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
curX += charWidth
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── CSI handler ──
|
|
366
|
+
|
|
367
|
+
function handleCSI(params: string, finalByte: string): void {
|
|
368
|
+
const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
369
|
+
|
|
370
|
+
switch (finalByte) {
|
|
371
|
+
case "A": // CUU - Cursor Up
|
|
372
|
+
curY -= Math.max(parts[0] ?? 1, 1)
|
|
373
|
+
clampCursor()
|
|
374
|
+
break
|
|
375
|
+
case "B": // CUD - Cursor Down
|
|
376
|
+
curY += Math.max(parts[0] ?? 1, 1)
|
|
377
|
+
clampCursor()
|
|
378
|
+
break
|
|
379
|
+
case "C": // CUF - Cursor Forward
|
|
380
|
+
curX += Math.max(parts[0] ?? 1, 1)
|
|
381
|
+
clampCursor()
|
|
382
|
+
break
|
|
383
|
+
case "D": // CUB - Cursor Back
|
|
384
|
+
curX -= Math.max(parts[0] ?? 1, 1)
|
|
385
|
+
clampCursor()
|
|
386
|
+
break
|
|
387
|
+
case "E": // CNL - Cursor Next Line
|
|
388
|
+
curY += Math.max(parts[0] ?? 1, 1)
|
|
389
|
+
curX = 0
|
|
390
|
+
clampCursor()
|
|
391
|
+
break
|
|
392
|
+
case "F": // CPL - Cursor Previous Line
|
|
393
|
+
curY -= Math.max(parts[0] ?? 1, 1)
|
|
394
|
+
curX = 0
|
|
395
|
+
clampCursor()
|
|
396
|
+
break
|
|
397
|
+
case "G": // CHA - Cursor Horizontal Absolute
|
|
398
|
+
curX = (parts[0] ?? 1) - 1
|
|
399
|
+
clampCursor()
|
|
400
|
+
break
|
|
401
|
+
case "H": // CUP - Cursor Position
|
|
402
|
+
case "f": // HVP - same as CUP
|
|
403
|
+
curY = (parts[0] ?? 1) - 1
|
|
404
|
+
curX = (parts[1] ?? 1) - 1
|
|
405
|
+
clampCursor()
|
|
406
|
+
break
|
|
407
|
+
case "J": // ED - Erase in Display
|
|
408
|
+
handleEraseDisplay(parts[0] ?? 0)
|
|
409
|
+
break
|
|
410
|
+
case "K": // EL - Erase in Line
|
|
411
|
+
handleEraseLine(parts[0] ?? 0)
|
|
412
|
+
break
|
|
413
|
+
case "L": // IL - Insert Lines
|
|
414
|
+
handleInsertLines(Math.max(parts[0] ?? 1, 1))
|
|
415
|
+
break
|
|
416
|
+
case "M": // DL - Delete Lines
|
|
417
|
+
handleDeleteLines(Math.max(parts[0] ?? 1, 1))
|
|
418
|
+
break
|
|
419
|
+
case "P": // DCH - Delete Characters
|
|
420
|
+
handleDeleteChars(Math.max(parts[0] ?? 1, 1))
|
|
421
|
+
break
|
|
422
|
+
case "@": // ICH - Insert Characters
|
|
423
|
+
handleInsertChars(Math.max(parts[0] ?? 1, 1))
|
|
424
|
+
break
|
|
425
|
+
case "X": // ECH - Erase Characters
|
|
426
|
+
handleEraseChars(Math.max(parts[0] ?? 1, 1))
|
|
427
|
+
break
|
|
428
|
+
case "S": // SU - Scroll Up
|
|
429
|
+
for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
|
|
430
|
+
scrollUp(scrollTop, scrollBottom)
|
|
431
|
+
}
|
|
432
|
+
break
|
|
433
|
+
case "T": // SD - Scroll Down
|
|
434
|
+
for (let i = 0; i < Math.max(parts[0] ?? 1, 1); i++) {
|
|
435
|
+
scrollDown(scrollTop, scrollBottom)
|
|
436
|
+
}
|
|
437
|
+
break
|
|
438
|
+
case "d": // VPA - Line Position Absolute
|
|
439
|
+
curY = (parts[0] ?? 1) - 1
|
|
440
|
+
clampCursor()
|
|
441
|
+
break
|
|
442
|
+
case "m": // SGR - Select Graphic Rendition
|
|
443
|
+
handleSGR(params)
|
|
444
|
+
break
|
|
445
|
+
case "r": // DECSTBM - Set Scrolling Region
|
|
446
|
+
scrollTop = (parts[0] ?? 1) - 1
|
|
447
|
+
scrollBottom = (parts[1] ?? rows) - 1
|
|
448
|
+
if (scrollTop < 0) scrollTop = 0
|
|
449
|
+
if (scrollBottom >= rows) scrollBottom = rows - 1
|
|
450
|
+
if (scrollTop > scrollBottom) {
|
|
451
|
+
scrollTop = 0
|
|
452
|
+
scrollBottom = rows - 1
|
|
453
|
+
}
|
|
454
|
+
curX = 0
|
|
455
|
+
curY = originMode ? scrollTop : 0
|
|
456
|
+
break
|
|
457
|
+
case "n": // DSR - Device Status Report (ignore)
|
|
458
|
+
break
|
|
459
|
+
case "c": // DA - Device Attributes (ignore)
|
|
460
|
+
break
|
|
461
|
+
case "s": // SCP - Save Cursor Position
|
|
462
|
+
savedCurX = curX
|
|
463
|
+
savedCurY = curY
|
|
464
|
+
break
|
|
465
|
+
case "u": // RCP - Restore Cursor Position
|
|
466
|
+
curX = savedCurX
|
|
467
|
+
curY = savedCurY
|
|
468
|
+
clampCursor()
|
|
469
|
+
break
|
|
470
|
+
default:
|
|
471
|
+
// Unknown CSI sequence — ignore
|
|
472
|
+
break
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function handleCSIPrivate(params: string, finalByte: string): void {
|
|
477
|
+
const parts = params.split(";").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
478
|
+
const set = finalByte === "h"
|
|
479
|
+
|
|
480
|
+
for (const code of parts) {
|
|
481
|
+
switch (code) {
|
|
482
|
+
case 1: // DECCKM - Application Cursor
|
|
483
|
+
applicationCursor = set
|
|
484
|
+
break
|
|
485
|
+
case 6: // DECOM - Origin Mode
|
|
486
|
+
originMode = set
|
|
487
|
+
break
|
|
488
|
+
case 7: // DECAWM - Autowrap Mode
|
|
489
|
+
autoWrap = set
|
|
490
|
+
break
|
|
491
|
+
case 25: // DECTCEM - Cursor Visible
|
|
492
|
+
curVisible = set
|
|
493
|
+
break
|
|
494
|
+
case 47: // Alternate screen buffer (old)
|
|
495
|
+
case 1047: // Alternate screen buffer
|
|
496
|
+
if (set && !useAltScreen) {
|
|
497
|
+
useAltScreen = true
|
|
498
|
+
grid = altGrid
|
|
499
|
+
} else if (!set && useAltScreen) {
|
|
500
|
+
useAltScreen = false
|
|
501
|
+
grid = mainGrid
|
|
502
|
+
}
|
|
503
|
+
break
|
|
504
|
+
case 66: // DECNKM - Application Keypad
|
|
505
|
+
applicationKeypad = set
|
|
506
|
+
break
|
|
507
|
+
case 1000: // Mouse tracking (basic)
|
|
508
|
+
case 1002: // Mouse tracking (button events)
|
|
509
|
+
case 1003: // Mouse tracking (all events)
|
|
510
|
+
mouseTracking = set
|
|
511
|
+
break
|
|
512
|
+
case 1004: // Focus tracking
|
|
513
|
+
focusTracking = set
|
|
514
|
+
break
|
|
515
|
+
case 1049: // Alternate screen buffer + save/restore cursor
|
|
516
|
+
if (set && !useAltScreen) {
|
|
517
|
+
savedCurX = curX
|
|
518
|
+
savedCurY = curY
|
|
519
|
+
useAltScreen = true
|
|
520
|
+
altGrid = makeGrid(cols, rows)
|
|
521
|
+
grid = altGrid
|
|
522
|
+
curX = 0
|
|
523
|
+
curY = 0
|
|
524
|
+
} else if (!set && useAltScreen) {
|
|
525
|
+
useAltScreen = false
|
|
526
|
+
grid = mainGrid
|
|
527
|
+
curX = savedCurX
|
|
528
|
+
curY = savedCurY
|
|
529
|
+
clampCursor()
|
|
530
|
+
}
|
|
531
|
+
break
|
|
532
|
+
case 2004: // Bracketed paste
|
|
533
|
+
bracketedPaste = set
|
|
534
|
+
break
|
|
535
|
+
case 5: // DECSCNM - Reverse Video
|
|
536
|
+
reverseVideo = set
|
|
537
|
+
break
|
|
538
|
+
case 4: // IRM - Insert Mode (via DEC private)
|
|
539
|
+
insertMode = set
|
|
540
|
+
break
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function handleEraseDisplay(mode: number): void {
|
|
546
|
+
switch (mode) {
|
|
547
|
+
case 0: // Erase from cursor to end
|
|
548
|
+
eraseCells(curY, curX, curY, cols - 1)
|
|
549
|
+
for (let row = curY + 1; row < rows; row++) {
|
|
550
|
+
eraseCells(row, 0, row, cols - 1)
|
|
551
|
+
}
|
|
552
|
+
break
|
|
553
|
+
case 1: // Erase from start to cursor
|
|
554
|
+
for (let row = 0; row < curY; row++) {
|
|
555
|
+
eraseCells(row, 0, row, cols - 1)
|
|
556
|
+
}
|
|
557
|
+
eraseCells(curY, 0, curY, curX)
|
|
558
|
+
break
|
|
559
|
+
case 2: // Erase entire display
|
|
560
|
+
case 3: // Erase entire display + scrollback
|
|
561
|
+
for (let row = 0; row < rows; row++) {
|
|
562
|
+
eraseCells(row, 0, row, cols - 1)
|
|
563
|
+
}
|
|
564
|
+
if (mode === 3) {
|
|
565
|
+
scrollback.length = 0
|
|
566
|
+
}
|
|
567
|
+
break
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function handleEraseLine(mode: number): void {
|
|
572
|
+
switch (mode) {
|
|
573
|
+
case 0: // Erase from cursor to end of line
|
|
574
|
+
eraseCells(curY, curX, curY, cols - 1)
|
|
575
|
+
break
|
|
576
|
+
case 1: // Erase from start to cursor
|
|
577
|
+
eraseCells(curY, 0, curY, curX)
|
|
578
|
+
break
|
|
579
|
+
case 2: // Erase entire line
|
|
580
|
+
eraseCells(curY, 0, curY, cols - 1)
|
|
581
|
+
break
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function eraseCells(row: number, startCol: number, _endRow: number, endCol: number): void {
|
|
586
|
+
const r = grid[row]
|
|
587
|
+
if (!r) return
|
|
588
|
+
for (let col = startCol; col <= endCol && col < cols; col++) {
|
|
589
|
+
r[col] = emptyCell()
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function handleInsertLines(count: number): void {
|
|
594
|
+
if (curY < scrollTop || curY > scrollBottom) return
|
|
595
|
+
for (let i = 0; i < count; i++) {
|
|
596
|
+
scrollDown(curY, scrollBottom)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function handleDeleteLines(count: number): void {
|
|
601
|
+
if (curY < scrollTop || curY > scrollBottom) return
|
|
602
|
+
for (let i = 0; i < count; i++) {
|
|
603
|
+
scrollUp(curY, scrollBottom)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function handleDeleteChars(count: number): void {
|
|
608
|
+
const row = grid[curY]
|
|
609
|
+
if (!row) return
|
|
610
|
+
for (let i = 0; i < count; i++) {
|
|
611
|
+
if (curX < cols) {
|
|
612
|
+
row.splice(curX, 1)
|
|
613
|
+
row.push(emptyCell())
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function handleInsertChars(count: number): void {
|
|
619
|
+
const row = grid[curY]
|
|
620
|
+
if (!row) return
|
|
621
|
+
for (let i = 0; i < count; i++) {
|
|
622
|
+
row.splice(curX, 0, emptyCell())
|
|
623
|
+
row.pop()
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function handleEraseChars(count: number): void {
|
|
628
|
+
const row = grid[curY]
|
|
629
|
+
if (!row) return
|
|
630
|
+
for (let i = 0; i < count && curX + i < cols; i++) {
|
|
631
|
+
row[curX + i] = emptyCell()
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── SGR (Select Graphic Rendition) ──
|
|
636
|
+
|
|
637
|
+
function handleSGR(rawParams: string): void {
|
|
638
|
+
// Parse SGR parameters, handling colon sub-parameters (e.g., "4:3" for curly underline)
|
|
639
|
+
const segments = rawParams.split(";")
|
|
640
|
+
const params: number[] = []
|
|
641
|
+
// Map from param index to colon sub-parameters (e.g., index of "4" -> [4, 3])
|
|
642
|
+
const subParams = new Map<number, number[]>()
|
|
643
|
+
for (const seg of segments) {
|
|
644
|
+
if (seg.includes(":")) {
|
|
645
|
+
const subs = seg.split(":").map((s) => (s === "" ? 0 : parseInt(s, 10)))
|
|
646
|
+
subParams.set(params.length, subs)
|
|
647
|
+
params.push(subs[0]!)
|
|
648
|
+
} else {
|
|
649
|
+
params.push(seg === "" ? 0 : parseInt(seg, 10))
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
|
654
|
+
attrs = resetAttrs()
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
let i = 0
|
|
659
|
+
while (i < params.length) {
|
|
660
|
+
const code = params[i]!
|
|
661
|
+
switch (code) {
|
|
662
|
+
case 0:
|
|
663
|
+
attrs = resetAttrs()
|
|
664
|
+
break
|
|
665
|
+
case 1:
|
|
666
|
+
attrs.bold = true
|
|
667
|
+
break
|
|
668
|
+
case 2:
|
|
669
|
+
attrs.faint = true
|
|
670
|
+
break
|
|
671
|
+
case 3:
|
|
672
|
+
attrs.italic = true
|
|
673
|
+
break
|
|
674
|
+
case 4: {
|
|
675
|
+
// SGR 4 with optional sub-parameter: 4:0=none, 4:1=single, 4:3=curly, etc.
|
|
676
|
+
const subs = subParams.get(i)
|
|
677
|
+
if (subs && subs.length > 1) {
|
|
678
|
+
const sub = subs[1]!
|
|
679
|
+
switch (sub) {
|
|
680
|
+
case 0:
|
|
681
|
+
attrs.underline = "none"
|
|
682
|
+
break
|
|
683
|
+
case 1:
|
|
684
|
+
attrs.underline = "single"
|
|
685
|
+
break
|
|
686
|
+
case 2:
|
|
687
|
+
attrs.underline = "double"
|
|
688
|
+
break
|
|
689
|
+
case 3:
|
|
690
|
+
attrs.underline = "curly"
|
|
691
|
+
break
|
|
692
|
+
case 4:
|
|
693
|
+
attrs.underline = "dotted"
|
|
694
|
+
break
|
|
695
|
+
case 5:
|
|
696
|
+
attrs.underline = "dashed"
|
|
697
|
+
break
|
|
698
|
+
default:
|
|
699
|
+
attrs.underline = "single"
|
|
700
|
+
break
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
attrs.underline = "single"
|
|
704
|
+
}
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
case 7:
|
|
708
|
+
attrs.inverse = true
|
|
709
|
+
break
|
|
710
|
+
case 8: // Hidden/conceal
|
|
711
|
+
attrs.hidden = true
|
|
712
|
+
break
|
|
713
|
+
case 9:
|
|
714
|
+
attrs.strikethrough = true
|
|
715
|
+
break
|
|
716
|
+
case 21: // Double underline
|
|
717
|
+
attrs.underline = "double"
|
|
718
|
+
break
|
|
719
|
+
case 22: // Normal intensity (neither bold nor faint)
|
|
720
|
+
attrs.bold = false
|
|
721
|
+
attrs.faint = false
|
|
722
|
+
break
|
|
723
|
+
case 23:
|
|
724
|
+
attrs.italic = false
|
|
725
|
+
break
|
|
726
|
+
case 24:
|
|
727
|
+
attrs.underline = "none"
|
|
728
|
+
break
|
|
729
|
+
case 27:
|
|
730
|
+
attrs.inverse = false
|
|
731
|
+
break
|
|
732
|
+
case 28: // Reveal (turn off hidden/conceal)
|
|
733
|
+
attrs.hidden = false
|
|
734
|
+
break
|
|
735
|
+
case 29:
|
|
736
|
+
attrs.strikethrough = false
|
|
737
|
+
break
|
|
738
|
+
// Foreground colors 30-37
|
|
739
|
+
case 30:
|
|
740
|
+
case 31:
|
|
741
|
+
case 32:
|
|
742
|
+
case 33:
|
|
743
|
+
case 34:
|
|
744
|
+
case 35:
|
|
745
|
+
case 36:
|
|
746
|
+
case 37:
|
|
747
|
+
attrs.fg = { ...PALETTE_256[code - 30]! }
|
|
748
|
+
break
|
|
749
|
+
case 38: {
|
|
750
|
+
// Extended foreground: 38;5;N (256) or 38;2;R;G;B (truecolor)
|
|
751
|
+
const result = parseExtendedColor(params, i)
|
|
752
|
+
if (result) {
|
|
753
|
+
attrs.fg = result.color
|
|
754
|
+
i = result.nextIndex - 1 // -1 because loop increments
|
|
755
|
+
}
|
|
756
|
+
break
|
|
757
|
+
}
|
|
758
|
+
case 39: // Default foreground
|
|
759
|
+
attrs.fg = null
|
|
760
|
+
break
|
|
761
|
+
// Background colors 40-47
|
|
762
|
+
case 40:
|
|
763
|
+
case 41:
|
|
764
|
+
case 42:
|
|
765
|
+
case 43:
|
|
766
|
+
case 44:
|
|
767
|
+
case 45:
|
|
768
|
+
case 46:
|
|
769
|
+
case 47:
|
|
770
|
+
attrs.bg = { ...PALETTE_256[code - 40]! }
|
|
771
|
+
break
|
|
772
|
+
case 48: {
|
|
773
|
+
// Extended background: 48;5;N (256) or 48;2;R;G;B (truecolor)
|
|
774
|
+
const result = parseExtendedColor(params, i)
|
|
775
|
+
if (result) {
|
|
776
|
+
attrs.bg = result.color
|
|
777
|
+
i = result.nextIndex - 1
|
|
778
|
+
}
|
|
779
|
+
break
|
|
780
|
+
}
|
|
781
|
+
case 49: // Default background
|
|
782
|
+
attrs.bg = null
|
|
783
|
+
break
|
|
784
|
+
// Bright foreground 90-97
|
|
785
|
+
case 90:
|
|
786
|
+
case 91:
|
|
787
|
+
case 92:
|
|
788
|
+
case 93:
|
|
789
|
+
case 94:
|
|
790
|
+
case 95:
|
|
791
|
+
case 96:
|
|
792
|
+
case 97:
|
|
793
|
+
attrs.fg = { ...PALETTE_256[code - 90 + 8]! }
|
|
794
|
+
break
|
|
795
|
+
// Bright background 100-107
|
|
796
|
+
case 100:
|
|
797
|
+
case 101:
|
|
798
|
+
case 102:
|
|
799
|
+
case 103:
|
|
800
|
+
case 104:
|
|
801
|
+
case 105:
|
|
802
|
+
case 106:
|
|
803
|
+
case 107:
|
|
804
|
+
attrs.bg = { ...PALETTE_256[code - 100 + 8]! }
|
|
805
|
+
break
|
|
806
|
+
}
|
|
807
|
+
i++
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function parseExtendedColor(params: number[], startIndex: number): { color: CellColor; nextIndex: number } | null {
|
|
812
|
+
if (startIndex + 1 >= params.length) return null
|
|
813
|
+
|
|
814
|
+
const type = params[startIndex + 1]
|
|
815
|
+
if (type === 5 && startIndex + 2 < params.length) {
|
|
816
|
+
// 256-color: 38;5;N
|
|
817
|
+
const idx = params[startIndex + 2]!
|
|
818
|
+
const color = PALETTE_256[idx] ?? { r: 0, g: 0, b: 0 }
|
|
819
|
+
return { color: { ...color }, nextIndex: startIndex + 3 }
|
|
820
|
+
} else if (type === 2 && startIndex + 4 < params.length) {
|
|
821
|
+
// Truecolor: 38;2;R;G;B
|
|
822
|
+
return {
|
|
823
|
+
color: {
|
|
824
|
+
r: params[startIndex + 2]!,
|
|
825
|
+
g: params[startIndex + 3]!,
|
|
826
|
+
b: params[startIndex + 4]!,
|
|
827
|
+
},
|
|
828
|
+
nextIndex: startIndex + 5,
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return null
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ── OSC handler ──
|
|
835
|
+
|
|
836
|
+
function handleOSC(oscString: string): void {
|
|
837
|
+
const semicolonIdx = oscString.indexOf(";")
|
|
838
|
+
if (semicolonIdx === -1) return
|
|
839
|
+
|
|
840
|
+
const code = parseInt(oscString.substring(0, semicolonIdx), 10)
|
|
841
|
+
const value = oscString.substring(semicolonIdx + 1)
|
|
842
|
+
|
|
843
|
+
switch (code) {
|
|
844
|
+
case 0: // Set icon name and window title
|
|
845
|
+
case 2: // Set window title
|
|
846
|
+
title = value
|
|
847
|
+
break
|
|
848
|
+
case 1: // Set icon name (ignore)
|
|
849
|
+
break
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── Main parser ──
|
|
854
|
+
|
|
855
|
+
function process(data: Uint8Array): void {
|
|
856
|
+
const text = decoder.decode(data, { stream: true })
|
|
857
|
+
|
|
858
|
+
for (let i = 0; i < text.length; i++) {
|
|
859
|
+
const ch = text[i]!
|
|
860
|
+
const code = text.charCodeAt(i)
|
|
861
|
+
|
|
862
|
+
switch (parserState) {
|
|
863
|
+
case "ground":
|
|
864
|
+
if (code === 0x1b) {
|
|
865
|
+
parserState = "escape"
|
|
866
|
+
escBuf = ""
|
|
867
|
+
} else if (code === 0x07) {
|
|
868
|
+
// BEL — ignore
|
|
869
|
+
} else if (code === 0x08) {
|
|
870
|
+
// BS - Backspace
|
|
871
|
+
if (curX > 0) curX--
|
|
872
|
+
} else if (code === 0x09) {
|
|
873
|
+
// TAB
|
|
874
|
+
curX = Math.min((Math.floor(curX / 8) + 1) * 8, cols - 1)
|
|
875
|
+
} else if (code === 0x0a || code === 0x0b || code === 0x0c) {
|
|
876
|
+
// LF, VT, FF — linefeed
|
|
877
|
+
curY++
|
|
878
|
+
if (curY > scrollBottom) {
|
|
879
|
+
curY = scrollBottom
|
|
880
|
+
scrollUp(scrollTop, scrollBottom)
|
|
881
|
+
}
|
|
882
|
+
} else if (code === 0x0d) {
|
|
883
|
+
// CR - Carriage Return
|
|
884
|
+
curX = 0
|
|
885
|
+
} else if (code >= 0x20) {
|
|
886
|
+
// Handle surrogate pairs for characters > U+FFFF
|
|
887
|
+
let char = ch
|
|
888
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
889
|
+
const nextCode = text.charCodeAt(i + 1)
|
|
890
|
+
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
|
891
|
+
char = ch + text[i + 1]!
|
|
892
|
+
i++
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
writeChar(char)
|
|
896
|
+
}
|
|
897
|
+
break
|
|
898
|
+
|
|
899
|
+
case "escape":
|
|
900
|
+
if (ch === "[") {
|
|
901
|
+
parserState = "csi"
|
|
902
|
+
escBuf = ""
|
|
903
|
+
} else if (ch === "]") {
|
|
904
|
+
parserState = "osc"
|
|
905
|
+
oscBuf = ""
|
|
906
|
+
} else if (ch === "P") {
|
|
907
|
+
parserState = "dcs"
|
|
908
|
+
escBuf = ""
|
|
909
|
+
} else if (ch === "c") {
|
|
910
|
+
// RIS - Reset to Initial State
|
|
911
|
+
fullReset()
|
|
912
|
+
} else if (ch === "D") {
|
|
913
|
+
// IND - Index (move cursor down, scroll if needed)
|
|
914
|
+
curY++
|
|
915
|
+
if (curY > scrollBottom) {
|
|
916
|
+
curY = scrollBottom
|
|
917
|
+
scrollUp(scrollTop, scrollBottom)
|
|
918
|
+
}
|
|
919
|
+
parserState = "ground"
|
|
920
|
+
} else if (ch === "M") {
|
|
921
|
+
// RI - Reverse Index (move cursor up, scroll if needed)
|
|
922
|
+
curY--
|
|
923
|
+
if (curY < scrollTop) {
|
|
924
|
+
curY = scrollTop
|
|
925
|
+
scrollDown(scrollTop, scrollBottom)
|
|
926
|
+
}
|
|
927
|
+
parserState = "ground"
|
|
928
|
+
} else if (ch === "7") {
|
|
929
|
+
// DECSC - Save Cursor + attributes + modes
|
|
930
|
+
savedState = {
|
|
931
|
+
curX,
|
|
932
|
+
curY,
|
|
933
|
+
attrs: { ...attrs, fg: attrs.fg ? { ...attrs.fg } : null, bg: attrs.bg ? { ...attrs.bg } : null },
|
|
934
|
+
originMode,
|
|
935
|
+
autoWrap,
|
|
936
|
+
}
|
|
937
|
+
parserState = "ground"
|
|
938
|
+
} else if (ch === "8") {
|
|
939
|
+
// DECRC - Restore Cursor + attributes + modes
|
|
940
|
+
curX = savedState.curX
|
|
941
|
+
curY = savedState.curY
|
|
942
|
+
attrs = {
|
|
943
|
+
...savedState.attrs,
|
|
944
|
+
fg: savedState.attrs.fg ? { ...savedState.attrs.fg } : null,
|
|
945
|
+
bg: savedState.attrs.bg ? { ...savedState.attrs.bg } : null,
|
|
946
|
+
}
|
|
947
|
+
originMode = savedState.originMode
|
|
948
|
+
autoWrap = savedState.autoWrap
|
|
949
|
+
clampCursor()
|
|
950
|
+
parserState = "ground"
|
|
951
|
+
} else if (ch === "E") {
|
|
952
|
+
// NEL - Next Line
|
|
953
|
+
curX = 0
|
|
954
|
+
curY++
|
|
955
|
+
if (curY > scrollBottom) {
|
|
956
|
+
curY = scrollBottom
|
|
957
|
+
scrollUp(scrollTop, scrollBottom)
|
|
958
|
+
}
|
|
959
|
+
parserState = "ground"
|
|
960
|
+
} else {
|
|
961
|
+
// Unknown escape — return to ground
|
|
962
|
+
parserState = "ground"
|
|
963
|
+
}
|
|
964
|
+
break
|
|
965
|
+
|
|
966
|
+
case "csi":
|
|
967
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
968
|
+
// Final byte — dispatch CSI
|
|
969
|
+
if (escBuf.startsWith("?")) {
|
|
970
|
+
handleCSIPrivate(escBuf.substring(1), ch)
|
|
971
|
+
} else {
|
|
972
|
+
handleCSI(escBuf, ch)
|
|
973
|
+
}
|
|
974
|
+
parserState = "ground"
|
|
975
|
+
} else if (escBuf.length >= 256) {
|
|
976
|
+
// Buffer overflow — drop to ground to avoid unbounded accumulation
|
|
977
|
+
parserState = "ground"
|
|
978
|
+
} else {
|
|
979
|
+
// Parameter or intermediate byte
|
|
980
|
+
escBuf += ch
|
|
981
|
+
}
|
|
982
|
+
break
|
|
983
|
+
|
|
984
|
+
case "osc":
|
|
985
|
+
if (code === 0x07) {
|
|
986
|
+
// BEL terminates OSC
|
|
987
|
+
handleOSC(oscBuf)
|
|
988
|
+
parserState = "ground"
|
|
989
|
+
} else if (code === 0x1b) {
|
|
990
|
+
// ESC might be start of ST (\x1b\\)
|
|
991
|
+
parserState = "oscString"
|
|
992
|
+
} else if (oscBuf.length >= 4096) {
|
|
993
|
+
// Buffer overflow — drop to ground to avoid unbounded accumulation
|
|
994
|
+
parserState = "ground"
|
|
995
|
+
} else {
|
|
996
|
+
oscBuf += ch
|
|
997
|
+
}
|
|
998
|
+
break
|
|
999
|
+
|
|
1000
|
+
case "oscString":
|
|
1001
|
+
if (ch === "\\") {
|
|
1002
|
+
// ST (String Terminator) — end of OSC
|
|
1003
|
+
handleOSC(oscBuf)
|
|
1004
|
+
}
|
|
1005
|
+
// Either way, back to ground
|
|
1006
|
+
parserState = "ground"
|
|
1007
|
+
break
|
|
1008
|
+
|
|
1009
|
+
case "dcs":
|
|
1010
|
+
// Consume until ST
|
|
1011
|
+
if (code === 0x1b) {
|
|
1012
|
+
parserState = "oscString" // Reuse ST detection
|
|
1013
|
+
}
|
|
1014
|
+
break
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function fullReset(): void {
|
|
1020
|
+
mainGrid = makeGrid(cols, rows)
|
|
1021
|
+
altGrid = makeGrid(cols, rows)
|
|
1022
|
+
grid = mainGrid
|
|
1023
|
+
scrollback = []
|
|
1024
|
+
curX = 0
|
|
1025
|
+
curY = 0
|
|
1026
|
+
curVisible = true
|
|
1027
|
+
savedCurX = 0
|
|
1028
|
+
savedCurY = 0
|
|
1029
|
+
savedState = { curX: 0, curY: 0, attrs: resetAttrs(), originMode: false, autoWrap: true }
|
|
1030
|
+
attrs = resetAttrs()
|
|
1031
|
+
title = ""
|
|
1032
|
+
useAltScreen = false
|
|
1033
|
+
bracketedPaste = false
|
|
1034
|
+
applicationCursor = false
|
|
1035
|
+
applicationKeypad = false
|
|
1036
|
+
autoWrap = true
|
|
1037
|
+
mouseTracking = false
|
|
1038
|
+
focusTracking = false
|
|
1039
|
+
originMode = false
|
|
1040
|
+
insertMode = false
|
|
1041
|
+
reverseVideo = false
|
|
1042
|
+
scrollTop = 0
|
|
1043
|
+
scrollBottom = rows - 1
|
|
1044
|
+
viewportOffset = 0
|
|
1045
|
+
parserState = "ground"
|
|
1046
|
+
escBuf = ""
|
|
1047
|
+
oscBuf = ""
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function resize(newCols: number, newRows: number): void {
|
|
1051
|
+
const newMain = makeGrid(newCols, newRows)
|
|
1052
|
+
const newAlt = makeGrid(newCols, newRows)
|
|
1053
|
+
|
|
1054
|
+
// Copy content from old grids
|
|
1055
|
+
copyGrid(mainGrid, newMain, Math.min(cols, newCols), Math.min(rows, newRows))
|
|
1056
|
+
copyGrid(altGrid, newAlt, Math.min(cols, newCols), Math.min(rows, newRows))
|
|
1057
|
+
|
|
1058
|
+
mainGrid = newMain
|
|
1059
|
+
altGrid = newAlt
|
|
1060
|
+
grid = useAltScreen ? altGrid : mainGrid
|
|
1061
|
+
cols = newCols
|
|
1062
|
+
rows = newRows
|
|
1063
|
+
scrollTop = 0
|
|
1064
|
+
scrollBottom = rows - 1
|
|
1065
|
+
clampCursor()
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function copyGrid(src: ScreenCell[][], dst: ScreenCell[][], copyCols: number, copyRows: number): void {
|
|
1069
|
+
for (let row = 0; row < copyRows; row++) {
|
|
1070
|
+
for (let col = 0; col < copyCols; col++) {
|
|
1071
|
+
const srcCell = src[row]?.[col]
|
|
1072
|
+
if (srcCell) {
|
|
1073
|
+
dst[row]![col] = { ...srcCell }
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function getCell(row: number, col: number): ScreenCell {
|
|
1080
|
+
const r = grid[row]
|
|
1081
|
+
if (!r || col >= cols) return emptyCell()
|
|
1082
|
+
return { ...r[col]! }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function getLine(row: number): ScreenCell[] {
|
|
1086
|
+
const r = grid[row]
|
|
1087
|
+
if (!r) return makeRow(cols)
|
|
1088
|
+
return r.map((cell) => ({ ...cell }))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function getText(): string {
|
|
1092
|
+
const lines: string[] = []
|
|
1093
|
+
|
|
1094
|
+
// Scrollback
|
|
1095
|
+
for (const row of scrollback) {
|
|
1096
|
+
lines.push(rowToString(row))
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Screen
|
|
1100
|
+
for (let r = 0; r < rows; r++) {
|
|
1101
|
+
lines.push(rowToString(grid[r]!))
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return lines.join("\n")
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function rowToString(row: ScreenCell[]): string {
|
|
1108
|
+
let line = ""
|
|
1109
|
+
for (let i = 0; i < row.length; i++) {
|
|
1110
|
+
const cell = row[i]!
|
|
1111
|
+
if (cell.wide) {
|
|
1112
|
+
line += cell.char
|
|
1113
|
+
} else if (cell.char === "") {
|
|
1114
|
+
// Skip spacer cells after wide chars, otherwise treat as space
|
|
1115
|
+
if (i > 0 && row[i - 1]?.wide) {
|
|
1116
|
+
continue
|
|
1117
|
+
}
|
|
1118
|
+
line += " "
|
|
1119
|
+
} else {
|
|
1120
|
+
line += cell.char
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return line.replace(/\s+$/, "") // Trim trailing whitespace
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function getTextRange(startRow: number, startCol: number, endRow: number, endCol: number): string {
|
|
1127
|
+
const parts: string[] = []
|
|
1128
|
+
|
|
1129
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
1130
|
+
const r = grid[row]
|
|
1131
|
+
if (!r) continue
|
|
1132
|
+
|
|
1133
|
+
const colStart = row === startRow ? startCol : 0
|
|
1134
|
+
const colEnd = row === endRow ? endCol : cols
|
|
1135
|
+
|
|
1136
|
+
let line = ""
|
|
1137
|
+
for (let col = colStart; col < colEnd; col++) {
|
|
1138
|
+
const cell = r[col]
|
|
1139
|
+
if (!cell) continue
|
|
1140
|
+
if (cell.char === "" && col > 0 && r[col - 1]?.wide) continue // Skip spacer
|
|
1141
|
+
line += cell.char || " "
|
|
1142
|
+
}
|
|
1143
|
+
parts.push(line.replace(/\s+$/, ""))
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return parts.join("\n")
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function getMode(mode: string): boolean {
|
|
1150
|
+
switch (mode) {
|
|
1151
|
+
case "altScreen":
|
|
1152
|
+
return useAltScreen
|
|
1153
|
+
case "cursorVisible":
|
|
1154
|
+
return curVisible
|
|
1155
|
+
case "bracketedPaste":
|
|
1156
|
+
return bracketedPaste
|
|
1157
|
+
case "applicationCursor":
|
|
1158
|
+
return applicationCursor
|
|
1159
|
+
case "applicationKeypad":
|
|
1160
|
+
return applicationKeypad
|
|
1161
|
+
case "autoWrap":
|
|
1162
|
+
return autoWrap
|
|
1163
|
+
case "mouseTracking":
|
|
1164
|
+
return mouseTracking
|
|
1165
|
+
case "focusTracking":
|
|
1166
|
+
return focusTracking
|
|
1167
|
+
case "originMode":
|
|
1168
|
+
return originMode
|
|
1169
|
+
case "insertMode":
|
|
1170
|
+
return insertMode
|
|
1171
|
+
case "reverseVideo":
|
|
1172
|
+
return reverseVideo
|
|
1173
|
+
default:
|
|
1174
|
+
return false
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return {
|
|
1179
|
+
get cols() {
|
|
1180
|
+
return cols
|
|
1181
|
+
},
|
|
1182
|
+
get rows() {
|
|
1183
|
+
return rows
|
|
1184
|
+
},
|
|
1185
|
+
process,
|
|
1186
|
+
resize,
|
|
1187
|
+
reset: fullReset,
|
|
1188
|
+
getCell,
|
|
1189
|
+
getLine,
|
|
1190
|
+
getText,
|
|
1191
|
+
getTextRange,
|
|
1192
|
+
getCursorPosition: () => ({ x: curX, y: curY }),
|
|
1193
|
+
getCursorVisible: () => curVisible,
|
|
1194
|
+
getTitle: () => title,
|
|
1195
|
+
getMode,
|
|
1196
|
+
getScrollbackLength: () => scrollback.length,
|
|
1197
|
+
getViewportOffset: () => viewportOffset,
|
|
1198
|
+
scrollViewport: (delta: number) => {
|
|
1199
|
+
viewportOffset = Math.max(0, Math.min(scrollback.length, viewportOffset + delta))
|
|
1200
|
+
},
|
|
1201
|
+
}
|
|
1202
|
+
}
|