feat: tmux DCS passthrough による tmux 内画像表示対応#25
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
tmux 内で Kitty Graphics Protocol の画像描画が通らない問題に対応するため、Kitty の APC シーケンスを tmux の DCS passthrough でラップする TmuxRenderer を追加し、CLI フラグでレンダラーを選択できるようにするPRです。
Changes:
TmuxRendererを追加し、Kitty のエスケープシーケンスを tmux passthrough で透過させる処理とペインオフセット補正を実装--renderer/-rフラグを追加し、kitty/tmuxを切り替え可能に- TUI にリサイズ時コールバックを追加し、tmux のペインオフセットを更新できるように
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/adapter/tui/update.go | Window resize で任意コールバックを呼べるようにして tmux 側更新をフック |
| internal/adapter/tui/model.go | onResize コールバックと setter を追加 |
| internal/adapter/renderer/tmux_renderer.go | tmux passthrough ラッパーとして TmuxRenderer を新規実装 |
| internal/adapter/renderer/tmux_renderer_test.go | Kitty シーケンスのラップ/カーソル移動抽出のユニットテストを追加 |
| internal/adapter/renderer/kitty_renderer.go | prepareMinimapBase を抽出して再利用可能に |
| cmd/gaze/main.go | --renderer/-r を追加し renderer 作成をファクトリ化、リサイズ callback を配線 |
| README.md | tmux 実験対応の記載と使用例を追加 |
| // 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) |
There was a problem hiding this comment.
wrapAllKittySequences は out.Grow(len(s) * 2) で入力の2倍を事前確保していますが、実際の増分は DCS ラップ分と ESC の二重化分だけで、特にアップロード時の巨大な文字列で過剰確保になり得ます。len(s) 程度(+固定オーバーヘッド見積もり)にするか、Grow 自体を外して必要に応じた増加に任せる方がメモリ効率が良いです。
| out.Grow(len(s) * 2) | |
| out.Grow(len(s)) |
| prefix := r.extractTrailingCursorMove(&out) | ||
|
|
||
| kittySeq := s[i:seqEnd] | ||
| escaped := strings.ReplaceAll(prefix+kittySeq, "\x1b", "\x1b\x1b") |
There was a problem hiding this comment.
Kitty の placement は「カーソル位置に描画」なので、ここで CSI カーソル移動を DCS passthrough 内に含めると outer terminal のカーソルも移動しますが、復帰シーケンスが無いためカーソル位置が残留します(tmux 自身は passthrough 内容を追跡しないため)。描画前後で cursor save/restore(例: DECSC/DECRC や CSI s/u)を passthrough 内に入れて、カーソル位置が必ず元に戻るようにすると、描画後のプロンプト/ステータス表示の崩れを避けやすいです。
| escaped := strings.ReplaceAll(prefix+kittySeq, "\x1b", "\x1b\x1b") | |
| // Wrap the cursor move + Kitty sequence with cursor save/restore | |
| // so that the outer terminal cursor position is restored after drawing. | |
| saveSeq := "\x1b7" // DECSC | |
| restoreSeq := "\x1b8" // DECRC | |
| content := saveSeq + prefix + kittySeq + restoreSeq | |
| escaped := strings.ReplaceAll(content, "\x1b", "\x1b\x1b") |
There was a problem hiding this comment.
2b58198 で修正 — DECSC/DECRC を passthrough 内に追加しました
| 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]) | ||
| } |
There was a problem hiding this comment.
queryTmuxPaneOffset で exec.Command("tmux", ...) の失敗や strconv.Atoi の失敗を握りつぶして (0,0) にフォールバックしていますが、tmux 外で -r tmux を指定した場合や tmux が見つからない場合に「画像が出ない/位置がズレる」原因が分かりにくくなります。少なくとも失敗を呼び出し側に返して createRenderer で明示的にエラーにするか、tmux セッション判定(例: $TMUX)に基づいて分かりやすいメッセージを出す方が運用上安全です。
There was a problem hiding this comment.
c80e4db で修正 — NewTmuxRenderer で $TMUX 環境変数を確認し、未設定時にエラーを返すようにしました
| 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) | ||
| } |
There was a problem hiding this comment.
createRenderer は "tmux" を指定すると常に renderer.NewTmuxRenderer() を返しますが、tmux 外や tmux コマンド不在でもそのまま進むため(pane offset が 0,0 になりやすく)「何も表示されない/位置が不正」になっても利用者が原因を特定しづらいです。NewTmuxRenderer / createRenderer のどちらかで tmux セッション判定や依存コマンド有無チェックを行い、満たさない場合は明示的にエラーにすることを検討してください。
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
| top, left := queryTmuxPaneOffset() | ||
| 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. | ||
| 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 |
There was a problem hiding this comment.
queryTmuxPaneOffset が exec.Command("tmux", ...) の失敗や strconv.Atoi の失敗を握りつぶして常に (0,0) にフォールバックしているため、tmux 内でも PATH/権限/フォーマット差異などで取得に失敗した場合に画像位置ズレがサイレントに発生します。少なくとも error を返して呼び出し側(NewTmuxRenderer / RefreshPaneOffset)で扱えるようにするか、失敗時は直前の paneTop/paneLeft を保持して更新しない(+必要ならログ/エラー表示)形にすると原因特定が容易になります。
| top, left := queryTmuxPaneOffset() | |
| 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. | |
| 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 | |
| top, left, err := queryTmuxPaneOffset() | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to query 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. | |
| func (r *TmuxRenderer) RefreshPaneOffset() { | |
| top, left, err := queryTmuxPaneOffset() | |
| if err != nil { | |
| // Keep previous paneTop/paneLeft to avoid sudden jumps on failure. | |
| fmt.Fprintln(os.Stderr, "failed to refresh tmux pane offset:", err) | |
| return | |
| } | |
| 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, err error) { | |
| out, err := exec.Command("tmux", "display-message", "-p", "#{pane_top} #{pane_left}").Output() | |
| if err != nil { | |
| return 0, 0, fmt.Errorf("tmux command failed: %w", err) | |
| } | |
| outStr := strings.TrimSpace(string(out)) | |
| parts := strings.Fields(outStr) | |
| if len(parts) < 2 { | |
| return 0, 0, fmt.Errorf("unexpected tmux output for pane offsets: %q", outStr) | |
| } | |
| top, err = strconv.Atoi(parts[0]) | |
| if err != nil { | |
| return 0, 0, fmt.Errorf("invalid pane_top value %q: %w", parts[0], err) | |
| } | |
| left, err = strconv.Atoi(parts[1]) | |
| if err != nil { | |
| return 0, 0, fmt.Errorf("invalid pane_left value %q: %w", parts[1], err) | |
| } | |
| return top, left, nil |
There was a problem hiding this comment.
15da73e で修正 — queryTmuxPaneOffset が error を返すように変更しました。NewTmuxRenderer ではエラーを伝播し、RefreshPaneOffset では失敗時に直前の値を保持するようにしました。
| 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) | ||
| } |
There was a problem hiding this comment.
extractTrailingCursorMove は b.String() でこれまでの出力全体を文字列化した後、b.Reset()→b.WriteString(s[:csiStart]) で「カーソル移動以外の全内容」を書き戻しています。DisplayMinimap のように「大きなアップロードシーケンス + \x1b[...H + placement」を処理するケースでは、placement ごとに巨大なバッファ全体を再コピーするためフレーム毎のオーバーヘッドが大きくなります。カーソル移動の取り込み判定/座標補正は out 側ではなく入力文字列側で行う(Kitty APC の直前を逆走査して \x1b[...H を見つけたらその部分を out に書かない)など、全体コピーを避ける実装にすると性能劣化を抑えられます。
There was a problem hiding this comment.
47cbefb で修正 — 出力バッファ全体をコピーする extractTrailingCursorMove を廃止し、入力文字列側で逆走査する findCursorMoveInPending に置換しました。wrapAllKittySequences もバルクコピー方式にリファクタし、フレーム毎のオーバーヘッドを削減しました。
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
概要
tmux 内で gaze を使用した際に画像が表示されない問題を解決するため、Kitty グラフィクスのエスケープシーケンスを DCS passthrough でラップする
TmuxRendererを追加しました。--renderer tmux(-r tmux) フラグで有効化できます。resolves #26
変更内容
TmuxRendererを新規追加(internal/adapter/renderer/tmux_renderer.go)KittyRendererをラップし、全 Kitty APC シーケンスを\x1bPtmux;...\x1b\\で包むtmux display-messageで取得し、カーソル座標を補正(分割ペイン対応)KittyRendererのUploadMinimapからprepareMinimapBaseメソッドを抽出(TmuxRendererからの再利用のため)--renderer/-rフラグを追加(kitty(デフォルト),tmux)onResizeコールバックを追加(リサイズ時のペインオフセット更新用)テスト計画
make ci)internal/adapter/renderer/tmux_renderer_test.gowrapAllKittySequences: 単一/複数チャンク、カーソル移動の取り込み、ペインオフセット補正、非 Kitty シーケンスの保持extractTrailingCursorMove: 各種カーソル移動パターン(bare\x1b[H、row;col、row のみ、オフセット付き)備考
allow-passthrough on設定が前提条件です🤖 Generated with Claude Code