Two focused, behaviour-preserving moves: 1. `OpenSignalTypeDialog` + `ApplySignalTypeChoice` moved from `shell.cpp` to `screen_sigtype_modal.cpp` so the popup owns all of its logic instead of having its open/apply functions live in the shell file. 2. The `export` command extracted from `commands.cpp` to a new `commands_export.cpp` under a `Tui::RegisterExportCommands()` member. `RegisterCommands()` calls it at the end. File-local helpers (`csv_quote`, `pin_side`) move alongside in an anonymous namespace. Establishes the pattern for future per-group splits: declare a `Register<X>Commands()` member, define it in its own file, call it from the orchestrator. Other groups stay in `commands.cpp` for now — nothing else has grown large enough to warrant the split. Sizes: shell.cpp 497 → 448, commands.cpp 846 → 675 (+ 191 for the new commands_export.cpp). DESIGN.md updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
449 lines
15 KiB
C++
449 lines
15 KiB
C++
#include "tui/tui.hpp"
|
|
#include "tui/tui_helpers.hpp"
|
|
|
|
#include "system/modules.hpp"
|
|
#include "system/signals.hpp"
|
|
#include "system/system.hpp"
|
|
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <ostream>
|
|
#include <set>
|
|
#include <system_error>
|
|
#include <thread>
|
|
|
|
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::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<std::vector<std::string>>(
|
|
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<std::string> &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<std::string> 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<std::string> 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;
|
|
in_source = true;
|
|
loading = true;
|
|
|
|
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 != 0) {
|
|
Print("source: line " + std::to_string(loading_lineno)
|
|
+ " is interactive (would open a screen) — aborting.");
|
|
screen_idx = 0;
|
|
loading.store(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);
|
|
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<std::string> 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';
|
|
}
|