From cb8e3cf6665c6a4b78bc7b62eda7a970a972d0cd Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 25 Oct 2026 01:43:52 +0200 Subject: [PATCH] tui: chat scene, markdown and row builders, live stream; differential tests vs tai.py Co-Authored-By: Claude Sonnet 5.5 --- taicpp/src/tui/TuiChatAgent.h | 83 +++ taicpp/src/tui/TuiChatRows.cpp | 282 +++++++++ taicpp/src/tui/TuiChatRows.h | 57 ++ taicpp/src/tui/TuiChatScene.cpp | 985 +++++++++++++++++++++++++++++++ taicpp/src/tui/TuiChatScene.h | 110 ++++ taicpp/src/tui/TuiChatSession.h | 60 ++ taicpp/src/tui/TuiLiveStream.cpp | 30 + taicpp/src/tui/TuiLiveStream.h | 19 + taicpp/src/tui/TuiMarkdown.cpp | 237 ++++++++ taicpp/src/tui/TuiMarkdown.h | 33 ++ taicpp/src/tui/TuiStdioGuard.cpp | 28 + taicpp/src/tui/TuiStdioGuard.h | 33 ++ taicpp/tests/tui_chat.cpp | 160 +++++ taicpp/tests/tui_rows.cpp | 70 +++ taicpp/tests/tui_script.cpp | 87 +++ 15 files changed, 2274 insertions(+) create mode 100644 taicpp/src/tui/TuiChatAgent.h create mode 100644 taicpp/src/tui/TuiChatRows.cpp create mode 100644 taicpp/src/tui/TuiChatRows.h create mode 100644 taicpp/src/tui/TuiChatScene.cpp create mode 100644 taicpp/src/tui/TuiChatScene.h create mode 100644 taicpp/src/tui/TuiChatSession.h create mode 100644 taicpp/src/tui/TuiLiveStream.cpp create mode 100644 taicpp/src/tui/TuiLiveStream.h create mode 100644 taicpp/src/tui/TuiMarkdown.cpp create mode 100644 taicpp/src/tui/TuiMarkdown.h create mode 100644 taicpp/src/tui/TuiStdioGuard.cpp create mode 100644 taicpp/src/tui/TuiStdioGuard.h create mode 100644 taicpp/tests/tui_chat.cpp create mode 100644 taicpp/tests/tui_rows.cpp create mode 100644 taicpp/tests/tui_script.cpp diff --git a/taicpp/src/tui/TuiChatAgent.h b/taicpp/src/tui/TuiChatAgent.h new file mode 100644 index 0000000..3bf6e62 --- /dev/null +++ b/taicpp/src/tui/TuiChatAgent.h @@ -0,0 +1,83 @@ +#pragma once +// TuiChatAgent / TuiChatHost / TuiStreamSink: the seam between the chat scene and +// the agent module. tai.py's tui_build_chat reaches into Agent (bot, profile, +// messages, chat pins, store.list_bots/load_session/save_session, switch_bot, +// run_turn, checkpoint, on_phase/prompter/stream_factory hooks) and a handful of +// module functions (handle_command, running_agents, short_error, VERSION, +// start_scheduler, tui_spawn_agent). The agent port lives in another module, so +// the chat scene depends on these interfaces only; docs/wiring/tui.md lists the +// adapter the integrator writes (detz::agent::Agent -> TuiChatAgent). +#include "TuiHistory.h" +#include "TuiValue.h" +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +struct TuiChatToolCall { + std::string name; // function.name + std::string arguments; // function.arguments +}; +struct TuiChatMessage { + std::string role; + std::optional content; // python msg.get("content") + std::vector tool_calls; +}; + +// python BackendError: a model/provider failure that becomes "error: ..." in the transcript. +class TuiChatBackendError : public std::runtime_error { +public: + explicit TuiChatBackendError(const std::string& what) : std::runtime_error(what) {} +}; +// python KeyboardInterrupt inside a turn. +class TuiChatInterrupted : public std::runtime_error { +public: + TuiChatInterrupted() : std::runtime_error("interrupted") {} +}; + +// Stream sink for a command's output (feed/pause/close, like LiveStream). +class TuiStreamSink { +public: + virtual ~TuiStreamSink() = default; + virtual void feed(const std::string& line) = 0; + virtual void pause() = 0; + virtual void close(const std::string& status) = 0; +}; + +class TuiChatAgent { +public: + virtual ~TuiChatAgent() = default; + virtual std::string bot() const = 0; + virtual std::string profile() const = 0; + virtual std::vector messages() const = 0; + virtual std::string provider_pin() const = 0; // agent.chat.provider_pin ("" when none) + virtual std::string model_pin() const = 0; + virtual std::vector list_bots() const = 0; // store.list_bots(profile) names + virtual std::vector load_session(const std::string& bot) = 0; // store.load_session(profile, bot, ctx) + virtual void save_session() = 0; // store.save_session(profile, messages, bot, ctx) + virtual void switch_bot(const std::string& name) = 0; // silent + virtual std::string run_turn(const std::string& text) = 0; // capture=True; may throw TuiChatBackendError/TuiChatInterrupted + virtual void checkpoint() = 0; + virtual void set_quiet(bool quiet) = 0; + // slash commands: returns "stay" (false: leave the app); `output` is what the command printed + virtual bool handle_command(const std::string& text, std::string& output) = 0; + virtual void set_on_phase(std::function fn) = 0; + virtual void set_prompter(std::function fn) = 0; + virtual void set_stream_factory(std::function(const std::string& command)> fn) = 0; + virtual std::shared_ptr history_backend() = 0; // VaultHistory.for_agent(agent), or null +}; + +class TuiChatHost { +public: + virtual ~TuiChatHost() = default; + virtual std::shared_ptr spawn_agent(TuiChatAgent& base, const std::string& name) = 0; // tui_spawn_agent + virtual int running_agents() = 0; + virtual std::string version() = 0; // VERSION + virtual std::string short_error(const std::string& message) = 0; + virtual std::function start_scheduler() = 0; // returns the stop function +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatRows.cpp b/taicpp/src/tui/TuiChatRows.cpp new file mode 100644 index 0000000..97c8c15 --- /dev/null +++ b/taicpp/src/tui/TuiChatRows.cpp @@ -0,0 +1,282 @@ +#include "TuiChatRows.h" +#include +namespace detz::tui { + +const UStr& TuiChatRows::spinner() { static const UStr s = U"⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; return s; } +const UStr& TuiChatRows::online_glyph() { static const UStr s = U"●"; return s; } +const UStr& TuiChatRows::tick_send() { static const UStr s = U"◷"; return s; } +const UStr& TuiChatRows::tick_sent() { static const UStr s = U"✓"; return s; } +const UStr& TuiChatRows::tick_read() { static const UStr s = U"✓✓"; return s; } +const TuiValue& TuiChatRows::type_answer() { static const TuiValue t = TuiValue::tag(); return t; } + +int TuiChatRows::sidebar_w(int cols) { + return std::max(TUI_CHAT_SIDEBAR_MIN, std::min(TUI_CHAT_SIDEBAR_MAX, static_cast(cols * 0.3))); +} + +TuiChatRows::Styles TuiChatRows::styles(const std::string& side) { + std::string bg, fg, meta; + if (side == "out") { bg = "role:bubble_out"; fg = "role:bubble_fg"; meta = "role:meta_out"; } + else if (side == "error") { bg = "role:tool_bg"; fg = "role:error"; meta = "role:meta_in"; } + else if (side == "note") { bg = "role:tool_bg"; fg = "role:muted"; meta = "role:meta_in"; } + else { bg = "role:bubble_in"; fg = "role:bubble_fg"; meta = "role:meta_in"; } + Styles s; + s["base"] = mk_style("role:fg", "role:bg"); + s["bubble"] = mk_style(fg, bg); + s["meta"] = mk_style(meta, bg); + s["tick"] = mk_style(side == "out" ? "role:tick_read" : meta, bg, true); + s["name"] = mk_style(side != "out" ? "role:accent2" : "role:meta_out", bg, true); + s["b"] = mk_style(fg, bg, true); + s["i"] = mk_style("role:info", bg); + s["c"] = mk_style("role:accent3", "role:code_bg"); + s["code"] = mk_style("role:accent2", "role:code_bg"); + s["h"] = mk_style(side == "out" ? "role:accent3" : "role:accent", bg, true); + s["bullet"] = mk_style(side != "out" ? "role:accent2" : "role:meta_out", bg, true); + s["quote"] = mk_style("role:muted", bg); + s["bar"] = mk_style(side == "error" ? "role:error" : "role:accent", bg); + return s; +} + +std::vector TuiChatRows::body_rows(const UStr& text, long inner, const Styles&, bool markdown) { + std::vector rows; + if (!markdown) { + for (auto& para : TuiStr::split(text, U'\n')) { + auto parts = TuiMarkdown::textwrap(para, inner); + if (parts.empty()) parts = {UStr()}; + for (auto& part : parts) rows.push_back({TuiMarkdown::Segs{{TuiStr::rstrip(part), ""}}, ""}); + } + return rows; + } + for (auto& block : TuiMarkdown::blocks(text)) { + const std::string& kind = block.kind; + if (kind == "blank") { + rows.push_back({{}, ""}); + } else if (kind == "rule") { + rows.push_back({TuiMarkdown::Segs{{TuiStr::repeat(U'─', inner), "quote"}}, ""}); + } else if (kind == "code") { + const UStr& line = block.segs[0].first; + for (long start = 0; start < std::max(1L, static_cast(line.size())); start += inner) { + rows.push_back({TuiMarkdown::Segs{{U" " + TuiStr::slice(line, start, start + inner - 1), "code"}}, "code"}); + } + } else if (kind == "li") { + long indent = static_cast(block.lead.size()) + 1; + auto wrapped = TuiMarkdown::wrap_segs(block.segs, std::max(4L, inner - indent)); + for (size_t pos = 0; pos < wrapped.size(); pos++) { + TuiMarkdown::Segs head; + if (pos == 0) head = {{TuiStr::slice(block.lead, 0, -1), ""}, {TuiStr::slice(block.lead, -1, static_cast(block.lead.size())), "bullet"}, {U" ", ""}}; + else head = {{TuiStr::repeat(U' ', indent), ""}}; + head.insert(head.end(), wrapped[pos].begin(), wrapped[pos].end()); + rows.push_back({head, ""}); + } + } else if (kind == "quote") { + TuiMarkdown::Segs tagged; + for (auto& s : block.segs) tagged.emplace_back(s.first, "quote"); + for (auto& parts : TuiMarkdown::wrap_segs(tagged, std::max(4L, inner - 2))) { + TuiMarkdown::Segs row{{U"▎ ", "bar"}}; + row.insert(row.end(), parts.begin(), parts.end()); + rows.push_back({row, ""}); + } + } else { + for (auto& parts : TuiMarkdown::wrap_segs(block.segs, inner)) rows.push_back({parts, ""}); + } + } + return rows; +} + +static long seg_width(const TuiMarkdown::Segs& segs) { + long n = 0; + for (auto& s : segs) n += static_cast(s.first.size()); + return n; +} + +SpanRows TuiChatRows::bubble_rows(const UStr& text, long width, const std::string& side, const Styles& styles_in, const UStr& stamp, + const UStr& ticks, bool ticks_read, const UStr& sender, bool markdown, bool boxed) { + width = std::max(10L, width); + const StyleP& base = styles_in.at("base"); + const StyleP& bubble = styles_in.at("bubble"); + auto style_of = [&](const std::string& tag) -> StyleP { + auto it = styles_in.find(tag); + return it != styles_in.end() && !tag.empty() ? it->second : bubble; + }; + UStr meta; + { + std::vector parts; + if (!stamp.empty()) parts.push_back(stamp); + if (!ticks.empty()) parts.push_back(ticks); + meta = TuiStr::strip(TuiStr::join(parts, U" ")); + } + if (width < 34) { + SpanRows rows; + if (!sender.empty()) rows.push_back(SpanRow{TuiSpan(TuiStr::ljust(TuiStr::head(sender, width), width), style_of("name"))}); + for (auto& br : body_rows(text, width, styles_in, markdown)) { + SpanRow spans; + for (auto& seg : br.first) spans.push_back(TuiSpan(seg.first, style_of(seg.second))); + rows.push_back(TuiSpans::pad(spans, width, base)); + } + if (!meta.empty()) rows.push_back(SpanRow{TuiSpan(TuiStr::rjust(meta, width), styles_in.at("meta"))}); + return rows; + } + long cap = std::max(14L, static_cast(width * TUI_CHAT_BUBBLE_MAX)); + long inner_cap = cap - 2 * TUI_CHAT_PAD; + auto body = body_rows(text, inner_cap, styles_in, markdown); + long need = std::max({1L, static_cast(sender.size()) + 1, static_cast(meta.size())}); + for (auto& br : body) need = std::max(need, seg_width(br.first)); + long bubble_w = std::min(cap, need + 2 * TUI_CHAT_PAD); + long inner = bubble_w - 2 * TUI_CHAT_PAD; + TuiSpan pad(UStr(static_cast(TUI_CHAT_PAD), U' '), bubble); + SpanRows rows; + if (boxed) { + if (sender.empty()) rows.push_back(SpanRow{TuiSpan(UStr(static_cast(bubble_w), U' '), bubble)}); + else rows.push_back(SpanRow{pad, TuiSpan(TuiStr::ljust(TuiStr::head(sender, inner), inner), style_of("name")), pad}); + } + for (auto& br : body) { + SpanRow spans; + spans.push_back(pad); + long used = 0; + for (auto& seg : br.first) { + spans.push_back(TuiSpan(seg.first, style_of(seg.second))); + used += static_cast(seg.first.size()); + } + StyleP fill_style = !br.second.empty() ? style_of(br.second) : bubble; + spans.push_back(TuiSpan(UStr(static_cast(std::max(0L, inner - used)), U' '), fill_style)); + spans.push_back(pad); + rows.push_back(spans); + } + if (boxed) { + if (!meta.empty()) { + SpanRow row{pad, TuiSpan(UStr(static_cast(std::max(0L, inner - static_cast(meta.size()))), U' '), bubble)}; + if (!stamp.empty()) row.push_back(TuiSpan(stamp, styles_in.at("meta"))); + if (!stamp.empty() && !ticks.empty()) row.push_back(TuiSpan(U" ", styles_in.at("meta"))); + if (!ticks.empty()) row.push_back(TuiSpan(ticks, ticks_read ? styles_in.at("tick") : styles_in.at("meta"))); + row.push_back(pad); + rows.push_back(row); + } else { + rows.push_back(SpanRow{TuiSpan(UStr(static_cast(bubble_w), U' '), bubble)}); + } + } + SpanRows aligned; + for (auto& spans : rows) { + SpanRow row; + UStr gap(static_cast(std::max(0L, width - bubble_w)), U' '); + if (side == "out") { + row.push_back(TuiSpan(gap, base)); + row.insert(row.end(), spans.begin(), spans.end()); + } else { + row = spans; + row.push_back(TuiSpan(gap, base)); + } + aligned.push_back(std::move(row)); + } + return aligned; +} + +UStr TuiChatRows::tool_summary(const std::string& args) { + bool ok = false; + TuiValue data = TuiValue::loads(args, &ok); + if (!ok) return TuiStr::u(TuiStr::strip_ascii(args)); + if (!data.is_dict()) return TuiStr::u(data.str()); + UStr out; + bool first = true; + for (auto& kv : data.pairs()) { + if (!first) out += U" "; + first = false; + std::string value; + if (kv.second.is_num()) { + value = kv.second.str(); + } else { + value = kv.second.dumps(false, false); + size_t b = 0, e = value.size(); + while (b < e && value[b] == '"') b++; + while (e > b && value[e - 1] == '"') e--; + value = value.substr(b, e - b); + } + out += TuiStr::u(kv.first + "=" + value); + } + return out; +} + +SpanRow TuiChatRows::tool_row(const UStr& name, const std::string& args, long width, const StyleP& base) { + width = std::max(10L, width); + StyleP chip = mk_style("role:on_accent", "role:accent2", true); + StyleP label = mk_style("role:fg", "role:tool_bg", true); + StyleP detail = mk_style("role:muted", "role:tool_bg"); + long room = std::max(0L, width - static_cast(name.size()) - 8); + UStr text = TuiStr::replace(tool_summary(args), U"\n", U" "); + if (static_cast(text.size()) > room) text = TuiStr::head(text, std::max(0L, room - 1)) + U"…"; + SpanRow spans{TuiSpan(U" ", base), TuiSpan(U" ⚙ ", chip), TuiSpan(U" " + name + U" ", label)}; + if (!text.empty()) spans.push_back(TuiSpan(text + U" ", detail)); + return TuiSpans::pad(spans, width, base); +} + +SpanRows TuiChatRows::live_rows(const UStr& command, const std::vector& lines, const UStr& status, long width, const StyleP& base) { + width = std::max(10L, width); + StyleP chip = mk_style("role:on_accent", "role:accent2", true); + StyleP head = mk_style("role:fg", "role:tool_bg", true); + StyleP body = mk_style("role:fg", "role:tool_bg"); + StyleP dim = mk_style("role:muted", "role:tool_bg"); + StyleP bar = mk_style("role:accent2", "role:tool_bg"); + UStr title = TuiStr::replace(command, U"\n", U" "); + long room = std::max(0L, width - 9); + if (static_cast(title.size()) > room) title = TuiStr::head(title, std::max(0L, room - 1)) + U"…"; + SpanRows rows; + rows.push_back(TuiSpans::pad(SpanRow{TuiSpan(U" ", base), TuiSpan(U" ⌁ ", chip), TuiSpan(U" " + title + U" ", head)}, width, base)); + std::vector shown = lines; + if (static_cast(shown.size()) > TUI_CHAT_LIVE_ROWS) shown.erase(shown.begin(), shown.end() - TUI_CHAT_LIVE_ROWS); + if (shown.empty()) shown = {status.empty() ? UStr(U"(waiting for output)") : UStr(U"(no output)")}; + for (auto& raw : shown) { + UStr text = TuiStr::head(TuiStr::expandtabs(raw, 4), std::max(0L, width - 4)); + rows.push_back(TuiSpans::pad(SpanRow{TuiSpan(U" ", base), TuiSpan(U"│ ", bar), TuiSpan(text, !lines.empty() ? body : dim)}, width, base)); + } + if (!status.empty()) { + rows.push_back(TuiSpans::pad(SpanRow{TuiSpan(U" ", base), TuiSpan(U"└ " + TuiStr::head(status, std::max(0L, width - 4)), dim)}, width, base)); + } + return rows; +} + +SpanRows TuiChatRows::note_rows(const UStr& text, long width, const std::string& side, const StyleP& base) { + Styles st = styles(side); + long cap = std::max(12L, static_cast(width * 0.9)); + auto lines = body_rows(text, cap - 4, st, false); + long longest = 1; + for (auto& br : lines) longest = std::max(longest, br.first.empty() ? 0L : static_cast(br.first[0].first.size())); + long block_w = std::min(cap, longest + 4); + SpanRows rows; + for (auto& br : lines) { + UStr raw = br.first.empty() ? UStr() : br.first[0].first; + rows.push_back(TuiSpans::pad(SpanRow{TuiSpan(U"▎", st["bar"]), TuiSpan(U" " + TuiStr::ljust(raw, block_w - 3) + U" ", st["bubble"])}, width, base)); + } + return rows; +} + +SpanRow TuiChatRows::pill_row(const UStr& text, long width, const StyleP& base_style, const StyleP& pill_style) { + UStr label = TuiStr::head(U" " + TuiStr::strip(text) + U" ", std::max(0L, width)); + long left = std::max(0L, (width - static_cast(label.size())) / 2); + long right = std::max(0L, width - left - static_cast(label.size())); + return SpanRow{TuiSpan(UStr(static_cast(left), U' '), base_style), TuiSpan(label, pill_style), TuiSpan(UStr(static_cast(right), U' '), base_style)}; +} + +SpanRows TuiChatRows::welcome_rows(long width, const UStr& name) { + width = std::max(10L, width); + StyleP base = mk_style("role:fg", "role:bg"); + StyleP logo = mk_style("role:accent", "role:bg", true); + StyleP sub = mk_style("role:muted", "role:bg"); + StyleP key = mk_style("role:service_fg", "role:service_bg", true); + std::vector lines = { + {TuiSpan(U"✦ tai", logo)}, + {TuiSpan(U"chatting with " + name, sub)}, + {}, + {TuiSpan(U" Enter ", key), TuiSpan(U" send ", sub), TuiSpan(U" /help ", key), TuiSpan(U" commands ", sub), TuiSpan(U" @ ", key), TuiSpan(U" mention a bot", sub)}, + {TuiSpan(U" F2 ", key), TuiSpan(U" bots ", sub), TuiSpan(U" F4 ", key), TuiSpan(U" theme", sub)}, + }; + SpanRows rows; + rows.push_back(SpanRow{TuiSpan(UStr(static_cast(width), U' '), base)}); + for (auto& spans : lines) { + long used = TuiSpans::width(spans); + long left = std::max(0L, (width - used) / 2); + SpanRow row{TuiSpan(UStr(static_cast(left), U' '), base)}; + row.insert(row.end(), spans.begin(), spans.end()); + rows.push_back(TuiSpans::pad(row, width, base)); + } + return rows; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatRows.h b/taicpp/src/tui/TuiChatRows.h new file mode 100644 index 0000000..ada71b7 --- /dev/null +++ b/taicpp/src/tui/TuiChatRows.h @@ -0,0 +1,57 @@ +#pragma once +// TuiChatRows: the chat transcript's row builders (tai.py tui_chat_sidebar_w, +// tui_chat_styles, tui_chat_body_rows, tui_chat_bubble_rows, tui_chat_tool_summary, +// tui_chat_tool_row, tui_chat_live_rows, tui_chat_note_rows, tui_chat_pill_row, +// tui_chat_welcome_rows) and the TUI_CHAT_* constants. Static utility class. +#include "TuiMarkdown.h" +#include "TuiStyle.h" +#include "TuiValue.h" +#include +#include +#include +namespace detz::tui { + +constexpr int TUI_CHAT_SIDEBAR_MIN = 24; +constexpr int TUI_CHAT_SIDEBAR_MAX = 36; +constexpr int TUI_CHAT_NARROW_COLS = 80; +constexpr int TUI_CHAT_INPUT_CAP = 2000; +constexpr int TUI_CHAT_HISTORY = 40; +constexpr int TUI_CHAT_LINE_CAP = 500; +constexpr double TUI_CHAT_BUBBLE_MAX = 0.7; +constexpr int TUI_CHAT_PAD = 2; +constexpr int TUI_CHAT_PROMPT_TIMEOUT = 600; +constexpr int TUI_CHAT_PROMPT_CHARS = 600; +constexpr double TUI_CHAT_REVEAL_TICK = 0.04; +constexpr int TUI_CHAT_REVEAL_STEPS = 40; +constexpr int TUI_CHAT_BLOCK_CAP = 300; +constexpr int TUI_CHAT_LIVE_ROWS = 8; +constexpr int TUI_CHAT_LIVE_KEEP = 200; +constexpr double TUI_CHAT_LIVE_TICK = 0.05; + +class TuiChatRows { +public: + using Styles = std::map; + using BodyRow = std::pair; // (segs, fill tag) + + static const UStr& spinner(); // TUI_CHAT_SPINNER + static const UStr& online_glyph(); // TUI_CHAT_ONLINE_GLYPH + static const UStr& tick_send(); // TUI_CHAT_TICK_SEND + static const UStr& tick_sent(); // TUI_CHAT_TICK_SENT + static const UStr& tick_read(); // TUI_CHAT_TICK_READ + static const TuiValue& type_answer(); // TUI_CHAT_TYPE_ANSWER sentinel + + static int sidebar_w(int cols); + static Styles styles(const std::string& side); + static std::vector body_rows(const UStr& text, long inner, const Styles& styles, bool markdown = true); + static SpanRows bubble_rows(const UStr& text, long width, const std::string& side, const Styles& styles, const UStr& stamp = UStr(), + const UStr& ticks = UStr(), bool ticks_read = false, const UStr& sender = UStr(), bool markdown = true, + bool boxed = true); + static UStr tool_summary(const std::string& args); + static SpanRow tool_row(const UStr& name, const std::string& args, long width, const StyleP& base); + static SpanRows live_rows(const UStr& command, const std::vector& lines, const UStr& status, long width, const StyleP& base); + static SpanRows note_rows(const UStr& text, long width, const std::string& side, const StyleP& base); + static SpanRow pill_row(const UStr& text, long width, const StyleP& base_style, const StyleP& pill_style); + static SpanRows welcome_rows(long width, const UStr& name); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatScene.cpp b/taicpp/src/tui/TuiChatScene.cpp new file mode 100644 index 0000000..b17ae7f --- /dev/null +++ b/taicpp/src/tui/TuiChatScene.cpp @@ -0,0 +1,985 @@ +#include "TuiChatScene.h" +#include "TuiMarkdown.h" +#include "TuiStdioGuard.h" +#include "TuiLiveStream.h" +#include "TuiComplete.h" +#include +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +std::function TuiChatScene::clock; + +namespace { +std::time_t now_time() { return TuiChatScene::clock ? TuiChatScene::clock() : std::time(nullptr); } +UStr strftime_hm() { + std::time_t t = now_time(); + std::tm tm{}; + localtime_r(&t, &tm); + char buf[16]; + std::strftime(buf, sizeof buf, "%H:%M", &tm); + return TuiStr::u(buf); +} +UStr month_day() { + std::time_t t = now_time(); + std::tm tm{}; + localtime_r(&t, &tm); + char buf[48]; + std::strftime(buf, sizeof buf, "%B", &tm); + return TuiStr::u(std::string(buf) + " " + std::to_string(tm.tm_mday)); +} +} // namespace + +TuiChatScene::TuiChatScene(TuiApp& app_ref, std::shared_ptr agent_in, TuiChatHost& host_ref) + : app(app_ref), host(host_ref), agent(std::move(agent_in)) { + auto s = std::make_shared(agent->bot(), agent); + sessions.emplace_back(agent->bot(), s); + session = s; + panel_style = mk_style("role:fg", "role:bg"); + dim_style = mk_style("role:muted", "role:bg"); + err_style = mk_style("role:error", "role:bg"); + bar_style = mk_style("role:fg", "role:panel"); + name_style = mk_style("role:fg", "role:panel", true); + side_style = mk_style("role:fg", "role:panel"); + pill_style = mk_style("role:service_fg", "role:service_bg", true); + for (const char* side : {"in", "out", "note", "error"}) bubble_styles[side] = TuiChatRows::styles(side); +} + +PanelP TuiChatScene::build() { + TuiPanelOpts o; + o.flow = "rows"; + o.style = mk_style(TuiColor(), "role:bg"); + o.id = "chat"; + root_p = std::make_shared(o); + TuiPanelOpts bo; + bo.h = TuiSpec("1fr"); + bo.style = mk_style(TuiColor(), "role:bg"); + bo.id = "body"; + auto body = std::make_shared(bo); + TuiPanelOpts swo; + swo.w = TuiSpec(TuiChatRows::sidebar_w(app.cols)); + swo.h = TuiSpec("100%"); + swo.style = side_style; + swo.id = "sidebar"; + sidebar_wrap = std::make_shared(swo); + TuiPanelOpts bro; + bro.h = TuiSpec(3); + bro.style = side_style; + bro.id = "brand"; + brand = std::make_shared(SpanRows{}, bro); + TuiPanelOpts sbo; + sbo.h = TuiSpec("1fr"); + sbo.style = side_style; + sbo.id = "bots"; + sidebar = std::make_shared(std::vector{}, sbo); + sidebar_wrap->add(brand); + sidebar_wrap->add(sidebar); + TuiPanelOpts ro; + ro.w = TuiSpec("1fr"); + ro.h = TuiSpec("100%"); + ro.style = mk_style(TuiColor(), "role:bg"); + ro.id = "right"; + auto right = std::make_shared(ro); + TuiPanelOpts ho; + ho.h = TuiSpec(3); + ho.style = bar_style; + ho.padding = TuiPanel::norm_padding(0, 1); + ho.id = "header"; + auto header = std::make_shared(ho); + TuiPanelOpts avo; + avo.w = TuiSpec(7); + avo.h = TuiSpec(3); + avo.style = bar_style; + avo.id = "avatar"; + avatar = std::make_shared(SpanRows{}, avo); + TuiPanelOpts info_o; + info_o.w = TuiSpec("1fr"); + info_o.h = TuiSpec(3); + info_o.valign = "middle"; + info_o.style = bar_style; + info_o.padding = TuiPanel::norm_padding(0, 1); + info_o.id = "info"; + auto info = std::make_shared(info_o); + TuiPanelOpts hn; + hn.h = TuiSpec(1); + hn.style = name_style; + hn.id = "head_name"; + head_name = std::make_shared(UStr(), hn, "left", false); + TuiPanelOpts sro; + sro.h = TuiSpec(1); + sro.style = bar_style; + sro.id = "status_row"; + auto status_row = std::make_shared(sro); + TuiPanelOpts hdo; + hdo.w = TuiSpec(2); + hdo.h = TuiSpec(1); + hdo.style = mk_style("role:success", "role:panel"); + hdo.id = "head_dot"; + head_dot = std::make_shared(U"● ", hdo, "left", false); + TuiPanelOpts hso; + hso.w = TuiSpec("1fr"); + hso.h = TuiSpec(1); + hso.style = mk_style("role:muted", "role:panel"); + hso.id = "head_status"; + head_status = std::make_shared(SpanRows{}, hso); + status_row->add(head_dot); + status_row->add(head_status); + info->add(head_name); + info->add(status_row); + TuiPanelOpts hmo; + hmo.w = TuiSpec(24); + hmo.h = TuiSpec(3); + hmo.style = bar_style; + hmo.id = "head_meta"; + head_meta = std::make_shared(SpanRows{}, hmo); + header->add(avatar); + header->add(info); + header->add(head_meta); + TuiPanelOpts tro; + tro.focusable = true; + tro.h = TuiSpec("1fr"); + tro.style = panel_style; + tro.id = "transcript"; + transcript = std::make_shared(tro); + TuiPanelOpts co; + co.h = TuiSpec(3); + co.gap = 1; + co.padding = TuiPanel::norm_padding(0, 1); + co.style = mk_style(TuiColor(), "role:bg"); + co.id = "composer"; + composer = std::make_shared(co); + TuiPanelOpts bxo; + bxo.w = TuiSpec("1fr"); + bxo.h = TuiSpec(1); + bxo.border = true; + bxo.rounded = true; + bxo.padding = TuiPanel::norm_padding(0, 1); + bxo.id = "input"; + bxo.style = mk_style("role:fg", "role:surface"); + bxo.border_style = mk_style("role:border", "role:bg"); + bxo.focus_border_style = mk_style("role:accent", "role:bg"); + std::shared_ptr backend = agent->history_backend(); + box = std::make_shared( + UStr(), [](const UStr& prefix) { return TuiComplete::command(prefix); }, + [this](const UStr& prefix) { + std::vector found = TuiComplete::mention(prefix, "", bots); + return found; + }, + true, [this](const UStr& value) { do_send(value); }, TUI_CHAT_INPUT_CAP, UStr(U"Write a message..."), std::make_shared(backend), bxo); + TuiPanelOpts sbo2; + sbo2.w = TuiSpec(10); + sbo2.h = TuiSpec(1); + sbo2.border = true; + sbo2.rounded = true; + sbo2.border_style = mk_style("role:accent", "role:bg"); + sbo2.focus_border_style = mk_style("role:accent2", "role:bg"); + sbo2.style = mk_style("role:on_accent", "role:accent", true); + sbo2.id = "send"; + send_btn = std::make_shared(U"Send ➤", [this](TuiEvent&) { do_send(box->value()); }, false, sbo2); + composer->add(box); + composer->add(send_btn); + right->add(header); + right->add(transcript); + right->add(composer); + body->add(sidebar_wrap); + body->add(right); + TuiPanelOpts sto; + sto.h = TuiSpec(1); + sto.style = bar_style; + sto.id = "status"; + status = std::make_shared(SpanRows{}, sto); + root_p->add(body); + root_p->add(status); + body_p = body; + + sidebar->set_handler("select", [this](TuiEvent& e) { select_bot(static_cast(e).value.str()); }); + root_p->set_handler("key", [this](TuiEvent& e) { on_root_key(static_cast(e)); }); + root_p->set_handler("resize", [this](TuiEvent&) { on_resize(); }); + box->set_handler("change", [this](TuiEvent&) { tick_input(); }); + tick_input(); + app.every(1.0, [this] { paint_status(); }); + on_resize(); + refresh_bots(); + render_history(); + paint_status(); + return root_p; +} + +// ---- transcript ---------------------------------------------------------------- + +void TuiChatScene::render_block(const TuiChatBlockP& block) { + long width = std::max(10, transcript->content_w() - 3); + const StyleP& base = panel_style; + const std::string& kind = block->kind; + std::string key = kind + ((kind == "msg" || kind == "note") ? "|" + block->side : ""); + if (!transcript->lines.empty() && last_key && (key != *last_key || kind == "msg")) transcript->append(UStr()); + SpanRows rows; + if (kind == "pill") { + rows = {TuiChatRows::pill_row(block->arg_text, width, base, pill_style)}; + } else if (kind == "welcome") { + rows = TuiChatRows::welcome_rows(width, block->arg_text); + } else if (kind == "tool") { + rows = {TuiChatRows::tool_row(block->name, block->args, width, base)}; + } else if (kind == "live") { + std::vector lines; + UStr status_text; + { + std::lock_guard g(block->lines_lock); + lines = block->lines; + status_text = block->status; + } + rows = TuiChatRows::live_rows(block->command, lines, status_text, width, base); + } else if (kind == "note") { + rows = TuiChatRows::note_rows(block->text, width, block->side, base); + } else { + rows = TuiChatRows::bubble_rows(block->text, width, block->side, bubble_styles[block->side], block->stamp, block->ticks, block->ticks_read, block->sender); + } + TuiSpan margin(U" ", base); + for (auto& spans : rows) { + SpanRow row{margin}; + row.insert(row.end(), spans.begin(), spans.end()); + transcript->append_spans(row); + } + last_key = key; +} + +void TuiChatScene::replay() { + if (!transcript->aw) return; + bool pinned = transcript->scroll_y() >= std::max(0, static_cast(transcript->lines.size()) - transcript->content_h()); + transcript->set_lines({}); + last_key.reset(); + for (auto& block : std::vector(session->blocks)) render_block(block); + while (static_cast(transcript->lines.size()) > TUI_CHAT_LINE_CAP && session->blocks.size() > 1) { + session->blocks.erase(session->blocks.begin()); + transcript->set_lines({}); + last_key.reset(); + for (auto& block : std::vector(session->blocks)) render_block(block); + } + if (pinned) transcript->set_scroll_y(std::max(0, static_cast(transcript->lines.size()) - transcript->content_h())); + else transcript->clamp_scroll(); + render_w = transcript->content_w(); + transcript->touch(); +} + +void TuiChatScene::maybe_replay() { + if (transcript->aw && transcript->content_w() != render_w) replay(); +} + +TuiChatBlockP TuiChatScene::journal(const TuiChatBlockP& block, TuiChatSessionP s) { + if (!s) s = cur(); + s->blocks.push_back(block); + if (s != cur()) { + if (s->blocks.size() > static_cast(TUI_CHAT_BLOCK_CAP)) s->blocks.erase(s->blocks.begin(), s->blocks.end() - TUI_CHAT_BLOCK_CAP); + return block; + } + if (transcript->aw) { + render_block(block); + if (static_cast(transcript->lines.size()) > TUI_CHAT_LINE_CAP && s->blocks.size() > 1) { + s->blocks.erase(s->blocks.begin()); + replay(); + } + } + return block; +} + +TuiChatBlockP TuiChatScene::bubble(const std::string& side, const UStr& text, const UStr& stamp, const UStr& ticks, bool ticks_read, + const UStr& sender, TuiChatSessionP s) { + auto b = std::make_shared(); + b->kind = "msg"; + b->side = side; + b->text = text; + b->stamp = stamp; + b->ticks = ticks; + b->ticks_read = ticks_read; + b->sender = sender; + return journal(b, s); +} + +TuiChatBlockP TuiChatScene::note(const UStr& text, const std::string& side, TuiChatSessionP s) { + auto b = std::make_shared(); + b->kind = "note"; + b->side = side; + b->text = text; + return journal(b, s); +} + +TuiChatBlockP TuiChatScene::pill(const UStr& text) { + auto b = std::make_shared(); + b->kind = "pill"; + b->arg_text = text; + return journal(b); +} + +TuiChatBlockP TuiChatScene::show(const std::string& sender, const UStr& text, const std::string& style) { + if (sender == "you") return bubble("out", text); + if (style == "err") return note(text, "error"); + if (style == "dim") return note(text); + return bubble("in", text, UStr(), UStr(), false, TuiStr::u(agent->bot())); +} + +void TuiChatScene::render_history(bool force) { + auto s = cur(); + if (!s->blocks.empty() && !force) { + replay(); + return; + } + s->blocks.clear(); + s->pending_tick.reset(); + transcript->set_lines({}); + last_key.reset(); + std::vector past; + for (auto& msg : agent->messages()) if (msg.role != "system") past.push_back(msg); + if (past.size() > static_cast(TUI_CHAT_HISTORY)) past.erase(past.begin(), past.end() - TUI_CHAT_HISTORY); + if (past.empty()) { + pill(month_day()); + auto w = std::make_shared(); + w->kind = "welcome"; + w->arg_text = TuiStr::u(agent->bot()); + journal(w); + } + for (auto& msg : past) { + if (msg.role == "user") { + bubble("out", TuiMarkdown::strip_ansi(TuiStr::u(msg.content.value_or("")))); + } else if (msg.role == "assistant") { + if (msg.content && !msg.content->empty()) bubble("in", TuiMarkdown::strip_ansi(TuiStr::u(*msg.content)), UStr(), UStr(), false, TuiStr::u(agent->bot())); + for (auto& call : msg.tool_calls) { + auto t = std::make_shared(); + t->kind = "tool"; + t->name = TuiStr::u(call.name.empty() ? "tool" : call.name); + t->args = call.arguments; + journal(t); + } + } + } +} + +// ---- status ---------------------------------------------------------------------- + +UStr TuiChatScene::phase_word() { + if (session->phase == "tool") return U"running " + TuiStr::u(session->detail.empty() ? "tool" : session->detail); + if (session->phase == "typing") return U"typing"; + if (session->phase == "ask") return U"waiting for you"; + return U"thinking"; +} + +TuiChatScene::StatusSpans TuiChatScene::status_spans() { + const char* panel = "role:panel"; + if (!(session->busy || session->pending)) { + return {U"online", TuiChatRows::online_glyph(), mk_style("role:success", panel), SpanRow{TuiSpan(U"online", mk_style("role:muted", panel))}}; + } + UStr word = phase_word(); + long frame = spin_frame; + const char* tints[] = {"role:accent", "role:accent2", "role:accent3"}; + const char* tint = tints[(frame / 3) % 3]; + UStr glyph; + if (session->phase != "typing") glyph = UStr(1, TuiChatRows::spinner()[static_cast(frame % static_cast(TuiChatRows::spinner().size()))]); + else glyph = (frame % 4 < 2) ? UStr(U"✎") : UStr(U"✐"); + long head = frame % (static_cast(word.size()) + 6) - 3; + SpanRow spans; + for (size_t pos = 0; pos < word.size(); pos++) { + long reach = std::labs(static_cast(pos) - head); + const char* fg = reach == 0 ? "role:fg" : (reach == 1 ? tint : "role:muted"); + spans.push_back(TuiSpan(UStr(1, word[pos]), mk_style(fg, panel, reach <= 1))); + } + long lit = (frame / 4) % 4; + for (long pos = 0; pos < 3; pos++) { + spans.push_back(TuiSpan(U".", mk_style(pos < lit ? tint : "role:border", panel, pos < lit))); + } + return {word + U"...", glyph, mk_style(tint, panel, true), spans}; +} + +int TuiChatScene::others_busy() { + int n = 0; + for (auto& kv : sessions) if (kv.second != cur() && kv.second->busy) n++; + return n; +} + +void TuiChatScene::paint_status() { + long width = std::max(20, app.cols); + StyleP key_style = mk_style("role:service_fg", "role:service_bg", true); + StyleP label_style = mk_style("role:muted", "role:panel"); + TuiSpan gap(U" ", bar_style); + std::vector left; + if (!cur()->note.empty()) { + bool warn = cur()->busy || cur()->pending.has_value(); + UStr glyph = warn ? status_spans().glyph : UStr(U"●"); + left.push_back(SpanRow{TuiSpan(U" " + glyph + U" ", mk_style(warn ? "role:warning" : "role:success", "role:panel", true)), + TuiSpan(cur()->note, mk_style("role:fg", "role:panel", true))}); + } + const std::pair keys[] = {{U"F2", U"Bots"}, {U"F4", U"Theme"}, {U"Tab", U"Focus"}, {U"i", U"Input"}, {U"/help", U"Commands"}}; + for (auto& kv : keys) { + left.push_back(SpanRow{TuiSpan(U" " + UStr(kv.first) + U" ", key_style), TuiSpan(U" " + UStr(kv.second) + U" ", label_style)}); + } + std::string pins; + for (const std::string& part : {agent->provider_pin(), agent->model_pin()}) { + if (part.empty()) continue; + if (!pins.empty()) pins += " "; + pins += part; + } + std::vector right; + right.push_back(SpanRow{TuiSpan(U" " + TuiStr::u(agent->profile() + "/" + agent->bot()) + U" ", mk_style("role:on_accent", "role:accent", true))}); + right.push_back(SpanRow{TuiSpan(U" " + TuiStr::u(pins.empty() ? "auto" : pins) + U" ", mk_style("role:service_fg", "role:service_bg"))}); + int busy = host.running_agents() + others_busy(); + if (busy) right.push_back(SpanRow{TuiSpan(U" +" + TuiStr::u(std::to_string(busy)) + U" ", mk_style("role:bg", "role:warning", true))}); + auto size = [](const std::vector& groups) { + long n = 0; + for (auto& g : groups) n += TuiSpans::width(g) + 1; + return n; + }; + while (left.size() > 1 && size(left) + size(right) > width) left.pop_back(); + while (right.size() > 1 && size(left) + size(right) > width) right.pop_back(); + SpanRow spans; + for (auto& g : left) { + spans.insert(spans.end(), g.begin(), g.end()); + spans.push_back(gap); + } + long fill = std::max(0L, width - size(left) - size(right)); + spans.push_back(TuiSpan(UStr(static_cast(fill), U' '), bar_style)); + for (auto& g : right) { + spans.insert(spans.end(), g.begin(), g.end()); + spans.push_back(gap); + } + status->set_row(spans); + std::string tint = "role:" + TuiChatList::avatar_role(TuiStr::u(agent->bot())); + StyleP av = mk_style("role:on_accent", tint, true); + UStr initial = TuiStr::upper(TuiStr::head(TuiStr::u(agent->bot()), 1)); + if (initial.empty()) initial = U"?"; + avatar->set(SpanRows{SpanRow{TuiSpan(UStr(7, U' '), av)}, SpanRow{TuiSpan(TuiStr::center(U" " + initial + U" ", 7), av)}, SpanRow{TuiSpan(UStr(7, U' '), av)}}); + head_name->set_text(TuiStr::u(agent->bot())); + StatusSpans ss = status_spans(); + if (ss.spans.empty()) head_status->set(SpanRows{}); + else head_status->set_row(ss.spans); + head_status->set_text(ss.text); + head_dot->set_text(ss.glyph + U" "); + head_dot->set_style(ss.dot_style); + brand->set(SpanRows{SpanRow{}, + SpanRow{TuiSpan(U" ✦ ", mk_style("role:accent", "role:panel", true)), TuiSpan(U"tai", mk_style("role:fg", "role:panel", true)), + TuiSpan(U" " + TuiStr::u(host.version()), mk_style("role:muted", "role:panel"))}, + SpanRow{TuiSpan(U" Bots", mk_style("role:accent2", "role:panel", true))}}); + StyleP meta_style = mk_style("role:muted", "role:panel"); + UStr label = TuiStr::u(pins.empty() ? "auto" : pins); + UStr meta_text = U"◆ " + label + U" "; + head_meta->set(SpanRows{SpanRow{}, SpanRow{TuiSpan(TuiStr::rjust(TuiStr::slice(meta_text, -22, static_cast(meta_text.size())), 24), meta_style)}, SpanRow{}}); + head_meta->set_visible(app.cols >= 70); +} + +void TuiChatScene::clear_box() { + box->set_lines({UStr()}); + box->reset_cursor_home(); + box->complete_hide(); + box->touch(); +} + +UStr TuiChatScene::bot_snippet(const std::string& name) { + std::vector msgs; + try { + msgs = name == agent->bot() ? agent->messages() : agent->load_session(name); + } catch (const std::exception&) { + return UStr(); + } + for (auto it = msgs.rbegin(); it != msgs.rend(); ++it) { + UStr content = TuiStr::strip(TuiStr::u(it->content.value_or(""))); + if (!content.empty()) return TuiStr::head(TuiStr::split(content, U'\n')[0], 40); + } + return UStr(); +} + +TuiValue TuiChatScene::bot_meta(const std::string& name) { + TuiChatSessionP item; + for (auto& kv : sessions) if (kv.first == name) item = kv.second; + bool busy = item && (item->busy || item->pending.has_value()); + UStr glyph; + if (busy) glyph = UStr(1, TuiChatRows::spinner()[static_cast(spin_frame % static_cast(TuiChatRows::spinner().size()))]); + if (item && item->queued) glyph = U"?"; + TuiValue meta = TuiValue::dict(); + meta.set("busy", TuiStr::s(glyph)); + meta.set("unread", item ? item->unread : 0); + return meta; +} + +void TuiChatScene::refresh_meta() { + for (auto& kv : sidebar->row_meta) { + TuiValue m = bot_meta(kv.first); + for (auto& p : m.pairs()) kv.second.set(p.first, p.second); + } + sidebar->touch(); +} + +void TuiChatScene::refresh_bots() { + std::vector names = agent->list_bots(); + for (auto& kv : sessions) if (std::find(names.begin(), names.end(), kv.first) == names.end()) names.push_back(kv.first); + if (std::find(names.begin(), names.end(), agent->bot()) == names.end()) names.insert(names.begin(), agent->bot()); + bots = names; + sidebar->options.clear(); + for (auto& n : names) sidebar->options.push_back(TuiOptions::norm_option(n)); + sidebar->set_selected(std::find(names.begin(), names.end(), agent->bot()) - names.begin()); + sidebar->row_meta.clear(); + for (auto& name : names) { + TuiValue meta = TuiValue::dict(); + meta.set("snippet", TuiStr::s(bot_snippet(name))); + std::string draft = drafts.count(name) ? drafts[name] : ""; + meta.set("draft", TuiStr::s(TuiStr::head(TuiStr::split(TuiStr::u(draft), U'\n')[0], 40))); + TuiValue bm = bot_meta(name); + for (auto& p : bm.pairs()) meta.set(p.first, p.second); + sidebar->row_meta[name] = meta; + } + sidebar->touch(); +} + +void TuiChatScene::activate(const TuiChatSessionP& s) { + session = s; + agent = s->agent; + if (box->history && box->history->backend) { + if (auto* vh = dynamic_cast(box->history->backend.get())) vh->set_bot([this] { return agent->bot(); }); + } +} + +TuiChatSessionP TuiChatScene::session_for(const std::string& name) { + for (auto& kv : sessions) if (kv.first == name) return kv.second; + auto s = std::make_shared(name, host.spawn_agent(*agent, name)); + sessions.emplace_back(name, s); + return s; +} + +void TuiChatScene::select_bot(const std::string& name) { + if (name == cur()->name) { + refresh_bots(); + return; + } + drafts[cur()->name] = TuiStr::s(box->value()); + auto target = session_for(name); + activate(target); + target->unread = 0; + std::string draft = drafts.count(name) ? drafts[name] : ""; + auto ls = TuiStr::split(TuiStr::u(draft), U'\n'); + if (ls.empty()) ls = {UStr()}; + box->set_lines(ls); + box->set_cx(static_cast(box->lines.back().size())); + box->set_cy(static_cast(box->lines.size()) - 1); + box->set_top(0); + box->history->reset(); + refresh_bots(); + render_history(false); + paint_status(); + if (target->queued) { + auto opener = target->queued; + target->queued = nullptr; + opener(); + } +} + +void TuiChatScene::run_command(const UStr& text) { + std::string before_profile = agent->profile(), before_bot = agent->bot(); + std::string out_raw; + bool stay; + try { + stay = agent->handle_command(TuiStr::s(text), out_raw); + } catch (const std::exception& exc) { + note(U"error: " + TuiStr::u(host.short_error(exc.what())), "error"); + return; + } + UStr out = TuiStr::strip(TuiMarkdown::strip_ansi(TuiStr::u(out_raw))); + if (!out.empty()) { + auto parts = TuiStr::split(out, U'\n'); + if (parts.size() > 100) parts.resize(100); + note(TuiStr::join(parts, U"\n")); + } + if (!stay) { + app.quit(); + return; + } + if (agent->profile() != before_profile) { + for (auto it = sessions.begin(); it != sessions.end();) { + if (it->second != cur() && !it->second->busy) it = sessions.erase(it); + else ++it; + } + cur()->name = agent->bot(); + } else if (agent->bot() != before_bot) { + std::string moved = agent->bot(); + agent->switch_bot(before_bot); + select_bot(moved); + } + refresh_bots(); + paint_status(); +} + +void TuiChatScene::settle_pending_tick(TuiChatSessionP s) { + if (!s) s = cur(); + TuiChatBlockP block = s->pending_tick; + s->pending_tick.reset(); + if (!block || std::find(s->blocks.begin(), s->blocks.end(), block) == s->blocks.end()) return; + block->ticks = TuiChatRows::tick_read(); + block->ticks_read = true; + if (s == cur()) replay(); +} + +void TuiChatScene::do_send(const UStr& value) { + UStr text = TuiStr::strip(value); + auto s = cur(); + if (text.empty() || s->pending || s->busy) return; + box->history->add(TuiStr::s(text)); + clear_box(); + drafts[agent->bot()] = ""; + if (text[0] == U'/') { + run_command(text); + return; + } + s->pending_tick = bubble("out", text, strftime_hm(), TuiChatRows::tick_send()); + s->pending = TuiStr::s(text); + s->note = U"thinking..."; + s->phase = "thinking"; + s->detail.clear(); + refresh_bots(); + paint_status(); + app.after(0.05, [this, s] { run_pending(s); }); +} + +void TuiChatScene::set_phase(const TuiChatSessionP& s, const std::string& name, const std::string& detail) { + s->phase = name; + s->detail = detail; +} + +void TuiChatScene::run_pending(TuiChatSessionP s) { + if (!s) s = cur(); + std::optional text = s->pending; + s->pending.reset(); + if (!text || text->empty()) return; + s->busy = true; + s->since = TuiApp::now(); + arm_spin(); + auto worker = s->agent; + worker->set_on_phase([this, s](const std::string& name, const std::string& detail) { set_phase(s, name, detail); }); + worker->set_prompter([this, s](const std::string& kind, const std::string& ask, const TuiValue& options) { return prompter(kind, ask, options, s); }); + worker->set_stream_factory([this, s](const std::string& command) { return live_stream(command, s); }); + std::string prompt_text = *text; + app.offload( + [this, worker, prompt_text]() -> TuiValue { + TuiStdioGuard::Scope scope(TuiStdioGuard::instance()); + try { + return TuiValue(worker->run_turn(prompt_text)); + } catch (const TuiChatInterrupted&) { + worker->checkpoint(); + return TuiValue("[interrupted]"); + } catch (const TuiChatBackendError& exc) { + return TuiValue("error: " + host.short_error(exc.what())); + } + }, + [this, s](const TuiValue& result, const std::string& error) { + std::string answer = result.is_str() ? result.as_str() : std::string(); + if (!error.empty()) answer = "error: " + host.short_error(error); + settle_pending_tick(s); + UStr full = TuiMarkdown::strip_ansi(TuiStr::u(answer.empty() ? "[no reply]" : answer)); + UStr stamp = strftime_hm(); + bool foreground = s == cur(); + bool animate = app.loop_active() && full.size() > 12 && foreground; + TuiChatBlockP block = bubble("in", animate ? UStr() : full, stamp, UStr(), false, TuiStr::u(s->name), s); + if (!foreground) s->unread += 1; + if (!animate) { + finish_turn(s); + return; + } + s->phase = "typing"; + TuiChatReveal reveal; + reveal.block = block; + reveal.full = full; + reveal.shown = 0; + reveal.step = std::max(6L, -(-static_cast(full.size()) / TUI_CHAT_REVEAL_STEPS)); + s->reveal = reveal; + app.after(TUI_CHAT_REVEAL_TICK, [this, s] { reveal_step(s); }); + }); +} + +std::shared_ptr TuiChatScene::live_stream(const std::string& command, const TuiChatSessionP& s) { + if (!app.loop_active()) return nullptr; + auto block = std::make_shared(); + block->kind = "live"; + block->command = TuiStr::head(TuiStr::u(command), 300); + auto queued = std::make_shared>(false); + auto flush = [this, block, s, queued] { + queued->store(false); + if (s == cur() && std::find(s->blocks.begin(), s->blocks.end(), block) != s->blocks.end()) replay(); + }; + auto push = [this, queued, flush] { + bool expected = false; + if (queued->compare_exchange_strong(expected, true)) { + app.run_on_loop([this, flush] { app.after(TUI_CHAT_LIVE_TICK, flush); }); + } + }; + app.run_on_loop([this, block, s] { journal(block, s); }); + return std::make_shared(block, push); +} + +void TuiChatScene::finish_turn(TuiChatSessionP s) { + if (!s) s = cur(); + s->busy = false; + s->phase = "idle"; + s->detail.clear(); + s->reveal.reset(); + s->note.clear(); + refresh_bots(); + paint_status(); +} + +void TuiChatScene::reveal_step(TuiChatSessionP s) { + if (!s) s = cur(); + if (!s->reveal) return; + TuiChatReveal& state = *s->reveal; + const UStr& full = state.full; + long n = static_cast(full.size()); + long end = std::min(n, state.shown + state.step); + while (end < n && !TuiStr::isspace(full[static_cast(end - 1)])) end++; + if (s != cur()) end = n; + state.shown = end; + TuiChatBlockP block = state.block; + if (std::find(s->blocks.begin(), s->blocks.end(), block) != s->blocks.end()) { + block->text = TuiStr::head(full, end); + if (s == cur()) replay(); + } + if (end >= n) finish_turn(s); + else app.after(TUI_CHAT_REVEAL_TICK, [this, s] { reveal_step(s); }); +} + +std::shared_ptr TuiChatScene::style_dialog(const std::shared_ptr& dialog) { + dialog->set_style(mk_style("role:fg", "role:surface")); + dialog->set_border_style(mk_style("role:accent", "role:bg")); + dialog->set_title_style(mk_style("role:accent2", "role:bg", true)); + dialog->set_rounded(true); + dialog->set_padding(TuiPanel::norm_padding(0, 1)); + for (TuiPanel* node : dialog->walk()) { + if (node == dialog.get()) continue; + if (auto* b = dynamic_cast(node)) { + b->set_style(mk_style("role:on_accent", "role:accent", true)); + b->set_rounded(true); + b->set_border_style(mk_style("role:accent", "role:surface")); + b->set_focus_border_style(mk_style("role:accent2", "role:surface")); + } else if (dynamic_cast(node)) { + node->set_style(mk_style("role:fg", "role:bg")); + } else if (!dynamic_cast(node) && !dynamic_cast(node)) { + node->set_style(mk_style("role:fg", "role:surface")); + } + } + return dialog; +} + +TuiValue TuiChatScene::prompter(const std::string& kind, const std::string& text_in, const TuiValue& options, TuiChatSessionP s) { + if (!s) s = cur(); + if (!app.loop_active()) return TuiValue(); + struct Answer { + std::mutex lock; + std::condition_variable cv; + bool done{false}; + TuiValue value; + bool typing{false}; + std::vector> shown; + }; + auto answer = std::make_shared(); + UStr text = TuiStr::head(TuiStr::u(text_in), TUI_CHAT_PROMPT_CHARS); + int width = std::min(72, std::max(30, app.cols - 6)); + std::string sname = s->name; + auto finish = [answer] { + std::lock_guard g(answer->lock); + answer->done = true; + answer->cv.notify_all(); + }; + auto pick = [answer](const TuiValue& value) { + std::lock_guard g(answer->lock); + answer->value = value; + }; + auto ask_text = [this, answer, kind, text, width, sname, pick, finish]() { + TuiPanelOpts po; + po.title = TuiStr::u("Question - " + sname); + po.w = TuiSpec(width); + auto dialog = std::make_shared(kind == "ask" ? text : UStr(U"Reply:"), 3, [pick](const UStr& v) { pick(TuiValue(TuiStr::s(v))); }, nullptr, nullptr, po); + style_dialog(dialog); + dialog->set_handler("close", [finish](TuiEvent&) { finish(); }); + { + std::lock_guard g(answer->lock); + answer->shown.push_back(dialog); + } + app.root->add(dialog); + }; + auto open_dialog = [this, answer, kind, text, width, sname, options, pick, finish, ask_text]() { + std::shared_ptr dialog; + TuiPanelOpts qo; + qo.w = TuiSpec(width); + if (kind == "approve") { + qo.title = TuiStr::u("Approval needed - " + sname); + std::vector opts = options.items(); + dialog = std::make_shared(U"Run this?\n\n" + text, opts, [pick](const TuiValue& v) { pick(v); }, false, qo); + } else if (options.truthy()) { + qo.title = TuiStr::u("Question - " + sname); + std::vector opts = options.items(); + TuiValue extra = TuiValue::list(); + extra.push(TuiValue("Type an answer...")); + extra.push(TuiChatRows::type_answer()); + opts.push_back(extra); + dialog = std::make_shared(text, opts, [answer, pick](const TuiValue& v) { + if (v == TuiChatRows::type_answer()) { + std::lock_guard g(answer->lock); + answer->typing = true; + return; + } + pick(v); + }, false, qo); + } else { + ask_text(); + return; + } + dialog->set_handler("close", [answer, finish, ask_text](TuiEvent&) { + bool typing; + { + std::lock_guard g(answer->lock); + typing = answer->typing; + if (typing) answer->typing = false; + } + if (typing) { + ask_text(); + return; + } + finish(); + }); + style_dialog(dialog); + { + std::lock_guard g(answer->lock); + answer->shown.push_back(dialog); + } + app.root->add(dialog); + }; + std::string before_phase = s->phase, before_detail = s->detail; + s->phase = "ask"; + s->detail.clear(); + if (s == cur()) { + app.run_on_loop(open_dialog); + } else { + s->queued = [this, open_dialog] { app.run_on_loop(open_dialog); }; + app.run_on_loop([this] { refresh_meta(); }); + } + { + std::unique_lock g(answer->lock); + bool ok = answer->cv.wait_for(g, std::chrono::seconds(TUI_CHAT_PROMPT_TIMEOUT), [&] { return answer->done; }); + if (!ok) { + s->queued = nullptr; + std::vector> shown = answer->shown; + g.unlock(); + for (auto& d : shown) app.run_on_loop([d] { d->close(); }); + g.lock(); + } + } + s->queued = nullptr; + s->phase = before_phase; + s->detail = before_detail; + std::lock_guard g(answer->lock); + return answer->value; +} + +void TuiChatScene::arm_spin() { + if (!spin_timer) spin_timer = app.every(0.08, [this] { spin(); }); +} + +void TuiChatScene::spin() { + std::vector live; + for (auto& kv : sessions) if (kv.second->busy) live.push_back(kv.second); + if (live.empty()) { + app.cancel_timer(spin_timer); + spin_timer.reset(); + return; + } + spin_frame++; + for (auto& item : live) { + std::string word = item->phase == "typing" ? "typing" + : item->phase == "tool" ? "running " + (item->detail.empty() ? std::string("tool") : item->detail) + : item->phase == "ask" ? "waiting for you" : "thinking"; + item->note = TuiStr::u(word + "... " + std::to_string(static_cast(TuiApp::now() - item->since)) + "s"); + } + refresh_meta(); + paint_status(); +} + +void TuiChatScene::toggle_sidebar() { + sidebar_wrap->set_visible(!sidebar_wrap->visible()); + sidebar_hide = !sidebar_wrap->visible(); + sidebar_auto = false; + if (sidebar_wrap->visible()) sidebar_wrap->set_w(TuiSpec(TuiChatRows::sidebar_w(app.cols))); + app.ensure_focus(); +} + +void TuiChatScene::on_resize() { + sidebar_wrap->set_w(TuiSpec(TuiChatRows::sidebar_w(app.cols))); + bool compact = app.cols < 50; + send_btn->set_label(compact ? UStr(U"➤") : UStr(U"Send ➤")); + send_btn->set_w(TuiSpec(compact ? 5 : 10)); + if (app.cols < TUI_CHAT_NARROW_COLS && sidebar_wrap->visible() && !sidebar_hide) { + sidebar_wrap->set_visible(false); + sidebar_auto = true; + } else if (app.cols >= TUI_CHAT_NARROW_COLS && sidebar_auto && !sidebar_hide) { + sidebar_wrap->set_visible(true); + sidebar_auto = false; + } + app.ensure_focus(); + app.after(0.05, [this] { maybe_replay(); }); +} + +void TuiChatScene::on_root_key(TuiKey& event) { + const std::string& key = event.key; + if (key == "f2") { + event.stop(); + toggle_sidebar(); + } else if (key == "f4") { + event.stop(); + std::string nxt = TuiThemes::cycle_name(TuiThemes::current()->name); + app.set_theme(nxt); + cur()->note = TuiStr::u("theme: " + nxt); + paint_status(); + } else if (key == "q") { + event.stop(); + app.quit(); + } else if (key == "i" || key == "escape") { + event.stop(); + box->focus(); + } +} + +void TuiChatScene::tick_input() { + int rows = std::max(1, std::min(5, static_cast(box->lines.size()))); + if (!box->h().is_int() || box->h().n != rows) { + box->set_h(TuiSpec(rows)); + send_btn->set_h(TuiSpec(rows)); + composer->set_h(TuiSpec(rows + 2)); + } +} + +int TuiChatScene::run(TuiChatHost& host, std::shared_ptr agent, const std::string& theme_name) { + TuiThemes::set(theme_name); + agent->set_quiet(true); + TuiApp app; + auto wh = app.winsize(); + app.rows = wh.first; + app.cols = wh.second; + std::function scheduler_stop = host.start_scheduler(); + std::shared_ptr scene; + int rc = 0; + try { + scene = std::make_shared(app, agent, host); + PanelP root = scene->build(); + app.mount(root); + scene->replay(); + scene->box->focus(); + rc = app.run(); + } catch (const TuiError& exc) { + std::cerr << "tui unavailable: " << exc.what() << "\n"; + rc = 2; + } + if (scheduler_stop) scheduler_stop(); + if (scene) { + for (auto& kv : scene->sessions) kv.second->agent->save_session(); + } else { + agent->save_session(); + } + return rc; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatScene.h b/taicpp/src/tui/TuiChatScene.h new file mode 100644 index 0000000..b6e869f --- /dev/null +++ b/taicpp/src/tui/TuiChatScene.h @@ -0,0 +1,110 @@ +#pragma once +// TuiChatScene: the chat scene on the local vault (tai.py tui_build_chat, +// tui_chat, TuiChatState, tui_cycle via F4): bots sidebar, header, markdown +// transcript, rounded composer, status bar. Turns run on an offload thread +// (TuiApp::offload) and land back on the loop. +// +// python builds this as one big closure over `refs`; here the closure variables +// are members and each inner function is a method with the same name. Turn and +// journal keys (blocks, pending, busy, phase, detail, since, reveal, +// pending_tick, note) read and write the active session, exactly like +// TuiChatState; everything else is a plain member. +#include "TuiApp.h" +#include "TuiButton.h" +#include "TuiChatAgent.h" +#include "TuiChatList.h" +#include "TuiChatLog.h" +#include "TuiChatRows.h" +#include "TuiChatSession.h" +#include "TuiDialog.h" +#include "TuiRich.h" +#include "TuiText.h" +#include "TuiTextBox.h" +#include +#include +namespace detz::tui { + +class TuiChatScene : public std::enable_shared_from_this { +public: + // sessions keep python dict insertion order + using SessionList = std::vector>; + + TuiChatScene(TuiApp& app, std::shared_ptr agent, TuiChatHost& host); + // builds the scene: returns the root panel + PanelP build(); + + // ---- refs ---- + TuiApp& app; + TuiChatHost& host; + std::shared_ptr agent; + SessionList sessions; + TuiChatSessionP session; // refs.session + std::map drafts; + bool sidebar_auto{false}, sidebar_hide{false}; + std::optional last_key; + std::vector bots; + long render_w{0}; + long spin_frame{0}; + TuiApp::TimerP spin_timer; + PanelP root_p, sidebar_wrap, body_p; + std::shared_ptr sidebar; + std::shared_ptr transcript; + std::shared_ptr box; + std::shared_ptr status, avatar, head_meta, brand, head_status; + std::shared_ptr composer; + std::shared_ptr head_name, head_dot; + std::shared_ptr send_btn; + + // ---- closure functions ---- + void render_block(const TuiChatBlockP& block); + void replay(); + void maybe_replay(); + TuiChatSessionP cur() { return session; } + TuiChatBlockP journal(const TuiChatBlockP& block, TuiChatSessionP s = nullptr); + TuiChatBlockP bubble(const std::string& side, const UStr& text, const UStr& stamp = UStr(), const UStr& ticks = UStr(), bool ticks_read = false, + const UStr& sender = UStr(), TuiChatSessionP s = nullptr); + TuiChatBlockP note(const UStr& text, const std::string& side = "note", TuiChatSessionP s = nullptr); + TuiChatBlockP pill(const UStr& text); + TuiChatBlockP show(const std::string& sender, const UStr& text, const std::string& style); // style: "err", "dim", "default" + void render_history(bool force = true); + UStr phase_word(); + struct StatusSpans { UStr text; UStr glyph; StyleP dot_style; SpanRow spans; }; + StatusSpans status_spans(); + int others_busy(); + void paint_status(); + void clear_box(); + UStr bot_snippet(const std::string& name); + TuiValue bot_meta(const std::string& name); + void refresh_meta(); + void refresh_bots(); + void activate(const TuiChatSessionP& s); + TuiChatSessionP session_for(const std::string& name); + void select_bot(const std::string& name); + void run_command(const UStr& text); + void settle_pending_tick(TuiChatSessionP s = nullptr); + void do_send(const UStr& value); + void run_pending(TuiChatSessionP s = nullptr); + std::shared_ptr live_stream(const std::string& command, const TuiChatSessionP& s); + void finish_turn(TuiChatSessionP s = nullptr); + void reveal_step(TuiChatSessionP s = nullptr); + void set_phase(const TuiChatSessionP& s, const std::string& name, const std::string& detail); + std::shared_ptr style_dialog(const std::shared_ptr& dialog); + TuiValue prompter(const std::string& kind, const std::string& text, const TuiValue& options, TuiChatSessionP s = nullptr); + void arm_spin(); + void spin(); + void toggle_sidebar(); + void on_resize(); + void on_root_key(TuiKey& event); + void tick_input(); + + StyleP panel_style, dim_style, err_style, bar_style, name_style, side_style, pill_style; + std::map bubble_styles; + + // wall clock for the message stamps and the date pill (python datetime.now()); null means time(). Tests inject a fixed one. + static std::function clock; + + // tui_chat(agent, theme_name) + static int run(TuiChatHost& host, std::shared_ptr agent, const std::string& theme_name); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiChatSession.h b/taicpp/src/tui/TuiChatSession.h new file mode 100644 index 0000000..6ac785c --- /dev/null +++ b/taicpp/src/tui/TuiChatSession.h @@ -0,0 +1,60 @@ +#pragma once +// TuiChatBlock and TuiChatSession (tai.py TuiChatSession): one bot's +// conversation inside the chat scene: its own agent, its own journal of +// rendered blocks, its own turn state. Bots run independently, so a long task in +// one never blocks talking to another. +// +// A journal block is a python list ["msg", side, text, stamp, ticks, read, sender], +// ["note", side, text], ["pill", text], ["welcome", name], ["tool", name, args] +// or ["live", command, lines, status]. Blocks are mutated after they are +// journaled (ticks flip to read, a reply types out, a live window fills) and +// found again by identity, so they are shared objects with named fields. +#include "TuiChatAgent.h" +#include "TuiStr.h" +#include +#include +#include +#include +#include +#include +namespace detz::tui { + +struct TuiChatBlock { + std::string kind; + std::string side; + UStr text, stamp, ticks, sender, name, command, status; + bool ticks_read{false}; + std::string args; + std::vector lines; + std::mutex lines_lock; // live blocks are fed from a turn thread + UStr arg_text; // pill text / welcome name +}; +using TuiChatBlockP = std::shared_ptr; + +struct TuiChatReveal { + TuiChatBlockP block; + UStr full; + long shown{0}; + long step{6}; +}; + +class TuiChatSession { +public: + TuiChatSession(std::string name, std::shared_ptr agent) : name(std::move(name)), agent(std::move(agent)) {} + std::string name; + std::shared_ptr agent; + std::vector blocks; + std::optional pending; + bool busy{false}; + std::string phase{"idle"}; + std::string detail; + double since{0.0}; + std::optional reveal; + TuiChatBlockP pending_tick; + UStr note; + int unread{0}; + std::function queued; +}; +using TuiChatSessionP = std::shared_ptr; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiLiveStream.cpp b/taicpp/src/tui/TuiLiveStream.cpp new file mode 100644 index 0000000..4a49a24 --- /dev/null +++ b/taicpp/src/tui/TuiLiveStream.cpp @@ -0,0 +1,30 @@ +#include "TuiLiveStream.h" +#include "TuiChatRows.h" +#include "TuiMarkdown.h" +namespace detz::tui { + +void TuiLiveStream::feed(const std::string& line) { + { + std::lock_guard g(block->lines_lock); + // line.rstrip("\n").split("\r")[-1], then ANSI stripped + std::string s = line; + while (!s.empty() && s.back() == '\n') s.pop_back(); + size_t cr = s.rfind('\r'); + if (cr != std::string::npos) s = s.substr(cr + 1); + block->lines.push_back(TuiMarkdown::strip_ansi(TuiStr::u(s))); + if (block->lines.size() > static_cast(TUI_CHAT_LIVE_KEEP)) { + block->lines.erase(block->lines.begin(), block->lines.end() - TUI_CHAT_LIVE_KEEP); + } + } + push(); +} + +void TuiLiveStream::close(const std::string& status) { + { + std::lock_guard g(block->lines_lock); + block->status = status.empty() ? UStr(U"done") : TuiStr::u(status); + } + push(); +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiLiveStream.h b/taicpp/src/tui/TuiLiveStream.h new file mode 100644 index 0000000..97819d9 --- /dev/null +++ b/taicpp/src/tui/TuiLiveStream.h @@ -0,0 +1,19 @@ +#pragma once +// TuiLiveStream: stream sink (feed/pause/close, like LiveStream) for a command's +// output in the chat transcript (tai.py TuiLiveStream). The journal block +// ["live", command, lines, status] is the message; feeding mutates it from the +// turn thread and asks the scene to repaint through `push`, which coalesces. +#include "TuiChatSession.h" +namespace detz::tui { + +class TuiLiveStream : public TuiStreamSink { +public: + TuiLiveStream(TuiChatBlockP block, std::function push) : block(std::move(block)), push(std::move(push)) {} + void feed(const std::string& line) override; + void pause() override {} + void close(const std::string& status) override; + TuiChatBlockP block; + std::function push; +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiMarkdown.cpp b/taicpp/src/tui/TuiMarkdown.cpp new file mode 100644 index 0000000..0b61361 --- /dev/null +++ b/taicpp/src/tui/TuiMarkdown.cpp @@ -0,0 +1,237 @@ +#include "TuiMarkdown.h" +#include +namespace detz::tui { + +bool TuiMarkdown::is_word(char32_t c) { return c == U'_' || TuiStr::isalnum(c); } + +UStr TuiMarkdown::strip_ansi(const UStr& text) { + UStr out; + size_t i = 0, n = text.size(); + while (i < n) { + if (text[i] == 0x1B && i + 1 < n && text[i + 1] == U'[') { + size_t j = i + 2; + while (j < n && ((text[j] >= U'0' && text[j] <= U'9') || text[j] == U';')) j++; + if (j < n && text[j] == U'm') { i = j + 1; continue; } + } + out.push_back(text[i]); + i++; + } + return out; +} +std::string TuiMarkdown::strip_ansi(const std::string& text) { return TuiStr::s(strip_ansi(TuiStr::u(text))); } + +TuiMarkdown::Segs TuiMarkdown::inline_segs(const UStr& text) { + Segs segs; + size_t n = text.size(); + size_t pos = 0; // end of the last match + size_t p = 0; // scan position + auto try_pair = [&](size_t at, char32_t d, size_t& end) -> bool { + // d d [^d\n]+ d d (two-char delimiters ** and __) + if (at + 1 >= n || text[at] != d || text[at + 1] != d) return false; + size_t j = at + 2; + size_t run_start = j; + while (j < n && text[j] != d && text[j] != U'\n') j++; + if (j == run_start) return false; + if (j + 1 < n && text[j] == d && text[j + 1] == d) { end = j + 2; return true; } + return false; + }; + while (p < n) { + size_t end = 0; + bool matched = false; + if (try_pair(p, U'*', end) || try_pair(p, U'_', end)) { + matched = true; + } else if (text[p] == U'`') { + size_t j = p + 1; + while (j < n && text[j] != U'`' && text[j] != U'\n') j++; + if (j > p + 1 && j < n && text[j] == U'`') { end = j + 1; matched = true; } + } + if (!matched && text[p] == U'*' && (p == 0 || !(is_word(text[p - 1]) || text[p - 1] == U'*'))) { + if (p + 1 < n && text[p + 1] != U'*' && !TuiStr::isspace(text[p + 1])) { + size_t j = p + 2; + while (j < n && text[j] != U'*' && text[j] != U'\n') j++; + if (j < n && text[j] == U'*') { + bool ahead_ok = j + 1 >= n || !(is_word(text[j + 1]) || text[j + 1] == U'*'); + if (ahead_ok) { end = j + 1; matched = true; } + } + } + } + if (!matched) { p++; continue; } + if (p > pos) segs.emplace_back(text.substr(pos, p - pos), ""); + UStr token = text.substr(p, end - p); + if (TuiStr::startswith(token, U"**") || TuiStr::startswith(token, U"__")) segs.emplace_back(token.substr(2, token.size() - 4), "b"); + else if (token[0] == U'`') segs.emplace_back(token.substr(1, token.size() - 2), "c"); + else segs.emplace_back(token.substr(1, token.size() - 2), "i"); + pos = end; + p = end; + } + if (pos < n) segs.emplace_back(text.substr(pos), ""); + return segs; +} + +std::vector TuiMarkdown::blocks(const UStr& text) { + std::vector out; + bool fence = false; + UStr src = TuiStr::replace(text, U"\t", U" "); + for (auto& raw : TuiStr::split(src, U'\n')) { + UStr line = TuiStr::rstrip(raw); + if (TuiStr::startswith(TuiStr::strip(line), U"```")) { + fence = !fence; + continue; + } + if (fence) { + out.push_back(Block{"code", Segs{{line, "code"}}, UStr()}); + continue; + } + if (TuiStr::strip(line).empty()) { + if (!out.empty() && out.back().kind != "blank") out.push_back(Block{"blank", {}, UStr()}); + continue; + } + // head: ^#{1,6}\s+(.*)$ + size_t hashes = 0; + while (hashes < line.size() && line[hashes] == U'#') hashes++; + bool head = hashes >= 1 && hashes <= 6 && hashes < line.size() && TuiStr::isspace(line[hashes]); + // list item: ^(\s*)([-*+]|\d+[.)])\s+(.*)$ + bool item = false; + UStr item_indent, item_marker, item_rest; + { + size_t i = 0; + while (i < line.size() && TuiStr::isspace(line[i])) i++; + size_t marker_start = i; + size_t marker_end = i; + if (i < line.size() && (line[i] == U'-' || line[i] == U'*' || line[i] == U'+')) { + marker_end = i + 1; + } else { + size_t d = i; + while (d < line.size() && line[d] >= U'0' && line[d] <= U'9') d++; + if (d > i && d < line.size() && (line[d] == U'.' || line[d] == U')')) marker_end = d + 1; + } + if (marker_end > marker_start && marker_end < line.size() && TuiStr::isspace(line[marker_end])) { + size_t r = marker_end; + while (r < line.size() && TuiStr::isspace(line[r])) r++; + item = true; + item_indent = line.substr(0, marker_start); + item_marker = line.substr(marker_start, marker_end - marker_start); + item_rest = line.substr(r); + } + } + bool quote = !line.empty() && line[0] == U'>'; + if (head) { + size_t r = hashes; + while (r < line.size() && TuiStr::isspace(line[r])) r++; + Segs segs; + for (auto& s : inline_segs(line.substr(r))) segs.emplace_back(s.first, "h"); + out.push_back(Block{"h", segs, UStr()}); + } else if ([&] { + // ^\s*(-{3,}|\*{3,}|_{3,})\s*$ + UStr t = TuiStr::lstrip(line); + t = TuiStr::rstrip(t); + if (t.size() < 3) return false; + char32_t c = t[0]; + if (c != U'-' && c != U'*' && c != U'_') return false; + return std::all_of(t.begin(), t.end(), [&](char32_t x) { return x == c; }); + }()) { + out.push_back(Block{"rule", {}, UStr()}); + } else if (item) { + UStr marker = (item_marker.size() == 1 && (item_marker[0] == U'-' || item_marker[0] == U'*' || item_marker[0] == U'+')) ? UStr(U"•") : item_marker; + UStr lead = TuiStr::repeat(U' ', 2 * static_cast(item_indent.size() / 2)) + marker; + out.push_back(Block{"li", inline_segs(item_rest), lead}); + } else if (quote) { + size_t r = 1; + if (r < line.size() && TuiStr::isspace(line[r])) r++; + out.push_back(Block{"quote", inline_segs(line.substr(r)), UStr()}); + } else { + out.push_back(Block{"p", inline_segs(line), UStr()}); + } + } + while (!out.empty() && out.back().kind == "blank") out.pop_back(); + return out; +} + +std::vector TuiMarkdown::wrap_segs(const Segs& segs, long width) { + width = std::max(1L, width); + std::vector lines; + Segs cur; + long used = 0; + auto flush = [&] { + while (!cur.empty() && TuiStr::all_space(cur.back().first)) cur.pop_back(); + lines.push_back(cur); + cur.clear(); + used = 0; + }; + for (auto& seg : segs) { + const UStr& text = seg.first; + size_t i = 0; + while (i < text.size()) { + bool space = TuiStr::isspace(text[i]); + size_t j = i; + while (j < text.size() && TuiStr::isspace(text[j]) == space) j++; + UStr token = text.substr(i, j - i); + i = j; + if (space) { + if (used && used + static_cast(token.size()) <= width) { + cur.emplace_back(token, seg.second); + used += static_cast(token.size()); + } + continue; + } + while (static_cast(token.size()) > width) { + if (used) flush(); + cur.emplace_back(token.substr(0, static_cast(width)), seg.second); + used = width; + flush(); + token = token.substr(static_cast(width)); + } + if (used + static_cast(token.size()) > width) flush(); + cur.emplace_back(token, seg.second); + used += static_cast(token.size()); + } + } + flush(); + return lines; +} + +std::vector TuiMarkdown::textwrap(const UStr& text_in, long width) { + // textwrap.wrap(text, width, expand_tabs=True, replace_whitespace=False, drop_whitespace=False, + // break_long_words=True, break_on_hyphens=False) + UStr text = TuiStr::expandtabs(text_in, 8); + std::vector chunks; + { + auto ws = [](char32_t c) { return c == U'\t' || c == U'\n' || c == 0x0B || c == 0x0C || c == U'\r' || c == U' '; }; + size_t i = 0; + while (i < text.size()) { + bool space = ws(text[i]); + size_t j = i; + while (j < text.size() && ws(text[j]) == space) j++; + chunks.push_back(text.substr(i, j - i)); + i = j; + } + } + std::reverse(chunks.begin(), chunks.end()); + std::vector lines; + while (!chunks.empty()) { + std::vector cur_line; + long cur_len = 0; + while (!chunks.empty()) { + long l = static_cast(chunks.back().size()); + if (cur_len + l <= width) { + cur_line.push_back(chunks.back()); + chunks.pop_back(); + cur_len += l; + } else { + break; + } + } + if (!chunks.empty() && static_cast(chunks.back().size()) > width) { + long space_left = width < 1 ? 1 : width - cur_len; + UStr chunk = chunks.back(); + cur_line.push_back(TuiStr::head(chunk, space_left)); + chunks.back() = TuiStr::from(chunk, space_left); + cur_len = 0; + for (auto& c : cur_line) cur_len += static_cast(c.size()); + } + if (!cur_line.empty()) lines.push_back(TuiStr::join(cur_line, UStr())); + } + return lines; +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiMarkdown.h b/taicpp/src/tui/TuiMarkdown.h new file mode 100644 index 0000000..553539e --- /dev/null +++ b/taicpp/src/tui/TuiMarkdown.h @@ -0,0 +1,33 @@ +#pragma once +// TuiMarkdown: the chat transcript's markdown subset and text wrapping (tai.py +// TUI_MD_INLINE_RE, TUI_MD_LIST_RE, tui_md_inline, tui_md_blocks, tui_wrap_segs, +// tui_strip_ansi) plus a port of python's textwrap.wrap for the plain-text +// bodies. Static utility class; the regexes are hand-written matchers with the +// same leftmost/greedy/lazy semantics as the python patterns. +#include "TuiStr.h" +#include +#include +#include +namespace detz::tui { + +class TuiMarkdown { +public: + using Seg = std::pair; // (text, tag): b bold, c code, i italic, ... + using Segs = std::vector; + struct Block { + std::string kind; // p, h, li, quote, code, rule, blank + Segs segs; + UStr lead; // list marker (indent + bullet) + }; + + static UStr strip_ansi(const UStr& text); // TUI_ANSI_RE = \x1b\[[0-9;]*m + static std::string strip_ansi(const std::string& text); + static Segs inline_segs(const UStr& text); // tui_md_inline + static std::vector blocks(const UStr& text); // tui_md_blocks + static std::vector wrap_segs(const Segs& segs, long width); // tui_wrap_segs + static std::vector textwrap(const UStr& text, long width); // textwrap.wrap(expand_tabs, keep whitespace, break long words) +private: + static bool is_word(char32_t c); +}; + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStdioGuard.cpp b/taicpp/src/tui/TuiStdioGuard.cpp new file mode 100644 index 0000000..606004d --- /dev/null +++ b/taicpp/src/tui/TuiStdioGuard.cpp @@ -0,0 +1,28 @@ +#include "TuiStdioGuard.h" +#include +namespace detz::tui { + +TuiStdioGuard& TuiStdioGuard::instance() { + static TuiStdioGuard guard; + return guard; +} + +void TuiStdioGuard::enter() { + std::lock_guard g(lock_); + if (depth_ == 0) { + saved_ = std::cout.rdbuf(sink_.rdbuf()); + sink_.str(""); + } + depth_++; +} + +void TuiStdioGuard::leave() { + std::lock_guard g(lock_); + depth_--; + if (depth_ == 0 && saved_) { + std::cout.rdbuf(saved_); + saved_ = nullptr; + } +} + +} // namespace detz::tui diff --git a/taicpp/src/tui/TuiStdioGuard.h b/taicpp/src/tui/TuiStdioGuard.h new file mode 100644 index 0000000..b0825f2 --- /dev/null +++ b/taicpp/src/tui/TuiStdioGuard.h @@ -0,0 +1,33 @@ +#pragma once +// TuiStdioGuard: counted stdout swap for turn threads (tai.py TuiStdioGuard). +// Several bots can run at once; a plain redirect per thread would restore in the +// wrong order and leave stdout pointing at a dead buffer. The first thread in +// swaps, the last one out restores. python swaps sys.stdout/sys.stdin; the C++ +// analog swaps std::cout's buffer so a turn's prints never reach the screen. +#include +#include +namespace detz::tui { + +class TuiStdioGuard { +public: + class Scope { + public: + explicit Scope(TuiStdioGuard& guard) : guard_(guard) { guard_.enter(); } + ~Scope() { guard_.leave(); } + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + private: + TuiStdioGuard& guard_; + }; + void enter(); + void leave(); + int depth() const { return depth_; } + static TuiStdioGuard& instance(); // TUI_STDIO_GUARD +private: + std::mutex lock_; + int depth_{0}; + std::streambuf* saved_{nullptr}; + std::ostringstream sink_; +}; + +} // namespace detz::tui diff --git a/taicpp/tests/tui_chat.cpp b/taicpp/tests/tui_chat.cpp new file mode 100644 index 0000000..45a633b --- /dev/null +++ b/taicpp/tests/tui_chat.cpp @@ -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 | frame | text | resize R C | timers | flush | sources +#include "TuiChatScene.h" +#include "TuiComplete.h" +#include +#include +#include +#include +using namespace detz::tui; + +struct FakeStore { + std::map> sessions; + std::vector bots{"main", "alpha", "beta"}; +}; + +class FakeAgent : public TuiChatAgent { +public: + FakeAgent(std::shared_ptr 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 messages() const override { return msgs_; } + std::string provider_pin() const override { return ""; } + std::string model_pin() const override { return ""; } + std::vector list_bots() const override { return store_->bots; } + std::vector 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) override {} + void set_prompter(std::function) override {} + void set_stream_factory(std::function(const std::string&)>) override {} + std::shared_ptr history_backend() override { return nullptr; } + int checkpointed{0}; +private: + std::shared_ptr store_; + std::string bot_; + std::vector msgs_; +}; + +class FakeHost : public TuiChatHost { +public: + explicit FakeHost(std::shared_ptr store) : store_(std::move(store)) {} + std::shared_ptr spawn_agent(TuiChatAgent&, const std::string& name) override { return std::make_shared(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 start_scheduler() override { return [] {}; } +private: + std::shared_ptr 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(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(1741944413); }; // 2025-03-14 09:26:53 UTC + auto store = std::make_shared(); + { + 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(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{"model-a", "model-b"}; }; + s.complete_files = [](const std::string&, const std::string&) { return std::vector{"src/a.txt"}; }; + s.dir_key = [] { return std::string("/tmp"); }; + TuiComplete::reset_command_help(); + } + } + return 0; +} diff --git a/taicpp/tests/tui_rows.cpp b/taicpp/tests/tui_rows.cpp new file mode 100644 index 0000000..7cc224f --- /dev/null +++ b/taicpp/tests/tui_rows.cpp @@ -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 +// text) so the output can be diffed byte for byte against the python functions. +#include "TuiChatRows.h" +#include +#include +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(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 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(width)) << "\n"; + } + std::cout << "==\n"; + } + return 0; +} diff --git a/taicpp/tests/tui_script.cpp b/taicpp/tests/tui_script.cpp new file mode 100644 index 0000000..6822908 --- /dev/null +++ b/taicpp/tests/tui_script.cpp @@ -0,0 +1,87 @@ +// tui_script: scripted headless session over the demo scene, to diff against python. +// Script lines (stdin): +// feed 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 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 +#include +#include +#include +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(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{"model-a", "model-b"}; }; + s.complete_files = [](const std::string&, const std::string&) { return std::vector{"src/a.txt"}; }; + s.dir_key = [] { return std::string("/tmp"); }; + TuiComplete::reset_command_help(); + } + } + return 0; +}