
2026/04/16 7:27
ターミナル用のページャーを作成しました。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
著者は、ターミナルユーザーインターフェース(TUI)構築の中核エンジンとなる再利用可能な Go のビューポートコンポーネントを開発した。このモジュール化されたシステムは、Kubernetes ログを表示するための kl や、Nomad を表示するための wander、および
$PAGER 環境変数を尊重して複数ページの内容を処理するdaily utility loreといったツールを稼働させている。アーキテクチャは、リサイズ、スクロール、検索(ショートカット /、r、i に正規表現対応付き)、水平方向のパニング、アイテム選択を含む必須機能をサポートしている。システムは、文字の折り返しおよびセル幅の計算(バイトをグリフに正確にマッピング)を行う Item インターフェース、表示向けの Viewport、検索機能向けの FilterableViewport の 3 つの主要モジュールを通じて、複数行および動的コンテンツを管理する。MultiItemのような高度な変種は、行番号などの動的プレフィックスをサポートしており、実装では特殊文字および絵文字の堅牢な描画が保証されている。開発者は、これらのユーティリティをテストまたは実行するために Go または Docker を使用でき、堅牢なコマンドラインアプリケーションの作成を容易にしている。今後の作業には、libghosttyとの統合およびエコシステムのさらなる改善が焦点となる。本文
【TL;DR】私は、k8s ログ向けの
kl や Nomad 向けの wander のようなターミナルアプリケーション(TUI)を開発しています。TUI コア機能には、アプリケーションマニフェストやログなど大規模なテキストブロックとの対話が含まれます。私のプロジェクトでは、テキストナビゲーション用の再利用可能な「Viewport(視窗)」コンポーネントを Go で作成しました。ターミナルページャーは、マルチページのテキストと対話的にナビゲートできるプログラムです。この Viewport コンポーネントを利用して作成した lore は、現在私が毎日使用している愛用ターミナルページャーとなりました。今回は、Viewport 内で実装したかった機能に加え、それを具体化するための学びやデザイン上の決断について詳述します。
ターミナルのページングとは
コマンドの実行と並んで、ターミナルはテキストを表示・ナビゲートするための場としても利用されます。
❯ cat file.txt I love terminals!
ターミナルは等幅フォントによる格子構造を持っています。サイズは行数と列数以降で定義され、テキストはこのグリッドに合わせて表示されます。
❯ cat ~/chessboard.txt a b c d e f g h 8 R N B Q K B N R 7 P P P P P P P P 6 5 4 3 2 P P P P P P P P 1 R N B Q K B N R a b c d e f g h
※補足:ターミナルでのテキスト装飾
ターミナルでは ANSI エスケープコードを使ってテキストを装飾できます。
❯ echo "default, \x1b[31mred text\x1b[0m, default" default, red text, default
上記のチェス盤パターンはこの方式で実現されています:
chessboard.txt は、ターミナル上で灰色のチェックボードとして表示されるよう ANSI エスケープコードを埋め込んだテキストファイルです。
開発者は頻繁に大量のテキストをターミナルで確認します(スクリプトや CLI の冗長な出力、git の差分、アプリケーションログ、man ページ、README、データベースクエリの結果〔例:psql/sqlite〕、エージェント型 AI ツールの出力など)。しかし、出力が 1 つのターミナル画面分の高さより短い場合は、特別なページャーを使わずに標準出力に直接印刷されます。その後、マウスでスクロールしたり、ターミナルエミュレータの標準機能(例:iTerm2 の cmd+f)で検索を行ったりします。あるいは、tmux などのマルチプレクサーを使って、独自のキーバインディングによる検索やログ取り戻しを行うかもしれません。しかし、テキストが複数のページにわたる場合、プログラムは対話的なナビゲーションのためにページャーを使用するのが一般的です。プログラムは
PAGER 環境変数をチェックし、設定されていればそのプログラムでテキストを表示します(標準出力にダンプする代わりに)。PAGER はパイプ入力を受け付けるか(command | mypager)、ファイル引数を受け付けるか(mypager myfile.txt)を示すプログラムを指しています。
※補足:プログラムが $PAGER を使う仕組み
git や man などのプログラムは、内部ロジックで
PAGER が設定されているかをチェックし、条件付きで使用します。通常、標準出力が TTY(対話的なターミナルセッション)ではない場合はパイプをスキップして直接出力するため、git diff | grep ... のような非 TTY 環境では PAGER は意味を持ちません。一方、標準出力が TTY ならプログラムは PAGER を使うことを選択し、設定されたプログラムを子プロセスとして起動し、パイプでページャーのプロセス標準入力と親プロセスの標準出力をつなげます。Andrew Healey の「シェルの構築」に関する最近の記事には、このパイプやシェルが使用するシステムコールについて詳しく書かれていますのでおすすめです。
開発者のマシンにある多くのプログラムは、
PAGER が設定されていなければ less をデフォルトのページャーとして使います。すべての出力をターミナルに直接ダンプさせたい場合は、PAGER=cat と設定します。他の選択肢には bat、most、delta があります。また、特定の出力用や bat 内の装飾後のページング用の環境変数もあります(例:git の出力用に GIT_PAGER、bat 内でのページング用に BAT_PAGER)。
最も一般的な
less は、オプションと設定を有効活用すると非常に強力です。デフォルトではテキストは一度 less を終了すれば失われますが、これは一般的に問題ありません。しかし --no-init/-X オプションを使うと、less から離れるまで表示されたテキスト履歴がターミナル出力に残ります。また --ignore-case/-i で検索を大文字小文字区別なく行うことができます。より詳しく学ぶには less のオプションや設定に関する記事を推奨します。
ターミナルアプリケーション(TUIs)
ターミナルアプリケーション、または TUI はネイティブデスクトップアプリやウェブサイトと似ていますが、ターミナル内で実行される点が異なります。TUI は通常、Alt スクリーンを使って全画面を一時的に占有し、タイトル、サイドバー、ヘルプテキスト、テキストビューポートなどのアプリケーションコンポーネントを表示します。テキストビューポートコンポーネントはターミナルページャーに似ていますが、画面の一部しか使用しません。TUI では他にも一般的なワークフローとして以下のものがあります:
- 画面内のコンポーネント間でフォーカスを移動させる
- 画面内のアイテムリストから選択する
- ユーザー入力を取得する
TUI では、最小の編集単位はピクセルではなくターミナルグリッドセルです。これは良い制約となり、アプリケーションを「必要な情報のみを除去してヒラリキーボード操作で階層的にデータを観る仕組み」とし、「ウェブサイトやデスクトップアプリのように大きなスクロール可能な画面やボタン満載のツールバーを提供する」ものと引き締める効果があります。
例えば、私は Kubernetes ログを複数のクラスターとネームスペースで扱うために
kl という TUI を構築しました。起動時に 2 つのテキストビューポートが表示されます:左側に構成されたクラスター、ネームスペース、ポッド、コンテナを示す Kubernetes エンティティ階層、右側には空の状態からログ表示です。
- 最初は選択ビューポートにフォーカスを当て、ログを tail するための 1 つ以上のコンテナを選択します。
- コンテナを選択し、そのログを表示します。
L を押すと、選択ツリーを非表示にして全画面でログを表示します。そこから / キーで完全一致検索を実行できます(例:ログ内で "ERROR" を検索)。x を押すと一致部分の前後文を含めて表示するか、一致部分のみを表示かを切り替えます。p は JSON ログを整形してスペースとインデントを追加します。Enter で単一のログビューにズームします。? で利用可能なすべてのコマンドを表示し、Ctrl+C で退出します。これにより TUI がコンポーネントからなるキーボード主導のアプリケーションであることがわかります。TUI の最も重要なコンポーネントは往々にしてミニターミナルページャーそのものです。kl では選択ツリーとログビューの双方がミニターミナルページャーです。私はこの共有機能を抽出して「Viewport コンポーネント」として実装しました。
Viewport コンポーネント
Viewport は任意量のテキストを持つ柔軟にサイズ変更可能なボックスです。このテキストボックスはリサイズ可能でスクロール可能、現在の位置を示すパーセンテージインジケータを表示し、未折りたたみ時などに水平パン(左右スクロール)時に文字の折り返しを切り替えられます。検索機能により一致結果のナビゲーションが可能、アイテム選択を許可、ANSI エスケープコードによるテキスト装飾に対応、ユニコードをサポートし、大量のテキストでも一般的にパフォーマンスが良いです。この Viewport は Go で書かれており、Bubble Tea TUI フレームワークを使用したアプリケーションへの統合が容易です。
この機能セットを実現するために、実装は以下の 3 つのモジュールで構成されています:
- item: テキストをラップし、文字列幅、ターミナル幅、折りたたみ状態に応じて 1 つ以上の行を占用します。
- viewport: アイテムを表示し、ナビゲーションを許可します。
- filterable viewport: Viewport 機能に検索機能を追加します。
Go がインストールされている場合、これらの要素と Composition(合成)による Filterable Viewport を試す最速の方法は以下のコマンドを実行することです:
go run github.com/robinovitch61/viewport/examples/filterableviewport@latest
Go がない場合は Docker で実行できます:
docker run -ti golang:1.26-alpine \ go run github.com/robinovitch61/viewport/examples/filterableviewport@latest
ユニコード対応
大半のユニコード文字はターミナルで 1 セルの幅を占用しますが、特殊なものは異なります。以下は
wcwidth ライブラリを使って文字列のターミナル列幅をチェックするシェル関数です:
termwidth() { uv run --with wcwidth python -c \ "import wcwidth; print(wcwidth.wcswidth('$1'))" }
単純な文字列は期待通りのセル幅になります:
❯ termwidth 'a' 1 ❯ termwidth '123' 3
しかし、ここはどうでしょうか:
❯ echo '\u2728' ✨ ❯ termwidth '✨' 2
スパークルエモジ
✨(ユニコードコードポイント U+2728 SPARKLES)は 2 つのターミナルセル分の幅を占用します。ターミナルにおけるユニコードテキストでは、以下のような概念があります:
- コードポイント: ユニコード標準に割り当てられた数字(例:U+2728)。
- グラフイム: 人間の認識する単一文字(1 つ以上のコードポイント)、例:
。✨ - バイトエンコーディング: コードポイントのバイト表現、UTF-8/16/32 に依存(例:
は UTF-8 で 3 バイト✨
)。0xE2 0x9C 0xA8 - コードポイント幅: コードポイントが占用するターミナルセル数(0、1、または 2)、例:
は 2。✨ - グラフィムのターミナル幅は、その構成するコードポイントの合計で決定されます。
見た目だけで文字が何セルを占用するか推測するのは困難です:
❯ termwidth '全' 2 ❯ termwidth '■' 1 ❯ termwidth '﷽' 1
ユニコードでは単一幅の文字
é も複数の方法で表現できます:単一の é コードポイントか、e と付随する結合アクセントコードポイント(例:U+0301)の組み合わせかのいずれかで。
- é U+00E9 LATIN SMALL LETTER E WITH ACUTE
- e U+0065 LATIN SMALL LETTER E + U+0301 COMBINING ACUTE ACCENT
❯ echo '\u00E9' é ❯ termwidth '\u00E9' 1 ❯ echo '\u0065\u0301' é ❯ termwidth '\u0065\u0301' 1 ❯ termwidth '\u0301' 0
Viewport でユニコードをサポートするためには、バイト列(Go では通常 UTF-8)からコードポイントへ、さらにグラフイムとその対応するターミナル幅へのマッピングを考慮する必要があります。
Item インターフェースはこの実装を簡潔に扱います:
type Item interface { // Width はターミナルセルにおける総幅を返します Width() int // Take は startWidth から startWidth + takeWidth までの文字列を取得し、width はターミナルセル単位です Take(startWidth, takeWidth int) string } // NewItem は文字列から SingleItem を構築し、Item インターフェースを実装します func NewItem(content string) SingleItem { // マップを構築: // コードポイント -> バイトオフセット // コードポイント -> 累積ターミナルセル幅 }
不変な文字列(例:Kubernetes ログ)に対して、到达するごとに一度
Item オブジェクトを eagerly Instantiate(初期化)できます。これは内部にスパースなマップ(コードポイントからバイトオフセットとターミナルセル幅へ)を構築します。MultiItem も同じインターフェースを満たしますが、複数の個別のアイテムを超えて動作し、prefix の変更(例:行番号、タイムスタンプ、コンテナ名など)のために全体の SingleItem を再構築する必要なく効率的に動的 prefix 付けが可能です。多行コンテンツ(例:整形された JSON ログ)については、MultiLineItem が複数の改行を超えるアイテムをサポートします。Item アブストラクションは、 successive calls to Take でアイテムのベース内容を使い切るまでの巻き戻し、およびアイテムが展開されている時の効率的な左右パンをうまくサポートします。
検索とフィルタリング
私は完全一致検索
/、正規表現検索 r、大文字小文字区別なし検索 i のための異なるキーボードショートカットを持つのが価値があると考えています。これはすべて「大文字小文字区別なしフラグ (?i) を自動で付与した単一の正規表現検索」として実装されています。これらの一般的な検索動作のそれぞれに個別の単一キーショートカットを割り当てることで、アプリは機敏に感じます。適用された検索はメモリ上のバッファに保存され、上下矢印キーでナビゲートできます。Filterable Viewport は、フィルタに基づいて現在表示されているセットからフィルタリングする全アイテムのシーケンスを維持します。x ショートカットは一致のみを表示するか、一致部分の前後文も含めて表示かを切り替えます。アイテムには複数の一致が存在し得、n/N で一致間をナビゲートして、フォーカスされた一致を画面に表示できます。
18,213 'Harry's across all 7 books
アイテム選択
いくつかのテキストビューでは、画面ごとの内容ページングで十分です。他のビューでは、表示可能なセットからアイテムを選択する必要があります。Viewport は这两种ケースの双方を扱い、選択を有効または無効にする設定を行います。例えば
kl では、ログ上で Enter を押すとそのログの完全なページビュー(美しくフォーマットされたもの)へ移動でき、そこからスクロール、パン、検索、前後のログを一つずつ切り替えることができます。
アイテム選択をサポートするため、Viewport はオブジェクトタイプに対して汎用型化されています:
// New は汎用型 T の Objects を表示する新しい Viewport を作成します func New[T Object](width, height int, opts ...Option[T]) (m *Model[T]) { ... } // GetSelectedItem は現在選択されているアイテムへのポインターを返します func (m *Model[T]) GetSelectedItem() *T { if !m.selectionEnabled { return nil } return m.getSelectedItem() }
呼び出し側は Enter キーイベントなどのキー入力を受け取り、フォーカスされた Viewport で
GetSelectedItem() を呼び出して返されるオブジェクトに応答できます。
less やその前に登場した more のように、lore もシンプルなターミナルページャーで、Viewport コンポーネントを頼りに全機能を実装しています。私が kl や wander といったアプリ内で「テキスト表示・ナビゲート」に合うミニターミナルページャーを構築している限り、その同じ機能をターミナル内のページングにも使えるでしょう!lore は less が持つ機能のサブセットのみをサポートしますが、私の日常活動に対してより直感的で有用な方式です。また、地から上へ理解することが価値があると感じています(バイトからターミナルビューへ)。さらに、私は実際に必要なターミナルページャーを理解して改善しながら続けています。
lore のインストール手順はこちらから確認できます。私は定期的に lore <some-file> や <command> | lore を実行しています。また ~/.zshrc で export PAGER=lore を設定しており、新しいプログラムが less を代わりに使ってくれるたびに嬉しく思います。このドメインは深いですね。libghostty などのパッケージの進展には興奮しています。これらは私が Go の Viewport で実装した多くの機能を Zig と C バインディングでカバーしています。コア Viewport 機能、kl、ターミナルページャー lore、そして今後の他の TUI を引き続き改善してまいります。