greetingカードの作成
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user