30分

Ghosttyの既存タブをtmuxなしで表示する — AX検証からTTYbirdへの実装まで

ghosttyttybirdrustaccessibilityterminal

既存のGhosttyタブを、tmuxなしでTTYbirdに表示できた。 UUIDを指定したVTファイル書き出しを使い、紐付け済みの端末を選ぶと一度取得して表示するスナップショットとして実装した。非選択タブの新しい出力と分割後の取得も、Ghostty 1.3.1の実機で確認した。

これは可視画面の完全なリアルタイム複製ではない。履歴を含む出力で、取得のたびにクリップボードと一時ファイルを使う。その制約を含めて操作を設計した。 Rust製のTTYbirdを作っている。複数の端末に散らばったAIエージェントを一覧にし、元の端末へ戻るツールだ。次に欲しくなったのが、左にエージェント一覧、右に選択した端末の画面を置く表示だった。

新しくTTYbird経由で起動した端末なら、自分でPTYの出力を受け取れる。難しいのは、すでに普通のGhosttyタブで動いている作業を、そのまま見たいという場合だ。プレビューのためだけにtmuxへ移ったり、エージェントを再起動したりしたくない。

当初は「普通のGhosttyタブは取得できない」と説明していた。しかし、それは広すぎた。2026年9月16日、ソース調査からmacOS Accessibility(以下AX)経由の読取りを見つけ、実際のGhosttyで検証した。この記事は、その訂正と実測の記録である。

訂正・実装追記(2026年9月16日):初版はAXの制約から、既存タブの取得にはGhostty側の変更が必要だと結論を広げすぎた。AppleScript+ファイル書き出しならUUIDで対象を指定でき、今回はこの経路をTTYbirdへ実装して検証した。出力通知がなくても定期取得は可能だが、クリップボードへの副作用があるため、この実装は端末の選択・表示切替・明示的な再取得のタイミングで一度だけ取得する。

「探して戻る」と「中に表示する」を分ける

TTYbirdの入口にしたいのは、普段の端末で動かしたClaudeやCodexを見つけ、その端末へ戻る体験だ。これはセッションの検出・検索と、移動先の紐付け・フォーカスで実現する。AXによる画面取得を完成させないと使えない機能ではない。Ghosttyの初回の紐付けが必要な場合はあり、「完全ゼロ設定」とは説明しない。

一方、今回調べたのは、その体験へ追加する既存タブのプレビューだ。読み取りによる一覧取得と、自分でPTYを起動・管理する機能も区別する。後者にはTTYbird側の管理プロセスがある。

利用者が毎日使い、他人にも紹介したくなるかは、実装とは別に試用で確かめる必要がある。まず「探して戻る」を現在の価値として伝え、選択時のスナップショットと、未達成の正確なライブ表示を区別する。

TTYbirdだけでどこまで実装できるか

答えは、UUID指定のファイル書き出しによる選択時のプレビューを実装できた。ただし、副作用なく正確なviewportを取得する経路はまだ検証できていない、となる。Ghosttyの変更をすべてのプレビューの前提にはしない。

求める表示TTYbird側の経路確認状況と制約
選んだAX要素の文字を確認するAX読取りと、選択中だけの定期取得実機で取得成功。参照失効・履歴・キャッシュへの対応が必要
UUIDで指定した既存端末の内容を読むAppleScript+write_screen_file:copy,vtTTYbirdへ実装。通常・非選択・分割後の取得に成功。clipboard・一時ファイルを使う
表示されているウィンドウを画像で映すScreenCaptureKit等による画像取得本検証では未実施。任意の非選択タブの最新描画は保証しない
副作用なく正確なviewportを読むGhostty側に小さいsnapshot取得機能を追加する案未実装。初期版は定期取得でもよく、変更通知は追加の最適化
TTYbirdで新しく起動した端末を表示する自分が管理するPTY出力+libghostty-vt既存Ghosttyプロセスへの接続とは別の方式

AX実測、UUID指定ファイル書出し、viewport取得拡張案を区別した構成図
AX実測、UUID指定ファイル書出し、viewport取得拡張案を区別した構成図

図は取得経路と検証段階の整理で、動作中のスクリーンショットではない。UUID指定の取得は実装・実機検証済み、viewport拡張は未実装の設計案だ。

libghosttyをリンクするだけでは接続できない

libghostty-vtは、端末出力を解釈し、画面状態を扱うためのライブラリだ。既存のGhosttyアプリから出力を取り出す入口とは別の役割を持つ。

Ghostty内部にはghostty_surface_read_textがある。しかし、必要なのはそのプロセス内のsurfaceへのハンドルだ。TTYbirdにライブラリをリンクしても、別プロセスで動いているGhosttyのsurfaceが自動で手に入るわけではない。調査したヘッダーも、このAPIをlibghostty-internalとして区別している。

AppleScript APIは、ウィンドウ・タブ・端末のIDやフォーカス操作を公開している。端末へ「戻る」入口にはなるが、検証した1.3.1の辞書には画面内容を返すgetterがなかった。

一方、GhosttyのAX実装はtext areaとして文字を返している。ここに実験できる経路があった。

UUID指定のファイル書出しという別経路

指摘を受けて記事に追加した重要な経路が、AppleScriptのperform actionwrite_screen_fileの組み合わせだ。これは初回の内部調査では把握していたが、clipboardへの副作用から常時プレビューの既定方式として採らず、初版の記事では十分に説明しなかった。

Ghostty 1.3.1では、terminal IDで対象を選び、その端末へ書出しアクションを実行する経路がある。copyがclipboardへ渡すのは画面本文ではなく、一時ファイルのパスだ。公式のアクション説明1.3.1の書出し実装で確認できる。

概念上の流れは、端末UUIDを指定 → write_screen_file:copy,vtを実行 → clipboardのパスを取得 → ファイルを読んでプレビュー、となる。VTやHTML形式も選べる。これならAX要素と端末UUIDを結び付けなくてもよい。この呼出しにフォーカス操作は含まれない。実機では非選択タブの新しい出力と分割後の指定対象を取得でき、取得前後で選択タブも変わらなかった。分割内部のフォーカス状態まで独立して計測したわけではない。

一方、取得対象は履歴を含むscreenであり、正確なviewportではない。clipboardの書換え、一時ファイルの生成、読取り後の削除も設計に含める必要がある。clipboardを退避・復元しても、その間のユーザーによるコピーとの競合は消えない。VT形式でもカーソル、折返し、スクロール位置まで一致する画面複製とは限らない。

このため常時・高頻度の既定経路にしにくいとは言える。しかし、それを実装不能と言い換えてはいけなかった。AX試行とは別にこの経路を実装し、選択した端末だけを1回取得する操作として検証した。以下のE系列が実装後の記録であり、先のAX試行の成功件数へ合算しない。

画像でよければScreenCaptureKitのウィンドウ取得も別候補になる。これは表示中のウィンドウを映す方式で、非選択タブの内部状態を読むAPIとは区別する。本記事では追試していない。

実装した操作と検証結果

紐付け済みのローカルGhosttyセッションを選び、次のように操作する。

  1. 紐付け済みのローカル端末を一覧で選ぶと、右側に表示する。
  2. Ghosttyでは選択時に一度取得し、rで再取得する。同じ端末を定期取得し続けることはない。
  3. pで表示を切り替え、Escで閉じる。入力するときはEnterで元の端末へ戻る。

説明画面を挟んでrを押してもらう案は、操作が増えるため変更した。未紐付けの端末にはセッション情報を表示し、gで正しいGhosttyペインを選べる。別の行へ移ると、前の内容と未実行の取得要求を消す。遅れて返った結果も、選択IDと世代が一致しなければ表示しない。取得エラーを契機に自動再試行することもない。

Ghosttyから得たVTデータは、tmux用の改行変換を通さずlibghostty-vtへ渡す。描画は120列で再構成した最大末尾200行、ファイルの読取りは最大1 MiB。元の折り返しやカーソル、スクロール位置を再現するとは約束しない。

実機検証では、合成文字列だけを出す専用タブを作った。実際のエージェント会話は取得していない。

チェックポイント確認したこと結果
E01選択中の通常タブをUUIDで取得し、libghosttyで日本語を描画成功
E02非選択タブへ追加した新しい出力を同じUUIDで取得成功
E03・E04分割前からある端末と、新しい分割ペインを別々のUUIDで取得それぞれのマーカーを確認
E05閉じたUUIDを指定terminal-not-foundとして拒否
E06検証用端末とプロセスの後片付け残存プロセス0。各取得後の新規出力ファイルも残存0
E07文字列+独自形式のクリップボードを保持し、元の内容を復元後に再読取り各形式のバイト列一致

最初の15:29の試行は、最初の取得後に検証側が未作成のackファイルを読んで失敗した。取得機能の完全な成功例としては扱っていない。検証器を直した後、15:31にVTの扱いと終了済みUUIDのエラー判定を見直し、最後にクリップボードの復元後readbackも追加した。最終試行の各時刻とソースhashを公開している。

実機試験とは別に、実PTY上のTTYbirdと合成AppleScript応答で、端末を選ぶとpなしで表示する、rで指定UUIDへ一度だけ再取得する、表示を閉じると取得しない、失敗後も勝手に再取得しない、画面が閉じないことを確認した。Rustの152テスト、Clippy、整形、独立したtmux実機テストも通った。合成応答のTUIテストだけで実際のGhostty動作を証明した扱いにはしていない。

クリップボードの復元は完全保証ではない

TTYbirdは最大4 MiBのクリップボード表現をメモリに退避する。書き出しが成功し、変更カウントと自分のファイルパスを確認できた場合だけ復元する。TTYbird同士の取得は排他し、保存先も同一ユーザー所有の通常ファイルかを確認する。読取り後は出力ファイルと空の専用ディレクトリを削除する。

それでもmacOSには、この処理で使える原子的な「変わっていなければ復元」がない。最後の確認と復元の間に別アプリがコピーする競合は残る。また、取得ヘルパーのクラッシュや10秒のタイムアウトでは、クリップボードにファイルパスや、一時領域に出力ファイルが残る可能性がある。通常のTTYbird終了では実行中の取得の後片付けを待つが、強制終了まで保証できない。

このため定期的な自動更新を採用せず、選択・表示・再取得の操作時だけ取得する。クリップボードの退避データをディスクへ保存して復旧可能にする方法も選んでいない。今回更新したのはローカルの開発ビルドであり、配布済みHomebrew版にこの機能が含まれるとは限らない。

ここからは先に実施したAX検証の記録

対象はmacOSで起動中のGhostty 1.3.1、build 15212。検証用のSwiftクライアントと、既知の文字列を出すPythonプログラムを用意した。

AppleScriptで専用ウィンドウを作り、その中のAXTextAreaを読む。普通のタブと同じ端末機能を使うが、実際のエージェント会話は読まない。ランダムなタイトルと専用の作業ディレクトリで合成端末を隔離し、候補が一意でない場合には取得を止めた。この識別方法はテスト専用であり、一般ユーザーの同名タブを安全に識別する方法を証明してはいない。

出力にはASCII、日本語、結合文字、絵文字、160行の履歴、alternate screenへの切替を含めた。さらに、同じタイトル・作業ディレクトリの別タブと分割を作り、参照の寿命を調べた。本文はログへ保存せず、マーカーの有無、長さ、時間、エラーを記録した。

どの順番で確認したか

時刻は2026年9月16日の日本時間。最初のコンパイル失敗の時間帯だけは概算で、それ以外は実行記録に基づく。

時刻チェックポイント分かったこと
14:27C00 検証開始対象版と範囲を固定。既存作業は保持
14:27〜14:31頃C01 権限・実行環境Nix SDKとSwiftの不一致で最初はコンパイル失敗。Xcode側で起動し、既存AX許可を確認
14:34C02〜C05 最初の取得文字取得、履歴、Unicode、更新遅延、通知を確認
14:36C06〜C08 追加試行分割後の参照無効化を確認。プロセス終了後もペインが残り、後片付けは不合格
14:37〜14:38計測・後片付けの修正出力完了ackから計時。負荷測定を分割前へ移動し、専用UUIDで残存を確認・終了
14:39再検証分割による無効化と、タブを閉じたことによる無効化を別々に確認
14:40〜14:41最終試行ソースのhashを記録し、範囲の端・絵文字・API所要時間を追加。45件の記録、正常終了
14:42C08 独立した再照合全試行で作った19端末IDと検証プロセスの残存ゼロ
14:47C09 根拠レビュー報告値と実測ログを独立レビューで照合

45件は成功したテスト45件という意味ではない。準備や観測を含む記録の件数だ。途中で検証器自体も修正しており、初期試行を最終試行と同条件の反復実験として数えてはいない。

読めた文字は可視画面ではなかった

最終試行の初期端末は14行だった。しかし、取得した文字列には160行の履歴の先頭マーカーも含まれていた。

AXVisibleCharacterRangeという名前だけを見ると、見えている部分の範囲だと思いたくなる。今回、その値は取得した履歴全体に対応していた。ソースの座標定義でも、screenはviewportと区別され、保持しているscrollbackを含む。

さらに、日本語・結合文字・絵文字を含むと、公開文字数は2482、全文のUTF-16長は2488だった。2482をそのまま範囲取得に渡すと末尾6 UTF-16単位が欠け、2488なら全文と一致した。絵文字単体はUTF-16の範囲指定で取得できた。

API名や返された件数を信じるだけでは足りない。利用する範囲の単位と、全文・履歴・viewportのどれを表しているかを確認する必要がある。

通知を登録できても出力通知は届かなかった

AXValueChangedの登録自体は成功した。しかし、B・C・Dと出力を変えた観測区間では、該当通知は0件だった。

対照としてタイトルを変更すると、AXTitleChangedは2件届き、対象windowと所有PIDの一致も確認できた。イベント受信系がすべて動いていない、という説明だけでは片付かない。

また、fixtureが出力を書き終えたackから約256msでは古い内容、約608msでは新しい内容が返った。これはソースの500msキャッシュと整合する。ただし、この2点から更新境界や最悪遅延を断定することはできない。

変更通知が来なくても、TTYbirdが定期取得すれば継続表示できる。 今回の結果が示したのは、観測したAX経路を出力通知だけで更新できるとは確認できなかった、という範囲だ。

TTYbirdにpub/subの受信器を作ることはできる。それでも、Ghostty側が必要な変更を通知し、通知に対応する新しい状態を返さなければ、出力更新は完結しない。通知だけを追加してキャッシュが古いままでも、受信直後に前の内容を表示してしまう。

非選択タブは読めたが分割で参照が失効した

同名タブを2つ作ると、選択中のAXツリーには1つのtextareaだけが現れた。最初のタブは列挙対象から外れたが、一度取得した参照を保持していれば、非選択中に追加された文字も読めた

これは嬉しい結果だった。しかし「最初から隠れている任意のタブを後から発見できる」とは違う。

最初のタブへ戻った直後も参照は有効だった。一方、分割を作ると、その参照はinvalidUIElementになった。元の端末が生きていることと、外部から取得したAX要素が生きていることは一致しない。

ここでタイトルや並び順を使って別のtextareaへ接続し直すと、同名タブや分割で取り違える余地がある。今回、取得したtextareaにはAXIdentifierが見つからず、AppleScriptの端末UUIDへ戻れる安定した対応付けも確認できなかった。

閉じたタブの参照と、同名・同じ作業場所で作り直した別タブも区別して試した。古い参照は無効のままで、別タブの内容へすり替わることはなかった。この結果を保つには、TTYbirdも失効時に黙って候補を推測し直さない設計にする必要がある。

大量の履歴を定期取得するコスト

合成出力を増やした試行では、306,612文字が返った。3標本でAX属性取得そのものは約2.38〜6.85ms、文字数やマーカーの判定まで含めたprobe全体は約38.14〜61.24msだった。

後者をGhosttyのAPI時間として報告すると誤る。クライアント側で文字列を何度も調べる処理も含んでいる。反対に、API部分だけ速くても、全セッションの全文を繰り返し処理すればTTYbird側の負担になる。

これは描画速度や継続CPU負荷の測定ではない。ただ、プレビューを選んでいないタブまで常時読み続ける設計を避ける理由にはなる。短い範囲を要求しても、Ghostty側の実装が全文を用意してから切り出すなら、要求した文字数だけでは処理量の上限を保証できない。

残る正確なライブ表示への課題

TTYbirdには、許可と接続状態の確認、対象選択、表示中だけの取得、キャッシュ、参照失効時の停止を実装できる。限定したAX文字プレビューならここから始められる。ただし、権限は今回の検証プログラムについての結果であり、配布バイナリでも確認が必要だ。

UUID指定のVTファイル書き出しは、今回の実装でAX要素との対応付けを必要としなくなった。文字出力を確かめる用途には使えるが、正確なviewportや副作用のない自動更新は残る課題だ。

clipboardや一時ファイルを介さず、UUIDで正確なviewportを返してほしいなら、Ghostty側で次を公開する拡張案がある。

  • AppleScriptの端末UUIDと一致する、安定したsurface識別子。
  • 全履歴ではなく、可視範囲とサイズ上限を明示したsnapshot。
  • 必要になった段階で、snapshotのrevisionと内容・サイズ・終了の変更通知。

最小構成は、既存のAppleScript端末オブジェクトにviewport getterを足し、TTYbirdが選択中の端末だけ定期取得する方式でもよい。pub/subや新しいIPC一式は開始条件ではない。

取得負荷や遅延が問題になったら、変更をまとめた通知とrevisionを追加する。その場合は切断・通知欠落・サイズ変更時に再同期し、cursor blinkなどの描画と文字内容の変更も区別する。

これは設計案で、Ghosttyへの実装や採用が済んだ仕様ではない。AX、AppleScript、IPCのどこを拡張するかは、要求する表示と非選択タブの公開範囲・権限設計から選ぶ。tmuxは必須要素ではない。

今回、TTYbirdは通常のGhosttyタブを選択時のスナップショットとして表示できるようになった。AX単独では難しかったUUID指定を別経路で解決できた一方、クリップボード、viewport、変更通知の課題は同じ方法では解消していない。取得できたことと、完全な画面ミラーができたことを分けて扱う。

検証資料と未確認事項

実装後のUUID書き出し検証JSONには、E01〜E07、ソースhash、検証範囲を収録した。

時系列・最終試行の観測値をJSONで読む。公開用に端末UUID、PID、個人のパスを除いた抜粋で、画面本文は含めていない。

再実行用のローカル成果物はTTYbird内のscripts/ghostty_ax_smoke.pyscripts/ghostty_ax_probe.swiftscripts/ghostty_ax_fixture.pyに作成した。検証記録はdocs/verification/ghostty-ax-2026-09-16/に保存している。本記事の作成時点では、これらのGitHubへの反映は確認していない。

最小化や別Space、手動スクロール、resize/reflow、色・カーソルの再現、権限失効、応答停止、長時間のCPU・メモリは未検証。別のmacOSやGhostty版への一般化もしない。既存のエージェント作業へ入力や終了を送らず、作った合成端末だけを後片付けした。