yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
chat_tui.cc
Go to the documentation of this file.
1#include "cli/tui/chat_tui.h"
2
3#if defined(YAZE_ENABLE_AGENT_CLI)
4
5#include <algorithm>
6#include <cmath>
7#include <utility>
8#include <vector>
9
10#include "absl/strings/str_cat.h"
11#include "absl/strings/str_format.h"
12#include "absl/time/time.h"
14#include "cli/tui/tui.h"
15#include "ftxui/component/component.hpp"
16#include "ftxui/component/component_base.hpp"
17#include "ftxui/component/event.hpp"
18#include "ftxui/component/screen_interactive.hpp"
19#include "ftxui/dom/elements.hpp"
20#include "ftxui/dom/table.hpp"
21#include "rom/rom.h"
22
23namespace yaze {
24namespace cli {
25namespace tui {
26
27using namespace ftxui;
28
29namespace {
30const std::vector<std::string> kSpinnerFrames = {"⠋", "⠙", "⠹", "⠸", "⠼",
31 "⠴", "⠦", "⠧", "⠇", "⠏"};
32
33Element RenderPanelPanel(const std::string& title,
34 const std::vector<Element>& body, Color border_color,
35 bool highlight = false) {
36 auto panel = window(text(title) | bold, vbox(body));
37 if (highlight) {
38 panel = panel | color(border_color) | bgcolor(Color::GrayDark);
39 } else {
40 panel = panel | color(border_color);
41 }
42 return panel;
43}
44
45Element RenderLatencySparkline(const std::vector<double>& data) {
46 if (data.empty()) {
47 return text("No latency data yet") | dim;
48 }
49 Elements bars;
50 for (double d : data) {
51 bars.push_back(gauge(d) | flex);
52 }
53 return hbox(bars);
54}
55
56Element RenderMetricLabel(const std::string& icon, const std::string& label,
57 const std::string& value, Color color) {
58 return hbox({text(icon) | ftxui::color(color),
59 text(" " + label + ": ") | bold,
60 text(value) | ftxui::color(color)});
61}
62} // namespace
63
64ChatTUI::ChatTUI(Rom* rom_context) : rom_context_(rom_context) {
65 if (rom_context_ != nullptr) {
66 agent_service_.SetRomContext(rom_context_);
67 rom_header_ = absl::StrFormat("ROM: %s | Size: %d bytes",
68 rom_context_->title(), rom_context_->size());
69 } else {
70 rom_header_ = "No ROM loaded.";
71 }
72 auto status = todo_manager_.Initialize();
73 todo_manager_ready_ = status.ok();
74 InitializeAutocomplete();
75 quick_actions_ = {"List dungeon entrances",
76 "Show sprite palette summary",
77 "Summarize overworld map",
78 "Find unused rooms",
79 "Explain ROM header",
80 "Search dialogue for 'Master Sword'",
81 "Suggest QA checklist",
82 "Show TODO status",
83 "Show ROM info"};
84}
85
86ChatTUI::~ChatTUI() {
87 CleanupWorkers();
88}
89
90void ChatTUI::SetRomContext(Rom* rom_context) {
91 rom_context_ = rom_context;
92 agent_service_.SetRomContext(rom_context_);
93 if (rom_context_ != nullptr) {
94 rom_header_ = absl::StrFormat("ROM: %s | Size: %d bytes",
95 rom_context_->title(), rom_context_->size());
96 } else {
97 rom_header_ = "No ROM loaded.";
98 }
99}
100
101void ChatTUI::InitializeAutocomplete() {
102 autocomplete_engine_.RegisterCommand("/help", "Show help message");
103 autocomplete_engine_.RegisterCommand("/exit", "Exit the chat");
104 autocomplete_engine_.RegisterCommand("/quit", "Exit the chat");
105 autocomplete_engine_.RegisterCommand("/clear", "Clear chat history");
106 autocomplete_engine_.RegisterCommand("/rom_info", "Display ROM information");
107 autocomplete_engine_.RegisterCommand("/status", "Show chat statistics");
108
109 // Add common prompts
110 autocomplete_engine_.RegisterCommand("What is", "Ask a question about ROM");
111 autocomplete_engine_.RegisterCommand("How do I", "Get help with a task");
112 autocomplete_engine_.RegisterCommand("Show me", "Request information");
113 autocomplete_engine_.RegisterCommand("List all", "List items from ROM");
114 autocomplete_engine_.RegisterCommand("Find", "Search for something");
115}
116
117void ChatTUI::Run() {
118 auto input_message = std::make_shared<std::string>();
119
120 // Create autocomplete input component
121 auto input_component =
122 CreateAutocompleteInput(input_message.get(), &autocomplete_engine_);
123
124 auto todo_popup_toggle = [this] {
125 ToggleTodoPopup();
126 };
127 auto shortcut_palette_toggle = [this] {
128 ToggleShortcutPalette();
129 };
130
131 // Handle Enter key BEFORE adding to container
132 input_component = CatchEvent(input_component,
133 [this, input_message, todo_popup_toggle,
134 shortcut_palette_toggle](const Event& event) {
135 if (event == Event::Return) {
136 if (input_message->empty())
137 return true;
138 OnSubmit(*input_message);
139 input_message->clear();
140 return true;
141 }
142 if (event == Event::Special({20})) { // Ctrl+T
143 todo_popup_toggle();
144 return true;
145 }
146 if (event == Event::Special({11})) { // Ctrl+K
147 shortcut_palette_toggle();
148 return true;
149 }
150 return false;
151 });
152
153 auto send_button = Button("Send", [this, input_message] {
154 if (input_message->empty())
155 return;
156 OnSubmit(*input_message);
157 input_message->clear();
158 });
159
160 auto quick_pick_index = std::make_shared<int>(0);
161 auto quick_pick_menu = Menu(&quick_actions_, quick_pick_index.get());
162
163 todo_popup_component_ = CreateTodoPopup();
164 shortcut_palette_component_ = BuildShortcutPalette();
165
166 // Add both input and button to container
167 auto input_container = Container::Horizontal({
168 input_component,
169 send_button,
170 });
171
172 input_component->TakeFocus();
173
174 auto main_renderer = Renderer(input_container, [this, input_component,
175 send_button, quick_pick_menu,
176 quick_pick_index] {
177 const auto& history = agent_service_.GetHistory();
178
179 // Build history view from current history state
180 std::vector<Element> history_elements;
181
182 for (const auto& msg : history) {
183 Element header =
184 text(msg.sender == agent::ChatMessage::Sender::kUser ? "You"
185 : "Agent") |
186 bold |
187 color(msg.sender == agent::ChatMessage::Sender::kUser ? Color::Yellow
188 : Color::Green);
189
190 Element body;
191 if (msg.table_data.has_value()) {
192 std::vector<std::vector<std::string>> table_rows;
193 if (!msg.table_data->headers.empty()) {
194 table_rows.push_back(msg.table_data->headers);
195 }
196 for (const auto& row : msg.table_data->rows) {
197 table_rows.push_back(row);
198 }
199 Table table(table_rows);
200 table.SelectAll().Border(LIGHT);
201 if (!msg.table_data->headers.empty()) {
202 table.SelectRow(0).Decorate(bold);
203 }
204 body = table.Render();
205 } else if (msg.json_pretty.has_value()) {
206 // Word wrap for JSON
207 body = paragraphAlignLeft(msg.json_pretty.value());
208 } else {
209 // Word wrap for regular messages
210 body = paragraphAlignLeft(msg.message);
211 }
212
213 auto message_block = vbox({
214 header,
215 separator(),
216 body,
217 });
218
219 if (msg.metrics.has_value()) {
220 const auto& metrics = msg.metrics.value();
221 message_block =
222 vbox({message_block, separator(),
223 text(absl::StrFormat("📊 Turn %d | Elapsed: %.2fs",
224 metrics.turn_index,
225 metrics.total_elapsed_seconds)) |
226 color(Color::Cyan) | dim});
227 }
228
229 history_elements.push_back(message_block | border);
230 }
231
232 Element history_view;
233 if (history.empty()) {
234 history_view =
235 vbox({text("yaze TUI") | bold | center,
236 text("A conversational agent for ROM hacking") | center,
237 separator(), text("No messages yet. Start chatting!") | dim}) |
238 flex | center;
239 } else {
240 history_view = vbox(history_elements) | vscroll_indicator | frame | flex;
241 }
242
243 // Build info panel with responsive layout
244 auto metrics = CurrentMetrics();
245
246 Element header_line =
247 hbox({text(rom_header_) | bold, filler(),
248 agent_busy_.load() ? text(kSpinnerFrames[spinner_index_.load() %
249 kSpinnerFrames.size()]) |
250 color(Color::Yellow)
251 : text("✓") | color(Color::GreenLight)});
252
253 std::vector<Element> info_cards;
254 info_cards.push_back(RenderPanelPanel(
255 "Session",
256 {
257 RenderMetricLabel("🕒", "Turns",
258 absl::StrFormat("%d", metrics.turn_index),
259 Color::Cyan),
260 RenderMetricLabel(
261 "🙋", "User", absl::StrFormat("%d", metrics.total_user_messages),
262 Color::White),
263 RenderMetricLabel(
264 "🤖", "Agent",
265 absl::StrFormat("%d", metrics.total_agent_messages),
266 Color::GreenLight),
267 RenderMetricLabel("🔧", "Tools",
268 absl::StrFormat("%d", metrics.total_tool_calls),
269 Color::YellowLight),
270 },
271 Color::GrayLight));
272
273 info_cards.push_back(RenderPanelPanel(
274 "Latency",
275 {RenderMetricLabel("⚡", "Last",
276 absl::StrFormat("%.2fs", last_response_seconds_),
277 Color::Yellow),
278 RenderMetricLabel(
279 "📈", "Average",
280 absl::StrFormat("%.2fs", metrics.average_latency_seconds),
281 Color::MagentaLight),
282 RenderLatencySparkline(latency_history_)},
283 Color::Magenta, agent_busy_.load()));
284
285 info_cards.push_back(RenderPanelPanel(
286 "Shortcuts",
287 {
288 text("⌨ Enter ↵ Send") | dim,
289 text("⌨ Shift+Enter ↩ Multiline") | dim,
290 text("⌨ /help, /rom_info, /status") | dim,
291 text("⌨ Ctrl+T TODO overlay · Ctrl+K shortcuts · f fullscreen") |
292 dim,
293 },
294 Color::BlueLight));
295
296 Elements layout_elements;
297 layout_elements.push_back(header_line);
298 layout_elements.push_back(separatorLight());
299 layout_elements.push_back(
300 vbox({hbox({
301 info_cards[0] | flex,
302 separator(),
303 info_cards[1] | flex,
304 separator(),
305 info_cards[2] | flex,
306 }) | flex,
307 separator(), history_view | bgcolor(Color::Black) | flex}) |
308 flex);
309
310 // Add metrics bar
311 layout_elements.push_back(separatorLight());
312 layout_elements.push_back(
313 hbox({text("Turns: ") | bold,
314 text(absl::StrFormat("%d", metrics.turn_index)), separator(),
315 text("User: ") | bold,
316 text(absl::StrFormat("%d", metrics.total_user_messages)),
317 separator(), text("Agent: ") | bold,
318 text(absl::StrFormat("%d", metrics.total_agent_messages)),
319 separator(), text("Tools: ") | bold,
320 text(absl::StrFormat("%d", metrics.total_tool_calls)), filler(),
321 text("Last response " +
322 absl::StrFormat("%.2fs", last_response_seconds_)) |
323 color(Color::GrayLight)}) |
324 color(Color::GrayLight));
325
326 // Add error if present
327 if (last_error_.has_value()) {
328 layout_elements.push_back(separator());
329 layout_elements.push_back(text(absl::StrCat("⚠ ERROR: ", *last_error_)) |
330 color(Color::Red));
331 }
332
333 // Add input area
334 layout_elements.push_back(separator());
335 layout_elements.push_back(
336 vbox({text("Quick Actions") | bold,
337 quick_pick_menu->Render() | frame | size(HEIGHT, EQUAL, 5) | flex,
338 separatorLight(), input_component->Render() | flex}));
339 layout_elements.push_back(hbox({
340 text("Press Enter to send | ") | dim,
341 send_button->Render(),
342 text(" | Tab quick actions · Ctrl+T TODO "
343 "overlay · Ctrl+K shortcuts") |
344 dim,
345 }) |
346 center);
347
348 Element base =
349 vbox(layout_elements) | borderRounded | bgcolor(Color::Black);
350
351 if ((todo_popup_visible_ && todo_popup_component_) ||
352 (shortcut_palette_visible_ && shortcut_palette_component_)) {
353 std::vector<Element> overlays;
354 overlays.push_back(base);
355 if (todo_popup_visible_ && todo_popup_component_) {
356 overlays.push_back(todo_popup_component_->Render());
357 }
358 if (shortcut_palette_visible_ && shortcut_palette_component_) {
359 overlays.push_back(shortcut_palette_component_->Render());
360 }
361 base = dbox(overlays);
362 }
363
364 return base;
365 });
366
367 screen_.Loop(main_renderer);
368}
369
370void ChatTUI::OnSubmit(const std::string& message) {
371 if (message.empty()) {
372 return;
373 }
374
375 if (message == "/exit" || message == "/quit") {
376 screen_.Exit();
377 return;
378 }
379 if (message == "/clear") {
380 agent_service_.ResetConversation();
381 // The renderer will see history is empty and detach children
382 return;
383 }
384 if (message == "/rom_info") {
385 auto response =
386 agent_service_.SendMessage("Show me information about the loaded ROM");
387 if (!response.ok()) {
388 last_error_ = std::string(response.status().message());
389 } else {
390 last_error_.reset();
391 }
392 return;
393 }
394 if (message == "/help") {
395 auto response = agent_service_.SendMessage("What commands can I use?");
396 if (!response.ok()) {
397 last_error_ = std::string(response.status().message());
398 } else {
399 last_error_.reset();
400 }
401 return;
402 }
403 if (message == "/status") {
404 const auto metrics = agent_service_.GetMetrics();
405 std::string status_message = absl::StrFormat(
406 "Chat Statistics:\n"
407 "- Total Turns: %d\n"
408 "- User Messages: %d\n"
409 "- Agent Messages: %d\n"
410 "- Tool Calls: %d\n"
411 "- Commands: %d\n"
412 "- Proposals: %d\n"
413 "- Total Elapsed Time: %.2f seconds\n"
414 "- Average Latency: %.2f seconds",
415 metrics.turn_index, metrics.total_user_messages,
416 metrics.total_agent_messages, metrics.total_tool_calls,
417 metrics.total_commands, metrics.total_proposals,
418 metrics.total_elapsed_seconds, metrics.average_latency_seconds);
419
420 // Add a system message with status
421 auto response = agent_service_.SendMessage(status_message);
422 if (!response.ok()) {
423 last_error_ = std::string(response.status().message());
424 } else {
425 last_error_.reset();
426 }
427 return;
428 }
429
430 LaunchAgentPrompt(message);
431}
432
433void ChatTUI::LaunchAgentPrompt(const std::string& prompt) {
434 if (prompt.empty()) {
435 return;
436 }
437
438 agent_busy_.store(true);
439 spinner_running_.store(true);
440 if (!spinner_thread_.joinable()) {
441 spinner_thread_ = std::thread([this] {
442 while (spinner_running_.load()) {
443 std::this_thread::sleep_for(std::chrono::milliseconds(90));
444 spinner_index_.fetch_add(1);
445 screen_.PostEvent(Event::Custom);
446 }
447 });
448 }
449
450 last_send_time_ = std::chrono::steady_clock::now();
451
452 auto future = std::async(std::launch::async, [this, prompt] {
453 auto response = agent_service_.SendMessage(prompt);
454 if (!response.ok()) {
455 last_error_ = std::string(response.status().message());
456 } else {
457 last_error_.reset();
458 }
459
460 auto end_time = std::chrono::steady_clock::now();
461 last_response_seconds_ =
462 std::chrono::duration<double>(end_time - last_send_time_).count();
463
464 latency_history_.push_back(last_response_seconds_);
465 if (latency_history_.size() > 30) {
466 latency_history_.erase(latency_history_.begin());
467 }
468
469 agent_busy_.store(false);
470 StopSpinner();
471 screen_.PostEvent(Event::Custom);
472 });
473
474 {
475 std::lock_guard<std::mutex> lock(worker_mutex_);
476 worker_futures_.push_back(std::move(future));
477 }
478}
479
480void ChatTUI::CleanupWorkers() {
481 std::lock_guard<std::mutex> lock(worker_mutex_);
482 for (auto& future : worker_futures_) {
483 if (future.valid()) {
484 future.wait();
485 }
486 }
487 worker_futures_.clear();
488
489 StopSpinner();
490}
491
492agent::ChatMessage::SessionMetrics ChatTUI::CurrentMetrics() const {
493 return agent_service_.GetMetrics();
494}
495
496void ChatTUI::StopSpinner() {
497 spinner_running_.store(false);
498 if (spinner_thread_.joinable()) {
499 spinner_thread_.join();
500 }
501}
502
503void ChatTUI::ToggleTodoPopup() {
504 if (!todo_popup_component_) {
505 todo_popup_component_ = CreateTodoPopup();
506 }
507 todo_popup_visible_ = !todo_popup_visible_;
508 if (todo_popup_visible_ && todo_popup_component_) {
509 screen_.PostEvent(Event::Custom);
510 }
511}
512
513void ChatTUI::ToggleShortcutPalette() {
514 if (!shortcut_palette_component_) {
515 shortcut_palette_component_ = BuildShortcutPalette();
516 }
517 shortcut_palette_visible_ = !shortcut_palette_visible_;
518 if (shortcut_palette_visible_) {
519 screen_.PostEvent(Event::Custom);
520 }
521}
522
523ftxui::Component ChatTUI::CreateTodoPopup() {
524 auto refresh_button =
525 Button("Refresh", [this] { screen_.PostEvent(Event::Custom); });
526 auto close_button = Button("Close", [this] {
527 todo_popup_visible_ = false;
528 screen_.PostEvent(Event::Custom);
529 });
530
531 auto renderer = Renderer([this, refresh_button, close_button] {
532 Elements rows;
533 if (!todo_manager_ready_) {
534 rows.push_back(text("TODO manager unavailable") | color(Color::Red) |
535 center);
536 } else {
537 auto todos = todo_manager_.GetAllTodos();
538 if (todos.empty()) {
539 rows.push_back(text("No TODOs tracked") | dim | center);
540 } else {
541 for (const auto& item : todos) {
542 rows.push_back(
543 hbox({text(absl::StrFormat("[%s]", item.StatusToString())) |
544 color(Color::Yellow),
545 text(" " + item.description) | flex,
546 text(item.category.empty()
547 ? ""
548 : absl::StrCat(" (", item.category, ")")) |
549 dim}));
550 }
551 }
552 }
553
554 return dbox({window(text("📝 TODO Overlay") | bold,
555 vbox({separatorLight(),
556 vbox(rows) | frame | size(HEIGHT, LESS_THAN, 12) |
557 size(WIDTH, LESS_THAN, 70),
558 separatorLight(),
559 hbox({refresh_button->Render(), text(" "),
560 close_button->Render()}) |
561 center})) |
562 size(WIDTH, LESS_THAN, 72) | size(HEIGHT, LESS_THAN, 18) |
563 center});
564 });
565
566 return renderer;
567}
568
569ftxui::Component ChatTUI::BuildShortcutPalette() {
570 std::vector<std::pair<std::string, std::string>> shortcuts = {
571 {"Ctrl+T", "Toggle TODO overlay"}, {"Ctrl+K", "Shortcut palette"},
572 {"Ctrl+L", "Clear chat history"}, {"Ctrl+Shift+S", "Save transcript"},
573 {"Ctrl+G", "Focus quick actions"}, {"Ctrl+P", "Command palette"},
574 {"Ctrl+F", "Fullscreen chat"}, {"Esc", "Back to unified layout"},
575 };
576
577 auto close_button = Button("Close", [this] {
578 shortcut_palette_visible_ = false;
579 screen_.PostEvent(Event::Custom);
580 });
581
582 auto renderer = Renderer([shortcuts, close_button] {
583 Elements rows;
584 for (const auto& [combo, desc] : shortcuts) {
585 rows.push_back(
586 hbox({text(combo) | bold | color(Color::Cyan), text(" " + desc)}));
587 }
588
589 return dbox(
590 {window(text("⌨ Shortcuts") | bold,
591 vbox({separatorLight(),
592 vbox(rows) | frame | size(HEIGHT, LESS_THAN, 12) |
593 size(WIDTH, LESS_THAN, 60),
594 separatorLight(), close_button->Render() | center})) |
595 size(WIDTH, LESS_THAN, 64) | size(HEIGHT, LESS_THAN, 16) | center});
596 });
597
598 return renderer;
599}
600
601bool ChatTUI::IsPopupOpen() const {
602 return todo_popup_visible_ || shortcut_palette_visible_;
603}
604
605} // namespace tui
606} // namespace cli
607} // namespace yaze
608
609#endif // YAZE_ENABLE_AGENT_CLI
ChatTUI(Rom *rom_context=nullptr)
Definition chat_tui.h:86
Component CreateAutocompleteInput(std::string *input_str, AutocompleteEngine *engine)
Create an input component with autocomplete suggestions.