diff --git a/Gopkg.lock b/Gopkg.lock
deleted file mode 100644
index c17e0c6..0000000
--- a/Gopkg.lock
+++ /dev/null
@@ -1,96 +0,0 @@
-# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
-
-
-[[projects]]
-  branch = "master"
-  name = "github.com/Netflix/go-expect"
-  packages = ["."]
-  revision = "c93bf25de8e869da25cf26bcd2932b36141f61ae"
-
-[[projects]]
-  name = "github.com/davecgh/go-spew"
-  packages = ["spew"]
-  revision = "346938d642f2ec3594ed81d874461961cd0faa76"
-  version = "v1.1.0"
-
-[[projects]]
-  branch = "master"
-  name = "github.com/gdamore/encoding"
-  packages = ["."]
-  revision = "b23993cbb6353f0e6aa98d0ee318a34728f628b9"
-
-[[projects]]
-  branch = "master"
-  name = "github.com/gdamore/tcell"
-  packages = [
-    ".",
-    "terminfo"
-  ]
-  revision = "b3cebc399d6f98536af845ed8a5144ab586f6759"
-
-[[projects]]
-  name = "github.com/kr/pty"
-  packages = ["."]
-  revision = "282ce0e5322c82529687d609ee670fac7c7d917c"
-  version = "v1.1.1"
-
-[[projects]]
-  name = "github.com/lucasb-eyer/go-colorful"
-  packages = ["."]
-  revision = "345fbb3dbcdb252d9985ee899a84963c0fa24c82"
-  version = "v1.0"
-
-[[projects]]
-  name = "github.com/mattn/go-runewidth"
-  packages = ["."]
-  revision = "9e777a8366cce605130a531d2cd6363d07ad7317"
-  version = "v0.0.2"
-
-[[projects]]
-  name = "github.com/pmezard/go-difflib"
-  packages = ["difflib"]
-  revision = "792786c7400a136282c1664665ae0a8db921c6c2"
-  version = "v1.0.0"
-
-[[projects]]
-  name = "github.com/stretchr/testify"
-  packages = [
-    "assert",
-    "require"
-  ]
-  revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71"
-  version = "v1.2.1"
-
-[[projects]]
-  branch = "master"
-  name = "golang.org/x/crypto"
-  packages = ["ssh/terminal"]
-  revision = "8ac0e0d97ce45cd83d1d7243c060cb8461dda5e9"
-
-[[projects]]
-  branch = "master"
-  name = "golang.org/x/sys"
-  packages = [
-    "unix",
-    "windows"
-  ]
-  revision = "9527bec2660bd847c050fda93a0f0c6dee0800bb"
-
-[[projects]]
-  name = "golang.org/x/text"
-  packages = [
-    "encoding",
-    "encoding/internal/identifier",
-    "internal/gen",
-    "transform",
-    "unicode/cldr"
-  ]
-  revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0"
-  version = "v0.3.0"
-
-[solve-meta]
-  analyzer-name = "dep"
-  analyzer-version = 1
-  inputs-digest = "bd24f29ad753e2f861768c734d71fbbc4e0380b5d7b43b6b101c01274cabcedb"
-  solver-name = "gps-cdcl"
-  solver-version = 1
diff --git a/Gopkg.toml b/Gopkg.toml
deleted file mode 100644
index f63b1c2..0000000
--- a/Gopkg.toml
+++ /dev/null
@@ -1,38 +0,0 @@
-# Gopkg.toml example
-#
-# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
-# for detailed Gopkg.toml documentation.
-#
-# required = ["github.com/user/thing/cmd/thing"]
-# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
-#
-# [[constraint]]
-#   name = "github.com/user/project"
-#   version = "1.0.0"
-#
-# [[constraint]]
-#   name = "github.com/user/project2"
-#   branch = "dev"
-#   source = "github.com/myfork/project2"
-#
-# [[override]]
-#   name = "github.com/x/y"
-#   version = "2.4.0"
-#
-# [prune]
-#   non-go = false
-#   go-tests = true
-#   unused-packages = true
-
-
-[[constraint]]
-  name = "github.com/kr/pty"
-  version = "1.1.1"
-
-[[constraint]]
-  name = "github.com/stretchr/testify"
-  version = "1.2.1"
-
-[prune]
-  go-tests = true
-  unused-packages = true
diff --git a/cmd/dump/main.go b/cmd/dump/main.go
deleted file mode 100644
index 1f9d415..0000000
--- a/cmd/dump/main.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"time"
-
-	"github.com/hinshun/vt10x"
-	"github.com/kr/pty"
-)
-
-func main() {
-	err := run()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v\n", err)
-		os.Exit(1)
-	}
-}
-
-func run() error {
-	ptm, pts, err := pty.Open()
-	if err != nil {
-		return err
-	}
-	defer pts.Close()
-	defer ptm.Close()
-
-	c := exec.Command(os.Getenv("SHELL"))
-	c.Stdout = pts
-	c.Stdin = pts
-	c.Stderr = pts
-
-	var state vt10x.State
-	term, err := vt10x.Create(&state, ptm)
-	if err != nil {
-		return err
-	}
-	defer term.Close()
-
-	rows, cols := state.Size()
-	vt10x.ResizePty(ptm, cols, rows)
-
-	go func() {
-		for {
-			err := term.Parse()
-			if err != nil {
-				fmt.Fprintln(os.Stderr, err)
-				break
-			}
-		}
-	}()
-
-	err = c.Start()
-	if err != nil {
-		return err
-	}
-
-	time.Sleep(time.Second)
-	fmt.Println(state.String())
-	return nil
-}
diff --git a/cmd/goterm/main.go b/cmd/goterm/main.go
deleted file mode 100644
index 2311bfe..0000000
--- a/cmd/goterm/main.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
-
-	"github.com/gdamore/tcell"
-	"github.com/hinshun/vt10x"
-	"github.com/kr/pty"
-)
-
-func main() {
-	err := goterm()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "%v\n", err)
-		os.Exit(1)
-	}
-}
-
-func goterm() error {
-	cmd := exec.Command(os.Getenv("SHELL"), "-i")
-	ptm, err := pty.Start(cmd)
-	if err != nil {
-		return err
-	}
-
-	// f, err := os.OpenFile("debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
-	// if err != nil {
-	// 	return err
-	// }
-	// state := vt10x.State{
-	// 	DebugLogger: log.New(f, "", log.LstdFlags),
-	// }
-	var state vt10x.State
-	term, err := vt10x.Create(&state, ptm)
-	if err != nil {
-		return err
-	}
-	defer term.Close()
-
-	s, err := tcell.NewScreen()
-	if err != nil {
-		return err
-	}
-	defer s.Fini()
-
-	err = s.Init()
-	if err != nil {
-		return err
-	}
-
-	width, height := s.Size()
-	vt10x.ResizePty(ptm, width, height)
-	term.Resize(width, height)
-
-	endc := make(chan bool)
-	updatec := make(chan struct{}, 1)
-	go func() {
-		defer close(endc)
-		for {
-			err := term.Parse()
-			if err != nil {
-				fmt.Fprintln(os.Stderr, err)
-				break
-			}
-			select {
-			case updatec <- struct{}{}:
-			default:
-			}
-		}
-	}()
-
-	go func() {
-		io.Copy(ptm, os.Stdin)
-	}()
-
-	eventc := make(chan tcell.Event, 4)
-	go func() {
-		for {
-			eventc <- s.PollEvent()
-		}
-	}()
-
-	for {
-		select {
-		case event := <-eventc:
-			switch ev := event.(type) {
-			case *tcell.EventResize:
-				width, height = ev.Size()
-				vt10x.ResizePty(ptm, width, height)
-				term.Resize(width, height)
-				s.Sync()
-			}
-		case <-endc:
-			return nil
-		case <-updatec:
-			update(s, &state, width, height)
-		}
-	}
-}
-
-func update(s tcell.Screen, state *vt10x.State, w, h int) {
-	state.Lock()
-	defer state.Unlock()
-	for y := 0; y < h; y++ {
-		for x := 0; x < w; x++ {
-			c, fg, bg := state.Cell(x, y)
-
-			style := tcell.StyleDefault
-			if fg != vt10x.DefaultFG {
-				style = style.Foreground(tcell.Color(fg))
-			}
-			if bg != vt10x.DefaultBG {
-				style = style.Background(tcell.Color(bg))
-			}
-
-			s.SetContent(x, y, c, nil, style)
-		}
-	}
-	if state.CursorVisible() {
-		curx, cury := state.Cursor()
-		s.ShowCursor(curx, cury)
-	} else {
-		s.HideCursor()
-	}
-	s.Show()
-}
diff --git a/color.go b/color.go
index 4ce0d8f..c16d3ac 100644
--- a/color.go
+++ b/color.go
@@ -24,12 +24,13 @@ const (
 // For example, a transparent background. Otherwise, the simple case is to
 // map default colors to another color.
 const (
-	DefaultFG Color = 0xff80 + iota
+	DefaultFG Color = 1<<24 + iota
 	DefaultBG
+	DefaultCursor
 )
 
 // Color maps to the ANSI colors [0, 16) and the xterm colors [16, 256).
-type Color uint16
+type Color uint32
 
 // ANSI returns true if Color is within [0, 16).
 func (c Color) ANSI() bool {
diff --git a/csi.go b/csi.go
index 138cff9..f5df174 100644
--- a/csi.go
+++ b/csi.go
@@ -74,26 +74,26 @@ func (t *State) handleCSI() {
 	case '@': // ICH - insert <n> blank char
 		t.insertBlanks(c.arg(0, 1))
 	case 'A': // CUU - cursor <n> up
-		t.moveTo(t.cur.x, t.cur.y-c.maxarg(0, 1))
+		t.moveTo(t.cur.X, t.cur.Y-c.maxarg(0, 1))
 	case 'B', 'e': // CUD, VPR - cursor <n> down
-		t.moveTo(t.cur.x, t.cur.y+c.maxarg(0, 1))
+		t.moveTo(t.cur.X, t.cur.Y+c.maxarg(0, 1))
 	case 'c': // DA - device attributes
 		if c.arg(0, 0) == 0 {
 			// TODO: write vt102 id
 		}
 	case 'C', 'a': // CUF, HPR - cursor <n> forward
-		t.moveTo(t.cur.x+c.maxarg(0, 1), t.cur.y)
+		t.moveTo(t.cur.X+c.maxarg(0, 1), t.cur.Y)
 	case 'D': // CUB - cursor <n> backward
-		t.moveTo(t.cur.x-c.maxarg(0, 1), t.cur.y)
+		t.moveTo(t.cur.X-c.maxarg(0, 1), t.cur.Y)
 	case 'E': // CNL - cursor <n> down and first col
-		t.moveTo(0, t.cur.y+c.arg(0, 1))
+		t.moveTo(0, t.cur.Y+c.arg(0, 1))
 	case 'F': // CPL - cursor <n> up and first col
-		t.moveTo(0, t.cur.y-c.arg(0, 1))
+		t.moveTo(0, t.cur.Y-c.arg(0, 1))
 	case 'g': // TBC - tabulation clear
 		switch c.arg(0, 0) {
 		// clear current tab stop
 		case 0:
-			t.tabs[t.cur.x] = false
+			t.tabs[t.cur.X] = false
 		// clear all tabs
 		case 3:
 			for i := range t.tabs {
@@ -103,7 +103,7 @@ func (t *State) handleCSI() {
 			goto unknown
 		}
 	case 'G', '`': // CHA, HPA - Move to <col>
-		t.moveTo(c.arg(0, 1)-1, t.cur.y)
+		t.moveTo(c.arg(0, 1)-1, t.cur.Y)
 	case 'H', 'f': // CUP, HVP - move to <row> <col>
 		t.moveAbsTo(c.arg(1, 1)-1, c.arg(0, 1)-1)
 	case 'I': // CHT - cursor forward tabulation <n> tab stops
@@ -115,15 +115,15 @@ func (t *State) handleCSI() {
 		// TODO: sel.ob.x = -1
 		switch c.arg(0, 0) {
 		case 0: // below
-			t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y)
-			if t.cur.y < t.rows-1 {
-				t.clear(0, t.cur.y+1, t.cols-1, t.rows-1)
+			t.clear(t.cur.X, t.cur.Y, t.cols-1, t.cur.Y)
+			if t.cur.Y < t.rows-1 {
+				t.clear(0, t.cur.Y+1, t.cols-1, t.rows-1)
 			}
 		case 1: // above
-			if t.cur.y > 1 {
-				t.clear(0, 0, t.cols-1, t.cur.y-1)
+			if t.cur.Y > 1 {
+				t.clear(0, 0, t.cols-1, t.cur.Y-1)
 			}
-			t.clear(0, t.cur.y, t.cur.x, t.cur.y)
+			t.clear(0, t.cur.Y, t.cur.X, t.cur.Y)
 		case 2: // all
 			t.clear(0, 0, t.cols-1, t.rows-1)
 		default:
@@ -132,11 +132,11 @@ func (t *State) handleCSI() {
 	case 'K': // EL - clear line
 		switch c.arg(0, 0) {
 		case 0: // right
-			t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y)
+			t.clear(t.cur.X, t.cur.Y, t.cols-1, t.cur.Y)
 		case 1: // left
-			t.clear(0, t.cur.y, t.cur.x, t.cur.y)
+			t.clear(0, t.cur.Y, t.cur.X, t.cur.Y)
 		case 2: // all
-			t.clear(0, t.cur.y, t.cols-1, t.cur.y)
+			t.clear(0, t.cur.Y, t.cols-1, t.cur.Y)
 		}
 	case 'S': // SU - scroll <n> lines up
 		t.scrollUp(t.top, c.arg(0, 1))
@@ -149,7 +149,7 @@ func (t *State) handleCSI() {
 	case 'M': // DL - delete <n> lines
 		t.deleteLines(c.arg(0, 1))
 	case 'X': // ECH - erase <n> chars
-		t.clear(t.cur.x, t.cur.y, t.cur.x+c.arg(0, 1)-1, t.cur.y)
+		t.clear(t.cur.X, t.cur.Y, t.cur.X+c.arg(0, 1)-1, t.cur.Y)
 	case 'P': // DCH - delete <n> chars
 		t.deleteChars(c.arg(0, 1))
 	case 'Z': // CBT - cursor backward tabulation <n> tab stops
@@ -158,7 +158,7 @@ func (t *State) handleCSI() {
 			t.putTab(false)
 		}
 	case 'd': // VPA - move to <row>
-		t.moveAbsTo(t.cur.x, c.arg(0, 1)-1)
+		t.moveAbsTo(t.cur.X, c.arg(0, 1)-1)
 	case 'h': // SM - set terminal mode
 		t.setMode(c.priv, true, c.args)
 	case 'm': // SGR - terminal attribute (color)
@@ -168,7 +168,7 @@ func (t *State) handleCSI() {
 		case 5: // DSR - device status report
 			t.w.Write([]byte("\033[0n"))
 		case 6: // CPR - cursor position report
-			t.w.Write([]byte(fmt.Sprintf("\033[%d;%dR", t.cur.y+1, t.cur.x+1)))
+			t.w.Write([]byte(fmt.Sprintf("\033[%d;%dR", t.cur.Y+1, t.cur.X+1)))
 		}
 	case 'r': // DECSTBM - set scrolling region
 		if c.priv {
diff --git a/debian/changelog b/debian/changelog
index c732932..93e561e 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+golang-github-hinshun-vt10x (0.0~git20220301.5011da4-1) UNRELEASED; urgency=low
+
+  * New upstream snapshot.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 14 Mar 2022 17:47:39 -0000
+
 golang-github-hinshun-vt10x (0.0~git20180809.d55458d+ds1-2) unstable; urgency=medium
 
   * Source-only upload for testing eligibility.
diff --git a/expect.go b/expect.go
deleted file mode 100644
index 1c1c646..0000000
--- a/expect.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package vt10x
-
-import (
-	expect "github.com/Netflix/go-expect"
-	"github.com/kr/pty"
-)
-
-// NewVT10XConsole returns a new expect.Console that multiplexes the
-// Stdin/Stdout to a VT10X terminal, allowing Console to interact with an
-// application sending ANSI escape sequences.
-func NewVT10XConsole(opts ...expect.ConsoleOpt) (*expect.Console, *State, error) {
-	ptm, pts, err := pty.Open()
-	if err != nil {
-		return nil, nil, err
-	}
-
-	var state State
-	term, err := Create(&state, pts)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	c, err := expect.NewConsole(append(opts, expect.WithStdin(ptm), expect.WithStdout(term), expect.WithCloser(pts, ptm, term))...)
-	if err != nil {
-		return nil, nil, err
-	}
-
-	return c, &state, nil
-}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..11095c6
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module github.com/hinshun/vt10x
+
+go 1.14
diff --git a/parse.go b/parse.go
index 51a1980..0b84145 100644
--- a/parse.go
+++ b/parse.go
@@ -7,27 +7,27 @@ func isControlCode(c rune) bool {
 func (t *State) parse(c rune) {
 	t.logf("%q", string(c))
 	if isControlCode(c) {
-		if t.handleControlCodes(c) || t.cur.attr.mode&attrGfx == 0 {
+		if t.handleControlCodes(c) || t.cur.Attr.Mode&attrGfx == 0 {
 			return
 		}
 	}
 	// TODO: update selection; see st.c:2450
 
-	if t.mode&ModeWrap != 0 && t.cur.state&cursorWrapNext != 0 {
-		t.lines[t.cur.y][t.cur.x].mode |= attrWrap
+	if t.mode&ModeWrap != 0 && t.cur.State&cursorWrapNext != 0 {
+		t.lines[t.cur.Y][t.cur.X].Mode |= attrWrap
 		t.newline(true)
 	}
 
-	if t.mode&ModeInsert != 0 && t.cur.x+1 < t.cols {
+	if t.mode&ModeInsert != 0 && t.cur.X+1 < t.cols {
 		// TODO: move shiz, look at st.c:2458
 		t.logln("insert mode not implemented")
 	}
 
-	t.setChar(c, &t.cur.attr, t.cur.x, t.cur.y)
-	if t.cur.x+1 < t.cols {
-		t.moveTo(t.cur.x+1, t.cur.y)
+	t.setChar(c, &t.cur.Attr, t.cur.X, t.cur.Y)
+	if t.cur.X+1 < t.cols {
+		t.moveTo(t.cur.X+1, t.cur.Y)
 	} else {
-		t.cur.state |= cursorWrapNext
+		t.cur.State |= cursorWrapNext
 	}
 }
 
@@ -56,20 +56,20 @@ func (t *State) parseEsc(c rune) {
 		'*', // set tertiary charset G2 (ignored)
 		'+': // set quaternary charset G3 (ignored)
 	case 'D': // IND - linefeed
-		if t.cur.y == t.bottom {
+		if t.cur.Y == t.bottom {
 			t.scrollUp(t.top, 1)
 		} else {
-			t.moveTo(t.cur.x, t.cur.y+1)
+			t.moveTo(t.cur.X, t.cur.Y+1)
 		}
 	case 'E': // NEL - next line
 		t.newline(true)
 	case 'H': // HTS - horizontal tab stop
-		t.tabs[t.cur.x] = true
+		t.tabs[t.cur.X] = true
 	case 'M': // RI - reverse index
-		if t.cur.y == t.top {
+		if t.cur.Y == t.top {
 			t.scrollDown(t.top, 1)
 		} else {
-			t.moveTo(t.cur.x, t.cur.y-1)
+			t.moveTo(t.cur.X, t.cur.Y-1)
 		}
 	case 'Z': // DECID - identify terminal
 		// TODO: write to our writer our id
@@ -132,9 +132,9 @@ func (t *State) parseEscAltCharset(c rune) {
 	t.logf("%q", string(c))
 	switch c {
 	case '0': // line drawing set
-		t.cur.attr.mode |= attrGfx
+		t.cur.Attr.Mode |= attrGfx
 	case 'B': // USASCII
-		t.cur.attr.mode &^= attrGfx
+		t.cur.Attr.Mode &^= attrGfx
 	case 'A', // UK (ignored)
 		'<', // multinational (ignored)
 		'5', // Finnish (ignored)
@@ -154,7 +154,7 @@ func (t *State) parseEscTest(c rune) {
 	if c == '8' {
 		for y := 0; y < t.rows; y++ {
 			for x := 0; x < t.cols; x++ {
-				t.setChar('E', &t.cur.attr, x, y)
+				t.setChar('E', &t.cur.Attr, x, y)
 			}
 		}
 	}
@@ -171,10 +171,10 @@ func (t *State) handleControlCodes(c rune) bool {
 		t.putTab(true)
 	// BS
 	case '\b':
-		t.moveTo(t.cur.x-1, t.cur.y)
+		t.moveTo(t.cur.X-1, t.cur.Y)
 	// CR
 	case '\r':
-		t.moveTo(0, t.cur.y)
+		t.moveTo(0, t.cur.Y)
 	// LF, VT, LF
 	case '\f', '\v', '\n':
 		// go to first col if mode is set
diff --git a/state.go b/state.go
index 9e8dd3e..b2b4078 100644
--- a/state.go
+++ b/state.go
@@ -62,18 +62,18 @@ const (
 	ChangedTitle
 )
 
-type glyph struct {
-	c      rune
-	mode   int16
-	fg, bg Color
+type Glyph struct {
+	Char   rune
+	Mode   int16
+	FG, BG Color
 }
 
-type line []glyph
+type line []Glyph
 
-type cursor struct {
-	attr  glyph
-	x, y  int
-	state uint8
+type Cursor struct {
+	Attr  Glyph
+	X, Y  int
+	State uint8
 }
 
 type parseState func(c rune)
@@ -91,7 +91,7 @@ type State struct {
 	altLines      []line
 	dirty         []bool // line dirtiness
 	anydirty      bool
-	cur, curSaved cursor
+	cur, curSaved Cursor
 	top, bottom   int // scroll limits
 	mode          ModeFlag
 	state         parseState
@@ -100,6 +100,14 @@ type State struct {
 	numlock       bool
 	tabs          []bool
 	title         string
+	colorOverride map[Color]Color
+}
+
+func newState(w io.Writer) *State {
+	return &State{
+		w:             w,
+		colorOverride: make(map[Color]Color),
+	}
 }
 
 func (t *State) logf(format string, args ...interface{}) {
@@ -133,15 +141,24 @@ func (t *State) Unlock() {
 	t.mu.Unlock()
 }
 
-// Cell returns the character code, foreground color, and background
-// color at position (x, y) relative to the top left of the terminal.
-func (t *State) Cell(x, y int) (ch rune, fg Color, bg Color) {
-	return t.lines[y][x].c, Color(t.lines[y][x].fg), Color(t.lines[y][x].bg)
+// Cell returns the glyph containing the character code, foreground color, and
+// background color at position (x, y) relative to the top left of the terminal.
+func (t *State) Cell(x, y int) Glyph {
+	cell := t.lines[y][x]
+	fg, ok := t.colorOverride[cell.FG]
+	if ok {
+		cell.FG = fg
+	}
+	bg, ok := t.colorOverride[cell.BG]
+	if ok {
+		cell.BG = bg
+	}
+	return cell
 }
 
 // Cursor returns the current position of the cursor.
-func (t *State) Cursor() (int, int) {
-	return t.cur.x, t.cur.y
+func (t *State) Cursor() Cursor {
+	return t.cur
 }
 
 // CursorVisible returns the visible state of the cursor.
@@ -149,9 +166,9 @@ func (t *State) CursorVisible() bool {
 	return t.mode&ModeHide == 0
 }
 
-// Mode tests if mode is currently set.
-func (t *State) Mode(mode ModeFlag) bool {
-	return t.mode&mode != 0
+// Mode returns the current terminal mode.
+func (t *State) Mode() ModeFlag {
+	return t.mode
 }
 
 // Title returns the current title set via the tty.
@@ -186,7 +203,7 @@ func (t *State) saveCursor() {
 
 func (t *State) restoreCursor() {
 	t.cur = t.curSaved
-	t.moveTo(t.cur.x, t.cur.y)
+	t.moveTo(t.cur.X, t.cur.Y)
 }
 
 func (t *State) put(c rune) {
@@ -194,7 +211,7 @@ func (t *State) put(c rune) {
 }
 
 func (t *State) putTab(forward bool) {
-	x := t.cur.x
+	x := t.cur.X
 	if forward {
 		if x == t.cols {
 			return
@@ -208,11 +225,11 @@ func (t *State) putTab(forward bool) {
 		for x--; x > 0 && !t.tabs[x]; x-- {
 		}
 	}
-	t.moveTo(x, t.cur.y)
+	t.moveTo(x, t.cur.Y)
 }
 
 func (t *State) newline(firstCol bool) {
-	y := t.cur.y
+	y := t.cur.Y
 	if y == t.bottom {
 		cur := t.cur
 		t.cur = t.defaultCursor()
@@ -224,7 +241,7 @@ func (t *State) newline(firstCol bool) {
 	if firstCol {
 		t.moveTo(0, y)
 	} else {
-		t.moveTo(t.cur.x, y)
+		t.moveTo(t.cur.X, y)
 	}
 }
 
@@ -240,8 +257,8 @@ var gfxCharTable = [62]rune{
 	'│', '≤', '≥', 'π', '≠', '£', '·', // x - ~
 }
 
-func (t *State) setChar(c rune, attr *glyph, x, y int) {
-	if attr.mode&attrGfx != 0 {
+func (t *State) setChar(c rune, attr *Glyph, x, y int) {
+	if attr.Mode&attrGfx != 0 {
 		if c >= 0x41 && c <= 0x7e && gfxCharTable[c-0x41] != 0 {
 			c = gfxCharTable[c-0x41]
 		}
@@ -249,21 +266,21 @@ func (t *State) setChar(c rune, attr *glyph, x, y int) {
 	t.changed |= ChangedScreen
 	t.dirty[y] = true
 	t.lines[y][x] = *attr
-	t.lines[y][x].c = c
-	//if t.options.BrightBold && attr.mode&attrBold != 0 && attr.fg < 8 {
-	if attr.mode&attrBold != 0 && attr.fg < 8 {
-		t.lines[y][x].fg = attr.fg + 8
+	t.lines[y][x].Char = c
+	//if t.options.BrightBold && attr.Mode&attrBold != 0 && attr.FG < 8 {
+	if attr.Mode&attrBold != 0 && attr.FG < 8 {
+		t.lines[y][x].FG = attr.FG + 8
 	}
-	if attr.mode&attrReverse != 0 {
-		t.lines[y][x].fg = attr.bg
-		t.lines[y][x].bg = attr.fg
+	if attr.Mode&attrReverse != 0 {
+		t.lines[y][x].FG = attr.BG
+		t.lines[y][x].BG = attr.FG
 	}
 }
 
-func (t *State) defaultCursor() cursor {
-	c := cursor{}
-	c.attr.fg = DefaultFG
-	c.attr.bg = DefaultBG
+func (t *State) defaultCursor() Cursor {
+	c := Cursor{}
+	c.Attr.FG = DefaultFG
+	c.Attr.BG = DefaultBG
 	return c
 }
 
@@ -291,7 +308,7 @@ func (t *State) resize(cols, rows int) bool {
 	if cols < 1 || rows < 1 {
 		return false
 	}
-	slide := t.cur.y - rows + 1
+	slide := t.cur.Y - rows + 1
 	if slide > 0 {
 		copy(t.lines, t.lines[slide:slide+rows])
 		copy(t.altLines, t.altLines[slide:slide+rows])
@@ -329,7 +346,7 @@ func (t *State) resize(cols, rows int) bool {
 	t.cols = cols
 	t.rows = rows
 	t.setScroll(0, rows-1)
-	t.moveTo(t.cur.x, t.cur.y)
+	t.moveTo(t.cur.X, t.cur.Y)
 	for i := 0; i < 2; i++ {
 		if mincols < cols && minrows > 0 {
 			t.clear(mincols, 0, cols-1, minrows-1)
@@ -357,8 +374,8 @@ func (t *State) clear(x0, y0, x1, y1 int) {
 	for y := y0; y <= y1; y++ {
 		t.dirty[y] = true
 		for x := x0; x <= x1; x++ {
-			t.lines[y][x] = t.cur.attr
-			t.lines[y][x].c = ' '
+			t.lines[y][x] = t.cur.Attr
+			t.lines[y][x].Char = ' '
 		}
 	}
 }
@@ -368,7 +385,7 @@ func (t *State) clearAll() {
 }
 
 func (t *State) moveAbsTo(x, y int) {
-	if t.cur.state&cursorOrigin != 0 {
+	if t.cur.State&cursorOrigin != 0 {
 		y += t.top
 	}
 	t.moveTo(x, y)
@@ -376,7 +393,7 @@ func (t *State) moveAbsTo(x, y int) {
 
 func (t *State) moveTo(x, y int) {
 	var miny, maxy int
-	if t.cur.state&cursorOrigin != 0 {
+	if t.cur.State&cursorOrigin != 0 {
 		miny = t.top
 		maxy = t.bottom
 	} else {
@@ -386,9 +403,9 @@ func (t *State) moveTo(x, y int) {
 	x = clamp(x, 0, t.cols-1)
 	y = clamp(y, miny, maxy)
 	t.changed |= ChangedScreen
-	t.cur.state &^= cursorWrapNext
-	t.cur.x = x
-	t.cur.y = y
+	t.cur.State &^= cursorWrapNext
+	t.cur.X = x
+	t.cur.Y = y
 }
 
 func (t *State) swapScreen() {
@@ -492,9 +509,9 @@ func (t *State) setMode(priv bool, set bool, args []int) {
 				}
 			case 6: // DECOM - origin
 				if set {
-					t.cur.state |= cursorOrigin
+					t.cur.State |= cursorOrigin
 				} else {
-					t.cur.state &^= cursorOrigin
+					t.cur.State &^= cursorOrigin
 				}
 				t.moveAbsTo(0, 0)
 			case 7: // DECAWM - auto wrap
@@ -594,64 +611,80 @@ func (t *State) setAttr(attr []int) {
 		a := attr[i]
 		switch a {
 		case 0:
-			t.cur.attr.mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink
-			t.cur.attr.fg = DefaultFG
-			t.cur.attr.bg = DefaultBG
+			t.cur.Attr.Mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink
+			t.cur.Attr.FG = DefaultFG
+			t.cur.Attr.BG = DefaultBG
 		case 1:
-			t.cur.attr.mode |= attrBold
+			t.cur.Attr.Mode |= attrBold
 		case 3:
-			t.cur.attr.mode |= attrItalic
+			t.cur.Attr.Mode |= attrItalic
 		case 4:
-			t.cur.attr.mode |= attrUnderline
+			t.cur.Attr.Mode |= attrUnderline
 		case 5, 6: // slow, rapid blink
-			t.cur.attr.mode |= attrBlink
+			t.cur.Attr.Mode |= attrBlink
 		case 7:
-			t.cur.attr.mode |= attrReverse
+			t.cur.Attr.Mode |= attrReverse
 		case 21, 22:
-			t.cur.attr.mode &^= attrBold
+			t.cur.Attr.Mode &^= attrBold
 		case 23:
-			t.cur.attr.mode &^= attrItalic
+			t.cur.Attr.Mode &^= attrItalic
 		case 24:
-			t.cur.attr.mode &^= attrUnderline
+			t.cur.Attr.Mode &^= attrUnderline
 		case 25, 26:
-			t.cur.attr.mode &^= attrBlink
+			t.cur.Attr.Mode &^= attrBlink
 		case 27:
-			t.cur.attr.mode &^= attrReverse
+			t.cur.Attr.Mode &^= attrReverse
 		case 38:
 			if i+2 < len(attr) && attr[i+1] == 5 {
 				i += 2
 				if between(attr[i], 0, 255) {
-					t.cur.attr.fg = Color(attr[i])
+					t.cur.Attr.FG = Color(attr[i])
 				} else {
 					t.logf("bad fgcolor %d\n", attr[i])
 				}
+			} else if i+4 < len(attr) && attr[i+1] == 2 {
+				i += 4
+				r, g, b := attr[i-2], attr[i-1], attr[i]
+				if !between(r, 0, 255) || !between(g, 0, 255) || !between(b, 0, 255) {
+					t.logf("bad fg rgb color (%d,%d,%d)\n", r, g, b)
+				} else {
+					t.cur.Attr.FG = Color(r<<16 | g<<8 | b)
+				}
 			} else {
 				t.logf("gfx attr %d unknown\n", a)
 			}
 		case 39:
-			t.cur.attr.fg = DefaultFG
+			t.cur.Attr.FG = DefaultFG
 		case 48:
 			if i+2 < len(attr) && attr[i+1] == 5 {
 				i += 2
 				if between(attr[i], 0, 255) {
-					t.cur.attr.bg = Color(attr[i])
+					t.cur.Attr.BG = Color(attr[i])
 				} else {
 					t.logf("bad bgcolor %d\n", attr[i])
 				}
+			} else if i+4 < len(attr) && attr[i+1] == 2 {
+				i += 4
+				r, g, b := attr[i-2], attr[i-1], attr[i]
+				if !between(r, 0, 255) || !between(g, 0, 255) || !between(b, 0, 255) {
+					t.logf("bad bg rgb color (%d,%d,%d)\n", r, g, b)
+				} else {
+					t.cur.Attr.BG = Color(r<<16 | g<<8 | b)
+				}
 			} else {
 				t.logf("gfx attr %d unknown\n", a)
 			}
 		case 49:
-			t.cur.attr.bg = DefaultBG
+			t.cur.Attr.BG = DefaultBG
 		default:
 			if between(a, 30, 37) {
-				t.cur.attr.fg = Color(a - 30)
+				t.cur.Attr.FG = Color(a - 30)
 			} else if between(a, 40, 47) {
-				t.cur.attr.bg = Color(a - 40)
+				t.cur.Attr.BG = Color(a - 40)
 			} else if between(a, 90, 97) {
-				t.cur.attr.fg = Color(a - 90 + 8)
+				t.cur.Attr.FG = Color(a - 90 + 8)
 			} else if between(a, 100, 107) {
-				t.cur.attr.bg = Color(a - 100 + 8)
+				t.cur.Attr.BG = Color(a - 100 + 8)
 			} else {
 				t.logf("gfx attr %d unknown\n", a)
 			}
@@ -660,46 +693,46 @@ func (t *State) setAttr(attr []int) {
 }
 
 func (t *State) insertBlanks(n int) {
-	src := t.cur.x
+	src := t.cur.X
 	dst := src + n
 	size := t.cols - dst
 	t.changed |= ChangedScreen
-	t.dirty[t.cur.y] = true
+	t.dirty[t.cur.Y] = true
 
 	if dst >= t.cols {
-		t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y)
+		t.clear(t.cur.X, t.cur.Y, t.cols-1, t.cur.Y)
 	} else {
-		copy(t.lines[t.cur.y][dst:dst+size], t.lines[t.cur.y][src:src+size])
-		t.clear(src, t.cur.y, dst-1, t.cur.y)
+		copy(t.lines[t.cur.Y][dst:dst+size], t.lines[t.cur.Y][src:src+size])
+		t.clear(src, t.cur.Y, dst-1, t.cur.Y)
 	}
 }
 
 func (t *State) insertBlankLines(n int) {
-	if t.cur.y < t.top || t.cur.y > t.bottom {
+	if t.cur.Y < t.top || t.cur.Y > t.bottom {
 		return
 	}
-	t.scrollDown(t.cur.y, n)
+	t.scrollDown(t.cur.Y, n)
 }
 
 func (t *State) deleteLines(n int) {
-	if t.cur.y < t.top || t.cur.y > t.bottom {
+	if t.cur.Y < t.top || t.cur.Y > t.bottom {
 		return
 	}
-	t.scrollUp(t.cur.y, n)
+	t.scrollUp(t.cur.Y, n)
 }
 
 func (t *State) deleteChars(n int) {
-	src := t.cur.x + n
-	dst := t.cur.x
+	src := t.cur.X + n
+	dst := t.cur.X
 	size := t.cols - src
 	t.changed |= ChangedScreen
-	t.dirty[t.cur.y] = true
+	t.dirty[t.cur.Y] = true
 
 	if src >= t.cols {
-		t.clear(t.cur.x, t.cur.y, t.cols-1, t.cur.y)
+		t.clear(t.cur.X, t.cur.Y, t.cols-1, t.cur.Y)
 	} else {
-		copy(t.lines[t.cur.y][dst:dst+size], t.lines[t.cur.y][src:src+size])
-		t.clear(t.cols-n, t.cur.y, t.cols-1, t.cur.y)
+		copy(t.lines[t.cur.Y][dst:dst+size], t.lines[t.cur.Y][src:src+size])
+		t.clear(t.cols-n, t.cur.Y, t.cols-1, t.cur.Y)
 	}
 }
 
@@ -708,8 +741,8 @@ func (t *State) setTitle(title string) {
 	t.title = title
 }
 
-func (t *State) Size() (rows int, cols int) {
-	return t.rows, t.cols
+func (t *State) Size() (cols, rows int) {
+	return t.cols, t.rows
 }
 
 func (t *State) String() string {
@@ -719,8 +752,8 @@ func (t *State) String() string {
 	var view []rune
 	for y := 0; y < t.rows; y++ {
 		for x := 0; x < t.cols; x++ {
-			c, _, _ := t.Cell(x, y)
-			view = append(view, c)
+			attr := t.Cell(x, y)
+			view = append(view, attr.Char)
 		}
 		view = append(view, '\n')
 	}
diff --git a/str.go b/str.go
index c8ca50c..2a42b04 100644
--- a/str.go
+++ b/str.go
@@ -1,6 +1,9 @@
 package vt10x
 
 import (
+	"fmt"
+	"math"
+	"regexp"
 	"strconv"
 	"strings"
 )
@@ -59,20 +62,77 @@ func (t *State) handleSTR() {
 
 	switch s.typ {
 	case ']': // OSC - operating system command
+		var p *string
 		switch d := s.arg(0, 0); d {
 		case 0, 1, 2:
 			title := s.argString(1, "")
 			if title != "" {
 				t.setTitle(title)
 			}
+		case 10:
+			if len(s.args) < 2 {
+				break
+			}
+
+			c := s.argString(1, "")
+			p := &c
+			if p != nil && *p == "?" {
+				t.oscColorResponse(int(DefaultFG), 10)
+			} else if err := t.setColorName(int(DefaultFG), p); err != nil {
+				t.logf("invalid foreground color: %s\n", maybe(p))
+			} else {
+				// TODO: redraw
+			}
+		case 11:
+			if len(s.args) < 2 {
+				break
+			}
+
+			c := s.argString(1, "")
+			p := &c
+			if p != nil && *p == "?" {
+				t.oscColorResponse(int(DefaultBG), 11)
+			} else if err := t.setColorName(int(DefaultBG), p); err != nil {
+				t.logf("invalid cursor color: %s\n", maybe(p))
+			} else {
+				// TODO: redraw
+			}
+		// case 12:
+		// if len(s.args) < 2 {
+		// 	break
+		// }
+
+		// c := s.argString(1, "")
+		// p := &c
+		// if p != nil && *p == "?" {
+		// 	t.oscColorResponse(int(DefaultCursor), 12)
+		// } else if err := t.setColorName(int(DefaultCursor), p); err != nil {
+		// 	t.logf("invalid background color: %s\n", p)
+		// } else {
+		// 	// TODO: redraw
+		// }
 		case 4: // color set
 			if len(s.args) < 3 {
 				break
 			}
-			// setcolorname(s.arg(1, 0), s.argString(2, ""))
+
+			c := s.argString(2, "")
+			p = &c
+			fallthrough
 		case 104: // color reset
-			// TODO: complain about invalid color, redraw, etc.
-			// setcolorname(s.arg(1, 0), nil)
+			j := -1
+			if len(s.args) > 1 {
+				j = s.arg(1, 0)
+			}
+			if p != nil && *p == "?" { // report
+				t.osc4ColorResponse(j)
+			} else if err := t.setColorName(j, p); err != nil {
+				if !(d == 104 && len(s.args) <= 1) {
+					t.logf("invalid color j=%d, p=%s\n", j, maybe(p))
+				}
+			} else {
+				// TODO: redraw
+			}
 		default:
 			t.logf("unknown OSC command %d\n", d)
 			// TODO: s.dump()
@@ -92,3 +152,179 @@ func (t *State) handleSTR() {
 		// t.str.dump()
 	}
 }
+
+func (t *State) setColorName(j int, p *string) error {
+	if !between(j, 0, 1<<24) {
+		return fmt.Errorf("invalid color value %d", j)
+	}
+
+	if p == nil {
+		// restore color
+		delete(t.colorOverride, Color(j))
+	} else {
+		// set color
+		r, g, b, err := parseColor(*p)
+		if err != nil {
+			return err
+		}
+		t.colorOverride[Color(j)] = Color(r<<16 | g<<8 | b)
+	}
+
+	return nil
+}
+
+func (t *State) oscColorResponse(j, num int) {
+	if j < 0 {
+		t.logf("failed to fetch osc color %d\n", j)
+		return
+	}
+
+	k, ok := t.colorOverride[Color(j)]
+	if ok {
+		j = int(k)
+	}
+
+	r, g, b := rgb(j)
+	t.w.Write([]byte(fmt.Sprintf("\033]%d;rgb:%02x%02x/%02x%02x/%02x%02x\007", num, r, r, g, g, b, b)))
+}
+
+func (t *State) osc4ColorResponse(j int) {
+	if j < 0 {
+		t.logf("failed to fetch osc4 color %d\n", j)
+		return
+	}
+
+	k, ok := t.colorOverride[Color(j)]
+	if ok {
+		j = int(k)
+	}
+
+	r, g, b := rgb(j)
+	t.w.Write([]byte(fmt.Sprintf("\033]4;%d;rgb:%02x%02x/%02x%02x/%02x%02x\007", j, r, r, g, g, b, b)))
+}
+
+func rgb(j int) (r, g, b int) {
+	return (j >> 16) & 0xff, (j >> 8) & 0xff, j & 0xff
+}
+
+var (
+	RGBPattern  = regexp.MustCompile(`^([\da-f]{1})\/([\da-f]{1})\/([\da-f]{1})$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$`)
+	HashPattern = regexp.MustCompile(`[\da-f]`)
+)
+
+func parseColor(p string) (r, g, b int, err error) {
+	if len(p) == 0 {
+		err = fmt.Errorf("empty color spec")
+		return
+	}
+
+	low := strings.ToLower(p)
+	if strings.HasPrefix(low, "rgb:") {
+		low = low[4:]
+		sm := RGBPattern.FindAllStringSubmatch(low, -1)
+		if len(sm) != 1 || len(sm[0]) == 0 {
+			err = fmt.Errorf("invalid rgb color spec: %s", p)
+			return
+		}
+		m := sm[0]
+
+		var base float64
+		if len(m[1]) > 0 {
+			base = 15
+		} else if len(m[4]) > 0 {
+			base = 255
+		} else if len(m[7]) > 0 {
+			base = 4095
+		} else {
+			base = 65535
+		}
+
+		r64, err := strconv.ParseInt(firstNonEmpty(m[1], m[4], m[7], m[10]), 16, 0)
+		if err != nil {
+			return r, g, b, err
+		}
+
+		g64, err := strconv.ParseInt(firstNonEmpty(m[2], m[5], m[8], m[11]), 16, 0)
+		if err != nil {
+			return r, g, b, err
+		}
+
+		b64, err := strconv.ParseInt(firstNonEmpty(m[3], m[6], m[9], m[12]), 16, 0)
+		if err != nil {
+			return r, g, b, err
+		}
+
+		r = int(math.Round(float64(r64) / base * 255))
+		g = int(math.Round(float64(g64) / base * 255))
+		b = int(math.Round(float64(b64) / base * 255))
+		return r, g, b, nil
+	} else if strings.HasPrefix(low, "#") {
+		low = low[1:]
+		m := HashPattern.FindAllString(low, -1)
+		if !oneOf(len(m), []int{3, 6, 9, 12}) {
+			err = fmt.Errorf("invalid hash color spec: %s", p)
+			return
+		}
+
+		adv := len(low) / 3
+		for i := 0; i < 3; i++ {
+			c, err := strconv.ParseInt(low[adv*i:adv*i+adv], 16, 0)
+			if err != nil {
+				return r, g, b, err
+			}
+
+			var v int64
+			switch adv {
+			case 1:
+				v = c << 4
+			case 2:
+				v = c
+			case 3:
+				v = c >> 4
+			default:
+				v = c >> 8
+			}
+
+			switch i {
+			case 0:
+				r = int(v)
+			case 1:
+				g = int(v)
+			case 2:
+				b = int(v)
+			}
+		}
+		return
+	} else {
+		err = fmt.Errorf("invalid color spec: %s", p)
+		return
+	}
+}
+
+func maybe(p *string) string {
+	if p == nil {
+		return "<nil>"
+	}
+	return *p
+}
+
+func firstNonEmpty(strs ...string) string {
+	if len(strs) == 0 {
+		return ""
+	}
+	for _, str := range strs {
+		if len(str) > 0 {
+			return str
+		}
+	}
+	return strs[len(strs)-1]
+}
+
+func oneOf(v int, is []int) bool {
+	for _, i := range is {
+		if v == i {
+			return true
+		}
+	}
+	return false
+}
diff --git a/str_test.go b/str_test.go
index 578f901..974f15e 100644
--- a/str_test.go
+++ b/str_test.go
@@ -13,3 +13,160 @@ func TestSTRParse(t *testing.T) {
 		t.Fatal("STR parse mismatch")
 	}
 }
+
+func TestParseColor(t *testing.T) {
+	type testCase struct {
+		name    string
+		input   string
+		r, g, b int
+	}
+
+	for _, tc := range []testCase{
+		{
+			"rgb 4 bit zero",
+			"rgb:0/0/0",
+			0, 0, 0,
+		},
+		{
+			"rgb 4 bit max",
+			"rgb:f/f/f",
+			255, 255, 255,
+		},
+		{
+			"rgb 4 bit values",
+			"rgb:1/2/3",
+			17, 34, 51,
+		},
+		{
+			"rgb 8 bit zero",
+			"rgb:00/00/00",
+			0, 0, 0,
+		},
+		{
+			"rgb 8 bit max",
+			"rgb:ff/ff/ff",
+			255, 255, 255,
+		},
+		{
+			"rgb 8 bit values",
+			"rgb:11/22/33",
+			17, 34, 51,
+		},
+		{
+			"rgb 12 bit zero",
+			"rgb:000/000/000",
+			0, 0, 0,
+		},
+		{
+			"rgb 12 bit max",
+			"rgb:fff/fff/fff",
+			255, 255, 255,
+		},
+		{
+			"rgb 12 bit values",
+			"rgb:111/222/333",
+			17, 34, 51,
+		},
+		{
+			"rgb 16 bit zero",
+			"rgb:0000/0000/0000",
+			0, 0, 0,
+		},
+		{
+			"rgb 16 bit max",
+			"rgb:ffff/ffff/ffff",
+			255, 255, 255,
+		},
+		{
+			"rgb 16 bit values",
+			"rgb:1111/2222/3333",
+			17, 34, 51,
+		},
+		{
+			"rgb 16 bit values",
+			"rgb:1111/2222/3333",
+			17, 34, 51,
+		},
+		{
+			"hash 4 bit zero",
+			"#000",
+			0, 0, 0,
+		},
+		{
+			"hash 4 bit max",
+			"#fff",
+			240, 240, 240,
+		},
+		{
+			"hash 4 bit values",
+			"#123",
+			16, 32, 48,
+		},
+		{
+			"hash 8 bit zero",
+			"#000000",
+			0, 0, 0,
+		},
+		{
+			"hash 8 bit max",
+			"#ffffff",
+			255, 255, 255,
+		},
+		{
+			"hash 8 bit values",
+			"#112233",
+			17, 34, 51,
+		},
+		{
+			"hash 12 bit zero",
+			"#000000000",
+			0, 0, 0,
+		},
+		{
+			"hash 12 bit max",
+			"#fffffffff",
+			255, 255, 255,
+		},
+		{
+			"hash 12 bit values",
+			"#111222333",
+			17, 34, 51,
+		},
+		{
+			"hash 16 bit zero",
+			"#000000000000",
+			0, 0, 0,
+		},
+		{
+			"hash 16 bit max",
+			"#ffffffffffff",
+			255, 255, 255,
+		},
+		{
+			"hash 16 bit values",
+			"#111122223333",
+			17, 34, 51,
+		},
+		{
+			"rgb upper case",
+			"RGB:0/A/F",
+			0, 170, 255,
+		},
+		{
+			"hash upper case",
+			"#FFF",
+			240, 240, 240,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			r, g, b, err := parseColor(tc.input)
+			if err != nil {
+				t.Fatalf("failed to parse color: %s", err)
+			}
+
+			if r != tc.r || g != tc.g || b != tc.b {
+				t.Fatalf("expected (%d, %d, %d), got (%d, %d, %d)", tc.r, tc.g, tc.b, r, g, b)
+			}
+		})
+	}
+}
diff --git a/vt.go b/vt.go
new file mode 100644
index 0000000..c4e58dd
--- /dev/null
+++ b/vt.go
@@ -0,0 +1,89 @@
+package vt10x
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"io/ioutil"
+)
+
+// Terminal represents the virtual terminal emulator.
+type Terminal interface {
+	// View displays the virtual terminal.
+	View
+
+	// Write parses input and writes terminal changes to state.
+	io.Writer
+
+	// Parse blocks on read on pty or io.Reader, then parses sequences until
+	// buffer empties. State is locked as soon as first rune is read, and unlocked
+	// when buffer is empty.
+	Parse(bf *bufio.Reader) error
+}
+
+// View represents the view of the virtual terminal emulator.
+type View interface {
+	// String dumps the virtual terminal contents.
+	fmt.Stringer
+
+	// Size returns the size of the virtual terminal.
+	Size() (cols, rows int)
+
+	// Resize changes the size of the virtual terminal.
+	Resize(cols, rows int)
+
+	// Mode returns the current terminal mode.//
+	Mode() ModeFlag
+
+	// Title represents the title of the console window.
+	Title() string
+
+	// Cell returns the glyph containing the character code, foreground color, and
+	// background color at position (x, y) relative to the top left of the terminal.
+	Cell(x, y int) Glyph
+
+	// Cursor returns the current position of the cursor.
+	Cursor() Cursor
+
+	// CursorVisible returns the visible state of the cursor.
+	CursorVisible() bool
+
+	// Lock locks the state object's mutex.
+	Lock()
+
+	// Unlock resets change flags and unlocks the state object's mutex.
+	Unlock()
+}
+
+type TerminalOption func(*TerminalInfo)
+
+type TerminalInfo struct {
+	w          io.Writer
+	cols, rows int
+}
+
+func WithWriter(w io.Writer) TerminalOption {
+	return func(info *TerminalInfo) {
+		info.w = w
+	}
+}
+
+func WithSize(cols, rows int) TerminalOption {
+	return func(info *TerminalInfo) {
+		info.cols = cols
+		info.rows = rows
+	}
+}
+
+// New returns a new virtual terminal emulator.
+func New(opts ...TerminalOption) Terminal {
+	info := TerminalInfo{
+		w:    ioutil.Discard,
+		cols: 80,
+		rows: 24,
+	}
+	for _, opt := range opts {
+		opt(&info)
+	}
+	return newTerminal(info)
+}
diff --git a/vt_other.go b/vt_other.go
index 6d4b500..c9d364e 100644
--- a/vt_other.go
+++ b/vt_other.go
@@ -6,47 +6,34 @@ import (
 	"bufio"
 	"bytes"
 	"io"
-	"os"
 	"unicode"
 	"unicode/utf8"
 )
 
-// VT represents the virtual terminal emulator.
-type VT struct {
-	dest *State
-	rwc  io.ReadWriteCloser
-	br   *bufio.Reader
-	pty  *os.File
+type terminal struct {
+	*State
 }
 
-// Create initializes a virtual terminal emulator with the target state
-// and io.ReadWriteCloser input.
-func Create(state *State, rwc io.ReadWriteCloser) (*VT, error) {
-	t := &VT{
-		dest: state,
-		rwc:  rwc,
-	}
-	t.init()
-	return t, nil
+func newTerminal(info TerminalInfo) *terminal {
+	t := &terminal{newState(info.w)}
+	t.init(info.cols, info.rows)
+	return t
 }
 
-func (t *VT) init() {
-	t.br = bufio.NewReader(t.rwc)
-	t.dest.w = t.rwc
-	t.dest.numlock = true
-	t.dest.state = t.dest.parse
-	t.dest.cur.attr.fg = DefaultFG
-	t.dest.cur.attr.bg = DefaultBG
-	t.Resize(80, 24)
-	t.dest.reset()
+func (t *terminal) init(cols, rows int) {
+	t.numlock = true
+	t.state = t.parse
+	t.cur.Attr.FG = DefaultFG
+	t.cur.Attr.BG = DefaultBG
+	t.Resize(cols, rows)
+	t.reset()
 }
 
-// Write parses input and writes terminal changes to state.
-func (t *VT) Write(p []byte) (int, error) {
+func (t *terminal) Write(p []byte) (int, error) {
 	var written int
 	r := bytes.NewReader(p)
-	t.dest.lock()
-	defer t.dest.unlock()
+	t.lock()
+	defer t.unlock()
 	for {
 		c, sz, err := r.ReadRune()
 		if err != nil {
@@ -61,51 +48,43 @@ func (t *VT) Write(p []byte) (int, error) {
 				// not enough bytes for a full rune
 				return written - 1, nil
 			}
-			t.dest.logln("invalid utf8 sequence")
+			t.logln("invalid utf8 sequence")
 			continue
 		}
-		t.dest.put(c)
+		t.put(c)
 	}
 	return written, nil
 }
 
-// Close closes the io.ReadWriteCloser.
-func (t *VT) Close() error {
-	return t.rwc.Close()
-}
-
-// Parse blocks on read on io.ReadWriteCloser, then parses sequences until
-// buffer empties. State is locked as soon as first rune is read, and unlocked
-// when buffer is empty.
 // TODO: add tests for expected blocking behavior
-func (t *VT) Parse() error {
+func (t *terminal) Parse(br *bufio.Reader) error {
 	var locked bool
 	defer func() {
 		if locked {
-			t.dest.unlock()
+			t.unlock()
 		}
 	}()
 	for {
-		c, sz, err := t.br.ReadRune()
+		c, sz, err := br.ReadRune()
 		if err != nil {
 			return err
 		}
 		if c == unicode.ReplacementChar && sz == 1 {
-			t.dest.logln("invalid utf8 sequence")
+			t.logln("invalid utf8 sequence")
 			break
 		}
 		if !locked {
-			t.dest.lock()
+			t.lock()
 			locked = true
 		}
 
 		// put rune for parsing and update state
-		t.dest.put(c)
+		t.put(c)
 
 		// break if our buffer is empty, or if buffer contains an
 		// incomplete rune.
-		n := t.br.Buffered()
-		if n == 0 || (n < 4 && !fullRuneBuffered(t.br)) {
+		n := br.Buffered()
+		if n == 0 || (n < 4 && !fullRuneBuffered(br)) {
 			break
 		}
 	}
@@ -121,9 +100,8 @@ func fullRuneBuffered(br *bufio.Reader) bool {
 	return utf8.FullRune(buf)
 }
 
-// Resize reports new size to pty and updates state.
-func (t *VT) Resize(cols, rows int) {
-	t.dest.lock()
-	defer t.dest.unlock()
-	_ = t.dest.resize(cols, rows)
+func (t *terminal) Resize(cols, rows int) {
+	t.lock()
+	defer t.unlock()
+	_ = t.resize(cols, rows)
 }
diff --git a/vt_posix.go b/vt_posix.go
index 94e4d00..80644f4 100644
--- a/vt_posix.go
+++ b/vt_posix.go
@@ -10,41 +10,31 @@ import (
 	"unicode/utf8"
 )
 
-// VT represents the virtual terminal emulator.
-type VT struct {
-	dest *State
-	rwc  io.ReadWriteCloser
-	br   *bufio.Reader
+type terminal struct {
+	*State
 }
 
-// Create initializes a virtual terminal emulator with the target state
-// and io.ReadWriteCloser input.
-func Create(state *State, rwc io.ReadWriteCloser) (*VT, error) {
-	t := &VT{
-		dest: state,
-		rwc:  rwc,
-	}
-	t.init()
-	return t, nil
+func newTerminal(info TerminalInfo) *terminal {
+	t := &terminal{newState(info.w)}
+	t.init(info.cols, info.rows)
+	return t
 }
 
-func (t *VT) init() {
-	t.br = bufio.NewReader(t.rwc)
-	t.dest.w = t.rwc
-	t.dest.numlock = true
-	t.dest.state = t.dest.parse
-	t.dest.cur.attr.fg = DefaultFG
-	t.dest.cur.attr.bg = DefaultBG
-	t.Resize(80, 24)
-	t.dest.reset()
+func (t *terminal) init(cols, rows int) {
+	t.numlock = true
+	t.state = t.parse
+	t.cur.Attr.FG = DefaultFG
+	t.cur.Attr.BG = DefaultBG
+	t.Resize(cols, rows)
+	t.reset()
 }
 
 // Write parses input and writes terminal changes to state.
-func (t *VT) Write(p []byte) (int, error) {
+func (t *terminal) Write(p []byte) (int, error) {
 	var written int
 	r := bytes.NewReader(p)
-	t.dest.lock()
-	defer t.dest.unlock()
+	t.lock()
+	defer t.unlock()
 	for {
 		c, sz, err := r.ReadRune()
 		if err != nil {
@@ -59,51 +49,43 @@ func (t *VT) Write(p []byte) (int, error) {
 				// not enough bytes for a full rune
 				return written - 1, nil
 			}
-			t.dest.logln("invalid utf8 sequence")
+			t.logln("invalid utf8 sequence")
 			continue
 		}
-		t.dest.put(c)
+		t.put(c)
 	}
 	return written, nil
 }
 
-// Close closes the io.ReadWriteCloser.
-func (t *VT) Close() error {
-	return t.rwc.Close()
-}
-
-// Parse blocks on read on pty or io.ReadCloser, then parses sequences until
-// buffer empties. State is locked as soon as first rune is read, and unlocked
-// when buffer is empty.
 // TODO: add tests for expected blocking behavior
-func (t *VT) Parse() error {
+func (t *terminal) Parse(br *bufio.Reader) error {
 	var locked bool
 	defer func() {
 		if locked {
-			t.dest.unlock()
+			t.unlock()
 		}
 	}()
 	for {
-		c, sz, err := t.br.ReadRune()
+		c, sz, err := br.ReadRune()
 		if err != nil {
 			return err
 		}
 		if c == unicode.ReplacementChar && sz == 1 {
-			t.dest.logln("invalid utf8 sequence")
+			t.logln("invalid utf8 sequence")
 			break
 		}
 		if !locked {
-			t.dest.lock()
+			t.lock()
 			locked = true
 		}
 
 		// put rune for parsing and update state
-		t.dest.put(c)
+		t.put(c)
 
 		// break if our buffer is empty, or if buffer contains an
 		// incomplete rune.
-		n := t.br.Buffered()
-		if n == 0 || (n < 4 && !fullRuneBuffered(t.br)) {
+		n := br.Buffered()
+		if n == 0 || (n < 4 && !fullRuneBuffered(br)) {
 			break
 		}
 	}
@@ -119,9 +101,8 @@ func fullRuneBuffered(br *bufio.Reader) bool {
 	return utf8.FullRune(buf)
 }
 
-// Resize reports new size to pty and updates state.
-func (t *VT) Resize(cols, rows int) {
-	t.dest.lock()
-	defer t.dest.unlock()
-	_ = t.dest.resize(cols, rows)
+func (t *terminal) Resize(cols, rows int) {
+	t.lock()
+	defer t.unlock()
+	_ = t.resize(cols, rows)
 }
diff --git a/vt_test.go b/vt_test.go
index 3533248..505492a 100644
--- a/vt_test.go
+++ b/vt_test.go
@@ -1,53 +1,37 @@
 package vt10x
 
 import (
-	"bufio"
-	"fmt"
 	"io"
-	"os"
-	"regexp"
-	"strconv"
 	"strings"
 	"testing"
-
-	"github.com/stretchr/testify/require"
-	"golang.org/x/crypto/ssh/terminal"
 )
 
-func extractStr(t *State, x0, x1, row int) string {
+func extractStr(term Terminal, x0, x1, row int) string {
 	var s []rune
 	for i := x0; i <= x1; i++ {
-		c, _, _ := t.Cell(i, row)
-		s = append(s, c)
+		attr := term.Cell(i, row)
+		s = append(s, attr.Char)
 	}
 	return string(s)
 }
 
 func TestPlainChars(t *testing.T) {
-	var st State
-	term, err := Create(&st, nil)
-	if err != nil {
-		t.Fatal(err)
-	}
+	term := New()
 	expected := "Hello world!"
-	_, err = term.Write([]byte(expected))
+	_, err := term.Write([]byte(expected))
 	if err != nil && err != io.EOF {
 		t.Fatal(err)
 	}
-	actual := extractStr(&st, 0, len(expected)-1, 0)
+	actual := extractStr(term, 0, len(expected)-1, 0)
 	if expected != actual {
 		t.Fatal(actual)
 	}
 }
 
 func TestNewline(t *testing.T) {
-	var st State
-	term, err := Create(&st, nil)
-	if err != nil {
-		t.Fatal(err)
-	}
+	term := New()
 	expected := "Hello world!\n...and more."
-	_, err = term.Write([]byte("\033[20h")) // set CRLF mode
+	_, err := term.Write([]byte("\033[20h")) // set CRLF mode
 	if err != nil && err != io.EOF {
 		t.Fatal(err)
 	}
@@ -57,85 +41,24 @@ func TestNewline(t *testing.T) {
 	}
 
 	split := strings.Split(expected, "\n")
-	actual := extractStr(&st, 0, len(split[0])-1, 0)
+	actual := extractStr(term, 0, len(split[0])-1, 0)
 	actual += "\n"
-	actual += extractStr(&st, 0, len(split[1])-1, 1)
+	actual += extractStr(term, 0, len(split[1])-1, 1)
 	if expected != actual {
 		t.Fatal(actual)
 	}
 
 	// A newline with a color set should not make the next line that color,
 	// which used to happen if it caused a scroll event.
+	st := (term.(*terminal))
 	st.moveTo(0, st.rows-1)
 	_, err = term.Write([]byte("\033[1;37m\n$ \033[m"))
 	if err != nil && err != io.EOF {
 		t.Fatal(err)
 	}
-	_, fg, bg := st.Cell(st.Cursor())
-	if fg != DefaultFG {
-		t.Fatal(st.cur.x, st.cur.y, fg, bg)
-	}
-}
-
-var (
-	dsrPattern = regexp.MustCompile(`(\d+);(\d+)`)
-)
-
-type Coord struct {
-	row int
-	col int
-}
-
-func TestVTCPR(t *testing.T) {
-	c, _, err := NewVT10XConsole()
-	require.NoError(t, err)
-	defer c.Close()
-
-	go func() {
-		c.ExpectEOF()
-	}()
-
-	coord, err := cpr(c.Tty())
-	require.NoError(t, err)
-	require.Equal(t, 1, coord.row)
-	require.Equal(t, 1, coord.col)
-}
-
-// cpr is an example application that requests for the cursor position report.
-func cpr(tty *os.File) (*Coord, error) {
-	oldState, err := terminal.MakeRaw(int(tty.Fd()))
-	if err != nil {
-		return nil, err
-	}
-
-	defer terminal.Restore(int(tty.Fd()), oldState)
-
-	// ANSI escape sequence for DSR - Device Status Report
-	// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences
-	fmt.Fprint(tty, "\x1b[6n")
-
-	// Reports the cursor position (CPR) to the application as (as though typed at
-	// the keyboard) ESC[n;mR, where n is the row and m is the column.
-	reader := bufio.NewReader(tty)
-	text, err := reader.ReadSlice('R')
-	if err != nil {
-		return nil, err
-	}
-
-	matches := dsrPattern.FindStringSubmatch(string(text))
-	if len(matches) != 3 {
-		return nil, fmt.Errorf("incorrect number of matches: %d", len(matches))
-	}
-
-	col, err := strconv.Atoi(matches[2])
-	if err != nil {
-		return nil, err
+	cur := term.Cursor()
+	attr := term.Cell(cur.X, cur.Y)
+	if attr.FG != DefaultFG {
+		t.Fatal(st.cur.X, st.cur.Y, attr.FG, attr.BG)
 	}
-
-	row, err := strconv.Atoi(matches[1])
-	if err != nil {
-		return nil, err
-	}
-
-	return &Coord{row, col}, nil
 }