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();
62 auto converted = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
63 value - now_file + now_sys);
64 return absl::FromChrono(converted);
68 std::string trimmed = std::string(absl::StripAsciiWhitespace(text));
69 if (trimmed.empty()) {
72 constexpr size_t kMaxLen = 64;
73 if (trimmed.size() > kMaxLen) {
74 trimmed = trimmed.substr(0, kMaxLen - 3);
75 trimmed.append(
"...");
81 const std::vector<cli::agent::ChatMessage>& history) {
82 for (
const auto& msg : history) {
84 !msg.message.empty()) {
85 std::string title =
TrimTitle(msg.message);
91 std::string fallback = path.stem().string();
92 if (!fallback.empty()) {
150 const absl::StatusOr<cli::agent::ChatMessage>& response) {
152 if (!response.ok()) {
155 "Agent Error: " + std::string(response.status().message()),
158 LOG_ERROR(
"AgentChat",
"Agent Error: %s",
159 response.status().ToString().c_str());
172 if (since < absl::Seconds(5)) {
181 std::vector<std::filesystem::path> candidates;
182 std::unordered_set<std::string> seen;
189 if (
auto sessions_dir = ResolveAgentSessionsDir()) {
191 if (std::filesystem::exists(*sessions_dir, ec)) {
192 for (
const auto& entry :
193 std::filesystem::directory_iterator(*sessions_dir, ec)) {
197 if (!entry.is_regular_file(ec)) {
200 auto path = entry.path();
201 if (path.extension() !=
".json") {
204 if (!absl::EndsWith(path.stem().string(),
"_history")) {
207 const std::string key = path.string();
208 if (seen.insert(key).second) {
209 candidates.push_back(std::move(path));
216 for (
const auto& path : candidates) {
223 if (snapshot_or.ok()) {
224 const auto& snapshot = snapshot_or.value();
225 entry.
message_count =
static_cast<int>(snapshot.history.size());
226 entry.
title = BuildConversationTitle(path, snapshot.history);
227 if (!snapshot.history.empty()) {
231 if (std::filesystem::exists(path, ec)) {
232 entry.
last_updated = FileTimeToAbsl(std::filesystem::last_write_time(path, ec));
235 if (entry.
is_active && snapshot.history.empty()) {
236 entry.
title =
"Current Session";
239 entry.
title = path.stem().string();
247 if (a.is_active != b.is_active) {
253 last_conversation_refresh_ = absl::Now();
256void AgentChat::SelectConversation(
const std::filesystem::path& path) {
260 active_history_path_ = path;
261 auto status = LoadHistory(path.string());
263 if (toast_manager_) {
264 toast_manager_->Show(std::string(status.message()), ToastType::kError,
270 RefreshConversationList(
true);
273void AgentChat::Draw(
float available_height) {
277 RefreshConversationList(
false);
282 const float content_width = ImGui::GetContentRegionAvail().x;
283 const bool wide_layout = content_width >= 680.0f;
286 RenderToolbar(!wide_layout);
289 float content_height =
290 available_height > 0 ? available_height : ImGui::GetContentRegionAvail().y;
293 const float sidebar_width =
294 std::clamp(content_width * 0.28f, 220.0f, 320.0f);
295 if (ImGui::BeginChild(
"##ChatSidebar", ImVec2(sidebar_width, content_height),
296 true, ImGuiWindowFlags_NoScrollbar)) {
297 RenderConversationSidebar(content_height);
304 if (ImGui::BeginChild(
"##ChatMain", ImVec2(0, content_height),
false,
305 ImGuiWindowFlags_NoScrollbar)) {
306 const float input_height =
307 ImGui::GetTextLineHeightWithSpacing() * 3.5f + 24.0f;
308 const float history_height =
309 std::max(140.0f, ImGui::GetContentRegionAvail().y - input_height);
311 if (ImGui::BeginChild(
"##ChatHistory", ImVec2(0, history_height),
true,
312 ImGuiWindowFlags_NoScrollbar)) {
314 if (scroll_to_bottom_ ||
315 (auto_scroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY())) {
316 ImGui::SetScrollHereY(1.0f);
317 scroll_to_bottom_ =
false;
322 RenderInputBox(input_height);
327void AgentChat::RenderToolbar(
bool compact) {
328 const auto& theme = AgentUI::GetTheme();
334 active_history_path_ = ResolveAgentChatHistoryPath();
335 RefreshConversationList(
true);
345 const bool history_available = AgentChatHistoryCodec::Available();
346 ImGui::BeginDisabled(!history_available);
348 const std::string filepath =
349 active_history_path_.empty() ? ResolveAgentChatHistoryPath()
350 : active_history_path_.string();
351 if (
auto status = SaveHistory(filepath); !status.ok()) {
352 if (toast_manager_) {
353 toast_manager_->Show(
354 "Failed to save history: " + std::string(status.message()),
357 }
else if (toast_manager_) {
358 toast_manager_->Show(
"Chat history saved", ToastType::kSuccess);
360 RefreshConversationList(
true);
365 const std::string filepath =
366 active_history_path_.empty() ? ResolveAgentChatHistoryPath()
367 : active_history_path_.string();
368 if (
auto status = LoadHistory(filepath); !status.ok()) {
369 if (toast_manager_) {
370 toast_manager_->Show(
371 "Failed to load history: " + std::string(status.message()),
374 }
else if (toast_manager_) {
375 toast_manager_->Show(
"Chat history loaded", ToastType::kSuccess);
378 ImGui::EndDisabled();
380 if (compact && !conversations_.empty()) {
382 ImGui::SetNextItemWidth(220.0f);
383 const char* current_label =
"Current Session";
384 for (
const auto& entry : conversations_) {
385 if (entry.is_active) {
386 current_label = entry.title.c_str();
390 if (ImGui::BeginCombo(
"##conversation_combo", current_label)) {
391 for (
const auto& entry : conversations_) {
392 bool selected = entry.is_active;
393 if (ImGui::Selectable(entry.title.c_str(), selected)) {
394 SelectConversation(entry.path);
397 ImGui::SetItemDefaultFocus();
406 ImGui::OpenPopup(
"ChatOptions");
408 if (ImGui::BeginPopup(
"ChatOptions")) {
409 ImGui::Checkbox(
"Auto-scroll", &auto_scroll_);
410 ImGui::Checkbox(
"Timestamps", &show_timestamps_);
411 ImGui::Checkbox(
"Reasoning", &show_reasoning_);
416void AgentChat::RenderConversationSidebar(
float height) {
417 const auto& theme = AgentUI::GetTheme();
419 if (!AgentChatHistoryCodec::Available()) {
420 ImGui::TextDisabled(
"Chat history persistence unavailable.");
421 ImGui::TextDisabled(
"Build with JSON support to enable sessions.");
426 const auto& config = context_->agent_config();
427 ImGui::TextColored(theme.text_secondary_color,
"Agent");
430 panel_opener_(
"agent.configuration");
434 panel_opener_(
"agent.builder");
437 ImGui::TextDisabled(
"Provider: %s",
438 config.ai_provider.empty() ?
"mock"
439 : config.ai_provider.c_str());
440 ImGui::TextDisabled(
"Model: %s",
441 config.ai_model.empty() ?
"not set"
442 : config.ai_model.c_str());
448 ImGui::TextColored(theme.text_secondary_color,
"Conversations");
451 RefreshConversationList(
true);
453 if (ImGui::IsItemHovered()) {
454 ImGui::SetTooltip(
"Refresh list");
458 ImGui::InputTextWithHint(
"##conversation_filter",
"Search...",
459 conversation_filter_,
sizeof(conversation_filter_));
462 const float list_height = std::max(0.0f, height - 80.0f);
463 if (ImGui::BeginChild(
"ConversationList", ImVec2(0, list_height),
false,
464 ImGuiWindowFlags_NoScrollbar)) {
465 std::string filter = absl::AsciiStrToLower(conversation_filter_);
466 if (conversations_.empty()) {
467 ImGui::TextDisabled(
"No saved conversations yet.");
470 for (
const auto& entry : conversations_) {
471 std::string title_lower = absl::AsciiStrToLower(entry.title);
472 if (!filter.empty() &&
473 title_lower.find(filter) == std::string::npos) {
477 ImGui::PushID(index++);
480 entry.is_active ? theme.status_active : theme.panel_bg_darker},
481 {ImGuiCol_HeaderHovered, theme.panel_bg_color},
482 {ImGuiCol_HeaderActive, theme.status_active}});
483 if (ImGui::Selectable(entry.title.c_str(), entry.is_active,
484 ImGuiSelectableFlags_SpanAllColumns)) {
485 SelectConversation(entry.path);
488 ImGui::TextDisabled(
"%d msg%s", entry.message_count,
489 entry.message_count == 1 ?
"" :
"s");
490 if (entry.last_updated != absl::InfinitePast()) {
492 ImGui::TextDisabled(
"%s",
493 absl::FormatTime(
"%b %d, %H:%M",
495 absl::LocalTimeZone())
507void AgentChat::RenderHistory() {
508 const auto& history = agent_service_.GetHistory();
510 if (history.empty()) {
511 ImGui::TextDisabled(
"Start a conversation with the agent...");
514 for (
size_t i = 0; i < history.size(); ++i) {
515 RenderMessage(history[i],
static_cast<int>(i));
516 if (message_spacing_ > 0) {
517 ImGui::Dummy(ImVec2(0, message_spacing_));
521 if (waiting_for_response_) {
522 RenderThinkingIndicator();
529 ImGui::PushID(index);
532 float wrap_width = ImGui::GetContentRegionAvail().x * 0.85f;
533 ImGui::SetCursorPosX(
534 is_user ? (ImGui::GetWindowContentRegionMax().x - wrap_width - 10) : 10);
539 if (show_timestamps_) {
540 std::string timestamp =
541 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(content_id.c_str(), ImVec2(wrap_width, 0),
true,
565 ImGuiWindowFlags_AlwaysUseWindowPadding)) {
569 }
else if (!is_user && msg.
json_pretty.has_value()) {
570 ImGui::TextWrapped(
"%s", msg.
json_pretty.value().c_str());
573 auto blocks = ParseMessageContent(msg.
message);
574 for (
const auto& block : blocks) {
575 if (block.type == ContentBlock::Type::kCode) {
576 RenderCodeBlock(block.content, block.language, index);
578 ImGui::TextWrapped(
"%s", block.content.c_str());
585 RenderProposalQuickActions(msg, index);
590 RenderToolTimeline(msg);
601void AgentChat::RenderThinkingIndicator() {
607 thinking_animation_ += ImGui::GetIO().DeltaTime;
608 int dots = (int)(thinking_animation_ * 3) % 4;
620void AgentChat::RenderInputBox(
float height) {
621 const auto& theme = AgentUI::GetTheme();
622 if (ImGui::BeginChild(
"ChatInput", ImVec2(0, height),
false,
623 ImGuiWindowFlags_NoScrollbar)) {
627 ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
628 ImGuiInputTextFlags_CtrlEnterForNewLine;
630 float button_row_height = ImGui::GetFrameHeightWithSpacing();
633 ImGui::GetContentRegionAvail().y - button_row_height - 6.0f);
635 ImGui::PushItemWidth(-1);
636 if (ImGui::IsWindowAppearing()) {
637 ImGui::SetKeyboardFocusHere();
640 bool submit = ImGui::InputTextMultiline(
641 "##Input", input_buffer_,
sizeof(input_buffer_),
642 ImVec2(0, input_height), flags);
644 bool clicked_send =
false;
647 clicked_send = ImGui::Button(
ICON_MD_SEND " Send", ImVec2(90, 0));
651 input_buffer_[0] =
'\0';
654 if (submit || clicked_send) {
655 std::string msg(input_buffer_);
656 while (!msg.empty() &&
657 std::isspace(
static_cast<unsigned char>(msg.back()))) {
663 input_buffer_[0] =
'\0';
664 ImGui::SetKeyboardFocusHere(-1);
668 ImGui::PopItemWidth();
677 if (msg.
message.find(
"Proposal:") != std::string::npos) {
679 if (ImGui::Button(
"View Proposal")) {
681 if (proposal_drawer_) {
682 proposal_drawer_->Show();
688void AgentChat::RenderCodeBlock(
const std::string& code,
689 const std::string& language,
int msg_index) {
690 const auto& theme = AgentUI::GetTheme();
692 absl::StrCat(
"code_", msg_index).c_str(), ImVec2(0, 0),
693 {.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