Files
essim/src/tui/shell.cpp
François ac2edd90c4 tui: show the Computing... progress as a real Modal (was invisible)
The Renderer-overlay approach to the global progress box didn't render. Use a
proper Modal like the palette / file dialog, driven by a plain bool
'computing_open' raised when a source starts and lowered when it ends or
aborts. The tick handler stays ahead of the modal guard, so the script keeps
running (and the screen behind it keeps updating) while the modal is shown;
computing_open is also added to the guard so stray keys are ignored mid-load.
The console screen's own Computing block was already removed, so no duplicate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:11:27 +02:00

458 lines
16 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::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<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 &param = 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;
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<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';
}