From f28e99d6fccdc286ef4c0578febbb2a1e8d5e783 Mon Sep 17 00:00:00 2001 From: Kennedy Carvalho Date: Mon, 5 Oct 2026 14:36:44 -0300 Subject: [PATCH] feat: support Kitty graphics in tmux --- README.md | 4 +- client/frame_pipeline.go | 2 +- client/kitty_renderer.go | 39 ++++++- client/kitty_tmux.go | 217 ++++++++++++++++++++++++++++++++++++++ client/kitty_tmux_test.go | 56 ++++++++++ client/main.go | 22 +++- docs/terminals.md | 8 +- docs/troubleshooting.md | 8 +- 8 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 client/kitty_tmux.go create mode 100644 client/kitty_tmux_test.go diff --git a/README.md b/README.md index 897c89d..f2855c6 100755 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/client/frame_pipeline.go b/client/frame_pipeline.go index 288e90d..20d0d93 100644 --- a/client/frame_pipeline.go +++ b/client/frame_pipeline.go @@ -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 } diff --git a/client/kitty_renderer.go b/client/kitty_renderer.go index 2bb6036..d45b2d5 100644 --- a/client/kitty_renderer.go +++ b/client/kitty_renderer.go @@ -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 @@ -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 @@ -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") } @@ -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) @@ -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 } @@ -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()) } diff --git a/client/kitty_tmux.go b/client/kitty_tmux.go new file mode 100644 index 0000000..0e836c3 --- /dev/null +++ b/client/kitty_tmux.go @@ -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, +} diff --git a/client/kitty_tmux_test.go b/client/kitty_tmux_test.go new file mode 100644 index 0000000..c03c2a7 --- /dev/null +++ b/client/kitty_tmux_test.go @@ -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) + } +} diff --git a/client/main.go b/client/main.go index 8fb7c04..41b34c5 100644 --- a/client/main.go +++ b/client/main.go @@ -292,6 +292,11 @@ func runInteractive() error { activeSocketPath = dir + "/browser.sock" } detectTerminalAndCalibrate() + if cfg.Renderer == "kitty" { + if err := setupKittySession(); err != nil { + return err + } + } s := initializeScreen() defer cleanShutdown(s) setupSignalHandling(s) @@ -325,7 +330,8 @@ func runInteractive() error { keyboardHandler.navigate(pb.NavigationAction_NAVIGATE, address) } pipeline.paused.Store(true) // The first redraw releases capture after initial navigation. - preparer := &framePreparer{renderer: cfg.Renderer, palette: cfg.Palette} + preparer := &framePreparer{renderer: cfg.Renderer, palette: cfg.Palette, + kitty: kittyEncoder{tmux: kittySession.tmux, imageID: kittySession.imageID, cell: charSize}} shutdownWg.Add(1) go func() { defer shutdownWg.Done() @@ -410,7 +416,7 @@ func initializeScreen() tcell.Screen { // finalizeScreen properly closes the tcell screen func finalizeScreen(s tcell.Screen) { if cfg.Renderer == "kitty" { - fmt.Fprint(graphicsOutput, kittyDelete) + _, _ = graphicsOutput.Write(kittyDeleteSequence()) } s.Fini() Debug("Screen finalized", DEBUG) @@ -627,6 +633,8 @@ func displayFrame(s tcell.Screen, frame *Frame) error { switch cfg.Renderer { case "tcell": err = displayWithTcell(s, frame.Image) + case "kitty": + err = writeKittyFrame(graphicsOutput, frame.Graphics, sDims.ViewTop+1, H_BORDER_WIDTH+1, frame.Width, frame.Height) default: err = writeGraphicsFrame(graphicsOutput, frame.Graphics, sDims.ViewTop+1, H_BORDER_WIDTH+1) } @@ -640,7 +648,8 @@ func invalidateGraphics(s tcell.Screen) { if cfg != nil && cfg.Renderer == "kitty" { // The splash and a partially written frame can own this placement even // when no browser frame has been recorded as successfully displayed. - fmt.Fprint(graphicsOutput, kittyDelete) + _, _ = graphicsOutput.Write(kittyDeleteSequence()) + kittySession.placeholderWidth, kittySession.placeholderRows = 0, 0 } displayedFrame = nil // Sixel has no portable delete-image command. A real terminal clear is @@ -856,7 +865,12 @@ func detectTerminalAndCalibrate() { if size, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ); err == nil && size.Col > 0 && size.Row > 0 && size.Xpixel >= size.Col && size.Ypixel >= size.Row { charSize = CharSize{int(size.Xpixel / size.Col), int(size.Ypixel / size.Row)} } else if err := calibrateXterm(); err != nil { - setDefaultCharSize() + if size, ok := tmuxCellSize(); ok { + charSize = size + Debug(fmt.Sprintf("Using tmux character size: %dx%d pixels", charSize.Width, charSize.Height), INFO) + } else { + setDefaultCharSize() + } } } diff --git a/docs/terminals.md b/docs/terminals.md index 94abbb3..111c47a 100644 --- a/docs/terminals.md +++ b/docs/terminals.md @@ -37,7 +37,13 @@ Run Termium directly in a terminal while diagnosing display problems. **SSH:** supported. Run `termium` on the remote Linux host from a local terminal with Kitty graphics or Sixel; Chromium runs remotely and the pages render on your screen. A slow connection lowers the frame rate. -**Terminal multiplexers:** tmux, screen, and herdr do not work well yet. Run Termium directly in the terminal window. +**Terminal multiplexers:** tmux 3.3 or newer works when its outer terminal supports Kitty graphics. Because tmux does not forward the graphics capability probe, select it explicitly: + +```bash +termium --renderer kitty +``` + +Termium enables `allow-passthrough` for the current pane and uses Unicode placeholders so images stay clipped to that pane. This changes only the active pane, not the tmux server's global configuration. screen and herdr do not work well yet; run Termium directly in the terminal window when using them. Native Windows builds are planned. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 24a422b..f9f8f43 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,7 +18,13 @@ Try one of the manual display options in [terminal support](terminals.md). For e termium --renderer tcell --splash NONE ``` -If you use tmux, screen, or SSH, also try launching directly in the local terminal. Include both results when reporting the issue. Character mode skips terminal graphics queries; automatic detection uses bounded timeouts. +Inside tmux 3.3 or newer on Kitty or another terminal implementing Kitty graphics, bypass automatic detection: + +```bash +termium --renderer kitty +``` + +Termium enables graphics passthrough for the current pane. If that fails, check `tmux -V`, `tmux show -Ap allow-passthrough`, and `$TERM` both inside and outside tmux. Also try launching directly in the local terminal and include both results when reporting the issue. screen and herdr are not supported yet. Character mode skips terminal graphics queries; automatic detection uses bounded timeouts. ## The page feels slow