From 303a3f3891898b5f02f7fd29bfa5b776abd9fc64 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:00:19 +0900 Subject: [PATCH 1/7] feat: add TmuxRenderer with DCS passthrough for tmux support Add a new TmuxRenderer that wraps KittyRenderer, enabling image display inside tmux by wrapping Kitty graphics escape sequences in DCS passthrough. Cursor positioning is offset by the pane position so images render in the correct pane. The --renderer/-r flag allows selecting between kitty (default) and tmux renderers. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/gaze/main.go | 54 ++++-- internal/adapter/renderer/kitty_renderer.go | 7 +- internal/adapter/renderer/tmux_renderer.go | 196 ++++++++++++++++++++ internal/adapter/tui/model.go | 8 + internal/adapter/tui/update.go | 3 + 5 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 internal/adapter/renderer/tmux_renderer.go diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index efac531..12ee8bb 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -27,30 +27,45 @@ func main() { } func newRootCmd() *cobra.Command { - var staticMode bool + var ( + staticMode bool + rendererType string + ) cmd := &cobra.Command{ Use: "gaze ", Short: "Terminal image viewer with zoom and pan", - Long: "gaze is a terminal image viewer that supports zoom, pan, and mouse interaction using Kitty Graphics Protocol.", + Long: "gaze is a terminal image viewer that supports zoom, pan, and mouse interaction using the Kitty Graphics Protocol.", Args: cobra.ExactArgs(1), SilenceUsage: true, } cmd.RunE = func(cmd *cobra.Command, args []string) error { if staticMode { - return runStatic(args) + return runStatic(args, rendererType) } - return runViewer(cmd, args) + return runViewer(cmd, args, rendererType) } cmd.Flags().BoolVar(&staticMode, "static", false, "Display image and exit without interactive mode") + cmd.Flags().StringVarP(&rendererType, "renderer", "r", "kitty", "Renderer protocol (kitty, tmux)") cmd.Version = version return cmd } -func runStatic(args []string) error { +func createRenderer(rendererType string) (usecase.RendererPort, error) { + switch rendererType { + case "kitty": + return renderer.NewKittyRenderer(), nil + case "tmux": + return renderer.NewTmuxRenderer(), nil + default: + return nil, fmt.Errorf("unknown renderer type %q: supported values are kitty, tmux", rendererType) + } +} + +func runStatic(args []string, rendererType string) error { imagePath := args[0] // Load image @@ -88,12 +103,15 @@ func runStatic(args []string) error { vp.SetImageSize(img.Width, img.Height) // Upload and display - kittyRenderer := renderer.NewKittyRenderer() - if err := kittyRenderer.Upload(img); err != nil { + imgRenderer, err := createRenderer(rendererType) + if err != nil { + return err + } + if err := imgRenderer.Upload(img); err != nil { return fmt.Errorf("uploading image: %w", err) } - output, err := kittyRenderer.Display(vp) + output, err := imgRenderer.Display(vp) if err != nil { return fmt.Errorf("displaying image: %w", err) } @@ -119,7 +137,7 @@ func runStatic(args []string) error { return nil } -func runViewer(_ *cobra.Command, args []string) error { +func runViewer(_ *cobra.Command, args []string, rendererType string) error { imagePath := args[0] // Load configuration @@ -138,13 +156,21 @@ func runViewer(_ *cobra.Command, args []string) error { } // Create renderer and use cases - kittyRenderer := renderer.NewKittyRenderer() + imgRenderer, err := createRenderer(rendererType) + if err != nil { + return err + } vpCtrl := usecase.NewViewportControlUseCase() - renderFrameUC := usecase.NewRenderFrameUseCase(kittyRenderer, cfg.Minimap) + renderFrameUC := usecase.NewRenderFrameUseCase(imgRenderer, cfg.Minimap) // Create TUI model model := tui.NewModel(img, cfg, vpCtrl, renderFrameUC) + // Wire up resize callback for renderers that need it (e.g. tmux pane offset) + if tmuxR, ok := imgRenderer.(*renderer.TmuxRenderer); ok { + model.SetOnResize(tmuxR.RefreshPaneOffset) + } + // Run Bubbletea program p := tea.NewProgram( model, @@ -156,12 +182,12 @@ func runViewer(_ *cobra.Command, args []string) error { return fmt.Errorf("running viewer: %w", err) } - // Clean up Kitty graphics — always attempt both cleanups + // Clean up renderer — always attempt both cleanups var errs []error - if err := kittyRenderer.ClearMinimap(); err != nil { + if err := imgRenderer.ClearMinimap(); err != nil { errs = append(errs, fmt.Errorf("clearing minimap: %w", err)) } - if err := kittyRenderer.Clear(); err != nil { + if err := imgRenderer.Clear(); err != nil { errs = append(errs, fmt.Errorf("clearing renderer: %w", err)) } diff --git a/internal/adapter/renderer/kitty_renderer.go b/internal/adapter/renderer/kitty_renderer.go index 54529c0..29f0286 100644 --- a/internal/adapter/renderer/kitty_renderer.go +++ b/internal/adapter/renderer/kitty_renderer.go @@ -119,7 +119,12 @@ func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, c fmt.Printf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.minimapID) } r.minimapID = atomic.AddUint32(&imageIDCounter, 1) + r.prepareMinimapBase(img, cols, rows, cellAspect) + return nil +} +// prepareMinimapBase downscales the image and prepares reusable buffers. +func (r *KittyRenderer) prepareMinimapBase(img *domain.ImageEntity, cols, rows int, cellAspect float64) { // Calculate pixel dimensions for the minimap. // Use a base cell width and derive height from aspect ratio. const baseCellW = 8.0 @@ -158,8 +163,6 @@ func (r *KittyRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, c r.prevIndicator = [4]int{} r.prevBorderColor = "" r.prevCached = false - - return nil } // DisplayMinimap composites the viewport indicator onto the minimap base, diff --git a/internal/adapter/renderer/tmux_renderer.go b/internal/adapter/renderer/tmux_renderer.go new file mode 100644 index 0000000..273508c --- /dev/null +++ b/internal/adapter/renderer/tmux_renderer.go @@ -0,0 +1,196 @@ +package renderer + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + "sync/atomic" + + "github.com/flexphere/gaze/internal/domain" +) + +// TmuxRenderer wraps KittyRenderer, adding DCS passthrough so that +// Kitty graphics escape sequences reach the outer terminal through tmux. +// Cursor coordinates are offset by the pane position so that images +// render inside the correct pane. +type TmuxRenderer struct { + inner *KittyRenderer + paneTop int + paneLeft int +} + +// NewTmuxRenderer creates a TmuxRenderer wrapping a KittyRenderer. +func NewTmuxRenderer() *TmuxRenderer { + top, left := queryTmuxPaneOffset() + return &TmuxRenderer{inner: NewKittyRenderer(), paneTop: top, paneLeft: left} +} + +// RefreshPaneOffset re-queries the tmux pane position. +// Call this on window resize to stay in sync with pane layout changes. +func (r *TmuxRenderer) RefreshPaneOffset() { + r.paneTop, r.paneLeft = queryTmuxPaneOffset() +} + +// queryTmuxPaneOffset returns the current pane's top-left corner offset +// within the outer terminal by querying tmux. +func queryTmuxPaneOffset() (top, left int) { + out, err := exec.Command("tmux", "display-message", "-p", "#{pane_top} #{pane_left}").Output() + if err != nil { + return 0, 0 + } + parts := strings.Fields(strings.TrimSpace(string(out))) + if len(parts) >= 2 { + top, _ = strconv.Atoi(parts[0]) + left, _ = strconv.Atoi(parts[1]) + } + return top, left +} + +// Upload encodes and transmits the image with tmux DCS passthrough wrapping. +func (r *TmuxRenderer) Upload(img *domain.ImageEntity) error { + r.inner.imageID = atomic.AddUint32(&imageIDCounter, 1) + r.inner.imgW = img.Width + r.inner.imgH = img.Height + + seq, err := buildUploadSequence(r.inner.imageID, img.Source) + if err != nil { + return err + } + fmt.Print(r.wrapAllKittySequences(seq)) + return nil +} + +// Display returns the Kitty placement sequence wrapped for tmux. +func (r *TmuxRenderer) Display(vp *domain.Viewport) (string, error) { + seq, err := r.inner.Display(vp) + if err != nil { + return "", err + } + return r.wrapAllKittySequences(seq), nil +} + +// Clear removes the image from the terminal via tmux passthrough. +func (r *TmuxRenderer) Clear() error { + if r.inner.imageID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.imageID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + return nil +} + +// UploadMinimap creates a downscaled thumbnail, deleting the old one via tmux passthrough. +func (r *TmuxRenderer) UploadMinimap(img *domain.ImageEntity, cols, rows int, cellAspect float64) error { + if r.inner.minimapID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.minimapID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + r.inner.minimapID = atomic.AddUint32(&imageIDCounter, 1) + r.inner.prepareMinimapBase(img, cols, rows, cellAspect) + return nil +} + +// DisplayMinimap returns the minimap sequence wrapped for tmux. +func (r *TmuxRenderer) DisplayMinimap(vp *domain.Viewport, cols, rows int, borderColor string) (string, error) { + seq, err := r.inner.DisplayMinimap(vp, cols, rows, borderColor) + if err != nil { + return "", err + } + return r.wrapAllKittySequences(seq), nil +} + +// ClearMinimap removes the minimap from the terminal via tmux passthrough. +func (r *TmuxRenderer) ClearMinimap() error { + if r.inner.minimapID > 0 { + seq := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", r.inner.minimapID) + fmt.Print(r.wrapAllKittySequences(seq)) + } + r.inner.prevCached = false + return nil +} + +// wrapAllKittySequences finds all Kitty APC sequences (\x1b_G...\x1b\\) in +// the input string and wraps each one in DCS passthrough. A CSI cursor +// positioning sequence (\x1b[...H) immediately preceding a Kitty APC is +// included inside the same passthrough — with row/col adjusted by the +// pane offset — so that the image renders inside the correct tmux pane. +func (r *TmuxRenderer) wrapAllKittySequences(s string) string { + var out strings.Builder + out.Grow(len(s) * 2) + + for i := 0; i < len(s); { + // Look for Kitty APC start: \x1b_G + if i+2 < len(s) && s[i] == 0x1b && s[i+1] == '_' && s[i+2] == 'G' { + // Find the ST terminator: \x1b\\ + end := strings.Index(s[i+3:], "\x1b\\") + if end >= 0 { + seqEnd := i + 3 + end + 2 // include \x1b\\ + + // Check if a CSI cursor position (\x1b[...H) was just written + // to out and pull it into the passthrough with pane offset applied. + prefix := r.extractTrailingCursorMove(&out) + + kittySeq := s[i:seqEnd] + escaped := strings.ReplaceAll(prefix+kittySeq, "\x1b", "\x1b\x1b") + out.WriteString("\x1bPtmux;") + out.WriteString(escaped) + out.WriteString("\x1b\\") + i = seqEnd + continue + } + } + out.WriteByte(s[i]) + i++ + } + + return out.String() +} + +// extractTrailingCursorMove checks if the builder ends with a CSI cursor +// position sequence (\x1b[;H or \x1b[H). If found, it removes +// that sequence from the builder and returns a new cursor move with +// row and col adjusted by the pane offset. +func (r *TmuxRenderer) extractTrailingCursorMove(b *strings.Builder) string { + s := b.String() + + // Scan backwards for \x1b[...H pattern + if len(s) < 2 { + return "" + } + if s[len(s)-1] != 'H' { + return "" + } + + // Walk backwards from the 'H' to find ESC [ + j := len(s) - 2 + for j >= 0 && ((s[j] >= '0' && s[j] <= '9') || s[j] == ';') { + j-- + } + if j < 1 || s[j] != '[' || s[j-1] != 0x1b { + return "" + } + + csiStart := j - 1 + params := s[j+1 : len(s)-1] // between '[' and 'H' + + // Parse row;col (default 1;1 for bare \x1b[H) + row, col := 1, 1 + if params != "" { + parts := strings.SplitN(params, ";", 2) + if v, err := strconv.Atoi(parts[0]); err == nil { + row = v + } + if len(parts) == 2 { + if v, err := strconv.Atoi(parts[1]); err == nil { + col = v + } + } + } + + // Rebuild the builder without the cursor sequence + b.Reset() + b.WriteString(s[:csiStart]) + + // Return adjusted cursor move + return fmt.Sprintf("\x1b[%d;%dH", row+r.paneTop, col+r.paneLeft) +} diff --git a/internal/adapter/tui/model.go b/internal/adapter/tui/model.go index 4b5b7fb..b75cfff 100644 --- a/internal/adapter/tui/model.go +++ b/internal/adapter/tui/model.go @@ -24,6 +24,9 @@ type Model struct { dragStartTermY int dragStartOffX float64 dragStartOffY float64 + + // Optional callback invoked on window resize (e.g. to refresh tmux pane offset) + onResize func() } // NewModel creates a new TUI model. @@ -47,3 +50,8 @@ func NewModel( renderFrame: renderFrame, } } + +// SetOnResize sets a callback that is invoked on window resize events. +func (m *Model) SetOnResize(fn func()) { + m.onResize = fn +} diff --git a/internal/adapter/tui/update.go b/internal/adapter/tui/update.go index 72427ae..8b8a332 100644 --- a/internal/adapter/tui/update.go +++ b/internal/adapter/tui/update.go @@ -14,6 +14,9 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + if m.onResize != nil { + m.onResize() + } cellW, cellH := QueryCellSize() m.viewport.SetCellAspectRatio(cellH / cellW) m.viewport.SetTerminalSize(msg.Width, msg.Height-1) // -1 for status bar From 3e4ec76f1c30e6e3f008ccbc4f92e38aacf01b8c Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:00:33 +0900 Subject: [PATCH 2/7] test: add TmuxRenderer unit tests Cover wrapAllKittySequences and extractTrailingCursorMove with table-driven tests including pane offset, multi-chunk uploads, and mixed cursor+placement sequences. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../adapter/renderer/tmux_renderer_test.go | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 internal/adapter/renderer/tmux_renderer_test.go diff --git a/internal/adapter/renderer/tmux_renderer_test.go b/internal/adapter/renderer/tmux_renderer_test.go new file mode 100644 index 0000000..b485b34 --- /dev/null +++ b/internal/adapter/renderer/tmux_renderer_test.go @@ -0,0 +1,220 @@ +package renderer + +import ( + "fmt" + "strings" + "testing" +) + +func TestWrapAllKittySequences_SingleSequence(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := "\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + + // The Kitty sequence should be wrapped in DCS passthrough with ESC doubled + escaped := strings.ReplaceAll(input, "\x1b", "\x1b\x1b") + want := "\x1bPtmux;" + escaped + "\x1b\\" + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_MultipleChunks(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + chunk1 := "\x1b_Gi=1,f=100,m=1;AAAA\x1b\\" + chunk2 := "\x1b_Gi=1,m=0;BBBB\x1b\\" + input := chunk1 + chunk2 + + got := r.wrapAllKittySequences(input) + + // Each chunk should be independently wrapped + wrap1 := "\x1bPtmux;" + strings.ReplaceAll(chunk1, "\x1b", "\x1b\x1b") + "\x1b\\" + wrap2 := "\x1bPtmux;" + strings.ReplaceAll(chunk2, "\x1b", "\x1b\x1b") + "\x1b\\" + want := wrap1 + wrap2 + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_PreserveNonKittySequences(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + // \x1b[H (cursor home) should pass through unchanged + input := "\x1b[2J" // clear screen — no Kitty sequence + got := r.wrapAllKittySequences(input) + + if got != input { + t.Errorf("non-Kitty sequence modified: got %q, want %q", got, input) + } +} + +func TestWrapAllKittySequences_CursorMoveBeforeKitty(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + // Cursor move immediately before Kitty sequence should be pulled into passthrough + input := "\x1b[H\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + + // \x1b[H with no offset (paneTop=0, paneLeft=0) becomes \x1b[1;1H + cursorMove := "\x1b[1;1H" + kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" + escaped := strings.ReplaceAll(cursorMove+kittySeq, "\x1b", "\x1b\x1b") + want := "\x1bPtmux;" + escaped + "\x1b\\" + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_CursorMoveWithPaneOffset(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: 5, paneLeft: 40} + + input := "\x1b[10;20H\x1b_Ga=p,i=1,q=2\x1b\\" + got := r.wrapAllKittySequences(input) + + // Row 10+5=15, Col 20+40=60 + cursorMove := "\x1b[15;60H" + kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" + escaped := strings.ReplaceAll(cursorMove+kittySeq, "\x1b", "\x1b\x1b") + want := "\x1bPtmux;" + escaped + "\x1b\\" + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_EmptyString(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + got := r.wrapAllKittySequences("") + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestWrapAllKittySequences_PlainText(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := "hello world" + got := r.wrapAllKittySequences(input) + if got != input { + t.Errorf("got %q, want %q", got, input) + } +} + +func TestExtractTrailingCursorMove(t *testing.T) { + tests := []struct { + name string + paneTop int + paneLeft int + builderText string + wantMove string + wantRemain string + }{ + { + name: "bare cursor home", + builderText: "\x1b[H", + wantMove: "\x1b[1;1H", + wantRemain: "", + }, + { + name: "row and col", + builderText: "\x1b[10;20H", + wantMove: "\x1b[10;20H", + wantRemain: "", + }, + { + name: "with pane offset", + paneTop: 3, + paneLeft: 50, + builderText: "\x1b[10;20H", + wantMove: "\x1b[13;70H", + wantRemain: "", + }, + { + name: "prefix text preserved", + builderText: "some text\x1b[5;10H", + wantMove: "\x1b[5;10H", + wantRemain: "some text", + }, + { + name: "no cursor move", + builderText: "just text", + wantMove: "", + wantRemain: "just text", + }, + { + name: "empty builder", + builderText: "", + wantMove: "", + wantRemain: "", + }, + { + name: "row only", + builderText: "\x1b[5H", + wantMove: "\x1b[5;1H", + wantRemain: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: tt.paneTop, paneLeft: tt.paneLeft} + var b strings.Builder + b.WriteString(tt.builderText) + + gotMove := r.extractTrailingCursorMove(&b) + gotRemain := b.String() + + if gotMove != tt.wantMove { + t.Errorf("move: got %q, want %q", gotMove, tt.wantMove) + } + if gotRemain != tt.wantRemain { + t.Errorf("remain: got %q, want %q", gotRemain, tt.wantRemain) + } + }) + } +} + +func TestWrapAllKittySequences_MixedUploadAndPlacement(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: 2, paneLeft: 10} + + // Simulate: upload chunks + cursor move + placement (like a real frame) + upload := "\x1b_Gi=1,f=100,a=t,t=d,q=2,m=0;DATA\x1b\\" + cursorAndPlace := "\x1b[5;3H\x1b_Ga=p,i=1,p=1,c=80,r=24,q=2\x1b\\" + input := upload + cursorAndPlace + + got := r.wrapAllKittySequences(input) + + // Upload wrapped without cursor move + wrapUpload := "\x1bPtmux;" + strings.ReplaceAll(upload, "\x1b", "\x1b\x1b") + "\x1b\\" + + // Placement wrapped with offset cursor move: row 5+2=7, col 3+10=13 + cursorMove := "\x1b[7;13H" + placeSeq := "\x1b_Ga=p,i=1,p=1,c=80,r=24,q=2\x1b\\" + wrapPlace := "\x1bPtmux;" + strings.ReplaceAll(cursorMove+placeSeq, "\x1b", "\x1b\x1b") + "\x1b\\" + + want := wrapUpload + wrapPlace + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestWrapAllKittySequences_DeleteCommand(t *testing.T) { + r := &TmuxRenderer{inner: NewKittyRenderer()} + + input := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", 42) + got := r.wrapAllKittySequences(input) + + escaped := strings.ReplaceAll(input, "\x1b", "\x1b\x1b") + want := "\x1bPtmux;" + escaped + "\x1b\\" + + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} From e6cf2ec42915ba05c9b3032f8c0c9390e0414153 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:00:38 +0900 Subject: [PATCH 3/7] docs: add tmux support to README Add tmux to the supported terminals table and document the -r tmux flag usage with the allow-passthrough prerequisite. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index e807465..da1cb74 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ A terminal image viewer with zoom and pan support, powered by the [Kitty Graphic ## Supported Terminals -| Terminal | Status | -|----------|--------| -| [Kitty](https://sw.kovidgoyal.net/kitty/) | Supported | -| [Ghostty](https://ghostty.org/) | Supported | -| [WezTerm](https://wezfurlong.org/wezterm/) | Supported | +| Terminal | Support | +| ------------------------------------------ | ------------------------ | +| [Kitty](https://sw.kovidgoyal.net/kitty/) | Supported | +| [Ghostty](https://ghostty.org/) | Supported | +| [WezTerm](https://wezfurlong.org/wezterm/) | Supported | +| tmux (with `allow-passthrough on`) | Experimental (`-r tmux`) | > Other terminals supporting the Kitty Graphics Protocol should also work. @@ -47,31 +48,35 @@ gaze photo.png gaze screenshot.jpg gaze animation.gif gaze image.webp + +# Use inside tmux (requires: tmux set -g allow-passthrough on) +gaze -r tmux photo.png +gaze --renderer tmux photo.png ``` ## Controls ### Keyboard -| Key | Action | -|-----|--------| -| `h` / `Left` | Pan left | -| `j` / `Down` | Pan down | -| `k` / `Up` | Pan up | -| `l` / `Right` | Pan right | -| `+` / `=` | Zoom in | -| `-` / `_` | Zoom out | -| `f` | Fit to window | -| `0` / `r` | Reset view | -| `m` | Toggle minimap | -| `q` / `Esc` / `Ctrl+C` | Quit | +| Key | Action | +| ---------------------- | -------------- | +| `h` / `Left` | Pan left | +| `j` / `Down` | Pan down | +| `k` / `Up` | Pan up | +| `l` / `Right` | Pan right | +| `+` / `=` | Zoom in | +| `-` / `_` | Zoom out | +| `f` | Fit to window | +| `0` / `r` | Reset view | +| `m` | Toggle minimap | +| `q` / `Esc` / `Ctrl+C` | Quit | ### Mouse -| Action | Effect | -|--------|--------| -| Drag | Pan the image | -| Scroll up | Zoom in (at cursor position) | +| Action | Effect | +| ----------- | ----------------------------- | +| Drag | Pan the image | +| Scroll up | Zoom in (at cursor position) | | Scroll down | Zoom out (at cursor position) | ## Configuration @@ -120,7 +125,7 @@ internal/ adapter/ tui/ Bubbletea TUI (Model/Update/View, KeyMap, StatusBar) config/ TOML configuration loader - renderer/ Kitty Graphics Protocol implementation + renderer/ Kitty Graphics / tmux passthrough protocol implementations infrastructure/ filesystem/ Image file loading (PNG, JPEG, GIF, BMP, TIFF, WebP) ``` From 2b5819865df1bedc0af25cc90eae90aab9b76e51 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:18:11 +0900 Subject: [PATCH 4/7] fix: add DECSC/DECRC cursor save/restore in tmux passthrough Wrap Kitty sequences with cursor save (\x1b7) and restore (\x1b8) inside the DCS passthrough so the outer terminal cursor position is restored after image drawing. Also reduce Grow pre-allocation from 2x to 1x. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapter/renderer/tmux_renderer.go | 16 ++++++--- .../adapter/renderer/tmux_renderer_test.go | 34 ++++++++----------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/internal/adapter/renderer/tmux_renderer.go b/internal/adapter/renderer/tmux_renderer.go index 273508c..f676818 100644 --- a/internal/adapter/renderer/tmux_renderer.go +++ b/internal/adapter/renderer/tmux_renderer.go @@ -2,6 +2,7 @@ package renderer import ( "fmt" + "os" "os/exec" "strconv" "strings" @@ -21,9 +22,13 @@ type TmuxRenderer struct { } // NewTmuxRenderer creates a TmuxRenderer wrapping a KittyRenderer. -func NewTmuxRenderer() *TmuxRenderer { +// Returns an error if not running inside a tmux session. +func NewTmuxRenderer() (*TmuxRenderer, error) { + if os.Getenv("TMUX") == "" { + return nil, fmt.Errorf("tmux renderer requires a tmux session (TMUX environment variable not set)") + } top, left := queryTmuxPaneOffset() - return &TmuxRenderer{inner: NewKittyRenderer(), paneTop: top, paneLeft: left} + return &TmuxRenderer{inner: NewKittyRenderer(), paneTop: top, paneLeft: left}, nil } // RefreshPaneOffset re-queries the tmux pane position. @@ -116,7 +121,7 @@ func (r *TmuxRenderer) ClearMinimap() error { // pane offset — so that the image renders inside the correct tmux pane. func (r *TmuxRenderer) wrapAllKittySequences(s string) string { var out strings.Builder - out.Grow(len(s) * 2) + out.Grow(len(s)) for i := 0; i < len(s); { // Look for Kitty APC start: \x1b_G @@ -130,8 +135,11 @@ func (r *TmuxRenderer) wrapAllKittySequences(s string) string { // to out and pull it into the passthrough with pane offset applied. prefix := r.extractTrailingCursorMove(&out) + // Wrap cursor move + Kitty sequence with cursor save/restore + // so that the outer terminal cursor is restored after drawing. kittySeq := s[i:seqEnd] - escaped := strings.ReplaceAll(prefix+kittySeq, "\x1b", "\x1b\x1b") + content := "\x1b7" + prefix + kittySeq + "\x1b8" // DECSC ... DECRC + escaped := strings.ReplaceAll(content, "\x1b", "\x1b\x1b") out.WriteString("\x1bPtmux;") out.WriteString(escaped) out.WriteString("\x1b\\") diff --git a/internal/adapter/renderer/tmux_renderer_test.go b/internal/adapter/renderer/tmux_renderer_test.go index b485b34..069a22f 100644 --- a/internal/adapter/renderer/tmux_renderer_test.go +++ b/internal/adapter/renderer/tmux_renderer_test.go @@ -6,15 +6,20 @@ import ( "testing" ) +// wrapExpected builds the expected DCS passthrough for a Kitty sequence, +// including DECSC/DECRC cursor save/restore. +func wrapExpected(content string) string { + withSaveRestore := "\x1b7" + content + "\x1b8" + escaped := strings.ReplaceAll(withSaveRestore, "\x1b", "\x1b\x1b") + return "\x1bPtmux;" + escaped + "\x1b\\" +} + func TestWrapAllKittySequences_SingleSequence(t *testing.T) { r := &TmuxRenderer{inner: NewKittyRenderer()} input := "\x1b_Ga=p,i=1,q=2\x1b\\" got := r.wrapAllKittySequences(input) - - // The Kitty sequence should be wrapped in DCS passthrough with ESC doubled - escaped := strings.ReplaceAll(input, "\x1b", "\x1b\x1b") - want := "\x1bPtmux;" + escaped + "\x1b\\" + want := wrapExpected(input) if got != want { t.Errorf("got %q, want %q", got, want) @@ -29,11 +34,7 @@ func TestWrapAllKittySequences_MultipleChunks(t *testing.T) { input := chunk1 + chunk2 got := r.wrapAllKittySequences(input) - - // Each chunk should be independently wrapped - wrap1 := "\x1bPtmux;" + strings.ReplaceAll(chunk1, "\x1b", "\x1b\x1b") + "\x1b\\" - wrap2 := "\x1bPtmux;" + strings.ReplaceAll(chunk2, "\x1b", "\x1b\x1b") + "\x1b\\" - want := wrap1 + wrap2 + want := wrapExpected(chunk1) + wrapExpected(chunk2) if got != want { t.Errorf("got %q, want %q", got, want) @@ -43,7 +44,6 @@ func TestWrapAllKittySequences_MultipleChunks(t *testing.T) { func TestWrapAllKittySequences_PreserveNonKittySequences(t *testing.T) { r := &TmuxRenderer{inner: NewKittyRenderer()} - // \x1b[H (cursor home) should pass through unchanged input := "\x1b[2J" // clear screen — no Kitty sequence got := r.wrapAllKittySequences(input) @@ -62,8 +62,7 @@ func TestWrapAllKittySequences_CursorMoveBeforeKitty(t *testing.T) { // \x1b[H with no offset (paneTop=0, paneLeft=0) becomes \x1b[1;1H cursorMove := "\x1b[1;1H" kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" - escaped := strings.ReplaceAll(cursorMove+kittySeq, "\x1b", "\x1b\x1b") - want := "\x1bPtmux;" + escaped + "\x1b\\" + want := wrapExpected(cursorMove + kittySeq) if got != want { t.Errorf("got %q, want %q", got, want) @@ -79,8 +78,7 @@ func TestWrapAllKittySequences_CursorMoveWithPaneOffset(t *testing.T) { // Row 10+5=15, Col 20+40=60 cursorMove := "\x1b[15;60H" kittySeq := "\x1b_Ga=p,i=1,q=2\x1b\\" - escaped := strings.ReplaceAll(cursorMove+kittySeq, "\x1b", "\x1b\x1b") - want := "\x1bPtmux;" + escaped + "\x1b\\" + want := wrapExpected(cursorMove + kittySeq) if got != want { t.Errorf("got %q, want %q", got, want) @@ -191,12 +189,12 @@ func TestWrapAllKittySequences_MixedUploadAndPlacement(t *testing.T) { got := r.wrapAllKittySequences(input) // Upload wrapped without cursor move - wrapUpload := "\x1bPtmux;" + strings.ReplaceAll(upload, "\x1b", "\x1b\x1b") + "\x1b\\" + wrapUpload := wrapExpected(upload) // Placement wrapped with offset cursor move: row 5+2=7, col 3+10=13 cursorMove := "\x1b[7;13H" placeSeq := "\x1b_Ga=p,i=1,p=1,c=80,r=24,q=2\x1b\\" - wrapPlace := "\x1bPtmux;" + strings.ReplaceAll(cursorMove+placeSeq, "\x1b", "\x1b\x1b") + "\x1b\\" + wrapPlace := wrapExpected(cursorMove + placeSeq) want := wrapUpload + wrapPlace @@ -210,9 +208,7 @@ func TestWrapAllKittySequences_DeleteCommand(t *testing.T) { input := fmt.Sprintf("\x1b_Ga=d,d=i,i=%d\x1b\\", 42) got := r.wrapAllKittySequences(input) - - escaped := strings.ReplaceAll(input, "\x1b", "\x1b\x1b") - want := "\x1bPtmux;" + escaped + "\x1b\\" + want := wrapExpected(input) if got != want { t.Errorf("got %q, want %q", got, want) From c80e4db9f251fd1007f48b4bc0c7260cf314b02c Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:18:17 +0900 Subject: [PATCH 5/7] fix: validate tmux session in NewTmuxRenderer Return an error from NewTmuxRenderer when the TMUX environment variable is not set, giving users a clear message instead of silently falling back to offset (0,0). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/gaze/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/gaze/main.go b/cmd/gaze/main.go index 12ee8bb..3953597 100644 --- a/cmd/gaze/main.go +++ b/cmd/gaze/main.go @@ -59,7 +59,7 @@ func createRenderer(rendererType string) (usecase.RendererPort, error) { case "kitty": return renderer.NewKittyRenderer(), nil case "tmux": - return renderer.NewTmuxRenderer(), nil + return renderer.NewTmuxRenderer() default: return nil, fmt.Errorf("unknown renderer type %q: supported values are kitty, tmux", rendererType) } From 15da73e40af3cf9756e8b67e2ef52e7a7c7871b2 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:28:18 +0900 Subject: [PATCH 6/7] fix: propagate error from queryTmuxPaneOffset Make queryTmuxPaneOffset return an error instead of silently falling back to (0,0). NewTmuxRenderer propagates the error, while RefreshPaneOffset retains the previous offset on failure to avoid disrupting rendering mid-frame. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/adapter/renderer/tmux_renderer.go | 90 +++++++++++++--------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/internal/adapter/renderer/tmux_renderer.go b/internal/adapter/renderer/tmux_renderer.go index f676818..7053c1c 100644 --- a/internal/adapter/renderer/tmux_renderer.go +++ b/internal/adapter/renderer/tmux_renderer.go @@ -27,29 +27,44 @@ func NewTmuxRenderer() (*TmuxRenderer, error) { if os.Getenv("TMUX") == "" { return nil, fmt.Errorf("tmux renderer requires a tmux session (TMUX environment variable not set)") } - top, left := queryTmuxPaneOffset() + top, left, err := queryTmuxPaneOffset() + if err != nil { + return nil, fmt.Errorf("querying tmux pane offset: %w", err) + } return &TmuxRenderer{inner: NewKittyRenderer(), paneTop: top, paneLeft: left}, nil } // RefreshPaneOffset re-queries the tmux pane position. -// Call this on window resize to stay in sync with pane layout changes. +// On failure, the previous offset is retained to avoid disrupting rendering. func (r *TmuxRenderer) RefreshPaneOffset() { - r.paneTop, r.paneLeft = queryTmuxPaneOffset() + top, left, err := queryTmuxPaneOffset() + if err != nil { + return // keep previous values + } + r.paneTop = top + r.paneLeft = left } // queryTmuxPaneOffset returns the current pane's top-left corner offset // within the outer terminal by querying tmux. -func queryTmuxPaneOffset() (top, left int) { +func queryTmuxPaneOffset() (top, left int, err error) { out, err := exec.Command("tmux", "display-message", "-p", "#{pane_top} #{pane_left}").Output() if err != nil { - return 0, 0 + return 0, 0, fmt.Errorf("running tmux display-message: %w", err) } parts := strings.Fields(strings.TrimSpace(string(out))) - if len(parts) >= 2 { - top, _ = strconv.Atoi(parts[0]) - left, _ = strconv.Atoi(parts[1]) + if len(parts) < 2 { + return 0, 0, fmt.Errorf("unexpected tmux output: %q", string(out)) + } + top, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("parsing pane_top %q: %w", parts[0], err) + } + left, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("parsing pane_left %q: %w", parts[1], err) } - return top, left + return top, left, nil } // Upload encodes and transmits the image with tmux DCS passthrough wrapping. @@ -119,10 +134,15 @@ func (r *TmuxRenderer) ClearMinimap() error { // positioning sequence (\x1b[...H) immediately preceding a Kitty APC is // included inside the same passthrough — with row/col adjusted by the // pane offset — so that the image renders inside the correct tmux pane. +// +// Uses bulk copies from the input string to avoid re-copying the output +// buffer when extracting cursor moves. func (r *TmuxRenderer) wrapAllKittySequences(s string) string { var out strings.Builder out.Grow(len(s)) + written := 0 // index in s up to which we've flushed to out + for i := 0; i < len(s); { // Look for Kitty APC start: \x1b_G if i+2 < len(s) && s[i] == 0x1b && s[i+1] == '_' && s[i+2] == 'G' { @@ -131,9 +151,11 @@ func (r *TmuxRenderer) wrapAllKittySequences(s string) string { if end >= 0 { seqEnd := i + 3 + end + 2 // include \x1b\\ - // Check if a CSI cursor position (\x1b[...H) was just written - // to out and pull it into the passthrough with pane offset applied. - prefix := r.extractTrailingCursorMove(&out) + // Scan backwards in the pending input for a cursor move + prefix, flushEnd := r.findCursorMoveInPending(s[written:i]) + + // Flush pending bytes up to (but not including) the cursor move + out.WriteString(s[written : written+flushEnd]) // Wrap cursor move + Kitty sequence with cursor save/restore // so that the outer terminal cursor is restored after drawing. @@ -143,43 +165,42 @@ func (r *TmuxRenderer) wrapAllKittySequences(s string) string { out.WriteString("\x1bPtmux;") out.WriteString(escaped) out.WriteString("\x1b\\") + + written = seqEnd i = seqEnd continue } } - out.WriteByte(s[i]) i++ } + // Flush remaining + out.WriteString(s[written:]) + return out.String() } -// extractTrailingCursorMove checks if the builder ends with a CSI cursor -// position sequence (\x1b[;H or \x1b[H). If found, it removes -// that sequence from the builder and returns a new cursor move with -// row and col adjusted by the pane offset. -func (r *TmuxRenderer) extractTrailingCursorMove(b *strings.Builder) string { - s := b.String() - - // Scan backwards for \x1b[...H pattern - if len(s) < 2 { - return "" - } - if s[len(s)-1] != 'H' { - return "" +// findCursorMoveInPending scans the pending (not yet flushed) portion of the +// input for a trailing CSI cursor position sequence (\x1b[;H). +// If found, returns the adjusted cursor move string and the flush boundary +// (number of bytes to flush before the cursor move). If not found, returns +// ("", len(pending)) so the caller flushes everything. +func (r *TmuxRenderer) findCursorMoveInPending(pending string) (cursorMove string, flushEnd int) { + if len(pending) < 2 || pending[len(pending)-1] != 'H' { + return "", len(pending) } // Walk backwards from the 'H' to find ESC [ - j := len(s) - 2 - for j >= 0 && ((s[j] >= '0' && s[j] <= '9') || s[j] == ';') { + j := len(pending) - 2 + for j >= 0 && ((pending[j] >= '0' && pending[j] <= '9') || pending[j] == ';') { j-- } - if j < 1 || s[j] != '[' || s[j-1] != 0x1b { - return "" + if j < 1 || pending[j] != '[' || pending[j-1] != 0x1b { + return "", len(pending) } csiStart := j - 1 - params := s[j+1 : len(s)-1] // between '[' and 'H' + params := pending[j+1 : len(pending)-1] // between '[' and 'H' // Parse row;col (default 1;1 for bare \x1b[H) row, col := 1, 1 @@ -195,10 +216,5 @@ func (r *TmuxRenderer) extractTrailingCursorMove(b *strings.Builder) string { } } - // Rebuild the builder without the cursor sequence - b.Reset() - b.WriteString(s[:csiStart]) - - // Return adjusted cursor move - return fmt.Sprintf("\x1b[%d;%dH", row+r.paneTop, col+r.paneLeft) + return fmt.Sprintf("\x1b[%d;%dH", row+r.paneTop, col+r.paneLeft), csiStart } From 47cbefba6357bfb97baab0bc135b0750a310f18b Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Sat, 28 Mar 2026 01:28:24 +0900 Subject: [PATCH 7/7] refactor: use input-side scanning for cursor move extraction Replace extractTrailingCursorMove (which copied the entire output buffer) with findCursorMoveInPending that scans the input string directly. Also switch wrapAllKittySequences to bulk copies instead of byte-by-byte writes, reducing per-frame overhead for large upload sequences. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../adapter/renderer/tmux_renderer_test.go | 83 +++++++++---------- 1 file changed, 40 insertions(+), 43 deletions(-) diff --git a/internal/adapter/renderer/tmux_renderer_test.go b/internal/adapter/renderer/tmux_renderer_test.go index 069a22f..a69808d 100644 --- a/internal/adapter/renderer/tmux_renderer_test.go +++ b/internal/adapter/renderer/tmux_renderer_test.go @@ -104,75 +104,72 @@ func TestWrapAllKittySequences_PlainText(t *testing.T) { } } -func TestExtractTrailingCursorMove(t *testing.T) { +func TestFindCursorMoveInPending(t *testing.T) { tests := []struct { - name string - paneTop int - paneLeft int - builderText string - wantMove string - wantRemain string + name string + paneTop int + paneLeft int + pending string + wantMove string + wantFlush int }{ { - name: "bare cursor home", - builderText: "\x1b[H", - wantMove: "\x1b[1;1H", - wantRemain: "", + name: "bare cursor home", + pending: "\x1b[H", + wantMove: "\x1b[1;1H", + wantFlush: 0, }, { - name: "row and col", - builderText: "\x1b[10;20H", - wantMove: "\x1b[10;20H", - wantRemain: "", + name: "row and col", + pending: "\x1b[10;20H", + wantMove: "\x1b[10;20H", + wantFlush: 0, }, { - name: "with pane offset", - paneTop: 3, - paneLeft: 50, - builderText: "\x1b[10;20H", - wantMove: "\x1b[13;70H", - wantRemain: "", + name: "with pane offset", + paneTop: 3, + paneLeft: 50, + pending: "\x1b[10;20H", + wantMove: "\x1b[13;70H", + wantFlush: 0, }, { - name: "prefix text preserved", - builderText: "some text\x1b[5;10H", - wantMove: "\x1b[5;10H", - wantRemain: "some text", + name: "prefix text preserved", + pending: "some text\x1b[5;10H", + wantMove: "\x1b[5;10H", + wantFlush: 9, // len("some text") }, { - name: "no cursor move", - builderText: "just text", - wantMove: "", - wantRemain: "just text", + name: "no cursor move", + pending: "just text", + wantMove: "", + wantFlush: 9, // len("just text") }, { - name: "empty builder", - builderText: "", - wantMove: "", - wantRemain: "", + name: "empty pending", + pending: "", + wantMove: "", + wantFlush: 0, }, { - name: "row only", - builderText: "\x1b[5H", - wantMove: "\x1b[5;1H", - wantRemain: "", + name: "row only", + pending: "\x1b[5H", + wantMove: "\x1b[5;1H", + wantFlush: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &TmuxRenderer{inner: NewKittyRenderer(), paneTop: tt.paneTop, paneLeft: tt.paneLeft} - var b strings.Builder - b.WriteString(tt.builderText) - gotMove := r.extractTrailingCursorMove(&b) - gotRemain := b.String() + gotMove, gotFlush := r.findCursorMoveInPending(tt.pending) if gotMove != tt.wantMove { t.Errorf("move: got %q, want %q", gotMove, tt.wantMove) } - if gotRemain != tt.wantRemain { - t.Errorf("remain: got %q, want %q", gotRemain, tt.wantRemain) + if gotFlush != tt.wantFlush { + t.Errorf("flushEnd: got %d, want %d", gotFlush, tt.wantFlush) } }) }