tui: chat scene, markdown and row builders, live stream; differential tests vs tai.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 01:43:52 +02:00
co-authored by Claude Sonnet 5.5
parent cf3cbcdf1c
commit cb8e3cf666
15 changed files with 2274 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
// tui_chat: scripted headless session over the local chat scene with a deterministic
// fake agent, to diff against tai.py (tui_build_chat with the same fake).
// Script lines (stdin): feed <hex> | frame | text | resize R C | timers | flush | sources
#include "TuiChatScene.h"
#include "TuiComplete.h"
#include <cstdio>
#include <iostream>
#include <map>
#include <sstream>
using namespace detz::tui;
struct FakeStore {
std::map<std::string, std::vector<TuiChatMessage>> sessions;
std::vector<std::string> bots{"main", "alpha", "beta"};
};
class FakeAgent : public TuiChatAgent {
public:
FakeAgent(std::shared_ptr<FakeStore> store, std::string bot) : store_(std::move(store)), bot_(std::move(bot)) { msgs_ = store_->sessions[bot_]; }
std::string bot() const override { return bot_; }
std::string profile() const override { return "default"; }
std::vector<TuiChatMessage> messages() const override { return msgs_; }
std::string provider_pin() const override { return ""; }
std::string model_pin() const override { return ""; }
std::vector<std::string> list_bots() const override { return store_->bots; }
std::vector<TuiChatMessage> load_session(const std::string& bot) override { return store_->sessions[bot]; }
void save_session() override { store_->sessions[bot_] = msgs_; }
void switch_bot(const std::string& name) override { bot_ = name; msgs_ = store_->sessions[name]; }
std::string run_turn(const std::string& text) override {
if (text == "boom") throw TuiChatBackendError("backend down");
if (text == "int") throw TuiChatInterrupted();
std::string reply = text.rfind("long", 0) == 0
? "# Heading\n\nSome **bold** and *italic* with `code`.\n\n- first item\n- second item that is long enough to wrap around the bubble\n\n```\ncode block line\n```\n> quoted words here\n\ntail paragraph with many words to wrap across the available width of the transcript okay"
: "echo " + text;
TuiChatMessage u;
u.role = "user";
u.content = text;
TuiChatMessage a;
a.role = "assistant";
a.content = reply;
msgs_.push_back(u);
msgs_.push_back(a);
return reply;
}
void checkpoint() override { checkpointed++; }
void set_quiet(bool) override {}
bool handle_command(const std::string& text, std::string& output) override {
if (text == "/help") { output = "\x1b[1mhelp line 1\x1b[0m\nhelp line 2\n"; return true; }
if (text == "/quit") return false;
if (text == "/bot alpha") { switch_bot("alpha"); output = "switched\n"; return true; }
if (text == "/fail") throw std::runtime_error("boom");
output = "unknown command\n";
return true;
}
void set_on_phase(std::function<void(const std::string&, const std::string&)>) override {}
void set_prompter(std::function<TuiValue(const std::string&, const std::string&, const TuiValue&)>) override {}
void set_stream_factory(std::function<std::shared_ptr<TuiStreamSink>(const std::string&)>) override {}
std::shared_ptr<TuiHistoryBackend> history_backend() override { return nullptr; }
int checkpointed{0};
private:
std::shared_ptr<FakeStore> store_;
std::string bot_;
std::vector<TuiChatMessage> msgs_;
};
class FakeHost : public TuiChatHost {
public:
explicit FakeHost(std::shared_ptr<FakeStore> store) : store_(std::move(store)) {}
std::shared_ptr<TuiChatAgent> spawn_agent(TuiChatAgent&, const std::string& name) override { return std::make_shared<FakeAgent>(store_, name); }
int running_agents() override { return 0; }
std::string version() override { return "9.9.9"; }
std::string short_error(const std::string& message) override { return message.substr(0, 300); }
std::function<void()> start_scheduler() override { return [] {}; }
private:
std::shared_ptr<FakeStore> store_;
};
static std::string unhex(const std::string& h) {
std::string out;
for (size_t i = 0; i + 1 < h.size(); i += 2) out.push_back(static_cast<char>(std::stoi(h.substr(i, 2), nullptr, 16)));
return out;
}
static std::string hex(const std::string& s) {
static const char* d = "0123456789abcdef";
std::string out;
for (unsigned char c : s) { out.push_back(d[c >> 4]); out.push_back(d[c & 15]); }
return out;
}
int main(int argc, char** argv) {
int rows = argc > 1 ? std::atoi(argv[1]) : 30;
int cols = argc > 2 ? std::atoi(argv[2]) : 100;
TuiChatScene::clock = [] { return static_cast<std::time_t>(1741944413); }; // 2025-03-14 09:26:53 UTC
auto store = std::make_shared<FakeStore>();
{
TuiChatMessage u;
u.role = "user";
u.content = "hi there";
TuiChatMessage a;
a.role = "assistant";
a.content = "Hello! **How** can I help?";
a.tool_calls.push_back({"shell_run", "{\"cmd\": \"ls\"}"});
store->sessions["main"] = {u, a};
TuiChatMessage b;
b.role = "assistant";
b.content = "alpha says `code`";
store->sessions["alpha"] = {b};
}
FakeHost host(store);
auto agent = std::make_shared<FakeAgent>(store, "main");
TuiApp app;
app.rows = rows;
app.cols = cols;
TuiChatScene scene(app, agent, host);
PanelP root = scene.build();
app.mount(root);
scene.replay();
scene.box->focus();
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream in(line);
std::string cmd;
in >> cmd;
if (cmd == "feed") {
std::string h;
in >> h;
for (auto& ev : app.input.feed(unhex(h))) app.dispatch(*ev);
} else if (cmd == "flush") {
for (auto& ev : app.input.flush()) app.dispatch(*ev);
} else if (cmd == "frame") {
std::printf("%s\n", hex(app.frame_bytes()).c_str());
} else if (cmd == "text") {
std::printf("%s\n--\n", app.render_text().c_str());
} else if (cmd == "resize") {
int r, c;
in >> r >> c;
app.rows = r;
app.cols = c;
app.compositor.invalidate();
app.dirty_layout();
TuiResize ev;
ev.panel = app.root.get();
ev.rows = r;
ev.cols = c;
app.root->emit(ev);
} else if (cmd == "timers") {
app.fire_timers(TuiApp::now() + 100.0);
} else if (cmd == "sources") {
auto& s = TuiComplete::sources();
s.commands = {"help", "profile", "bots", "quit"};
s.help_text = " /help show help Show this help\n /profile NAME switch Switch profile\n /bots list bots\n";
s.provider_labels = {"Muse", "Grok"};
s.default_models = [] { return std::vector<std::string>{"model-a", "model-b"}; };
s.complete_files = [](const std::string&, const std::string&) { return std::vector<std::string>{"src/a.txt"}; };
s.dir_key = [] { return std::string("/tmp"); };
TuiComplete::reset_command_help();
}
}
return 0;
}
+70
View File
@@ -0,0 +1,70 @@
// tui_rows: differential harness for the chat row builders and markdown helpers.
// Reads one JSON case per line on stdin, prints the rendered rows (each span as
// <sgr>text) so the output can be diffed byte for byte against the python functions.
#include "TuiChatRows.h"
#include <iostream>
#include <string>
using namespace detz::tui;
static std::string spans_out(const SpanRows& rows) {
std::string out;
for (auto& row : rows) {
for (auto& span : row) out += "<" + span.style->sgr().substr(1) + ">" + TuiStr::s(span.text);
out += "\n";
}
return out;
}
static std::string segs_out(const TuiMarkdown::Segs& segs) {
std::string out = "[";
for (auto& s : segs) out += "(" + TuiValue(TuiStr::s(s.first)).repr() + "," + TuiValue(s.second).repr() + ")";
return out + "]";
}
int main() {
std::string line;
while (std::getline(std::cin, line)) {
bool ok = false;
TuiValue c = TuiValue::loads(line, &ok);
if (!ok) { std::cout << "BAD JSON\n"; continue; }
std::string fn = c.get("fn").as_str();
UStr text = TuiStr::u(c.get("text").as_str());
long width = static_cast<long>(c.get("width").as_int());
if (fn == "bubble") {
auto st = TuiChatRows::styles(c.get("side").as_str());
std::cout << spans_out(TuiChatRows::bubble_rows(text, width, c.get("side").as_str(), st, TuiStr::u(c.get("stamp").as_str()),
TuiStr::u(c.get("ticks").as_str()), c.get("read").truthy(),
TuiStr::u(c.get("sender").as_str()), c.get("markdown").truthy(), c.get("boxed").truthy()));
} else if (fn == "tool") {
auto base = TuiChatRows::styles("in")["base"];
std::cout << spans_out(SpanRows{TuiChatRows::tool_row(TuiStr::u(c.get("name").as_str()), c.get("args").as_str(), width, base)});
} else if (fn == "live") {
auto base = TuiChatRows::styles("in")["base"];
std::vector<UStr> lines;
for (auto& l : c.get("lines").items()) lines.push_back(TuiStr::u(l.as_str()));
std::cout << spans_out(TuiChatRows::live_rows(text, lines, TuiStr::u(c.get("status").as_str()), width, base));
} else if (fn == "note") {
auto base = TuiChatRows::styles("in")["base"];
std::cout << spans_out(TuiChatRows::note_rows(text, width, c.get("side").as_str(), base));
} else if (fn == "pill") {
auto st = TuiChatRows::styles("in");
std::cout << spans_out(SpanRows{TuiChatRows::pill_row(text, width, st["base"], st["bubble"])});
} else if (fn == "welcome") {
std::cout << spans_out(TuiChatRows::welcome_rows(width, text));
} else if (fn == "md") {
for (auto& b : TuiMarkdown::blocks(text)) std::cout << b.kind << " " << segs_out(b.segs) << " " << TuiValue(TuiStr::s(b.lead)).repr() << "\n";
} else if (fn == "wrap") {
auto segs = TuiMarkdown::inline_segs(text);
for (auto& l : TuiMarkdown::wrap_segs(segs, width)) std::cout << segs_out(l) << "\n";
} else if (fn == "textwrap") {
auto parts = TuiMarkdown::textwrap(text, width);
if (parts.empty()) parts = {UStr()};
for (auto& l : parts) std::cout << TuiValue(TuiStr::s(l)).repr() << "\n";
} else if (fn == "strip") {
std::cout << TuiValue(TuiStr::s(TuiMarkdown::strip_ansi(text))).repr() << "\n";
} else if (fn == "sidebar") {
std::cout << TuiChatRows::sidebar_w(static_cast<int>(width)) << "\n";
}
std::cout << "==\n";
}
return 0;
}
+87
View File
@@ -0,0 +1,87 @@
// tui_script: scripted headless session over the demo scene, to diff against python.
// Script lines (stdin):
// feed <hex> stdin bytes -> TuiInput -> app.dispatch
// flush TuiInput.flush() -> dispatch (lone ESC resolution)
// frame print hex of app.frame_bytes()
// text print app.render_text()
// resize R C like SIGWINCH: new size, invalidate, dirty_layout, Resize event
// shell <text> feed text (with \r\n for \n) into the demo shell screen
// ask open the demo question dialog (F1 path)
// sources install the completion sources used by the prompt dialog
#include "TuiComplete.h"
#include "TuiDemo.h"
#include <cstdio>
#include <iostream>
#include <sstream>
#include <string>
using namespace detz::tui;
static std::string unhex(const std::string& h) {
std::string out;
for (size_t i = 0; i + 1 < h.size(); i += 2) out.push_back(static_cast<char>(std::stoi(h.substr(i, 2), nullptr, 16)));
return out;
}
static std::string hex(const std::string& s) {
static const char* d = "0123456789abcdef";
std::string out;
for (unsigned char c : s) { out.push_back(d[c >> 4]); out.push_back(d[c & 15]); }
return out;
}
int main(int argc, char** argv) {
int rows = argc > 1 ? std::atoi(argv[1]) : 30;
int cols = argc > 2 ? std::atoi(argv[2]) : 100;
TuiApp app;
app.rows = rows;
app.cols = cols;
auto built = TuiDemo::build(app, false);
app.mount(built.first);
auto& refs = *built.second;
std::string line;
while (std::getline(std::cin, line)) {
std::istringstream in(line);
std::string cmd;
in >> cmd;
if (cmd == "feed") {
std::string h;
in >> h;
for (auto& ev : app.input.feed(unhex(h))) app.dispatch(*ev);
} else if (cmd == "flush") {
for (auto& ev : app.input.flush()) app.dispatch(*ev);
} else if (cmd == "frame") {
std::printf("%s\n", hex(app.frame_bytes()).c_str());
} else if (cmd == "text") {
std::printf("%s\n--\n", app.render_text().c_str());
} else if (cmd == "resize") {
int r, c;
in >> r >> c;
app.rows = r;
app.cols = c;
app.compositor.invalidate();
app.dirty_layout();
TuiResize ev;
ev.panel = app.root.get();
ev.rows = r;
ev.cols = c;
app.root->emit(ev);
} else if (cmd == "shell") {
std::string h;
in >> h;
refs.shell->screen.feed(unhex(h));
} else if (cmd == "ask") {
refs.ask();
} else if (cmd == "spawn") {
refs.spawn();
} else if (cmd == "sources") {
auto& s = TuiComplete::sources();
s.commands = {"help", "profile", "bots", "quit"};
s.help_text = " /help show help Show this help\n /profile NAME switch Switch profile\n /bots list bots\n";
s.provider_labels = {"Muse", "Grok"};
s.default_models = [] { return std::vector<std::string>{"model-a", "model-b"}; };
s.complete_files = [](const std::string&, const std::string&) { return std::vector<std::string>{"src/a.txt"}; };
s.dir_key = [] { return std::string("/tmp"); };
TuiComplete::reset_command_help();
}
}
return 0;
}