Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ If you want sharp terminal text on any terminal, Carbonyl and Browsh are good ch

## Known limitations

- **Terminal multiplexers:** tmux, screen, and herdr do not work well yet. Run Termium directly in the terminal window.
- **Terminal multiplexers:** tmux 3.3+ works over a Kitty-graphics terminal with `termium --renderer kitty`. screen and herdr do not work well yet.
- **Docker:** Chromium's sandbox needs namespaces that Docker's default seccomp profile blocks. Run the container with `--security-opt seccomp=unconfined`.
- **Text is pixels:** page text is part of the image, so terminal-native selection and screen readers cannot read it. On `main`, select inside the webpage and use **F10 → Copy selected text** (F8); see [Browser tools](docs/browser-tools.md).
- **Platforms:** no native Windows or Linux ARM64 packages yet.
Expand All @@ -103,7 +103,7 @@ the next release. [Controls, availability and limits](docs/browser-tools.md).

## What's next

1. **Multiplexers:** tmux and screen support.
1. **Multiplexers:** screen and broader multiplexer support.
2. **Distribution:** Homebrew and AUR packages, more clean-machine testing, automated dependency updates.
3. **Browser polish:** persistent browsing sessions, richer clipboard formats, dark mode that follows your terminal.
4. **Native Windows:** add native packages; Windows through WSL2 is supported today.
Expand Down
2 changes: 1 addition & 1 deletion client/frame_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func (p *framePreparer) prepare(raw *Frame) (*Frame, error) {
}()
f := &Frame{storage: arena, Data: raw.Data, Generation: raw.Generation, State: raw.State, Width: dim.Width, Height: dim.Height, Timestamp: raw.Timestamp, CapturedAt: raw.CapturedAt}
if p.renderer == "kitty" && format == "png" {
f.Graphics, err = encodeKittyPayloadInto(raw.Data, "f=100,"+kittyPlacement, arena.alloc)
f.Graphics, err = encodeKittyPayloadInto(raw.Data, "f=100,"+p.kitty.placement(dim.Width, dim.Height), arena.alloc, p.kitty.tmux)
if err != nil {
return nil, err
}
Expand Down
39 changes: 34 additions & 5 deletions client/kitty_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ type kittyEncoder struct {
compressed bytes.Buffer
compressor *zlib.Writer
row []byte
tmux bool
imageID uint32
cell CharSize
}

func (e *kittyEncoder) placement(width, height int) string {
if !e.tmux {
return kittyPlacement
}
id := e.imageID
if id == 0 {
id = 1
}
columns, rows := kittyCells(width, height, e.cell)
return fmt.Sprintf("a=T,i=%d,p=1,C=1,q=2,U=1,c=%d,r=%d", id, columns, rows)
}

// JPEG captures have already been decoded for renderer preparation. Send those
Expand Down Expand Up @@ -67,7 +82,7 @@ func (e *kittyEncoder) encodeRGBInto(img *image.RGBA, alloc frameAllocator) ([]b
if err := e.compressor.Close(); err != nil {
return nil, err
}
return encodeKittyPayloadInto(e.compressed.Bytes(), fmt.Sprintf("f=24,s=%d,v=%d,o=z,%s", bounds.Dx(), bounds.Dy(), kittyPlacement), alloc)
return encodeKittyPayloadInto(e.compressed.Bytes(), fmt.Sprintf("f=24,s=%d,v=%d,o=z,%s", bounds.Dx(), bounds.Dy(), e.placement(bounds.Dx(), bounds.Dy())), alloc, e.tmux)
}

// Explicit lossless PNG capture can pass through unchanged. Both this path and
Expand All @@ -77,13 +92,16 @@ func encodeKittyPNG(data []byte) ([]byte, error) {
}

func encodeKittyPayload(data []byte, control string) ([]byte, error) {
return encodeKittyPayloadInto(data, control, nil)
return encodeKittyPayloadInto(data, control, nil, false)
}
func encodeKittyPayloadInto(data []byte, control string, alloc frameAllocator) ([]byte, error) {
func encodeKittyPayloadInto(data []byte, control string, alloc frameAllocator, tmux bool) ([]byte, error) {
encodedSize := base64.StdEncoding.EncodedLen(len(data))
chunks := max(1, (encodedSize+kittyChunkSize-1)/kittyChunkSize)
// Conservative framing allowance; guard allocation before growing the buffer.
size := encodedSize + chunks*(len(kittyMore)+len(kittyST)) + len(control)
if tmux {
size += chunks * (len(tmuxPassthroughPrefix) + len(tmuxPassthroughSuffix) + 2)
}
if size > maxFrameBytes {
return nil, fmt.Errorf("Kitty output exceeds 32 MiB; reduce the terminal size")
}
Expand All @@ -97,6 +115,10 @@ func encodeKittyPayloadInto(data []byte, control string, alloc frameAllocator) (
for offset := 0; ; offset += rawChunkSize {
end := min(offset+rawChunkSize, len(data))
more := end < len(data)
if tmux {
output.WriteString(tmuxPassthroughPrefix)
output.WriteByte('\033') // tmux requires every inner ESC to be doubled.
}
if offset == 0 {
output.WriteString(kittyAPC)
output.WriteString(control)
Expand All @@ -111,7 +133,13 @@ func encodeKittyPayloadInto(data []byte, control string, alloc frameAllocator) (
}
base64.StdEncoding.Encode(chunk[:], data[offset:end])
output.Write(chunk[:base64.StdEncoding.EncodedLen(end-offset)])
if tmux {
output.WriteByte('\033')
}
output.WriteString(kittyST)
if tmux {
output.WriteString(tmuxPassthroughSuffix)
}
if !more {
return output.Bytes(), nil
}
Expand All @@ -125,9 +153,10 @@ func displayWithKittyRGBA(img *image.RGBA) error {
if err := encoder.Encode(&data, img); err != nil {
return err
}
payload, err := encodeKittyPNG(data.Bytes())
kitty := kittyEncoder{tmux: kittySession.tmux, imageID: kittySession.imageID, cell: charSize}
payload, err := encodeKittyPayloadInto(data.Bytes(), "f=100,"+kitty.placement(img.Bounds().Dx(), img.Bounds().Dy()), nil, kitty.tmux)
if err != nil {
return err
}
return writeGraphicsFrame(graphicsOutput, payload, sDims.ViewTop+1, H_BORDER_WIDTH+1)
return writeKittyFrame(graphicsOutput, payload, sDims.ViewTop+1, H_BORDER_WIDTH+1, img.Bounds().Dx(), img.Bounds().Dy())
}
217 changes: 217 additions & 0 deletions client/kitty_tmux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package main

import (
"context"
"crypto/rand"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"
)

const (
tmuxPassthroughPrefix = "\033Ptmux;"
tmuxPassthroughSuffix = "\033\\"
kittyPlaceholder = '\U0010EEEE'
)

type kittySessionState struct {
tmux bool
imageID uint32
placeholderWidth int
placeholderRows int
}

var kittySession = kittySessionState{imageID: 1}

func setupKittySession() error {
kittySession = kittySessionState{imageID: 1}
if os.Getenv("TMUX") == "" {
return nil
}
if err := enableTmuxPassthrough(); err != nil {
return err
}
id, err := randomKittyImageID(rand.Reader)
if err != nil {
return fmt.Errorf("create Kitty image ID: %w", err)
}
kittySession.tmux, kittySession.imageID = true, id
return nil
}

func tmuxCommand(args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "tmux", args...)
output, err := cmd.CombinedOutput()
if ctx.Err() != nil {
return "", fmt.Errorf("tmux command timed out")
}
if err != nil {
return "", fmt.Errorf("tmux %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output)), nil
}

func enableTmuxPassthrough() error {
value, err := tmuxCommand("show", "-Ap", "allow-passthrough")
if err != nil {
return fmt.Errorf("Kitty graphics in tmux require tmux 3.3 or newer: %w", err)
}
fields := strings.Fields(value)
if len(fields) > 0 && (fields[len(fields)-1] == "on" || fields[len(fields)-1] == "all") {
return nil
}
if _, err := tmuxCommand("set", "-p", "allow-passthrough", "on"); err != nil {
return fmt.Errorf("enable Kitty graphics passthrough for this tmux pane: %w", err)
}
return nil
}

func tmuxCellSize() (CharSize, bool) {
if os.Getenv("TMUX") == "" {
return CharSize{}, false
}
value, err := tmuxCommand("display-message", "-p", "#{window_cell_width} #{window_cell_height}")
if err != nil {
return CharSize{}, false
}
fields := strings.Fields(value)
if len(fields) != 2 {
return CharSize{}, false
}
width, widthErr := strconv.Atoi(fields[0])
height, heightErr := strconv.Atoi(fields[1])
if widthErr != nil || heightErr != nil || width < 1 || height < 1 {
return CharSize{}, false
}
return CharSize{Width: width, Height: height}, true
}

func randomKittyImageID(source io.Reader) (uint32, error) {
var raw [4]byte
for {
if _, err := io.ReadFull(source, raw[:3]); err != nil {
return 0, err
}
id := uint32(raw[0])<<16 | uint32(raw[1])<<8 | uint32(raw[2])
if id != 0 {
return id, nil
}
}
}

func wrapTmuxPassthrough(data []byte) []byte {
output := make([]byte, 0, len(data)+len(tmuxPassthroughPrefix)+len(tmuxPassthroughSuffix)+2)
output = append(output, tmuxPassthroughPrefix...)
for _, b := range data {
output = append(output, b)
if b == '\033' {
output = append(output, b)
}
}
return append(output, tmuxPassthroughSuffix...)
}

func kittyDeleteSequence() []byte {
id := kittySession.imageID
if id == 0 {
id = 1
}
command := []byte(fmt.Sprintf("\033_Ga=d,d=I,i=%d,q=2\033\\", id))
if kittySession.tmux {
return wrapTmuxPassthrough(command)
}
return command
}

func writeKittyFrame(w io.Writer, data []byte, row, column, width, height int) error {
if !kittySession.tmux {
return writeGraphicsFrame(w, data, row, column)
}
columns, rows := kittyCells(width, height, charSize)
if rows > len(kittyRowDiacritics) {
return fmt.Errorf("Kitty tmux image is %d rows tall; reduce the pane below %d rows", rows, len(kittyRowDiacritics)+1)
}
write := func(data []byte) error {
n, err := w.Write(data)
if err == nil && n != len(data) {
err = io.ErrShortWrite
}
return err
}
if err := write([]byte("\033[s")); err != nil {
return err
}
var firstErr error
if err := write(data); err != nil {
firstErr = err
}
if firstErr == nil && (kittySession.placeholderWidth != columns || kittySession.placeholderRows != rows) {
id := kittySession.imageID
color := []byte(fmt.Sprintf("\033[38;2;%d;%d;%dm", id>>16, id>>8&255, id&255))
for y := 0; y < rows; y++ {
line := strings.Builder{}
line.Grow(columns*4 + 32)
fmt.Fprintf(&line, "\033[%d;%dH%s%c%c", row+y, column, color, kittyPlaceholder, kittyRowDiacritics[y])
for x := 1; x < columns; x++ {
line.WriteRune(kittyPlaceholder)
}
if err := write([]byte(line.String())); err != nil {
firstErr = err
break
}
}
if firstErr == nil {
kittySession.placeholderWidth, kittySession.placeholderRows = columns, rows
}
}
if err := write([]byte("\033[39m\033[u")); firstErr == nil {
firstErr = err
}
return firstErr
}

func kittyCells(width, height int, cell CharSize) (int, int) {
if cell.Width < 1 {
cell.Width = 8
}
if cell.Height < 1 {
cell.Height = 16
}
return max(1, (width+cell.Width-1)/cell.Width), max(1, (height+cell.Height-1)/cell.Height)
}

// Normative Kitty graphics protocol row/column diacritics, derived from
// Unicode 6.0. Only the row mark is needed because columns inherit left-to-right.
var kittyRowDiacritics = [...]rune{
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C,
0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592,
0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB,
0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3,
0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954,
0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71,
0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7,
0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE,
0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1,
0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1,
0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
}
56 changes: 56 additions & 0 deletions client/kitty_tmux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestKittyTmuxFramingAndPlaceholders(t *testing.T) {
wrapped := wrapTmuxPassthrough([]byte("\033_Gq=2;abc\033\\"))
if string(wrapped) != "\033Ptmux;\033\033_Gq=2;abc\033\033\\\033\\" {
t.Fatalf("bad tmux passthrough framing: %q", wrapped)
}

previousSession, previousSize := kittySession, charSize
t.Cleanup(func() { kittySession, charSize = previousSession, previousSize })
kittySession = kittySessionState{tmux: true, imageID: 0x123456}
charSize = CharSize{Width: 8, Height: 16}
encoder := kittyEncoder{tmux: true, imageID: kittySession.imageID, cell: charSize}
payload, err := encodeKittyPayloadInto([]byte("png"), "f=100,"+encoder.placement(16, 32), nil, true)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := writeKittyFrame(&out, payload, 3, 2, 16, 32); err != nil {
t.Fatal(err)
}
text := out.String()
if !strings.Contains(text, "a=T,i=1193046,p=1,C=1,q=2,U=1,c=2,r=2") ||
!strings.Contains(text, "\033[38;2;18;52;86m") || strings.Count(text, string(kittyPlaceholder)) != 4 ||
!strings.HasPrefix(text, "\033[s"+tmuxPassthroughPrefix) || !strings.HasSuffix(text, "\033[39m\033[u") {
t.Fatalf("bad tmux Kitty frame: %q", text)
}

out.Reset()
if err := writeKittyFrame(&out, payload, 3, 2, 16, 32); err != nil {
t.Fatal(err)
}
if strings.ContainsRune(out.String(), kittyPlaceholder) {
t.Fatal("unchanged placeholder grid was redrawn")
}
if got := string(kittyDeleteSequence()); !strings.Contains(got, "a=d,d=I,i=1193046") || !strings.HasPrefix(got, tmuxPassthroughPrefix) {
t.Fatalf("bad tmux Kitty delete: %q", got)
}
}

func TestKittyCellsAndImageID(t *testing.T) {
columns, rows := kittyCells(17, 33, CharSize{Width: 8, Height: 16})
if columns != 3 || rows != 3 {
t.Fatalf("got %dx%d cells", columns, rows)
}
id, err := randomKittyImageID(bytes.NewReader([]byte{0, 0, 0, 0x12, 0x34, 0x56}))
if err != nil || id != 0x123456 {
t.Fatalf("got image ID %#x: %v", id, err)
}
}
Loading