Skip to content

feat: tmux DCS passthrough による tmux 内画像表示対応#25

Merged
flexphere merged 7 commits intomainfrom
feat/tmux-renderer
Mar 27, 2026
Merged

feat: tmux DCS passthrough による tmux 内画像表示対応#25
flexphere merged 7 commits intomainfrom
feat/tmux-renderer

Conversation

@flexphere
Copy link
Copy Markdown
Owner

@flexphere flexphere commented Mar 27, 2026

概要

tmux 内で gaze を使用した際に画像が表示されない問題を解決するため、Kitty グラフィクスのエスケープシーケンスを DCS passthrough でラップする TmuxRenderer を追加しました。--renderer tmux (-r tmux) フラグで有効化できます。

resolves #26

変更内容

  • TmuxRenderer を新規追加(internal/adapter/renderer/tmux_renderer.go
    • KittyRenderer をラップし、全 Kitty APC シーケンスを \x1bPtmux;...\x1b\\ で包む
    • カーソル移動シーケンスを Kitty コマンドと同一の passthrough 内に含め、正しい位置に画像を配置
    • tmux ペインオフセットを tmux display-message で取得し、カーソル座標を補正(分割ペイン対応)
    • ウィンドウリサイズ時にペインオフセットを自動更新
  • KittyRendererUploadMinimap から prepareMinimapBase メソッドを抽出(TmuxRenderer からの再利用のため)
  • --renderer / -r フラグを追加(kitty(デフォルト), tmux
  • TUI モデルに onResize コールバックを追加(リサイズ時のペインオフセット更新用)

テスト計画

  • 既存テスト全パス (make ci)
  • 新規テスト追加: internal/adapter/renderer/tmux_renderer_test.go
    • wrapAllKittySequences: 単一/複数チャンク、カーソル移動の取り込み、ペインオフセット補正、非 Kitty シーケンスの保持
    • extractTrailingCursorMove: 各種カーソル移動パターン(bare \x1b[H、row;col、row のみ、オフセット付き)
  • 手動確認: Ghostty + tmux 環境で画像表示・ミニマップ配置・分割ペイン内の描画を確認済み

備考

  • tmux の allow-passthrough on 設定が前提条件です
  • tmux 対応は Experimental としています(ペイン分割時の座標補正は起動時・リサイズ時のスナップショットベース)

🤖 Generated with Claude Code

flexphere and others added 3 commits March 28, 2026 01:00
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>
Copilot AI review requested due to automatic review settings March 27, 2026 16:01
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapAllKittySequences は out.Grow(len(s) * 2) で入力の2倍を事前確保していますが、実際の増分は DCS ラップ分と ESC の二重化分だけで、特にアップロード時の巨大な文字列で過剰確保になり得ます。len(s) 程度(+固定オーバーヘッド見積もり)にするか、Grow 自体を外して必要に応じた増加に任せる方がメモリ効率が良いです。

Suggested change
out.Grow(len(s) * 2)
out.Grow(len(s))

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2b58198 で修正 — out.Grow(len(s)) に変更しました

prefix := r.extractTrailingCursorMove(&out)

kittySeq := s[i:seqEnd]
escaped := strings.ReplaceAll(prefix+kittySeq, "\x1b", "\x1b\x1b")
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kitty の placement は「カーソル位置に描画」なので、ここで CSI カーソル移動を DCS passthrough 内に含めると outer terminal のカーソルも移動しますが、復帰シーケンスが無いためカーソル位置が残留します(tmux 自身は passthrough 内容を追跡しないため)。描画前後で cursor save/restore(例: DECSC/DECRC や CSI s/u)を passthrough 内に入れて、カーソル位置が必ず元に戻るようにすると、描画後のプロンプト/ステータス表示の崩れを避けやすいです。

Suggested change
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")

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2b58198 で修正 — DECSC/DECRC を passthrough 内に追加しました

Comment on lines +38 to +46
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])
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryTmuxPaneOffset で exec.Command("tmux", ...) の失敗や strconv.Atoi の失敗を握りつぶして (0,0) にフォールバックしていますが、tmux 外で -r tmux を指定した場合や tmux が見つからない場合に「画像が出ない/位置がズレる」原因が分かりにくくなります。少なくとも失敗を呼び出し側に返して createRenderer で明示的にエラーにするか、tmux セッション判定(例: $TMUX)に基づいて分かりやすいメッセージを出す方が運用上安全です。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c80e4db で修正 — NewTmuxRenderer$TMUX 環境変数を確認し、未設定時にエラーを返すようにしました

Comment on lines +57 to +65
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)
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createRenderer は "tmux" を指定すると常に renderer.NewTmuxRenderer() を返しますが、tmux 外や tmux コマンド不在でもそのまま進むため(pane offset が 0,0 になりやすく)「何も表示されない/位置が不正」になっても利用者が原因を特定しづらいです。NewTmuxRenderer / createRenderer のどちらかで tmux セッション判定や依存コマンド有無チェックを行い、満たさない場合は明示的にエラーにすることを検討してください。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c80e4db で修正 — コメント #3 と同一コミットで対応しました

flexphere and others added 2 commits March 28, 2026 01:18
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>
@flexphere flexphere marked this pull request as ready for review March 27, 2026 16:19
Copilot AI review requested due to automatic review settings March 27, 2026 16:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment on lines +30 to +52
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
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryTmuxPaneOffset が exec.Command("tmux", ...) の失敗や strconv.Atoi の失敗を握りつぶして常に (0,0) にフォールバックしているため、tmux 内でも PATH/権限/フォーマット差異などで取得に失敗した場合に画像位置ズレがサイレントに発生します。少なくとも error を返して呼び出し側(NewTmuxRenderer / RefreshPaneOffset)で扱えるようにするか、失敗時は直前の paneTop/paneLeft を保持して更新しない(+必要ならログ/エラー表示)形にすると原因特定が容易になります。

Suggested change
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

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15da73e で修正 — queryTmuxPaneOffseterror を返すように変更しました。NewTmuxRenderer ではエラーを伝播し、RefreshPaneOffset では失敗時に直前の値を保持するようにしました。

Comment on lines +161 to +204
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)
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractTrailingCursorMove は b.String() でこれまでの出力全体を文字列化した後、b.Reset()→b.WriteString(s[:csiStart]) で「カーソル移動以外の全内容」を書き戻しています。DisplayMinimap のように「大きなアップロードシーケンス + \x1b[...H + placement」を処理するケースでは、placement ごとに巨大なバッファ全体を再コピーするためフレーム毎のオーバーヘッドが大きくなります。カーソル移動の取り込み判定/座標補正は out 側ではなく入力文字列側で行う(Kitty APC の直前を逆走査して \x1b[...H を見つけたらその部分を out に書かない)など、全体コピーを避ける実装にすると性能劣化を抑えられます。

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

47cbefb で修正 — 出力バッファ全体をコピーする extractTrailingCursorMove を廃止し、入力文字列側で逆走査する findCursorMoveInPending に置換しました。wrapAllKittySequences もバルクコピー方式にリファクタし、フレーム毎のオーバーヘッドを削減しました。

flexphere and others added 2 commits March 28, 2026 01:28
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>
@flexphere flexphere merged commit 124c241 into main Mar 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ghostty + tmuxで画像が表示されない

2 participants