Skip to content

Commit fbb9763

Browse files
csweichelona-agent
andcommitted
feat: port xterm.js headless terminal emulator to Go
Pure-Go headless terminal emulator ported from xterm.js. Processes VT/ANSI escape sequences and maintains terminal buffer state without requiring a browser or DOM. Includes: - Full VT500 parser (CSI, OSC, DCS, APC) - Normal/alt screen buffers with scrollback and reflow - Text attributes and 16/256/RGB color support - SerializeAddon for terminal state snapshots - 58 conformance tests against xterm.js golden data - Interactive WebSocket demo in _example/ Ported from gitpod-io/gitpod-next shared/go/xterm. Co-authored-by: Ona <no-reply@ona.com>
1 parent f4cc107 commit fbb9763

83 files changed

Lines changed: 26152 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ go.work.sum
3030
# Editor/IDE
3131
# .idea/
3232
# .vscode/
33+
_example/_example

PORTING_SPEC.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# xterm.js → Go Porting Specification
2+
3+
This document is the reference for porting the headless xterm.js terminal emulator to Go.
4+
5+
**Source:** https://github.com/xtermjs/xterm.js (MIT license)
6+
**Target:** This repository (`github.com/gitpod-io/xterm-go`)
7+
8+
9+
## Goal
10+
11+
A pure-Go headless terminal emulator that processes VT/ANSI escape sequences and maintains buffer state. No rendering, no DOM, no browser APIs. Stdlib-only dependencies (no third-party packages).
12+
13+
## Architecture Overview
14+
15+
```
16+
Terminal (public API)
17+
└── coreTerminal (internal orchestration)
18+
├── inputHandler (sequence → buffer ops)
19+
│ └── parser (EscapeSequenceParser)
20+
│ ├── oscParser
21+
│ ├── dcsParser
22+
│ └── apcParser
23+
├── bufferService
24+
│ └── bufferSet (normal + alt)
25+
│ └── buffer
26+
│ ├── CircularList[*BufferLine]
27+
│ └── markers
28+
├── coreService (data events)
29+
├── optionsService
30+
├── charsetService
31+
├── unicodeService
32+
└── oscLinkService
33+
```
34+
35+
## Porting Rules
36+
37+
### General
38+
1. **Port behavior, not syntax.** Translate TypeScript idioms to idiomatic Go.
39+
2. **No dependency injection framework.** xterm.js uses `InstantiationService` — replace with plain struct composition and constructor injection.
40+
3. **No `interface{}`.** Use concrete types or generics.
41+
4. **Unexported by default.** Only export the public Terminal API. Internal types are unexported.
42+
5. **Single package.** Everything lives in `package xterm` at the repository root.
43+
6. **Tests required.** Every file `foo.go` must have `foo_test.go`. Use table-driven tests with `cmp.Diff`.
44+
45+
### Type Mapping
46+
47+
| TypeScript | Go |
48+
|---|---|
49+
| `interface` | Go `interface` (only when needed for polymorphism) |
50+
| `class` | `struct` with methods |
51+
| `enum` (const enum) | `const` block with `iota` or explicit values |
52+
| `Uint16Array` / `Uint32Array` / `Int32Array` | `[]uint16` / `[]uint32` / `[]int32` |
53+
| `number` (integer context) | `int32` or `uint32` (match xterm.js bit widths) |
54+
| `string` | `string` or `[]rune` depending on context |
55+
| `Emitter<T>` / `IEvent<T>` | `EventEmitter[T]` (callback-based, see below) |
56+
| `IDisposable` | `Disposable` interface with `Dispose()` |
57+
| `Promise<T>` | Drop async support — Go port is synchronous |
58+
59+
### Event System
60+
61+
xterm.js uses `Emitter<T>` with `.event` property returning `IEvent<T>`. Port as:
62+
63+
```go
64+
// EventEmitter is a synchronous event emitter.
65+
type EventEmitter[T any] struct {
66+
listeners []func(T)
67+
}
68+
69+
func (e *EventEmitter[T]) Fire(value T) { ... }
70+
func (e *EventEmitter[T]) Event(listener func(T)) Disposable { ... }
71+
```
72+
73+
### Bit Layout Preservation
74+
75+
The attribute bit layouts MUST match xterm.js exactly. This ensures compatibility if we ever need to exchange buffer state.
76+
77+
**fg (uint32):**
78+
- bits 0-7: blue (RGB) or palette index
79+
- bits 8-15: green (RGB)
80+
- bits 16-23: red (RGB)
81+
- bits 24-25: color mode (0=default, 1=P16, 2=P256, 3=RGB)
82+
- bit 26: INVERSE
83+
- bit 27: BOLD
84+
- bit 28: UNDERLINE
85+
- bit 29: BLINK
86+
- bit 30: INVISIBLE
87+
- bit 31: STRIKETHROUGH
88+
89+
**bg (uint32):**
90+
- bits 0-25: same color layout as fg
91+
- bit 26: ITALIC
92+
- bit 27: DIM
93+
- bit 28: HAS_EXTENDED
94+
- bit 29: PROTECTED
95+
- bit 30: OVERLINE
96+
97+
**content (uint32):**
98+
- bits 0-20: codepoint (max 0x10FFFF)
99+
- bit 21: IS_COMBINED (cell has combined string data)
100+
- bits 22-23: wcwidth (0-2)
101+
102+
### Parser State Machine
103+
104+
The parser is a table-driven VT500 state machine. The transition table is a `[]uint16` of 4095 entries:
105+
- Index: `state << 8 | charCode`
106+
- Value: `action << 8 | nextState`
107+
108+
15 states, 18 actions. Port the `VT500_TRANSITION_TABLE` initialization exactly.
109+
110+
### CircularList
111+
112+
Generic circular buffer used for scrollback:
113+
114+
```go
115+
type CircularList[T any] struct {
116+
array []T
117+
length int
118+
maxLen int
119+
startIdx int
120+
// events for insert/delete/trim
121+
}
122+
```
123+
124+
### BufferLine Cell Storage
125+
126+
Each cell is stored as 3 values in parallel slices:
127+
- `content []uint32` — codepoint + width + combined flag
128+
- `fg []uint32` — foreground color + text attributes
129+
- `bg []uint32` — background color + flags
130+
131+
Combined characters (emoji, accented chars) store their string in a side map.
132+
133+
## File Mapping
134+
135+
| xterm.js Source | Go Target | Phase |
136+
|---|---|---|
137+
| `src/common/Types.ts` | `types.go` | 1 |
138+
| `src/common/buffer/Constants.ts` | `constants.go` | 1 |
139+
| `src/common/parser/Constants.ts` | `constants.go` | 1 |
140+
| `src/common/CircularList.ts` | `circularlist.go` | 1 |
141+
| `src/common/Event.ts` | `event.go` | 1 |
142+
| `src/common/Lifecycle.ts` | `lifecycle.go` | 1 |
143+
| `src/common/buffer/AttributeData.ts` | `attributedata.go` | 1 |
144+
| `src/common/buffer/CellData.ts` | `celldata.go` | 1 |
145+
| `src/common/parser/EscapeSequenceParser.ts` | `parser.go` | 2 |
146+
| `src/common/parser/Params.ts` | `parser_params.go` | 2 |
147+
| `src/common/parser/OscParser.ts` | `parser_osc.go` | 2 |
148+
| `src/common/parser/DcsParser.ts` | `parser_dcs.go` | 2 |
149+
| `src/common/parser/ApcParser.ts` | `parser_apc.go` | 2 |
150+
| `src/common/buffer/BufferLine.ts` | `bufferline.go` | 3 |
151+
| `src/common/buffer/Buffer.ts` | `buffer.go` | 3 |
152+
| `src/common/buffer/BufferSet.ts` | `bufferset.go` | 3 |
153+
| `src/common/buffer/Marker.ts` | `marker.go` | 3 |
154+
| `src/common/buffer/BufferReflow.ts` | `bufferreflow.go` | 3 |
155+
| `src/common/services/OptionsService.ts` | `options.go` | 4 |
156+
| `src/common/services/BufferService.ts` | `bufferservice.go` | 4 |
157+
| `src/common/services/CoreService.ts` | `coreservice.go` | 4 |
158+
| `src/common/services/CharsetService.ts` | `charset.go` | 4 |
159+
| `src/common/services/UnicodeService.ts` | `unicode.go` | 4 |
160+
| `src/common/services/MouseStateService.ts` | `mousestate.go` | 4 |
161+
| `src/common/services/OscLinkService.ts` | `osclink.go` | 4 |
162+
| `src/common/data/Charsets.ts` | `charset.go` | 4 |
163+
| `src/common/InputHandler.ts` | `inputhandler.go` + `inputhandler_*.go` | 5 |
164+
| `src/common/input/WriteBuffer.ts` | `writebuffer.go` | 5 |
165+
| `src/common/input/TextDecoder.ts` | `textdecoder.go` | 5 |
166+
| `src/headless/Terminal.ts` | `terminal.go` | 6 |
167+
| `src/common/CoreTerminal.ts` | `terminal.go` | 6 |
168+
169+
## Subagent Instructions
170+
171+
Each subagent works on one phase. The subagent should:
172+
173+
1. Clone the xterm.js repo (or read source files via GitHub raw URLs)
174+
2. Read the relevant TypeScript source files listed in the phase's Linear issue
175+
3. Create the Go files at the repository root
176+
4. Write tests for each file
177+
5. Run `go test ./...` to verify
178+
6. Run `gofmt` on all files
179+
7. Commit and push to a feature branch
180+
8. Create a PR
181+
182+
### Reading xterm.js source
183+
Use raw GitHub URLs:
184+
```
185+
https://raw.githubusercontent.com/xtermjs/xterm.js/master/src/common/<path>
186+
```
187+
188+
### Key xterm.js files to read for context (all phases)
189+
- `src/common/Types.ts` — all interfaces
190+
- `src/common/buffer/Types.ts` — buffer interfaces
191+
- `src/common/parser/Types.ts` — parser interfaces
192+
- `src/common/buffer/Constants.ts` — bit layout constants
193+
- `src/common/parser/Constants.ts` — parser state/action enums
194+
195+
## Phase Dependencies
196+
197+
```
198+
Phase 1 (types/constants) ──┬──→ Phase 2 (parser)
199+
├──→ Phase 3 (buffer)
200+
└──→ Phase 4 (services) ──→ Phase 5 (input handler) ──→ Phase 6 (terminal)
201+
↑ ↑
202+
Phase 3 ───────────────────────┘
203+
Phase 2 ───────────────────────┘
204+
```
205+
206+
Phases 2 and 3 can run in parallel after Phase 1.
207+
Phase 4 depends on Phase 1 and Phase 3.
208+
Phase 5 depends on Phases 2, 3, and 4.
209+
Phase 6 depends on all previous phases.

README.md

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,110 @@
11
# xterm-go
2-
A port of xterm.js to Go
2+
3+
A pure-Go headless terminal emulator ported from [xterm.js](https://github.com/xtermjs/xterm.js).
4+
5+
It processes VT/ANSI escape sequences and maintains terminal buffer state without requiring a browser, DOM, or any rendering. This enables server-side terminal state tracking, screen content extraction, and headless terminal testing.
6+
7+
The implementation follows the VT500 specification and is a direct port of the headless subset of xterm.js (MIT license).
8+
9+
## Install
10+
11+
```
12+
go get github.com/gitpod-io/xterm-go
13+
```
14+
15+
## Usage
16+
17+
```go
18+
package main
19+
20+
import (
21+
"fmt"
22+
23+
xterm "github.com/gitpod-io/xterm-go"
24+
)
25+
26+
func main() {
27+
term := xterm.New(xterm.WithCols(80), xterm.WithRows(24))
28+
defer term.Dispose()
29+
30+
term.WriteString("Hello, world!\r\n")
31+
term.WriteString("\x1b[1;31mRed bold text\x1b[0m\r\n")
32+
33+
fmt.Println(term.String())
34+
fmt.Printf("Cursor: (%d, %d)\n", term.CursorX(), term.CursorY())
35+
}
36+
```
37+
38+
## Features
39+
40+
- Full VT500 escape sequence parsing (CSI, OSC, DCS, APC)
41+
- Normal and alternate screen buffers with scrollback
42+
- Text attributes: bold, italic, underline, strikethrough, blink, inverse, dim, overline
43+
- 16-color, 256-color, and 24-bit RGB color support
44+
- Terminal resize with reflow
45+
- Serialize addon for terminal state snapshots
46+
- Conformance tests against xterm.js golden data
47+
48+
## API
49+
50+
### Terminal
51+
52+
```go
53+
// Create a terminal with options.
54+
term := xterm.New(
55+
xterm.WithCols(80),
56+
xterm.WithRows(24),
57+
xterm.WithScrollback(1000),
58+
)
59+
60+
// Write data (implements io.Writer).
61+
term.Write([]byte("\x1b[2J"))
62+
term.WriteString("text")
63+
64+
// Read state.
65+
term.Cols() // terminal width
66+
term.Rows() // terminal height
67+
term.CursorX() // cursor column
68+
term.CursorY() // cursor row
69+
term.GetLine(y) // text content of line y
70+
term.String() // full screen content
71+
term.Buffer() // active buffer
72+
term.NormalBuffer() // normal screen buffer
73+
term.AltBuffer() // alternate screen buffer
74+
term.IsAltBufferActive() // whether alt buffer is active
75+
76+
// Resize.
77+
term.Resize(cols, rows)
78+
79+
// Events.
80+
term.OnData(func(s string) { }) // terminal response data (DA, DSR)
81+
term.OnBell(func() { }) // BEL character
82+
term.OnTitleChange(func(s string) { }) // OSC title change
83+
term.OnLineFeed(func() { }) // line feed
84+
85+
// Cleanup.
86+
term.Dispose()
87+
```
88+
89+
### SerializeAddon
90+
91+
Produces compact escape-sequence snapshots of terminal state for reconnection:
92+
93+
```go
94+
addon := xterm.NewSerializeAddon(term)
95+
snapshot := addon.Serialize(nil) // []byte of escape sequences
96+
```
97+
98+
## Conformance
99+
100+
Cross-implementation tests verify the Go port produces identical behavior to xterm.js. See [`conformance/README.md`](conformance/README.md).
101+
102+
```bash
103+
go test -run TestConformance -v
104+
```
105+
106+
## License
107+
108+
MIT — see [LICENSE](LICENSE).
109+
110+
The original xterm.js is also MIT licensed: https://github.com/xtermjs/xterm.js/blob/master/LICENSE

_example/go.mod

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module github.com/gitpod-io/xterm-go/_example
2+
3+
go 1.25.7
4+
5+
replace github.com/gitpod-io/xterm-go => ../
6+
7+
require (
8+
github.com/creack/pty v1.1.24
9+
github.com/gitpod-io/xterm-go v0.0.0-00010101000000-000000000000
10+
github.com/google/uuid v1.6.0
11+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
12+
)
13+
14+
require golang.org/x/net v0.52.0 // indirect

_example/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
3+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
8+
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
9+
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
10+
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=

0 commit comments

Comments
 (0)