greetingカードの作成
This commit is contained in:
parent
c48abf062e
commit
0c29de1b10
3
TODO.md
3
TODO.md
|
|
@ -5,8 +5,9 @@
|
|||
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
|
||||
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||
- [ ] TUI 拡充
|
||||
- [ ] 通知チャネル (Warn/Error 可視化) → [tickets/tui-notification-channel.md](tickets/tui-notification-channel.md)
|
||||
- [ ] 空 Pod 起動時の Greeting カード → [tickets/tui-greeting-card.md](tickets/tui-greeting-card.md)
|
||||
- [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md)
|
||||
- [ ] 新しい Pod を spawn する UI の設計 → [tickets/tui-pod-spawn-ui.md](tickets/tui-pod-spawn-ui.md)
|
||||
- [ ] ツール呼び出しのフレーム更新型表示 → [tickets/tui-tool-call-ui.md](tickets/tui-tool-call-ui.md)
|
||||
|
|
|
|||
|
|
@ -58,10 +58,12 @@ impl PodController {
|
|||
let (event_tx, _) = broadcast::channel::<Event>(256);
|
||||
|
||||
let manifest_toml = toml::to_string_pretty(pod.manifest()).unwrap_or_default();
|
||||
let greeting = build_greeting(&pod);
|
||||
let shared_state = Arc::new(PodSharedState::new(
|
||||
pod.manifest().pod.name.clone(),
|
||||
pod.session_id(),
|
||||
manifest_toml.clone(),
|
||||
greeting,
|
||||
));
|
||||
|
||||
// Create runtime directory and write initial files
|
||||
|
|
@ -337,6 +339,37 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
fn build_greeting<C, St>(pod: &Pod<C, St>) -> protocol::Greeting
|
||||
where
|
||||
C: LlmClient,
|
||||
St: Store,
|
||||
{
|
||||
let manifest = pod.manifest();
|
||||
let provider = match manifest.provider.kind {
|
||||
manifest::ProviderKind::Anthropic => "anthropic",
|
||||
manifest::ProviderKind::Openai => "openai",
|
||||
manifest::ProviderKind::Gemini => "gemini",
|
||||
manifest::ProviderKind::Ollama => "ollama",
|
||||
};
|
||||
// The tool list mirrors `builtin_tools`. A fresh `ScopedFs`/`Tracker`
|
||||
// is instantiated only to invoke the factories for name extraction;
|
||||
// the instances themselves are discarded.
|
||||
let fs = tools::ScopedFs::new(pod.scope().clone(), pod.pwd().to_path_buf());
|
||||
let tracker = tools::Tracker::new();
|
||||
let tool_names = tools::builtin_tools(fs, tracker)
|
||||
.iter()
|
||||
.map(|def| def().0.name)
|
||||
.collect();
|
||||
protocol::Greeting {
|
||||
pod_name: manifest.pod.name.clone(),
|
||||
cwd: pod.pwd().display().to_string(),
|
||||
provider: provider.into(),
|
||||
model: manifest.provider.model.clone(),
|
||||
scope_summary: pod.scope().summary(),
|
||||
tools: tool_names,
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_error_code(e: &PodError) -> ErrorCode {
|
||||
match e {
|
||||
PodError::Worker(we) => match we {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,14 @@ mod tests {
|
|||
"test-pod".into(),
|
||||
session_store::new_session_id(),
|
||||
"[pod]\nname = \"test-pod\"".into(),
|
||||
protocol::Greeting {
|
||||
pod_name: "test-pod".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "anthropic".into(),
|
||||
model: "claude".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub struct PodSharedState {
|
|||
pub pod_name: String,
|
||||
pub session_id: SessionId,
|
||||
pub manifest_toml: String,
|
||||
pub greeting: protocol::Greeting,
|
||||
pub status: RwLock<PodStatus>,
|
||||
pub history: RwLock<Vec<Item>>,
|
||||
}
|
||||
|
|
@ -25,11 +26,17 @@ pub enum PodStatus {
|
|||
}
|
||||
|
||||
impl PodSharedState {
|
||||
pub fn new(pod_name: String, session_id: SessionId, manifest_toml: String) -> Self {
|
||||
pub fn new(
|
||||
pod_name: String,
|
||||
session_id: SessionId,
|
||||
manifest_toml: String,
|
||||
greeting: protocol::Greeting,
|
||||
) -> Self {
|
||||
Self {
|
||||
pod_name,
|
||||
session_id,
|
||||
manifest_toml,
|
||||
greeting,
|
||||
status: RwLock::new(PodStatus::Idle),
|
||||
history: RwLock::new(Vec::new()),
|
||||
}
|
||||
|
|
@ -86,9 +93,21 @@ mod tests {
|
|||
"test-pod".into(),
|
||||
session_store::new_session_id(),
|
||||
"[pod]\nname = \"test-pod\"".into(),
|
||||
test_greeting(),
|
||||
)
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
pod_name: "test-pod".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "anthropic".into(),
|
||||
model: "claude".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_status_is_idle() {
|
||||
let state = test_state();
|
||||
|
|
|
|||
|
|
@ -85,7 +85,15 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
|||
.iter()
|
||||
.map(|item| serde_json::to_value(item).expect("Item is Serialize"))
|
||||
.collect();
|
||||
if writer.write(&Event::History { items: values }).await.is_err() {
|
||||
let greeting = handle.shared_state.greeting.clone();
|
||||
if writer
|
||||
.write(&Event::History {
|
||||
items: values,
|
||||
greeting,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,9 +66,25 @@ pub enum Event {
|
|||
},
|
||||
History {
|
||||
items: Vec<serde_json::Value>,
|
||||
greeting: Greeting,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pod self-description rendered by the TUI when a session starts empty.
|
||||
///
|
||||
/// Built once in the Pod controller from the resolved manifest and
|
||||
/// transmitted alongside `Event::History` so clients don't need their
|
||||
/// own view of the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Greeting {
|
||||
pub pod_name: String,
|
||||
pub cwd: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub scope_summary: String,
|
||||
pub tools: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supporting types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -153,12 +169,22 @@ mod tests {
|
|||
fn event_history_format() {
|
||||
let event = Event::History {
|
||||
items: vec![serde_json::json!({"type": "message", "role": "user"})],
|
||||
greeting: Greeting {
|
||||
pod_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "anthropic".into(),
|
||||
model: "claude".into(),
|
||||
scope_summary: "Writable:\n - /tmp".into(),
|
||||
tools: vec!["Read".into()],
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "history");
|
||||
assert!(parsed["data"]["items"].is_array());
|
||||
assert_eq!(parsed["data"]["items"][0]["role"], "user");
|
||||
assert_eq!(parsed["data"]["greeting"]["pod_name"], "test");
|
||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use protocol::{Event, Method};
|
||||
use protocol::{Event, Greeting, Method};
|
||||
|
||||
pub struct App {
|
||||
pub pod_name: String,
|
||||
|
|
@ -23,6 +23,7 @@ pub enum OutputItem {
|
|||
TurnHeader(String),
|
||||
Padded(MessageKind, String),
|
||||
PaddedRight(MessageKind, String),
|
||||
GreetingCard(Greeting),
|
||||
Blank,
|
||||
}
|
||||
|
||||
|
|
@ -165,8 +166,13 @@ impl App {
|
|||
self.current_tool = None;
|
||||
}
|
||||
Event::ToolCallArgsDelta { .. } => {}
|
||||
Event::History { items } => {
|
||||
Event::History { items, greeting } => {
|
||||
self.restore_history(&items);
|
||||
if self.turn_index == 0 {
|
||||
self.output_queue
|
||||
.insert(0, OutputItem::GreetingCard(greeting));
|
||||
self.output_queue.insert(1, OutputItem::Blank);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ use ratatui::Frame;
|
|||
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use protocol::Greeting;
|
||||
|
||||
use crate::app::{App, MessageKind, OutputItem, fmt_tokens};
|
||||
|
||||
/// Draw the fixed viewport (3 lines: separator, status, input).
|
||||
|
|
@ -62,6 +64,34 @@ pub fn flush_output(
|
|||
.render(buf.area, buf);
|
||||
})?;
|
||||
}
|
||||
OutputItem::GreetingCard(g) => {
|
||||
let lines = greeting_lines(&g);
|
||||
let inner_width = width.saturating_sub(4);
|
||||
let body_height: u16 = lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let w = l.width() as u16;
|
||||
if inner_width == 0 || w == 0 {
|
||||
1
|
||||
} else {
|
||||
w.div_ceil(inner_width)
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
let height = body_height + 2; // top + bottom border
|
||||
terminal.insert_before(height, |buf| {
|
||||
Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::DarkGray))
|
||||
.padding(Padding::horizontal(1)),
|
||||
)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(buf.area, buf);
|
||||
})?;
|
||||
}
|
||||
OutputItem::PaddedRight(kind, text) => {
|
||||
let style = kind_style(&kind);
|
||||
let lines: Vec<Line> = text
|
||||
|
|
@ -159,6 +189,41 @@ fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
|
|||
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
|
||||
}
|
||||
|
||||
fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
|
||||
let label = Style::default().fg(Color::DarkGray);
|
||||
let value = Style::default().fg(Color::White);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
g.pod_name.clone(),
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{} ({})", g.model, g.provider),
|
||||
Style::default().fg(Color::Cyan),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("cwd: ", label),
|
||||
Span::styled(g.cwd.clone(), value),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("tools: ", label),
|
||||
Span::styled(g.tools.join(", "), value),
|
||||
]));
|
||||
|
||||
if !g.scope_summary.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
for line in g.scope_summary.lines() {
|
||||
lines.push(Line::from(Span::styled(line.to_owned(), value)));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn kind_style(kind: &MessageKind) -> Style {
|
||||
match kind {
|
||||
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
|
||||
|
|
|
|||
91
tickets/native-gui-mvp.md
Normal file
91
tickets/native-gui-mvp.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# ネイティブ GUI クライアント MVP
|
||||
|
||||
## 背景
|
||||
|
||||
TUI は ratatui の `insert_before` を使った append-only モデルで動いており、ツール呼び出しのライブ更新(引数のストリーミングプレビュー、状態遷移の視覚化)のような「既に描いた領域の書き換え」が本質的に苦手である。`tickets/tui-tool-call-ui.md` で妥協的な拡張策(inline viewport を可変化してアクティブフレームを保持)は立てたが、terminal のスクロールバックモデルと live-updating な LLM UX の相性は根本的に悪く、どう組んでも制約と妥協がついて回る。
|
||||
|
||||
一方、GUI 側ならそもそも**全領域が毎フレーム再描画される retained-mode**なので、ツールフレームの live 更新・折り畳み・インタラクティブな介入といった操作が自然に書ける。TUI は軽量・ssh 親和のクライアントとして残し、**リッチな対話は GUI クライアントに切り出す**方針を取る。
|
||||
|
||||
## 方針
|
||||
|
||||
### アーキテクチャ
|
||||
|
||||
- **プロセス分離 + ソケット接続を維持**。GUI は独立バイナリとして動き、Pod はこれまで通り別プロセス。
|
||||
- **通信は既存の `protocol` クレート**(`Method` / `Event`)をそのまま使う。GUI と TUI は同じ protocol を喋る。
|
||||
- **Pod の spawn は GUI から直接行う**。manifest を選択 → `pod` バイナリを subprocess として起動 → その socket に接続、という流れ。daemon 層は導入しない。
|
||||
- **MVP は単一 Pod**。複数 Pod の並列管理は本チケットの範囲外とし、GUI 側の protocol 抽象が固まってから別チケットで拡張する。
|
||||
|
||||
### GUI フレームワーク: GPUI
|
||||
|
||||
- Rust ネイティブ + async-aware で、`protocol` クレートを直接リンクできる。
|
||||
- GPU 加速の retained-mode で、ツールフレームの live 更新が素直に書ける。
|
||||
- virtualized list / text input / scrollable 履歴など、LLM チャット UI に必要な部品が一通り揃っている。
|
||||
- 既知のリスク: プラットフォーム成熟度(macOS > Linux > Windows)、独立ライブラリとしての新しさ、Markdown レンダラ等のウィジェット生態系が Tauri/Iced より薄い。本 MVP は Linux only なのでプラットフォーム面のリスクは受容できる。
|
||||
|
||||
### プラットフォーム
|
||||
|
||||
- **Linux only**。macOS / Windows は MVP の範囲外。GPUI の Linux サポートが動作する前提で組む。
|
||||
|
||||
## MVP スコープ
|
||||
|
||||
### 含む
|
||||
|
||||
1. **Pod の spawn と接続**
|
||||
- manifest ファイルを選ぶ UI(ファイルダイアログ or CLI 引数)
|
||||
- `pod` バイナリを subprocess として起動し、その socket に接続
|
||||
- 接続確立後は TUI と同じ protocol で対話
|
||||
2. **現 TUI の機能相当**
|
||||
- 入力フィールド + 送信
|
||||
- ストリーミングテキストの表示(`TextDelta` → 追記)
|
||||
- ターンヘッダ / ターン統計 / ステータスバー相当の情報表示
|
||||
- セッション再開時の履歴復元(`Event::History`)
|
||||
- エラー表示
|
||||
- cancel / graceful shutdown
|
||||
3. **ツール呼び出しのフレーム更新 UI**
|
||||
- `tickets/tui-tool-call-ui.md` で定義したライフサイクル(Pending → Streaming → Executing → Done/Error)をそのまま GPUI 側で実装
|
||||
- TUI と違い inline viewport の制約が無いので、履歴スクロール内でも自由に再描画できる
|
||||
- `ToolCallArgsDelta` を毎フレーム反映してライブプレビュー
|
||||
- 完了済みフレームは履歴内に状態が焼き込まれた形で残る
|
||||
4. **Pod の明示的 shutdown**
|
||||
- GUI から shutdown 操作を行い、Pod subprocess を graceful に終了させる
|
||||
- shutdown 完了後は GUI 自体も正常終了する
|
||||
|
||||
### 含まない
|
||||
|
||||
- 複数 Pod の並列表示・切替(別チケット)
|
||||
- daemon 層の導入
|
||||
- macOS / Windows サポート
|
||||
- ツール結果のリッチレンダリング(Markdown 整形、シンタックスハイライト、diff 表示等)
|
||||
- ツール実行への対話的介入(permission ask/reply の UI 実装は `tickets/permission-extension-point.md` 側)
|
||||
- protocol の拡張(compact 通知・R-R パターン等は `tickets/protocol-design.md` 側で進行し、GUI は完了次第追従する)
|
||||
- GUI 内での manifest 編集
|
||||
- テーマカスタマイズ、キーバインドカスタマイズ
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **GPUI のイベントループと Tokio ランタイムの統合**: GPUI 側の executor に Pod からの socket イベントをどう流し込むか
|
||||
- **socket client の置き場所**: 現 `crates/tui/src/client.rs` と同等のクライアントを別 crate に切り出して共有するか、GUI crate 内に閉じて持つか
|
||||
- **Pod subprocess のライフサイクル管理**: GUI プロセスが落ちたときの Pod 側の後処理(orphan prevention)、Pod が異常終了したときの GUI 側の復帰 UX
|
||||
- **ツールフレームのデータモデル**: `OutputItem` 列に載せるか別コレクションで持つか(TUI 側の設計議論と共通部分あり。共有可能なら abstract して流用)
|
||||
- **履歴スクロールの挙動**: 下端追従(chat 流儀)と手動スクロール時の追従停止
|
||||
- **入力エリアの多行対応**: 単一行でよいか、複数行 + Ctrl+Enter 送信等
|
||||
|
||||
## 完了条件
|
||||
|
||||
- Linux 上で GUI バイナリが起動し、manifest を指定すると `pod` subprocess を起動して socket 接続する。
|
||||
- 基本的なチャット(user 入力 → assistant 応答のストリーミング → ターン統計)が TUI と同等に動く。
|
||||
- ツール呼び出しが 1 フレームとして表示され、`ToolCallArgsDelta` がライブでプレビューされ、完了時に視覚的に状態が変わる。
|
||||
- セッション再開時に履歴(ツール呼び出し含む)が復元される。
|
||||
- GUI から shutdown 操作で Pod を正常終了させられ、GUI 自体も正常終了する。
|
||||
|
||||
## 新規クレート構成(案)
|
||||
|
||||
- `crates/gui/` (GPUI を使うバイナリ)
|
||||
- socket client を共有する場合は `crates/client/` を新設し、TUI と GUI がそれぞれ依存する形に整理する選択肢もある
|
||||
|
||||
## 範囲外(再掲・重要なもの)
|
||||
|
||||
- 複数 Pod の並列管理・切替。単一 Pod に集中する。
|
||||
- macOS / Windows。Linux only で完結させる。
|
||||
- protocol の新規イベント追加。既存 protocol で足りる範囲に留める。
|
||||
- TUI の廃止。TUI は軽量クライアントとして並行して残る。
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
# TUI: 空 Pod で起動したときの Greeting カード
|
||||
|
||||
## 背景
|
||||
|
||||
新しい Pod を作って TUI で起動した直後、ユーザーは**ほぼ空の画面**を見ることになる。Pod がどういう設定で動いているか(cwd、scope、利用可能なツール、model)、何を打てばよいか(入力フォーカスはどこか、最初のメッセージを送ればよいだけなのか)が画面から読み取れない。
|
||||
|
||||
最初のターンが始まる前に、Pod の自己紹介と使い方ヒントを1枚のカードとして見せることで、**空画面の困惑と Pod 設定の不透明さを同時に解消**したい。
|
||||
|
||||
## 要件
|
||||
|
||||
### 表示タイミング
|
||||
|
||||
- セッションが空(まだ turn が1つも無い)の状態で TUI を開いた直後に表示する。
|
||||
- ユーザーが最初のメッセージを送信した時点で消えるか、履歴の頭に flatten して残るかは設計時に判断。
|
||||
|
||||
### カードに載せる情報
|
||||
|
||||
最低限:
|
||||
|
||||
- **Pod 名**(manifest の `pod.name`)
|
||||
- **cwd**(絶対パス)
|
||||
- **model / provider**
|
||||
- **scope の要約**(readable / writable パス。既に `Scope::summary()` が存在する)
|
||||
- **登録ツール一覧**(ツール名のみ)
|
||||
|
||||
任意で:
|
||||
|
||||
- システムプロンプトの冒頭数行、または AGENTS.md が取り込まれているかどうかの表示
|
||||
- 基本キーバインドのヒント(終了・通知ペインなど、別チケットで足される機能も想定)
|
||||
|
||||
### 再開時の扱い
|
||||
|
||||
- 既存セッション(turn がある状態)を再開したときは表示しない。履歴冒頭に残すかどうかは設計時に判断(残すなら flatten 済みカードとして)。
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **flatten するか、消すか**: 初回メッセージ送信で消える一時カード vs 履歴の一部として残るカード
|
||||
- **カード内のレイアウト**: 単一 widget か、ブロック構成か
|
||||
- **AGENTS.md 取り込み済みかどうかの可視化**を入れるか(AGENTS.md 取り込みチケット完了後に追加検討でも可)
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `test_pod.local.toml` のような空セッション Pod を TUI で開くと、Pod 名・cwd・scope・ツール一覧がカード状に表示される。
|
||||
- ユーザーが最初のメッセージを送った後、カードは規定の挙動(消える or 残る)に従う。
|
||||
- 既存セッションを再開したときは Greeting が出ない。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- テーマ・配色のカスタマイズ。
|
||||
- Pod ごとにカスタムの Greeting 本文を持たせる仕組み。
|
||||
106
tickets/tui-tool-call-ui.md
Normal file
106
tickets/tui-tool-call-ui.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# TUI: ツール呼び出しをアクティブ領域内で更新型表示する
|
||||
|
||||
## 背景
|
||||
|
||||
現在の TUI はツール呼び出しを append-only のテキスト行として扱っており、1 回の呼び出しごとに `[tool] Read` / `[tool] Read done (128 bytes)` / `[tool result] ...` の**3〜4 行**が別々に積まれる。プロバイダがストリーミングで流してくる `ToolCallArgsDelta` は `crates/tui/src/app.rs:167` で丸ごと無視されており、**ユーザーはツールがどんな引数で呼ばれようとしているかを完了まで見られない**。
|
||||
|
||||
この制約は単なる実装漏れではなく、現行の**レンダリングモデルから来る構造的なもの**である。
|
||||
|
||||
### 現行レンダリングモデル
|
||||
|
||||
TUI は ratatui の **inline viewport + `insert_before`** で動いている:
|
||||
|
||||
- `crates/tui/src/ui.rs` の `draw()` で描く inline viewport は **separator / status / input の 3 行固定**
|
||||
- 履歴は `terminal.insert_before(height, ...)` で inline viewport の**上に押し出す**
|
||||
- 一度 `insert_before` で流した行はターミナルのスクロールバックに入り、**アプリから書き換え不能**になる
|
||||
|
||||
つまり現行アーキテクチャには「update 可能な領域」という概念が inline viewport の 3 行以外に存在しない。`ToolCallArgsDelta` を delta ごとに行として積むと履歴が爆発するので暫定的に捨てている、というのが現状の正確な理解。
|
||||
|
||||
### 追跡して書き換えるような API は存在しない
|
||||
|
||||
スクロールバックに入った内容は terminal emulator の private buffer に属し、ANSI / crossterm の層でも編集できない。ratatui 側にも「過去に insert した行を更新する」API は無い。書き換えできる唯一の場所は **毎フレーム再描画される inline viewport の内部**。
|
||||
|
||||
## 方針: アクティブ領域として inline viewport を拡張する
|
||||
|
||||
「書き換えできる場所でだけ書き換える」という一点で現行モデルと両立させる:
|
||||
|
||||
1. **inline viewport を可変高さにする**。現行の 3 行固定(separator / status / input)に加え、`active` 領域を上部に確保する。
|
||||
2. **進行中のツール呼び出しフレームを active 領域に保持**する。`ToolCallStart` でフレームを作り、`ToolCallArgsDelta` で毎フレーム再描画し、引数の流入をライブプレビューする。
|
||||
3. **完了 + 引き継ぎのタイミングで `insert_before` に格下げ**する。ツールが終わり、次のブロック(assistant text / 次の tool / turn end)が始まった時点で、そのフレームの最終状態を `insert_before` でスクロールバックに追い出し、履歴として immutable にする。
|
||||
4. **active 領域は常に最小限に保つ**。何も進行していないときは 0 行。ツールが複数並列で走っているときはそれぞれのフレーム分だけ膨らむ。
|
||||
|
||||
このモデルは現行のスクロールバック活用・コピペ親和性を保ったまま、進行中の部分だけを mutable にする最小侵襲のアプローチ。fullscreen TUI への移行(全履歴を state から毎フレーム描く大工事)は取らない。
|
||||
|
||||
## 要件
|
||||
|
||||
### レンダリングモデルの変更
|
||||
|
||||
- inline viewport を可変高さにし、active 領域 + 固定 3 行(separator / status / input)の構成にする。
|
||||
- active 領域が画面高さを侵食しすぎないように**上限**を設ける(上限超過時は active 内をスクロールまたは省略)。上限値の決め方は設計時に判断。
|
||||
|
||||
### フレームのライフサイクル
|
||||
|
||||
ツール呼び出し 1 回 = フレーム 1 個。tool_use_id で同一フレームに集約する。
|
||||
|
||||
- `ToolCallStart`: フレームを作り active 領域に追加。状態 = **Pending**(args 未確定)
|
||||
- `ToolCallArgsDelta`: フレーム内の argument preview に delta を連結し、次フレームで再描画。状態 = **Streaming**
|
||||
- `ToolCallDone`: args 受信完了。状態 = **Executing**(実行中)
|
||||
- `ToolResult`: 対応する `tool_use_id` のフレームに紐付ける。結果を取り込み、状態 = **Done** または **Error**
|
||||
- フレームが Done/Error になり、次のブロック(text / tool / turn end 等)が来た時点で `insert_before` に flush し active 領域から削除する
|
||||
|
||||
### 並列ツール呼び出し
|
||||
|
||||
- 複数の tool_use_id が同時に active 領域に存在できる。
|
||||
- イベントが interleave した順序で届いても tool_use_id で正しく振り分ける。
|
||||
- 並列実行の並び順は active 領域内で安定(開始順が望ましい)。
|
||||
|
||||
### 引数のライブプレビュー
|
||||
|
||||
- delta を**生の文字列として連結**し、そのまま表示する。途中の JSON を整形しようとしない(破綻する)。
|
||||
- プレビュー長に上限を設ける。1 フレームが active 領域を占有しないように折り畳みまたは省略。
|
||||
- 非常に長い引数(Write の `content` 等)は最小実装では省略でよい。展開キーは任意。
|
||||
|
||||
### 状態による視覚差異
|
||||
|
||||
- Pending / Streaming / Executing / Done / Error を色・枠線・アイコン等で区別する。
|
||||
- 完了フレームが `insert_before` で格下げされた後も、Done/Error のスタイルは履歴側に残る(insert_before に渡す時点で最終スタイルを焼き込む)。
|
||||
|
||||
### 結果の取り込み
|
||||
|
||||
- `ToolResult` はフレームに統合され、**別行として insert_before されない**。
|
||||
- 結果のサマリ(`ToolResult.summary`)はフレーム内にインライン表示。本体(`content`)は最小実装では省略または簡易表示。リッチレンダリングは別チケット。
|
||||
|
||||
### 履歴再生との共存
|
||||
|
||||
- セッション再開で `Event::History` を受けたときも、過去のツール呼び出しはフレーム化された最終状態で `insert_before` に流れる(active 領域には入らない)。
|
||||
- 現行の `App::restore_history`(`app.rs:240`)にも手が入る。tool_call + tool_result を 1 フレームに集約する経路が必要。
|
||||
|
||||
### イベントの欠落耐性
|
||||
|
||||
- プロバイダ起因でイベントが欠落したり順序が逆転しても panic しない。
|
||||
- `ToolResult` が来ないまま `TurnEnd` になった場合、active 領域のフレームは **Incomplete** 状態で flush する(握りつぶさず視覚化する)。
|
||||
|
||||
## 設計で決めること
|
||||
|
||||
- **active 領域の高さ上限**: 画面の N% か絶対行数か、超過時は active 内スクロールか自動折り畳みか
|
||||
- **フレームのデータモデル**: 既存 `OutputItem` に `ToolFrame` バリアントを足すか、active 領域専用の別コレクションとして持つか(後者の方が flush タイミングの制御が素直)
|
||||
- **flush のトリガー**: 「次のブロックが来たら flush」で十分か、時間経過やユーザー操作でも flush する必要があるか
|
||||
- **折り畳み / 展開**: 最小実装に含めるか、別チケットに切り出すか
|
||||
- **並列フレームの並び順**: 開始順 / 終了順 / 画面上の発生順
|
||||
- **Error と通知チャネルの使い分け**: ツール失敗はフレームの Error 状態で済ませるか、`tickets/tui-notification-channel.md` の通知にも流すか
|
||||
|
||||
## 完了条件
|
||||
|
||||
- `ToolCallArgsDelta` の内容が active 領域のフレーム内でライブに表示される。
|
||||
- 1 回のツール呼び出しが 1 フレームとして表示され、start → args streaming → done → result の遷移が同じ領域の更新として見える。
|
||||
- 完了フレームは次のブロック到着時に自動で `insert_before` に格下げされ、スクロールバックで immutable な履歴として残る。
|
||||
- 並列ツール呼び出しでフレームが混ざらない(tool_use_id で正しく振り分けられる)。
|
||||
- `ToolResult` が欠落したまま turn が終わった場合でもフレームが Incomplete として履歴に残り、panic しない。
|
||||
- セッション再開時に過去のツール呼び出しがフレーム形式で履歴に復元される。
|
||||
|
||||
## 範囲外
|
||||
|
||||
- **フルスクリーン TUI への移行**。inline viewport + insert_before の組合せを維持する。
|
||||
- **ツール結果そのもののリッチレンダリング**(Markdown 整形・シンタックスハイライト・diff 表示等)。本チケットはコンテナと状態遷移に集中する。
|
||||
- **ツール実行のキャンセル / 介入 UI**。ユーザーがフレームから操作を起こす仕組みは別チケット。
|
||||
- **プロバイダ側イベントスキーマの変更**。既存の `ToolCallStart` / `ToolCallArgsDelta` / `ToolCallDone` / `ToolResult` をそのまま使う。
|
||||
Loading…
Reference in New Issue
Block a user