7#include <unordered_set>
9#include "absl/strings/ascii.h"
10#include "absl/strings/match.h"
11#include "absl/strings/str_format.h"
12#include "absl/strings/strip.h"
13#include "absl/time/clock.h"
14#include "absl/time/time.h"
24#include "imgui/imgui.h"
25#include "imgui/misc/cpp/imgui_stdlib.h"
30#include "nlohmann/json.hpp"
41 return (*agent_dir /
"agent_chat_history.json").string();
45 return (*temp_dir /
"agent_chat_history.json").string();
47 return (std::filesystem::current_path() /
"agent_chat_history.json").string();
52 if (!agent_dir.ok()) {
55 return *agent_dir /
"sessions";
59 using FileClock = std::filesystem::file_time_type::clock;
60 auto now_file = FileClock::now();
61 auto now_sys = std::chrono::system_clock::now();
63 std::chrono::time_point_cast<std::chrono::system_clock::duration>(
64 value - now_file + now_sys);
65 return absl::FromChrono(converted);
69 std::string trimmed = std::string(absl::StripAsciiWhitespace(text));
70 if (trimmed.empty()) {
73 constexpr size_t kMaxLen = 64;
74 if (trimmed.size() > kMaxLen) {
75 trimmed = trimmed.substr(0, kMaxLen - 3);
76 trimmed.append(
"...");
82 const std::filesystem::path& path,
83 const std::vector<cli::agent::ChatMessage>& history) {
84 for (
const auto& msg : history) {
86 !msg.message.empty()) {
87 std::string title =
TrimTitle(msg.message);
93 std::string fallback = path.stem().string();
94 if (!fallback.empty()) {
152 const absl::StatusOr<cli::agent::ChatMessage>& response) {
154 if (!response.ok()) {
157 "Agent Error: " + std::string(response.status().message()),
160 LOG_ERROR(
"AgentChat",
"Agent Error: %s",
161 response.status().ToString().c_str());
174 if (since < absl::Seconds(5)) {
183 std::vector<std::filesystem::path> candidates;
184 std::unordered_set<std::string> seen;
191 if (
auto sessions_dir = ResolveAgentSessionsDir()) {
193 if (std::filesystem::exists(*sessions_dir, ec)) {
194 for (
const auto& entry :
195 std::filesystem::directory_iterator(*sessions_dir, ec)) {
199 if (!entry.is_regular_file(ec)) {
202 auto path = entry.path();
203 if (path.extension() !=
".json") {
206 if (!absl::EndsWith(path.stem().string(),
"_history")) {
209 const std::string key = path.string();
210 if (seen.insert(key).second) {
211 candidates.push_back(std::move(path));
218 for (
const auto& path : candidates) {
225 if (snapshot_or.ok()) {
226 const auto& snapshot = snapshot_or.value();
227 entry.
message_count =
static_cast<int>(snapshot.history.size());
228 entry.
title = BuildConversationTitle(path, snapshot.history);
229 if (!snapshot.history.empty()) {
233 if (std::filesystem::exists(path, ec)) {
235 FileTimeToAbsl(std::filesystem::last_write_time(path, ec));
238 if (entry.
is_active && snapshot.history.empty()) {
239 entry.
title =
"Current Session";
242 entry.
title = path.stem().string();
250 if (a.is_active != b.is_active) {
256 last_conversation_refresh_ = absl::Now();
259void AgentChat::SelectConversation(
const std::filesystem::path& path) {
263 active_history_path_ = path;
264 auto status = LoadHistory(path.string());
266 if (toast_manager_) {
267 toast_manager_->Show(std::string(status.message()), ToastType::kError,
273 RefreshConversationList(
true);
276void AgentChat::Draw(
float available_height) {
280 RefreshConversationList(
false);
285 const float content_width = ImGui::GetContentRegionAvail().x;
286 const bool wide_layout = content_width >= 680.0f;
289 RenderToolbar(!wide_layout);
292 float content_height = available_height > 0
294 : ImGui::GetContentRegionAvail().y;
297 const float sidebar_width =
298 std::clamp(content_width * 0.28f, 220.0f, 320.0f);
299 if (ImGui::BeginChild(
"##ChatSidebar",
300 ImVec2(sidebar_width, content_height),
true,
301 ImGuiWindowFlags_NoScrollbar)) {
302 RenderConversationSidebar(content_height);
309 if (ImGui::BeginChild(
"##ChatMain", ImVec2(0, content_height),
false,
310 ImGuiWindowFlags_NoScrollbar)) {
311 const float input_height =
312 ImGui::GetTextLineHeightWithSpacing() * 3.5f + 24.0f;
313 const float history_height =
314 std::max(140.0f, ImGui::GetContentRegionAvail().y - input_height);
316 if (ImGui::BeginChild(
"##ChatHistory", ImVec2(0, history_height),
true,
317 ImGuiWindowFlags_NoScrollbar)) {
319 if (scroll_to_bottom_ ||
320 (auto_scroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY())) {
321 ImGui::SetScrollHereY(1.0f);
322 scroll_to_bottom_ =
false;
327 RenderInputBox(input_height);
332void AgentChat::RenderToolbar(
bool compact) {
333 const auto& theme = AgentUI::GetTheme();
339 active_history_path_ = ResolveAgentChatHistoryPath();
340 RefreshConversationList(
true);
350 const bool history_available = AgentChatHistoryCodec::Available();
351 ImGui::BeginDisabled(!history_available);
353 const std::string filepath = active_history_path_.empty()
354 ? ResolveAgentChatHistoryPath()
355 : active_history_path_.string();
356 if (
auto status = SaveHistory(filepath); !status.ok()) {
357 if (toast_manager_) {
358 toast_manager_->Show(
359 "Failed to save history: " + std::string(status.message()),
362 }
else if (toast_manager_) {
363 toast_manager_->Show(
"Chat history saved", ToastType::kSuccess);
365 RefreshConversationList(
true);
370 const std::string filepath = active_history_path_.empty()
371 ? ResolveAgentChatHistoryPath()
372 : active_history_path_.string();
373 if (
auto status = LoadHistory(filepath); !status.ok()) {
374 if (toast_manager_) {
375 toast_manager_->Show(
376 "Failed to load history: " + std::string(status.message()),
379 }
else if (toast_manager_) {
380 toast_manager_->Show(
"Chat history loaded", ToastType::kSuccess);
383 ImGui::EndDisabled();
385 if (compact && !conversations_.empty()) {
387 ImGui::SetNextItemWidth(220.0f);
388 const char* current_label =
"Current Session";
389 for (
const auto& entry : conversations_) {
390 if (entry.is_active) {
391 current_label = entry.title.c_str();
395 if (ImGui::BeginCombo(
"##conversation_combo", current_label)) {
396 for (
const auto& entry : conversations_) {
397 bool selected = entry.is_active;
398 if (ImGui::Selectable(entry.title.c_str(), selected)) {
399 SelectConversation(entry.path);
402 ImGui::SetItemDefaultFocus();
411 ImGui::OpenPopup(
"ChatOptions");
413 if (ImGui::BeginPopup(
"ChatOptions")) {
414 ImGui::Checkbox(
"Auto-scroll", &auto_scroll_);
415 ImGui::Checkbox(
"Timestamps", &show_timestamps_);
416 ImGui::Checkbox(
"Reasoning", &show_reasoning_);
421void AgentChat::RenderConversationSidebar(
float height) {
422 const auto& theme = AgentUI::GetTheme();
424 if (!AgentChatHistoryCodec::Available()) {
425 ImGui::TextDisabled(
"Chat history persistence unavailable.");
426 ImGui::TextDisabled(
"Build with JSON support to enable sessions.");
431 const auto& config = context_->agent_config();
432 ImGui::TextColored(theme.text_secondary_color,
"Agent");
435 panel_opener_(
"agent.configuration");
439 panel_opener_(
"agent.builder");
442 ImGui::TextDisabled(
"Provider: %s", config.ai_provider.empty()
444 : config.ai_provider.c_str());
445 ImGui::TextDisabled(
"Model: %s", config.ai_model.empty()
447 : config.ai_model.c_str());
453 ImGui::TextColored(theme.text_secondary_color,
"Conversations");
456 RefreshConversationList(
true);
458 if (ImGui::IsItemHovered()) {
459 ImGui::SetTooltip(
"Refresh list");
463 ImGui::InputTextWithHint(
"##conversation_filter",
"Search...",
464 conversation_filter_,
sizeof(conversation_filter_));
467 const float list_height = std::max(0.0f, height - 80.0f);
468 if (ImGui::BeginChild(
"ConversationList", ImVec2(0, list_height),
false,
469 ImGuiWindowFlags_NoScrollbar)) {
470 std::string filter = absl::AsciiStrToLower(conversation_filter_);
471 if (conversations_.empty()) {
472 ImGui::TextDisabled(
"No saved conversations yet.");
475 for (
const auto& entry : conversations_) {
476 std::string title_lower = absl::AsciiStrToLower(entry.title);
477 if (!filter.empty() && title_lower.find(filter) == std::string::npos) {
481 ImGui::PushID(index++);
484 entry.is_active ? theme.status_active : theme.panel_bg_darker},
485 {ImGuiCol_HeaderHovered, theme.panel_bg_color},
486 {ImGuiCol_HeaderActive, theme.status_active}});
487 if (ImGui::Selectable(entry.title.c_str(), entry.is_active,
488 ImGuiSelectableFlags_SpanAllColumns)) {
489 SelectConversation(entry.path);
492 ImGui::TextDisabled(
"%d msg%s", entry.message_count,
493 entry.message_count == 1 ?
"" :
"s");
494 if (entry.last_updated != absl::InfinitePast()) {
497 "%s", absl::FormatTime(
"%b %d, %H:%M", entry.last_updated,
498 absl::LocalTimeZone())
510void AgentChat::RenderHistory() {
511 const auto& history = agent_service_.GetHistory();
513 if (history.empty()) {
514 ImGui::TextDisabled(
"Start a conversation with the agent...");
517 for (
size_t i = 0; i < history.size(); ++i) {
518 RenderMessage(history[i],
static_cast<int>(i));
519 if (message_spacing_ > 0) {
520 ImGui::Dummy(ImVec2(0, message_spacing_));
524 if (waiting_for_response_) {
525 RenderThinkingIndicator();
532 ImGui::PushID(index);
535 float wrap_width = ImGui::GetContentRegionAvail().x * 0.85f;
536 ImGui::SetCursorPosX(
537 is_user ? (ImGui::GetWindowContentRegionMax().x - wrap_width - 10) : 10);
542 if (show_timestamps_) {
543 std::string timestamp =
544 absl::FormatTime(
"%H:%M:%S", msg.
timestamp, absl::LocalTimeZone());
557 const auto& theme = AgentUI::GetTheme();
558 ImVec4 bg_col = is_user ? theme.panel_bg_darker : theme.panel_bg_color;
563 std::string content_id =
"msg_content_" + std::to_string(index);
564 if (ImGui::BeginChild(
565 content_id.c_str(), ImVec2(wrap_width, 0),
566 ImGuiChildFlags_Borders | ImGuiChildFlags_AlwaysUseWindowPadding,
571 }
else if (!is_user && msg.
json_pretty.has_value()) {
572 ImGui::TextWrapped(
"%s", msg.
json_pretty.value().c_str());
575 auto blocks = ParseMessageContent(msg.
message);
576 for (
const auto& block : blocks) {
577 if (block.type == ContentBlock::Type::kCode) {
578 RenderCodeBlock(block.content, block.language, index);
580 ImGui::TextWrapped(
"%s", block.content.c_str());
587 RenderProposalQuickActions(msg, index);
592 RenderToolTimeline(msg);
603void AgentChat::RenderThinkingIndicator() {
609 thinking_animation_ += ImGui::GetIO().DeltaTime;
610 int dots = (int)(thinking_animation_ * 3) % 4;
622void AgentChat::RenderInputBox(
float height) {
623 const auto& theme = AgentUI::GetTheme();
624 if (ImGui::BeginChild(
"ChatInput", ImVec2(0, height),
false,
625 ImGuiWindowFlags_NoScrollbar)) {
629 ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
630 ImGuiInputTextFlags_CtrlEnterForNewLine;
632 float button_row_height = ImGui::GetFrameHeightWithSpacing();
633 float input_height = std::max(
634 48.0f, ImGui::GetContentRegionAvail().y - button_row_height - 6.0f);
636 ImGui::PushItemWidth(-1);
637 if (ImGui::IsWindowAppearing()) {
638 ImGui::SetKeyboardFocusHere();
641 bool submit = ImGui::InputTextMultiline(
"##Input", input_buffer_,
642 sizeof(input_buffer_),
643 ImVec2(0, input_height), flags);
645 bool clicked_send =
false;
648 clicked_send = ImGui::Button(
ICON_MD_SEND " Send", ImVec2(90, 0));
652 input_buffer_[0] =
'\0';
655 if (submit || clicked_send) {
656 std::string msg(input_buffer_);
657 while (!msg.empty() &&
658 std::isspace(
static_cast<unsigned char>(msg.back()))) {
664 input_buffer_[0] =
'\0';
665 ImGui::SetKeyboardFocusHere(-1);
669 ImGui::PopItemWidth();
678 if (msg.
message.find(
"Proposal:") != std::string::npos) {
680 if (ImGui::Button(
"View Proposal")) {
682 if (proposal_drawer_) {
683 proposal_drawer_->Show();
689void AgentChat::RenderCodeBlock(
const std::string& code,
690 const std::string& language,
int msg_index) {
691 const auto& theme = AgentUI::GetTheme();
693 ImVec2(0, 0), {.bg = theme.code_bg_color},
true,
694 ImGuiWindowFlags_AlwaysAutoResize);
696 if (!language.empty()) {
697 ImGui::TextDisabled(
"%s", language.c_str());
701 ImGui::SetClipboardText(code.c_str());
703 toast_manager_->Show(
"Code copied", ToastType::kSuccess);
706 ImGui::TextUnformatted(code.c_str());
711 telemetry_history_.push_back(telemetry);
713 if (telemetry_history_.size() > 100) {
714 telemetry_history_.erase(telemetry_history_.begin());
718void AgentChat::SetLastPlanSummary(
const std::string& summary) {
719 last_plan_summary_ = summary;
722std::vector<AgentChat::ContentBlock> AgentChat::ParseMessageContent(
723 const std::string& content) {
724 std::vector<ContentBlock> blocks;
728 while (pos < content.length()) {
729 size_t code_start = content.find(
"```", pos);
730 if (code_start == std::string::npos) {
732 blocks.push_back({ContentBlock::Type::kText, content.substr(pos),
""});
737 if (code_start > pos) {
738 blocks.push_back({ContentBlock::Type::kText,
739 content.substr(pos, code_start - pos),
""});
742 size_t code_end = content.find(
"```", code_start + 3);
743 if (code_end == std::string::npos) {
746 {ContentBlock::Type::kText, content.substr(code_start),
""});
751 std::string language;
752 size_t newline = content.find(
'\n', code_start + 3);
753 size_t content_start = code_start + 3;
754 if (newline != std::string::npos && newline < code_end) {
755 language = content.substr(code_start + 3, newline - (code_start + 3));
756 content_start = newline + 1;
759 std::string code = content.substr(content_start, code_end - content_start);
760 blocks.push_back({ContentBlock::Type::kCode, code, language});
768void AgentChat::RenderTableData(
775 if (ImGui::BeginTable(
"ToolResultTable",
776 static_cast<int>(table.
headers.size()),
777 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
778 ImGuiTableFlags_ScrollY)) {
780 for (
const auto& header : table.
headers) {
781 ImGui::TableSetupColumn(header.c_str());
783 ImGui::TableHeadersRow();
786 for (
const auto& row : table.
rows) {
787 ImGui::TableNextRow();
788 for (
size_t col = 0; col < std::min(row.size(), table.
headers.size());
790 ImGui::TableSetColumnIndex(
static_cast<int>(col));
791 ImGui::TextWrapped(
"%s", row[col].c_str());
808 if (meta.tool_names.empty() && meta.tool_iterations == 0) {
816 const auto& timeline_theme = AgentUI::GetTheme();
818 {{ImGuiCol_Header, timeline_theme.panel_bg_darker},
819 {ImGuiCol_HeaderHovered, timeline_theme.panel_bg_color}});
823 meta.tool_iterations, meta.latency_seconds);
825 if (ImGui::TreeNode(
"##ToolTimeline",
"%s", header.c_str())) {
827 if (!meta.tool_names.empty()) {
828 ImGui::TextDisabled(
"Tools called:");
829 for (
const auto& tool : meta.tool_names) {
830 ImGui::BulletText(
"%s", tool.c_str());
836 ImGui::TextDisabled(
"Provider: %s", meta.provider.c_str());
837 if (!meta.model.empty()) {
838 ImGui::TextDisabled(
"Model: %s", meta.model.c_str());
845absl::Status AgentChat::LoadHistory(
const std::string& filepath) {
847 auto snapshot_or = AgentChatHistoryCodec::Load(filepath);
848 if (!snapshot_or.ok()) {
849 return snapshot_or.status();
851 const auto& snapshot = snapshot_or.value();
852 agent_service_.ReplaceHistory(snapshot.history);
853 history_loaded_ =
true;
854 scroll_to_bottom_ =
true;
855 return absl::OkStatus();
857 return absl::UnimplementedError(
"JSON support not available");
861absl::Status AgentChat::SaveHistory(
const std::string& filepath) {
864 snapshot.
history = agent_service_.GetHistory();
866 std::filesystem::path path(filepath);
868 if (path.has_parent_path()) {
869 std::filesystem::create_directories(path.parent_path(), ec);
871 return absl::InternalError(absl::StrFormat(
872 "Failed to create history directory: %s", ec.message()));
876 return AgentChatHistoryCodec::Save(path, snapshot);
878 return absl::UnimplementedError(
"JSON support not available");
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
absl::StatusOr< ChatMessage > SendMessage(const std::string &message)
static absl::StatusOr< Snapshot > Load(const std::filesystem::path &path)
float thinking_animation_
void SendMessage(const std::string &message)
void SetContext(AgentUIContext *context)
void RefreshConversationList(bool force=false)
void Initialize(ToastManager *toast_manager, ProposalDrawer *proposal_drawer)
ToastManager * toast_manager_
AgentUIContext * context_
std::filesystem::path active_history_path_
void SetRomContext(Rom *rom)
bool waiting_for_response_
std::vector< ConversationEntry > conversations_
cli::agent::ConversationalAgentService agent_service_
ProposalDrawer * proposal_drawer_
absl::Time last_conversation_refresh_
void HandleAgentResponse(const absl::StatusOr< cli::agent::ChatMessage > &response)
cli::agent::ConversationalAgentService * GetAgentService()
Unified context for agent UI components.
ImGui drawer for displaying and managing agent proposals.
void Show(const std::string &message, ToastType type=ToastType::kInfo, float ttl_seconds=3.0f)
RAII guard for ImGui style colors.
RAII guard for ImGui style vars.
RAII guard for ImGui child windows with optional styling.
#define ICON_MD_FOLDER_OPEN
#define ICON_MD_BUILD_CIRCLE
#define ICON_MD_AUTO_FIX_HIGH
#define ICON_MD_CONTENT_COPY
#define ICON_MD_DELETE_FOREVER
#define ICON_MD_ADD_COMMENT
#define ICON_MD_SMART_TOY
#define LOG_ERROR(category, format,...)
std::string BuildConversationTitle(const std::filesystem::path &path, const std::vector< cli::agent::ChatMessage > &history)
std::string TrimTitle(const std::string &text)
std::string ResolveAgentChatHistoryPath()
std::optional< std::filesystem::path > ResolveAgentSessionsDir()
absl::Time FileTimeToAbsl(std::filesystem::file_time_type value)
ImVec4 GetDisabledColor()
std::vector< std::string > headers
std::vector< std::vector< std::string > > rows
std::optional< ModelMetadata > model_metadata
std::optional< TableData > table_data
std::optional< std::string > json_pretty
std::vector< cli::agent::ChatMessage > history
std::filesystem::path path