From e9e80c5918522cda0397f4258713551c256832a1 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 5 May 2026 17:16:03 +0900 Subject: [PATCH] =?UTF-8?q?docs(tickets):=20Permission=E3=81=AE=E3=83=81?= =?UTF-8?q?=E3=82=B1=E3=83=83=E3=83=88=E3=81=A8TUI=E3=81=AEmd=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 1 + docs/manifest.toml | 285 ++++++++++++++++++++++++++ tickets/permission-extension-point.md | 66 ++++-- tickets/tui-assistant-markdown.md | 82 ++++++++ 4 files changed, 420 insertions(+), 14 deletions(-) create mode 100644 docs/manifest.toml create mode 100644 tickets/tui-assistant-markdown.md diff --git a/TODO.md b/TODO.md index 6e28f0e3..1a68838d 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,7 @@ - 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md) - セッションコンテキスト長 / ウィンドウ占有率の常時表示 → [tickets/tui-context-usage-indicator.md](tickets/tui-context-usage-indicator.md) - Compaction 進行中のライブ表示 → [tickets/tui-compact-progress.md](tickets/tui-compact-progress.md) + - Assistant 応答の Markdown スタイル表示 → [tickets/tui-assistant-markdown.md](tickets/tui-assistant-markdown.md) - Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md) - Prune: 保護境界を turn 数から末尾 token budget に置き換え → [tickets/prune-token-budget.md](tickets/prune-token-budget.md) - メモリ機構 diff --git a/docs/manifest.toml b/docs/manifest.toml new file mode 100644 index 00000000..baa742cb --- /dev/null +++ b/docs/manifest.toml @@ -0,0 +1,285 @@ +# ============================================================================ +# Pod Manifest リファレンス +# ============================================================================ +# Pod の宣言的設定 (`PodManifest` / `PodManifestConfig`)。 +# +# カスケード層は下から順に +# 1. builtin defaults (`manifest::defaults`) +# 2. user manifest (`/manifest.toml`) +# 3. project manifest (cwd から上方向に探す `.insomnia/manifest.toml`) +# 4. programmatic overlay (呼び出し側が差し込む) +# 上の層が同名フィールドを上書き、scope rule と skills.directories は +# 累積マージ、tool_output.per_tool は key 単位でマージ。 +# +# パス解決: 相対パスは「その層の manifest ファイルが置かれているディレクトリ」 +# を base に絶対パスへ解決される (overlay 層は cwd)。マージは絶対化済みの +# 値同士で行われる。 +# +# 凡例: +# - 必須 … 値が無いと resolve エラー +# - 任意 … 省略可 +# - デフォルト … 省略時に採用される値 (None なら "なし") +# - 値 … enum 等で取り得る値 +# ---------------------------------------------------------------------------- + + +# ===== [pod] ================================================================ +# Pod メタデータ。 +[pod] + +# 必須。Pod の表示名 (ResolveError::MissingField("pod.name") の対象)。 +name = "example-agent" + +# 任意。デフォルト: なし。 +# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。 +# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`) +# とは別系統の単なるファイルパス。 +# prompt_pack = "./prompts.local.toml" + + +# ===== [model] ============================================================== +# LLM モデル設定。次の 3 形態を受ける: +# (a) `ref` 単独 — カタログから全部解決 +# (b) `ref` + 一部 override — auth など個別差し替え +# (c) `scheme` + `model_id` 直書き — カタログを使わない inline 指定 +# (b) / (c) では `ref` 未指定なら `scheme` / `model_id` / `auth` が必須。 +# (実際の必須判定は `crates/provider` の resolve 側で行う) +[model] + +# 任意。形式: "/"。 +# 最初の `/` だけで split されるので、`openrouter/anthropic/claude-sonnet-4` +# のように内部 model_id に `/` が含まれる場合もそのまま書ける。 +ref = "anthropic/claude-sonnet-4-6" + +# 任意 (ただし ref 未指定時は実質必須)。デフォルト: なし。 +# 値: "anthropic" | "openai_chat" | "openai_responses" | "gemini" +# scheme = "anthropic" + +# 任意 (ref 未指定時は実質必須)。デフォルト: なし。 +# プロバイダが受け付けるモデル ID 文字列。 +# model_id = "claude-sonnet-4-20250514" + +# 任意。デフォルト: scheme ごとの組み込み既定 URL。 +# base_url = "https://api.anthropic.com" + +# 任意 (ref 未指定時は実質必須)。デフォルト: なし。 +# kind の値: "none" | "api_key" | "codex_oauth" +# - "none" … 認証不要 (ローカル Ollama 等) +# - "api_key" … env / file のいずれかで key を渡す。両方指定なら env 優先。 +# env 未指定時は scheme ごとの既定環境変数: +# Anthropic -> INSOMNIA_API_KEY_ANTHROPIC +# OpenaiChat / OpenaiResponses -> INSOMNIA_API_KEY_OPENAI +# Gemini -> INSOMNIA_API_KEY_GEMINI +# file 指定時、相対パスは manifest base 起点で解決。 +# - "codex_oauth" … ChatGPT OAuth (`~/.codex/auth.json`)。追加フィールドなし。 +# auth = { kind = "none" } +# auth = { kind = "api_key" } # env のみ既定使用 +# auth = { kind = "api_key", env = "MY_ANTHROPIC_KEY" } +# auth = { kind = "api_key", file = "./sk-ant.local" } +# auth = { kind = "codex_oauth" } + +# 任意。デフォルト: モデルカタログ → provider.default_capability → scheme 既定 +# の順で解決される。明示 override したいときだけ書く。 +# [model.capability] +# # 値: "none" | "sequential" | "parallel" +# tool_calling = "parallel" +# # 値: "none" | "json_object" | "json_schema" +# structured_output = "json_schema" +# # 任意。値: 省略 (= None) | "effort" | "budget_tokens" | "both" +# reasoning = "both" +# # 任意。デフォルト: false +# vision = false +# # 値: { kind = "explicit", max_breakpoints = } | { kind = "auto" } +# prompt_caching = { kind = "explicit", max_breakpoints = 4 } + + +# ===== [worker] ============================================================= +# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。 +[worker] + +# 任意。デフォルト: "$insomnia/default" (`defaults::DEFAULT_INSTRUCTION`)。 +# システムプロンプト本体の `PromptLoader` 参照。 +# プレフィクス: "$insomnia/..." | "$user/..." | "$workspace/..." +# instruction = "$insomnia/default" + +# 任意。デフォルト: なし (プロバイダ任せ)。 +# 1 レスポンスあたりの出力 token 上限。 +# max_tokens = 4096 + +# 任意。デフォルト: なし (無制限)。 +# 1 セッションの最大ターン数。NonZeroU32 — 0 は parse エラー。 +# max_turns = 50 + +# 任意。デフォルト: なし (プロバイダ既定)。 +# temperature = 0.3 + +# 任意。デフォルト: なし (プロバイダ既定)。 +# top_p = 0.9 + +# 任意。デフォルト: なし (プロバイダ既定)。 +# top_k = 40 + +# 任意。デフォルト: 空配列。 +# stop_sequences = ["\n\n", ""] + +# 任意。デフォルト: なし。 +# 値: +# - 文字列 effort: "minimal" | "low" | "medium" | "high" | "xhigh" +# あるいは provider-native な任意ラベル文字列 +# - 整数: Anthropic 系 thinking.budget_tokens (-1 で dynamic) +# reasoning = "medium" +# reasoning = -1 + +# 任意。tool 実行 content の byte 長キャップ。 +# セクション省略時は default_max_bytes = 16 * 1024、per_tool 空。 +# [worker.tool_output] +# # 任意。デフォルト: 16384 (`defaults::TOOL_OUTPUT_MAX_BYTES` = 16 KiB)。 +# default_max_bytes = 16384 +# +# # 任意。デフォルト: 空マップ。tool 名キーで個別キャップ上書き。 +# # キーは tool の登録名 ("Read", "Grep", "Glob", ...)。 +# [worker.tool_output.per_tool] +# Read = 32768 +# Grep = 4096 + + +# ===== [scope] ============================================================== +# Pod がアクセスできるディレクトリ/ファイル範囲。 +# - allow: 最低 1 件必要 (空だと ResolveError / ScopeError::EmptyAllow)。 +# 複数 allow がマッチした場合は最大の permission が採用される。 +# - deny : 任意。マッチした deny の最小 permission *未満* に effective を +# 押し下げる (deny.read で完全遮断、deny.write で Read 止まり)。 +# `target` は最終的に絶対パスでなければならない。manifest 内では相対 OK +# (manifest base 起点で resolve)。 + +# 必須: 最低 1 件の allow ルール。 +[[scope.allow]] +# 必須。manifest 内では相対 OK (base 起点で絶対化)。 +target = "./" +# 必須。値: "read" | "write" +permission = "write" +# 任意。デフォルト: true (再帰的にマッチ)。 +# false の場合、ルール自身および直下の child のみマッチ。 +# recursive = true + +# allow は何件でも書ける。 +# [[scope.allow]] +# target = "/abs/docs" +# permission = "read" +# recursive = false + +# 任意。アクセスを *ルール内 permission 未満* に押し下げる。 +# [[scope.deny]] +# target = "./secrets" +# permission = "write" # write を禁止 → 該当パスは Read までに降格 +# recursive = true +# +# [[scope.deny]] +# target = "./secrets/key" +# permission = "read" # read 自体を禁止 → アクセス完全遮断 +# recursive = true + + +# ===== [compaction] ========================================================= +# コンテキスト圧縮 (Prune / Compact)。セクション省略で両方無効。 +# セクションを書いた時点で Prune は有効化、Compact は閾値が None なら無効。 +# [compaction] +# +# # 任意。デフォルト: 3 (`defaults::PRUNE_PROTECTED_TURNS`)。 +# # pruning から保護する末尾ターン数。 +# prune_protected_turns = 3 +# +# # 任意。デフォルト: 4096 (`defaults::PRUNE_MIN_SAVINGS`)。 +# # prune が発火するための最低節約 token 推定値。 +# prune_min_savings = 4096 +# +# # 任意。デフォルト: なし (proactive compact 無効)。 +# # ターン間チェック (Controller post-run)。占有 token > これ で次ターン前に compact。 +# compact_threshold = 80000 +# +# # 任意。デフォルト: なし (safety-net compact 無効)。 +# # ターン中チェック (PodInterceptor::pre_llm_request)。期待される関係: +# # compact_threshold < compact_request_threshold (proactive を先に発火)。 +# # 逆順設定は許容するが warn ログを出す。 +# compact_request_threshold = 90000 +# +# # 任意。デフォルト: 8000 (`defaults::COMPACT_RETAINED_TOKENS`)。 +# # compaction 後の history 末尾に verbatim で残す token budget。 +# compact_retained_tokens = 8000 +# +# # 任意。デフォルト: 8000 (`defaults::COMPACT_AUTO_READ_BUDGET`)。 +# # compact worker が `mark_read_required` で取り込める累計 token。 +# compact_auto_read_budget = 8000 +# +# # 任意。デフォルト: 50000 (`defaults::COMPACT_WORKER_MAX_INPUT_TOKENS`)。 +# # compact worker 自身の累積入力 token cap。超過で abort (circuit breaker)。 +# compact_worker_max_input_tokens = 50000 +# +# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。 +# # compact 専用モデルを使う場合のみ書く ([model] と同じ形式)。 +# # [compaction.model] +# # ref = "anthropic/claude-haiku-4-5" + + +# ===== [memory] ============================================================= +# Memory subsystem の opt-in。 +# - セクションが *ある* … memory tools (MemoryRead/Write/Edit) を登録、 +# `/memory/` と `/knowledge/` +# の通常 write を Pod 自体に対して deny する。 +# - セクションが *無い* … 何も起きない (legacy 動作)。 +# `[memory]` だけ書いて中身を省略するのも有効 (全フィールド既定値で有効化)。 +# [memory] +# +# # 任意。デフォルト: Pod の pwd (構築時)。 +# # 必ず絶対パス (相対なら manifest base 起点で resolve)。 +# workspace_root = "/abs/path/to/workspace" +# +# # 任意。デフォルト: tool 側既定 = 20。 +# # MemoryQuery / KnowledgeQuery が 1 回に返す最大件数。 +# query_result_limit = 20 +# +# # 任意。デフォルト: tool 側既定 = 3。 +# # 各マッチ前後に表示するコンテキスト行数。`query` 省略時は無視。 +# query_excerpt_lines = 3 +# +# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。 +# # Phase 1 (extract) ワーカーのモデル ([model] と同じ形式)。 +# # Haiku / 4o-mini / Flash クラスの軽量 reasoning モデル推奨。 +# # [memory.extract_model] +# # ref = "anthropic/claude-haiku-4-5" +# +# # 任意。デフォルト: なし (Phase 1 自動発火を完全停止)。 +# # 前回 extract pointer 以降の累積入力 token がこの値を超えると Phase 1 起動。 +# # ※ memory tools と resident injection は extract_threshold が None でも動く。 +# extract_threshold = 30000 +# +# # 任意。デフォルト: 30000 (`defaults::MEMORY_EXTRACT_WORKER_MAX_INPUT_TOKENS`)。 +# # extract worker 自身の累積入力 token cap (超過で abort)。 +# extract_worker_max_input_tokens = 30000 +# +# # 任意。デフォルト: メインモデルを `clone_boxed()` で複製。 +# # Phase 2 (consolidation) ワーカーのモデル。reasoning クラス推奨。 +# # [memory.consolidation_model] +# # ref = "anthropic/claude-sonnet-4-6" +# +# # 任意。デフォルト: なし。 +# # `_staging/` のエントリ数がこの値以上で Phase 2 発火 (files / bytes は OR)。 +# consolidation_threshold_files = 50 +# +# # 任意。デフォルト: なし。 +# # `_staging/` の総バイト数がこの値以上で Phase 2 発火 (files / bytes は OR)。 +# # files / bytes の両方が None だと Phase 2 完全無効。 +# consolidation_threshold_bytes = 1048576 + + +# ===== [skills] ============================================================= +# 外部 Agent Skills (`SKILL.md`) を Workflow として読み込むディレクトリ群。 +# セクション省略 = 何もロードしない (implicit な `$config_dir/skills/` 検索や +# builtin probe は存在しない)。 +# [skills] +# +# # 任意。デフォルト: 空配列。 +# # 各エントリは skills *root* (root の child 各々が `/SKILL.md` を持つ +# # skill バンドル)。root 自身は skill ではない。 +# # 相対パスは manifest base 起点で解決。マージ時は層を跨いで concat される。 +# directories = [".claude/skills", ".cursor/skills"] diff --git a/tickets/permission-extension-point.md b/tickets/permission-extension-point.md index 8554324a..40070c0a 100644 --- a/tickets/permission-extension-point.md +++ b/tickets/permission-extension-point.md @@ -10,44 +10,82 @@ OpenCode はパターンベースのルール(tool × pattern → allow/deny/a ## 方針 -`PreToolCall` Hook として実装する。マニフェストにルールを宣言し、 -insomnia 層の Hook 実装がツール呼び出し時に評価する。 +Permission の評価点は `PreToolCall` Hook とする。マニフェストにルールを宣言し、 +insomnia 層が built-in の `PreToolCall` Hook として登録してツール呼び出し時に評価する。 + +`deny` はターン全体の Cancel/Abort ではない。対象 tool call を実行せず、 +permission denied を表す `is_error = true` の synthetic tool result を履歴に追加してターンを継続する。 +これにより provider が要求する `tool_use` / `tool_result` の対応を壊さず、LLM は拒否結果を見て別手段の検討やユーザーへの説明に進める。 + +`ask` は `deny` の代替ではなく、ユーザー承認待ちを明示する action として扱う。承認されれば元の tool call を実行し、拒否されれば `deny` と同じ synthetic tool result に落とす。 ```toml -[[permission]] +[permissions] +default_action = "allow" # allow | deny | ask + +[[permissions.rule]] tool = "bash" pattern = "rm *" action = "deny" -[[permission]] +[[permissions.rule]] tool = "file_write" pattern = "*.env" action = "deny" +``` -[[permission]] -tool = "*" +allowlist 型にしたい場合: + +```toml +[permissions] +default_action = "deny" + +[[permissions.rule]] +tool = "read" +pattern = "*" +action = "allow" + +[[permissions.rule]] +tool = "grep" pattern = "*" action = "allow" ``` -評価順序(OpenCode に倣う): -1. 最初にマッチした `deny` → 拒否 -2. すべてマッチする `allow` → 許可 -3. それ以外 → `ask`(ユーザー確認) +確認待ちを基本にしたい場合: + +```toml +[permissions] +default_action = "ask" + +[[permissions.rule]] +tool = "bash" +pattern = "rm *" +action = "deny" +``` + +評価順序: +1. `[permissions]` が無い場合、Permission 層は無効。従来通り実行する +2. `[permissions]` がある場合、`default_action` は必須 +3. `[[permissions.rule]]` は宣言順に評価し、最初に `tool` と `pattern` が一致した rule の `action` を採用する +4. 一致する rule が無ければ `permissions.default_action` を採用する ## 設計ポイント - 設計原則3: 新しい trait は作らない。`PreToolCall` Hook として実装 - 設計原則2: マニフェストに宣言した以上、insomnia 層が解決する -- `ask` アクションは Pod Protocol の拡張が必要(Method に `PermissionReply` を追加) +- Permission Hook は Pod が自動登録する built-in Hook とし、ユーザー追加 Hook より先に評価する +- `deny` は `PreToolAction::Abort` / 既存 `Skip` では表現しない。tool call 単位の拒否結果を履歴へ返すため、Worker 側に synthetic tool result を返せる action が必要 +- `ask` アクションは Pod Protocol の拡張が必要(Event に `PermissionRequest`、Method に `PermissionReply` を追加) +- `ask` を処理できない実行環境では暗黙に待機しない。設定時に validation error とするか、fail closed で `deny` 相当の synthetic tool result に落とす - `Scope` との関係: Scope は書き込みの物理的境界、Permission はツール実行のポリシー。補完関係 - ルール評価はパターンマッチのみ。コンテキスト依存の判断はしない(シンプルに保つ) ## 段階的実装 1. **拡張ポイントの記録**(今): docs/pod.md の拡張ポイント表に追加 -2. **deny/allow の実装**(ツール実装時): PreToolCall Hook でパターン評価 -3. **ask の実装**(Protocol 拡張時): Method/Event に Permission 関連メッセージを追加 +2. **deny/allow の実装**(ツール実装時): `default_action` と rule 評価を manifest に追加し、built-in `PreToolCall` Hook でパターン評価 +3. **拒否 tool result の実装**: `deny` が turn Abort ではなく synthetic error tool result として履歴に入るよう Worker の pre-tool action を拡張 +4. **ask の実装**(Protocol 拡張時): Method/Event に Permission 関連メッセージを追加し、承認後に元 tool call を実行、拒否時は synthetic error tool result を返す ## 受け皿になる外部仕様 @@ -55,7 +93,7 @@ action = "allow" `tickets/agent-skills.md` で ingest した SKILL.md の frontmatter には agent-skills 仕様の experimental field である `allowed-tools` (例: `["Read", "Bash"]`) が含まれる場合がある。`crates/memory/src/skill.rs::parse_skill_md` 時点では `tracing::warn!` で受け流しているだけで、実効化していない。 -本チケットの Permission 層が固まった時点で、Skill 由来 Workflow を実行中のみ当該 skill の `allowed-tools` リストに含まれるツールしか走れない形で反映する。スコープは「Workflow 実行中」相当 (Workflow の system message が context に乗っているターン) に限定する想定。skill 単位で local な permission 集合を持つので、グローバルな `[[permission]]` ルールとは独立に評価する。 +本チケットの Permission 層が固まった時点で、Skill 由来 Workflow を実行中のみ当該 skill の `allowed-tools` リストに含まれるツールしか走れない形で反映する。スコープは「Workflow 実行中」相当 (Workflow の system message が context に乗っているターン) に限定する想定。skill 単位で local な permission 集合を持つので、グローバルな `[[permissions.rule]]` ルールとは独立に評価する。 実装上の足がかり: - `WorkflowRecord` の出所は `WorkflowSource::Skill { dir }` で識別済み (`crates/memory/src/workflow.rs`)。`dir` は manifest `[skills] directories` に書かれた skill ルートそのもの diff --git a/tickets/tui-assistant-markdown.md b/tickets/tui-assistant-markdown.md new file mode 100644 index 00000000..5464ae35 --- /dev/null +++ b/tickets/tui-assistant-markdown.md @@ -0,0 +1,82 @@ +# TUI: Assistant 応答の Markdown スタイル表示 + +## 背景 + +LLM の出力は実質 Markdown だが、TUI は `Block::AssistantText { text }` を +`push_padded_lines` で 1 行ずつ素のテキストとして +`Style(MessageKind::Assistant)` に流しているだけで、`**強調**` / +`` `code` `` / `# 見出し` / `- list` 等の記号がそのまま見える状態になっている +(`crates/tui/src/ui.rs:592-595, 640-648`)。スタイルが付かないため、 +構造のあるアシスタント応答は読みにくい。 + +ratatui 0.30 の `Vec>` で表現できる範囲のスタイル付けで +十分目的を満たせる。既存の `wrap_line_into`(`crates/tui/src/ui.rs:473-`)が +span 単位のラップを既に実装しているため、Markdown レンダラは +スタイル付きの `Vec` を返すだけでよく、ラップ/スクロール/overview +畳み込みの仕組みを変える必要はない。 + +## 方針 + +- `pulldown-cmark` を `tui` クレートの依存に追加し、Event ストリームを + 既存の `MessageKind` / `ratatui::style::Style` 体系へ畳み込む小さな + 自前レンダラを `crates/tui/src/markdown.rs` に置く。 +- レンダラの公開面は `render(text: &str, base: Style) -> Vec>` + 程度の 1 関数。`Block::AssistantText` の `Mode::Detail` / `Mode::Normal` + 描画から呼ぶ。`Mode::Overview` は現行通り 1 行畳み込み(Markdown 記号 + 含めて表示しても情報量はほぼ同じなので素のテキストでよい)。 +- ストリーミング中の不完全要素(未閉鎖の `**` や開きっぱなしのフェンス) + は CommonMark の流儀(テキスト扱い/EOF で閉じる)に任せる。挙動が + 破綻する場合だけ末尾要素を素のテキストにフォールバックする小さな + 後処理を入れる余地を残す。 +- `tui-markdown` クレートは採用しない。syntect 依存でビルドが肥大する + 割にカスタマイズが効かず、本クレートの色味(`MessageKind` + パレット)との整合を握りにくいため。 + +## 対応する Markdown 要素 + +最小限の "対応できる範囲" を以下に限定する。CommonMark + GFM の一部。 + +- 強調: `**bold**` / `*italic*` / `~~strike~~`(GFM) +- インラインコード: `` `code` `` +- フェンスコードブロック: ` ```lang ` / ` ``` `(言語タグは無視、 + ブロック全体を等幅・低彩度の背景/前景で塗る) +- 見出し: H1〜H4(H5/H6 は H4 と同等) +- 箇条書きリスト: `-` / `*` / `+`、ネスト可(深さ分インデント) +- 順序リスト: `1.` / `1)`、ネスト可(番号は元の値で表示) +- 引用: `> ...`(ネスト可) +- 水平線: `---` / `***` +- リンク: `[text](url)` の `text` をリンク色で着色(URL は表示しない) + +## 範囲外 + +- 表(GFM table) +- 画像 `![alt](src)`(テキストとしても表示しない) +- HTML パススルー(タグはそのまま生テキストで出る) +- 数式(`$...$` / `$$...$$`) +- コードブロックの syntax highlighting +- リンクのターミナルクリック(OSC 8)/URL の自動表示 +- `Thinking` 本文 / `SystemMessage` への適用 + (同じ `markdown::render` を後で差せばよい。本チケットは + `Block::AssistantText` のみ) +- ライブストリーム最中の "途中要素のフォールバック" の作り込み + (CommonMark のデフォルト挙動で破綻が見えたら別チケット) + +## 完了条件 + +- アシスタント応答に含まれる上記要素が、それぞれ視認可能な + スタイルで描画される。 +- ストリーミング中、テキストが追記されるたびに描画が更新され、 + フェンスコードブロックの開きが先に着いて中身が後から流れる + ようなケースでも、テキスト全体の見た目が大きく崩れない。 +- `Mode::Detail` / `Mode::Normal` で Markdown スタイルが、 + `Mode::Overview` では従来通りの 1 行畳み込みが出る。 +- 既存の `wrap_line_into` によるラップ・右パディング・スクロール + が引き続き機能する(行幅計算が乱れない)。 + +## 影響範囲 + +- `crates/tui/Cargo.toml`: `pulldown-cmark` を追加(`cargo add` 経由)。 +- `crates/tui/src/markdown.rs`: 新設。`render(&str, Style) -> Vec>`。 +- `crates/tui/src/ui.rs`: `Block::AssistantText` 分岐で Markdown + レンダラを呼ぶ。`Mode::Overview` は現行のまま。 +- `crates/tui/src/main.rs` または `lib.rs`: 新モジュールの宣言。