#include "tui/tui.hpp" #include "tui/tui_helpers.hpp" #include "system/modules.hpp" #include "system/signals.hpp" #include "system/system.hpp" #include #include #include #include #include #include #include #include #include #include void Tui::BootDispatch(const std::string &raw) { // Called before Run() — no FTXUI screen yet, so any `Source()` invoked // from here takes the headless synchronous branch (drains the script // without the event-paced loop). Output goes to `output` and is // visible as soon as the screen starts. // // We pin `screen_idx = 0` (console) for the duration of the dispatch // so the source loop's "is interactive (would open a screen)" guard // doesn't trip on the constructor's boot value of 4 (dashboard). // After execution, restore to 4 so the user lands on the dashboard. screen_idx = 0; Dispatch(raw); screen_idx = 4; } void Tui::Print(const std::string &line) { output.push_back(line); scroll_offset = 0; // any new line snaps the view back to the tail } void Tui::DumpOutput(std::ostream &out) const { for (const std::string &line : output) out << line << "\n"; } void Tui::HistoryUp() { if (history.empty()) return; if (history_idx == -1) history_idx = (int)history.size() - 1; else if (history_idx > 0) history_idx--; input = history[history_idx]; cursor_pos = (int)input.size(); } void Tui::HistoryDown() { if (history_idx == -1) return; history_idx++; if (history_idx >= (int)history.size()) { history_idx = -1; input.clear(); } else { input = history[history_idx]; } cursor_pos = (int)input.size(); } void Tui::CancelPending() { if (pending.empty()) return; pending.clear(); input.clear(); cursor_pos = 0; history_idx = -1; Print("(cancelled)"); } void Tui::Submit() { if (!pending.empty()) { if (input.empty()) { Print("(empty — Esc to cancel)"); return; } Prompt p = std::move(pending.front()); pending.pop_front(); Print(" " + p.question + ": " + input); std::string answer = std::move(input); input.clear(); cursor_pos = 0; history_idx = -1; p.on_answer(answer); return; } if (input.empty()) return; history_idx = -1; Print("> " + input); std::string raw = std::move(input); input.clear(); cursor_pos = 0; Dispatch(raw); } void Tui::Dispatch(const std::string &raw) { auto tokens = Tokenize(raw); if (tokens.empty()) return; auto it = commands.find(tokens[0]); if (it == commands.end()) { Print("unknown command: " + tokens[0]); if (!in_source) { history.push_back(raw); AppendHistory(raw); } return; } const std::string name = it->first; const CommandSpec &spec = it->second; if (tokens.size() - 1 > spec.params.size()) { Print("too many arguments for '" + name + "'"); if (!in_source) { history.push_back(raw); AppendHistory(raw); } return; } auto args = std::make_shared>( tokens.begin() + 1, tokens.end()); if (args->size() == spec.params.size() || !spec.prompt_for_missing) { Finalize(name, spec, *args); return; } for (size_t i = args->size(); i < spec.params.size(); ++i) { bool last = (i + 1 == spec.params.size()); const auto ¶m = spec.params[i]; pending.push_back({ param.name, [this, name, &spec, args, last](const std::string &s) { args->push_back(s); if (last) Finalize(name, spec, *args); }, param.completion, }); } } void Tui::Finalize(const std::string &name, const CommandSpec &spec, const std::vector &args) { // Build the canonical form from the *raw* args (pre-expansion) so that // history and script-save preserve `$var` references. std::string canonical = name; for (const auto &a : args) { if (a.find_first_of(" \t\"") != std::string::npos) canonical += " \"" + a + "\""; else canonical += " " + a; } if (!in_source) { history.push_back(canonical); AppendHistory(canonical); } // Expand variables only for the action call so commands see resolved values. std::vector exec_args; exec_args.reserve(args.size()); for (const auto &a : args) exec_args.push_back(ExpandVars(a)); spec.action(exec_args); static const std::set no_record = { "clear", "help", "quit", "exit", "source", "script-save", }; // A bare invocation of an `interactive` command opens a full-screen mode // rather than mutating state — skip it. Any mutating action taken inside // that screen records its own canonical line via the action callbacks. bool opens_screen = spec.interactive && args.empty(); if (spec.scriptable && !opens_screen && !no_record.count(name)) recorded.push_back(canonical); } std::string Tui::ExpandVars(const std::string &s) const { std::string out; out.reserve(s.size()); size_t i = 0; while (i < s.size()) { if (s[i] != '$') { out.push_back(s[i++]); continue; } size_t j = i + 1; bool braces = (j < s.size() && s[j] == '{'); if (braces) ++j; size_t start = j; while (j < s.size() && (std::isalnum((unsigned char)s[j]) || s[j] == '_')) ++j; std::string name = s.substr(start, j - start); if (braces) { if (j >= s.size() || s[j] != '}') { // Unmatched brace — emit literally and resume after the '$'. out.push_back('$'); ++i; continue; } ++j; } if (name.empty()) { out.push_back('$'); ++i; continue; } auto it = vars.find(name); if (it != vars.end()) out += it->second; else out += s.substr(i, j - i); // keep unknown as-is i = j; } return out; } namespace { // User-data dir, platform-aware. Returns the base directory under which // every persistent file lives (history, export-last, …). Empty if none // of the expected env vars are set. std::filesystem::path UserDataDir() { namespace fs = std::filesystem; #ifdef _WIN32 if (const char *p = std::getenv("LOCALAPPDATA"); p && *p) return fs::path(p) / "essim"; if (const char *p = std::getenv("APPDATA"); p && *p) return fs::path(p) / "essim"; if (const char *p = std::getenv("USERPROFILE"); p && *p) return fs::path(p) / "AppData" / "Local" / "essim"; #else if (const char *p = std::getenv("XDG_DATA_HOME"); p && *p) return fs::path(p) / "essim"; if (const char *p = std::getenv("HOME"); p && *p) return fs::path(p) / ".local" / "share" / "essim"; #endif return {}; } std::filesystem::path HistoryPath() { auto d = UserDataDir(); return d.empty() ? std::filesystem::path{} : (d / "history"); } } // namespace // Free helpers for "last used (dir, filename) per purpose". Stored as a // tiny two-line file per key under the user-data dir. Used by the file // dialog so each call site (export, save, restore, …) gets its own // memory without further plumbing. namespace { std::filesystem::path LastUsedPath(const std::string &key) { auto d = UserDataDir(); return d.empty() ? std::filesystem::path{} : (d / (key + ".last")); } } // namespace void SaveLastUsed(const std::string &key, const std::string &dir, const std::string &filename) { auto p = LastUsedPath(key); if (p.empty()) return; std::error_code ec; std::filesystem::create_directories(p.parent_path(), ec); std::ofstream f(p); if (!f) return; f << dir << '\n' << filename << '\n'; } bool LoadLastUsed(const std::string &key, std::string &dir, std::string &filename) { auto p = LastUsedPath(key); if (p.empty()) return false; std::ifstream f(p); if (!f) return false; std::string d, n; if (std::getline(f, d) && std::getline(f, n) && !d.empty() && !n.empty()) { dir = d; filename = n; return true; } return false; } void Tui::LoadHistory() { auto p = HistoryPath(); if (p.empty()) return; std::ifstream f(p); std::string line; while (std::getline(f, line)) if (!line.empty()) history.push_back(line); } void Tui::Source(const std::string &filename) { std::string expanded = filename; if (!expanded.empty() && expanded[0] == '~') { if (const char *home = std::getenv("HOME")) expanded = std::string(home) + expanded.substr(1); } std::ifstream f(expanded); if (!f) { Print("source failed: cannot open " + filename); return; } // Slurp the whole file so we can drive line-by-line processing from the // event loop (one line per posted task). This lets the screen redraw // between lines and surface the "Computing…" modal. loading_lines.clear(); std::string line; while (std::getline(f, line)) loading_lines.push_back(line); loading_filename = filename; loading_idx = 0; loading_executed = 0; loading_lineno = 0; loading_prev_in_source = in_source; source_origin_screen = screen_idx; // a sourced line that leaves this screen aborts in_source = true; loading = true; computing_open = true; // raise the global "Computing…" progress modal if (!screen_ptr) { // Headless fallback (e.g. tests): drain synchronously. while (loading.load()) ProcessNextSourceLine(); return; } // Pacing thread: post one tick at a time and wait for the main thread // to ack it (by clearing tick_in_flight from ProcessNextSourceLine) // before sleeping & posting the next. Without this, a long-running line // (e.g. a Mentor parse) lets the ticker queue many ticks; FTXUI then // drains them in a batch without redrawing between, so the modal // counter freezes. tick_in_flight.store(false); std::thread([this]() { using namespace std::chrono_literals; while (loading.load()) { // Wait until main thread is ready for a new tick. while (loading.load() && tick_in_flight.load()) std::this_thread::sleep_for(5ms); if (!loading.load()) break; std::this_thread::sleep_for(30ms); if (!loading.load()) break; tick_in_flight.store(true); if (screen_ptr) screen_ptr->PostEvent(ftxui::Event::Special("\x02tick")); } }).detach(); } void Tui::ProcessNextSourceLine() { if (!loading.load()) return; while (loading_idx < loading_lines.size()) { const std::string &raw = loading_lines[loading_idx++]; ++loading_lineno; size_t start = raw.find_first_not_of(" \t"); if (start == std::string::npos) continue; if (raw[start] == '#') continue; std::string trimmed = raw.substr(start); while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back())) trimmed.pop_back(); if (trimmed.empty()) continue; input = trimmed; cursor_pos = (int)input.size(); Submit(); ++loading_executed; if (screen_idx != source_origin_screen) { Print("source: line " + std::to_string(loading_lineno) + " is interactive (would open a screen) — aborting."); screen_idx = source_origin_screen; loading.store(false); computing_open = false; tick_in_flight.store(false); in_source = loading_prev_in_source; return; } // One effective line per tick — ack so the ticker can pace the next. tick_in_flight.store(false); return; } Print("source: " + loading_filename + " (" + std::to_string(loading_executed) + " line(s))"); loading.store(false); computing_open = false; tick_in_flight.store(false); in_source = loading_prev_in_source; } void Tui::DumpCommandsMd(std::ostream &out) const { out << "# essim — command reference\n\n" "Auto-generated from the live command registry. Regenerate with\n" "`cmake --build build --target doc` after adding or changing\n" "commands; the binary itself is the single source of truth.\n\n" "Keys global to the shell: `Esc` cancels a multi-step prompt or\n" "leaves an interactive screen; `Tab` completes commands/paths\n" "(top-level prompt) or cycles focus inside an interactive\n" "screen; `PageUp` / `PageDown` scroll output by 10 lines,\n" "`Home` / `End` jump to top / bottom; ↑ / ↓ walk command\n" "history.\n"; auto emit_group = [&](const std::string &title, bool want_interactive) { bool printed_title = false; for (const auto &kv : commands) { const CommandSpec &spec = kv.second; if (spec.hidden) continue; // aliases & internal entries if (spec.interactive != want_interactive) continue; if (!printed_title) { out << "\n## " << title << "\n\n"; printed_title = true; } out << "### `" << kv.first << "`"; if (spec.interactive) out << " *(interactive)*"; out << "\n\n" << spec.description << "\n\n"; if (spec.params.empty()) { out << "**No arguments.**"; } else { out << "**Arguments**\n\n"; int i = 1; for (const auto &p : spec.params) { out << i << ". `" << p.name << "`"; switch (p.completion) { case Completion::Path: out << " *(Tab → path completion)*"; break; case Completion::Command: out << " *(Tab → command completion)*"; break; case Completion::None: break; } out << "\n"; ++i; } } out << "\n"; std::vector notes; if (spec.interactive) { notes.emplace_back("bare form opens an interactive screen; " "inline form (all args) is scriptable"); } else if (!spec.prompt_for_missing) { notes.emplace_back("no per-arg prompt: pass all args inline " "(or run bare for an empty-args path)"); } else if (!spec.params.empty()) { notes.emplace_back("missing args trigger one prompt each"); } if (!spec.scriptable) { notes.emplace_back("not recorded by `script-save` and " "rejected by `source`"); } if (!notes.empty()) { out << "**Notes**\n\n"; for (const auto &n : notes) out << "- " << n << "\n"; out << "\n"; } out << "---\n"; } }; emit_group("Interactive commands", true); emit_group("Other commands", false); } void Tui::AppendHistory(const std::string &cmd) { auto p = HistoryPath(); if (p.empty()) return; std::string trimmed = cmd; while (!trimmed.empty() && std::isspace((unsigned char)trimmed.back())) trimmed.pop_back(); if (trimmed.empty()) return; std::error_code ec; std::filesystem::create_directories(p.parent_path(), ec); if (ec) return; std::ofstream f(p, std::ios::app); if (f) f << trimmed << '\n'; }