use crate::mosaic::WidgetContext;
use nightshade::prelude::serde::{Deserialize, Serialize};
use nightshade::prelude::*;
use crate::app_context::AppContext;
use crate::messages::EditorMessage;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
enum ChatStatus {
#[default]
Idle,
Streaming,
ToolUse,
}
#[derive(Clone)]
enum ChatMessageRole {
User,
Assistant,
}
#[derive(Clone)]
struct ChatMessage {
role: ChatMessageRole,
text: String,
thinking: Option<String>,
tool_uses: Vec<String>,
}
#[derive(Clone)]
struct ToolUseState {
tool_name: String,
finished: bool,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(crate = "nightshade::prelude::serde")]
pub struct ChatWidget {
#[serde(skip)]
messages: Vec<ChatMessage>,
#[serde(skip)]
input_text: String,
#[serde(skip)]
streaming_text: String,
#[serde(skip)]
thinking_text: String,
#[serde(skip)]
active_tools: Vec<ToolUseState>,
#[serde(skip)]
status: ChatStatus,
#[serde(skip)]
session_id: Option<String>,
#[serde(skip)]
initialized: bool,
#[serde(skip)]
scroll_to_bottom: bool,
#[serde(skip)]
error_message: Option<String>,
}
impl Default for ChatWidget {
fn default() -> Self {
Self {
messages: Vec::new(),
input_text: String::new(),
streaming_text: String::new(),
thinking_text: String::new(),
active_tools: Vec::new(),
status: ChatStatus::Idle,
session_id: None,
initialized: false,
scroll_to_bottom: false,
error_message: None,
}
}
}
impl ChatWidget {
fn ensure_initialized(&mut self, context: &mut WidgetContext<AppContext, EditorMessage>) {
if self.initialized {
return;
}
self.initialized = true;
if context.app.claude_command_sender.is_some() {
return;
}
let (command_sender, command_receiver, event_sender, event_receiver) =
nightshade::claude::create_cli_channels();
let config = nightshade::claude::ClaudeConfig {
system_prompt: Some(
"You are an assistant embedded in the Nightshade game engine editor. \
You can use MCP tools to manipulate the 3D scene."
.to_string(),
),
mcp_config: nightshade::claude::McpConfig::Auto,
..Default::default()
};
nightshade::claude::spawn_cli_worker(command_receiver, event_sender, config);
context.app.claude_command_sender = Some(command_sender);
context.app.claude_event_receiver = Some(event_receiver);
}
fn drain_events(&mut self, context: &mut WidgetContext<AppContext, EditorMessage>) {
let Some(receiver) = context.app.claude_event_receiver.as_ref() else {
return;
};
while let Ok(event) = receiver.try_recv() {
match event {
nightshade::claude::CliEvent::SessionStarted { session_id } => {
self.session_id = Some(session_id);
}
nightshade::claude::CliEvent::TextDelta { text } => {
self.streaming_text.push_str(&text);
self.scroll_to_bottom = true;
}
nightshade::claude::CliEvent::ThinkingDelta { text } => {
self.thinking_text.push_str(&text);
self.scroll_to_bottom = true;
}
nightshade::claude::CliEvent::ToolUseStarted { tool_name, .. } => {
self.active_tools.push(ToolUseState {
tool_name,
finished: false,
});
self.status = ChatStatus::ToolUse;
self.scroll_to_bottom = true;
}
nightshade::claude::CliEvent::ToolUseInputDelta { .. } => {}
nightshade::claude::CliEvent::ToolUseFinished { .. } => {
if let Some(tool) = self.active_tools.last_mut() {
tool.finished = true;
}
self.status = ChatStatus::Streaming;
}
nightshade::claude::CliEvent::TurnComplete { .. } => {
self.finalize_assistant_message();
self.status = ChatStatus::Idle;
}
nightshade::claude::CliEvent::Complete { .. } => {
self.finalize_assistant_message();
self.status = ChatStatus::Idle;
}
nightshade::claude::CliEvent::Error { message } => {
self.error_message = Some(message);
self.status = ChatStatus::Idle;
}
}
}
}
fn finalize_assistant_message(&mut self) {
let text = std::mem::take(&mut self.streaming_text);
let thinking = if self.thinking_text.is_empty() {
None
} else {
Some(std::mem::take(&mut self.thinking_text))
};
let tool_uses: Vec<String> = self
.active_tools
.drain(..)
.map(|tool| tool.tool_name)
.collect();
if !text.is_empty() || thinking.is_some() || !tool_uses.is_empty() {
self.messages.push(ChatMessage {
role: ChatMessageRole::Assistant,
text,
thinking,
tool_uses,
});
self.scroll_to_bottom = true;
}
}
fn send_message(&mut self, context: &mut WidgetContext<AppContext, EditorMessage>) {
let prompt = self.input_text.trim().to_string();
if prompt.is_empty() {
return;
}
self.messages.push(ChatMessage {
role: ChatMessageRole::User,
text: prompt.clone(),
thinking: None,
tool_uses: Vec::new(),
});
if let Some(sender) = context.app.claude_command_sender.as_ref() {
let _ = sender.send(nightshade::claude::CliCommand::StartQuery {
prompt,
session_id: self.session_id.clone(),
model: None,
});
self.status = ChatStatus::Streaming;
}
self.input_text.clear();
self.scroll_to_bottom = true;
}
pub(crate) fn render(
&mut self,
ui: &mut egui::Ui,
context: &mut WidgetContext<AppContext, EditorMessage>,
) {
self.ensure_initialized(context);
self.drain_events(context);
let available = ui.available_rect_before_wrap();
ui.painter()
.rect_filled(available, 0.0, ui.style().visuals.panel_fill);
let input_height = 60.0;
let status_height = 20.0;
let messages_rect = egui::Rect::from_min_max(
available.min,
egui::pos2(
available.max.x,
available.max.y - input_height - status_height,
),
);
let status_rect = egui::Rect::from_min_max(
egui::pos2(available.min.x, messages_rect.max.y),
egui::pos2(available.max.x, messages_rect.max.y + status_height),
);
let input_rect = egui::Rect::from_min_max(
egui::pos2(available.min.x, status_rect.max.y),
available.max,
);
self.render_status(ui, status_rect);
self.render_messages(ui, messages_rect);
self.render_input(ui, input_rect, context);
}
fn render_status(&self, ui: &mut egui::Ui, rect: egui::Rect) {
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.add_space(8.0);
match self.status {
ChatStatus::Idle => {
child.colored_label(egui::Color32::from_rgb(120, 120, 120), "Ready");
}
ChatStatus::Streaming => {
child.spinner();
child.add_space(4.0);
child.colored_label(egui::Color32::from_rgb(100, 180, 255), "Responding...");
}
ChatStatus::ToolUse => {
child.spinner();
child.add_space(4.0);
let tool_name = self
.active_tools
.last()
.map(|tool| tool.tool_name.as_str())
.unwrap_or("unknown");
child.colored_label(
egui::Color32::from_rgb(255, 180, 50),
format!("Using tool: {}", tool_name),
);
}
}
if let Some(ref error) = self.error_message {
child.add_space(8.0);
child.colored_label(egui::Color32::from_rgb(255, 80, 80), error);
}
}
fn render_messages(&mut self, ui: &mut egui::Ui, rect: egui::Rect) {
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::top_down(egui::Align::LEFT)),
);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(&mut child, |ui| {
ui.add_space(8.0);
if self.messages.is_empty() && self.streaming_text.is_empty() {
ui.centered_and_justified(|ui| {
ui.colored_label(
egui::Color32::from_rgb(120, 120, 120),
"Send a message to start a conversation with Claude.",
);
});
return;
}
for message in &self.messages {
self.render_single_message(ui, message);
ui.add_space(8.0);
}
if !self.thinking_text.is_empty() || !self.streaming_text.is_empty() {
let in_progress_message = ChatMessage {
role: ChatMessageRole::Assistant,
text: self.streaming_text.clone(),
thinking: if self.thinking_text.is_empty() {
None
} else {
Some(self.thinking_text.clone())
},
tool_uses: self
.active_tools
.iter()
.map(|tool| tool.tool_name.clone())
.collect(),
};
self.render_single_message(ui, &in_progress_message);
}
if self.scroll_to_bottom {
ui.scroll_to_cursor(Some(egui::Align::BOTTOM));
self.scroll_to_bottom = false;
}
});
}
fn render_single_message(&self, ui: &mut egui::Ui, message: &ChatMessage) {
let (label_text, label_color) = match message.role {
ChatMessageRole::User => ("You", egui::Color32::from_rgb(100, 180, 255)),
ChatMessageRole::Assistant => ("Claude", egui::Color32::from_rgb(255, 160, 50)),
};
ui.horizontal(|ui| {
ui.colored_label(label_color, egui::RichText::new(label_text).strong());
});
if let Some(ref thinking) = message.thinking {
egui::CollapsingHeader::new(
egui::RichText::new("Thinking...")
.italics()
.color(egui::Color32::from_rgb(150, 150, 150)),
)
.id_salt(ui.next_auto_id())
.show(ui, |ui| {
ui.colored_label(egui::Color32::from_rgb(150, 150, 150), thinking);
});
}
for tool_name in &message.tool_uses {
ui.horizontal(|ui| {
ui.colored_label(
egui::Color32::from_rgb(255, 180, 50),
format!(" [tool: {}]", tool_name),
);
});
}
if !message.text.is_empty() {
ui.label(&message.text);
}
}
fn render_input(
&mut self,
ui: &mut egui::Ui,
rect: egui::Rect,
context: &mut WidgetContext<AppContext, EditorMessage>,
) {
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::left_to_right(egui::Align::Center)),
);
child.add_space(4.0);
let text_edit_width = rect.width() - 70.0;
let response = child.add(
egui::TextEdit::multiline(&mut self.input_text)
.desired_width(text_edit_width)
.desired_rows(2)
.hint_text("Message Claude...")
.return_key(egui::KeyboardShortcut::new(
egui::Modifiers::SHIFT,
egui::Key::Enter,
)),
);
let enter_pressed = response.has_focus()
&& child.input(|input| input.key_pressed(egui::Key::Enter) && !input.modifiers.shift);
let is_busy = self.status != ChatStatus::Idle;
child.add_space(4.0);
let send_enabled = !self.input_text.trim().is_empty() && !is_busy;
if child
.add_enabled(send_enabled, egui::Button::new("Send"))
.clicked()
|| (enter_pressed && send_enabled)
{
self.send_message(context);
}
}
}