"Use when adding an interactive review/approval step to a CLI pipeline using Textual TUI with immutable state."
Install
npx skillscat add shimo4228/claude-code-learned-skills/textual-tui-pipeline-interception Install via the SkillsCat registry.
SKILL.md
Textual TUI Pipeline Interception
Textual TUI パイプライン挿入パターン
Extracted / 抽出日: 2026-02-10
Context / コンテキスト: CLI パイプラインに対話的レビュー/承認ステップを Textual で挿入するパターン
Problem / 課題
CLIツールのパイプラインは 生成 → 出力 と直結しがち。ユーザーが結果を確認・編集してから出力したい場合、パイプライン途中に対話ステップを挿入する必要がある。
課題:
- 既存パイプラインを壊さずに TUI を挿入したい
- TUI の状態管理を不変にしたい(frozen dataclass パターンとの一貫性)
- Textual の非同期 UI と同期 CLI パイプラインを統合したい
- テスト可能にしたい(Textual Pilot API)
Solution / 解決策
1. パイプライン挿入パターン
TUI を 生成 → [レビュー] → 出力 の間に挿入。launch_review() は同じ型を受け取り返す。
# main.py — 挿入ポイント
result = generate(input_data) # 既存処理
# NEW: --review フラグ時のみ TUI 起動
if review and result.items:
from myapp.tui import launch_review
result = launch_review(result) # 同じ型 in/out
write_output(result) # 既存処理(変更不要)2. 不変状態モデル
TUI 状態を frozen dataclass で管理。状態変更は純粋関数。
from dataclasses import dataclass
from enum import StrEnum
class ItemStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
@dataclass(frozen=True)
class ReviewItem:
"""レビュー対象アイテム。不変。"""
data: MyModel # 元データ (Pydantic frozen)
original_index: int
status: ItemStatus = ItemStatus.PENDING
@dataclass(frozen=True)
class ReviewState:
"""レビューセッション全体の不変状態。"""
items: tuple[ReviewItem, ...]
current_index: int = 0
filter_status: ItemStatus | None = None # None = 全件
@property
def stats(self) -> dict[str, int]:
return {s.value: sum(1 for i in self.items if i.status == s) for s in ItemStatus}
def filtered_items(self) -> list[ReviewItem]:
if self.filter_status is None:
return list(self.items)
return [i for i in self.items if i.status == self.filter_status]3. 純粋関数で状態変更
def set_item_status(state: ReviewState, idx: int, status: ItemStatus) -> ReviewState:
"""ステータスを変更した新 ReviewState を返す。元は不変。"""
items = list(state.items)
old = items[idx]
items[idx] = ReviewItem(
data=old.data, original_index=old.original_index,
status=status,
)
return ReviewState(
items=tuple(items),
current_index=state.current_index,
filter_status=state.filter_status,
)
def edit_item(state: ReviewState, idx: int, **updates: Any) -> ReviewState:
"""Pydantic model_copy でアイテムデータを編集。"""
items = list(state.items)
old = items[idx]
new_data = old.data.model_copy(update=updates) # Pydantic frozen
items[idx] = ReviewItem(data=new_data, original_index=old.original_index, status=old.status)
return ReviewState(items=tuple(items), current_index=state.current_index, filter_status=state.filter_status)
def navigate(state: ReviewState, delta: int) -> ReviewState:
"""循環ナビゲーション。"""
filtered = state.filtered_items()
if not filtered:
return state
new_idx = (state.current_index + delta) % len(filtered)
return ReviewState(items=state.items, current_index=new_idx, filter_status=state.filter_status)4. Textual App パターン
from textual.app import App
from textual.binding import Binding
class ReviewApp(App[None]):
BINDINGS = [
Binding("a", "accept", "Accept"),
Binding("r", "reject", "Reject"),
Binding("e", "edit", "Edit"),
Binding("n", "next", "Next"),
Binding("s", "save_quit", "Save"),
Binding("q", "quit_app", "Quit"),
]
def __init__(self, initial_state: ReviewState) -> None:
self.state = initial_state
self.save_requested = False
super().__init__()
# 状態更新パターン: state 置換 → UI リフレッシュ
def action_accept(self) -> None:
idx = self._current_original_index()
self.state = set_item_status(self.state, idx, ItemStatus.ACCEPTED)
self.state = navigate(self.state, +1)
self._refresh_ui()
def action_save_quit(self) -> None:
self.save_requested = True
self.exit()
def _refresh_ui(self) -> None:
"""全ウィジェットを現在の state で更新。"""
self.query_one(StatsBar).update_stats(self.state.stats)
self.query_one(ItemDisplay).show(self.state.current_item)5. 公開 API(同じ型 in/out)
def launch_review(result: MyResult) -> MyResult:
"""TUI を起動し、承認済みアイテムのみ含む Result を返す。"""
state = create_initial_state(result.items)
app = ReviewApp(state)
app.run() # ブロッキング(Textual が内部で async 処理)
if not app.save_requested:
return result # 保存せず終了 → 元データそのまま
accepted = [item.data for item in app.state.items if item.status == ItemStatus.ACCEPTED]
return result.model_copy(update={"items": accepted})6. Textual Pilot テスト
import pytest
@pytest.mark.asyncio
async def test_accept_card():
state = create_initial_state(sample_items)
app = ReviewApp(state)
async with app.run_test() as pilot:
await pilot.press("a")
assert app.state.items[0].status == ItemStatus.ACCEPTED
async def test_save_exits_app():
app = ReviewApp(create_initial_state(sample_items))
async with app.run_test() as pilot:
await pilot.press("s")
assert app.save_requested is TrueKey Design Choices / 設計上のポイント
| 選択 | 理由 |
|---|---|
launch_review() が同じ型を返す |
パイプラインに透過的に挿入可能 |
| frozen dataclass + 純粋関数 | 既存コードベースの不変性パターンと一貫 |
self.state = new_state パターン |
Textual では reactive よりシンプル |
_refresh_ui() を毎回呼ぶ |
明示的 UI 更新で挙動が予測可能 |
save_requested フラグ |
quit vs save を区別 |
| CSS インライン | 小規模 TUI では .tcss ファイル管理不要 |
When to Use / 使用すべき場面
- CLI ツールに対話的レビュー/承認ステップを追加したい
- 生成結果(LLM出力、変換結果等)をユーザーが確認してから出力したい
- Accept/Reject/Edit のワークフローが必要
- 既存の不変データモデル(Pydantic frozen, frozen dataclass)と統合したい
When NOT to Use / 使用すべきでない場面
- 単純な yes/no 確認(
typer.confirm()で十分) - Web UI が必要な場合(Textual はターミナル専用)
- リアルタイムデータストリーム(Textual は静的データのレビュー向き)
Related Patterns / 関連パターン
python-immutable-accumulator.md— 不変蓄積の基本パターンbackward-compatible-frozen-extension.md— frozen モデルの後方互換拡張cost-aware-llm-pipeline.md—process → [review] → writeパイプラインの元パターン